diff --git a/app/Http/Controllers/Api/V1/Admin/Agent/AgentAdminUserRoleSyncController.php b/app/Http/Controllers/Api/V1/Admin/Agent/AgentAdminUserRoleSyncController.php index 80ac71d..3d1293d 100644 --- a/app/Http/Controllers/Api/V1/Admin/Agent/AgentAdminUserRoleSyncController.php +++ b/app/Http/Controllers/Api/V1/Admin/Agent/AgentAdminUserRoleSyncController.php @@ -32,7 +32,7 @@ final class AgentAdminUserRoleSyncController extends Controller return $denied; } - if (! $admin->isSuperAdmin() && ! $admin->hasAdminPermission('prd.agent.user.manage')) { + if (! $admin->isSuperAdmin() && ! $admin->hasPermissionCode('agent.node.manage')) { return AdminAgentNodeAccess::denyUnlessCanManageParent($admin, $agent); } diff --git a/app/Http/Controllers/Api/V1/Admin/Agent/AgentNodeAdminUserStoreController.php b/app/Http/Controllers/Api/V1/Admin/Agent/AgentNodeAdminUserStoreController.php index e2a344a..7a05323 100644 --- a/app/Http/Controllers/Api/V1/Admin/Agent/AgentNodeAdminUserStoreController.php +++ b/app/Http/Controllers/Api/V1/Admin/Agent/AgentNodeAdminUserStoreController.php @@ -27,7 +27,7 @@ final class AgentNodeAdminUserStoreController extends Controller return $denied; } - if (! $admin->isSuperAdmin() && ! $admin->hasAdminPermission('prd.agent.user.manage')) { + if (! $admin->isSuperAdmin() && ! $admin->hasPermissionCode('agent.node.manage')) { return AdminAgentNodeAccess::denyUnlessCanManageParent($admin, $agent_node); } diff --git a/app/Http/Controllers/Api/V1/Admin/Agent/AgentNodeRoleStoreController.php b/app/Http/Controllers/Api/V1/Admin/Agent/AgentNodeRoleStoreController.php index c2068c5..b3faa70 100644 --- a/app/Http/Controllers/Api/V1/Admin/Agent/AgentNodeRoleStoreController.php +++ b/app/Http/Controllers/Api/V1/Admin/Agent/AgentNodeRoleStoreController.php @@ -27,7 +27,7 @@ final class AgentNodeRoleStoreController extends Controller return $denied; } - if (! $admin->isSuperAdmin() && ! $admin->hasAdminPermission('prd.agent.role.manage')) { + if (! $admin->isSuperAdmin() && ! $admin->hasPermissionCode('agent.node.manage')) { return AdminAgentNodeAccess::denyUnlessCanManageParent($admin, $agent_node); } diff --git a/app/Http/Controllers/Api/V1/Admin/Agent/AgentRolePermissionSyncController.php b/app/Http/Controllers/Api/V1/Admin/Agent/AgentRolePermissionSyncController.php index 04a7a0a..878c4f1 100644 --- a/app/Http/Controllers/Api/V1/Admin/Agent/AgentRolePermissionSyncController.php +++ b/app/Http/Controllers/Api/V1/Admin/Agent/AgentRolePermissionSyncController.php @@ -8,6 +8,7 @@ use App\Services\AuditLogger; use Illuminate\Http\JsonResponse; use App\Http\Controllers\Controller; use App\Services\Agent\AgentRoleService; +use App\Support\AdminPermissionInheritance; use App\Support\AgentRoleAuthorization; use App\Support\AdminRoleApiPresenter; use App\Http\Requests\Admin\AgentRolePermissionSyncRequest; @@ -35,7 +36,9 @@ final class AgentRolePermissionSyncController extends Controller return AgentRoleAuthorization::denyUnlessRoleManageable($admin, $admin_role); } - $slugs = array_values(array_unique($request->validated('permission_slugs'))); + $slugs = AdminPermissionInheritance::expand( + array_values(array_unique($request->validated('permission_slugs'))), + ); $before = AdminRoleApiPresenter::item($admin_role); $role = $service->syncPermissions($admin, $admin_role, $slugs); diff --git a/app/Http/Controllers/Api/V1/Admin/Audit/AuditLogIndexController.php b/app/Http/Controllers/Api/V1/Admin/Audit/AuditLogIndexController.php index 2a8f322..ee77e08 100644 --- a/app/Http/Controllers/Api/V1/Admin/Audit/AuditLogIndexController.php +++ b/app/Http/Controllers/Api/V1/Admin/Audit/AuditLogIndexController.php @@ -5,8 +5,10 @@ namespace App\Http\Controllers\Api\V1\Admin\Audit; use App\Models\AuditLog; use Illuminate\Http\Request; use App\Support\AdminApiList; +use App\Support\ApiResponse; use Illuminate\Http\JsonResponse; use App\Http\Controllers\Controller; +use App\Support\AuditLogApiPresenter; /** * GET /api/v1/admin/audit-logs — 运营/客服查询审计留痕。 @@ -46,25 +48,6 @@ final class AuditLogIndexController extends Controller $paginator = $q->paginate($p['perPage'], ['*'], 'page', $p['page']); - return AdminApiList::json($paginator, fn (AuditLog $r) => $this->row($r)); - } - - /** @return array */ - private function row(AuditLog $r): array - { - return [ - 'id' => (int) $r->id, - 'operator_type' => $r->operator_type, - 'operator_id' => (int) $r->operator_id, - 'module_code' => $r->module_code, - 'action_code' => $r->action_code, - 'target_type' => $r->target_type, - 'target_id' => $r->target_id, - 'before_json' => $r->before_json, - 'after_json' => $r->after_json, - 'ip' => $r->ip, - 'user_agent' => $r->user_agent, - 'created_at' => $r->created_at?->toIso8601String(), - ]; + return ApiResponse::success(AuditLogApiPresenter::listPayload($paginator)); } } diff --git a/app/Http/Controllers/Api/V1/Admin/Dashboard/AdminDashboardController.php b/app/Http/Controllers/Api/V1/Admin/Dashboard/AdminDashboardController.php index 8089e39..8ceb054 100644 --- a/app/Http/Controllers/Api/V1/Admin/Dashboard/AdminDashboardController.php +++ b/app/Http/Controllers/Api/V1/Admin/Dashboard/AdminDashboardController.php @@ -8,6 +8,7 @@ use App\Support\ApiResponse; use Illuminate\Http\Request; use Illuminate\Http\JsonResponse; use App\Http\Controllers\Controller; +use App\Support\AdminScopeContextResolver; use App\Services\Admin\AdminDashboardSnapshotBuilder; /** @@ -31,6 +32,8 @@ final class AdminDashboardController extends Controller ); } - return ApiResponse::success($this->dashboard->build($admin)); + $scope = AdminScopeContextResolver::fromRequest($request, $admin); + + return ApiResponse::success($this->dashboard->build($scope)); } } diff --git a/app/Http/Controllers/Api/V1/Admin/Draw/AdminDrawFinanceSummaryController.php b/app/Http/Controllers/Api/V1/Admin/Draw/AdminDrawFinanceSummaryController.php index f51367e..b239f2d 100644 --- a/app/Http/Controllers/Api/V1/Admin/Draw/AdminDrawFinanceSummaryController.php +++ b/app/Http/Controllers/Api/V1/Admin/Draw/AdminDrawFinanceSummaryController.php @@ -5,10 +5,12 @@ namespace App\Http\Controllers\Api\V1\Admin\Draw; use App\Models\Draw; use App\Models\TicketItem; use App\Models\TicketOrder; +use App\Models\AdminUser; use App\Support\ApiResponse; use App\Models\SettlementBatch; use Illuminate\Http\JsonResponse; use App\Http\Controllers\Controller; +use App\Support\AdminScopePolicy; /** * GET /api/v1/admin/draws/{draw}/finance-summary — 单期投注/派彩汇总(客服/财务视角,PRD §15.4)。 @@ -17,20 +19,27 @@ use App\Http\Controllers\Controller; */ final class AdminDrawFinanceSummaryController extends Controller { - public function __invoke(Draw $draw): JsonResponse + public function __invoke(\Illuminate\Http\Request $request, Draw $draw): JsonResponse { + $admin = $request->lotteryAdmin(); + abort_if(! $admin instanceof AdminUser, 401); + $scope = AdminScopePolicy::resolveContext($request, $admin); + $drawId = (int) $draw->id; - $totalBetMinor = (int) TicketOrder::query()->where('draw_id', $drawId)->sum('total_actual_deduct'); - $orderCount = (int) TicketOrder::query()->where('draw_id', $drawId)->count(); - $itemCount = (int) TicketItem::query()->where('draw_id', $drawId)->count(); + $orders = TicketOrder::query()->where('draw_id', $drawId); + $items = TicketItem::query()->where('draw_id', $drawId); + AdminScopePolicy::applyViaPlayer($orders, $scope); + AdminScopePolicy::applyViaPlayer($items, $scope); - $currencyCode = (string) (TicketOrder::query() - ->where('draw_id', $drawId) - ->value('currency_code') ?? ''); + $totalBetMinor = (int) (clone $orders)->sum('total_actual_deduct'); + $orderCount = (int) (clone $orders)->count(); + $itemCount = (int) (clone $items)->count(); - $totalWinMinor = (int) TicketItem::query()->where('draw_id', $drawId)->sum('win_amount'); - $totalJackpotWinMinor = (int) TicketItem::query()->where('draw_id', $drawId)->sum('jackpot_win_amount'); + $currencyCode = (string) ((clone $orders)->value('currency_code') ?? ''); + + $totalWinMinor = (int) (clone $items)->sum('win_amount'); + $totalJackpotWinMinor = (int) (clone $items)->sum('jackpot_win_amount'); $totalPayoutMinor = $totalWinMinor + $totalJackpotWinMinor; $approxHouseGrossMinor = $totalBetMinor - $totalPayoutMinor; diff --git a/app/Http/Controllers/Api/V1/Admin/Draw/AdminDrawIndexController.php b/app/Http/Controllers/Api/V1/Admin/Draw/AdminDrawIndexController.php index f596f8a..8c6bae3 100644 --- a/app/Http/Controllers/Api/V1/Admin/Draw/AdminDrawIndexController.php +++ b/app/Http/Controllers/Api/V1/Admin/Draw/AdminDrawIndexController.php @@ -8,8 +8,9 @@ use App\Models\Draw; use App\Models\TicketItem; use App\Models\TicketOrder; use Illuminate\Http\Request; -use App\Support\AdminSiteScope; use App\Support\AdminApiList; +use App\Support\AdminScopeContext; +use App\Support\AdminScopePolicy; use App\Services\LotterySettings; use Illuminate\Http\JsonResponse; use App\Http\Controllers\Controller; @@ -28,7 +29,7 @@ final class AdminDrawIndexController extends Controller $p = AdminApiList::readPaging($request); $drawNo = trim((string) $request->query('draw_no', '')); $status = trim((string) $request->query('status', '')); - $agentNodeId = $request->integer('agent_node_id') ?: null; + $scope = AdminScopePolicy::resolveContext($request, $admin); $q = Draw::query()->orderByDesc('draw_time')->orderByDesc('id'); @@ -45,8 +46,7 @@ final class AdminDrawIndexController extends Controller $statsByDrawId = $this->aggregateListStats( $paginator->getCollection()->pluck('id')->map(fn ($id) => (int) $id)->all(), - $admin, - $agentNodeId, + $scope, ); return AdminApiList::jsonWith($paginator, fn (Draw $row) => $this->row($row, $statsByDrawId), [ @@ -63,21 +63,21 @@ final class AdminDrawIndexController extends Controller * @param list $drawIds * @return array */ - private function aggregateListStats(array $drawIds, AdminUser $admin, ?int $agentNodeId): array + private function aggregateListStats(array $drawIds, AdminScopeContext $scope): array { if ($drawIds === []) { return []; } $betQuery = TicketOrder::query()->whereIn('draw_id', $drawIds); - $this->scopeOrdersToVisiblePlayers($betQuery, $admin, $agentNodeId); + $this->scopeOrdersToVisiblePlayers($betQuery, $scope); $betByDraw = $betQuery ->groupBy('draw_id') ->selectRaw('draw_id, COALESCE(SUM(total_actual_deduct), 0) AS total_bet') ->pluck('total_bet', 'draw_id'); $payoutQuery = TicketItem::query()->whereIn('draw_id', $drawIds); - $this->scopeTicketItemsToVisiblePlayers($payoutQuery, $admin, $agentNodeId); + $this->scopeTicketItemsToVisiblePlayers($payoutQuery, $scope); $payoutRows = $payoutQuery ->groupBy('draw_id') ->selectRaw( @@ -104,38 +104,28 @@ final class AdminDrawIndexController extends Controller /** * @param \Illuminate\Database\Eloquent\Builder $query */ - private function scopeOrdersToVisiblePlayers($query, AdminUser $admin, ?int $agentNodeId): void + private function scopeOrdersToVisiblePlayers($query, AdminScopeContext $scope): void { - if ($admin->isSuperAdmin() && ($agentNodeId === null || $agentNodeId <= 0)) { + if ($scope->isSuperAdmin() && $scope->effectiveRequestedAgentNodeId() === null) { return; } - $query->whereHas('player', static function ($playerQuery) use ($admin, $agentNodeId): void { - AdminSiteScope::applyPlayerFilters( - $playerQuery, - $admin, - null, - $agentNodeId !== null && $agentNodeId > 0 ? $agentNodeId : null, - ); + $query->whereHas('player', function ($playerQuery) use ($scope): void { + AdminScopePolicy::applyPlayerFilters($playerQuery, $scope); }); } /** * @param \Illuminate\Database\Eloquent\Builder $query */ - private function scopeTicketItemsToVisiblePlayers($query, AdminUser $admin, ?int $agentNodeId): void + private function scopeTicketItemsToVisiblePlayers($query, AdminScopeContext $scope): void { - if ($admin->isSuperAdmin() && ($agentNodeId === null || $agentNodeId <= 0)) { + if ($scope->isSuperAdmin() && $scope->effectiveRequestedAgentNodeId() === null) { return; } - $query->whereHas('player', static function ($playerQuery) use ($admin, $agentNodeId): void { - AdminSiteScope::applyPlayerFilters( - $playerQuery, - $admin, - null, - $agentNodeId !== null && $agentNodeId > 0 ? $agentNodeId : null, - ); + $query->whereHas('player', function ($playerQuery) use ($scope): void { + AdminScopePolicy::applyPlayerFilters($playerQuery, $scope); }); } diff --git a/app/Http/Controllers/Api/V1/Admin/Jackpot/AdminJackpotContributionIndexController.php b/app/Http/Controllers/Api/V1/Admin/Jackpot/AdminJackpotContributionIndexController.php index 50c2d1a..e801468 100644 --- a/app/Http/Controllers/Api/V1/Admin/Jackpot/AdminJackpotContributionIndexController.php +++ b/app/Http/Controllers/Api/V1/Admin/Jackpot/AdminJackpotContributionIndexController.php @@ -7,7 +7,7 @@ use App\Support\AdminApiList; use Illuminate\Http\JsonResponse; use App\Models\JackpotContribution; use App\Http\Controllers\Controller; -use App\Support\AdminDataScope; +use App\Support\AdminScopePolicy; /** * GET /api/v1/admin/jackpot/contributions — Jackpot 蓄水流水。 @@ -18,6 +18,7 @@ final class AdminJackpotContributionIndexController extends Controller { $admin = $request->lotteryAdmin(); abort_if($admin === null, 401); + $scope = AdminScopePolicy::resolveContext($request, $admin); $p = AdminApiList::readPaging($request); $drawNo = trim((string) $request->query('draw_no', '')); @@ -30,7 +31,7 @@ final class AdminJackpotContributionIndexController extends Controller $q->whereHas('draw', fn ($d) => $d->where('draw_no', 'like', '%'.$drawNo.'%')); } - AdminDataScope::applyEloquentViaPlayer($q, $admin); + AdminScopePolicy::applyViaPlayer($q, $scope); $paginator = $q->paginate($p['perPage'], ['*'], 'page', $p['page']); diff --git a/app/Http/Controllers/Api/V1/Admin/Player/AdminPlayerIndexController.php b/app/Http/Controllers/Api/V1/Admin/Player/AdminPlayerIndexController.php index 0875e8b..9e78d74 100644 --- a/app/Http/Controllers/Api/V1/Admin/Player/AdminPlayerIndexController.php +++ b/app/Http/Controllers/Api/V1/Admin/Player/AdminPlayerIndexController.php @@ -8,7 +8,7 @@ use App\Support\ApiResponse; use Illuminate\Http\JsonResponse; use App\Http\Controllers\Controller; use App\Support\AdminApiList; -use App\Support\AdminSiteScope; +use App\Support\AdminScopePolicy; use App\Support\PlayerApiPresenter; /** GET /api/v1/admin/players */ @@ -22,8 +22,7 @@ final class AdminPlayerIndexController extends Controller $p = AdminApiList::readPaging($request); $keyword = trim((string) $request->query('keyword', '')); $status = $request->query('status'); - $siteCode = $request->query('site_code'); - $agentNodeId = $request->integer('agent_node_id') ?: null; + $scope = AdminScopePolicy::resolveContext($request, $admin); $q = Player::query() ->with([ @@ -32,12 +31,7 @@ final class AdminPlayerIndexController extends Controller ]) ->orderByDesc('id'); - AdminSiteScope::applyPlayerFilters( - $q, - $admin, - is_string($siteCode) ? $siteCode : null, - $agentNodeId, - ); + AdminScopePolicy::applyPlayerFilters($q, $scope); if ($keyword !== '') { $term = '%'.addcslashes($keyword, '%_\\').'%'; diff --git a/app/Http/Controllers/Api/V1/Admin/Reports/AdminReportDailyProfitController.php b/app/Http/Controllers/Api/V1/Admin/Reports/AdminReportDailyProfitController.php index 27109b8..933f8c1 100644 --- a/app/Http/Controllers/Api/V1/Admin/Reports/AdminReportDailyProfitController.php +++ b/app/Http/Controllers/Api/V1/Admin/Reports/AdminReportDailyProfitController.php @@ -6,6 +6,7 @@ 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/daily-profit */ @@ -19,13 +20,14 @@ final class AdminReportDailyProfitController extends Controller $validated = $request->validated(); $p = AdminApiList::readPaging($request); $range = $service->resolveDateRange($validated); + $scope = AdminScopePolicy::resolveContext($request, $admin, 'site_code', 'agent_node_id'); $paginator = $service->dailyProfitPaginated( $range['date_from'], $range['date_to'], $p['page'], $p['perPage'], - $admin, + $scope, ); return AdminApiList::json($paginator, static fn (array $row): array => $row); diff --git a/app/Http/Controllers/Api/V1/Admin/Reports/AdminReportPlayDimensionController.php b/app/Http/Controllers/Api/V1/Admin/Reports/AdminReportPlayDimensionController.php index 9f61a22..341d0bb 100644 --- a/app/Http/Controllers/Api/V1/Admin/Reports/AdminReportPlayDimensionController.php +++ b/app/Http/Controllers/Api/V1/Admin/Reports/AdminReportPlayDimensionController.php @@ -6,6 +6,7 @@ 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/play-dimension */ @@ -20,6 +21,7 @@ final class AdminReportPlayDimensionController extends Controller $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->playDimensionPaginated( $playCode !== '' ? $playCode : null, @@ -27,7 +29,7 @@ final class AdminReportPlayDimensionController extends Controller $range['date_to'], $p['page'], $p['perPage'], - $admin, + $scope, ); return AdminApiList::json($paginator, static function (object $row): array { diff --git a/app/Http/Controllers/Api/V1/Admin/Reports/AdminReportPlayerWinLossController.php b/app/Http/Controllers/Api/V1/Admin/Reports/AdminReportPlayerWinLossController.php index 384430c..48a8c80 100644 --- a/app/Http/Controllers/Api/V1/Admin/Reports/AdminReportPlayerWinLossController.php +++ b/app/Http/Controllers/Api/V1/Admin/Reports/AdminReportPlayerWinLossController.php @@ -6,6 +6,7 @@ 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/player-win-loss */ @@ -20,7 +21,7 @@ final class AdminReportPlayerWinLossController extends Controller $p = AdminApiList::readPaging($request); $range = $service->resolveDateRange($validated); $playerId = isset($validated['player_id']) ? (int) $validated['player_id'] : null; - $agentNodeId = isset($validated['agent_node_id']) ? (int) $validated['agent_node_id'] : null; + $scope = AdminScopePolicy::resolveContext($request, $admin, 'site_code', 'agent_node_id'); $paginator = $service->playerWinLossPaginated( $playerId, @@ -28,8 +29,7 @@ final class AdminReportPlayerWinLossController extends Controller $range['date_to'], $p['page'], $p['perPage'], - $admin, - $agentNodeId > 0 ? $agentNodeId : null, + $scope, ); return AdminApiList::json($paginator, static function (object $row): array { diff --git a/app/Http/Controllers/Api/V1/Admin/Reports/AdminReportRebateCommissionController.php b/app/Http/Controllers/Api/V1/Admin/Reports/AdminReportRebateCommissionController.php index 0ed857c..dc08a02 100644 --- a/app/Http/Controllers/Api/V1/Admin/Reports/AdminReportRebateCommissionController.php +++ b/app/Http/Controllers/Api/V1/Admin/Reports/AdminReportRebateCommissionController.php @@ -6,6 +6,7 @@ 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 */ @@ -20,6 +21,7 @@ final class AdminReportRebateCommissionController extends Controller $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, @@ -27,7 +29,7 @@ final class AdminReportRebateCommissionController extends Controller $range['date_to'], $p['page'], $p['perPage'], - $admin, + $scope, ); return AdminApiList::json($paginator, static function (object $row): array { diff --git a/app/Http/Controllers/Api/V1/Admin/Settlement/AdminSettlementBatchAdjustmentController.php b/app/Http/Controllers/Api/V1/Admin/Settlement/AdminSettlementBatchAdjustmentController.php new file mode 100644 index 0000000..0344e99 --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/Settlement/AdminSettlementBatchAdjustmentController.php @@ -0,0 +1,56 @@ +user(); + if (! $admin instanceof AdminUser) { + return ApiResponse::error( + trans('admin.unauthenticated', [], $request->lotteryLocale()), + \App\Lottery\ErrorCode::AdminUnauthenticated->value, + null, + 401, + ); + } + + try { + $result = $this->service->apply( + $batch, + $admin, + (int) $request->validated('player_id'), + (int) $request->validated('amount_delta'), + (string) $request->validated('reason'), + $request, + ); + } catch (TicketOperationException $e) { + return ApiResponse::error( + LotteryMessage::wallet($request, $e->lotteryCode), + $e->lotteryCode, + $e->payload, + $e->httpStatus, + ); + } catch (\RuntimeException $e) { + return ApiMessage::runtimeErrorResponse($request, $e, 0, 422); + } + + return ApiResponse::success($result); + } +} diff --git a/app/Http/Controllers/Api/V1/Admin/Settlement/AdminSettlementBatchDetailsController.php b/app/Http/Controllers/Api/V1/Admin/Settlement/AdminSettlementBatchDetailsController.php index 57f8f22..1ad0f1b 100644 --- a/app/Http/Controllers/Api/V1/Admin/Settlement/AdminSettlementBatchDetailsController.php +++ b/app/Http/Controllers/Api/V1/Admin/Settlement/AdminSettlementBatchDetailsController.php @@ -4,7 +4,7 @@ namespace App\Http\Controllers\Api\V1\Admin\Settlement; use Illuminate\Http\Request; use App\Support\AdminApiList; -use App\Support\AdminSiteScope; +use App\Support\AdminScopePolicy; use App\Support\AgentNodeApiPresenter; use App\Models\SettlementBatch; use Illuminate\Http\JsonResponse; @@ -22,7 +22,7 @@ final class AdminSettlementBatchDetailsController extends Controller abort_if($admin === null, 401); $p = AdminApiList::readPaging($request); - $agentNodeId = $request->integer('agent_node_id') ?: null; + $scope = AdminScopePolicy::resolveContext($request, $admin); $detailQuery = TicketSettlementDetail::query() ->where('settlement_batch_id', $batch->id) @@ -33,14 +33,9 @@ final class AdminSettlementBatchDetailsController extends Controller 'ticketItem.order:id,currency_code', ]); - if (! $admin->isSuperAdmin() || ($agentNodeId !== null && $agentNodeId > 0)) { - $detailQuery->whereHas('ticketItem.player', static function ($playerQuery) use ($admin, $agentNodeId): void { - AdminSiteScope::applyPlayerFilters( - $playerQuery, - $admin, - null, - $agentNodeId !== null && $agentNodeId > 0 ? $agentNodeId : null, - ); + if (! $scope->isSuperAdmin() || $scope->effectiveRequestedAgentNodeId() !== null) { + $detailQuery->whereHas('ticketItem.player', function ($playerQuery) use ($scope): void { + AdminScopePolicy::applyPlayerFilters($playerQuery, $scope); }); } diff --git a/app/Http/Controllers/Api/V1/Admin/Settlement/AdminSettlementBatchIndexController.php b/app/Http/Controllers/Api/V1/Admin/Settlement/AdminSettlementBatchIndexController.php index e144b60..e82b09c 100644 --- a/app/Http/Controllers/Api/V1/Admin/Settlement/AdminSettlementBatchIndexController.php +++ b/app/Http/Controllers/Api/V1/Admin/Settlement/AdminSettlementBatchIndexController.php @@ -4,7 +4,7 @@ namespace App\Http\Controllers\Api\V1\Admin\Settlement; use Illuminate\Http\Request; use App\Support\AdminApiList; -use App\Support\AdminSiteScope; +use App\Support\AdminScopePolicy; use App\Models\SettlementBatch; use Illuminate\Http\JsonResponse; use App\Http\Controllers\Controller; @@ -23,20 +23,15 @@ final class AdminSettlementBatchIndexController extends Controller $p = AdminApiList::readPaging($request); $drawNo = trim((string) $request->query('draw_no', '')); $status = trim((string) $request->query('status', '')); - $agentNodeId = $request->integer('agent_node_id') ?: null; + $scope = AdminScopePolicy::resolveContext($request, $admin); $q = SettlementBatch::query() ->with(['draw:id,draw_no']) ->orderByDesc('id'); - if (! $admin->isSuperAdmin() || ($agentNodeId !== null && $agentNodeId > 0)) { - $q->whereHas('details.ticketItem.player', static function ($playerQuery) use ($admin, $agentNodeId): void { - AdminSiteScope::applyPlayerFilters( - $playerQuery, - $admin, - null, - $agentNodeId !== null && $agentNodeId > 0 ? $agentNodeId : null, - ); + if (! $scope->isSuperAdmin() || $scope->effectiveRequestedAgentNodeId() !== null) { + $q->whereHas('details.ticketItem.player', function ($playerQuery) use ($scope): void { + AdminScopePolicy::applyPlayerFilters($playerQuery, $scope); }); } diff --git a/app/Http/Controllers/Api/V1/Admin/Ticket/AdminTicketItemIndexController.php b/app/Http/Controllers/Api/V1/Admin/Ticket/AdminTicketItemIndexController.php index 7e4b11d..da20a96 100644 --- a/app/Http/Controllers/Api/V1/Admin/Ticket/AdminTicketItemIndexController.php +++ b/app/Http/Controllers/Api/V1/Admin/Ticket/AdminTicketItemIndexController.php @@ -8,7 +8,7 @@ use App\Models\TicketItem; use App\Support\ApiResponse; use App\Support\CurrencyFormatter; use App\Support\PaginationTrait; -use App\Support\AdminSiteScope; +use App\Support\AdminScopePolicy; use App\Support\AgentNodeApiPresenter; use App\Support\TicketItemListFilters; use Illuminate\Http\JsonResponse; @@ -37,6 +37,7 @@ final class AdminTicketItemIndexController extends Controller abort_if($admin === null, 401); $validated = $request->validated(); + $scope = AdminScopePolicy::resolveContext($request, $admin); $perPage = $this->perPage($request, 'per_page', 10, 100); $page = $this->page($request); @@ -88,15 +89,7 @@ final class AdminTicketItemIndexController extends Controller is_string($validated['end_date'] ?? null) ? $validated['end_date'] : null, ); - $agentNodeId = isset($validated['agent_node_id']) ? (int) $validated['agent_node_id'] : null; - - AdminSiteScope::applyViaPlayerRelationWithSiteCode( - $query, - $admin, - is_string($validated['site_code'] ?? null) ? $validated['site_code'] : null, - 'player', - $agentNodeId > 0 ? $agentNodeId : null, - ); + AdminScopePolicy::applyViaPlayerRelationWithContext($query, $scope, 'player'); $paginator = $query->paginate(perPage: $perPage, page: $page, columns: ['*']); diff --git a/app/Http/Controllers/Api/V1/Admin/User/AdminRolePermissionSyncController.php b/app/Http/Controllers/Api/V1/Admin/User/AdminRolePermissionSyncController.php index 58d6c93..b799376 100644 --- a/app/Http/Controllers/Api/V1/Admin/User/AdminRolePermissionSyncController.php +++ b/app/Http/Controllers/Api/V1/Admin/User/AdminRolePermissionSyncController.php @@ -8,6 +8,7 @@ use App\Services\AuditLogger; use Illuminate\Http\JsonResponse; use Illuminate\Support\Facades\DB; use App\Http\Controllers\Controller; +use App\Support\AdminPermissionInheritance; use App\Support\AdminRoleApiPresenter; use App\Http\Requests\Admin\AdminRolePermissionSyncRequest; @@ -15,7 +16,9 @@ final class AdminRolePermissionSyncController extends Controller { public function __invoke(AdminRolePermissionSyncRequest $request, AdminRole $admin_role): JsonResponse { - $slugs = array_values(array_unique($request->validated('permission_slugs', []))); + $slugs = AdminPermissionInheritance::expand( + array_values(array_unique($request->validated('permission_slugs', []))), + ); $before = AdminRoleApiPresenter::item($admin_role); DB::transaction(function () use ($admin_role, $slugs): void { diff --git a/app/Http/Controllers/Api/V1/Admin/User/AdminRoleStoreController.php b/app/Http/Controllers/Api/V1/Admin/User/AdminRoleStoreController.php index 43d4538..5a1aa3f 100644 --- a/app/Http/Controllers/Api/V1/Admin/User/AdminRoleStoreController.php +++ b/app/Http/Controllers/Api/V1/Admin/User/AdminRoleStoreController.php @@ -8,6 +8,7 @@ use App\Services\AuditLogger; use Illuminate\Http\JsonResponse; use Illuminate\Support\Facades\DB; use App\Http\Controllers\Controller; +use App\Support\AdminPermissionInheritance; use App\Support\AdminRoleApiPresenter; use App\Http\Requests\Admin\AdminRoleStoreRequest; @@ -15,7 +16,9 @@ final class AdminRoleStoreController extends Controller { public function __invoke(AdminRoleStoreRequest $request): JsonResponse { - $permissionSlugs = array_values(array_unique($request->validated('permission_slugs', []))); + $permissionSlugs = AdminPermissionInheritance::expand( + array_values(array_unique($request->validated('permission_slugs', []))), + ); $role = DB::transaction(function () use ($request, $permissionSlugs): AdminRole { $role = AdminRole::query()->create([ diff --git a/app/Http/Controllers/Api/V1/Admin/Wallet/TransferOrderListController.php b/app/Http/Controllers/Api/V1/Admin/Wallet/TransferOrderListController.php index e69edfc..08705da 100644 --- a/app/Http/Controllers/Api/V1/Admin/Wallet/TransferOrderListController.php +++ b/app/Http/Controllers/Api/V1/Admin/Wallet/TransferOrderListController.php @@ -7,9 +7,10 @@ use App\Support\ApiResponse; use App\Models\TransferOrder; use App\Support\PaginationTrait; use Illuminate\Http\JsonResponse; -use App\Support\AdminSiteScope; +use App\Support\AdminScopePolicy; use App\Support\AgentNodeApiPresenter; use App\Support\CurrencyFormatter; +use App\Services\Wallet\LotteryTransferService; use App\Http\Controllers\Controller; use App\Http\Requests\Admin\TransferOrderListRequest; @@ -34,12 +35,17 @@ final class TransferOrderListController extends Controller private const ALLOWED_STATUS = ['processing', 'success', 'failed', 'pending_reconcile', 'reversed', 'manually_processed']; + public function __construct( + private readonly LotteryTransferService $transferService, + ) {} + public function __invoke(TransferOrderListRequest $request): JsonResponse { $admin = $request->lotteryAdmin(); abort_if($admin === null, 401); $validated = $request->validated(); + $scope = AdminScopePolicy::resolveContext($request, $admin); $perPage = $this->perPage($request, 'per_page', 10, 100); $page = $this->page($request); @@ -89,15 +95,7 @@ final class TransferOrderListController extends Controller } } - $agentNodeId = isset($validated['agent_node_id']) ? (int) $validated['agent_node_id'] : null; - - AdminSiteScope::applyViaPlayerRelationWithSiteCode( - $query, - $admin, - is_string($validated['site_code'] ?? null) ? $validated['site_code'] : null, - 'player', - $agentNodeId > 0 ? $agentNodeId : null, - ); + AdminScopePolicy::applyViaPlayerRelationWithContext($query, $scope, 'player'); $paginator = $query->paginate($perPage, ['*'], 'page', $page); $items = $paginator->getCollection()->map( @@ -120,8 +118,9 @@ final class TransferOrderListController extends Controller $p = $o->player; $amount = (int) $o->amount; $canWriteWallet = $admin !== null && ( - $admin->hasAdminPermission('prd.wallet_adjust.manage') - || $admin->hasAdminPermission('prd.wallet_reconcile.manage') + $admin->hasPermissionCode('service.wallet.adjust') + || $admin->hasPermissionCode('service.reconcile.manage') + || $admin->hasPermissionCode('service.wallet.manage') ); return [ @@ -139,15 +138,16 @@ final class TransferOrderListController extends Controller 'amount_formatted' => CurrencyFormatter::fromMinor($amount), 'idempotent_key' => $o->idempotent_key, 'status' => $o->status, - 'can_reverse' => $canWriteWallet && $o->status === 'pending_reconcile', + 'can_reverse' => $canWriteWallet + && $o->status === 'pending_reconcile' + && ($o->direction === 'out' || $this->transferService->isEligibleForTransferInReverse($o)), 'can_complete_credit' => $canWriteWallet && $o->direction === 'in' && $o->status === 'pending_reconcile' && $o->fail_reason === 'lottery_credit_failed' && trim((string) $o->external_ref_no) !== '', 'can_manually_process' => $canWriteWallet - && in_array($o->status, ['processing', 'failed', 'pending_reconcile'], true) - && ! ($o->direction === 'out' && $o->status === 'pending_reconcile'), + && $this->transferService->isEligibleForManualProcess($o), 'external_ref_no' => $o->external_ref_no, 'external_request_payload' => $o->external_request_payload, 'external_response_payload' => $o->external_response_payload, diff --git a/app/Http/Controllers/Api/V1/Admin/Wallet/TransferOrderReconcileController.php b/app/Http/Controllers/Api/V1/Admin/Wallet/TransferOrderReconcileController.php index f1cfd8b..38083fe 100644 --- a/app/Http/Controllers/Api/V1/Admin/Wallet/TransferOrderReconcileController.php +++ b/app/Http/Controllers/Api/V1/Admin/Wallet/TransferOrderReconcileController.php @@ -6,6 +6,7 @@ use App\Lottery\ErrorCode; use App\Models\TransferOrder; use App\Support\ApiMessage; use App\Support\ApiResponse; +use App\Support\AdminScopePolicy; use App\Support\LotteryMessage; use App\Exceptions\WalletOperationException; use App\Services\Wallet\LotteryTransferService; @@ -16,8 +17,8 @@ use App\Http\Requests\Admin\Wallet\TransferOrderCompleteCreditRequest; use Illuminate\Http\JsonResponse; /** - * 后台:转账订单对账操作(冲正 / 人工处理)。 - * PRD §12:待对账 -> 已冲正 / 已人工处理。 + * 后台:转账订单对账操作(冲正 / 补入账 / 标记结案)。 + * PRD §12:待对账 -> 已冲正 / 已结案(manually_processed,仅改状态)。 */ final class TransferOrderReconcileController extends Controller { @@ -27,10 +28,16 @@ final class TransferOrderReconcileController extends Controller public function reverse(TransferOrderReverseRequest $request, string $transferNo): JsonResponse { + $admin = $request->lotteryAdmin(); + abort_if($admin === null, 401); + $order = TransferOrder::query()->where('transfer_no', $transferNo)->first(); if ($order === null) { return ApiMessage::errorResponse($request, 'wallet.order_not_found', ErrorCode::ClientHttpError->value, null, 404); } + if (! AdminScopePolicy::transferOrderAccessible($admin, $order)) { + return ApiMessage::errorResponse($request, 'admin.site_player_access_denied', ErrorCode::AdminForbidden->value, null, 403); + } try { $this->transferService->reconcileTransferOrder( @@ -52,10 +59,16 @@ final class TransferOrderReconcileController extends Controller public function manuallyProcess(TransferOrderManuallyProcessRequest $request, string $transferNo): JsonResponse { + $admin = $request->lotteryAdmin(); + abort_if($admin === null, 401); + $order = TransferOrder::query()->where('transfer_no', $transferNo)->first(); if ($order === null) { return ApiMessage::errorResponse($request, 'wallet.order_not_found', ErrorCode::ClientHttpError->value, null, 404); } + if (! AdminScopePolicy::transferOrderAccessible($admin, $order)) { + return ApiMessage::errorResponse($request, 'admin.site_player_access_denied', ErrorCode::AdminForbidden->value, null, 403); + } try { $this->transferService->reconcileTransferOrder( @@ -77,10 +90,16 @@ final class TransferOrderReconcileController extends Controller public function completeCredit(TransferOrderCompleteCreditRequest $request, string $transferNo): JsonResponse { + $admin = $request->lotteryAdmin(); + abort_if($admin === null, 401); + $order = TransferOrder::query()->where('transfer_no', $transferNo)->first(); if ($order === null) { return ApiMessage::errorResponse($request, 'wallet.order_not_found', ErrorCode::ClientHttpError->value, null, 404); } + if (! AdminScopePolicy::transferOrderAccessible($admin, $order)) { + return ApiMessage::errorResponse($request, 'admin.site_player_access_denied', ErrorCode::AdminForbidden->value, null, 403); + } try { $this->transferService->reconcileTransferOrder( diff --git a/app/Http/Controllers/Api/V1/Admin/Wallet/WalletTransactionListController.php b/app/Http/Controllers/Api/V1/Admin/Wallet/WalletTransactionListController.php index 502df5b..fc0ca13 100644 --- a/app/Http/Controllers/Api/V1/Admin/Wallet/WalletTransactionListController.php +++ b/app/Http/Controllers/Api/V1/Admin/Wallet/WalletTransactionListController.php @@ -6,7 +6,7 @@ use App\Models\WalletTxn; use App\Support\ApiResponse; use App\Support\PaginationTrait; use Illuminate\Http\JsonResponse; -use App\Support\AdminSiteScope; +use App\Support\AdminScopePolicy; use App\Support\AgentNodeApiPresenter; use App\Support\CurrencyFormatter; use App\Http\Controllers\Controller; @@ -39,6 +39,7 @@ final class WalletTransactionListController extends Controller abort_if($admin === null, 401); $validated = $request->validated(); + $scope = AdminScopePolicy::resolveContext($request, $admin); $perPage = $this->perPage($request, 'per_page', 10, 100); $page = $this->page($request); @@ -92,15 +93,7 @@ final class WalletTransactionListController extends Controller } } - $agentNodeId = isset($validated['agent_node_id']) ? (int) $validated['agent_node_id'] : null; - - AdminSiteScope::applyViaPlayerRelationWithSiteCode( - $query, - $admin, - is_string($validated['site_code'] ?? null) ? $validated['site_code'] : null, - 'player', - $agentNodeId > 0 ? $agentNodeId : null, - ); + AdminScopePolicy::applyViaPlayerRelationWithContext($query, $scope, 'player'); $paginator = $query->paginate($perPage, ['*'], 'page', $page); $items = $paginator->getCollection()->map(fn (WalletTxn $t) => $this->formatRow($t)); diff --git a/app/Http/Middleware/RecordAdminApiAudit.php b/app/Http/Middleware/RecordAdminApiAudit.php index 4ba2f38..d817f67 100644 --- a/app/Http/Middleware/RecordAdminApiAudit.php +++ b/app/Http/Middleware/RecordAdminApiAudit.php @@ -60,9 +60,36 @@ final class RecordAdminApiAudit return $response; } + private static function isAuditAlreadyRecorded(Request $request): bool + { + foreach (self::auditRecordedRequests($request) as $candidate) { + if ($candidate->attributes->get(self::ATTRIBUTE_AUDIT_RECORDED) === true) { + return true; + } + } + + return false; + } + + /** @return list */ + private static function auditRecordedRequests(Request $request): array + { + $requests = [$request]; + + try { + $resolved = request(); + if ($resolved !== $request) { + $requests[] = $resolved; + } + } catch (\Throwable) { + } + + return $requests; + } + private function shouldRecord(Request $request, Response $response): bool { - if ($request->attributes->get(self::ATTRIBUTE_AUDIT_RECORDED) === true) { + if (self::isAuditAlreadyRecorded($request)) { return false; } diff --git a/app/Http/Requests/Admin/AdminSettingBatchUpdateRequest.php b/app/Http/Requests/Admin/AdminSettingBatchUpdateRequest.php index 846c119..d3f8067 100644 --- a/app/Http/Requests/Admin/AdminSettingBatchUpdateRequest.php +++ b/app/Http/Requests/Admin/AdminSettingBatchUpdateRequest.php @@ -2,12 +2,31 @@ namespace App\Http\Requests\Admin; +use App\Models\AdminUser; use Illuminate\Foundation\Http\FormRequest; final class AdminSettingBatchUpdateRequest extends FormRequest { public function authorize(): bool { + $admin = $this->lotteryAdmin(); + if (! $admin instanceof AdminUser) { + return false; + } + + /** @var list|null $items */ + $items = $this->input('items'); + if (! is_array($items)) { + return true; + } + + foreach ($items as $item) { + $key = is_array($item) ? (string) ($item['key'] ?? '') : ''; + if (str_starts_with($key, 'settlement.')) { + return $admin->hasAdminPermission('prd.payout.manage'); + } + } + return true; } diff --git a/app/Http/Requests/Admin/AdminSettingUpdateRequest.php b/app/Http/Requests/Admin/AdminSettingUpdateRequest.php index 63a60e4..a570044 100644 --- a/app/Http/Requests/Admin/AdminSettingUpdateRequest.php +++ b/app/Http/Requests/Admin/AdminSettingUpdateRequest.php @@ -2,12 +2,23 @@ namespace App\Http\Requests\Admin; +use App\Models\AdminUser; use Illuminate\Foundation\Http\FormRequest; final class AdminSettingUpdateRequest extends FormRequest { public function authorize(): bool { + $admin = $this->lotteryAdmin(); + if (! $admin instanceof AdminUser) { + return false; + } + + $key = (string) $this->route('key', ''); + if (str_starts_with($key, 'settlement.')) { + return $admin->hasAdminPermission('prd.payout.manage'); + } + return true; } diff --git a/app/Http/Requests/Admin/DashboardAnalyticsRequest.php b/app/Http/Requests/Admin/DashboardAnalyticsRequest.php index d9b9aed..4f1de30 100644 --- a/app/Http/Requests/Admin/DashboardAnalyticsRequest.php +++ b/app/Http/Requests/Admin/DashboardAnalyticsRequest.php @@ -28,6 +28,8 @@ final class DashboardAnalyticsRequest extends FormRequest 'date_to' => ['nullable', 'date_format:Y-m-d', 'required_if:period,custom', 'after_or_equal:date_from'], 'metric' => ['sometimes', 'string', Rule::in(['overview', 'bet', 'payout', 'profit'])], 'play_code' => ['nullable', 'string', 'max:64'], + 'site_code' => ['nullable', 'string', 'max:32'], + 'agent_node_id' => ['nullable', 'integer', 'min:1'], ]; } @@ -40,6 +42,8 @@ final class DashboardAnalyticsRequest extends FormRequest 'date_to' => $this->input('date_to'), 'metric' => (string) $this->input('metric', 'overview'), 'play_code' => $this->input('play_code'), + 'site_code' => $this->input('site_code'), + 'agent_node_id' => $this->integer('agent_node_id') ?: null, ]; } } diff --git a/app/Http/Requests/Admin/SettlementPayoutAdjustmentRequest.php b/app/Http/Requests/Admin/SettlementPayoutAdjustmentRequest.php new file mode 100644 index 0000000..eeb0c6c --- /dev/null +++ b/app/Http/Requests/Admin/SettlementPayoutAdjustmentRequest.php @@ -0,0 +1,22 @@ + ['required', 'integer', 'min:1'], + 'amount_delta' => ['required', 'integer', 'not_in:0'], + 'reason' => ['required', 'string', 'min:3', 'max:500'], + ]; + } +} diff --git a/app/Models/AdminUser.php b/app/Models/AdminUser.php index aa13620..da72929 100644 --- a/app/Models/AdminUser.php +++ b/app/Models/AdminUser.php @@ -5,6 +5,7 @@ namespace App\Models; use Laravel\Sanctum\HasApiTokens; use Illuminate\Support\Facades\DB; use App\Support\AdminPermissionBridge; +use App\Models\AgentNode; use Illuminate\Notifications\Notifiable; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Database\Eloquent\Relations\BelongsToMany; @@ -127,20 +128,32 @@ final class AdminUser extends Authenticatable { $agentId = $this->primaryAgentNodeId(); if ($agentId !== null) { - $fromAgent = DB::table('admin_user_agent_roles as uar') - ->join('admin_role_menu_actions as rma', 'rma.role_id', '=', 'uar.role_id') - ->join('admin_menu_actions as ma', 'ma.id', '=', 'rma.menu_action_id') - ->where('uar.admin_user_id', $this->id) - ->where('uar.agent_node_id', $agentId) - ->where('ma.status', 1) - ->pluck('ma.permission_code') - ->all(); - - if ($fromAgent !== []) { - return $fromAgent; - } + return $this->agentRoleMenuActionPermissionCodes($agentId); } + return $this->siteRoleMenuActionPermissionCodes(); + } + + /** + * @return list + */ + private function agentRoleMenuActionPermissionCodes(int $agentId): array + { + return DB::table('admin_user_agent_roles as uar') + ->join('admin_role_menu_actions as rma', 'rma.role_id', '=', 'uar.role_id') + ->join('admin_menu_actions as ma', 'ma.id', '=', 'rma.menu_action_id') + ->where('uar.admin_user_id', $this->id) + ->where('uar.agent_node_id', $agentId) + ->where('ma.status', 1) + ->pluck('ma.permission_code') + ->all(); + } + + /** + * @return list + */ + private function siteRoleMenuActionPermissionCodes(): array + { return DB::table('admin_user_site_roles as usr') ->join('admin_role_menu_actions as rma', 'rma.role_id', '=', 'usr.role_id') ->join('admin_menu_actions as ma', 'ma.id', '=', 'rma.menu_action_id') @@ -150,6 +163,16 @@ final class AdminUser extends Authenticatable ->all(); } + public function effectiveRoleSource(): string + { + $agentId = $this->primaryAgentNodeId(); + if ($agentId !== null) { + return $this->agentRoleMenuActionPermissionCodes($agentId) !== [] ? 'agent' : 'agent_empty'; + } + + return $this->siteRoleMenuActionPermissionCodes() !== [] ? 'site' : 'none'; + } + public function syncRoleSlugsForDefaultSite(array $slugs): void { $siteId = self::defaultAdminSiteId(); @@ -325,6 +348,22 @@ final class AdminUser extends Authenticatable return count(array_intersect($needed, $effective)) > 0; } + /** + * 仅按 permission_code 判定(不处理 prd.* 映射)。 + */ + public function hasPermissionCode(string $permissionCode): bool + { + if ($permissionCode === '') { + return false; + } + + if ($this->isSuperAdmin()) { + return true; + } + + return in_array($permissionCode, $this->effectiveMenuActionPermissionCodes(), true); + } + /** * @return list 与 Next 侧栏兼容的 `prd.*` slug 列表 */ diff --git a/app/Services/Admin/AdminDashboardAnalyticsBuilder.php b/app/Services/Admin/AdminDashboardAnalyticsBuilder.php index 7d357c1..09700a6 100644 --- a/app/Services/Admin/AdminDashboardAnalyticsBuilder.php +++ b/app/Services/Admin/AdminDashboardAnalyticsBuilder.php @@ -3,6 +3,7 @@ namespace App\Services\Admin; use App\Models\AdminUser; +use App\Support\AdminScopeContextResolver; /** * 仪表盘可筛选分析数据:区间汇总、日趋势、玩法拆解。 @@ -30,6 +31,12 @@ final class AdminDashboardAnalyticsBuilder return null; } + $scope = AdminScopeContextResolver::fromValues( + $admin, + requestedSiteCode: isset($filters['site_code']) ? (string) $filters['site_code'] : null, + requestedAgentNodeId: isset($filters['agent_node_id']) ? (int) $filters['agent_node_id'] : null, + ); + $period = (string) ($filters['period'] ?? 'last_7_days'); $metric = (string) ($filters['metric'] ?? 'overview'); $playCode = isset($filters['play_code']) && $filters['play_code'] !== '' @@ -45,7 +52,7 @@ final class AdminDashboardAnalyticsBuilder $dateFrom = $range['date_from']; $dateTo = $range['date_to']; - $trend = $this->reportQuery->dailyProfitSeriesFilled($dateFrom, $dateTo, scopedAdmin: $admin); + $trend = $this->reportQuery->dailyProfitSeriesFilled($dateFrom, $dateTo, scope: $scope); return [ 'period' => $period, @@ -53,8 +60,8 @@ final class AdminDashboardAnalyticsBuilder 'play_code' => $playCode, 'date_from' => $dateFrom, 'date_to' => $dateTo, - 'currency_code' => $this->reportQuery->resolvePeriodCurrencyCode($dateFrom, $dateTo, $admin), - 'summary' => $this->reportQuery->periodFinanceTotals($dateFrom, $dateTo, $admin), + 'currency_code' => $this->reportQuery->resolvePeriodCurrencyCode($dateFrom, $dateTo, $scope), + 'summary' => $this->reportQuery->periodFinanceTotals($dateFrom, $dateTo, $scope), 'daily_series' => $trend['series'], 'chart_meta' => [ 'chart_date_from' => $trend['chart_date_from'], @@ -66,14 +73,14 @@ final class AdminDashboardAnalyticsBuilder $dateFrom, $dateTo, $playCode, - scopedAdmin: $admin, + scope: $scope, ), 'agent_breakdown' => $this->reportQuery->agentRankingRows( $dateFrom, $dateTo, $playCode, limit: 200, - scopedAdmin: $admin, + scope: $scope, ), ]; } @@ -81,6 +88,6 @@ final class AdminDashboardAnalyticsBuilder /** 与 {@see AdminAuthorizationRegistry} 中 dashboard 类 API 资源的 `dashboard.view` 绑定一致。 */ private function canView(AdminUser $admin): bool { - return $admin->hasAdminPermission('prd.dashboard.view'); + return $admin->hasPermissionCode('dashboard.view'); } } diff --git a/app/Services/Admin/AdminDashboardSnapshotBuilder.php b/app/Services/Admin/AdminDashboardSnapshotBuilder.php index 51114f9..bebded2 100644 --- a/app/Services/Admin/AdminDashboardSnapshotBuilder.php +++ b/app/Services/Admin/AdminDashboardSnapshotBuilder.php @@ -14,6 +14,10 @@ use App\Models\DrawResultBatch; use App\Lottery\DrawResultBatchStatus; use App\Services\Draw\DrawHallSnapshotBuilder; use App\Support\AdminDataScope; +use App\Support\AdminScopeContext; +use App\Support\AdminAgentScope; +use App\Support\AdminScopeContextResolver; +use Illuminate\Database\Eloquent\Builder as EloquentBuilder; /** * 后台首页仪表盘:聚合大厅快照、当期财务、期号面板、风控摘要、异常转账计数。 @@ -28,8 +32,9 @@ final class AdminDashboardSnapshotBuilder ) {} /** @return array */ - public function build(AdminUser $admin): array + public function build(AdminScopeContext $scope): array { + $admin = $scope->admin; $hall = $this->hallSnapshot->build(); $canDraw = $this->canDrawFinanceAndRisk($admin); $canWallet = $this->canWalletReconcile($admin); @@ -53,11 +58,11 @@ final class AdminDashboardSnapshotBuilder ]; if ($canDraw) { - $this->fillPlatformOverview($out, $admin); + $this->fillPlatformOverview($out, $scope); } if ($canWallet) { - $out['abnormal_transfer_total'] = $this->abnormalTransferTotal($admin); + $out['abnormal_transfer_total'] = $this->abnormalTransferTotal($scope); } if ($hall === null) { @@ -82,7 +87,7 @@ final class AdminDashboardSnapshotBuilder ]; if ($canDraw) { - $out['finance'] = $this->financeSummary($draw, $admin); + $out['finance'] = $this->financeSummary($draw, $scope); $out['draw'] = $this->drawPanel($draw); $out['risk'] = $this->riskPanel($draw); } @@ -91,35 +96,48 @@ final class AdminDashboardSnapshotBuilder } /** @param array $out */ - private function fillPlatformOverview(array &$out, AdminUser $admin): void + private function fillPlatformOverview(array &$out, AdminScopeContext $scope): void { - $out['today_finance'] = $this->todayFinanceSummary($admin); - $out['lifetime_finance'] = $this->reportQuery->platformLifetimeTotals($admin); - $out['platform_risk'] = $this->platformRiskSummary(); - $out['result_batch_queue'] = $this->resultBatchQueue(); + $admin = $scope->admin; + $out['today_finance'] = $this->todayFinanceSummary($scope); + $out['lifetime_finance'] = $this->reportQuery->platformLifetimeTotals( + $scope, + ); + + if ($admin->isSuperAdmin() + && $scope->effectiveRequestedSiteCode() === null + && $scope->effectiveRequestedAgentNodeId() === null + ) { + $out['platform_risk'] = $this->platformRiskSummary(); + $out['result_batch_queue'] = $this->resultBatchQueue(); + } } private function canDrawFinanceAndRisk(AdminUser $admin): bool { - return $admin->hasAdminPermission('prd.dashboard.view') - || $admin->hasAdminPermission('prd.draw_result.manage') - || $admin->hasAdminPermission('prd.draw_result.view') - || $admin->hasAdminPermission('prd.risk.view') - || $admin->hasAdminPermission('prd.risk.manage'); + return $admin->hasPermissionCode('dashboard.view') + || $admin->hasPermissionCode('draw.results.view') + || $admin->hasPermissionCode('draw.review.review') + || $admin->hasPermissionCode('draw.review.publish') + || $admin->hasPermissionCode('risk.monitor.view') + || $admin->hasPermissionCode('risk.monitor.manage'); } private function canWalletReconcile(AdminUser $admin): bool { - return $admin->hasAdminPermission('prd.wallet_reconcile.manage') - || $admin->hasAdminPermission('prd.wallet_reconcile.view') - || $admin->hasAdminPermission('prd.wallet_reconcile.view_cs'); + return $admin->hasPermissionCode('service.reconcile.manage') + || $admin->hasPermissionCode('service.reconcile.view') + || $admin->hasPermissionCode('service.wallet.view') + || $admin->hasPermissionCode('service.wallet.manage'); } - private function abnormalTransferTotal(AdminUser $admin): int + private function abnormalTransferTotal(AdminScopeContext $scope): int { + $admin = $scope->admin; $query = TransferOrder::query() ->whereIn('status', ['processing', 'failed', 'pending_reconcile']); AdminDataScope::applyEloquentViaPlayer($query, $admin); + $this->applyRequestedScopeViaPlayer($query, $scope); return (int) $query->count(); } @@ -129,10 +147,15 @@ final class AdminDashboardSnapshotBuilder * * @return array */ - private function todayFinanceSummary(AdminUser $admin): array + private function todayFinanceSummary(AdminScopeContext $scope): array { + $admin = $scope->admin; $today = now()->toDateString(); - $rows = $this->reportQuery->dailyProfitRows($today, $today, $admin); + $rows = $this->reportQuery->dailyProfitRows( + $today, + $today, + $scope, + ); $row = $rows[0] ?? [ 'business_date' => $today, 'total_bet_minor' => 0, @@ -140,10 +163,12 @@ final class AdminDashboardSnapshotBuilder 'approx_house_gross_minor' => 0, ]; - $currencyCode = (string) (TicketOrder::query() + $currencyQuery = TicketOrder::query() ->join('draws', 'draws.id', '=', 'ticket_orders.draw_id') - ->where('draws.business_date', $today) - ->value('ticket_orders.currency_code') ?? ''); + ->where('draws.business_date', $today); + AdminDataScope::applyEloquentViaPlayer($currencyQuery, $admin); + $this->applyRequestedScopeViaPlayer($currencyQuery, $scope); + $currencyCode = (string) ($currencyQuery->value('ticket_orders.currency_code') ?? ''); return [ 'business_date' => (string) $row['business_date'], @@ -155,14 +180,17 @@ final class AdminDashboardSnapshotBuilder } /** @return array */ - private function financeSummary(Draw $draw, AdminUser $admin): array + private function financeSummary(Draw $draw, AdminScopeContext $scope): array { + $admin = $scope->admin; $drawId = (int) $draw->id; $orderQuery = TicketOrder::query()->where('draw_id', $drawId); $itemQuery = TicketItem::query()->where('draw_id', $drawId); AdminDataScope::applyEloquentViaPlayer($orderQuery, $admin); AdminDataScope::applyEloquentViaPlayer($itemQuery, $admin); + $this->applyRequestedScopeViaPlayer($orderQuery, $scope); + $this->applyRequestedScopeViaPlayer($itemQuery, $scope); $totalBetMinor = (int) $orderQuery->sum('total_actual_deduct'); $orderCount = (int) $orderQuery->count(); @@ -387,4 +415,29 @@ final class AdminDashboardSnapshotBuilder return 'other'; } + + /** + * 叠加全局 scope 参数(site_code / agent_node_id)到 player 关联模型查询。 + * + * @param EloquentBuilder $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); + } + }); + } } diff --git a/app/Services/Admin/AdminReportQueryService.php b/app/Services/Admin/AdminReportQueryService.php index 0335b02..706a01c 100644 --- a/app/Services/Admin/AdminReportQueryService.php +++ b/app/Services/Admin/AdminReportQueryService.php @@ -5,6 +5,8 @@ namespace App\Services\Admin; use App\Models\AdminUser; use App\Models\AuditLog; use App\Support\AdminDataScope; +use App\Support\AdminScopeContext; +use App\Support\AdminScopeContextResolver; use App\Models\Draw; use App\Models\RiskPool; use App\Models\RiskPoolLockLog; @@ -23,6 +25,19 @@ use Illuminate\Support\Facades\DB; */ final class AdminReportQueryService { + private function normalizeScope(AdminUser|AdminScopeContext|null $scope): ?AdminScopeContext + { + if ($scope instanceof AdminScopeContext) { + return $scope; + } + + if ($scope instanceof AdminUser) { + return AdminScopeContextResolver::fromValues($scope); + } + + return null; + } + /** * @return array{date_from: string, date_to: string} */ @@ -111,9 +126,10 @@ final class AdminReportQueryService * business_day_count: int * } */ - public function periodFinanceTotals(string $dateFrom, string $dateTo, ?AdminUser $scopedAdmin = null): array + public function periodFinanceTotals(string $dateFrom, string $dateTo, AdminUser|AdminScopeContext|null $scope = null): array { - $rows = $this->dailyProfitRows($dateFrom, $dateTo, $scopedAdmin); + $context = $this->normalizeScope($scope); + $rows = $this->dailyProfitRows($dateFrom, $dateTo, $context); $totalBet = 0; $totalPayout = 0; $totalGross = 0; @@ -126,7 +142,7 @@ final class AdminReportQueryService $activityQuery = DB::table('draws as d') ->join('ticket_orders as o', 'o.draw_id', '=', 'd.id') ->whereBetween('d.business_date', [$dateFrom, $dateTo]); - AdminDataScope::applyToTicketOrdersViaPlayer($activityQuery, $scopedAdmin, 'o'); + AdminDataScope::applyToTicketOrdersViaPlayer($activityQuery, $context?->admin, 'o'); $activity = $activityQuery ->selectRaw('COUNT(DISTINCT d.id) as draw_count') ->selectRaw('COUNT(DISTINCT d.business_date) as business_day_count') @@ -146,8 +162,9 @@ final class AdminReportQueryService * * @return list> */ - public function dailyProfitSeriesFilled(string $dateFrom, string $dateTo, int $maxDays = 90, ?AdminUser $scopedAdmin = null): array + public function dailyProfitSeriesFilled(string $dateFrom, string $dateTo, int $maxDays = 90, AdminUser|AdminScopeContext|null $scope = null): array { + $context = $this->normalizeScope($scope); $from = Carbon::parse($dateFrom)->startOfDay(); $to = Carbon::parse($dateTo)->startOfDay(); $spanDays = (int) $from->diffInDays($to) + 1; @@ -160,7 +177,7 @@ final class AdminReportQueryService $truncated = true; } - $indexed = collect($this->dailyProfitRows($chartFrom, $chartTo, $scopedAdmin))->keyBy('business_date'); + $indexed = collect($this->dailyProfitRows($chartFrom, $chartTo, $context))->keyBy('business_date'); $cursor = Carbon::parse($chartFrom)->startOfDay(); $end = Carbon::parse($chartTo)->startOfDay(); $series = []; @@ -193,9 +210,9 @@ final class AdminReportQueryService string $dateTo, ?string $playCode = null, int $limit = 12, - ?AdminUser $scopedAdmin = null, + AdminUser|AdminScopeContext|null $scope = null, ): array { - return $this->playDimensionBaseQuery($playCode, $dateFrom, $dateTo, $scopedAdmin) + return $this->playDimensionBaseQuery($playCode, $dateFrom, $dateTo, $scope) ->orderByDesc('total_bet_minor') ->limit($limit) ->get() @@ -229,8 +246,9 @@ final class AdminReportQueryService string $dateTo, ?string $playCode = null, int $limit = 200, - ?AdminUser $scopedAdmin = null, + AdminUser|AdminScopeContext|null $scope = null, ): array { + $context = $this->normalizeScope($scope); $query = DB::table('ticket_items as ti') ->join('ticket_orders as o', 'o.id', '=', 'ti.order_id') ->leftJoin('players as p', 'p.id', '=', 'ti.player_id') @@ -250,7 +268,12 @@ final class AdminReportQueryService $query->where('ti.play_code', $playCode); } - AdminDataScope::applyToPlayersAlias($query, $scopedAdmin, 'p'); + AdminDataScope::applyToPlayersAlias( + $query, + $context?->admin, + 'p', + $context?->effectiveRequestedAgentNodeId(), + ); return $query ->orderByDesc('total_bet_minor') @@ -270,20 +293,21 @@ final class AdminReportQueryService ->all(); } - public function resolvePeriodCurrencyCode(string $dateFrom, string $dateTo, ?AdminUser $scopedAdmin = null): ?string + public function resolvePeriodCurrencyCode(string $dateFrom, string $dateTo, AdminUser|AdminScopeContext|null $scope = null): ?string { + $context = $this->normalizeScope($scope); $currencyQuery = DB::table('ticket_orders as o') ->join('draws as d', 'd.id', '=', 'o.draw_id') ->whereBetween('d.business_date', [$dateFrom, $dateTo]); - AdminDataScope::applyToTicketOrdersViaPlayer($currencyQuery, $scopedAdmin, 'o'); + AdminDataScope::applyToTicketOrdersViaPlayer($currencyQuery, $context?->admin, 'o'); $currencyCode = (string) ($currencyQuery->orderByDesc('o.id')->value('o.currency_code') ?? ''); return $currencyCode !== '' ? $currencyCode : null; } - public function dailyProfitPaginated(string $dateFrom, string $dateTo, int $page, int $perPage, ?AdminUser $scopedAdmin = null): LengthAwarePaginator + public function dailyProfitPaginated(string $dateFrom, string $dateTo, int $page, int $perPage, AdminUser|AdminScopeContext|null $scope = null): LengthAwarePaginator { - $rows = $this->dailyProfitRows($dateFrom, $dateTo, $scopedAdmin); + $rows = $this->dailyProfitRows($dateFrom, $dateTo, $scope); $total = count($rows); $offset = max(0, ($page - 1) * $perPage); $items = array_slice($rows, $offset, $perPage); @@ -296,18 +320,19 @@ final class AdminReportQueryService /** * @return list> */ - public function dailyProfitRows(string $dateFrom, string $dateTo, ?AdminUser $scopedAdmin = null): array + public function dailyProfitRows(string $dateFrom, string $dateTo, AdminUser|AdminScopeContext|null $scope = null): array { + $context = $this->normalizeScope($scope); $betSub = DB::table('ticket_orders as o') ->selectRaw('o.draw_id, SUM(o.total_actual_deduct) as total_bet_minor') ->groupBy('o.draw_id'); - AdminDataScope::applyToTicketOrdersViaPlayer($betSub, $scopedAdmin, 'o'); + AdminDataScope::applyToTicketOrdersViaPlayer($betSub, $context?->admin, 'o'); $payoutSub = DB::table('ticket_items as ti') ->join('ticket_orders as o', 'o.id', '=', 'ti.order_id') ->selectRaw('ti.draw_id, SUM(ti.win_amount + ti.jackpot_win_amount) as total_payout_minor') ->groupBy('ti.draw_id'); - AdminDataScope::applyToTicketOrdersViaPlayer($payoutSub, $scopedAdmin, 'o'); + AdminDataScope::applyToTicketOrdersViaPlayer($payoutSub, $context?->admin, 'o'); return DB::table('draws as d') ->whereBetween('d.business_date', [$dateFrom, $dateTo]) @@ -351,15 +376,16 @@ final class AdminReportQueryService * date_to: ?string * } */ - public function platformLifetimeTotals(?AdminUser $scopedAdmin = null): array + public function platformLifetimeTotals(AdminUser|AdminScopeContext|null $scope = null): array { + $context = $this->normalizeScope($scope); $betQuery = DB::table('ticket_orders as o'); - AdminDataScope::applyToTicketOrdersViaPlayer($betQuery, $scopedAdmin, 'o'); + AdminDataScope::applyToTicketOrdersViaPlayer($betQuery, $context?->admin, 'o'); $totalBetMinor = (int) $betQuery->sum('o.total_actual_deduct'); $payoutQuery = DB::table('ticket_items as ti') ->join('ticket_orders as o', 'o.id', '=', 'ti.order_id'); - AdminDataScope::applyToTicketOrdersViaPlayer($payoutQuery, $scopedAdmin, 'o'); + AdminDataScope::applyToTicketOrdersViaPlayer($payoutQuery, $context?->admin, 'o'); $payoutAgg = $payoutQuery ->selectRaw('COALESCE(SUM(ti.win_amount), 0) as win_minor, COALESCE(SUM(ti.jackpot_win_amount), 0) as jackpot_minor') ->first(); @@ -369,7 +395,7 @@ final class AdminReportQueryService $activityQuery = DB::table('draws as d') ->join('ticket_orders as o', 'o.draw_id', '=', 'd.id'); - AdminDataScope::applyToTicketOrdersViaPlayer($activityQuery, $scopedAdmin, 'o'); + AdminDataScope::applyToTicketOrdersViaPlayer($activityQuery, $context?->admin, 'o'); $activity = $activityQuery ->selectRaw('COUNT(DISTINCT d.id) as draw_count') ->selectRaw('COUNT(DISTINCT d.business_date) as business_day_count') @@ -384,14 +410,14 @@ final class AdminReportQueryService $dateTo = $this->formatBusinessDateValue($activity?->date_to); $currencyQuery = DB::table('ticket_orders as o'); - AdminDataScope::applyToTicketOrdersViaPlayer($currencyQuery, $scopedAdmin, 'o'); + AdminDataScope::applyToTicketOrdersViaPlayer($currencyQuery, $context?->admin, 'o'); $currencyCode = (string) ($currencyQuery->orderByDesc('o.id')->value('o.currency_code') ?? ''); $orderCountQuery = DB::table('ticket_orders as o'); - AdminDataScope::applyToTicketOrdersViaPlayer($orderCountQuery, $scopedAdmin, 'o'); + AdminDataScope::applyToTicketOrdersViaPlayer($orderCountQuery, $context?->admin, 'o'); $itemCountQuery = DB::table('ticket_items as ti') ->join('ticket_orders as o', 'o.id', '=', 'ti.order_id'); - AdminDataScope::applyToTicketOrdersViaPlayer($itemCountQuery, $scopedAdmin, 'o'); + AdminDataScope::applyToTicketOrdersViaPlayer($itemCountQuery, $context?->admin, 'o'); return [ 'currency_code' => $currencyCode !== '' ? $currencyCode : null, @@ -415,10 +441,9 @@ final class AdminReportQueryService string $dateTo, int $page, int $perPage, - ?AdminUser $scopedAdmin = null, - ?int $requestedAgentNodeId = null, + AdminUser|AdminScopeContext|null $scope = null, ): LengthAwarePaginator { - $query = $this->playerWinLossBaseQuery($playerId, $dateFrom, $dateTo, $scopedAdmin, $requestedAgentNodeId); + $query = $this->playerWinLossBaseQuery($playerId, $dateFrom, $dateTo, $scope); return $query->paginate($perPage, ['*'], 'page', $page); } @@ -429,9 +454,9 @@ final class AdminReportQueryService string $dateTo, int $page, int $perPage, - ?AdminUser $scopedAdmin = null, + AdminUser|AdminScopeContext|null $scope = null, ): LengthAwarePaginator { - $query = $this->playDimensionBaseQuery($playCode, $dateFrom, $dateTo, $scopedAdmin); + $query = $this->playDimensionBaseQuery($playCode, $dateFrom, $dateTo, $scope); return $query->paginate($perPage, ['*'], 'page', $page); } @@ -442,9 +467,9 @@ final class AdminReportQueryService string $dateTo, int $page, int $perPage, - ?AdminUser $scopedAdmin = null, + AdminUser|AdminScopeContext|null $scope = null, ): LengthAwarePaginator { - $query = $this->rebateCommissionBaseQuery($playCode, $dateFrom, $dateTo, $scopedAdmin); + $query = $this->rebateCommissionBaseQuery($playCode, $dateFrom, $dateTo, $scope); return $query->paginate($perPage, ['*'], 'page', $page); } @@ -452,21 +477,21 @@ final class AdminReportQueryService /** * @return list> */ - public function reportRows(string $reportType, ?array $filterJson, ?AdminUser $scopedAdmin = null): array + public function reportRows(string $reportType, ?array $filterJson, AdminUser|AdminScopeContext|null $scope = null): array { $range = $this->resolveDateRange($filterJson); $dateFrom = $range['date_from']; $dateTo = $range['date_to']; return match ($reportType) { - 'draw_profit_summary' => $this->drawProfitExportRows($filterJson, $scopedAdmin), - 'daily_profit_summary' => $this->dailyProfitExportRows($dateFrom, $dateTo, $scopedAdmin), - 'player_win_loss' => $this->playerWinLossExportRows($filterJson, $dateFrom, $dateTo, $scopedAdmin), - 'play_dimension_report' => $this->playDimensionExportRows($filterJson, $dateFrom, $dateTo, $scopedAdmin), - 'rebate_commission_report' => $this->rebateCommissionExportRows($filterJson, $dateFrom, $dateTo, $scopedAdmin), + 'draw_profit_summary' => $this->drawProfitExportRows($filterJson, $scope), + '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, $scopedAdmin), - 'wallet_txns_daily' => $this->walletTxnsExportRows($filterJson, $dateFrom, $dateTo, $scopedAdmin), + 'wallet_transfer_report', 'transfer_orders_daily' => $this->transferOrdersExportRows($filterJson, $dateFrom, $dateTo, $scope), + 'wallet_txns_daily' => $this->walletTxnsExportRows($filterJson, $dateFrom, $dateTo, $scope), 'hot_number_risk_report' => $this->hotNumberRiskExportRows($filterJson), 'sold_out_number_report' => $this->soldOutNumberExportRows($filterJson), default => [ @@ -513,12 +538,12 @@ final class AdminReportQueryService /** * @return list> */ - private function dailyProfitExportRows(string $dateFrom, string $dateTo, ?AdminUser $scopedAdmin = null): array + private function dailyProfitExportRows(string $dateFrom, string $dateTo, AdminUser|AdminScopeContext|null $scope = null): array { $rows = [ ['日期', '下注', '派彩', '盈亏'], ]; - foreach ($this->dailyProfitRows($dateFrom, $dateTo, $scopedAdmin) as $row) { + foreach ($this->dailyProfitRows($dateFrom, $dateTo, $scope) as $row) { $rows[] = [ $row['business_date'], $row['total_bet_minor'], @@ -533,13 +558,13 @@ final class AdminReportQueryService /** * @return list> */ - private function playerWinLossExportRows(?array $filterJson, string $dateFrom, string $dateTo, ?AdminUser $scopedAdmin = null): array + private function playerWinLossExportRows(?array $filterJson, string $dateFrom, string $dateTo, AdminUser|AdminScopeContext|null $scope = null): array { $playerId = isset($filterJson['player_id']) ? (int) $filterJson['player_id'] : null; $rows = [ ['玩家ID', '用户名', '下注', '派彩', '净输赢'], ]; - $items = $this->playerWinLossBaseQuery($playerId, $dateFrom, $dateTo, $scopedAdmin)->get(); + $items = $this->playerWinLossBaseQuery($playerId, $dateFrom, $dateTo, $scope)->get(); foreach ($items as $row) { $rows[] = [ (int) $row->player_id, @@ -556,13 +581,13 @@ final class AdminReportQueryService /** * @return list> */ - private function playDimensionExportRows(?array $filterJson, string $dateFrom, string $dateTo, ?AdminUser $scopedAdmin = null): array + private function playDimensionExportRows(?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->playDimensionBaseQuery($playCode !== '' ? $playCode : null, $dateFrom, $dateTo, $scopedAdmin)->get(); + $items = $this->playDimensionBaseQuery($playCode !== '' ? $playCode : null, $dateFrom, $dateTo, $scope)->get(); foreach ($items as $row) { $rows[] = [ (string) $row->play_code, @@ -579,13 +604,13 @@ final class AdminReportQueryService /** * @return list> */ - private function rebateCommissionExportRows(?array $filterJson, string $dateFrom, string $dateTo, ?AdminUser $scopedAdmin = null): array + 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, $scopedAdmin)->get(); + $items = $this->rebateCommissionBaseQuery($playCode !== '' ? $playCode : null, $dateFrom, $dateTo, $scope)->get(); foreach ($items as $row) { $rows[] = [ (string) $row->play_code, @@ -635,9 +660,9 @@ final class AdminReportQueryService ?int $playerId, string $dateFrom, string $dateTo, - ?AdminUser $scopedAdmin = null, - ?int $requestedAgentNodeId = null, + 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') ->leftJoin('players as p', 'p.id', '=', 'ti.player_id') @@ -659,16 +684,20 @@ final class AdminReportQueryService $query->where('ti.player_id', $playerId); } - if ($scopedAdmin !== null) { - AdminDataScope::applyToPlayersAlias($query, $scopedAdmin, 'p', $requestedAgentNodeId); - } + AdminDataScope::applyToPlayersAlias( + $query, + $context?->admin, + 'p', + $context?->effectiveRequestedAgentNodeId(), + ); return $query; } /** @return \Illuminate\Database\Query\Builder */ - private function playDimensionBaseQuery(?string $playCode, string $dateFrom, string $dateTo, ?AdminUser $scopedAdmin = null) + private function playDimensionBaseQuery(?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') ->selectRaw('ti.play_code') @@ -686,14 +715,15 @@ final class AdminReportQueryService $query->where('ti.play_code', $playCode); } - AdminDataScope::applyToTicketOrdersViaPlayer($query, $scopedAdmin, 'o'); + AdminDataScope::applyToTicketOrdersViaPlayer($query, $context?->admin, 'o'); return $query; } /** @return \Illuminate\Database\Query\Builder */ - private function rebateCommissionBaseQuery(?string $playCode, string $dateFrom, string $dateTo, ?AdminUser $scopedAdmin = null) + 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') ->selectRaw('ti.play_code') @@ -709,7 +739,7 @@ final class AdminReportQueryService $query->where('ti.play_code', $playCode); } - AdminDataScope::applyToTicketOrdersViaPlayer($query, $scopedAdmin, 'o'); + AdminDataScope::applyToTicketOrdersViaPlayer($query, $context?->admin, 'o'); return $query; } @@ -717,8 +747,9 @@ final class AdminReportQueryService /** * @return list> */ - private function drawProfitExportRows(?array $filterJson, ?AdminUser $scopedAdmin = null): array + private function drawProfitExportRows(?array $filterJson, AdminUser|AdminScopeContext|null $scope = null): array { + $context = $this->normalizeScope($scope); $draw = $this->resolveDrawForReport($filterJson); if ($draw === null) { return [['提示', '请提供 draw_id 或 draw_no']]; @@ -727,9 +758,9 @@ final class AdminReportQueryService $drawId = (int) $draw->id; $orderQuery = TicketOrder::query()->where('draw_id', $drawId); $itemQuery = TicketItem::query()->where('draw_id', $drawId); - if ($scopedAdmin !== null) { - AdminDataScope::applyEloquentViaPlayer($orderQuery, $scopedAdmin); - AdminDataScope::applyEloquentViaPlayer($itemQuery, $scopedAdmin); + if ($context !== null) { + AdminDataScope::applyEloquentViaPlayer($orderQuery, $context->admin); + AdminDataScope::applyEloquentViaPlayer($itemQuery, $context->admin); } $totalBetMinor = (int) $orderQuery->sum('total_actual_deduct'); @@ -952,8 +983,9 @@ final class AdminReportQueryService /** * @return list> */ - private function transferOrdersExportRows(?array $filterJson, string $dateFrom, string $dateTo, ?AdminUser $scopedAdmin = null): array + private function transferOrdersExportRows(?array $filterJson, string $dateFrom, string $dateTo, AdminUser|AdminScopeContext|null $scope = null): array { + $context = $this->normalizeScope($scope); $rows = [ ['转账单号', '玩家ID', '用户名', '昵称', '方向', '币种', '金额', '状态', '外部单号', '失败原因', '创建时间', '完成时间'], ]; @@ -962,8 +994,8 @@ final class AdminReportQueryService ->with(['player:id,username,nickname']) ->orderByDesc('id'); - if ($scopedAdmin !== null) { - AdminDataScope::applyEloquentViaPlayer($query, $scopedAdmin); + if ($context !== null) { + AdminDataScope::applyEloquentViaPlayer($query, $context->admin); } $playerId = isset($filterJson['player_id']) ? (int) $filterJson['player_id'] : null; @@ -998,8 +1030,9 @@ final class AdminReportQueryService /** * @return list> */ - private function walletTxnsExportRows(?array $filterJson, string $dateFrom, string $dateTo, ?AdminUser $scopedAdmin = null): array + private function walletTxnsExportRows(?array $filterJson, string $dateFrom, string $dateTo, AdminUser|AdminScopeContext|null $scope = null): array { + $context = $this->normalizeScope($scope); $rows = [ ['流水号', '玩家ID', '用户名', '业务类型', '业务单号', '方向', '金额', '变动前余额', '变动后余额', '状态', '外部单号', '备注', '创建时间'], ]; @@ -1008,8 +1041,8 @@ final class AdminReportQueryService ->with(['player:id,username']) ->orderByDesc('id'); - if ($scopedAdmin !== null) { - AdminDataScope::applyEloquentViaPlayer($query, $scopedAdmin); + if ($context !== null) { + AdminDataScope::applyEloquentViaPlayer($query, $context->admin); } $playerId = isset($filterJson['player_id']) ? (int) $filterJson['player_id'] : null; diff --git a/app/Services/Agent/AgentRoleService.php b/app/Services/Agent/AgentRoleService.php index c9e687f..072c6b2 100644 --- a/app/Services/Agent/AgentRoleService.php +++ b/app/Services/Agent/AgentRoleService.php @@ -5,6 +5,7 @@ namespace App\Services\Agent; use App\Models\AdminRole; use App\Models\AdminUser; use App\Models\AgentNode; +use App\Support\AdminPermissionInheritance; use App\Support\AgentRoleAuthorization; use Illuminate\Support\Facades\DB; use Illuminate\Validation\ValidationException; @@ -16,10 +17,14 @@ final class AgentRoleService */ public function createForAgent(AdminUser $actor, AgentNode $owner, array $payload): AdminRole { + $permissionSlugs = AdminPermissionInheritance::expand( + array_values(array_unique($payload['permission_slugs'] ?? [])), + ); + AgentRoleAuthorization::assertSlugsForAgentRole( $actor, $owner, - array_values(array_unique($payload['permission_slugs'] ?? [])), + $permissionSlugs, ); $slug = trim((string) $payload['slug']); @@ -30,7 +35,7 @@ final class AgentRoleService throw ValidationException::withMessages(['slug' => ['unique']]); } - return DB::transaction(function () use ($payload, $owner, $slug): AdminRole { + return DB::transaction(function () use ($payload, $owner, $slug, $permissionSlugs): AdminRole { $role = AdminRole::query()->create([ 'slug' => $slug, 'code' => $slug, @@ -44,7 +49,7 @@ final class AgentRoleService 'delegated_from_role_id' => null, ]); - $role->syncLegacyPermissionSlugs($payload['permission_slugs'] ?? []); + $role->syncLegacyPermissionSlugs($permissionSlugs); return $role->fresh(); }); @@ -80,6 +85,7 @@ final class AgentRoleService */ public function syncPermissions(AdminUser $actor, AdminRole $role, array $permissionSlugs): AdminRole { + $permissionSlugs = AdminPermissionInheritance::expand($permissionSlugs); $owner = AgentNode::query()->findOrFail((int) $role->owner_agent_id); AgentRoleAuthorization::assertSlugsForAgentRole($actor, $owner, $permissionSlugs); $role->syncLegacyPermissionSlugs($permissionSlugs); diff --git a/app/Services/AuditLogger.php b/app/Services/AuditLogger.php index 5110686..15c78ed 100644 --- a/app/Services/AuditLogger.php +++ b/app/Services/AuditLogger.php @@ -6,6 +6,7 @@ use App\Models\Player; use App\Models\AuditLog; use App\Models\AdminUser; use Illuminate\Http\Request; +use App\Http\Middleware\RecordAdminApiAudit; /** * 审计日志写入入口:落到表 audit_logs,仅 created_at。 @@ -70,7 +71,7 @@ final class AuditLogger ?array $beforeJson = null, ?array $afterJson = null, ): AuditLog { - return self::record( + $row = self::record( $operatorType, $operatorId, $moduleCode, @@ -82,6 +83,24 @@ final class AuditLogger $request->ip(), $request->userAgent(), ); + + self::markAdminApiAuditRecorded($request); + + return $row; + } + + /** 业务层已写审计时,避免 {@see RecordAdminApiAudit} 重复落库(FormRequest 可能与管道中的 Request 非同一实例)。 */ + public static function markAdminApiAuditRecorded(Request $request): void + { + $request->attributes->set(RecordAdminApiAudit::ATTRIBUTE_AUDIT_RECORDED, true); + + try { + $resolved = request(); + if ($resolved !== $request) { + $resolved->attributes->set(RecordAdminApiAudit::ATTRIBUTE_AUDIT_RECORDED, true); + } + } catch (\Throwable) { + } } public static function recordForAdmin(AdminUser $admin, ?Request $request = null, ?string $moduleCode = null, ?string $actionCode = null, ?string $targetType = null, ?string $targetId = null, ?array $beforeJson = null, ?array $afterJson = null): AuditLog diff --git a/app/Services/Draw/DrawCancelBetRefundService.php b/app/Services/Draw/DrawCancelBetRefundService.php index 9de4002..f53ceda 100644 --- a/app/Services/Draw/DrawCancelBetRefundService.php +++ b/app/Services/Draw/DrawCancelBetRefundService.php @@ -86,6 +86,8 @@ final class DrawCancelBetRefundService $this->ticketWallet->reverseBetDeduct($lockedOrder); } + $this->ticketWallet->releaseReservedBetDeduct($lockedOrder, 'draw_cancelled_release'); + $lockedOrder->forceFill(['status' => 'refunded'])->save(); } } diff --git a/app/Services/Settlement/SettlementPayoutCorrectionService.php b/app/Services/Settlement/SettlementPayoutCorrectionService.php new file mode 100644 index 0000000..d7f4b6b --- /dev/null +++ b/app/Services/Settlement/SettlementPayoutCorrectionService.php @@ -0,0 +1,120 @@ + + */ + public function apply( + SettlementBatch $batch, + AdminUser $admin, + int $playerId, + int $amountDelta, + string $reason, + Request $request, + ): array { + return DB::transaction(function () use ($batch, $admin, $playerId, $amountDelta, $reason, $request): array { + /** @var SettlementBatch $lockedBatch */ + $lockedBatch = SettlementBatch::query()->whereKey($batch->id)->lockForUpdate()->firstOrFail(); + if ($lockedBatch->status !== SettlementBatchStatus::Paid->value) { + throw new \RuntimeException('settlement_batch_not_paid'); + } + + $detail = TicketSettlementDetail::query() + ->where('settlement_batch_id', $lockedBatch->id) + ->whereHas('ticketItem', fn ($query) => $query->where('player_id', $playerId)) + ->with(['ticketItem.order']) + ->orderBy('id') + ->first(); + + if ($detail === null || $detail->ticketItem === null) { + throw new \RuntimeException('settlement_player_not_in_batch'); + } + + $player = Player::query()->whereKey($playerId)->firstOrFail(); + $currencyCode = strtoupper((string) ($detail->ticketItem->order?->currency_code ?? 'NPR')); + $correctionNo = $this->newCorrectionNo(); + $remark = sprintf( + 'settlement_batch_adjustment:%d:%d:%s', + (int) $lockedBatch->id, + $playerId, + mb_substr(trim($reason), 0, 180) + ); + + $before = [ + 'batch_id' => (int) $lockedBatch->id, + 'batch_status' => (string) $lockedBatch->status, + 'player_id' => $playerId, + 'currency_code' => $currencyCode, + 'amount_delta' => $amountDelta, + 'reason' => $reason, + 'ticket_item_id' => (int) $detail->ticket_item_id, + 'ticket_no' => (string) ($detail->ticketItem->ticket_no ?? ''), + 'original_win_amount' => (int) $detail->win_amount, + 'original_jackpot_allocation_amount' => (int) $detail->jackpot_allocation_amount, + ]; + + $txnNo = $this->walletService->applySettlementCorrection( + $player, + $currencyCode, + $amountDelta, + $correctionNo, + $remark, + ); + + AuditLogger::recordForAdmin( + $admin, + $request, + moduleCode: 'settlement', + actionCode: 'payout_adjustment', + targetType: 'settlement_batch_adjustment', + targetId: $correctionNo, + beforeJson: $before, + afterJson: [ + 'batch_id' => (int) $lockedBatch->id, + 'player_id' => $playerId, + 'currency_code' => $currencyCode, + 'amount_delta' => $amountDelta, + 'direction' => $amountDelta > 0 ? 'credit' : 'debit', + 'reason' => $reason, + 'correction_no' => $correctionNo, + 'wallet_txn_no' => $txnNo, + ], + ); + $request->attributes->set(RecordAdminApiAudit::ATTRIBUTE_AUDIT_RECORDED, true); + + return [ + 'batch_id' => (int) $lockedBatch->id, + 'player_id' => $playerId, + 'currency_code' => $currencyCode, + 'amount_delta' => $amountDelta, + 'direction' => $amountDelta > 0 ? 'credit' : 'debit', + 'reason' => $reason, + 'correction_no' => $correctionNo, + 'wallet_txn_no' => $txnNo, + ]; + }); + } + + private function newCorrectionNo(): string + { + return 'SC'.now()->format('YmdHis').str_pad((string) random_int(0, 999999), 6, '0', STR_PAD_LEFT); + } +} diff --git a/app/Services/Ticket/TicketPendingConfirmReconcileService.php b/app/Services/Ticket/TicketPendingConfirmReconcileService.php index a33fd5a..70eb997 100644 --- a/app/Services/Ticket/TicketPendingConfirmReconcileService.php +++ b/app/Services/Ticket/TicketPendingConfirmReconcileService.php @@ -124,6 +124,8 @@ final class TicketPendingConfirmReconcileService $this->ticketWallet->reverseBetDeduct($lockedOrder); } + $this->ticketWallet->releaseReservedBetDeduct($lockedOrder, $reasonCode.'_release'); + return $this->refundPendingConfirmItems($lockedOrder, $reasonCode); } diff --git a/app/Services/Ticket/TicketPlacementService.php b/app/Services/Ticket/TicketPlacementService.php index b86a21b..b703027 100644 --- a/app/Services/Ticket/TicketPlacementService.php +++ b/app/Services/Ticket/TicketPlacementService.php @@ -297,6 +297,13 @@ final class TicketPlacementService 'status' => $failedItems === [] ? 'pending_confirm' : 'partial_pending_confirm', ])->save(); + $this->ticketWalletService->reserveBetDeduct( + $player, + $currencyCode, + $successTotalActualDeduct, + $order, + ); + return [ 'order' => $order, 'draw_id' => (int) $draw->id, @@ -317,7 +324,12 @@ final class TicketPlacementService $draw = Draw::query()->whereKey((int) $placement['draw_id'])->firstOrFail(); try { - $balanceAfter = $this->ticketWalletService->deduct($player, (string) $placement['currency_code'], (int) $placement['success_total_actual_deduct'], $order); + $balanceAfter = $this->ticketWalletService->finalizeReservedBetDeduct( + $player, + (string) $placement['currency_code'], + (int) $placement['success_total_actual_deduct'], + $order, + ); DB::transaction(function () use ($order, $draw, $placement): void { $successfulItems = TicketItem::query() @@ -361,6 +373,7 @@ final class TicketPlacementService } $order->forceFill(['status' => 'refunded'])->save(); + $this->ticketWalletService->releaseReservedBetDeduct($order, 'wallet_deduct_failed_release'); $this->ticketWalletService->reverseBetDeduct($order); }); diff --git a/app/Services/Ticket/TicketWalletService.php b/app/Services/Ticket/TicketWalletService.php index b118ddb..dd77b6c 100644 --- a/app/Services/Ticket/TicketWalletService.php +++ b/app/Services/Ticket/TicketWalletService.php @@ -2,12 +2,12 @@ namespace App\Services\Ticket; -use App\Models\Player; -use App\Models\WalletTxn; -use App\Lottery\ErrorCode; -use App\Models\TicketOrder; -use App\Models\PlayerWallet; use App\Exceptions\TicketOperationException; +use App\Lottery\ErrorCode; +use App\Models\Player; +use App\Models\PlayerWallet; +use App\Models\TicketOrder; +use App\Models\WalletTxn; use App\Services\Wallet\WalletBalanceRealtimeNotifier; final class TicketWalletService @@ -15,47 +15,90 @@ final class TicketWalletService public function __construct( private readonly WalletBalanceRealtimeNotifier $balanceRealtime, ) {} + private const TXN_POSTED = 'posted'; private const TXN_DIR_OUT = 2; private const TXN_DIR_IN = 1; - public function deduct(Player $player, string $currencyCode, int $amountMinor, TicketOrder $order): int + private const BIZ_BET_RESERVE = 'bet_reserve'; + + private const BIZ_BET_RESERVE_RELEASE = 'bet_reserve_release'; + + public function reserveBetDeduct(Player $player, string $currencyCode, int $amountMinor, TicketOrder $order): void { - $wallet = PlayerWallet::query() - ->where('player_id', $player->id) - ->where('wallet_type', 'lottery') - ->where('currency_code', strtoupper($currencyCode)) - ->lockForUpdate() - ->first(); - - if ($wallet === null) { - $wallet = PlayerWallet::query()->create([ - 'player_id' => $player->id, - 'wallet_type' => 'lottery', - 'currency_code' => strtoupper($currencyCode), - 'balance' => 0, - 'frozen_balance' => 0, - 'status' => 0, - 'version' => 0, - ]); - $wallet = PlayerWallet::query()->whereKey($wallet->id)->lockForUpdate()->firstOrFail(); + if ($amountMinor <= 0) { + return; } - if ((int) $wallet->status !== 0) { - throw new TicketOperationException('wallet_frozen', ErrorCode::WalletLotteryFrozen->value); + $idempotentKey = self::BIZ_BET_RESERVE.':'.$order->order_no; + if (WalletTxn::query()->where('biz_type', self::BIZ_BET_RESERVE)->where('idempotent_key', $idempotentKey)->exists()) { + return; } + $wallet = $this->lockOrCreateWallet($player, $currencyCode); $before = (int) $wallet->balance; - $available = $before - (int) $wallet->frozen_balance; + $frozenBefore = (int) $wallet->frozen_balance; + $available = $before - $frozenBefore; if ($available < $amountMinor) { throw new TicketOperationException('bet_insufficient_balance', ErrorCode::BetInsufficientBalance->value); } + $wallet->forceFill([ + 'frozen_balance' => $frozenBefore + $amountMinor, + 'version' => (int) $wallet->version + 1, + ])->save(); + + WalletTxn::query()->create([ + 'txn_no' => $this->newTxnNo(), + 'player_id' => $player->id, + 'wallet_id' => $wallet->id, + 'biz_type' => self::BIZ_BET_RESERVE, + 'biz_no' => $order->order_no, + 'direction' => self::TXN_DIR_OUT, + 'amount' => $amountMinor, + 'balance_before' => $before, + 'balance_after' => $before, + 'status' => self::TXN_POSTED, + 'external_ref_no' => null, + 'idempotent_key' => $idempotentKey, + 'remark' => 'pending_confirm_reserve', + ]); + } + + public function finalizeReservedBetDeduct(Player $player, string $currencyCode, int $amountMinor, TicketOrder $order): int + { + if ($amountMinor <= 0) { + $wallet = PlayerWallet::query() + ->where('player_id', $player->id) + ->where('wallet_type', 'lottery') + ->where('currency_code', strtoupper($currencyCode)) + ->first(); + + return $wallet !== null ? (int) $wallet->balance : 0; + } + + $deductIdempotentKey = 'bet_deduct:'.$order->order_no; + $existingDeduct = WalletTxn::query() + ->where('biz_type', 'bet_deduct') + ->where('idempotent_key', $deductIdempotentKey) + ->first(); + if ($existingDeduct !== null) { + return (int) $existingDeduct->balance_after; + } + + $wallet = $this->lockOrCreateWallet($player, $currencyCode); + $before = (int) $wallet->balance; + $frozenBefore = (int) $wallet->frozen_balance; + if ($frozenBefore < $amountMinor) { + throw new TicketOperationException('bet_reserve_missing', ErrorCode::BetInsufficientBalance->value); + } + $after = $before - $amountMinor; $wallet->forceFill([ 'balance' => $after, + 'frozen_balance' => $frozenBefore - $amountMinor, 'version' => (int) $wallet->version + 1, ])->save(); @@ -71,7 +114,7 @@ final class TicketWalletService 'balance_after' => $after, 'status' => self::TXN_POSTED, 'external_ref_no' => null, - 'idempotent_key' => 'bet_deduct:'.$order->order_no, + 'idempotent_key' => $deductIdempotentKey, 'remark' => null, ]); @@ -81,6 +124,63 @@ final class TicketWalletService return $after; } + public function releaseReservedBetDeduct(TicketOrder $order, string $remark = 'pending_confirm_release'): void + { + $reserveIdempotentKey = self::BIZ_BET_RESERVE.':'.$order->order_no; + $releaseIdempotentKey = self::BIZ_BET_RESERVE_RELEASE.':'.$order->order_no; + + if (! WalletTxn::query()->where('biz_type', self::BIZ_BET_RESERVE)->where('idempotent_key', $reserveIdempotentKey)->exists()) { + return; + } + + if (WalletTxn::query()->where('biz_type', 'bet_deduct')->where('biz_no', $order->order_no)->where('status', self::TXN_POSTED)->exists()) { + return; + } + + if (WalletTxn::query()->where('biz_type', self::BIZ_BET_RESERVE_RELEASE)->where('idempotent_key', $releaseIdempotentKey)->exists()) { + return; + } + + $wallet = PlayerWallet::query() + ->where('player_id', $order->player_id) + ->where('wallet_type', 'lottery') + ->where('currency_code', strtoupper((string) $order->currency_code)) + ->lockForUpdate() + ->first(); + + if ($wallet === null) { + return; + } + + $before = (int) $wallet->balance; + $frozenBefore = (int) $wallet->frozen_balance; + $releaseAmount = min($frozenBefore, (int) $order->total_actual_deduct); + if ($releaseAmount <= 0) { + return; + } + + $wallet->forceFill([ + 'frozen_balance' => $frozenBefore - $releaseAmount, + 'version' => (int) $wallet->version + 1, + ])->save(); + + WalletTxn::query()->create([ + 'txn_no' => $this->newTxnNo(), + 'player_id' => (int) $order->player_id, + 'wallet_id' => $wallet->id, + 'biz_type' => self::BIZ_BET_RESERVE_RELEASE, + 'biz_no' => $order->order_no, + 'direction' => self::TXN_DIR_IN, + 'amount' => $releaseAmount, + 'balance_before' => $before, + 'balance_after' => $before, + 'status' => self::TXN_POSTED, + 'external_ref_no' => null, + 'idempotent_key' => $releaseIdempotentKey, + 'remark' => $remark, + ]); + } + public function reverseBetDeduct(TicketOrder $order): void { $deductTxn = WalletTxn::query() @@ -131,9 +231,6 @@ final class TicketWalletService $this->balanceRealtime->notifyAfterMovement($wallet, $amount, 'bet_reverse'); } - /** - * 结算派彩入账(产品文档:派彩写入钱包流水;幂等键按结算批次 + 玩家)。 - */ /** * 手动爆池补发:结算批次已派彩后,将 Jackpot 份额追加入账(幂等键按批次 + 玩家 + 爆池日志)。 */ @@ -154,26 +251,7 @@ final class TicketWalletService } $currency = strtoupper($currencyCode); - - $wallet = PlayerWallet::query() - ->where('player_id', $player->id) - ->where('wallet_type', 'lottery') - ->where('currency_code', $currency) - ->lockForUpdate() - ->first(); - - if ($wallet === null) { - $wallet = PlayerWallet::query()->create([ - 'player_id' => $player->id, - 'wallet_type' => 'lottery', - 'currency_code' => $currency, - 'balance' => 0, - 'frozen_balance' => 0, - 'status' => 0, - 'version' => 0, - ]); - $wallet = PlayerWallet::query()->whereKey($wallet->id)->lockForUpdate()->firstOrFail(); - } + $wallet = $this->lockOrCreateWallet($player, $currency); $before = (int) $wallet->balance; $after = $before + $amountMinor; @@ -214,26 +292,7 @@ final class TicketWalletService return; } - $wallet = PlayerWallet::query() - ->where('player_id', $player->id) - ->where('wallet_type', 'lottery') - ->where('currency_code', $currency) - ->lockForUpdate() - ->first(); - - if ($wallet === null) { - $wallet = PlayerWallet::query()->create([ - 'player_id' => $player->id, - 'wallet_type' => 'lottery', - 'currency_code' => $currency, - 'balance' => 0, - 'frozen_balance' => 0, - 'status' => 0, - 'version' => 0, - ]); - $wallet = PlayerWallet::query()->whereKey($wallet->id)->lockForUpdate()->firstOrFail(); - } - + $wallet = $this->lockOrCreateWallet($player, $currency); $before = (int) $wallet->balance; $after = $before + $amountMinor; $wallet->forceFill([ @@ -261,6 +320,85 @@ final class TicketWalletService $this->balanceRealtime->notifyAfterMovement($wallet, $amountMinor, 'settle_payout'); } + public function applySettlementCorrection( + Player $player, + string $currencyCode, + int $amountMinor, + string $correctionNo, + string $remark, + ): string { + if ($amountMinor === 0) { + throw new TicketOperationException('adjustment_delta_zero', ErrorCode::WalletInvalidAmount->value); + } + + $currency = strtoupper($currencyCode); + $wallet = $this->lockOrCreateWallet($player, $currency); + $before = (int) $wallet->balance; + $available = $before - (int) $wallet->frozen_balance; + $direction = $amountMinor > 0 ? self::TXN_DIR_IN : self::TXN_DIR_OUT; + $absAmount = abs($amountMinor); + + if ($amountMinor < 0 && $available < $absAmount) { + throw new TicketOperationException('bet_insufficient_balance', ErrorCode::BetInsufficientBalance->value); + } + + $after = $before + $amountMinor; + $wallet->forceFill([ + 'balance' => $after, + 'version' => (int) $wallet->version + 1, + ])->save(); + + $txn = WalletTxn::query()->create([ + 'txn_no' => $this->newTxnNo(), + 'player_id' => $player->id, + 'wallet_id' => $wallet->id, + 'biz_type' => 'settlement_adjustment', + 'biz_no' => $correctionNo, + 'direction' => $direction, + 'amount' => $absAmount, + 'balance_before' => $before, + 'balance_after' => $after, + 'status' => self::TXN_POSTED, + 'external_ref_no' => null, + 'idempotent_key' => 'settlement_adjustment:'.$correctionNo, + 'remark' => $remark, + ]); + + $wallet->refresh(); + $this->balanceRealtime->notifyAfterMovement($wallet, $amountMinor, $amountMinor > 0 ? 'settle_payout' : 'bet_reverse'); + + return (string) $txn->txn_no; + } + + private function lockOrCreateWallet(Player $player, string $currencyCode): PlayerWallet + { + $wallet = PlayerWallet::query() + ->where('player_id', $player->id) + ->where('wallet_type', 'lottery') + ->where('currency_code', strtoupper($currencyCode)) + ->lockForUpdate() + ->first(); + + if ($wallet === null) { + $wallet = PlayerWallet::query()->create([ + 'player_id' => $player->id, + 'wallet_type' => 'lottery', + 'currency_code' => strtoupper($currencyCode), + 'balance' => 0, + 'frozen_balance' => 0, + 'status' => 0, + 'version' => 0, + ]); + $wallet = PlayerWallet::query()->whereKey($wallet->id)->lockForUpdate()->firstOrFail(); + } + + if ((int) $wallet->status !== 0) { + throw new TicketOperationException('wallet_frozen', ErrorCode::WalletLotteryFrozen->value); + } + + return $wallet; + } + private function newTxnNo(): string { return 'WL'.now()->format('YmdHis').str_pad((string) random_int(0, 999999), 6, '0', STR_PAD_LEFT); diff --git a/app/Services/Wallet/HttpMainSiteWalletBalanceClient.php b/app/Services/Wallet/HttpMainSiteWalletBalanceClient.php index e548551..992148d 100644 --- a/app/Services/Wallet/HttpMainSiteWalletBalanceClient.php +++ b/app/Services/Wallet/HttpMainSiteWalletBalanceClient.php @@ -70,6 +70,21 @@ final class HttpMainSiteWalletBalanceClient $timeout = $config->walletTimeoutSeconds; $apiKey = $config->walletApiKey; + if (app()->environment(['production']) + && $config->source === \App\Services\Integration\PartnerSiteConfig::SOURCE_LEGACY_ENV + && (! is_string($apiKey) || trim($apiKey) === '') + ) { + return new MainSiteWalletBalanceProbeResult( + success: false, + mainBalanceMinor: null, + currencyCode: $currencyCode, + requestUrl: '', + httpStatus: null, + message: 'MAIN_SITE_WALLET_API_KEY 未配置', + responseBody: null, + ); + } + $headers = ['Accept' => 'application/json']; if (is_string($apiKey) && $apiKey !== '') { $headers['Authorization'] = 'Bearer '.$apiKey; diff --git a/app/Services/Wallet/HttpMainSiteWalletGateway.php b/app/Services/Wallet/HttpMainSiteWalletGateway.php index afd1fe1..edc92e4 100644 --- a/app/Services/Wallet/HttpMainSiteWalletGateway.php +++ b/app/Services/Wallet/HttpMainSiteWalletGateway.php @@ -53,6 +53,24 @@ final class HttpMainSiteWalletGateway implements MainSiteWalletGateway ); } + public function refundMainForFailedLotteryDeposit( + Player $player, + string $currencyCode, + int $amountMinor, + string $idempotentKey, + ): MainSiteWalletResult { + $config = $this->partnerSiteConfigResolver->resolveForPlayer($player); + + return $this->post( + $config->walletCreditPath, + $player, + $currencyCode, + $amountMinor, + $idempotentKey, + $config, + ); + } + private function post( string $path, Player $player, @@ -108,10 +126,6 @@ final class HttpMainSiteWalletGateway implements MainSiteWalletGateway ); } - $url = $base.'/'.ltrim($path, '/'); - $timeout = $config->walletTimeoutSeconds; - $apiKey = $config->walletApiKey; - $requestBody = [ 'site_code' => $player->site_code, 'site_player_id' => $player->site_player_id, @@ -128,6 +142,22 @@ final class HttpMainSiteWalletGateway implements MainSiteWalletGateway ], ]); + $url = $base.'/'.ltrim($path, '/'); + $timeout = $config->walletTimeoutSeconds; + $apiKey = $config->walletApiKey; + + if (app()->environment(['production']) + && $config->source === \App\Services\Integration\PartnerSiteConfig::SOURCE_LEGACY_ENV + && (! is_string($apiKey) || trim($apiKey) === '') + ) { + return MainSiteWalletResult::failure( + 'main_site_wallet_api_key_required', + ['reason' => 'missing_main_site_wallet_api_key'], + false, + $requestSnapshot, + ); + } + $headers = []; if (is_string($apiKey) && $apiKey !== '') { $headers['Authorization'] = 'Bearer '.$apiKey; diff --git a/app/Services/Wallet/LotteryTransferService.php b/app/Services/Wallet/LotteryTransferService.php index 804a5b6..dae5b40 100644 --- a/app/Services/Wallet/LotteryTransferService.php +++ b/app/Services/Wallet/LotteryTransferService.php @@ -37,7 +37,7 @@ final class LotteryTransferService /** PRD §12:对账后冲正 */ private const ST_REVERSED = 'reversed'; - /** PRD §12:对账后人工处理 */ + /** PRD §12:对账后标记结案(仅改状态,不动钱包) */ private const ST_MANUALLY_PROCESSED = 'manually_processed'; private const BIZ_TRANSFER_IN = 'transfer_in'; @@ -326,10 +326,10 @@ final class LotteryTransferService } /** - * 对账操作:冲正 / 人工处理。 + * 对账操作:冲正 / 补入账 / 标记结案。 * * 冲正(reverse):主站确认未成功,对已扣彩票余额的转出单做反向操作(加回余额),标记为已冲正。 - * 人工处理(manually_process):管理员确认该订单已通过其它途径解决,仅标记状态,不动钱包。 + * 标记结案(manually_process):确认已在系统外处理完毕,仅改订单状态,不动钱包。 * * @param 'reverse'|'manually_process' $action * @throws WalletOperationException @@ -402,6 +402,30 @@ final class LotteryTransferService deltaSign: 1, ); } + } elseif ($this->isEligibleForTransferInReverse($locked)) { + $player = Player::query()->whereKey($locked->player_id)->firstOrFail(); + $refund = $this->mainSite->refundMainForFailedLotteryDeposit( + $player, + (string) $locked->currency_code, + (int) $locked->amount, + 'refund:'.$locked->transfer_no, + ); + + if (! $refund->ok) { + throw new WalletOperationException( + $refund->errorMessage ?? 'main_site_refund_failed', + $refund->uncertain + ? ErrorCode::WalletTransferPending->value + : ErrorCode::WalletExternalRejected->value, + $refund->uncertain ? 409 : 422, + ); + } + + $locked->forceFill([ + 'external_request_payload' => $refund->requestPayload, + 'external_response_payload' => $refund->responsePayload, + 'external_ref_no' => $refund->externalRefNo ?? $locked->external_ref_no, + ])->save(); } $locked->forceFill([ @@ -510,6 +534,14 @@ final class LotteryTransferService ); } + if (! $this->isEligibleForManualProcess($locked)) { + throw new WalletOperationException( + 'manually_process_not_eligible', + ErrorCode::WalletExternalRejected->value, + 422, + ); + } + $locked->forceFill([ 'status' => self::ST_MANUALLY_PROCESSED, 'fail_reason' => 'manually_processed: '.($remark ?: 'admin_manual'), @@ -518,12 +550,32 @@ final class LotteryTransferService } /** 仅主站已扣款(有 external_ref_no)且彩票入账失败时可补完成转入。 */ - private function isEligibleForCompleteCredit(TransferOrder $order): bool + public function isEligibleForCompleteCredit(TransferOrder $order): bool { return $order->fail_reason === 'lottery_credit_failed' && trim((string) $order->external_ref_no) !== ''; } + public function isEligibleForTransferInReverse(TransferOrder $order): bool + { + return $order->direction === self::DIR_IN + && $order->status === self::ST_PENDING_RECONCILE + && $this->isEligibleForCompleteCredit($order); + } + + public function isEligibleForManualProcess(TransferOrder $order): bool + { + if (! in_array($order->status, [self::ST_PROCESSING, self::ST_FAILED, self::ST_PENDING_RECONCILE], true)) { + return false; + } + + if ($order->direction === self::DIR_OUT && $order->status === self::ST_PENDING_RECONCILE) { + return false; + } + + return $order->fail_reason !== 'lottery_credit_failed'; + } + private function lockLotteryWalletById(int $playerId, string $currencyCode): PlayerWallet { $wallet = PlayerWallet::query() diff --git a/app/Services/Wallet/MainSiteWalletGateway.php b/app/Services/Wallet/MainSiteWalletGateway.php index 0409875..5fbb573 100644 --- a/app/Services/Wallet/MainSiteWalletGateway.php +++ b/app/Services/Wallet/MainSiteWalletGateway.php @@ -30,4 +30,14 @@ interface MainSiteWalletGateway int $amountMinor, string $idempotentKey, ): MainSiteWalletResult; + + /** + * 转入异常冲正:主站已扣款但彩票侧未入账时,把钱退回主站玩家钱包。 + */ + public function refundMainForFailedLotteryDeposit( + Player $player, + string $currencyCode, + int $amountMinor, + string $idempotentKey, + ): MainSiteWalletResult; } diff --git a/app/Services/Wallet/StubMainSiteWalletGateway.php b/app/Services/Wallet/StubMainSiteWalletGateway.php index c5ff6fb..786e5f2 100644 --- a/app/Services/Wallet/StubMainSiteWalletGateway.php +++ b/app/Services/Wallet/StubMainSiteWalletGateway.php @@ -39,6 +39,21 @@ final class StubMainSiteWalletGateway implements MainSiteWalletGateway ], $req); } + public function refundMainForFailedLotteryDeposit( + Player $player, + string $currencyCode, + int $amountMinor, + string $idempotentKey, + ): MainSiteWalletResult { + $req = self::requestSnapshot($player, $currencyCode, $amountMinor, $idempotentKey, 'stub_refund'); + + return MainSiteWalletResult::success('stub-refund:'.$idempotentKey, [ + 'stub' => true, + 'currency' => $currencyCode, + 'amount_minor' => $amountMinor, + ], $req); + } + /** * @return array */ diff --git a/app/Support/AdminAgentScope.php b/app/Support/AdminAgentScope.php index 78d7b9d..fb58e8b 100644 --- a/app/Support/AdminAgentScope.php +++ b/app/Support/AdminAgentScope.php @@ -69,7 +69,7 @@ final class AdminAgentScope return true; } - if (! $admin->hasAdminPermission('prd.agent.manage')) { + if (! $admin->hasPermissionCode('agent.node.manage')) { return false; } diff --git a/app/Support/AdminAuthProfile.php b/app/Support/AdminAuthProfile.php index 74816d9..1487086 100644 --- a/app/Support/AdminAuthProfile.php +++ b/app/Support/AdminAuthProfile.php @@ -32,6 +32,7 @@ final class AdminAuthProfile * depth: int * }, * is_super_admin: bool, + * operational_permissions: list, * delegation_ceiling: list * } */ @@ -49,6 +50,7 @@ final class AdminAuthProfile 'navigation' => AdminAuthorizationRegistry::visibleNavigationItems($permissionSlugs, $fresh), 'agent' => self::agentContext($fresh), 'is_super_admin' => $fresh->isSuperAdmin(), + 'operational_permissions' => $permissionSlugs, 'delegation_ceiling' => AgentDelegationAuthorization::delegationLegacySlugsForAdminUser($fresh), ]; } diff --git a/app/Support/AdminAuthorizationRegistry.php b/app/Support/AdminAuthorizationRegistry.php index da6809b..1026499 100644 --- a/app/Support/AdminAuthorizationRegistry.php +++ b/app/Support/AdminAuthorizationRegistry.php @@ -379,7 +379,6 @@ final class AdminAuthorizationRegistry ['code' => 'admin.admin-users.update', 'module_code' => 'system', 'name' => '更新管理员', 'http_method' => 'PUT', 'uri_pattern' => '/api/v1/admin/admin-users/{admin_user}', 'route_name' => 'api.v1.admin.admin-users.update', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.admin_user.manage']], ['code' => 'admin.admin-users.destroy', 'module_code' => 'system', 'name' => '删除管理员', 'http_method' => 'DELETE', 'uri_pattern' => '/api/v1/admin/admin-users/{admin_user}', 'route_name' => 'api.v1.admin.admin-users.destroy', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.admin_user.manage']], ['code' => 'admin.admin-users.permission-catalog', 'module_code' => 'system', 'name' => '管理员权限目录', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/admin-user-permission-catalog', 'route_name' => 'api.v1.admin.admin-users.permission-catalog', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.admin_user.manage', 'prd.admin_role.manage']], - ['code' => 'admin.admin-users.permissions.sync', 'module_code' => 'system', 'name' => '管理员权限同步', 'http_method' => 'PUT', 'uri_pattern' => '/api/v1/admin/admin-users/{admin_user}/permissions', 'route_name' => 'api.v1.admin.admin-users.permissions.sync', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.admin_user.manage']], ['code' => 'admin.admin-users.roles.sync', 'module_code' => 'system', 'name' => '管理员角色同步', 'http_method' => 'PUT', 'uri_pattern' => '/api/v1/admin/admin-users/{admin_user}/roles', 'route_name' => 'api.v1.admin.admin-users.roles.sync', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.admin_user.manage']], ['code' => 'admin.admin-roles.index', 'module_code' => 'system', 'name' => '角色列表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/admin-roles', 'route_name' => 'api.v1.admin.admin-roles.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.admin_role.manage']], ['code' => 'admin.admin-roles.store', 'module_code' => 'system', 'name' => '创建角色', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/admin-roles', 'route_name' => 'api.v1.admin.admin-roles.store', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.admin_role.manage']], @@ -473,6 +472,7 @@ final class AdminAuthorizationRegistry ['code' => 'admin.settlement-batches.approve', 'module_code' => 'settlement', 'name' => '审核结算批次', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/settlement-batches/{batch}/approve', 'route_name' => 'api.v1.admin.settlement-batches.approve', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.payout.review']], ['code' => 'admin.settlement-batches.reject', 'module_code' => 'settlement', 'name' => '驳回结算批次', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/settlement-batches/{batch}/reject', 'route_name' => 'api.v1.admin.settlement-batches.reject', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.payout.review']], ['code' => 'admin.settlement-batches.payout', 'module_code' => 'settlement', 'name' => '执行派彩', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/settlement-batches/{batch}/payout', 'route_name' => 'api.v1.admin.settlement-batches.payout', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.payout.manage']], + ['code' => 'admin.settlement-batches.adjustments.store', 'module_code' => 'settlement', 'name' => '结算补差调账', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/settlement-batches/{batch}/adjustments', 'route_name' => 'api.v1.admin.settlement-batches.adjustments.store', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.payout.manage']], ['code' => 'admin.jackpot.pools.index', 'module_code' => 'jackpot', 'name' => '奖池列表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/jackpot/pools', 'route_name' => 'api.v1.admin.jackpot.pools.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.jackpot.manage', 'prd.jackpot.view']], ['code' => 'admin.jackpot.payout-logs.index', 'module_code' => 'jackpot', 'name' => '奖池派彩日志', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/jackpot/payout-logs', 'route_name' => 'api.v1.admin.jackpot.payout-logs.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.jackpot.manage', 'prd.jackpot.view']], diff --git a/app/Support/AdminPermissionInheritance.php b/app/Support/AdminPermissionInheritance.php new file mode 100644 index 0000000..dea5c80 --- /dev/null +++ b/app/Support/AdminPermissionInheritance.php @@ -0,0 +1,61 @@ +> + */ + private const IMPLIED_BY_SLUG = [ + 'prd.agent.manage' => ['prd.agent.view'], + 'prd.agent.role.manage' => ['prd.agent.role.view'], + 'prd.agent.user.manage' => ['prd.agent.user.view'], + 'prd.integration.manage' => ['prd.integration.view'], + 'prd.wallet_reconcile.manage' => ['prd.wallet_reconcile.view'], + 'prd.draw_result.manage' => ['prd.draw_result.view'], + 'prd.odds.manage' => ['prd.odds.view'], + 'prd.risk_cap.manage' => ['prd.risk_cap.view'], + 'prd.rebate.manage' => ['prd.rebate.view'], + 'prd.jackpot.manage' => ['prd.jackpot.view'], + 'prd.payout.manage' => ['prd.payout.view'], + 'prd.payout.review' => ['prd.payout.view'], + 'prd.report.export' => ['prd.report.view'], + 'prd.risk.manage' => ['prd.risk.view'], + ]; + + /** + * @param list $permissionSlugs + * @return list + */ + public static function expand(array $permissionSlugs): array + { + $expanded = []; + + foreach ($permissionSlugs as $slug) { + if (! is_string($slug) || $slug === '') { + continue; + } + + self::appendWithImplications($expanded, $slug); + } + + return array_values(array_keys($expanded)); + } + + /** + * @param array $accumulator + */ + private static function appendWithImplications(array &$accumulator, string $slug): void + { + if (isset($accumulator[$slug])) { + return; + } + + $accumulator[$slug] = true; + + foreach (self::IMPLIED_BY_SLUG[$slug] ?? [] as $implied) { + self::appendWithImplications($accumulator, $implied); + } + } +} diff --git a/app/Support/AdminScopeContext.php b/app/Support/AdminScopeContext.php new file mode 100644 index 0000000..fdac7a3 --- /dev/null +++ b/app/Support/AdminScopeContext.php @@ -0,0 +1,53 @@ +admin->isSuperAdmin(); + } + + public function actorAgentNode(): ?AgentNode + { + return $this->admin->primaryAgentNode(); + } + + public function actorAgentNodeId(): ?int + { + $node = $this->actorAgentNode(); + + return $node !== null ? (int) $node->id : null; + } + + public function adminSiteId(): ?int + { + $node = $this->actorAgentNode(); + + return $node !== null ? (int) $node->admin_site_id : null; + } + + public function effectiveRequestedAgentNodeId(): ?int + { + return $this->requestedAgentNodeId !== null && $this->requestedAgentNodeId > 0 + ? $this->requestedAgentNodeId + : null; + } + + public function effectiveRequestedSiteCode(): ?string + { + $siteCode = is_string($this->requestedSiteCode) ? trim($this->requestedSiteCode) : ''; + + return $siteCode !== '' ? $siteCode : null; + } +} diff --git a/app/Support/AdminScopeContextResolver.php b/app/Support/AdminScopeContextResolver.php new file mode 100644 index 0000000..5552c23 --- /dev/null +++ b/app/Support/AdminScopeContextResolver.php @@ -0,0 +1,42 @@ +query($siteParam); + $agentNodeId = $request->integer($agentParam) ?: null; + + return self::fromValues( + $admin, + is_string($siteCode) ? $siteCode : null, + $agentNodeId, + ); + } + + public static function fromValues( + AdminUser $admin, + ?string $requestedSiteCode = null, + ?int $requestedAgentNodeId = null, + ): AdminScopeContext { + $siteCode = is_string($requestedSiteCode) ? trim($requestedSiteCode) : ''; + $agentNodeId = $requestedAgentNodeId !== null && $requestedAgentNodeId > 0 + ? $requestedAgentNodeId + : null; + + return new AdminScopeContext( + admin: $admin, + requestedSiteCode: $siteCode !== '' ? $siteCode : null, + requestedAgentNodeId: $agentNodeId, + ); + } +} diff --git a/app/Support/AdminScopePolicy.php b/app/Support/AdminScopePolicy.php new file mode 100644 index 0000000..b65c442 --- /dev/null +++ b/app/Support/AdminScopePolicy.php @@ -0,0 +1,115 @@ + $query + */ + public static function applyViaPlayer( + EloquentBuilder $query, + AdminUser|AdminScopeContext $scope, + string $relation = 'player', + ): void { + $context = self::normalizeContext($scope); + AdminSiteScope::applyViaPlayerRelation($query, $context->admin, $relation); + } + + /** + * @param EloquentBuilder<\App\Models\Player> $query + */ + public static function applyPlayerFilters(EloquentBuilder $query, AdminScopeContext $context): void + { + AdminSiteScope::applyPlayerFilters( + $query, + $context->admin, + $context->effectiveRequestedSiteCode(), + $context->effectiveRequestedAgentNodeId(), + ); + } + + /** + * @param EloquentBuilder $query + */ + public static function applyViaPlayerRelationWithContext( + EloquentBuilder $query, + AdminScopeContext $context, + string $relation = 'player', + ): void { + AdminSiteScope::applyViaPlayerRelationWithSiteCode( + $query, + $context->admin, + $context->effectiveRequestedSiteCode(), + $relation, + $context->effectiveRequestedAgentNodeId(), + ); + } + + /** + * @param QueryBuilder $query + */ + public static function applyPlayersAlias( + QueryBuilder $query, + AdminUser|AdminScopeContext $scope, + string $alias = 'p', + ): void { + $context = self::normalizeContext($scope); + AdminDataScope::applyToPlayersAlias( + $query, + $context->admin, + $alias, + $context->effectiveRequestedAgentNodeId(), + ); + } + + /** + * @param QueryBuilder $query + */ + public static function applyTicketOrdersViaPlayer( + QueryBuilder $query, + AdminUser|AdminScopeContext $scope, + string $orderAlias = 'o', + ): void { + $context = self::normalizeContext($scope); + AdminDataScope::applyToTicketOrdersViaPlayer($query, $context->admin, $orderAlias); + } + + public static function transferOrderAccessible(AdminUser|AdminScopeContext $scope, TransferOrder $order): bool + { + $context = self::normalizeContext($scope); + $player = $order->player; + if ($player === null) { + return false; + } + + return AdminSiteScope::playerAccessible($context->admin, $player); + } + + private static function normalizeContext(AdminUser|AdminScopeContext $scope): AdminScopeContext + { + if ($scope instanceof AdminScopeContext) { + return $scope; + } + + return AdminScopeContextResolver::fromValues($scope); + } +} diff --git a/app/Support/AgentRoleAuthorization.php b/app/Support/AgentRoleAuthorization.php index 56a0994..28984fe 100644 --- a/app/Support/AgentRoleAuthorization.php +++ b/app/Support/AgentRoleAuthorization.php @@ -37,7 +37,7 @@ final class AgentRoleAuthorization return false; } - return $admin->isSuperAdmin() || $admin->hasAdminPermission('prd.agent.role.manage'); + return $admin->isSuperAdmin() || $admin->hasPermissionCode('agent.node.manage'); } /** diff --git a/app/Support/AuditLogApiPresenter.php b/app/Support/AuditLogApiPresenter.php new file mode 100644 index 0000000..3c830eb --- /dev/null +++ b/app/Support/AuditLogApiPresenter.php @@ -0,0 +1,227 @@ + */ + private const MODULE_LABELS = [ + 'agent' => '代理', + 'system' => '系统', + 'settings' => '系统设置', + 'integration' => '对接站点', + 'player_manage' => '玩家管理', + 'risk_cap' => '风险池', + 'odds' => '赔率', + 'play_config' => '玩法配置', + 'settlement' => '结算', + 'report_jobs' => '报表任务', + 'reconcile_jobs' => '对账任务', + 'draw' => '开奖', + 'wallet' => '钱包', + 'reconcile' => '对账', + 'job' => '任务', + 'bootstrap' => '系统', + ]; + + /** @var array */ + private const ACTION_LABELS = [ + 'agent_role.sync_permissions' => '同步代理角色权限', + 'admin_role.sync_permissions' => '同步平台角色权限', + 'admin_role.create' => '创建平台角色', + 'admin_role.update' => '更新平台角色', + 'admin_role.delete' => '删除平台角色', + 'agent_role.create' => '创建代理角色', + 'agent_role.update' => '更新代理角色', + 'agent_role.destroy' => '删除代理角色', + 'agent_admin_user.create' => '创建代理账号', + 'agent_admin_user.sync_roles' => '同步代理账号角色', + 'agent_node.create' => '创建代理节点', + 'agent_node.update' => '更新代理节点', + 'agent_node.destroy' => '删除代理节点', + 'agent.delegation.sync' => '同步代理授权', + 'admin_user.create' => '创建管理员', + 'admin_user.update' => '更新管理员', + 'admin_user.delete' => '删除管理员', + 'sync_permissions' => '同步权限', + 'batch_update' => '批量更新设置', + 'payout_adjustment' => '派彩调整', + 'rotate_secrets' => '轮换密钥', + 'toggle_active' => '切换启用状态', + 'enqueue' => '提交报表任务', + ]; + + /** @var array */ + private const TARGET_TYPE_LABELS = [ + 'admin_role' => '角色', + 'admin_user' => '管理员', + 'agent_node' => '代理节点', + 'admin_site' => '对接站点', + 'player' => '玩家', + 'lottery_settings' => '系统设置', + 'lottery_setting' => '设置项', + 'risk_cap_version' => '风险池版本', + 'odds_version' => '赔率版本', + 'play_config_item' => '玩法', + 'play_config_version' => '玩法配置版本', + 'settlement_batch_adjustment' => '结算调整单', + 'report_job' => '报表任务', + 'reconcile_job' => '对账任务', + 'transfer_no' => '转账单', + 'draw' => '期次', + 'batch' => '批次', + ]; + + /** @var array */ + private const VERB_LABELS = [ + 'sync' => '同步', + 'store' => '创建', + 'update' => '更新', + 'destroy' => '删除', + 'delete' => '删除', + 'create' => '创建', + 'publish' => '发布', + 'reopen' => '重新开奖', + 'freeze' => '冻结', + 'unfreeze' => '解冻', + 'run' => '执行', + 'transfer' => '转账', + 'start' => '开始', + 'test' => '测试', + ]; + + /** + * @param LengthAwarePaginator $paginator + * @return array{items: list>, meta: array{current_page: int, per_page: int, total: int, last_page: int}} + */ + public static function listPayload(LengthAwarePaginator $paginator): array + { + $items = collect($paginator->items()); + $resourceNames = self::loadResourceNames($items); + + return AdminApiList::payload( + $paginator, + fn (AuditLog $r) => self::row($r, $resourceNames), + ); + } + + /** + * @param array $resourceNames + * @return array + */ + public static function row(AuditLog $r, array $resourceNames = []): array + { + return [ + 'id' => (int) $r->id, + 'operator_type' => $r->operator_type, + 'operator_id' => (int) $r->operator_id, + 'module_code' => $r->module_code, + 'action_code' => $r->action_code, + 'target_type' => $r->target_type, + 'target_id' => $r->target_id, + 'module_label' => self::moduleLabel($r->module_code), + 'action_label' => self::actionLabel($r, $resourceNames), + 'target_label' => self::targetLabel($r, $resourceNames), + 'before_json' => $r->before_json, + 'after_json' => $r->after_json, + 'ip' => $r->ip, + 'user_agent' => $r->user_agent, + 'created_at' => $r->created_at?->toIso8601String(), + ]; + } + + /** + * @param Collection $items + * @return array + */ + private static function loadResourceNames(Collection $items): array + { + $codes = $items + ->pluck('target_type') + ->filter(fn (?string $type) => self::isApiResourceCode($type)) + ->unique() + ->values() + ->all(); + + if ($codes === []) { + return []; + } + + /** @var array */ + return DB::table('admin_api_resources') + ->whereIn('code', $codes) + ->pluck('name', 'code') + ->all(); + } + + private static function moduleLabel(?string $code): string + { + if ($code === null || $code === '') { + return '—'; + } + + return self::MODULE_LABELS[$code] ?? $code; + } + + /** + * @param array $resourceNames + */ + private static function actionLabel(AuditLog $r, array $resourceNames): string + { + $action = (string) ($r->action_code ?? ''); + if ($action === '') { + return '—'; + } + + if (isset(self::ACTION_LABELS[$action])) { + return self::ACTION_LABELS[$action]; + } + + $targetType = (string) ($r->target_type ?? ''); + if (self::isApiResourceCode($targetType) && isset($resourceNames[$targetType])) { + return $resourceNames[$targetType]; + } + + if (isset(self::VERB_LABELS[$action])) { + return self::VERB_LABELS[$action]; + } + + return $action; + } + + /** + * @param array $resourceNames + */ + private static function targetLabel(AuditLog $r, array $resourceNames): string + { + $type = (string) ($r->target_type ?? ''); + $id = trim((string) ($r->target_id ?? '')); + + if ($type === '') { + return $id !== '' ? '#'.$id : '—'; + } + + if (self::isApiResourceCode($type)) { + $name = $resourceNames[$type] ?? $type; + + return $id !== '' ? sprintf('%s #%s', $name, $id) : $name; + } + + $typeLabel = self::TARGET_TYPE_LABELS[$type] ?? $type; + + return $id !== '' ? sprintf('%s #%s', $typeLabel, $id) : $typeLabel; + } + + private static function isApiResourceCode(?string $code): bool + { + return is_string($code) && str_starts_with($code, 'admin.'); + } +} diff --git a/docs/admin-rbac.md b/docs/admin-rbac.md index 8e8a1d1..6d9133b 100644 --- a/docs/admin-rbac.md +++ b/docs/admin-rbac.md @@ -32,7 +32,13 @@ php artisan lottery:admin-auth-audit # 仅体检:受保护路由是 ## 仪表盘 API 与子块权限 - `GET /api/v1/admin/dashboard` 与 `…/analytics`:中间件要求 `dashboard.view`(对应产品权限 `prd.dashboard.view`)。 -- 进入仪表盘后,财务/期号/风控、钱包异常计数等子块仍按 `AdminDashboardSnapshotBuilder` 内各 `prd.*` 细分(与侧栏其它模块权限一致)。 +- 子块权限判定统一按 `permission_code`(如 `draw.results.view`、`risk.monitor.view`、`service.reconcile.view`),`prd.*` 仅作为展示映射。 + +## 站点优先作用域约束(2026-06) + +- 后台查询范围统一为:`site_scope ∩ agent_subtree_scope`。 +- 新增统一入口 `App\Support\AdminScopePolicy`,查询应优先通过该策略应用数据范围。 +- `auth/me` 继续返回 `permissions`(`prd.*`)兼容前端,同时新增 `operational_permissions` 字段用于显式表达可操作权限集合。 ## 已废弃的 `prd.*`(请求体仍可传入,会自动归一) diff --git a/routes/api/v1/admin/draw.php b/routes/api/v1/admin/draw.php index d197274..1bd452b 100644 --- a/routes/api/v1/admin/draw.php +++ b/routes/api/v1/admin/draw.php @@ -26,6 +26,7 @@ use App\Http\Controllers\Api\V1\Admin\Settlement\AdminSettlementBatchShowControl use App\Http\Controllers\Api\V1\Admin\Settlement\AdminSettlementBatchIndexController; use App\Http\Controllers\Api\V1\Admin\Settlement\AdminSettlementBatchExportController; use App\Http\Controllers\Api\V1\Admin\Settlement\AdminSettlementBatchPayoutController; +use App\Http\Controllers\Api\V1\Admin\Settlement\AdminSettlementBatchAdjustmentController; use App\Http\Controllers\Api\V1\Admin\Settlement\AdminSettlementBatchRejectController; use App\Http\Controllers\Api\V1\Admin\Settlement\AdminSettlementBatchApproveController; use App\Http\Controllers\Api\V1\Admin\Settlement\AdminSettlementBatchDetailsController; @@ -118,3 +119,7 @@ Route::middleware('admin.api-resource') Route::middleware('admin.api-resource') ->post('settlement-batches/{batch}/payout', AdminSettlementBatchPayoutController::class) ->name('api.v1.admin.settlement-batches.payout'); + +Route::middleware('admin.api-resource') + ->post('settlement-batches/{batch}/adjustments', AdminSettlementBatchAdjustmentController::class) + ->name('api.v1.admin.settlement-batches.adjustments.store'); diff --git a/routes/api/v1/admin/user.php b/routes/api/v1/admin/user.php index 57d9b05..0c0842c 100644 --- a/routes/api/v1/admin/user.php +++ b/routes/api/v1/admin/user.php @@ -13,7 +13,6 @@ use App\Http\Controllers\Api\V1\Admin\User\AdminUserUpdateController; use App\Http\Controllers\Api\V1\Admin\User\AdminUserDestroyController; use App\Http\Controllers\Api\V1\Admin\User\AdminUserRoleSyncController; use App\Http\Controllers\Api\V1\Admin\User\AdminPermissionCatalogController; -use App\Http\Controllers\Api\V1\Admin\User\AdminUserPermissionSyncController; /** * 管理员账号与权限管理路由。 @@ -32,8 +31,6 @@ Route::middleware('admin.api-resource') ->name('api.v1.admin.admin-users.destroy'); Route::get('admin-user-permission-catalog', AdminPermissionCatalogController::class) ->name('api.v1.admin.admin-users.permission-catalog'); - Route::put('admin-users/{admin_user}/permissions', AdminUserPermissionSyncController::class) - ->name('api.v1.admin.admin-users.permissions.sync'); Route::put('admin-users/{admin_user}/roles', AdminUserRoleSyncController::class) ->name('api.v1.admin.admin-users.roles.sync'); Route::get('admin-roles', AdminRoleIndexController::class) diff --git a/tests/Feature/AdminAgentDelegationApiTest.php b/tests/Feature/AdminAgentDelegationApiTest.php index b7137a8..5ad5847 100644 --- a/tests/Feature/AdminAgentDelegationApiTest.php +++ b/tests/Feature/AdminAgentDelegationApiTest.php @@ -188,5 +188,5 @@ test('auth me includes delegation ceiling for agent user', function (): void { $this->withHeader('Authorization', 'Bearer '.$token) ->getJson('/api/v1/admin/auth/me') ->assertOk() - ->assertJsonStructure(['data' => ['admin' => ['delegation_ceiling']]]); + ->assertJsonStructure(['data' => ['admin' => ['delegation_ceiling', 'operational_permissions']]]); }); diff --git a/tests/Feature/AdminAuditLogDedupTest.php b/tests/Feature/AdminAuditLogDedupTest.php new file mode 100644 index 0000000..c841828 --- /dev/null +++ b/tests/Feature/AdminAuditLogDedupTest.php @@ -0,0 +1,135 @@ +artisan('lottery:admin-auth-sync')->assertExitCode(0); +}); + +test('audit log presenter maps business action and entity target', function (): void { + $row = new AuditLog([ + 'operator_type' => 'admin', + 'operator_id' => 1, + 'module_code' => 'agent', + 'action_code' => 'agent_role.sync_permissions', + 'target_type' => 'admin_role', + 'target_id' => '5', + ]); + $row->id = 1; + + $payload = AuditLogApiPresenter::row($row); + + expect($payload['module_label'])->toBe('代理') + ->and($payload['action_label'])->toBe('同步代理角色权限') + ->and($payload['target_label'])->toBe('角色 #5'); +}); + +test('audit log presenter uses admin api resource name for middleware style rows', function (): void { + $resourceName = (string) DB::table('admin_api_resources') + ->where('code', 'admin.agent-roles.permissions.sync') + ->value('name'); + expect($resourceName)->not->toBe(''); + + $row = new AuditLog([ + 'operator_type' => 'admin', + 'operator_id' => 1, + 'module_code' => 'agent', + 'action_code' => 'sync', + 'target_type' => 'admin.agent-roles.permissions.sync', + 'target_id' => '5', + ]); + $row->id = 2; + + $resourceNames = ['admin.agent-roles.permissions.sync' => $resourceName]; + $payload = AuditLogApiPresenter::row($row, $resourceNames); + + expect($payload['action_label'])->toBe($resourceName) + ->and($payload['target_label'])->toBe($resourceName.' #5'); +}); + +test('agent role permission sync records one business audit and skips middleware duplicate', function (): void { + $siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id'); + $rootId = (int) DB::table('agent_nodes')->where('admin_site_id', $siteId)->where('depth', 0)->value('id'); + $service = app(\App\Services\Agent\AgentNodeService::class); + + $super = AdminUser::query()->create([ + 'username' => 'audit_dedup_super', + 'name' => 'Super', + 'email' => null, + 'password' => Hash::make('secret-strong'), + 'status' => 0, + ]); + grantSuperAdminRole($super); + $token = $super->createToken('test', ['*'], now()->addDay())->plainTextToken; + + $branch = $service->createChild($super, [ + 'parent_id' => $rootId, + 'code' => 'audit-branch', + 'name' => 'Audit Branch', + ]); + + $create = $this->withHeader('Authorization', 'Bearer '.$token) + ->postJson('/api/v1/admin/agent-nodes/'.$branch->id.'/roles', [ + 'slug' => 'audit_role', + 'name' => 'Audit Role', + 'permission_slugs' => ['prd.agent.view'], + ]) + ->assertOk(); + + $roleId = (int) $create->json('data.id'); + $maxIdBeforeSync = (int) AuditLog::query()->max('id'); + + $this->withHeader('Authorization', 'Bearer '.$token) + ->putJson('/api/v1/admin/agent-roles/'.$roleId.'/permissions', [ + 'permission_slugs' => ['prd.agent.view', 'prd.agent.role.view'], + ]) + ->assertOk(); + + $newRows = AuditLog::query() + ->where('id', '>', $maxIdBeforeSync) + ->orderBy('id') + ->get(); + + expect($newRows)->toHaveCount(1) + ->and($newRows[0]->action_code)->toBe('agent_role.sync_permissions') + ->and($newRows[0]->target_type)->toBe('admin_role') + ->and($newRows[0]->target_id)->toBe((string) $roleId); +}); + +test('audit log index returns chinese display labels', function (): void { + AuditLogger::record( + AuditLogger::OPERATOR_ADMIN, + 1, + 'agent', + 'agent_role.sync_permissions', + 'admin_role', + '9', + ); + + $admin = AdminUser::query()->create([ + 'username' => 'audit_list_super', + 'name' => 'Super', + 'email' => null, + 'password' => Hash::make('secret-strong'), + 'status' => 0, + ]); + grantSuperAdminRole($admin); + $token = $admin->createToken('test', ['*'], now()->addDay())->plainTextToken; + + $this->withHeader('Authorization', 'Bearer '.$token) + ->getJson('/api/v1/admin/audit-logs?per_page=5') + ->assertOk() + ->assertJsonPath('code', ErrorCode::Success->value) + ->assertJsonPath('data.items.0.module_label', '代理') + ->assertJsonPath('data.items.0.action_label', '同步代理角色权限') + ->assertJsonPath('data.items.0.target_label', '角色 #9'); +}); diff --git a/tests/Feature/AdminAuthLoginTest.php b/tests/Feature/AdminAuthLoginTest.php index f98702e..5bcb922 100644 --- a/tests/Feature/AdminAuthLoginTest.php +++ b/tests/Feature/AdminAuthLoginTest.php @@ -57,7 +57,8 @@ test('admin auth me returns current admin profile', function () { ->assertOk() ->assertJsonPath('code', ErrorCode::Success->value) ->assertJsonPath('data.admin.username', 'admin_me') - ->assertJsonPath('data.admin.navigation.0.segment', 'dashboard'); + ->assertJsonPath('data.admin.navigation.0.segment', 'dashboard') + ->assertJsonStructure(['data' => ['admin' => ['permissions', 'operational_permissions']]]); }); test('admin login returns bearer token when captcha passes validation', function () { @@ -95,7 +96,7 @@ test('admin login returns bearer token when captcha passes validation', function ->assertJsonPath('data.admin.navigation.1.segment', 'agents') ->assertJsonPath('data.admin.navigation.1.nav_group', 'agent') ->assertJsonPath('data.admin.navigation.2.segment', 'draws') - ->assertJsonStructure(['data' => ['token', 'token_type', 'admin' => ['id', 'username', 'nickname', 'email', 'permissions', 'navigation']]]); + ->assertJsonStructure(['data' => ['token', 'token_type', 'admin' => ['id', 'username', 'nickname', 'email', 'permissions', 'operational_permissions', 'navigation']]]); $token = $resp->json('data.token'); expect($token)->not->toBeNull(); diff --git a/tests/Feature/AdminSettingBatchUpdateTest.php b/tests/Feature/AdminSettingBatchUpdateTest.php index 922c1a9..7300e75 100644 --- a/tests/Feature/AdminSettingBatchUpdateTest.php +++ b/tests/Feature/AdminSettingBatchUpdateTest.php @@ -35,6 +35,32 @@ function settingsAdminToken(): string return $admin->createToken('test', ['*'], now()->addDay())->plainTextToken; } +function settingsReadOnlyToken(): string +{ + $admin = AdminUser::query()->create([ + 'username' => 'settings_readonly', + 'name' => 'Settings Readonly', + 'email' => null, + 'password' => Hash::make('secret-strong'), + 'status' => 0, + ]); + + $role = AdminRole::query()->create([ + 'slug' => 'settings_readonly_role', + 'name' => 'Settings Readonly Role', + ]); + $role->syncLegacyPermissionSlugs(['prd.rebate.manage']); + + $admin->roles()->sync([ + (int) $role->id => [ + 'site_id' => AdminUser::defaultAdminSiteId(), + 'granted_at' => now(), + ], + ]); + + return $admin->createToken('test', ['*'], now()->addDay())->plainTextToken; +} + test('admin can batch update settings in one request', function (): void { LotterySettings::put('draw.interval_minutes', 5, 'draw'); LotterySettings::put('draw.cooldown_minutes', 15, 'draw'); @@ -101,3 +127,33 @@ test('admin can update single setting with false value', function (): void { expect(LotterySetting::query()->where('setting_key', 'settlement.apply_rebate_to_payout')->value('value_json'))->toBeFalse(); }); + +test('non payout manager cannot batch update settlement settings', function (): void { + LotterySettings::put('settlement.auto_payout_on_tick', true, 'settlement'); + + $token = settingsReadOnlyToken(); + + $this->withHeader('Authorization', 'Bearer '.$token) + ->putJson('/api/v1/admin/settings/batch', [ + 'items' => [ + ['key' => 'settlement.auto_payout_on_tick', 'value' => false], + ], + ]) + ->assertForbidden(); + + expect(LotterySetting::query()->where('setting_key', 'settlement.auto_payout_on_tick')->value('value_json'))->toBeTrue(); +}); + +test('non payout manager cannot update single settlement setting', function (): void { + LotterySettings::put('settlement.auto_approve_on_tick', true, 'settlement'); + + $token = settingsReadOnlyToken(); + + $this->withHeader('Authorization', 'Bearer '.$token) + ->putJson('/api/v1/admin/settings/settlement.auto_approve_on_tick', [ + 'value' => false, + ]) + ->assertForbidden(); + + expect(LotterySetting::query()->where('setting_key', 'settlement.auto_approve_on_tick')->value('value_json'))->toBeTrue(); +}); diff --git a/tests/Feature/AdminSettlementPayoutAdjustmentTest.php b/tests/Feature/AdminSettlementPayoutAdjustmentTest.php new file mode 100644 index 0000000..27b1d85 --- /dev/null +++ b/tests/Feature/AdminSettlementPayoutAdjustmentTest.php @@ -0,0 +1,252 @@ +create([ + 'username' => 'settlement_manager', + 'name' => 'Settlement Manager', + 'email' => null, + 'password' => Hash::make('secret-strong'), + 'status' => 0, + ]); + + $role = AdminRole::query()->create([ + 'slug' => 'settlement_manager_role', + 'name' => 'Settlement Manager Role', + ]); + $role->syncLegacyPermissionSlugs(['prd.payout.manage']); + + $admin->roles()->sync([ + (int) $role->id => [ + 'site_id' => AdminUser::defaultAdminSiteId(), + 'granted_at' => now(), + ], + ]); + + return $admin->createToken('test', ['*'], now()->addDay())->plainTextToken; +} + +test('admin can apply settlement payout adjustment for paid batch and audit it', function (): void { + $token = settlementPayoutManagerToken(); + $managerId = (int) AdminUser::query()->where('username', 'settlement_manager')->value('id'); + + $draw = Draw::query()->create([ + 'draw_no' => '20260603-001', + 'business_date' => '2026-06-03', + 'sequence_no' => 1, + 'status' => DrawStatus::Settled->value, + 'start_time' => now()->subHour(), + 'close_time' => now()->subMinutes(30), + 'draw_time' => now()->subMinutes(20), + 'cooling_end_time' => now()->subMinutes(10), + 'result_source' => 'manual', + 'current_result_version' => 1, + 'settle_version' => 1, + 'is_reopened' => false, + ]); + + $player = Player::query()->create([ + 'site_code' => 'main', + 'site_player_id' => 'settlement-adjust-player', + 'username' => 'settle_player', + 'nickname' => null, + 'default_currency' => 'NPR', + 'status' => 0, + ]); + + $wallet = PlayerWallet::query()->create([ + 'player_id' => $player->id, + 'wallet_type' => 'lottery', + 'currency_code' => 'NPR', + 'balance' => 1_000, + 'frozen_balance' => 0, + 'status' => 0, + 'version' => 0, + ]); + + $order = TicketOrder::query()->create([ + 'order_no' => 'TO-SETTLE-ADJUST-1', + 'player_id' => $player->id, + 'draw_id' => $draw->id, + 'currency_code' => 'NPR', + 'total_bet_amount' => 100, + 'total_rebate_amount' => 0, + 'total_actual_deduct' => 100, + 'total_estimated_payout' => 500, + 'status' => 'settled', + 'submit_source' => 'h5', + 'client_trace_id' => 'settlement-adjust-trace', + ]); + + $item = TicketItem::query()->create([ + 'ticket_no' => 'TK-SETTLE-ADJUST-1', + 'order_id' => $order->id, + 'player_id' => $player->id, + 'draw_id' => $draw->id, + 'original_number' => '1234', + 'normalized_number' => '1234', + 'play_code' => 'big', + 'dimension' => 4, + 'digit_slot' => null, + 'bet_mode' => 'straight', + 'unit_bet_amount' => 100, + 'total_bet_amount' => 100, + 'rebate_rate_snapshot' => 0, + 'commission_rate_snapshot' => 0, + 'actual_deduct_amount' => 100, + 'odds_snapshot_json' => [], + 'rule_snapshot_json' => [], + 'combination_count' => 1, + 'estimated_max_payout' => 500, + 'risk_locked_amount' => 0, + 'status' => 'settled_win', + 'fail_reason_code' => null, + 'fail_reason_text' => null, + 'win_amount' => 500, + 'jackpot_win_amount' => 0, + 'settled_at' => now()->subMinutes(5), + ]); + + $resultBatch = DrawResultBatch::query()->create([ + 'draw_id' => $draw->id, + 'result_version' => 1, + 'source_type' => 'manual', + 'rng_seed_hash' => null, + 'raw_seed_encrypted' => null, + 'status' => 'published', + 'created_by' => $managerId, + 'confirmed_by' => $managerId, + 'confirmed_at' => now()->subMinutes(9), + ]); + + $batch = SettlementBatch::query()->create([ + 'draw_id' => $draw->id, + 'result_batch_id' => $resultBatch->id, + 'settle_version' => 1, + 'status' => 'paid', + 'total_ticket_count' => 1, + 'total_win_count' => 1, + 'total_payout_amount' => 500, + 'total_jackpot_payout_amount' => 0, + 'review_status' => 'approved', + 'reviewed_by' => $managerId, + 'reviewed_at' => now()->subMinutes(6), + 'review_remark' => 'approved', + 'paid_at' => now()->subMinutes(5), + 'started_at' => now()->subMinutes(8), + 'finished_at' => now()->subMinutes(7), + ]); + + TicketSettlementDetail::query()->create([ + 'settlement_batch_id' => $batch->id, + 'ticket_item_id' => $item->id, + 'matched_prize_tier' => '1st', + 'win_amount' => 500, + 'jackpot_allocation_amount' => 0, + 'match_detail_json' => ['numbers' => ['1234']], + ]); + + $this->withHeader('Authorization', 'Bearer '.$token) + ->postJson("/api/v1/admin/settlement-batches/{$batch->id}/adjustments", [ + 'player_id' => $player->id, + 'amount_delta' => 120, + 'reason' => 'manual payout correction', + ]) + ->assertOk() + ->assertJsonPath('data.batch_id', $batch->id) + ->assertJsonPath('data.player_id', $player->id) + ->assertJsonPath('data.amount_delta', 120) + ->assertJsonPath('data.direction', 'credit'); + + $wallet->refresh(); + expect((int) $wallet->balance)->toBe(1_120) + ->and(WalletTxn::query()->where('biz_type', 'settlement_adjustment')->count())->toBe(1) + ->and(AuditLog::query()->where('module_code', 'settlement')->where('action_code', 'payout_adjustment')->count())->toBe(1); +}); + +test('admin settlement payout adjustment rejects player outside batch', function (): void { + $token = settlementPayoutManagerToken(); + $managerId = (int) AdminUser::query()->where('username', 'settlement_manager')->value('id'); + + $draw = Draw::query()->create([ + 'draw_no' => '20260603-002', + 'business_date' => '2026-06-03', + 'sequence_no' => 2, + 'status' => DrawStatus::Settled->value, + 'start_time' => now()->subHour(), + 'close_time' => now()->subMinutes(30), + 'draw_time' => now()->subMinutes(20), + 'cooling_end_time' => now()->subMinutes(10), + 'result_source' => 'manual', + 'current_result_version' => 1, + 'settle_version' => 1, + 'is_reopened' => false, + ]); + + $resultBatch = DrawResultBatch::query()->create([ + 'draw_id' => $draw->id, + 'result_version' => 1, + 'source_type' => 'manual', + 'rng_seed_hash' => null, + 'raw_seed_encrypted' => null, + 'status' => 'published', + 'created_by' => $managerId, + 'confirmed_by' => $managerId, + 'confirmed_at' => now()->subMinutes(9), + ]); + + $batch = SettlementBatch::query()->create([ + 'draw_id' => $draw->id, + 'result_batch_id' => $resultBatch->id, + 'settle_version' => 1, + 'status' => 'paid', + 'total_ticket_count' => 0, + 'total_win_count' => 0, + 'total_payout_amount' => 0, + 'total_jackpot_payout_amount' => 0, + 'review_status' => 'approved', + 'reviewed_by' => $managerId, + 'reviewed_at' => now()->subMinutes(6), + 'review_remark' => 'approved', + 'paid_at' => now()->subMinutes(5), + 'started_at' => now()->subMinutes(8), + 'finished_at' => now()->subMinutes(7), + ]); + + $player = Player::query()->create([ + 'site_code' => 'main', + 'site_player_id' => 'not-in-batch', + 'username' => null, + 'nickname' => null, + 'default_currency' => 'NPR', + 'status' => 0, + ]); + + $this->withHeader('Authorization', 'Bearer '.$token) + ->postJson("/api/v1/admin/settlement-batches/{$batch->id}/adjustments", [ + 'player_id' => $player->id, + 'amount_delta' => 80, + 'reason' => 'should reject', + ]) + ->assertStatus(422); + + expect(WalletTxn::query()->where('biz_type', 'settlement_adjustment')->count())->toBe(0); +}); diff --git a/tests/Feature/AdminWalletApiTest.php b/tests/Feature/AdminWalletApiTest.php index 8cbf466..33c1bf1 100644 --- a/tests/Feature/AdminWalletApiTest.php +++ b/tests/Feature/AdminWalletApiTest.php @@ -6,6 +6,8 @@ use App\Models\WalletTxn; use App\Lottery\ErrorCode; use App\Models\PlayerWallet; use App\Models\TransferOrder; +use App\Services\Wallet\MainSiteWalletResult; +use App\Services\Wallet\MainSiteWalletGateway; use App\Services\Wallet\LotteryTransferService; use Illuminate\Support\Facades\Hash; use Illuminate\Foundation\Testing\RefreshDatabase; @@ -127,6 +129,7 @@ test('admin transfer order list exposes available reconcile actions by status', ['TI_processing', 'processing'], ['TI_failed', 'failed'], ['TI_wait', 'pending_reconcile'], + ['TI_credit_failed', 'pending_reconcile'], ['TI_done', 'success'], ] as [$no, $st] ) { @@ -140,8 +143,8 @@ test('admin transfer order list exposes available reconcile actions by status', 'status' => $st, 'external_request_payload' => null, 'external_response_payload' => null, - 'external_ref_no' => null, - 'fail_reason' => null, + 'external_ref_no' => $no === 'TI_credit_failed' ? 'main-ref-credit-failed' : null, + 'fail_reason' => $no === 'TI_credit_failed' ? 'lottery_credit_failed' : null, 'finished_at' => $st === 'success' ? now() : null, ]); } @@ -157,9 +160,12 @@ test('admin transfer order list exposes available reconcile actions by status', ->and($byNo['TI_processing']['can_manually_process'])->toBeTrue() ->and($byNo['TI_failed']['can_reverse'])->toBeFalse() ->and($byNo['TI_failed']['can_manually_process'])->toBeTrue() - ->and($byNo['TI_wait']['can_reverse'])->toBeTrue() + ->and($byNo['TI_wait']['can_reverse'])->toBeFalse() ->and($byNo['TI_wait']['can_manually_process'])->toBeTrue() ->and($byNo['TI_wait']['can_complete_credit'])->toBeFalse() + ->and($byNo['TI_credit_failed']['can_reverse'])->toBeTrue() + ->and($byNo['TI_credit_failed']['can_manually_process'])->toBeFalse() + ->and($byNo['TI_credit_failed']['can_complete_credit'])->toBeTrue() ->and($byNo['TI_done']['can_reverse'])->toBeFalse() ->and($byNo['TI_done']['can_manually_process'])->toBeFalse(); }); @@ -194,7 +200,7 @@ test('admin can manually process abnormal transfer orders except completed ones' 'external_request_payload' => null, 'external_response_payload' => null, 'external_ref_no' => null, - 'fail_reason' => null, + 'fail_reason' => $no === 'TI_failed_manual' ? 'lottery_credit_failed' : null, 'finished_at' => $st === 'success' ? now() : null, ]); } @@ -206,14 +212,80 @@ test('admin can manually process abnormal transfer orders except completed ones' $this->withHeader('Authorization', 'Bearer '.$token) ->postJson('/api/v1/admin/wallet/transfer-orders/TI_failed_manual/manually-process') - ->assertOk() - ->assertJsonPath('data.status', 'manually_processed'); + ->assertStatus(422) + ->assertJsonPath('code', ErrorCode::WalletExternalRejected->value); $this->withHeader('Authorization', 'Bearer '.$token) ->postJson('/api/v1/admin/wallet/transfer-orders/TI_success_manual/manually-process') ->assertStatus(422); }); +test('admin can reverse transfer in credit-failed pending reconcile order and refund main site once', function (): void { + $token = makeAdminToken(); + $this->app->instance(MainSiteWalletGateway::class, new class implements MainSiteWalletGateway + { + public function debitMainForLotteryDeposit(Player $player, string $currencyCode, int $amountMinor, string $idempotentKey): MainSiteWalletResult + { + return MainSiteWalletResult::failure('not_used'); + } + + public function creditMainForLotteryWithdraw(Player $player, string $currencyCode, int $amountMinor, string $idempotentKey): MainSiteWalletResult + { + return MainSiteWalletResult::failure('not_used'); + } + + public function refundMainForFailedLotteryDeposit(Player $player, string $currencyCode, int $amountMinor, string $idempotentKey): MainSiteWalletResult + { + return MainSiteWalletResult::success( + 'main-refund-ref-1', + ['success' => true, 'mock' => true], + ['mock' => true, 'idempotent_key' => $idempotentKey], + ); + } + }); + + $player = Player::query()->create([ + 'site_code' => 'stub-refund-site', + 'site_player_id' => 'reverse-credit-failed-player', + 'username' => null, + 'nickname' => null, + 'default_currency' => 'NPR', + 'status' => 0, + ]); + + TransferOrder::query()->create([ + 'transfer_no' => 'TI_reverse_credit_failed', + 'player_id' => $player->id, + 'direction' => 'in', + 'currency_code' => 'NPR', + 'amount' => 350, + 'idempotent_key' => 'reverse-credit-failed-key', + 'status' => 'pending_reconcile', + 'external_request_payload' => ['kind' => 'deposit'], + 'external_response_payload' => ['kind' => 'timeout'], + 'external_ref_no' => 'main-debit-ref-1', + 'fail_reason' => 'lottery_credit_failed', + 'finished_at' => null, + ]); + + $path = '/api/v1/admin/wallet/transfer-orders/TI_reverse_credit_failed/reverse'; + + $this->withHeader('Authorization', 'Bearer '.$token) + ->postJson($path, ['remark' => 'refund main site']) + ->assertOk() + ->assertJsonPath('data.status', 'reversed'); + + $this->withHeader('Authorization', 'Bearer '.$token) + ->postJson($path, ['remark' => 'refund main site again']) + ->assertOk() + ->assertJsonPath('data.status', 'reversed'); + + $order = TransferOrder::query()->where('transfer_no', 'TI_reverse_credit_failed')->firstOrFail(); + expect($order->status)->toBe('reversed') + ->and($order->external_ref_no)->toBe('main-refund-ref-1') + ->and(data_get($order->external_request_payload, 'mock'))->toBeTrue(); +}); + test('admin lists wallet transactions and filters abnormal', function (): void { $token = makeAdminToken(); diff --git a/tests/Feature/AuditLoggerTest.php b/tests/Feature/AuditLoggerTest.php index aa9479b..e6a1ff2 100644 --- a/tests/Feature/AuditLoggerTest.php +++ b/tests/Feature/AuditLoggerTest.php @@ -3,6 +3,7 @@ use App\Models\AuditLog; use Illuminate\Http\Request; use App\Services\AuditLogger; +use App\Http\Middleware\RecordAdminApiAudit; use Illuminate\Foundation\Testing\RefreshDatabase; uses(RefreshDatabase::class); @@ -65,6 +66,8 @@ test('audit logger record from request fills ip', function (): void { expect($row) ->ip->toContain('198.51.100.2') ->user_agent->toBe('TestAgent'); + + expect($request->attributes->get(RecordAdminApiAudit::ATTRIBUTE_AUDIT_RECORDED))->toBeTrue(); }); test('record for system uses operator zero', function (): void { diff --git a/tests/Feature/DrawResultsApiTest.php b/tests/Feature/DrawResultsApiTest.php index 44e0b96..483aaa9 100644 --- a/tests/Feature/DrawResultsApiTest.php +++ b/tests/Feature/DrawResultsApiTest.php @@ -69,7 +69,7 @@ test('draw results index returns published draws with PRD shaped results', funct ]); } - $this->getJson('/api/v1/draw/results?per_page=5') + $this->getJson('/api/v1/draw/results?size=5') ->assertOk() ->assertJsonPath('code', 0) ->assertJsonPath('data.items.0.draw_no', '20260509-111')