diff --git a/.env.example b/.env.example index 8459998..ea2ccf0 100644 --- a/.env.example +++ b/.env.example @@ -33,13 +33,16 @@ REVERB_SERVER_HOST=0.0.0.0 REVERB_HOST=localhost REVERB_PORT=8080 REVERB_SCHEME=http +# WebSocket Origin 白名单,逗号分隔;支持域名、URL、*.example.com,禁止 * +REVERB_ALLOWED_ORIGINS=localhost,127.0.0.1 QUEUE_CONNECTION=redis CACHE_STORE=redis LOTTERY_RISK_POOL_USE_REDIS_LUA=true -# 生产建议独立配置;留空则回落 MAIN_SITE_SSO_JWT_SECRET(勿在生产与 SSO 混用) -# LOTTERY_NATIVE_JWT_SECRET= +# 原生玩家登录必须显式配置;须与 MAIN_SITE_SSO_JWT_SECRET 及所有站点保存的 SSO 密钥不同(包括停用站点) +# 可用 openssl rand -base64 48 生成;留空时原生登录与已签发原生 Token 验证均返回 503 +LOTTERY_NATIVE_JWT_SECRET= # 预发可设为 false,禁止代理账期关账 AGENT_SETTLEMENT_ALLOW_PRODUCTION_CLOSE=true diff --git a/README.md b/README.md index 0440306..cf000f3 100644 --- a/README.md +++ b/README.md @@ -139,6 +139,8 @@ php artisan queue:work --tries=3 --timeout=120 --sleep=1 - `REVERB_HOST`:浏览器连接 Reverb 时看到的主机名或 IP - `SANCTUM_STATEFUL_DOMAINS`:允许带 Cookie 的前端来源列表 +原生玩家账号登录必须显式配置 `LOTTERY_NATIVE_JWT_SECRET`。该密钥必须是独立随机值,不得与 legacy `MAIN_SITE_SSO_JWT_SECRET` 或任一 `admin_sites` 站点保存的 SSO 密钥相同(包括停用站点);缺失或冲突时,原生 Token 签发与验证都会返回 HTTP 503。生成新密钥可使用 `openssl rand -base64 48`。修改后执行 `php artisan config:cache`,并重启 HTTP、queue、scheduler 与 Reverb 等常驻进程,确保签发和验证使用同一个新值。 + ## 生产性能基线 为避免调度锁、大厅快照缓存与业务表争抢同一数据库,生产环境请至少满足: diff --git a/app/Contracts/WalletApiDnsResolver.php b/app/Contracts/WalletApiDnsResolver.php new file mode 100644 index 0000000..3163968 --- /dev/null +++ b/app/Contracts/WalletApiDnsResolver.php @@ -0,0 +1,9 @@ + */ + public function resolveAll(string $hostname): array; +} diff --git a/app/Events/BalanceUpdateBroadcast.php b/app/Events/BalanceUpdateBroadcast.php index 7edae00..ab41bfd 100644 --- a/app/Events/BalanceUpdateBroadcast.php +++ b/app/Events/BalanceUpdateBroadcast.php @@ -2,8 +2,8 @@ namespace App\Events; -use Illuminate\Broadcasting\Channel; use Illuminate\Queue\SerializesModels; +use Illuminate\Broadcasting\PrivateChannel; use Illuminate\Foundation\Events\Dispatchable; use Illuminate\Broadcasting\InteractsWithSockets; use Illuminate\Contracts\Broadcasting\ShouldBroadcast; @@ -53,11 +53,11 @@ final class BalanceUpdateBroadcast implements ShouldBroadcast /** * 使用私有频道,只有指定玩家能收到自己的余额变动。 * - * @return array + * @return array */ public function broadcastOn(): array { - return [new Channel('player.'.$this->playerId)]; + return [new PrivateChannel('player.'.$this->playerId)]; } public function broadcastAs(): string diff --git a/app/Http/Controllers/Api/V1/Admin/Reports/ReportJobDownloadController.php b/app/Http/Controllers/Api/V1/Admin/Reports/ReportJobDownloadController.php index 8091034..55c6085 100644 --- a/app/Http/Controllers/Api/V1/Admin/Reports/ReportJobDownloadController.php +++ b/app/Http/Controllers/Api/V1/Admin/Reports/ReportJobDownloadController.php @@ -2,8 +2,9 @@ namespace App\Http\Controllers\Api\V1\Admin\Reports; -use App\Models\AdminUser; use App\Models\ReportJob; +use Illuminate\Http\Request; +use App\Support\AdminReportJobPolicy; use App\Services\Admin\AdminReportJobService; use App\Services\Admin\AdminReportQueryService; use App\Services\Admin\AdminReportSpreadsheetExporter; @@ -13,11 +14,20 @@ use Symfony\Component\HttpFoundation\StreamedResponse; final class ReportJobDownloadController { public function __invoke( + Request $request, ReportJob $report_job, AdminReportJobService $service, AdminReportQueryService $queryService, AdminReportSpreadsheetExporter $spreadsheetExporter, ): StreamedResponse { + $admin = $request->lotteryAdmin(); + abort_if($admin === null, 401); + abort_unless(AdminReportJobPolicy::jobAccessible($admin, $report_job), 403); + abort_unless( + AdminReportJobPolicy::canExportReportType($admin, (string) $report_job->report_type), + 403, + ); + $filterJson = is_array($report_job->filter_json) ? $report_job->filter_json : null; $range = $queryService->resolveDateRange($filterJson); $dateFrom = $range['date_from']; @@ -30,8 +40,7 @@ final class ReportJobDownloadController $dateTo, ); $filename = $label.'_'.$pathSuffix.'.'.$report_job->export_format; - $scopedAdmin = AdminUser::query()->find((int) $report_job->admin_user_id); - $rows = $service->reportRows((string) $report_job->report_type, $filterJson, $scopedAdmin); + $rows = $service->reportRows((string) $report_job->report_type, $filterJson, $admin); if ((string) $report_job->export_format === 'xlsx') { return $spreadsheetExporter->streamDownload($rows, $filename); diff --git a/app/Http/Controllers/Api/V1/Admin/Reports/ReportJobIndexController.php b/app/Http/Controllers/Api/V1/Admin/Reports/ReportJobIndexController.php index ad77fce..2ad6ef5 100644 --- a/app/Http/Controllers/Api/V1/Admin/Reports/ReportJobIndexController.php +++ b/app/Http/Controllers/Api/V1/Admin/Reports/ReportJobIndexController.php @@ -2,17 +2,21 @@ namespace App\Http\Controllers\Api\V1\Admin\Reports; -use App\Http\Controllers\Controller; use App\Models\ReportJob; +use Illuminate\Http\Request; use App\Support\AdminApiList; use Illuminate\Http\JsonResponse; -use Illuminate\Http\Request; +use App\Http\Controllers\Controller; +use App\Support\AdminReportJobPolicy; /** GET /api/v1/admin/report-jobs */ final class ReportJobIndexController extends Controller { public function __invoke(Request $request): JsonResponse { + $admin = $request->lotteryAdmin(); + abort_if($admin === null, 401); + $p = AdminApiList::readPaging($request); $reportType = trim((string) $request->query('report_type', '')); @@ -23,6 +27,8 @@ final class ReportJobIndexController extends Controller $query->where('report_type', $reportType); } + AdminReportJobPolicy::applyToJobsQuery($query, $admin); + $paginator = $query->paginate($p['perPage'], ['*'], 'page', $p['page']); return AdminApiList::json($paginator, fn (ReportJob $j) => $this->row($j)); diff --git a/app/Http/Controllers/Api/V1/Admin/Reports/ReportJobShowController.php b/app/Http/Controllers/Api/V1/Admin/Reports/ReportJobShowController.php index 59b412e..e3a185e 100644 --- a/app/Http/Controllers/Api/V1/Admin/Reports/ReportJobShowController.php +++ b/app/Http/Controllers/Api/V1/Admin/Reports/ReportJobShowController.php @@ -2,16 +2,22 @@ namespace App\Http\Controllers\Api\V1\Admin\Reports; -use App\Http\Controllers\Controller; use App\Models\ReportJob; use App\Support\ApiResponse; +use Illuminate\Http\Request; use Illuminate\Http\JsonResponse; +use App\Http\Controllers\Controller; +use App\Support\AdminReportJobPolicy; /** GET /api/v1/admin/report-jobs/{report_job} */ final class ReportJobShowController extends Controller { - public function __invoke(ReportJob $report_job): JsonResponse + public function __invoke(Request $request, ReportJob $report_job): JsonResponse { + $admin = $request->lotteryAdmin(); + abort_if($admin === null, 401); + abort_unless(AdminReportJobPolicy::jobAccessible($admin, $report_job), 403); + return ApiResponse::success([ 'id' => (int) $report_job->id, 'job_no' => $report_job->job_no, diff --git a/app/Http/Controllers/Api/V1/Admin/Reports/ReportJobStoreController.php b/app/Http/Controllers/Api/V1/Admin/Reports/ReportJobStoreController.php index 030ec16..ebe4291 100644 --- a/app/Http/Controllers/Api/V1/Admin/Reports/ReportJobStoreController.php +++ b/app/Http/Controllers/Api/V1/Admin/Reports/ReportJobStoreController.php @@ -2,12 +2,13 @@ namespace App\Http\Controllers\Api\V1\Admin\Reports; -use App\Http\Controllers\Controller; -use App\Http\Requests\Admin\ReportJobStoreRequest; use App\Models\AdminUser; -use App\Services\Admin\AdminReportJobService; use App\Support\ApiResponse; use Illuminate\Http\JsonResponse; +use App\Http\Controllers\Controller; +use App\Support\AdminReportJobPolicy; +use App\Services\Admin\AdminReportJobService; +use App\Http\Requests\Admin\ReportJobStoreRequest; /** POST /api/v1/admin/report-jobs */ final class ReportJobStoreController extends Controller @@ -18,6 +19,10 @@ final class ReportJobStoreController extends Controller $admin = $request->lotteryAdmin(); $data = $request->validated(); + abort_unless( + AdminReportJobPolicy::canExportReportType($admin, (string) $data['report_type']), + 403, + ); $job = $service->enqueue( $admin, @@ -35,4 +40,4 @@ final class ReportJobStoreController extends Controller 'status' => $job->status, ]); } -} \ No newline at end of file +} diff --git a/app/Http/Middleware/EnsurePlayerApi.php b/app/Http/Middleware/EnsurePlayerApi.php index 2a09975..a939de2 100644 --- a/app/Http/Middleware/EnsurePlayerApi.php +++ b/app/Http/Middleware/EnsurePlayerApi.php @@ -38,6 +38,8 @@ final class EnsurePlayerApi // 使用 attributes,避免与 Laravel 内置 input 混淆 $request->attributes->set('lottery_player', $player); + // 广播私有频道授权使用 Request::user() 读取当前玩家。 + $request->setUserResolver(static fn () => $player); return $next($request); } diff --git a/app/Lottery/ErrorCode.php b/app/Lottery/ErrorCode.php index 414e14f..27c1932 100644 --- a/app/Lottery/ErrorCode.php +++ b/app/Lottery/ErrorCode.php @@ -109,7 +109,7 @@ enum ErrorCode: int /** 库中无对应玩家(未建档) */ case PlayerNotRegistered = 8003; - /** 未配置 `MAIN_SITE_SSO_JWT_SECRET`(通常 HTTP 503) */ + /** SSO / 原生 JWT 密钥缺失或存在不安全的共用配置(通常 HTTP 503) */ case PlayerSsoSecretNotConfigured = 8004; /** 账号已冻结或禁止登录(status ≠ active) */ diff --git a/app/Models/AdminUser.php b/app/Models/AdminUser.php index 37503e3..7193e11 100644 --- a/app/Models/AdminUser.php +++ b/app/Models/AdminUser.php @@ -3,16 +3,16 @@ namespace App\Models; use Laravel\Sanctum\HasApiTokens; +use App\Support\AgentPlatformRole; +use App\Support\SuperAdminAccount; use Illuminate\Support\Facades\DB; +use App\Support\PlatformSystemRoles; use App\Support\AdminPermissionBridge; -use App\Support\AgentProfileCapabilityFilter; -use App\Models\AdminRole; -use App\Models\AgentNode; -use App\Models\AgentProfile; use Illuminate\Notifications\Notifiable; +use App\Support\AgentProfileCapabilityFilter; +use Illuminate\Validation\ValidationException; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Database\Eloquent\Relations\BelongsToMany; -use Illuminate\Validation\ValidationException; final class AdminUser extends Authenticatable { @@ -139,10 +139,10 @@ final class AdminUser extends Authenticatable }); } - /** 经营代理主账号:仅平台角色 slug=agent(见 {@see \App\Support\AgentPlatformRole})。 */ + /** 经营代理主账号:仅平台角色 slug=agent(见 {@see AgentPlatformRole})。 */ public function syncPrimaryPlatformAgentRole(int $agentNodeId): void { - $this->syncAgentRoleIds($agentNodeId, [\App\Support\AgentPlatformRole::id()]); + $this->syncAgentRoleIds($agentNodeId, [AgentPlatformRole::id()]); } /** @@ -250,8 +250,8 @@ final class AdminUser extends Authenticatable public function syncSystemRoleSlugsForSite(int $siteId, array $slugs): void { $slugs = array_values(array_unique($slugs)); - \App\Support\SuperAdminAccount::assertNotSiteRoleAssignment($slugs); - if (in_array(\App\Support\PlatformSystemRoles::SLUG_AGENT, $slugs, true)) { + SuperAdminAccount::assertNotSiteRoleAssignment($slugs); + if (in_array(PlatformSystemRoles::SLUG_AGENT, $slugs, true)) { throw ValidationException::withMessages([ 'role_slugs' => [trans('admin.agent_role_not_assignable_to_platform_account')], ]); diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 68d086b..bc66c3b 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -5,11 +5,13 @@ namespace App\Providers; use App\Models\Player; use App\Models\AdminUser; use Illuminate\Http\Request; +use App\Contracts\WalletApiDnsResolver; use Illuminate\Support\ServiceProvider; use Illuminate\Cache\RateLimiting\Limit; use Illuminate\Support\Facades\RateLimiter; use App\Services\Wallet\MainSiteWalletGateway; use App\Services\Wallet\HttpMainSiteWalletGateway; +use App\Support\Integration\SystemWalletApiDnsResolver; final class AppServiceProvider extends ServiceProvider { @@ -19,6 +21,7 @@ final class AppServiceProvider extends ServiceProvider public function register(): void { $this->app->singleton(MainSiteWalletGateway::class, HttpMainSiteWalletGateway::class); + $this->app->singleton(WalletApiDnsResolver::class, SystemWalletApiDnsResolver::class); } /** diff --git a/app/Services/Admin/AdminReportJobService.php b/app/Services/Admin/AdminReportJobService.php index a084f4d..72c26d4 100644 --- a/app/Services/Admin/AdminReportJobService.php +++ b/app/Services/Admin/AdminReportJobService.php @@ -4,10 +4,12 @@ namespace App\Services\Admin; use App\Models\AdminUser; use App\Models\ReportJob; -use App\Services\AuditLogger; -use Illuminate\Http\Request; -use Illuminate\Support\Facades\DB; use Illuminate\Support\Str; +use Illuminate\Http\Request; +use App\Services\AuditLogger; +use Illuminate\Support\Facades\DB; +use App\Support\AdminReportJobPolicy; +use Illuminate\Auth\Access\AuthorizationException; /** * 报表导出任务:落库 `report_jobs`(同步生成,可后续接队列)。 @@ -68,9 +70,13 @@ final class AdminReportJobService /** * @return list> */ - public function reportRows(string $reportType, ?array $filterJson, ?AdminUser $scopedAdmin = null): array + public function reportRows(string $reportType, ?array $filterJson, AdminUser $admin): array { - return $this->queryService->reportRows($reportType, $filterJson, $scopedAdmin); + if (! AdminReportJobPolicy::canExportReportType($admin, $reportType)) { + throw new AuthorizationException; + } + + return $this->queryService->reportRows($reportType, $filterJson, $admin); } public function reportLabel(string $reportType): string diff --git a/app/Services/Admin/AdminReportQueryService.php b/app/Services/Admin/AdminReportQueryService.php index 4200ebf..5d40cc0 100644 --- a/app/Services/Admin/AdminReportQueryService.php +++ b/app/Services/Admin/AdminReportQueryService.php @@ -2,23 +2,26 @@ 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 Carbon\Carbon; use App\Models\Draw; +use App\Models\AuditLog; use App\Models\RiskPool; -use App\Models\RiskPoolLockLog; -use App\Models\SettlementBatch; +use App\Models\AdminUser; +use App\Models\WalletTxn; use App\Models\TicketItem; use App\Models\TicketOrder; use App\Models\TransferOrder; -use App\Models\WalletTxn; -use Carbon\Carbon; +use App\Services\AuditLogger; +use App\Support\LimitedQuery; +use App\Models\RiskPoolLockLog; +use App\Models\SettlementBatch; +use App\Support\AdminDataScope; +use App\Support\AdminScopeContext; +use Illuminate\Support\Facades\DB; +use Illuminate\Database\Query\Builder; +use App\Support\AdminScopeContextResolver; use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Pagination\LengthAwarePaginator as PaginatorInstance; -use Illuminate\Support\Facades\DB; /** * 报表中心聚合查询(模块十三)。 @@ -498,7 +501,7 @@ final class AdminReportQueryService ->whereDate('d.business_date', '>=', $dateFrom) ->whereDate('d.business_date', '<=', $dateTo) ->selectRaw($providerCodeSql.' as provider_code') - ->selectRaw("COALESCE(NULLIF(MAX(ti.provider_name), ''), MAX(bp.name), ".$providerCodeSql.") as provider_name") + ->selectRaw("COALESCE(NULLIF(MAX(ti.provider_name), ''), MAX(bp.name), ".$providerCodeSql.') as provider_name') ->selectRaw('COUNT(ti.id) as ticket_item_count') ->selectRaw('SUM(ti.actual_deduct_amount) as total_bet_minor') ->selectRaw('SUM(ti.win_amount + ti.jackpot_win_amount) as total_payout_minor') @@ -514,7 +517,7 @@ final class AdminReportQueryService /** * @return list> */ - public function reportRows(string $reportType, ?array $filterJson, AdminUser|AdminScopeContext|null $scope = null): array + public function reportRows(string $reportType, ?array $filterJson, AdminUser|AdminScopeContext $scope): array { $range = $this->resolveDateRange($filterJson); $dateFrom = $range['date_from']; @@ -526,7 +529,7 @@ final class AdminReportQueryService 'player_win_loss' => $this->playerWinLossExportRows($filterJson, $dateFrom, $dateTo, $scope), 'play_dimension_report' => $this->playDimensionExportRows($filterJson, $dateFrom, $dateTo, $scope), 'provider_profit_report' => $this->providerProfitExportRows($dateFrom, $dateTo, $scope), - 'audit_operation_report' => $this->auditExportRows($filterJson, $dateFrom, $dateTo), + 'audit_operation_report' => $this->auditExportRows($filterJson, $dateFrom, $dateTo, $scope), 'wallet_transfer_report', 'transfer_orders_daily' => $this->transferOrdersExportRows($filterJson, $dateFrom, $dateTo, $scope), 'wallet_txns_daily' => $this->walletTxnsExportRows($filterJson, $dateFrom, $dateTo, $scope), 'hot_number_risk_report' => $this->hotNumberRiskExportRows($filterJson), @@ -645,27 +648,38 @@ final class AdminReportQueryService foreach ($this->providerProfitPaginated($dateFrom, $dateTo, 1, 10_000, $scope)->items() as $row) { $rows[] = [(string) $row->provider_code, (string) $row->provider_name, (int) $row->ticket_item_count, (int) $row->total_bet_minor, (int) $row->total_payout_minor, (int) $row->approx_house_gross_minor]; } + return $rows; } /** * @return list> */ - private function auditExportRows(?array $filterJson, string $dateFrom, string $dateTo): array - { + private function auditExportRows( + ?array $filterJson, + string $dateFrom, + string $dateTo, + AdminUser|AdminScopeContext $scope, + ): array { + $admin = $scope instanceof AdminScopeContext ? $scope->admin : $scope; $operatorId = isset($filterJson['operator_id']) ? (int) $filterJson['operator_id'] : null; $rows = [ ['ID', '操作者类型', '操作者ID', '模块', '操作', 'IP', '时间'], ]; $q = AuditLog::query()->orderByDesc('id'); + if (! $admin->isSuperAdmin()) { + // audit_logs 没有可靠的站点快照;普通审计账号仅能导出自己的后台操作。 + $q->where('operator_type', AuditLogger::OPERATOR_ADMIN) + ->where('operator_id', (int) $admin->getKey()); + } if ($operatorId !== null && $operatorId > 0) { $q->where('operator_id', $operatorId); } $q->whereDate('created_at', '>=', $dateFrom) ->whereDate('created_at', '<=', $dateTo); - $limited = \App\Support\LimitedQuery::get($q, 5000); + $limited = LimitedQuery::get($q, 5000); foreach ($limited['rows'] as $log) { $rows[] = [ (int) $log->id, @@ -685,7 +699,7 @@ final class AdminReportQueryService return $rows; } - /** @return \Illuminate\Database\Query\Builder */ + /** @return Builder */ private function playerWinLossBaseQuery( ?int $playerId, string $dateFrom, @@ -725,7 +739,7 @@ final class AdminReportQueryService return $query; } - /** @return \Illuminate\Database\Query\Builder */ + /** @return Builder */ private function playDimensionBaseQuery(?string $playCode, string $dateFrom, string $dateTo, AdminUser|AdminScopeContext|null $scope = null) { $context = $this->normalizeScope($scope); @@ -755,9 +769,9 @@ final class AdminReportQueryService /** * @return list> */ - private function drawProfitExportRows(?array $filterJson, AdminUser|AdminScopeContext|null $scope = null): array + private function drawProfitExportRows(?array $filterJson, AdminUser|AdminScopeContext $scope): array { - $context = $this->normalizeScope($scope); + $admin = $scope instanceof AdminScopeContext ? $scope->admin : $scope; $draw = $this->resolveDrawForReport($filterJson); if ($draw === null) { return [['提示', '请提供 draw_id 或 draw_no']]; @@ -766,10 +780,8 @@ final class AdminReportQueryService $drawId = (int) $draw->id; $orderQuery = TicketOrder::query()->where('draw_id', $drawId); $itemQuery = TicketItem::query()->where('draw_id', $drawId); - if ($context !== null) { - AdminDataScope::applyEloquentViaPlayer($orderQuery, $context->admin); - AdminDataScope::applyEloquentViaPlayer($itemQuery, $context->admin); - } + AdminDataScope::applyEloquentViaPlayer($orderQuery, $admin); + AdminDataScope::applyEloquentViaPlayer($itemQuery, $admin); $totalBetMinor = (int) $orderQuery->sum('total_actual_deduct'); $orderCount = (int) $orderQuery->count(); @@ -805,11 +817,13 @@ final class AdminReportQueryService ], ]; - $batches = SettlementBatch::query() - ->where('draw_id', $drawId) - ->orderByDesc('id') - ->limit(100) - ->get(); + $batches = $admin->isSuperAdmin() + ? SettlementBatch::query() + ->where('draw_id', $drawId) + ->orderByDesc('id') + ->limit(100) + ->get() + : collect(); foreach ($batches as $batch) { $rows[] = [ diff --git a/app/Services/AgentSettlement/AgentGameSettlementRecorder.php b/app/Services/AgentSettlement/AgentGameSettlementRecorder.php index 31e46df..deb008e 100644 --- a/app/Services/AgentSettlement/AgentGameSettlementRecorder.php +++ b/app/Services/AgentSettlement/AgentGameSettlementRecorder.php @@ -2,11 +2,10 @@ namespace App\Services\AgentSettlement; -use App\Models\Player; use App\Models\TicketItem; -use App\Services\Player\PlayerCreditService; use App\Support\PlayerFundingMode; use Illuminate\Support\Facades\DB; +use App\Services\Player\PlayerCreditService; /** * 彩票注单游戏结算侧入账:快照、占成流水、回水计提、玩家已用额度。 @@ -30,8 +29,12 @@ final class AgentGameSettlementRecorder && (int) ($player->agent_node_id ?? 0) > 0; } - public function recordForTicketItem(TicketItem $item, int $netWin, string $terminalStatus): void - { + public function recordForTicketItem( + TicketItem $item, + int $netWin, + string $terminalStatus, + int $settlementVersion = 0, + ): void { if (! $this->shouldRecord($item)) { return; } @@ -77,7 +80,7 @@ final class AgentGameSettlementRecorder $settledAt = now(); - DB::transaction(function () use ($item, $player, $snapshot, $shareSnapshot, $basicRebateRate, $gameWinLoss, $basicRebate, $result, $settledAt, $validBet, $extraRebate, $gameType): void { + DB::transaction(function () use ($item, $player, $snapshot, $shareSnapshot, $basicRebateRate, $gameWinLoss, $basicRebate, $result, $settledAt, $validBet, $extraRebate, $gameType, $settlementVersion): void { $item->forceFill([ 'agent_node_id' => $snapshot['agent_node_id'], 'share_snapshot' => $shareSnapshot, @@ -134,13 +137,13 @@ final class AgentGameSettlementRecorder $holdAmount = (int) $item->actual_deduct_amount; if ($holdAmount > 0) { - $this->playerCreditService->releaseBetHold($player, $holdAmount, $item->id); + $this->playerCreditService->releaseBetHold($player, $holdAmount, $item->id, $settlementVersion); } if ($gameWinLoss > 0) { - $this->playerCreditService->applySettledLoss($player, (int) round($gameWinLoss), $item->id); + $this->playerCreditService->applySettledLoss($player, (int) round($gameWinLoss), $item->id, $settlementVersion); } elseif ($gameWinLoss < 0) { - $this->playerCreditService->applySettledWin($player, (int) round(abs($gameWinLoss)), $item->id); + $this->playerCreditService->applySettledWin($player, (int) round(abs($gameWinLoss)), $item->id, $settlementVersion); } }); } diff --git a/app/Services/AgentSettlement/GameSettlementReversalService.php b/app/Services/AgentSettlement/GameSettlementReversalService.php index 375f25a..da2a186 100644 --- a/app/Services/AgentSettlement/GameSettlementReversalService.php +++ b/app/Services/AgentSettlement/GameSettlementReversalService.php @@ -4,10 +4,10 @@ namespace App\Services\AgentSettlement; use App\Models\Player; use App\Models\TicketItem; -use App\Services\Player\PlayerCreditService; use App\Support\PlayerFundingMode; -use Illuminate\Database\QueryException; use Illuminate\Support\Facades\DB; +use Illuminate\Database\QueryException; +use App\Services\Player\PlayerCreditService; final class GameSettlementReversalService { @@ -15,16 +15,26 @@ final class GameSettlementReversalService private readonly PlayerCreditService $playerCreditService, ) {} - public function reverseTicketItem(TicketItem $item): void + public function reverseTicketItem(TicketItem $item, int $settlementVersion = 0): void { - $ledger = DB::table('share_ledger')->where('ticket_item_id', $item->id)->whereNull('reversal_of_id')->first(); + $ledger = DB::table('share_ledger as sl') + ->where('sl.ticket_item_id', $item->id) + ->whereNull('sl.reversal_of_id') + ->whereNotExists(function ($query): void { + $query->selectRaw('1') + ->from('share_ledger as reversal') + ->whereColumn('reversal.reversal_of_id', 'sl.id'); + }) + ->orderByDesc('sl.id') + ->select('sl.*') + ->first(); if ($ledger === null) { return; } $settledAt = now(); - DB::transaction(function () use ($item, $ledger, $settledAt): void { + DB::transaction(function () use ($item, $ledger, $settledAt, $settlementVersion): void { // 幂等闸门:share_ledger.reversal_of_id 上有 partial unique 索引, // 同一原账不允许插入多条反转记录。重复调用时唯一约束冲突即视为已反转、跳过。 // 一次只让 reversal_of_id 出现一次写入,确保所有后续副作用(rebate、credit 冲正) @@ -79,8 +89,14 @@ final class GameSettlementReversalService if ($player !== null && PlayerFundingMode::usesCredit($player)) { $gameWinLoss = (int) $ledger->game_win_loss; if ($gameWinLoss !== 0) { - $this->playerCreditService->reverseGameSettlement($player, $gameWinLoss, $item->id); + $this->playerCreditService->reverseGameSettlement($player, $gameWinLoss, $item->id, $settlementVersion); } + $this->playerCreditService->restoreBetHoldAfterSettlementReversal( + $player, + (int) $item->actual_deduct_amount, + (int) $item->id, + $settlementVersion, + ); } }); } diff --git a/app/Services/Player/NativeJwtSecretGuard.php b/app/Services/Player/NativeJwtSecretGuard.php new file mode 100644 index 0000000..c24672b --- /dev/null +++ b/app/Services/Player/NativeJwtSecretGuard.php @@ -0,0 +1,71 @@ +rejectConfiguration('原生登录未配置'); + } + + $legacySsoSecret = config('lottery.main_site.sso_jwt_secret'); + if ($this->matches($secret, $legacySsoSecret)) { + $this->rejectConfiguration('原生登录密钥不得与 legacy SSO 密钥相同'); + } + + if ($this->matchesStoredSiteSsoSecret($secret)) { + $this->rejectConfiguration('原生登录密钥不得与任何站点保存的 SSO 密钥相同'); + } + + return $secret; + } + + private function matchesStoredSiteSsoSecret(string $nativeSecret): bool + { + try { + if (! Schema::hasTable('admin_sites') + || ! Schema::hasColumn('admin_sites', 'sso_jwt_secret_encrypted')) { + return false; + } + + $sites = AdminSite::query() + ->whereNotNull('sso_jwt_secret_encrypted') + ->get(['sso_jwt_secret_encrypted']); + } catch (Throwable) { + $this->rejectConfiguration('无法检查原生登录密钥是否与站点 SSO 密钥冲突'); + } + + foreach ($sites as $site) { + if ($this->matches($nativeSecret, $site->decryptedSsoJwtSecret())) { + return true; + } + } + + return false; + } + + private function matches(string $nativeSecret, mixed $candidate): bool + { + return is_string($candidate) + && $candidate !== '' + && hash_equals($nativeSecret, $candidate); + } + + private function rejectConfiguration(string $message): never + { + throw new PlayerAuthenticationException( + $message, + ErrorCode::PlayerSsoSecretNotConfigured->value, + 503, + ); + } +} diff --git a/app/Services/Player/PlayerCreditService.php b/app/Services/Player/PlayerCreditService.php index a624842..a0d0926 100644 --- a/app/Services/Player/PlayerCreditService.php +++ b/app/Services/Player/PlayerCreditService.php @@ -3,19 +3,20 @@ namespace App\Services\Player; use App\Models\Player; -use App\Services\Agent\AgentUsedCreditSyncService; use App\Support\AgentOverdueGuard; use App\Support\CreditAmountScale; use App\Support\PlayerFundingMode; -use Illuminate\Database\QueryException; use Illuminate\Support\Facades\DB; +use Illuminate\Database\QueryException; use Illuminate\Validation\ValidationException; +use App\Services\Agent\AgentUsedCreditSyncService; final class PlayerCreditService { public function __construct( private readonly AgentUsedCreditSyncService $usedCreditSync, ) {} + /** * @param array{credit_limit?: int} $payload */ @@ -163,8 +164,12 @@ final class PlayerCreditService $this->syncAgentUsedCredit($player); } - public function applySettledLoss(Player $player, int $amountMinor, int $ticketItemId): void - { + public function applySettledLoss( + Player $player, + int $amountMinor, + int $ticketItemId, + int $settlementVersion = 0, + ): void { if ($amountMinor <= 0) { return; } @@ -187,6 +192,7 @@ final class PlayerCreditService 'reason' => 'game_settlement_loss', 'ref_type' => 'ticket_item', 'ref_id' => $ticketItemId, + 'settlement_version' => $settlementVersion, 'created_at' => $now, 'updated_at' => $now, ]); @@ -218,8 +224,12 @@ final class PlayerCreditService $this->syncAgentUsedCredit($player); } - public function applySettledWin(Player $player, int $amountMinor, int $ticketItemId): void - { + public function applySettledWin( + Player $player, + int $amountMinor, + int $ticketItemId, + int $settlementVersion = 0, + ): void { if ($amountMinor <= 0) { return; } @@ -228,30 +238,55 @@ final class PlayerCreditService return; } - $now = now(); + $applied = DB::transaction(function () use ($player, $amountMinor, $ticketItemId, $settlementVersion): bool { + $now = now(); + $currency = (string) $player->default_currency; + $requestedMajor = CreditAmountScale::minorToMajor($amountMinor, $currency); + $account = DB::table('player_credit_accounts') + ->where('player_id', $player->id) + ->lockForUpdate() + ->first(); + $appliedMajor = $account === null + ? 0 + : min((int) $account->used_credit, $requestedMajor); + $appliedMinor = CreditAmountScale::majorToMinor($appliedMajor, $currency); - // 先写 credit_ledger:以 (ref_type, ref_id, reason) partial unique 索引为幂等闸门。 - // 已存在同 (ticket_item, game_settlement_win) 的流水则直接返回,避免并发/重入场景下重复扣减 used_credit。 - try { - DB::table('credit_ledger')->insert([ - 'owner_type' => 'player', - 'owner_id' => $player->id, - 'amount' => $amountMinor, - 'reason' => 'game_settlement_win', - 'ref_type' => 'ticket_item', - 'ref_id' => $ticketItemId, - 'created_at' => $now, - 'updated_at' => $now, - ]); - } catch (QueryException $e) { - if ($this->isUniqueViolation($e)) { - return; + // 流水记录实际释放的额度,而非可能超过 used_credit 的名义中奖额。 + // 结算被驳回时必须按这个实际值恢复,否则会凭空增加玩家已用额度。 + try { + DB::table('credit_ledger')->insert([ + 'owner_type' => 'player', + 'owner_id' => $player->id, + 'amount' => $appliedMinor, + 'reason' => 'game_settlement_win', + 'ref_type' => 'ticket_item', + 'ref_id' => $ticketItemId, + 'settlement_version' => $settlementVersion, + 'created_at' => $now, + 'updated_at' => $now, + ]); + } catch (QueryException $e) { + if ($this->isUniqueViolation($e)) { + return false; + } + throw $e; } - throw $e; - } - $this->decreaseUsedCredit($player, $amountMinor); - $this->syncAgentUsedCredit($player); + if ($account !== null && $appliedMajor > 0) { + DB::table('player_credit_accounts') + ->where('player_id', $player->id) + ->update([ + 'used_credit' => (int) $account->used_credit - $appliedMajor, + 'updated_at' => $now, + ]); + } + + return true; + }); + + if ($applied) { + $this->syncAgentUsedCredit($player); + } } public function assertMayPlaceBet(Player $player, int $amountMinor): void @@ -290,8 +325,12 @@ final class PlayerCreditService } } - public function releaseBetHold(Player $player, int $amountMinor, int $ticketItemId): void - { + public function releaseBetHold( + Player $player, + int $amountMinor, + int $ticketItemId, + int $settlementVersion = 0, + ): void { if ($amountMinor <= 0 || ! PlayerFundingMode::usesCredit($player)) { return; } @@ -308,6 +347,7 @@ final class PlayerCreditService 'reason' => 'bet_hold_release', 'ref_type' => 'ticket_item', 'ref_id' => $ticketItemId, + 'settlement_version' => $settlementVersion, 'created_at' => $now, 'updated_at' => $now, ]); @@ -354,14 +394,33 @@ final class PlayerCreditService $this->syncAgentUsedCredit($player); } - public function reverseGameSettlement(Player $player, int $gameWinLossSigned, int $ticketItemId): void - { + public function reverseGameSettlement( + Player $player, + int $gameWinLossSigned, + int $ticketItemId, + int $settlementVersion = 0, + ): void { if ($gameWinLossSigned === 0 || ! PlayerFundingMode::usesCredit($player)) { return; } $now = now(); $amountMinor = abs($gameWinLossSigned); + if ($gameWinLossSigned < 0) { + // 中奖释放 used_credit 时可能受 0 下限截断;反转只能恢复当时实际释放的数额。 + // 旧流水或人工修复数据可能不存在,保留名义值兜底以兼容历史记录。 + $recordedAmount = DB::table('credit_ledger') + ->where('owner_type', 'player') + ->where('owner_id', $player->id) + ->where('ref_type', 'ticket_item') + ->where('ref_id', $ticketItemId) + ->where('reason', 'game_settlement_win') + ->where('settlement_version', $settlementVersion) + ->value('amount'); + if ($recordedAmount !== null) { + $amountMinor = abs((int) $recordedAmount); + } + } // 先写 credit_ledger:以 (ref_type, ref_id, reason) partial unique 索引为幂等闸门。 try { @@ -372,6 +431,7 @@ final class PlayerCreditService 'reason' => 'game_settlement_reversal', 'ref_type' => 'ticket_item', 'ref_id' => $ticketItemId, + 'settlement_version' => $settlementVersion, 'created_at' => $now, 'updated_at' => $now, ]); @@ -410,6 +470,56 @@ final class PlayerCreditService $this->syncAgentUsedCredit($player); } + /** 驳回结算后恢复原注占额,使票回到 pending_draw 时额度状态与结算前一致。 */ + public function restoreBetHoldAfterSettlementReversal( + Player $player, + int $amountMinor, + int $ticketItemId, + int $settlementVersion = 0, + ): void { + if ($amountMinor <= 0 || ! PlayerFundingMode::usesCredit($player)) { + return; + } + + $now = now(); + try { + DB::table('credit_ledger')->insert([ + 'owner_type' => 'player', + 'owner_id' => $player->id, + 'amount' => -$amountMinor, + 'reason' => 'bet_hold_restore', + 'ref_type' => 'ticket_item', + 'ref_id' => $ticketItemId, + 'settlement_version' => $settlementVersion, + 'created_at' => $now, + 'updated_at' => $now, + ]); + } catch (QueryException $e) { + if ($this->isUniqueViolation($e)) { + return; + } + throw $e; + } + + $majorDelta = CreditAmountScale::minorToMajor($amountMinor, (string) $player->default_currency); + $row = DB::table('player_credit_accounts') + ->where('player_id', $player->id) + ->lockForUpdate() + ->first(); + if ($row === null) { + return; + } + + DB::table('player_credit_accounts') + ->where('player_id', $player->id) + ->update([ + 'used_credit' => (int) $row->used_credit + $majorDelta, + 'updated_at' => $now, + ]); + + $this->syncAgentUsedCredit($player); + } + /** * @param int $cumulativePaidMinor 账单累计已登记收付(minor),支持部分收付多笔递增。 */ diff --git a/app/Services/Player/PlayerNativeAuthService.php b/app/Services/Player/PlayerNativeAuthService.php index 9faea0f..eac1f1b 100644 --- a/app/Services/Player/PlayerNativeAuthService.php +++ b/app/Services/Player/PlayerNativeAuthService.php @@ -11,6 +11,10 @@ use App\Exceptions\PlayerAuthenticationException; final class PlayerNativeAuthService { + public function __construct( + private readonly NativeJwtSecretGuard $nativeJwtSecretGuard, + ) {} + /** * @return array{access_token: string, expires_in: int, token_type: string, player: array} */ @@ -85,14 +89,7 @@ final class PlayerNativeAuthService public function issueToken(Player $player, ?int $ttlSeconds = null): string { - $secret = (string) config('lottery.player_auth.native.secret', ''); - if ($secret === '') { - throw new PlayerAuthenticationException( - '原生登录未配置', - ErrorCode::PlayerSsoSecretNotConfigured->value, - 503, - ); - } + $secret = $this->nativeJwtSecretGuard->validatedSecret(); $ttl = $ttlSeconds ?? (int) config('lottery.player_auth.native.ttl_seconds', 28800); $now = time(); diff --git a/app/Services/PlayerTokenResolver.php b/app/Services/PlayerTokenResolver.php index 86f13a2..7b7a6f8 100644 --- a/app/Services/PlayerTokenResolver.php +++ b/app/Services/PlayerTokenResolver.php @@ -11,6 +11,7 @@ use App\Support\PlayerAuthSource; use App\Support\PlayerFundingMode; use App\Support\PlayerTokenAesUnwrap; use Illuminate\Database\QueryException; +use App\Services\Player\NativeJwtSecretGuard; use App\Support\PlayerAutoRegistrationDefaults; use App\Exceptions\PlayerAuthenticationException; use App\Services\Integration\PartnerSiteConfigResolver; @@ -42,6 +43,7 @@ final class PlayerTokenResolver public function __construct( private readonly PartnerSiteConfigResolver $partnerSiteConfigResolver, + private readonly NativeJwtSecretGuard $nativeJwtSecretGuard, ) {} public function resolve(Request $request): Player @@ -158,14 +160,7 @@ final class PlayerTokenResolver private function resolveNativeJwt(string $jwt): Player { - $secret = (string) config('lottery.player_auth.native.secret', ''); - if ($secret === '') { - throw new PlayerAuthenticationException( - '原生登录未配置', - ErrorCode::PlayerSsoSecretNotConfigured->value, - 503, - ); - } + $secret = $this->nativeJwtSecretGuard->validatedSecret(); $alg = (string) config('lottery.player_auth.jwt.algorithm', 'HS256'); @@ -256,6 +251,14 @@ final class PlayerTokenResolver } } + if ((string) $player->auth_source !== PlayerAuthSource::MAIN_SITE_SSO + || (string) $player->funding_mode !== PlayerFundingMode::WALLET) { + throw new PlayerAuthenticationException( + 'SSO 玩家映射与账号认证域冲突', + ErrorCode::PlayerTokenInvalid->value, + ); + } + if (! $player->wasRecentlyCreated) { $player->forceFill(['last_login_at' => $now])->save(); } diff --git a/app/Services/Settlement/SettlementBatchWorkflowService.php b/app/Services/Settlement/SettlementBatchWorkflowService.php index 0d5e712..8c8cf30 100644 --- a/app/Services/Settlement/SettlementBatchWorkflowService.php +++ b/app/Services/Settlement/SettlementBatchWorkflowService.php @@ -10,11 +10,11 @@ use App\Lottery\DrawStatus; use App\Models\JackpotPool; use App\Models\TicketOrder; use App\Models\SettlementBatch; +use App\Support\PlayerFundingMode; use Illuminate\Support\Facades\DB; use App\Lottery\SettlementBatchStatus; -use App\Services\AgentSettlement\GameSettlementReversalService; use App\Services\Ticket\TicketWalletService; -use App\Support\PlayerFundingMode; +use App\Services\AgentSettlement\GameSettlementReversalService; final class SettlementBatchWorkflowService { @@ -67,7 +67,7 @@ final class SettlementBatchWorkflowService if ($itemIds !== []) { $items = TicketItem::query()->whereIn('id', $itemIds)->get(); foreach ($items as $item) { - $this->gameSettlementReversal->reverseTicketItem($item); + $this->gameSettlementReversal->reverseTicketItem($item, (int) $locked->settle_version); } TicketItem::query() @@ -118,15 +118,22 @@ final class SettlementBatchWorkflowService throw new \RuntimeException('draw_has_unsettled_tickets'); } - if ($batchItemIds !== []) { - $orphanPendingPayout = TicketItem::query() - ->where('draw_id', $locked->draw_id) - ->where('status', 'pending_payout') - ->whereNotIn('id', $batchItemIds) - ->exists(); - if ($orphanPendingPayout) { - throw new \RuntimeException('draw_has_unsettled_tickets'); - } + $orphanPendingPayout = TicketItem::query() + ->where('draw_id', $locked->draw_id) + ->where('status', 'pending_payout') + ->whereNotIn('id', $batchItemIds) + ->whereNotExists(function ($query): void { + $query->selectRaw('1') + ->from('ticket_settlement_details as other_details') + ->join('settlement_batches as other_batches', 'other_batches.id', '=', 'other_details.settlement_batch_id') + ->whereColumn('other_details.ticket_item_id', 'ticket_items.id') + ->whereColumn('other_batches.draw_id', 'ticket_items.draw_id') + ->where('other_batches.status', SettlementBatchStatus::Approved->value) + ->where('other_batches.review_status', 'approved'); + }) + ->exists(); + if ($orphanPendingPayout) { + throw new \RuntimeException('draw_has_unsettled_tickets'); } $details = $locked->details()->with(['ticketItem.order'])->get(); @@ -193,10 +200,34 @@ final class SettlementBatchWorkflowService 'paid_at' => now(), ])->save(); - Draw::query()->whereKey($locked->draw_id)->update([ - 'status' => DrawStatus::Settled->value, - 'settle_version' => (int) $locked->settle_version, - ]); + $hasIncompleteProviderBatch = SettlementBatch::query() + ->where('draw_id', $locked->draw_id) + ->whereIn('status', [ + SettlementBatchStatus::Running->value, + SettlementBatchStatus::PendingReview->value, + SettlementBatchStatus::Approved->value, + ]) + ->exists(); + $hasPendingTicket = TicketItem::query() + ->where('draw_id', $locked->draw_id) + ->whereIn('status', [ + 'pending_confirm', + 'partial_pending_confirm', + 'pending_draw', + 'pending_payout', + ]) + ->exists(); + + if (! $hasIncompleteProviderBatch && ! $hasPendingTicket) { + $settleVersion = (int) SettlementBatch::query() + ->where('draw_id', $locked->draw_id) + ->where('status', SettlementBatchStatus::Paid->value) + ->max('settle_version'); + Draw::query()->whereKey($locked->draw_id)->update([ + 'status' => DrawStatus::Settled->value, + 'settle_version' => $settleVersion, + ]); + } return $locked->refresh(); }); diff --git a/app/Services/Settlement/SettlementOrchestrator.php b/app/Services/Settlement/SettlementOrchestrator.php index f3a3f20..4e37941 100644 --- a/app/Services/Settlement/SettlementOrchestrator.php +++ b/app/Services/Settlement/SettlementOrchestrator.php @@ -3,9 +3,9 @@ namespace App\Services\Settlement; use App\Models\Draw; -use App\Models\BetProvider; use App\Models\TicketItem; use App\Lottery\DrawStatus; +use App\Models\BetProvider; use App\Models\JackpotPool; use App\Models\DrawResultItem; use App\Models\DrawResultBatch; @@ -14,10 +14,10 @@ use Illuminate\Support\Facades\DB; use App\Lottery\DrawResultBatchStatus; use App\Lottery\SettlementBatchStatus; use App\Models\TicketSettlementDetail; -use App\Services\Draw\DrawHallSnapshotBuilder; -use App\Services\Draw\LotteryHallRealtimeBroadcaster; use App\Services\Ticket\RiskPoolService; +use App\Services\Draw\DrawHallSnapshotBuilder; use App\Services\Jackpot\JackpotBurstAllocator; +use App\Services\Draw\LotteryHallRealtimeBroadcaster; use App\Services\AgentSettlement\AgentGameSettlementRecorder; /** @@ -54,50 +54,46 @@ final class SettlementOrchestrator return ['handled' => false, 'jackpot_bursts' => [], 'should_notify_status' => false]; } - $ticketItems = TicketItem::query() - ->where('draw_id', $locked->id) - ->where('status', 'pending_draw') - ->with(['combinations', 'order']) - ->orderBy('id') - ->limit(10_000) - ->get(); - - if ($ticketItems->count() >= 10_000) { - \Illuminate\Support\Facades\Log::warning('SettlementOrchestrator: ticket_items hit safety cap', [ - 'draw_id' => $locked->id, - 'draw_no' => $locked->draw_no, - 'loaded_count' => $ticketItems->count(), - ]); - } - $jackpotBursts = []; $handled = false; $latestSettleVersion = (int) $locked->settle_version; - $ticketItemsByProvider = $ticketItems->isEmpty() - ? collect([BetProvider::DEFAULT_CODE => collect()]) - : $ticketItems->groupBy( - fn (TicketItem $item): string => strtoupper((string) ($item->provider_code ?: BetProvider::DEFAULT_CODE)), - ); - $providerCodes = $ticketItemsByProvider->keys()->values()->all(); $publishedBatches = DrawResultBatch::query() ->where('draw_id', $locked->id) ->where('status', DrawResultBatchStatus::Published->value) - ->whereIn('provider_code', $providerCodes) ->orderBy('provider_code') ->orderByDesc('result_version') ->orderByDesc('id') ->get() - ->unique('provider_code') - ->keyBy('provider_code'); + ->unique(fn (DrawResultBatch $batch): string => strtoupper((string) $batch->provider_code)) + ->keyBy(fn (DrawResultBatch $batch): string => strtoupper((string) $batch->provider_code)); + + /** + * @var array + * }> $providerContexts + */ + $providerContexts = []; + $openProviderContext = function (string $providerCode) use ( + &$providerContexts, + &$handled, + &$latestSettleVersion, + $locked, + $publishedBatches, + ): ?array { + if (isset($providerContexts[$providerCode])) { + return $providerContexts[$providerCode]; + } - foreach ($ticketItemsByProvider as $providerCode => $providerTicketItems) { /** @var DrawResultBatch|null $publishedBatch */ $publishedBatch = $publishedBatches->get($providerCode); if ($publishedBatch === null) { - continue; + return null; } - $existingDone = SettlementBatch::query() + $batchRow = SettlementBatch::query() ->where('draw_id', $locked->id) ->where('result_batch_id', $publishedBatch->id) ->whereIn('status', [ @@ -107,48 +103,109 @@ final class SettlementOrchestrator SettlementBatchStatus::Paid->value, SettlementBatchStatus::Completed->value, ]) + ->orderByDesc('id') ->first(); - if ($existingDone !== null) { - $handled = true; - $latestSettleVersion = max($latestSettleVersion, (int) $existingDone->settle_version); - continue; + if ($batchRow !== null && in_array($batchRow->status, [ + SettlementBatchStatus::Paid->value, + SettlementBatchStatus::Completed->value, + ], true)) { + throw new \RuntimeException('settlement_batch_already_finalized_with_pending_tickets'); } - $items = DrawResultItem::query() - ->where('result_batch_id', $publishedBatch->id) + if ($batchRow !== null) { + $handled = true; + $latestSettleVersion = max($latestSettleVersion, (int) $batchRow->settle_version); + $batchRow->forceFill([ + 'status' => SettlementBatchStatus::Running->value, + 'review_status' => 'pending', + 'reviewed_by' => null, + 'reviewed_at' => null, + 'review_remark' => null, + 'finished_at' => null, + ])->save(); + } else { + $latestSettleVersion++; + $batchRow = SettlementBatch::query()->create([ + 'draw_id' => $locked->id, + 'result_batch_id' => $publishedBatch->id, + 'settle_version' => $latestSettleVersion, + 'status' => SettlementBatchStatus::Running->value, + 'review_status' => 'pending', + 'started_at' => now(), + ]); + } + + $providerContexts[$providerCode] = [ + 'published_batch' => $publishedBatch, + 'settlement_batch' => $batchRow, + 'board' => new PublishedDrawResultBoard( + DrawResultItem::query() + ->where('result_batch_id', $publishedBatch->id) + ->orderBy('id') + ->get(), + ), + 'prepared' => [], + ]; + + return $providerContexts[$providerCode]; + }; + + $chunkSize = max(1, (int) config('lottery.settlement.ticket_chunk_size', 10_000)); + $lastTicketItemId = 0; + $sawPendingTicket = false; + + while (true) { + $ticketItems = TicketItem::query() + ->where('draw_id', $locked->id) + ->where('status', 'pending_draw') + ->where('id', '>', $lastTicketItemId) + ->with(['combinations', 'order']) ->orderBy('id') + ->limit($chunkSize) ->get(); - $board = new PublishedDrawResultBoard($items); - $nextSettleVersion = $latestSettleVersion + 1; - $latestSettleVersion = $nextSettleVersion; - $batchRow = SettlementBatch::query()->create([ - 'draw_id' => $locked->id, - 'result_batch_id' => $publishedBatch->id, - 'settle_version' => $nextSettleVersion, - 'status' => SettlementBatchStatus::Running->value, - 'review_status' => 'pending', - 'started_at' => now(), - ]); + if ($ticketItems->isEmpty()) { + break; + } + + $sawPendingTicket = true; + $lastTicketItemId = (int) $ticketItems->last()->id; + + foreach ($ticketItems as $item) { + $providerCode = strtoupper((string) ($item->provider_code ?: BetProvider::DEFAULT_CODE)); + $context = $openProviderContext($providerCode); + if ($context === null) { + continue; + } - /** @var list $prepared */ - $prepared = []; - foreach ($providerTicketItems as $item) { $matcher = $this->matchers->for((string) $item->play_code); - $result = $matcher->match($item, $board, $item->combinations); + $result = $matcher->match($item, $context['board'], $item->combinations); $gross = max(0, (int) $result['win_amount']); $tier = $result['matched_prize_tier'] ?? null; - $tier = is_string($tier) ? $tier : null; - $net = $this->payoutAdjuster->adjustGrossWin($gross, $item); - $prepared[] = [ + $providerContexts[$providerCode]['prepared'][] = [ 'item' => $item, 'gross_win' => $gross, - 'matched_tier' => $tier, - 'net_win' => $net, + 'matched_tier' => is_string($tier) ? $tier : null, + 'net_win' => $this->payoutAdjuster->adjustGrossWin($gross, $item), 'match_detail' => $result['match_detail'], ]; } + } + + if (! $sawPendingTicket + && $publishedBatches->has(BetProvider::DEFAULT_CODE) + && ! SettlementBatch::query()->where('draw_id', $locked->id)->exists() + ) { + $openProviderContext(BetProvider::DEFAULT_CODE); + } + + foreach ($providerContexts as $providerCode => $context) { + $board = $context['board']; + $batchRow = $context['settlement_batch']; + + /** @var list $prepared */ + $prepared = $context['prepared']; $allocations = []; $totalJackpotPayout = 0; @@ -187,9 +244,10 @@ final class SettlementOrchestrator } } - $ticketCount = 0; - $winCount = 0; - $totalPayout = 0; + $ticketCount = (int) $batchRow->total_ticket_count; + $winCount = (int) $batchRow->total_win_count; + $totalPayout = (int) $batchRow->total_payout_amount; + $totalJackpotPayout += (int) $batchRow->total_jackpot_payout_amount; foreach ($prepared as $p) { /** @var TicketItem $item */ @@ -216,7 +274,12 @@ final class SettlementOrchestrator 'status' => $terminalStatus, ])->save(); - $this->agentGameSettlement->recordForTicketItem($item, $net, $terminalStatus); + $this->agentGameSettlement->recordForTicketItem( + $item, + $net, + $terminalStatus, + (int) $batchRow->settle_version, + ); if ($finalCredit > 0) { $winCount++; @@ -239,17 +302,51 @@ final class SettlementOrchestrator } $batchRow->forceFill([ - 'status' => SettlementBatchStatus::PendingReview->value, + 'status' => SettlementBatchStatus::Running->value, 'total_ticket_count' => $ticketCount, 'total_win_count' => $winCount, 'total_payout_amount' => $totalPayout, 'total_jackpot_payout_amount' => $totalJackpotPayout, - 'finished_at' => now(), + 'finished_at' => null, ])->save(); $handled = true; } + $hasPendingDrawTickets = TicketItem::query() + ->where('draw_id', $locked->id) + ->where('status', 'pending_draw') + ->exists(); + + if (! $hasPendingDrawTickets) { + $finishedBatchCount = SettlementBatch::query() + ->where('draw_id', $locked->id) + ->where('status', SettlementBatchStatus::Running->value) + ->update([ + 'status' => SettlementBatchStatus::PendingReview->value, + 'finished_at' => now(), + 'updated_at' => now(), + ]); + $handled = $handled || $finishedBatchCount > 0; + } + + if (! $handled) { + $activeBatch = SettlementBatch::query() + ->where('draw_id', $locked->id) + ->whereIn('status', [ + SettlementBatchStatus::PendingReview->value, + SettlementBatchStatus::Approved->value, + SettlementBatchStatus::Paid->value, + SettlementBatchStatus::Completed->value, + ]) + ->orderByDesc('settle_version') + ->first(); + if ($activeBatch !== null) { + $handled = true; + $latestSettleVersion = max($latestSettleVersion, (int) $activeBatch->settle_version); + } + } + if (! $handled) { return ['handled' => false, 'jackpot_bursts' => [], 'should_notify_status' => false]; } diff --git a/app/Services/Settlement/SettlementTickFinalizer.php b/app/Services/Settlement/SettlementTickFinalizer.php index d7dcd36..ff454db 100644 --- a/app/Services/Settlement/SettlementTickFinalizer.php +++ b/app/Services/Settlement/SettlementTickFinalizer.php @@ -2,11 +2,11 @@ namespace App\Services\Settlement; -use App\Models\SettlementBatch; -use App\Lottery\SettlementBatchStatus; use App\Services\AuditLogger; +use App\Models\SettlementBatch; use App\Services\LotterySettings; use Illuminate\Support\Facades\DB; +use App\Lottery\SettlementBatchStatus; /** * draw tick 在自动结算后,按系统设置自动审核并派彩入账。 @@ -24,15 +24,38 @@ final class SettlementTickFinalizer $paid = 0; $payoutFailed = 0; - if (! (bool) LotterySettings::get('settlement.auto_approve_on_tick', true)) { - return ['approved' => 0, 'paid' => 0, 'payout_failed' => 0]; - } + $autoApprove = (bool) LotterySettings::get('settlement.auto_approve_on_tick', true); + $pending = $autoApprove + ? SettlementBatch::query() + ->where('status', SettlementBatchStatus::PendingReview->value) + ->orderBy('id') + ->limit((int) config('lottery.draw_tick_finalize_limit', 5)) + ->get() + : collect(); - $pending = SettlementBatch::query() - ->where('status', SettlementBatchStatus::PendingReview->value) - ->orderBy('id') + // 除本轮待审核批次外,也恢复处理上轮已批准但尚未派彩的批次。 + // 这样进程即使在 approve 提交后、payout 开始前退出,下一 tick 仍能续跑。 + $approvedDrawIds = DB::table('settlement_batches as approved') + ->where('approved.status', SettlementBatchStatus::Approved->value) + ->whereNotExists(function ($query): void { + $query->selectRaw('1') + ->from('settlement_batches as sibling') + ->whereColumn('sibling.draw_id', 'approved.draw_id') + ->whereIn('sibling.status', [ + SettlementBatchStatus::Running->value, + SettlementBatchStatus::PendingReview->value, + ]); + }) + ->groupBy('approved.draw_id') + ->orderByRaw('MAX(approved.auto_payout_attempts)') + ->orderByRaw('MIN(approved.id)') ->limit((int) config('lottery.draw_tick_finalize_limit', 5)) - ->get(); + ->pluck('approved.draw_id'); + $candidateDrawIds = $pending->pluck('draw_id') + ->merge($approvedDrawIds) + ->map(fn ($id): int => (int) $id) + ->unique() + ->values(); foreach ($pending as $batch) { try { @@ -43,32 +66,53 @@ final class SettlementTickFinalizer continue; } + } - if (! (bool) LotterySettings::get('settlement.auto_payout_on_tick', true)) { + if (! (bool) LotterySettings::get('settlement.auto_payout_on_tick', true)) { + return ['approved' => $approved, 'paid' => 0, 'payout_failed' => 0]; + } + + foreach ($candidateDrawIds as $drawId) { + $hasUnapprovedBatch = SettlementBatch::query() + ->where('draw_id', $drawId) + ->whereIn('status', [ + SettlementBatchStatus::Running->value, + SettlementBatchStatus::PendingReview->value, + ]) + ->exists(); + if ($hasUnapprovedBatch) { continue; } - try { - $this->workflow->payout($batch->fresh()); - $paid++; - AuditLogger::recordForSystem( - moduleCode: 'settlement', - actionCode: 'auto_payout', - targetType: 'settlement_batch', - targetId: (string) $batch->id, - afterJson: ['draw_id' => (int) $batch->draw_id], - ); - } catch (\Throwable $e) { - report($e); - $this->markAutoPayoutFailed($batch, $e); - $payoutFailed++; + $approvedBatches = SettlementBatch::query() + ->where('draw_id', $drawId) + ->where('status', SettlementBatchStatus::Approved->value) + ->orderBy('id') + ->get(); + + foreach ($approvedBatches as $batch) { + try { + $this->workflow->payout($batch); + $paid++; + AuditLogger::recordForSystem( + moduleCode: 'settlement', + actionCode: 'auto_payout', + targetType: 'settlement_batch', + targetId: (string) $batch->id, + afterJson: ['draw_id' => (int) $batch->draw_id], + ); + } catch (\Throwable $e) { + report($e); + $this->recordAutoPayoutFailure($batch, $e); + $payoutFailed++; + } } } return ['approved' => $approved, 'paid' => $paid, 'payout_failed' => $payoutFailed]; } - private function markAutoPayoutFailed(SettlementBatch $batch, \Throwable $e): void + private function recordAutoPayoutFailure(SettlementBatch $batch, \Throwable $e): void { $message = mb_substr($e->getMessage(), 0, 200); @@ -79,7 +123,7 @@ final class SettlementTickFinalizer } $locked->forceFill([ - 'status' => SettlementBatchStatus::Failed->value, + 'auto_payout_attempts' => (int) $locked->auto_payout_attempts + 1, 'review_remark' => 'auto_payout_failed: '.$message, ])->save(); @@ -95,4 +139,4 @@ final class SettlementTickFinalizer ); }); } -} \ No newline at end of file +} diff --git a/app/Services/Ticket/TicketPendingConfirmReconcileService.php b/app/Services/Ticket/TicketPendingConfirmReconcileService.php index 552875d..6733007 100644 --- a/app/Services/Ticket/TicketPendingConfirmReconcileService.php +++ b/app/Services/Ticket/TicketPendingConfirmReconcileService.php @@ -5,10 +5,13 @@ namespace App\Services\Ticket; use App\Models\Draw; use App\Models\WalletTxn; use App\Models\TicketItem; +use App\Models\BetProvider; use App\Models\TicketOrder; +use App\Support\PlayerFundingMode; +use Illuminate\Support\Facades\DB; +use App\Services\Player\PlayerCreditService; use App\Services\Draw\DrawHallSnapshotBuilder; use App\Services\Jackpot\JackpotContributionService; -use Illuminate\Support\Facades\DB; final class TicketPendingConfirmReconcileService { @@ -17,6 +20,7 @@ final class TicketPendingConfirmReconcileService private readonly JackpotContributionService $jackpotContribution, private readonly DrawHallSnapshotBuilder $drawHallSnapshot, private readonly TicketWalletService $ticketWallet, + private readonly PlayerCreditService $playerCredit, ) {} /** @@ -45,6 +49,13 @@ final class TicketPendingConfirmReconcileService return 'skipped'; } + $player = $lockedOrder->player()->first(); + if ($player !== null && PlayerFundingMode::usesCredit($player)) { + // 信用单的占额与 pending_confirm 在同一数据库事务提交; + // 能读到该状态即代表占额成功,无需依赖仅钱包盘才有的 bet_deduct 流水。 + return $this->confirmOrder($lockedOrder); + } + $hasPostedDeduct = WalletTxn::query() ->where('biz_type', 'bet_deduct') ->where('biz_no', $lockedOrder->order_no) @@ -114,6 +125,17 @@ final class TicketPendingConfirmReconcileService private function refundStalePendingOrder(TicketOrder $lockedOrder, string $reasonCode): string { + $player = $lockedOrder->player()->first(); + if ($player !== null && PlayerFundingMode::usesCredit($player)) { + $this->playerCredit->reverseBetHold( + $player, + (int) $lockedOrder->total_actual_deduct, + (int) $lockedOrder->id, + ); + + return $this->refundPendingConfirmItems($lockedOrder, $reasonCode); + } + $hasPostedDeduct = WalletTxn::query() ->where('biz_type', 'bet_deduct') ->where('biz_no', $lockedOrder->order_no) @@ -131,6 +153,15 @@ final class TicketPendingConfirmReconcileService private function refundOrderWithoutDeduct(TicketOrder $lockedOrder): string { + $player = $lockedOrder->player()->first(); + if ($player !== null && PlayerFundingMode::usesCredit($player)) { + $this->playerCredit->reverseBetHold( + $player, + (int) $lockedOrder->total_actual_deduct, + (int) $lockedOrder->id, + ); + } + return $this->refundPendingConfirmItems($lockedOrder, 'pending_confirm_timeout'); } @@ -155,7 +186,7 @@ final class TicketPendingConfirmReconcileService if ($locks !== []) { $this->riskPool->release( (int) $lockedOrder->draw_id, - (string) ($item->provider_code ?: \App\Models\BetProvider::DEFAULT_CODE), + (string) ($item->provider_code ?: BetProvider::DEFAULT_CODE), $item, $locks, ); diff --git a/app/Services/Wallet/HttpMainSiteWalletBalanceClient.php b/app/Services/Wallet/HttpMainSiteWalletBalanceClient.php index 992148d..d66d177 100644 --- a/app/Services/Wallet/HttpMainSiteWalletBalanceClient.php +++ b/app/Services/Wallet/HttpMainSiteWalletBalanceClient.php @@ -3,9 +3,9 @@ namespace App\Services\Wallet; use App\Models\Player; -use Illuminate\Support\Facades\Http; +use App\Services\Integration\PartnerSiteConfig; +use App\Support\Integration\WalletApiRequestGuard; use App\Services\Integration\PartnerSiteConfigResolver; -use App\Support\Integration\WalletApiUrlSanitizer; /** * 查询主站钱包余额(供玩家端余额接口填充 main_balance)。 @@ -14,6 +14,7 @@ final class HttpMainSiteWalletBalanceClient { public function __construct( private readonly PartnerSiteConfigResolver $partnerSiteConfigResolver, + private readonly WalletApiRequestGuard $walletApiRequestGuard, ) {} public function fetch(Player $player, string $currencyCode): ?int @@ -52,8 +53,11 @@ final class HttpMainSiteWalletBalanceClient ); } - $base = WalletApiUrlSanitizer::normalizeAndValidate($config->walletApiUrl); - if ($base === null) { + $endpoint = $this->walletApiRequestGuard->guard( + $config->walletApiUrl, + $config->walletTimeoutSeconds, + ); + if ($endpoint === null) { return new MainSiteWalletBalanceProbeResult( success: false, mainBalanceMinor: null, @@ -66,12 +70,11 @@ final class HttpMainSiteWalletBalanceClient } $path = $config->walletBalancePath; - $url = $base.'/'.ltrim($path, '/'); - $timeout = $config->walletTimeoutSeconds; + $url = $endpoint->baseUrl.'/'.ltrim($path, '/'); $apiKey = $config->walletApiKey; if (app()->environment(['production']) - && $config->source === \App\Services\Integration\PartnerSiteConfig::SOURCE_LEGACY_ENV + && $config->source === PartnerSiteConfig::SOURCE_LEGACY_ENV && (! is_string($apiKey) || trim($apiKey) === '') ) { return new MainSiteWalletBalanceProbeResult( @@ -97,8 +100,7 @@ final class HttpMainSiteWalletBalanceClient ]; try { - $response = Http::withHeaders($headers) - ->timeout($timeout) + $response = $endpoint->request($headers) ->acceptJson() ->get($url, $query); } catch (\Throwable $e) { diff --git a/app/Services/Wallet/HttpMainSiteWalletGateway.php b/app/Services/Wallet/HttpMainSiteWalletGateway.php index edc92e4..c62fd00 100644 --- a/app/Services/Wallet/HttpMainSiteWalletGateway.php +++ b/app/Services/Wallet/HttpMainSiteWalletGateway.php @@ -3,10 +3,10 @@ namespace App\Services\Wallet; use App\Models\Player; -use Illuminate\Support\Facades\Http; use GuzzleHttp\Exception\ConnectException; +use App\Services\Integration\PartnerSiteConfig; +use App\Support\Integration\WalletApiRequestGuard; use App\Services\Integration\PartnerSiteConfigResolver; -use App\Support\Integration\WalletApiUrlSanitizer; /** * 通过 HTTP 调用主站钱包 API(路径见 config lottery.main_site.wallet_*_path)。 @@ -15,6 +15,7 @@ final class HttpMainSiteWalletGateway implements MainSiteWalletGateway { public function __construct( private readonly PartnerSiteConfigResolver $partnerSiteConfigResolver, + private readonly WalletApiRequestGuard $walletApiRequestGuard, ) {} public function debitMainForLotteryDeposit( @@ -77,7 +78,7 @@ final class HttpMainSiteWalletGateway implements MainSiteWalletGateway string $currencyCode, int $amountMinor, string $idempotentKey, - \App\Services\Integration\PartnerSiteConfig $config, + PartnerSiteConfig $config, ): MainSiteWalletResult { if (! $config->hasWalletApi()) { $requestSnapshot = [ @@ -108,8 +109,11 @@ final class HttpMainSiteWalletGateway implements MainSiteWalletGateway ); } - $base = WalletApiUrlSanitizer::normalizeAndValidate($config->walletApiUrl); - if ($base === null) { + $endpoint = $this->walletApiRequestGuard->guard( + $config->walletApiUrl, + $config->walletTimeoutSeconds, + ); + if ($endpoint === null) { return MainSiteWalletResult::failure( 'wallet_api_url_invalid', ['reason' => 'invalid_base_url'], @@ -142,12 +146,11 @@ final class HttpMainSiteWalletGateway implements MainSiteWalletGateway ], ]); - $url = $base.'/'.ltrim($path, '/'); - $timeout = $config->walletTimeoutSeconds; + $url = $endpoint->baseUrl.'/'.ltrim($path, '/'); $apiKey = $config->walletApiKey; if (app()->environment(['production']) - && $config->source === \App\Services\Integration\PartnerSiteConfig::SOURCE_LEGACY_ENV + && $config->source === PartnerSiteConfig::SOURCE_LEGACY_ENV && (! is_string($apiKey) || trim($apiKey) === '') ) { return MainSiteWalletResult::failure( @@ -164,8 +167,7 @@ final class HttpMainSiteWalletGateway implements MainSiteWalletGateway } try { - $response = Http::withHeaders($headers) - ->timeout($timeout) + $response = $endpoint->request($headers) ->acceptJson() ->asJson() ->post($url, $requestBody); diff --git a/app/Services/Wallet/HttpMainSiteWalletIdempotentProbeClient.php b/app/Services/Wallet/HttpMainSiteWalletIdempotentProbeClient.php index 65aa7c8..e65ee95 100644 --- a/app/Services/Wallet/HttpMainSiteWalletIdempotentProbeClient.php +++ b/app/Services/Wallet/HttpMainSiteWalletIdempotentProbeClient.php @@ -3,15 +3,15 @@ namespace App\Services\Wallet; use App\Models\Player; -use Illuminate\Support\Facades\Http; +use App\Support\Integration\WalletApiRequestGuard; use App\Services\Integration\PartnerSiteConfigResolver; -use App\Support\Integration\WalletApiUrlSanitizer; /** 按幂等键查询主站钱包侧是否已有对应划转记录。 */ final class HttpMainSiteWalletIdempotentProbeClient { public function __construct( private readonly PartnerSiteConfigResolver $partnerSiteConfigResolver, + private readonly WalletApiRequestGuard $walletApiRequestGuard, ) {} public function probe(Player $player, string $idempotentKey): MainSiteWalletIdempotentProbeResult @@ -32,8 +32,11 @@ final class HttpMainSiteWalletIdempotentProbeClient ); } - $base = WalletApiUrlSanitizer::normalizeAndValidate($config->walletApiUrl); - if ($base === null) { + $endpoint = $this->walletApiRequestGuard->guard( + $config->walletApiUrl, + $config->walletTimeoutSeconds, + ); + if ($endpoint === null) { return new MainSiteWalletIdempotentProbeResult( status: MainSiteWalletIdempotentProbeResult::STATUS_UNAVAILABLE, message: 'wallet_api_url_invalid', @@ -41,7 +44,7 @@ final class HttpMainSiteWalletIdempotentProbeClient } $path = $config->walletLookupIdempotentPath; - $url = $base.'/'.ltrim($path, '/'); + $url = $endpoint->baseUrl.'/'.ltrim($path, '/'); $headers = ['Accept' => 'application/json']; if (is_string($config->walletApiKey) && $config->walletApiKey !== '') { $headers['Authorization'] = 'Bearer '.$config->walletApiKey; @@ -54,8 +57,7 @@ final class HttpMainSiteWalletIdempotentProbeClient ]; try { - $response = Http::withHeaders($headers) - ->timeout($config->walletTimeoutSeconds) + $response = $endpoint->request($headers) ->acceptJson() ->get($url, $query); } catch (\Throwable $e) { diff --git a/app/Services/Wallet/MainSiteWalletBalanceProbeResult.php b/app/Services/Wallet/MainSiteWalletBalanceProbeResult.php index 193687b..0d6a756 100644 --- a/app/Services/Wallet/MainSiteWalletBalanceProbeResult.php +++ b/app/Services/Wallet/MainSiteWalletBalanceProbeResult.php @@ -3,7 +3,7 @@ namespace App\Services\Wallet; /** - * 主站 balance 探测结果(后台联调检测用,含诊断信息)。 + * 主站 balance 探测结果(后台联调检测用)。 */ final readonly class MainSiteWalletBalanceProbeResult { @@ -29,7 +29,6 @@ final readonly class MainSiteWalletBalanceProbeResult 'request_url' => $this->requestUrl, 'http_status' => $this->httpStatus, 'message' => $this->message, - 'response_preview' => $this->responseBody, ]; } } diff --git a/app/Support/AdminReportJobPolicy.php b/app/Support/AdminReportJobPolicy.php new file mode 100644 index 0000000..a2bce19 --- /dev/null +++ b/app/Support/AdminReportJobPolicy.php @@ -0,0 +1,48 @@ + $query + */ + public static function applyToJobsQuery(Builder $query, AdminUser $admin): void + { + if ($admin->isSuperAdmin()) { + return; + } + + $query->where('admin_user_id', (int) $admin->getKey()); + } + + public static function jobAccessible(AdminUser $admin, ReportJob $job): bool + { + if ($admin->isSuperAdmin()) { + return true; + } + + return $job->admin_user_id !== null + && (int) $job->admin_user_id === (int) $admin->getKey(); + } + + public static function canExportReportType(AdminUser $admin, string $reportType): bool + { + if (! $admin->hasPermissionCode('service.report.export')) { + return false; + } + + return match ($reportType) { + 'audit_operation_report' => $admin->hasPermissionCode('service.audit.view'), + // 风险池没有可靠的站点归属快照;在可证明隔离前只允许全局超管导出。 + 'hot_number_risk_report', 'sold_out_number_report' => $admin->isSuperAdmin() + && $admin->hasPermissionCode('risk.monitor.view'), + default => true, + }; + } +} diff --git a/app/Support/Integration/GuardedWalletApiEndpoint.php b/app/Support/Integration/GuardedWalletApiEndpoint.php new file mode 100644 index 0000000..30b3c5f --- /dev/null +++ b/app/Support/Integration/GuardedWalletApiEndpoint.php @@ -0,0 +1,43 @@ + $headers */ + public function request(array $headers = []): PendingRequest + { + $pending = Http::withHeaders($headers) + ->withoutRedirecting() + ->connectTimeout($this->connectTimeoutSeconds) + ->timeout($this->totalTimeoutSeconds) + // A proxy would resolve the hostname independently and defeat DNS pinning. + ->withOptions(['proxy' => '']); + + if (filter_var($this->hostname, FILTER_VALIDATE_IP) === false) { + $address = filter_var($this->pinnedIp, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) + ? '['.$this->pinnedIp.']' + : $this->pinnedIp; + + $pending->withOptions([ + 'curl' => [ + CURLOPT_RESOLVE => [$this->hostname.':'.$this->port.':'.$address], + ], + ]); + } + + return $pending; + } +} diff --git a/app/Support/Integration/SystemWalletApiDnsResolver.php b/app/Support/Integration/SystemWalletApiDnsResolver.php new file mode 100644 index 0000000..56b078c --- /dev/null +++ b/app/Support/Integration/SystemWalletApiDnsResolver.php @@ -0,0 +1,31 @@ + $record['ip'] ?? null, + 'AAAA' => $record['ipv6'] ?? null, + default => null, + }; + + if (is_string($address) && $address !== '') { + $addresses[] = $address; + } + } + + return array_values(array_unique($addresses)); + } +} diff --git a/app/Support/Integration/WalletApiRequestGuard.php b/app/Support/Integration/WalletApiRequestGuard.php new file mode 100644 index 0000000..0933a77 --- /dev/null +++ b/app/Support/Integration/WalletApiRequestGuard.php @@ -0,0 +1,74 @@ +dnsResolver->resolveAll($hostname); + } catch (\Throwable) { + return null; + } + } + + $addresses = array_values(array_unique(array_filter( + $addresses, + static fn (mixed $address): bool => is_string($address) && $address !== '', + ))); + + if ($addresses === []) { + return null; + } + + foreach ($addresses as $address) { + if (! WalletApiUrlSanitizer::isPublicIp($address)) { + return null; + } + } + + $totalTimeoutSeconds = max(1, $timeoutSeconds); + + return new GuardedWalletApiEndpoint( + baseUrl: $baseUrl, + hostname: $hostname, + port: $port, + pinnedIp: $addresses[0], + totalTimeoutSeconds: $totalTimeoutSeconds, + connectTimeoutSeconds: min(self::MAX_CONNECT_TIMEOUT_SECONDS, $totalTimeoutSeconds), + ); + } +} diff --git a/app/Support/Integration/WalletApiUrlSanitizer.php b/app/Support/Integration/WalletApiUrlSanitizer.php index b8fb04c..db6b59a 100644 --- a/app/Support/Integration/WalletApiUrlSanitizer.php +++ b/app/Support/Integration/WalletApiUrlSanitizer.php @@ -11,7 +11,7 @@ namespace App\Support\Integration; * - 不允许除 / 以外的 path(即仅允许根地址) * - 拒绝 localhost 与私网/保留网段(IP 字面量层面) * - * 说明:对 hostname 不做 DNS 解析(避免引入不确定性),但会拦截 localhost 及明显内网标识。 + * hostname 的 DNS 解析与请求时固定解析由 WalletApiRequestGuard 负责。 */ final class WalletApiUrlSanitizer { @@ -26,13 +26,6 @@ final class WalletApiUrlSanitizer return null; } - // E2E:允许本地 mock 主站钱包(http://127.0.0.1:port),生产 LOTTERY_E2E 默认 false。 - if ((bool) env('LOTTERY_E2E', false) && app()->environment(['local', 'testing'])) { - if (preg_match('#^https?://127\.0\.0\.1:\d{1,5}$#', rtrim($raw, '/')) === 1) { - return rtrim($raw, '/'); - } - } - // 允许尾部 /,归一化后移除 $raw = rtrim($raw, " \t\n\r\0\x0B/"); @@ -68,6 +61,8 @@ final class WalletApiUrlSanitizer return null; } + $host = trim($host, '[]'); + // 明确拦截 localhost / 本地常见名 if ($host === 'localhost' || $host === 'local' || $host === 'localdomain') { return null; @@ -76,7 +71,16 @@ final class WalletApiUrlSanitizer // 拦截 IP 字面量私网 $isIp = filter_var($host, FILTER_VALIDATE_IP) !== false; if ($isIp) { - if (self::ipIsPrivateOrReserved($host)) { + if (! self::isPublicIp($host)) { + return null; + } + } else { + $host = rtrim($host, '.'); + if (! str_contains($host, '.') + || preg_match('/^[0-9.]+$/D', $host) === 1 + || preg_match('/(^|\.)(?:0x[0-9a-f]+|0[0-7]+)(?:\.|$)/iD', $host) === 1 + || filter_var($host, FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME) === false + ) { return null; } } @@ -89,7 +93,10 @@ final class WalletApiUrlSanitizer } } - $normalized = 'https://'.$host; + $normalizedHost = filter_var($host, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) + ? '['.$host.']' + : $host; + $normalized = 'https://'.$normalizedHost; if (isset($parts['port'])) { $normalized .= ':'.(string) (int) $parts['port']; } @@ -97,18 +104,26 @@ final class WalletApiUrlSanitizer return $normalized; } - private static function ipIsPrivateOrReserved(string $ip): bool + public static function isPublicIp(string $ip): bool { + if (filter_var( + $ip, + FILTER_VALIDATE_IP, + FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE, + ) === false) { + return false; + } + if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) { $v = ip2long($ip); if ($v === false) { - return true; + return false; } // PHP 在 macOS 上 ip2long 可能为有符号,强转为 int64 统一处理 $v = (int) $v; - return self::ipInRangesV4((int) $v, [ + return ! self::ipInRangesV4((int) $v, [ // 0.0.0.0/8 ['base' => ip2long('0.0.0.0'), 'mask' => 0xFF000000], // 10.0.0.0/8 @@ -125,6 +140,11 @@ final class WalletApiUrlSanitizer ['base' => ip2long('100.64.0.0'), 'mask' => 0xFFC00000], // 192.0.0.0/24 (IETF Protocol Assignments) ['base' => ip2long('192.0.0.0'), 'mask' => 0xFFFFFF00], + // Documentation and deprecated relay ranges + ['base' => ip2long('192.0.2.0'), 'mask' => 0xFFFFFF00], + ['base' => ip2long('192.88.99.0'), 'mask' => 0xFFFFFF00], + ['base' => ip2long('198.51.100.0'), 'mask' => 0xFFFFFF00], + ['base' => ip2long('203.0.113.0'), 'mask' => 0xFFFFFF00], // 198.18.0.0/15 (benchmarking) ['base' => ip2long('198.18.0.0'), 'mask' => 0xFFFE0000], // 224.0.0.0/4 (multicast) @@ -138,17 +158,17 @@ final class WalletApiUrlSanitizer if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) { $bin = inet_pton($ip); if ($bin === false) { - return true; + return false; } // IPv6 ::1 (loopback) if (substr($bin, 0, 15) === str_repeat("\0", 15) && $bin[15] === "\1") { - return true; + return false; } // IPv6 unspecified :: if ($bin === str_repeat("\0", 16)) { - return true; + return false; } $b0 = ord($bin[0]); @@ -156,34 +176,52 @@ final class WalletApiUrlSanitizer // ff00::/8 multicast if ($b0 === 0xFF) { - return true; + return false; } // fc00::/7 unique local => fc or fd if ($b0 === 0xFC || $b0 === 0xFD) { - return true; + return false; } // fe80::/10 link-local => fe + (second byte & 0xC0) == 0x80 if ($b0 === 0xFE && (($b1 & 0xC0) === 0x80)) { - return true; + return false; } // IPv4-mapped ::ffff:0:0/96 => 检查最后 4 字节映射的 IPv4 是否为私网 if (substr($bin, 0, 10) === str_repeat("\0", 10) && substr($bin, 10, 2) === "\xFF\xFF") { $v4bin = substr($bin, 12, 4); $v4 = inet_ntop($v4bin); - // inet_ntop 对 v4bin 有时返回 false,这里保守返回 true + // inet_ntop 对 v4bin 有时返回 false,这里保守拒绝 if ($v4 === false) { - return true; + return false; } - return self::ipIsPrivateOrReserved($v4); + return self::isPublicIp($v4); } + + // IPv4 translation prefixes can otherwise tunnel a private IPv4 target. + $isIpv4Translation = substr($bin, 0, 12) === "\x00\x64\xFF\x9B\x00\x00\x00\x00\x00\x00\x00\x00" + || substr($bin, 0, 6) === "\x00\x64\xFF\x9B\x00\x01"; + + // 100::/64 discard-only, 2001::/23 protocol assignments, + // 2001:db8::/32 and 3fff::/20 documentation, 2002::/16 deprecated 6to4. + if ($isIpv4Translation + || substr($bin, 0, 8) === "\x00\x64\x00\x00\x00\x00\x00\x00" + || ($b0 === 0x20 && $b1 === 0x01 && (ord($bin[2]) & 0xFE) === 0) + || substr($bin, 0, 4) === "\x20\x01\x0D\xB8" + || ($b0 === 0x3F && ($b1 & 0xF0) === 0xF0) + || ($b0 === 0x20 && $b1 === 0x02) + ) { + return false; + } + + return true; } // 非法 IP:保守拒绝 - return true; + return false; } private static function ipInRangesV4(int $v, array $ranges): bool @@ -199,4 +237,3 @@ final class WalletApiUrlSanitizer return false; } } - diff --git a/bootstrap/app.php b/bootstrap/app.php index 2e205c6..db277c5 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -10,24 +10,24 @@ */ use App\Lottery\ErrorCode; -use App\Support\ApiResponse; use App\Support\ApiMessage; -use App\Support\ApiValidationErrors; -use Illuminate\Http\Request; use Illuminate\Support\Str; +use App\Support\ApiResponse; +use Illuminate\Http\Request; use App\Support\LotteryLocale; +use App\Support\ApiValidationErrors; use Illuminate\Foundation\Application; use App\Http\Middleware\EnsureAdminApi; use App\Http\Middleware\EnsurePlayerApi; -use App\Http\Middleware\RecordAdminApiAudit; use Illuminate\Console\Scheduling\Schedule; +use App\Http\Middleware\RecordAdminApiAudit; use Illuminate\Auth\AuthenticationException; -use App\Http\Middleware\EnsureAdminApiResourcePermission; use Illuminate\Validation\ValidationException; use App\Http\Middleware\NegotiateLotteryLocale; use Illuminate\Foundation\Configuration\Exceptions; use Illuminate\Foundation\Configuration\Middleware; use Illuminate\Database\Eloquent\ModelNotFoundException; +use App\Http\Middleware\EnsureAdminApiResourcePermission; use Symfony\Component\HttpKernel\Exception\HttpException; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\HttpKernel\Exception\TooManyRequestsHttpException; @@ -38,9 +38,15 @@ return Application::configure(basePath: dirname(__DIR__)) // 自动加前缀 `api` + middleware `api`,见 routes/api.php api: __DIR__.'/../routes/api.php', commands: __DIR__.'/../routes/console.php', - channels: __DIR__.'/../routes/channels.php', health: '/up', ) + ->withBroadcasting( + __DIR__.'/../routes/channels.php', + [ + 'prefix' => 'api', + 'middleware' => ['api', 'lottery.player'], + ], + ) ->withMiddleware(function (Middleware $middleware): void { // 多语言:必须在其他 api 中间件之前执行,以便鉴权失败时也能按语言返回 msg $middleware->api(prepend: [ @@ -106,7 +112,7 @@ return Application::configure(basePath: dirname(__DIR__)) ); }); - $exceptions->render(function (\InvalidArgumentException $e, Request $request) use ($locale) { + $exceptions->render(function (InvalidArgumentException $e, Request $request) use ($locale) { if (! $request->is('api/*')) { return null; } @@ -173,7 +179,7 @@ return Application::configure(basePath: dirname(__DIR__)) ); }); - $exceptions->render(function (HttpException $e, Request $request) use ($locale) { + $exceptions->render(function (HttpException $e, Request $request) { if (! $request->is('api/*')) { return null; } diff --git a/config/lottery.php b/config/lottery.php index 1626c3c..fed028f 100644 --- a/config/lottery.php +++ b/config/lottery.php @@ -18,6 +18,7 @@ return [ 'settlement' => [ 'default_timezone' => env('LOTTERY_SETTLEMENT_TIMEZONE', env('APP_TIMEZONE', 'UTC')), + 'ticket_chunk_size' => max(1, (int) env('LOTTERY_SETTLEMENT_TICKET_CHUNK_SIZE', 10_000)), ], /* @@ -63,6 +64,7 @@ return [ | jwt.* :主站签发的 JWT;验签通过后若无映射行则自动建档 | max_ttl_seconds :允许 (exp-iat) 最大秒数(默认 300=5 分钟),与「短效 Token」对齐 | require_iat_claim:为 true 时必须带 iat,否则拒绝(不建档) + | native.secret :原生玩家 JWT 专用密钥;必须显式配置且不得与任何 SSO 密钥共用 | | aes.key_base64 :可选。32 字节原始密钥再做 Base64 写入 env LOTTERY_PLAYER_TOKEN_AES_KEY; | 有值时 Bearer 串(非 xxx.yyy.zzz 外形)会先尝试 AES-GCM 解包为内层 JWT 再验签。 @@ -80,7 +82,7 @@ return [ 'key_base64' => env('LOTTERY_PLAYER_TOKEN_AES_KEY'), ], 'native' => [ - 'secret' => env('LOTTERY_NATIVE_JWT_SECRET', env('MAIN_SITE_SSO_JWT_SECRET', '')), + 'secret' => env('LOTTERY_NATIVE_JWT_SECRET'), 'ttl_seconds' => max(300, min(86400, (int) env('LOTTERY_NATIVE_JWT_TTL_SECONDS', 28800))), 'claim_player_id' => 'player_id', 'claim_auth_source' => 'auth_source', @@ -132,5 +134,4 @@ return [ 'draw_tick_settle_limit' => max(1, (int) env('LOTTERY_DRAW_TICK_SETTLE_LIMIT', 3)), 'draw_tick_finalize_limit' => max(1, (int) env('LOTTERY_DRAW_TICK_FINALIZE_LIMIT', 5)), 'draw_tick_rng_limit' => max(1, (int) env('LOTTERY_DRAW_TICK_RNG_LIMIT', 10)), - ]; diff --git a/config/reverb.php b/config/reverb.php index ee447b7..63617fa 100644 --- a/config/reverb.php +++ b/config/reverb.php @@ -1,5 +1,31 @@ env('REVERB_SCHEME', 'https'), 'useTLS' => env('REVERB_SCHEME', 'https') === 'https', ], - 'allowed_origins' => ['*'], + 'allowed_origins' => $allowedOrigins, 'ping_interval' => env('REVERB_APP_PING_INTERVAL', 60), 'activity_timeout' => env('REVERB_APP_ACTIVITY_TIMEOUT', 30), 'max_connections' => env('REVERB_APP_MAX_CONNECTIONS'), diff --git a/database/migrations/2026_07_21_150000_remove_cross_site_agent_role_assignments.php b/database/migrations/2026_07_21_150000_remove_cross_site_agent_role_assignments.php index 7f8076d..6d334e4 100644 --- a/database/migrations/2026_07_21_150000_remove_cross_site_agent_role_assignments.php +++ b/database/migrations/2026_07_21_150000_remove_cross_site_agent_role_assignments.php @@ -1,7 +1,7 @@ unsignedInteger('settlement_version')->default(0)->after('ref_id'); + }); + } + + $this->dropIndex(); + $this->createIndex(includeSettlementVersion: true); + } + + public function down(): void + { + if (! Schema::hasTable('credit_ledger')) { + return; + } + + $this->dropIndex(); + $this->createIndex(includeSettlementVersion: false); + + if (Schema::hasColumn('credit_ledger', 'settlement_version')) { + Schema::table('credit_ledger', function (Blueprint $table): void { + $table->dropColumn('settlement_version'); + }); + } + } + + private function dropIndex(): void + { + if (! Schema::hasIndex('credit_ledger', self::INDEX)) { + return; + } + + $driver = Schema::getConnection()->getDriverName(); + if (in_array($driver, ['pgsql', 'sqlite'], true)) { + DB::statement('DROP INDEX IF EXISTS '.self::INDEX); + + return; + } + + Schema::table('credit_ledger', function (Blueprint $table): void { + $table->dropUnique(self::INDEX); + }); + } + + private function createIndex(bool $includeSettlementVersion): void + { + $columns = $includeSettlementVersion + ? 'ref_type, ref_id, reason, settlement_version' + : 'ref_type, ref_id, reason'; + $driver = Schema::getConnection()->getDriverName(); + + if (in_array($driver, ['pgsql', 'sqlite'], true)) { + DB::statement( + 'CREATE UNIQUE INDEX '.self::INDEX.' ON credit_ledger ('.$columns.') WHERE ref_id IS NOT NULL', + ); + + return; + } + + Schema::table('credit_ledger', function (Blueprint $table) use ($includeSettlementVersion): void { + $columns = ['ref_type', 'ref_id', 'reason']; + if ($includeSettlementVersion) { + $columns[] = 'settlement_version'; + } + $table->unique($columns, self::INDEX); + }); + } +}; diff --git a/database/migrations/2026_07_22_020000_add_auto_payout_attempts_to_settlement_batches.php b/database/migrations/2026_07_22_020000_add_auto_payout_attempts_to_settlement_batches.php new file mode 100644 index 0000000..2a92a1e --- /dev/null +++ b/database/migrations/2026_07_22_020000_add_auto_payout_attempts_to_settlement_batches.php @@ -0,0 +1,32 @@ +unsignedInteger('auto_payout_attempts')->default(0)->after('paid_at'); + }); + } + + public function down(): void + { + if (! Schema::hasTable('settlement_batches') + || ! Schema::hasColumn('settlement_batches', 'auto_payout_attempts')) { + return; + } + + Schema::table('settlement_batches', function (Blueprint $table): void { + $table->dropColumn('auto_payout_attempts'); + }); + } +}; diff --git a/routes/channels.php b/routes/channels.php index df2ad28..cbe5871 100644 --- a/routes/channels.php +++ b/routes/channels.php @@ -1,7 +1,10 @@ id === (int) $id; -}); +Broadcast::channel( + 'player.{playerId}', + static fn (Player $player, string $playerId): bool => ctype_digit($playerId) + && (string) $player->getKey() === $playerId, +); diff --git a/tests/Feature/AdminIntegrationSiteApiTest.php b/tests/Feature/AdminIntegrationSiteApiTest.php index f2184b9..564dbc9 100644 --- a/tests/Feature/AdminIntegrationSiteApiTest.php +++ b/tests/Feature/AdminIntegrationSiteApiTest.php @@ -1,19 +1,21 @@ artisan('lottery:admin-auth-sync')->assertExitCode(0); }); @@ -221,7 +223,81 @@ test('connectivity test probes partner balance api', function (): void { $response->assertOk() ->assertJsonPath('data.probe.success', true) - ->assertJsonPath('data.probe.main_balance_minor', 12345); + ->assertJsonPath('data.probe.main_balance_minor', 12345) + ->assertJsonMissingPath('data.probe.response_preview'); +}); + +test('connectivity test rejects hostname resolving to a private address before sending', function (): void { + fakeWalletApiDns([ + 'wallet.private.test' => ['93.184.216.34', '10.0.0.8'], + ], []); + Http::preventStrayRequests(); + + $token = integrationAdminToken(); + $create = $this->withHeader('Authorization', 'Bearer '.$token) + ->postJson('/api/v1/admin/integration-sites', [ + 'code' => 'private-probe-site', + 'name' => 'Private Probe', + 'wallet_api_url' => 'https://wallet.private.test', + 'admin_account' => [ + 'username' => 'private_probe_admin', + 'nickname' => 'Private Probe Admin', + 'password' => 'secret-strong', + ], + ]); + + $this->withHeader('Authorization', 'Bearer '.$token) + ->postJson('/api/v1/admin/integration-sites/'.(int) $create->json('data.id').'/connectivity-test', [ + 'site_player_id' => '10001', + 'currency_code' => 'NPR', + ]) + ->assertOk() + ->assertJsonPath('data.probe.success', false) + ->assertJsonPath('data.probe.http_status', null) + ->assertJsonPath('data.probe.message', 'wallet_api_url 无效(拒绝以防 SSRF)') + ->assertJsonMissingPath('data.probe.response_preview'); + + Http::assertSentCount(0); +}); + +test('connectivity test does not follow redirects or expose response preview', function (): void { + fakeWalletApiDns([ + 'wallet.redirect.test' => ['93.184.216.34'], + ], []); + Http::fake([ + 'https://wallet.redirect.test/*' => Http::response( + ['secret' => 'must-not-be-returned'], + 302, + ['Location' => 'https://169.254.169.254/latest/meta-data'], + ), + 'https://169.254.169.254/*' => Http::response(['role' => 'internal'], 200), + ]); + + $token = integrationAdminToken(); + $create = $this->withHeader('Authorization', 'Bearer '.$token) + ->postJson('/api/v1/admin/integration-sites', [ + 'code' => 'redirect-probe-site', + 'name' => 'Redirect Probe', + 'wallet_api_url' => 'https://wallet.redirect.test', + 'admin_account' => [ + 'username' => 'redirect_probe_admin', + 'nickname' => 'Redirect Probe Admin', + 'password' => 'secret-strong', + ], + ]); + + $this->withHeader('Authorization', 'Bearer '.$token) + ->postJson('/api/v1/admin/integration-sites/'.(int) $create->json('data.id').'/connectivity-test', [ + 'site_player_id' => '10001', + 'currency_code' => 'NPR', + ]) + ->assertOk() + ->assertJsonPath('data.probe.success', false) + ->assertJsonPath('data.probe.http_status', 302) + ->assertJsonMissingPath('data.probe.response_preview') + ->assertJsonMissing(['must-not-be-returned']); + + Http::assertSentCount(1); }); test('export parameter sheet excludes plaintext secrets', function (): void { @@ -329,7 +405,7 @@ test('site scoped admin only sees bound integration sites', function (): void { }); test('player list is filtered by admin site binding', function (): void { - $this->seed(\Database\Seeders\CurrencySeeder::class); + $this->seed(CurrencySeeder::class); Player::query()->create([ 'site_code' => 'site-a', diff --git a/tests/Feature/AdminReportJobScopeTest.php b/tests/Feature/AdminReportJobScopeTest.php new file mode 100644 index 0000000..37cff3a --- /dev/null +++ b/tests/Feature/AdminReportJobScopeTest.php @@ -0,0 +1,234 @@ +artisan('lottery:admin-auth-sync')->assertExitCode(0); +}); + +/** + * @param list $permissionCodes + */ +function makeReportScopeAdmin(string $username, int $siteId, array $permissionCodes): AdminUser +{ + $admin = AdminUser::query()->create([ + 'username' => $username, + 'name' => $username, + 'email' => null, + 'password' => Hash::make('secret-strong'), + 'status' => 0, + ]); + + $role = AdminRole::query()->create([ + 'slug' => 'report_scope_'.$username, + 'code' => 'report_scope_'.$username, + 'name' => 'Report Scope '.$username, + 'scope_type' => AdminRole::SCOPE_SYSTEM, + 'status' => 1, + 'is_system' => false, + 'sort_order' => 0, + ]); + + $actionIds = DB::table('admin_menu_actions') + ->whereIn('permission_code', $permissionCodes) + ->pluck('id'); + + foreach ($actionIds as $actionId) { + DB::table('admin_role_menu_actions')->insert([ + 'role_id' => (int) $role->id, + 'menu_action_id' => (int) $actionId, + ]); + } + + DB::table('admin_user_site_roles')->insert([ + 'admin_user_id' => (int) $admin->id, + 'site_id' => $siteId, + 'role_id' => (int) $role->id, + 'granted_at' => now(), + ]); + + return $admin; +} + +function makeReportScopeJob(AdminUser $owner, string $jobNo, string $reportType = 'daily_profit_summary'): ReportJob +{ + return ReportJob::query()->create([ + 'job_no' => $jobNo, + 'admin_user_id' => (int) $owner->id, + 'report_type' => $reportType, + 'export_format' => 'csv', + 'filter_json' => [ + 'date_from' => now()->toDateString(), + 'date_to' => now()->toDateString(), + ], + 'status' => 'completed', + 'output_path' => 'reports/'.$jobNo.'.csv', + 'finished_at' => now(), + ]); +} + +function makeReportScopeSuperAdmin(): AdminUser +{ + $admin = AdminUser::query()->create([ + 'username' => 'report_scope_super', + 'name' => 'Report Scope Super', + 'email' => null, + 'password' => Hash::make('secret-strong'), + 'status' => 0, + ]); + grantSuperAdminRole($admin); + + return $admin; +} + +test('report job list show and download are owner only with an explicit super admin exception', function (): void { + $siteAId = (int) DB::table('admin_sites')->where('is_default', true)->value('id'); + $siteB = AdminSite::query()->create([ + 'code' => 'report-scope-b', + 'name' => 'Report Scope B', + 'currency_code' => 'NPR', + 'status' => 1, + 'is_default' => false, + ]); + $permissions = ['service.report.view', 'service.report.export']; + $owner = makeReportScopeAdmin('report_owner', $siteAId, $permissions); + $sameSitePeer = makeReportScopeAdmin('report_same_site_peer', $siteAId, $permissions); + $crossSitePeer = makeReportScopeAdmin('report_cross_site_peer', (int) $siteB->id, $permissions); + + $ownedJob = makeReportScopeJob($owner, 'RPT-SCOPE-OWN'); + $sameSiteJob = makeReportScopeJob($sameSitePeer, 'RPT-SCOPE-SAME'); + $crossSiteJob = makeReportScopeJob($crossSitePeer, 'RPT-SCOPE-CROSS'); + + $deletedCreator = makeReportScopeAdmin('report_deleted_creator', $siteAId, $permissions); + $orphanedJob = makeReportScopeJob($deletedCreator, 'RPT-SCOPE-ORPHAN'); + $deletedCreator->delete(); + $orphanedJob->refresh(); + expect($orphanedJob->admin_user_id)->toBeNull(); + + Sanctum::actingAs($owner, ['*']); + $visibleIds = collect($this->getJson('/api/v1/admin/report-jobs') + ->assertOk() + ->json('data.items')) + ->pluck('id') + ->all(); + + expect($visibleIds)->toBe([(int) $ownedJob->id]); + + $this->getJson('/api/v1/admin/report-jobs/'.$ownedJob->id)->assertOk(); + $this->get('/api/v1/admin/report-jobs/'.$ownedJob->id.'/download')->assertOk(); + + foreach ([$sameSiteJob, $crossSiteJob, $orphanedJob] as $deniedJob) { + $this->getJson('/api/v1/admin/report-jobs/'.$deniedJob->id)->assertForbidden(); + $this->get('/api/v1/admin/report-jobs/'.$deniedJob->id.'/download')->assertForbidden(); + } + + $super = makeReportScopeSuperAdmin(); + Sanctum::actingAs($super, ['*']); + $superVisibleIds = collect($this->getJson('/api/v1/admin/report-jobs') + ->assertOk() + ->json('data.items')) + ->pluck('id') + ->all(); + + expect($superVisibleIds)->toContain( + (int) $ownedJob->id, + (int) $sameSiteJob->id, + (int) $crossSiteJob->id, + (int) $orphanedJob->id, + ); + + $this->getJson('/api/v1/admin/report-jobs/'.$orphanedJob->id)->assertOk(); + $this->get('/api/v1/admin/report-jobs/'.$orphanedJob->id.'/download')->assertOk(); +}); + +test('sensitive report types require their own capabilities and risk export is super admin only', function (): void { + $siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id'); + $base = makeReportScopeAdmin('report_base_exporter', $siteId, [ + 'service.report.view', + 'service.report.export', + ]); + $auditor = makeReportScopeAdmin('report_auditor', $siteId, [ + 'service.report.view', + 'service.report.export', + 'service.audit.view', + ]); + $riskViewer = makeReportScopeAdmin('report_risk_viewer', $siteId, [ + 'service.report.view', + 'service.report.export', + 'risk.monitor.view', + ]); + + Sanctum::actingAs($base, ['*']); + $this->postJson('/api/v1/admin/report-jobs', [ + 'report_type' => 'audit_operation_report', + ])->assertForbidden(); + $legacyAuditJob = makeReportScopeJob($base, 'RPT-SENSITIVE-AUDIT', 'audit_operation_report'); + $this->get('/api/v1/admin/report-jobs/'.$legacyAuditJob->id.'/download')->assertForbidden(); + + Sanctum::actingAs($auditor, ['*']); + $this->postJson('/api/v1/admin/report-jobs', [ + 'report_type' => 'audit_operation_report', + ])->assertOk(); + + Sanctum::actingAs($riskViewer, ['*']); + $legacyRiskJob = makeReportScopeJob($riskViewer, 'RPT-SENSITIVE-RISK', 'hot_number_risk_report'); + foreach (['hot_number_risk_report', 'sold_out_number_report'] as $reportType) { + $this->postJson('/api/v1/admin/report-jobs', [ + 'report_type' => $reportType, + ])->assertForbidden(); + } + $this->get('/api/v1/admin/report-jobs/'.$legacyRiskJob->id.'/download')->assertForbidden(); + + $super = makeReportScopeSuperAdmin(); + Sanctum::actingAs($super, ['*']); + foreach (['hot_number_risk_report', 'sold_out_number_report'] as $reportType) { + $created = $this->postJson('/api/v1/admin/report-jobs', [ + 'report_type' => $reportType, + ])->assertOk(); + $this->get('/api/v1/admin/report-jobs/'.(int) $created->json('data.id').'/download')->assertOk(); + } +}); + +test('non super audit export is limited to the current actor', function (): void { + $siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id'); + $auditor = makeReportScopeAdmin('report_audit_owner', $siteId, [ + 'service.report.view', + 'service.report.export', + 'service.audit.view', + ]); + $other = makeReportScopeAdmin('report_audit_other', $siteId, [ + 'service.report.view', + 'service.report.export', + 'service.audit.view', + ]); + + AuditLogger::recordForAdmin($auditor, null, 'audit_scope_own', 'own_action', null, null, null, null); + AuditLogger::recordForAdmin($other, null, 'audit_scope_other', 'other_action', null, null, null, null); + + Sanctum::actingAs($auditor, ['*']); + $create = $this->postJson('/api/v1/admin/report-jobs', [ + 'report_type' => 'audit_operation_report', + 'export_format' => 'csv', + 'parameters' => [ + 'date_from' => now()->toDateString(), + 'date_to' => now()->toDateString(), + ], + ])->assertOk(); + + $content = $this->get('/api/v1/admin/report-jobs/'.(int) $create->json('data.id').'/download') + ->assertOk() + ->streamedContent(); + + expect($content)->toContain('audit_scope_own') + ->not->toContain('audit_scope_other'); +}); diff --git a/tests/Feature/GameSettlementReversalTest.php b/tests/Feature/GameSettlementReversalTest.php index e31b8e6..4f3b6fd 100644 --- a/tests/Feature/GameSettlementReversalTest.php +++ b/tests/Feature/GameSettlementReversalTest.php @@ -1,10 +1,13 @@ 0, ]); - $drawId = (int) \App\Models\Draw::query()->create([ + $drawId = (int) Draw::query()->create([ 'draw_no' => 'REV-DRAW-1', 'business_date' => now()->toDateString(), 'sequence_no' => 1, - 'status' => \App\Lottery\DrawStatus::Open->value, + 'status' => DrawStatus::Open->value, 'current_result_version' => 0, 'settle_version' => 0, 'is_reopened' => false, @@ -148,11 +151,11 @@ test('reversal restores credit used for settled win on credit player', function 'updated_at' => now(), ]); - $drawId = (int) \App\Models\Draw::query()->create([ + $drawId = (int) Draw::query()->create([ 'draw_no' => 'REV-CREDIT-DRAW', 'business_date' => now()->toDateString(), 'sequence_no' => 1, - 'status' => \App\Lottery\DrawStatus::Open->value, + 'status' => DrawStatus::Open->value, 'current_result_version' => 0, 'settle_version' => 0, 'is_reopened' => false, @@ -219,12 +222,52 @@ test('reversal restores credit used for settled win on credit player', function 'updated_at' => now(), ]); - // Simulate post-win used_credit (win decreased used by 10 major for 1000 minor). + // 模拟结算前已有 11 主单位占额:释放本注 1,再以中奖额释放 10,结算后为 0。 DB::table('player_credit_accounts')->where('player_id', $player->id)->update(['used_credit' => 0]); $item = TicketItem::query()->findOrFail($itemId); app(GameSettlementReversalService::class)->reverseTicketItem($item); - expect((int) DB::table('player_credit_accounts')->where('player_id', $player->id)->value('used_credit'))->toBe(10); + expect((int) DB::table('player_credit_accounts')->where('player_id', $player->id)->value('used_credit'))->toBe(11); expect(DB::table('credit_ledger')->where('reason', 'game_settlement_reversal')->where('owner_id', $player->id)->exists())->toBeTrue(); }); + +test('reversal restores only the credit actually released by an oversized win', function (): void { + $site = DB::table('admin_sites')->where('is_default', true)->first(); + $player = Player::query()->create([ + 'site_code' => (string) $site->code, + 'agent_node_id' => (int) DB::table('agent_nodes')->where('depth', 0)->value('id'), + 'site_player_id' => 'rev-credit-clipped', + 'auth_source' => 'lottery_native', + 'funding_mode' => 'credit', + 'username' => 'revcreditclipped', + 'nickname' => null, + 'default_currency' => 'NPR', + 'status' => 0, + ]); + + DB::table('player_credit_accounts')->insert([ + 'player_id' => $player->id, + 'credit_limit' => 10000, + 'used_credit' => 1, + 'frozen_credit' => 0, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $credit = app(PlayerCreditService::class); + $credit->releaseBetHold($player, 100, 99881, 1); + $credit->applySettledWin($player, 1000, 99881, 1); + + expect((int) DB::table('player_credit_accounts')->where('player_id', $player->id)->value('used_credit'))->toBe(0) + ->and((int) DB::table('credit_ledger') + ->where('ref_id', 99881) + ->where('reason', 'game_settlement_win') + ->where('settlement_version', 1) + ->value('amount'))->toBe(0); + + $credit->reverseGameSettlement($player, -1000, 99881, 1); + $credit->restoreBetHoldAfterSettlementReversal($player, 100, 99881, 1); + + expect((int) DB::table('player_credit_accounts')->where('player_id', $player->id)->value('used_credit'))->toBe(1); +}); diff --git a/tests/Feature/PlayerFoundationTest.php b/tests/Feature/PlayerFoundationTest.php index b1afae7..d39954f 100644 --- a/tests/Feature/PlayerFoundationTest.php +++ b/tests/Feature/PlayerFoundationTest.php @@ -116,6 +116,37 @@ test('jwt first successful login auto-registers player mapping', function () { ->and($player->nickname)->toBe($username); }); +test('main site sso jwt cannot take over an existing native credit player mapping', function (): void { + config(['lottery.player_auth.dev_bypass' => false]); + config(['lottery.main_site.sso_jwt_secret' => 'jwt-test-secret-at-least-32-bytes-long']); + + $native = Player::query()->create([ + 'site_code' => 'main', + 'site_player_id' => 'native-sso-collision', + 'auth_source' => 'lottery_native', + 'funding_mode' => 'credit', + 'username' => 'native_collision', + 'nickname' => null, + 'default_currency' => 'NPR', + 'status' => 0, + ]); + + $now = time(); + $jwt = JWT::encode([ + 'site_code' => 'main', + 'site_player_id' => $native->site_player_id, + 'iat' => $now, + 'exp' => $now + 300, + ], 'jwt-test-secret-at-least-32-bytes-long', 'HS256'); + + $this->withHeader('Authorization', 'Bearer '.$jwt) + ->getJson('/api/v1/player/me') + ->assertUnauthorized() + ->assertJsonPath('code', ErrorCode::PlayerTokenInvalid->value); + + expect($native->fresh()->last_login_at)->toBeNull(); +}); + test('player me rejects non-active status with 8005', function () { $code = ErrorCode::PlayerAccountSuspended->value; $player = Player::query()->create([ diff --git a/tests/Feature/PlayerNativeJwtSecretGuardTest.php b/tests/Feature/PlayerNativeJwtSecretGuardTest.php new file mode 100644 index 0000000..9b1c9eb --- /dev/null +++ b/tests/Feature/PlayerNativeJwtSecretGuardTest.php @@ -0,0 +1,174 @@ + 'independent-native-secret-32bytes!!', + 'lottery.player_auth.native.ttl_seconds' => 3600, + 'lottery.main_site.sso_jwt_secret' => null, + 'lottery.main_site.wallet_api_url' => null, + ]); + + $this->seed(CurrencySeeder::class); + $this->seed(LotterySettingsSeeder::class); +}); + +function nativeSecretGuardPlayer(string $suffix): Player +{ + $site = DB::table('admin_sites')->where('is_default', true)->first(); + $rootId = (int) DB::table('agent_nodes')->where('depth', 0)->value('id'); + + return Player::query()->create([ + 'site_code' => (string) $site->code, + 'agent_node_id' => $rootId, + 'site_player_id' => 'native:secret-guard-'.$suffix, + 'auth_source' => PlayerAuthSource::LOTTERY_NATIVE, + 'funding_mode' => PlayerFundingMode::CREDIT, + 'username' => 'secret_guard_'.$suffix, + 'password_hash' => Hash::make('secret-pass'), + 'default_currency' => 'NPR', + 'status' => 0, + ]); +} + +function nativeSecretGuardToken(Player $player, string $secret): string +{ + $now = time(); + + return JWT::encode([ + 'player_id' => (int) $player->id, + 'auth_source' => PlayerAuthSource::LOTTERY_NATIVE, + 'token_version' => (int) ($player->native_token_version ?? 0), + 'site_code' => (string) $player->site_code, + 'iat' => $now, + 'exp' => $now + 3600, + ], $secret, 'HS256'); +} + +function expectNativeSecretConfigurationRejected(Closure $callback): void +{ + try { + $callback(); + test()->fail('Expected native JWT secret configuration to be rejected.'); + } catch (PlayerAuthenticationException $exception) { + expect($exception->lotteryCode)->toBe(ErrorCode::PlayerSsoSecretNotConfigured->value) + ->and($exception->httpStatus)->toBe(503); + } +} + +test('missing native secret rejects token issuance and verification with 503', function (): void { + $player = nativeSecretGuardPlayer('missing'); + config(['lottery.player_auth.native.secret' => null]); + + expectNativeSecretConfigurationRejected( + fn () => app(PlayerNativeAuthService::class)->issueToken($player), + ); + + $token = nativeSecretGuardToken($player, 'previous-native-secret-32bytes!!'); + $this->withHeader('Authorization', 'Bearer '.$token) + ->getJson('/api/v1/player/me') + ->assertStatus(503) + ->assertJsonPath('code', ErrorCode::PlayerSsoSecretNotConfigured->value); +}); + +test('native secret matching legacy sso secret rejects token issuance and verification', function (): void { + $player = nativeSecretGuardPlayer('legacy-match'); + $sharedSecret = 'shared-legacy-native-secret-32bytes!!'; + config([ + 'lottery.player_auth.native.secret' => $sharedSecret, + 'lottery.main_site.sso_jwt_secret' => $sharedSecret, + ]); + + expectNativeSecretConfigurationRejected( + fn () => app(PlayerNativeAuthService::class)->issueToken($player), + ); + + $token = nativeSecretGuardToken($player, $sharedSecret); + $this->withHeader('Authorization', 'Bearer '.$token) + ->getJson('/api/v1/player/me') + ->assertStatus(503) + ->assertJsonPath('code', ErrorCode::PlayerSsoSecretNotConfigured->value); +}); + +test('native secret matching enabled database site sso secret rejects issuance and verification', function (): void { + $player = nativeSecretGuardPlayer('database-match'); + $sharedSecret = 'shared-database-native-secret-32bytes!!'; + config([ + 'lottery.player_auth.native.secret' => $sharedSecret, + 'lottery.main_site.sso_jwt_secret' => 'different-legacy-sso-secret-32bytes!!', + ]); + DB::table('admin_sites')->where('is_default', true)->update([ + 'status' => 1, + 'sso_jwt_secret_encrypted' => encrypt($sharedSecret), + 'updated_at' => now(), + ]); + + expectNativeSecretConfigurationRejected( + fn () => app(PlayerNativeAuthService::class)->issueToken($player), + ); + + $token = nativeSecretGuardToken($player, $sharedSecret); + $this->withHeader('Authorization', 'Bearer '.$token) + ->getJson('/api/v1/player/me') + ->assertStatus(503) + ->assertJsonPath('code', ErrorCode::PlayerSsoSecretNotConfigured->value); +}); + +test('native secret matching disabled database site sso secret rejects issuance and verification', function (): void { + $player = nativeSecretGuardPlayer('disabled-database-match'); + $sharedSecret = 'shared-disabled-site-secret-32bytes!!'; + config([ + 'lottery.player_auth.native.secret' => $sharedSecret, + 'lottery.main_site.sso_jwt_secret' => 'different-legacy-sso-secret-32bytes!!', + ]); + DB::table('admin_sites')->where('is_default', true)->update([ + 'status' => 0, + 'sso_jwt_secret_encrypted' => encrypt($sharedSecret), + 'updated_at' => now(), + ]); + + expectNativeSecretConfigurationRejected( + fn () => app(PlayerNativeAuthService::class)->issueToken($player), + ); + + $token = nativeSecretGuardToken($player, $sharedSecret); + $this->withHeader('Authorization', 'Bearer '.$token) + ->getJson('/api/v1/player/me') + ->assertStatus(503) + ->assertJsonPath('code', ErrorCode::PlayerSsoSecretNotConfigured->value); +}); + +test('independent native secret still issues and verifies tokens', function (): void { + $player = nativeSecretGuardPlayer('independent'); + config([ + 'lottery.player_auth.native.secret' => 'independent-native-secret-32bytes!!', + 'lottery.main_site.sso_jwt_secret' => 'different-legacy-sso-secret-32bytes!!', + ]); + DB::table('admin_sites')->where('is_default', true)->update([ + 'status' => 1, + 'sso_jwt_secret_encrypted' => encrypt('different-database-sso-secret-32bytes!!'), + 'updated_at' => now(), + ]); + + $token = app(PlayerNativeAuthService::class)->issueToken($player); + + $this->withHeader('Authorization', 'Bearer '.$token) + ->getJson('/api/v1/player/me') + ->assertOk() + ->assertJsonPath('data.id', $player->id) + ->assertJsonPath('data.auth_source', PlayerAuthSource::LOTTERY_NATIVE); +}); diff --git a/tests/Feature/PlayerRealtimeBroadcastTest.php b/tests/Feature/PlayerRealtimeBroadcastTest.php index bf7a334..70f4b45 100644 --- a/tests/Feature/PlayerRealtimeBroadcastTest.php +++ b/tests/Feature/PlayerRealtimeBroadcastTest.php @@ -1,21 +1,25 @@ seed(LotterySettingsSeeder::class); }); +test('balance update broadcasts only on the player private channel', function (): void { + $event = new BalanceUpdateBroadcast(42, 'NPR', 10_000, -500, 'bet', 1_234_567); + $channels = $event->broadcastOn(); + + expect($channels)->toHaveCount(1) + ->and($channels[0])->toBeInstanceOf(PrivateChannel::class) + ->and($channels[0]->name)->toBe('private-player.42'); +}); + +test('player private channel auth allows only the matching bearer player', function (): void { + config([ + 'broadcasting.default' => 'reverb', + 'broadcasting.connections.reverb.key' => 'test-reverb-key', + 'broadcasting.connections.reverb.secret' => 'test-reverb-secret', + 'broadcasting.connections.reverb.app_id' => 'test-reverb-app', + ]); + Broadcast::purge('reverb'); + require base_path('routes/channels.php'); + + $player = Player::query()->create([ + 'site_code' => 'test', + 'site_player_id' => 'ws-auth-owner', + 'default_currency' => 'NPR', + 'status' => 0, + ]); + $otherPlayer = Player::query()->create([ + 'site_code' => 'test', + 'site_player_id' => 'ws-auth-other', + 'default_currency' => 'NPR', + 'status' => 0, + ]); + $payload = [ + 'socket_id' => '1234.5678', + 'channel_name' => 'private-player.'.$player->id, + ]; + + $this->postJson('/api/broadcasting/auth', $payload) + ->assertUnauthorized(); + + $this->withHeader('Authorization', 'Bearer dev:'.$otherPlayer->id) + ->postJson('/api/broadcasting/auth', $payload) + ->assertForbidden(); + + $this->withHeader('Authorization', 'Bearer dev:'.$player->id) + ->postJson('/api/broadcasting/auth', $payload) + ->assertOk() + ->assertJsonStructure(['auth']); +}); + test('wallet balance notifier dispatches balance update broadcast', function (): void { Event::fake([BalanceUpdateBroadcast::class]); @@ -109,11 +162,11 @@ test('risk pool acquire dispatches warning and sold out broadcasts', function () test('risk cap publish dispatches play catalog updated broadcast', function (): void { Event::fake([PlayCatalogUpdatedBroadcast::class]); - $admin = \App\Models\AdminUser::query()->create([ + $admin = AdminUser::query()->create([ 'username' => 'risk_cap_admin', 'name' => 'Risk Cap QA', 'email' => null, - 'password' => \Illuminate\Support\Facades\Hash::make('secret-strong'), + 'password' => Hash::make('secret-strong'), 'status' => 0, ]); grantSuperAdminRole($admin); diff --git a/tests/Feature/SettlementChunkAndProviderPayoutTest.php b/tests/Feature/SettlementChunkAndProviderPayoutTest.php new file mode 100644 index 0000000..e47b7df --- /dev/null +++ b/tests/Feature/SettlementChunkAndProviderPayoutTest.php @@ -0,0 +1,401 @@ +create([ + 'draw_no' => 'CHUNK-'.$suffix, + 'business_date' => '2026-07-22', + 'sequence_no' => random_int(1000, 9999), + 'status' => DrawStatus::Settling->value, + 'start_time' => now()->subMinutes(10), + 'close_time' => now()->subMinutes(2), + 'draw_time' => now()->subMinute(), + 'current_result_version' => 1, + 'settle_version' => 0, + 'is_reopened' => false, + ]); +} + +function createChunkTestPlayer(string $suffix): Player +{ + $player = Player::query()->create([ + 'site_code' => 'test', + 'site_player_id' => 'chunk-'.$suffix, + 'username' => 'chunk_'.$suffix, + 'default_currency' => 'NPR', + 'status' => 0, + ]); + PlayerWallet::query()->create([ + 'player_id' => $player->id, + 'wallet_type' => 'lottery', + 'currency_code' => 'NPR', + 'balance' => 0, + 'frozen_balance' => 0, + 'status' => 0, + 'version' => 0, + ]); + + return $player; +} + +function createChunkTestResult(Draw $draw, string $providerCode): DrawResultBatch +{ + $batch = DrawResultBatch::query()->create([ + 'draw_id' => $draw->id, + 'provider_code' => $providerCode, + 'provider_name' => $providerCode, + 'result_version' => 1, + 'source_type' => 'manual', + 'status' => DrawResultBatchStatus::Published->value, + 'confirmed_at' => now(), + ]); + + foreach (DrawPrizeLayout::slots() as $slot) { + $number = $slot['prize_type'] === 'first' ? '1234' : '5678'; + DrawResultItem::query()->create([ + 'draw_id' => $draw->id, + 'result_batch_id' => $batch->id, + 'prize_type' => $slot['prize_type'], + 'prize_index' => $slot['prize_index'], + 'number_4d' => $number, + 'suffix_3d' => substr($number, -3), + 'suffix_2d' => substr($number, -2), + 'head_digit' => (int) $number[0], + 'tail_digit' => (int) $number[3], + ]); + } + + return $batch; +} + +function createChunkTestTicket( + Draw $draw, + Player $player, + TicketOrder $order, + string $providerCode, + string $suffix, +): TicketItem { + $item = TicketItem::query()->create([ + 'ticket_no' => 'CHUNK-TICKET-'.$suffix, + 'order_id' => $order->id, + 'player_id' => $player->id, + 'draw_id' => $draw->id, + 'provider_code' => $providerCode, + 'provider_name' => $providerCode, + 'original_number' => '1234', + 'normalized_number' => '1234', + 'play_code' => 'pos_4a', + 'bet_mode' => 'unit', + 'unit_bet_amount' => 10_000, + 'total_bet_amount' => 10_000, + 'actual_deduct_amount' => 10_000, + 'odds_snapshot_json' => [['prize_scope' => 'first', 'odds_value' => 100_000]], + 'rule_snapshot_json' => [], + 'combination_count' => 1, + 'estimated_max_payout' => 100_000, + 'risk_locked_amount' => 0, + 'status' => 'pending_draw', + 'win_amount' => 0, + 'jackpot_win_amount' => 0, + ]); + TicketCombination::query()->create([ + 'ticket_item_id' => $item->id, + 'combination_no' => 1, + 'number_4d' => '1234', + 'bet_amount' => 10_000, + 'estimated_payout' => 100_000, + ]); + + return $item; +} + +function createChunkTestOrder(Draw $draw, Player $player, string $suffix, int $ticketCount): TicketOrder +{ + return TicketOrder::query()->create([ + 'order_no' => 'CHUNK-ORDER-'.$suffix, + 'player_id' => $player->id, + 'draw_id' => $draw->id, + 'currency_code' => 'NPR', + 'total_bet_amount' => 10_000 * $ticketCount, + 'total_rebate_amount' => 0, + 'total_actual_deduct' => 10_000 * $ticketCount, + 'total_estimated_payout' => 100_000 * $ticketCount, + 'status' => 'placed', + 'submit_source' => 'test', + 'client_trace_id' => 'chunk-trace-'.$suffix, + ]); +} + +test('settlement processes every ticket chunk into one provider batch', function (): void { + config()->set('lottery.settlement.ticket_chunk_size', 2); + $suffix = bin2hex(random_bytes(4)); + $draw = createChunkTestDraw($suffix); + $player = createChunkTestPlayer($suffix); + $order = createChunkTestOrder($draw, $player, $suffix, 3); + createChunkTestResult($draw, 'SG'); + + for ($i = 1; $i <= 3; $i++) { + createChunkTestTicket($draw, $player, $order, 'SG', $suffix.'-'.$i); + } + + expect(app(SettlementOrchestrator::class)->trySettleDraw($draw))->toBeTrue(); + + $batch = SettlementBatch::query()->where('draw_id', $draw->id)->firstOrFail(); + expect(SettlementBatch::query()->where('draw_id', $draw->id)->count())->toBe(1) + ->and($batch->status)->toBe(SettlementBatchStatus::PendingReview->value) + ->and((int) $batch->total_ticket_count)->toBe(3) + ->and((int) $batch->total_win_count)->toBe(3) + ->and((int) $batch->total_payout_amount)->toBe(300_000) + ->and($batch->details()->count())->toBe(3) + ->and(TicketItem::query()->where('draw_id', $draw->id)->where('status', 'pending_draw')->exists())->toBeFalse(); +}); + +test('settlement resumes a legacy partial provider batch instead of skipping remaining tickets', function (): void { + config()->set('lottery.settlement.ticket_chunk_size', 2); + $suffix = bin2hex(random_bytes(4)); + $draw = createChunkTestDraw($suffix); + $player = createChunkTestPlayer($suffix); + $order = createChunkTestOrder($draw, $player, $suffix, 3); + createChunkTestResult($draw, 'SG'); + createChunkTestTicket($draw, $player, $order, 'SG', $suffix.'-1'); + createChunkTestTicket($draw, $player, $order, 'SG', $suffix.'-2'); + + app(SettlementOrchestrator::class)->trySettleDraw($draw); + $existingBatch = SettlementBatch::query()->where('draw_id', $draw->id)->firstOrFail(); + + createChunkTestTicket($draw, $player, $order, 'SG', $suffix.'-3'); + expect(app(SettlementOrchestrator::class)->trySettleDraw($draw->fresh()))->toBeTrue(); + + $resumedBatch = SettlementBatch::query()->where('draw_id', $draw->id)->firstOrFail(); + expect((int) $resumedBatch->id)->toBe((int) $existingBatch->id) + ->and(SettlementBatch::query()->where('draw_id', $draw->id)->count())->toBe(1) + ->and($resumedBatch->status)->toBe(SettlementBatchStatus::PendingReview->value) + ->and((int) $resumedBatch->total_ticket_count)->toBe(3) + ->and($resumedBatch->details()->count())->toBe(3) + ->and(TicketItem::query()->where('draw_id', $draw->id)->where('status', 'pending_draw')->exists())->toBeFalse(); +}); + +test('approved provider batches payout independently and settle draw after the last provider', function (): void { + config()->set('lottery.settlement.ticket_chunk_size', 1); + $suffix = bin2hex(random_bytes(4)); + $draw = createChunkTestDraw($suffix); + $player = createChunkTestPlayer($suffix); + $order = createChunkTestOrder($draw, $player, $suffix, 2); + + foreach (['SG', 'MY'] as $providerCode) { + createChunkTestResult($draw, $providerCode); + createChunkTestTicket($draw, $player, $order, $providerCode, $suffix.'-'.$providerCode); + } + + expect(app(SettlementOrchestrator::class)->trySettleDraw($draw))->toBeTrue(); + + $batches = SettlementBatch::query()->where('draw_id', $draw->id)->orderBy('id')->get(); + expect($batches)->toHaveCount(2) + ->and($batches->every(fn (SettlementBatch $batch): bool => $batch->status === SettlementBatchStatus::PendingReview->value))->toBeTrue(); + + $admin = AdminUser::query()->create([ + 'username' => 'chunk_reviewer_'.$suffix, + 'name' => 'Chunk Reviewer', + 'password' => Hash::make('secret-strong'), + 'status' => 0, + ]); + $workflow = app(SettlementBatchWorkflowService::class); + foreach ($batches as $batch) { + $workflow->approve($batch, $admin); + } + + expect(app(SettlementOrchestrator::class)->trySettleDraw($draw->fresh()))->toBeTrue() + ->and(SettlementBatch::query()->where('draw_id', $draw->id)->where('status', SettlementBatchStatus::Approved->value)->count())->toBe(2); + + $workflow->payout($batches[0]->fresh()); + expect($draw->fresh()->status)->toBe(DrawStatus::Settling->value) + ->and(SettlementBatch::query()->whereKey($batches[0]->id)->value('status'))->toBe(SettlementBatchStatus::Paid->value) + ->and(TicketItem::query()->where('draw_id', $draw->id)->where('status', 'pending_payout')->count())->toBe(1); + + $workflow->payout($batches[1]->fresh()); + expect($draw->fresh()->status)->toBe(DrawStatus::Settled->value) + ->and(SettlementBatch::query()->where('draw_id', $draw->id)->where('status', SettlementBatchStatus::Paid->value)->count())->toBe(2) + ->and(TicketItem::query()->where('draw_id', $draw->id)->where('status', 'settled_win')->count())->toBe(2) + ->and($order->fresh()->status)->toBe('settled'); +}); + +test('tick finalizer approves all provider batches before paying them', function (): void { + $suffix = bin2hex(random_bytes(4)); + $draw = createChunkTestDraw($suffix); + $player = createChunkTestPlayer($suffix); + $order = createChunkTestOrder($draw, $player, $suffix, 2); + + foreach (['SG', 'MY'] as $providerCode) { + createChunkTestResult($draw, $providerCode); + createChunkTestTicket($draw, $player, $order, $providerCode, $suffix.'-'.$providerCode); + } + + app(SettlementOrchestrator::class)->trySettleDraw($draw); + $result = app(SettlementTickFinalizer::class)->finalizePendingBatches(); + + expect($result)->toMatchArray(['approved' => 2, 'paid' => 2, 'payout_failed' => 0]) + ->and($draw->fresh()->status)->toBe(DrawStatus::Settled->value) + ->and(SettlementBatch::query()->where('draw_id', $draw->id)->where('status', SettlementBatchStatus::Paid->value)->count())->toBe(2) + ->and(TicketItem::query()->where('draw_id', $draw->id)->where('status', 'settled_win')->count())->toBe(2); +}); + +test('tick finalizer resumes approved batches left before payout', function (): void { + $suffix = bin2hex(random_bytes(4)); + $draw = createChunkTestDraw($suffix); + $player = createChunkTestPlayer($suffix); + $order = createChunkTestOrder($draw, $player, $suffix, 2); + + foreach (['SG', 'MY'] as $providerCode) { + createChunkTestResult($draw, $providerCode); + createChunkTestTicket($draw, $player, $order, $providerCode, $suffix.'-'.$providerCode); + } + + app(SettlementOrchestrator::class)->trySettleDraw($draw); + $workflow = app(SettlementBatchWorkflowService::class); + foreach (SettlementBatch::query()->where('draw_id', $draw->id)->get() as $batch) { + $workflow->approveBySystem($batch, 'simulate restart after approval'); + } + + expect(SettlementBatch::query()->where('draw_id', $draw->id)->where('status', SettlementBatchStatus::PendingReview->value)->exists())->toBeFalse() + ->and(SettlementBatch::query()->where('draw_id', $draw->id)->where('status', SettlementBatchStatus::Approved->value)->count())->toBe(2); + + $result = app(SettlementTickFinalizer::class)->finalizePendingBatches(); + + expect($result)->toMatchArray(['approved' => 0, 'paid' => 2, 'payout_failed' => 0]) + ->and($draw->fresh()->status)->toBe(DrawStatus::Settled->value) + ->and(SettlementBatch::query()->where('draw_id', $draw->id)->where('status', SettlementBatchStatus::Paid->value)->count())->toBe(2) + ->and(TicketItem::query()->where('draw_id', $draw->id)->where('status', 'settled_win')->count())->toBe(2); +}); + +test('blocked approved draw does not starve a later eligible draw at the scan limit', function (): void { + config()->set('lottery.draw_tick_finalize_limit', 1); + + $blockedSuffix = bin2hex(random_bytes(4)); + $blockedDraw = createChunkTestDraw($blockedSuffix); + $blockedResult = createChunkTestResult($blockedDraw, 'BLOCKED'); + foreach ([SettlementBatchStatus::Approved, SettlementBatchStatus::Running] as $status) { + SettlementBatch::query()->create([ + 'draw_id' => $blockedDraw->id, + 'result_batch_id' => $blockedResult->id, + 'settle_version' => 1, + 'status' => $status->value, + 'total_ticket_count' => 0, + 'total_win_count' => 0, + 'total_payout_amount' => 0, + 'total_jackpot_payout_amount' => 0, + 'review_status' => $status === SettlementBatchStatus::Approved ? 'approved' : 'pending', + 'reviewed_at' => $status === SettlementBatchStatus::Approved ? now() : null, + 'started_at' => now(), + 'finished_at' => $status === SettlementBatchStatus::Approved ? now() : null, + ]); + } + + $eligibleSuffix = bin2hex(random_bytes(4)); + $eligibleDraw = createChunkTestDraw($eligibleSuffix); + $player = createChunkTestPlayer($eligibleSuffix); + $order = createChunkTestOrder($eligibleDraw, $player, $eligibleSuffix, 1); + createChunkTestResult($eligibleDraw, 'SG'); + createChunkTestTicket($eligibleDraw, $player, $order, 'SG', $eligibleSuffix.'-SG'); + app(SettlementOrchestrator::class)->trySettleDraw($eligibleDraw); + $eligibleBatch = SettlementBatch::query()->where('draw_id', $eligibleDraw->id)->firstOrFail(); + app(SettlementBatchWorkflowService::class)->approveBySystem($eligibleBatch, 'simulate approved backlog'); + + $result = app(SettlementTickFinalizer::class)->finalizePendingBatches(); + + expect($result)->toMatchArray(['approved' => 0, 'paid' => 1, 'payout_failed' => 0]) + ->and($eligibleBatch->fresh()->status)->toBe(SettlementBatchStatus::Paid->value) + ->and($eligibleDraw->fresh()->status)->toBe(DrawStatus::Settled->value) + ->and(SettlementBatch::query()->where('draw_id', $blockedDraw->id)->where('status', SettlementBatchStatus::Approved->value)->count())->toBe(1); +}); + +test('manual approval still allows automatic payout when auto approval is disabled', function (): void { + $approvedSuffix = bin2hex(random_bytes(4)); + $approvedDraw = createChunkTestDraw($approvedSuffix); + $approvedPlayer = createChunkTestPlayer($approvedSuffix); + $approvedOrder = createChunkTestOrder($approvedDraw, $approvedPlayer, $approvedSuffix, 1); + createChunkTestResult($approvedDraw, 'SG'); + createChunkTestTicket($approvedDraw, $approvedPlayer, $approvedOrder, 'SG', $approvedSuffix.'-SG'); + app(SettlementOrchestrator::class)->trySettleDraw($approvedDraw); + $approvedBatch = SettlementBatch::query()->where('draw_id', $approvedDraw->id)->firstOrFail(); + app(SettlementBatchWorkflowService::class)->approveBySystem($approvedBatch, 'manual approval simulation'); + + $pendingSuffix = bin2hex(random_bytes(4)); + $pendingDraw = createChunkTestDraw($pendingSuffix); + $pendingPlayer = createChunkTestPlayer($pendingSuffix); + $pendingOrder = createChunkTestOrder($pendingDraw, $pendingPlayer, $pendingSuffix, 1); + createChunkTestResult($pendingDraw, 'MY'); + createChunkTestTicket($pendingDraw, $pendingPlayer, $pendingOrder, 'MY', $pendingSuffix.'-MY'); + app(SettlementOrchestrator::class)->trySettleDraw($pendingDraw); + $pendingBatch = SettlementBatch::query()->where('draw_id', $pendingDraw->id)->firstOrFail(); + + LotterySettings::put('settlement.auto_approve_on_tick', false, 'settlement', 'test manual approval mode'); + LotterySettings::put('settlement.auto_payout_on_tick', true, 'settlement', 'test automatic payout mode'); + + $result = app(SettlementTickFinalizer::class)->finalizePendingBatches(); + + expect($result)->toMatchArray(['approved' => 0, 'paid' => 1, 'payout_failed' => 0]) + ->and($approvedBatch->fresh()->status)->toBe(SettlementBatchStatus::Paid->value) + ->and($approvedDraw->fresh()->status)->toBe(DrawStatus::Settled->value) + ->and($pendingBatch->fresh()->status)->toBe(SettlementBatchStatus::PendingReview->value) + ->and($pendingDraw->fresh()->status)->toBe(DrawStatus::Settling->value); +}); + +test('a repeatedly failing approved draw does not starve a later healthy draw', function (): void { + config()->set('lottery.draw_tick_finalize_limit', 1); + + $poisonSuffix = bin2hex(random_bytes(4)); + $poisonDraw = createChunkTestDraw($poisonSuffix); + $poisonPlayer = createChunkTestPlayer($poisonSuffix); + $poisonOrder = createChunkTestOrder($poisonDraw, $poisonPlayer, $poisonSuffix, 1); + createChunkTestResult($poisonDraw, 'SG'); + createChunkTestTicket($poisonDraw, $poisonPlayer, $poisonOrder, 'SG', $poisonSuffix.'-SG'); + app(SettlementOrchestrator::class)->trySettleDraw($poisonDraw); + $poisonBatch = SettlementBatch::query()->where('draw_id', $poisonDraw->id)->firstOrFail(); + app(SettlementBatchWorkflowService::class)->approveBySystem($poisonBatch, 'simulate poison batch'); + createChunkTestTicket($poisonDraw, $poisonPlayer, $poisonOrder, 'SG', $poisonSuffix.'-ORPHAN'); + + $healthySuffix = bin2hex(random_bytes(4)); + $healthyDraw = createChunkTestDraw($healthySuffix); + $healthyPlayer = createChunkTestPlayer($healthySuffix); + $healthyOrder = createChunkTestOrder($healthyDraw, $healthyPlayer, $healthySuffix, 1); + createChunkTestResult($healthyDraw, 'MY'); + createChunkTestTicket($healthyDraw, $healthyPlayer, $healthyOrder, 'MY', $healthySuffix.'-MY'); + app(SettlementOrchestrator::class)->trySettleDraw($healthyDraw); + $healthyBatch = SettlementBatch::query()->where('draw_id', $healthyDraw->id)->firstOrFail(); + app(SettlementBatchWorkflowService::class)->approveBySystem($healthyBatch, 'simulate healthy backlog'); + + $first = app(SettlementTickFinalizer::class)->finalizePendingBatches(); + expect($first)->toMatchArray(['approved' => 0, 'paid' => 0, 'payout_failed' => 1]) + ->and($poisonBatch->fresh()->status)->toBe(SettlementBatchStatus::Approved->value) + ->and((int) $poisonBatch->fresh()->auto_payout_attempts)->toBe(1) + ->and($healthyBatch->fresh()->status)->toBe(SettlementBatchStatus::Approved->value); + + $second = app(SettlementTickFinalizer::class)->finalizePendingBatches(); + expect($second)->toMatchArray(['approved' => 0, 'paid' => 1, 'payout_failed' => 0]) + ->and($healthyBatch->fresh()->status)->toBe(SettlementBatchStatus::Paid->value) + ->and($healthyDraw->fresh()->status)->toBe(DrawStatus::Settled->value) + ->and($poisonBatch->fresh()->status)->toBe(SettlementBatchStatus::Approved->value); +}); diff --git a/tests/Feature/SettlementCreditAndReportFixesTest.php b/tests/Feature/SettlementCreditAndReportFixesTest.php index 414ea3e..a54d0e6 100644 --- a/tests/Feature/SettlementCreditAndReportFixesTest.php +++ b/tests/Feature/SettlementCreditAndReportFixesTest.php @@ -1,23 +1,24 @@ setRelation('player', $player); $recorder = app(AgentGameSettlementRecorder::class); - $recorder->recordForTicketItem($item, 0, 'settled_lose'); - expect(countActiveShareLedgerRows($item->id))->toBe(1); + DB::table('player_credit_accounts')->where('player_id', $player->id)->update(['used_credit' => 1]); - app(GameSettlementReversalService::class)->reverseTicketItem($item->fresh()); - expect(countActiveShareLedgerRows($item->id))->toBe(0); + $recorder->recordForTicketItem($item, 0, 'settled_lose', 1); + expect(countActiveShareLedgerRows($item->id))->toBe(1) + ->and((int) DB::table('player_credit_accounts')->where('player_id', $player->id)->value('used_credit'))->toBe(1); + + app(GameSettlementReversalService::class)->reverseTicketItem($item->fresh(), 1); + expect(countActiveShareLedgerRows($item->id))->toBe(0) + ->and((int) DB::table('player_credit_accounts')->where('player_id', $player->id)->value('used_credit'))->toBe(1); $item->forceFill(['agent_settled_at' => null])->save(); - $recorder->recordForTicketItem($item->fresh(), 0, 'settled_lose'); - expect(countActiveShareLedgerRows($item->id))->toBe(1); + $recorder->recordForTicketItem($item->fresh(), 0, 'settled_lose', 2); + expect(countActiveShareLedgerRows($item->id))->toBe(1) + ->and((int) DB::table('player_credit_accounts')->where('player_id', $player->id)->value('used_credit'))->toBe(1) + ->and(DB::table('credit_ledger') + ->where('ref_type', 'ticket_item') + ->where('ref_id', $item->id) + ->where('reason', 'game_settlement_loss') + ->count())->toBe(2); + + app(GameSettlementReversalService::class)->reverseTicketItem($item->fresh(), 2); + expect(countActiveShareLedgerRows($item->id))->toBe(0) + ->and((int) DB::table('player_credit_accounts')->where('player_id', $player->id)->value('used_credit'))->toBe(1) + ->and(DB::table('credit_ledger') + ->where('ref_type', 'ticket_item') + ->where('ref_id', $item->id) + ->where('reason', 'game_settlement_reversal') + ->count())->toBe(2) + ->and(DB::table('credit_ledger') + ->where('ref_type', 'ticket_item') + ->where('ref_id', $item->id) + ->where('reason', 'bet_hold_restore') + ->count())->toBe(2); +}); + +test('credit win re-settlement applies the same direction again for a new version', function (): void { + $player = creditAgentPlayerForFixtures('win-re-settle'); + DB::table('player_credit_accounts')->where('player_id', $player->id)->update(['used_credit' => 10]); + + $credit = app(PlayerCreditService::class); + $credit->releaseBetHold($player, 100, 99001, 1); + $credit->applySettledWin($player, 300, 99001, 1); + expect((int) DB::table('player_credit_accounts')->where('player_id', $player->id)->value('used_credit'))->toBe(6); + + $credit->reverseGameSettlement($player, -300, 99001, 1); + $credit->restoreBetHoldAfterSettlementReversal($player, 100, 99001, 1); + expect((int) DB::table('player_credit_accounts')->where('player_id', $player->id)->value('used_credit'))->toBe(10); + + $credit->releaseBetHold($player, 100, 99001, 2); + $credit->applySettledWin($player, 300, 99001, 2); + + expect((int) DB::table('player_credit_accounts')->where('player_id', $player->id)->value('used_credit'))->toBe(6) + ->and(DB::table('credit_ledger') + ->where('ref_type', 'ticket_item') + ->where('ref_id', 99001) + ->where('reason', 'game_settlement_win') + ->count())->toBe(2); }); test('credit player payout skips lottery wallet settle_payout txn', function (): void { @@ -279,7 +328,7 @@ test('settlement report show rejects inaccessible settlement period', function ( ->assertForbidden(); }); -test('settlement tick finalizer marks approved batch failed when payout throws', function (): void { +test('settlement tick finalizer keeps approved batch retryable when payout throws', function (): void { $player = creditAgentPlayerForFixtures('tick-fail'); $draw = Draw::query()->create([ 'draw_no' => 'FIX-FAIL-DRAW', @@ -363,7 +412,7 @@ test('settlement tick finalizer marks approved batch failed when payout throws', 'updated_at' => now(), ]); - DB::table('ticket_items')->insert([ + $orphanItemId = (int) DB::table('ticket_items')->insertGetId([ 'ticket_no' => 'T-FIX-FAIL-ORPHAN', 'order_id' => $orderId, 'player_id' => $player->id, @@ -394,6 +443,13 @@ test('settlement tick finalizer marks approved batch failed when payout throws', $result = app(SettlementTickFinalizer::class)->finalizePendingBatches(); expect($result['payout_failed'])->toBe(1) - ->and($batch->fresh()->status)->toBe(SettlementBatchStatus::Failed->value) + ->and($batch->fresh()->status)->toBe(SettlementBatchStatus::Approved->value) ->and($batch->fresh()->review_remark)->toContain('auto_payout_failed'); -}); \ No newline at end of file + + DB::table('ticket_items')->where('id', $orphanItemId)->delete(); + $retry = app(SettlementTickFinalizer::class)->finalizePendingBatches(); + + expect($retry)->toMatchArray(['approved' => 0, 'paid' => 1, 'payout_failed' => 0]) + ->and($batch->fresh()->status)->toBe(SettlementBatchStatus::Paid->value) + ->and($draw->fresh()->status)->toBe(DrawStatus::Settled->value); +}); diff --git a/tests/Feature/TicketBettingApiTest.php b/tests/Feature/TicketBettingApiTest.php index 313f62b..f37dce1 100644 --- a/tests/Feature/TicketBettingApiTest.php +++ b/tests/Feature/TicketBettingApiTest.php @@ -1446,3 +1446,126 @@ test('ticket pending confirmation reconcile refunds when draw no longer accepts ->and(WalletTxn::query()->where('biz_type', 'bet_reverse')->where('biz_no', 'TO-PENDING-CLOSED')->count())->toBe(1) ->and((int) RiskPool::query()->where('draw_id', $draw->id)->where('normalized_number', '1234')->value('locked_amount'))->toBe(0); }); + +test('ticket pending confirmation reconcile releases credit hold when draw no longer accepts bets', function (): void { + $draw = ticketOpenDraw('20260511-credit-stale'); + $draw->forceFill([ + 'status' => DrawStatus::Closed->value, + 'close_time' => now()->subMinute(), + 'draw_time' => now()->subMinute(), + ])->save(); + + $site = DB::table('admin_sites')->where('is_default', true)->first(); + $player = Player::query()->create([ + 'site_code' => (string) $site->code, + 'agent_node_id' => (int) DB::table('agent_nodes')->where('depth', 0)->value('id'), + 'site_player_id' => 'native:stale-credit-hold', + 'auth_source' => 'lottery_native', + 'funding_mode' => 'credit', + 'username' => 'stale_credit_hold', + 'nickname' => null, + 'default_currency' => 'NPR', + 'status' => 0, + ]); + + DB::table('player_credit_accounts')->insert([ + 'player_id' => $player->id, + 'credit_limit' => 1000, + 'used_credit' => 1, + 'frozen_credit' => 0, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $order = TicketOrder::query()->create([ + 'order_no' => 'TO-PENDING-CREDIT-CLOSED', + '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' => 3000, + 'status' => 'pending_confirm', + 'submit_source' => 'h5', + 'client_trace_id' => 'pending-credit-on-closed-draw', + 'created_at' => now()->subMinutes(20), + 'updated_at' => now()->subMinutes(20), + ]); + TicketOrder::query()->whereKey($order->id)->update(['updated_at' => now()->subMinutes(20)]); + + $item = TicketItem::query()->create([ + 'ticket_no' => 'TK-PENDING-CREDIT-CLOSED', + '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' => 3000, + 'risk_locked_amount' => 3000, + 'status' => 'pending_confirm', + 'fail_reason_code' => null, + 'fail_reason_text' => null, + 'win_amount' => 0, + 'jackpot_win_amount' => 0, + 'settled_at' => null, + 'created_at' => now()->subMinutes(20), + 'updated_at' => now()->subMinutes(20), + ]); + + TicketCombination::query()->create([ + 'ticket_item_id' => $item->id, + 'combination_no' => 1, + 'number_4d' => '1234', + 'bet_amount' => 100, + 'estimated_payout' => 3000, + 'created_at' => now()->subMinutes(20), + ]); + + RiskPool::query()->create([ + 'draw_id' => $draw->id, + 'normalized_number' => '1234', + 'total_cap_amount' => 5000, + 'locked_amount' => 3000, + 'remaining_amount' => 2000, + 'sold_out_status' => 0, + 'version' => 1, + ]); + + $this->artisan('lottery:ticket-pending-confirm-reconcile --stale-minutes=15 --limit=100') + ->expectsOutputToContain('refunded: 1') + ->assertExitCode(0); + + expect($order->fresh()->status)->toBe('refunded') + ->and($item->fresh()->status)->toBe('refunded') + ->and((int) DB::table('player_credit_accounts')->where('player_id', $player->id)->value('used_credit'))->toBe(0) + ->and(DB::table('credit_ledger') + ->where('owner_id', $player->id) + ->where('reason', 'bet_hold_release') + ->where('ref_type', 'ticket_order') + ->where('ref_id', $order->id) + ->count())->toBe(1) + ->and((int) RiskPool::query()->where('draw_id', $draw->id)->where('normalized_number', '1234')->value('locked_amount'))->toBe(0); + + $this->artisan('lottery:ticket-pending-confirm-reconcile --stale-minutes=15 --limit=100') + ->assertExitCode(0); + + expect(DB::table('credit_ledger') + ->where('owner_id', $player->id) + ->where('reason', 'bet_hold_release') + ->where('ref_type', 'ticket_order') + ->where('ref_id', $order->id) + ->count())->toBe(1); +}); diff --git a/tests/Feature/WalletBalanceTest.php b/tests/Feature/WalletBalanceTest.php index 8ba3310..1a2e2ed 100644 --- a/tests/Feature/WalletBalanceTest.php +++ b/tests/Feature/WalletBalanceTest.php @@ -4,12 +4,13 @@ use App\Models\Player; use App\Lottery\ErrorCode; use App\Models\PlayerWallet; use Database\Seeders\CurrencySeeder; -use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Http; +use Illuminate\Foundation\Testing\RefreshDatabase; uses(RefreshDatabase::class); beforeEach(function (): void { + fakeWalletApiDns(); $this->seed(CurrencySeeder::class); config(['lottery.main_site.wallet_api_url' => null]); }); diff --git a/tests/Feature/WalletTransferScenariosTest.php b/tests/Feature/WalletTransferScenariosTest.php index 69a9137..54957b5 100644 --- a/tests/Feature/WalletTransferScenariosTest.php +++ b/tests/Feature/WalletTransferScenariosTest.php @@ -18,6 +18,7 @@ use Illuminate\Foundation\Testing\RefreshDatabase; uses(RefreshDatabase::class); beforeEach(function (): void { + fakeWalletApiDns(); config(['lottery.main_site.wallet_api_url' => null]); $this->seed(CurrencySeeder::class); $this->seed(LotterySettingsSeeder::class); @@ -139,6 +140,38 @@ test('transfer in main site explicit failure returns 1009 and marks order failed ->and(WalletTxn::query()->where('player_id', $player->id)->count())->toBe(0); }); +test('transfer in rejects private dns answer before sending wallet bearer request', function (): void { + fakeWalletApiDns([ + 'private-debit.test' => ['10.0.0.8'], + ], []); + Http::preventStrayRequests(); + config(['lottery.main_site.wallet_api_url' => 'https://private-debit.test']); + config(['lottery.main_site.wallet_debit_path' => 'debit']); + + $player = Player::query()->create([ + 'site_code' => 'main', + 'site_player_id' => 'private-dns-in', + 'username' => null, + 'nickname' => null, + 'default_currency' => 'NPR', + 'status' => 0, + ]); + + $key = 'private-dns-in-'.uniqid('', true); + + $this->withHeader('Authorization', 'Bearer dev:'.$player->id) + ->postJson('/api/v1/wallet/transfer-in', [ + 'amount' => 500, + 'currency' => 'NPR', + 'idempotent_key' => $key, + ]) + ->assertStatus(400) + ->assertJsonPath('code', ErrorCode::WalletExternalRejected->value); + + expect(TransferOrder::query()->where('idempotent_key', $key)->value('status'))->toBe('failed'); + Http::assertSentCount(0); +}); + test('transfer in main site timeout returns 1002 and pending_reconcile', function (): void { Http::fake([ 'timeout-debit.test/*' => Http::response([], 504), diff --git a/tests/Feature/WalletTransferTest.php b/tests/Feature/WalletTransferTest.php index 23f86ae..769993e 100644 --- a/tests/Feature/WalletTransferTest.php +++ b/tests/Feature/WalletTransferTest.php @@ -14,6 +14,7 @@ use Illuminate\Foundation\Testing\RefreshDatabase; uses(RefreshDatabase::class); beforeEach(function (): void { + fakeWalletApiDns(); config(['lottery.main_site.wallet_api_url' => null]); $this->seed(CurrencySeeder::class); $this->seed(LotterySettingsSeeder::class); diff --git a/tests/Pest.php b/tests/Pest.php index 865a1e6..02d2f26 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -2,9 +2,24 @@ use Tests\TestCase; use App\Models\AdminUser; +use App\Models\AgentProfile; use Illuminate\Support\Carbon; +use App\Support\SuperAdminAccount; use Illuminate\Support\Facades\DB; +use App\Events\OddsUpdateBroadcast; +use App\Events\PlayToggleBroadcast; +use App\Events\RiskSoldOutBroadcast; +use App\Events\RiskWarningBroadcast; +use App\Support\PlatformSystemRoles; +use App\Events\JackpotBurstBroadcast; +use App\Events\BalanceUpdateBroadcast; +use App\Events\DrawCountdownBroadcast; use Illuminate\Support\Facades\Schema; +use App\Contracts\WalletApiDnsResolver; +use App\Events\DrawStatusChangeBroadcast; +use App\Events\PlayCatalogUpdatedBroadcast; +use App\Events\DrawResultPublishedBroadcast; +use App\Support\Integration\WalletApiRequestGuard; use Illuminate\Foundation\Testing\RefreshDatabase; /* @@ -53,8 +68,8 @@ expect()->extend('toBeOne', function () { /** 为后台测试账号挂上唯一超级管理员(不绑定站点)。 */ function grantSuperAdminRole(AdminUser $admin): void { - \App\Support\PlatformSystemRoles::ensureSuperAdminRole(); - \App\Support\SuperAdminAccount::assign($admin); + PlatformSystemRoles::ensureSuperAdminRole(); + SuperAdminAccount::assign($admin); } /** 为后台测试账号挂上代理节点(需已存在 agent_nodes / admin_user_agents 表)。 */ @@ -76,6 +91,37 @@ function agentChildPayload(array $overrides = []): array ], $overrides); } +/** + * 为钱包 HTTP 测试提供离线 DNS,避免测试依赖机器或公网解析状态。 + * + * @param array> $recordsByHost + * @param list $defaultRecords + */ +function fakeWalletApiDns( + array $recordsByHost = [], + array $defaultRecords = ['93.184.216.34'], +): void { + app()->instance(WalletApiDnsResolver::class, new class($recordsByHost, $defaultRecords) implements WalletApiDnsResolver + { + /** + * @param array> $recordsByHost + * @param list $defaultRecords + */ + public function __construct( + private readonly array $recordsByHost, + private readonly array $defaultRecords, + ) {} + + public function resolveAll(string $hostname): array + { + return $this->recordsByHost[$hostname] ?? $this->defaultRecords; + } + }); + + // Guard 可能已在当前测试中解析过,清除后确保使用刚绑定的 DNS resolver。 + app()->forgetInstance(WalletApiRequestGuard::class); +} + function bindAdminUserToAgent(AdminUser $admin, int $agentNodeId): void { DB::table('admin_user_agents')->updateOrInsert( @@ -96,7 +142,7 @@ function ensureRootAgentProfileSeeded(): void return; } - \App\Models\AgentProfile::query()->updateOrCreate( + AgentProfile::query()->updateOrCreate( ['agent_node_id' => (int) $rootId], [ 'total_share_rate' => 100, @@ -154,15 +200,15 @@ function ensureAdminActionCatalogSeeded(): void function allBroadcastEvents(): array { return [ - \App\Events\BalanceUpdateBroadcast::class, - \App\Events\DrawCountdownBroadcast::class, - \App\Events\DrawResultPublishedBroadcast::class, - \App\Events\DrawStatusChangeBroadcast::class, - \App\Events\JackpotBurstBroadcast::class, - \App\Events\OddsUpdateBroadcast::class, - \App\Events\PlayCatalogUpdatedBroadcast::class, - \App\Events\PlayToggleBroadcast::class, - \App\Events\RiskSoldOutBroadcast::class, - \App\Events\RiskWarningBroadcast::class, + BalanceUpdateBroadcast::class, + DrawCountdownBroadcast::class, + DrawResultPublishedBroadcast::class, + DrawStatusChangeBroadcast::class, + JackpotBurstBroadcast::class, + OddsUpdateBroadcast::class, + PlayCatalogUpdatedBroadcast::class, + PlayToggleBroadcast::class, + RiskSoldOutBroadcast::class, + RiskWarningBroadcast::class, ]; } diff --git a/tests/Unit/WalletApiRequestGuardTest.php b/tests/Unit/WalletApiRequestGuardTest.php new file mode 100644 index 0000000..bf04cf5 --- /dev/null +++ b/tests/Unit/WalletApiRequestGuardTest.php @@ -0,0 +1,74 @@ +toBeNull(); +})->with([ + 'ipv4 loopback' => 'https://127.0.0.1', + 'ipv4 shortened notation' => 'https://127.1', + 'ipv4 decimal integer notation' => 'https://2130706433', + 'ipv4 hexadecimal notation' => 'https://0x7f000001', + 'ipv4 octal notation' => 'https://0177.0.0.1', + 'ipv4 private' => 'https://10.0.0.1', + 'ipv4 link local' => 'https://169.254.169.254', + 'ipv4 cgnat' => 'https://100.64.0.1', + 'ipv4 documentation' => 'https://192.0.2.1', + 'ipv6 loopback' => 'https://[::1]', + 'ipv6 unique local' => 'https://[fd00::1]', + 'ipv6 link local' => 'https://[fe80::1]', + 'ipv6 nat64 private target' => 'https://[64:ff9b::a00:1]', + 'ipv6 protocol assignment' => 'https://[2001::1]', + 'ipv6 documentation' => 'https://[2001:db8::1]', +]); + +test('wallet api guard rejects hostname when any dns answer is non-public', function (): void { + fakeWalletApiDns([ + 'wallet.example.test' => ['93.184.216.34', '10.0.0.8'], + ], []); + + expect(app(WalletApiRequestGuard::class)->guard('https://wallet.example.test', 10)) + ->toBeNull(); +}); + +test('wallet api guard rejects unresolved hostname', function (): void { + fakeWalletApiDns([], []); + + expect(app(WalletApiRequestGuard::class)->guard('https://missing.example.test', 10)) + ->toBeNull(); +}); + +test('wallet api guard accepts public ipv4 and ipv6 dns answers and bounds connect timeout', function (): void { + fakeWalletApiDns([ + 'wallet.example.test' => ['93.184.216.34', '2606:4700:4700::1111'], + ], []); + + $endpoint = app(WalletApiRequestGuard::class)->guard('https://wallet.example.test:8443', 120); + + expect($endpoint)->not->toBeNull() + ->and($endpoint?->baseUrl)->toBe('https://wallet.example.test:8443') + ->and($endpoint?->port)->toBe(8443) + ->and($endpoint?->pinnedIp)->toBe('93.184.216.34') + ->and($endpoint?->totalTimeoutSeconds)->toBe(120) + ->and($endpoint?->connectTimeoutSeconds)->toBe(5); + + $pending = $endpoint?->request(['Authorization' => 'Bearer secret']); + $optionsProperty = new ReflectionProperty(PendingRequest::class, 'options'); + $options = $optionsProperty->getValue($pending); + + expect($options['allow_redirects'] ?? null)->toBeFalse() + ->and($options['proxy'] ?? null)->toBe('') + ->and($options['timeout'] ?? null)->toBe(120) + ->and($options['connect_timeout'] ?? null)->toBe(5) + ->and($options['curl'][CURLOPT_RESOLVE] ?? null) + ->toBe(['wallet.example.test:8443:93.184.216.34']); +}); + +test('wallet api sanitizer accepts normal public literals', function (): void { + expect(WalletApiUrlSanitizer::normalizeAndValidate('https://93.184.216.34')) + ->toBe('https://93.184.216.34') + ->and(WalletApiUrlSanitizer::normalizeAndValidate('https://[2606:4700:4700::1111]')) + ->toBe('https://[2606:4700:4700::1111]'); +});