fix(core): harden settlement and wallet integration

This commit is contained in:
wchino
2026-07-22 01:40:37 +08:00
parent 150c3e7ebd
commit 35f6e46958
54 changed files with 2564 additions and 359 deletions

View File

@@ -33,13 +33,16 @@ REVERB_SERVER_HOST=0.0.0.0
REVERB_HOST=localhost REVERB_HOST=localhost
REVERB_PORT=8080 REVERB_PORT=8080
REVERB_SCHEME=http REVERB_SCHEME=http
# WebSocket Origin 白名单逗号分隔支持域名、URL、*.example.com禁止 *
REVERB_ALLOWED_ORIGINS=localhost,127.0.0.1
QUEUE_CONNECTION=redis QUEUE_CONNECTION=redis
CACHE_STORE=redis CACHE_STORE=redis
LOTTERY_RISK_POOL_USE_REDIS_LUA=true LOTTERY_RISK_POOL_USE_REDIS_LUA=true
# 生产建议独立配置;留空则回落 MAIN_SITE_SSO_JWT_SECRET(勿在生产与 SSO 混用 # 原生玩家登录必须显式配置;须与 MAIN_SITE_SSO_JWT_SECRET 及所有站点保存的 SSO 密钥不同(包括停用站点
# LOTTERY_NATIVE_JWT_SECRET= # 可用 openssl rand -base64 48 生成;留空时原生登录与已签发原生 Token 验证均返回 503
LOTTERY_NATIVE_JWT_SECRET=
# 预发可设为 false禁止代理账期关账 # 预发可设为 false禁止代理账期关账
AGENT_SETTLEMENT_ALLOW_PRODUCTION_CLOSE=true AGENT_SETTLEMENT_ALLOW_PRODUCTION_CLOSE=true

View File

@@ -139,6 +139,8 @@ php artisan queue:work --tries=3 --timeout=120 --sleep=1
- `REVERB_HOST`:浏览器连接 Reverb 时看到的主机名或 IP - `REVERB_HOST`:浏览器连接 Reverb 时看到的主机名或 IP
- `SANCTUM_STATEFUL_DOMAINS`:允许带 Cookie 的前端来源列表 - `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 等常驻进程,确保签发和验证使用同一个新值。
## 生产性能基线 ## 生产性能基线
为避免调度锁、大厅快照缓存与业务表争抢同一数据库,生产环境请至少满足: 为避免调度锁、大厅快照缓存与业务表争抢同一数据库,生产环境请至少满足:

View File

@@ -0,0 +1,9 @@
<?php
namespace App\Contracts;
interface WalletApiDnsResolver
{
/** @return list<string> */
public function resolveAll(string $hostname): array;
}

View File

@@ -2,8 +2,8 @@
namespace App\Events; namespace App\Events;
use Illuminate\Broadcasting\Channel;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Foundation\Events\Dispatchable; use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Broadcasting\InteractsWithSockets; use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast; use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
@@ -53,11 +53,11 @@ final class BalanceUpdateBroadcast implements ShouldBroadcast
/** /**
* 使用私有频道,只有指定玩家能收到自己的余额变动。 * 使用私有频道,只有指定玩家能收到自己的余额变动。
* *
* @return array<int, Channel> * @return array<int, PrivateChannel>
*/ */
public function broadcastOn(): array public function broadcastOn(): array
{ {
return [new Channel('player.'.$this->playerId)]; return [new PrivateChannel('player.'.$this->playerId)];
} }
public function broadcastAs(): string public function broadcastAs(): string

View File

@@ -2,8 +2,9 @@
namespace App\Http\Controllers\Api\V1\Admin\Reports; namespace App\Http\Controllers\Api\V1\Admin\Reports;
use App\Models\AdminUser;
use App\Models\ReportJob; use App\Models\ReportJob;
use Illuminate\Http\Request;
use App\Support\AdminReportJobPolicy;
use App\Services\Admin\AdminReportJobService; use App\Services\Admin\AdminReportJobService;
use App\Services\Admin\AdminReportQueryService; use App\Services\Admin\AdminReportQueryService;
use App\Services\Admin\AdminReportSpreadsheetExporter; use App\Services\Admin\AdminReportSpreadsheetExporter;
@@ -13,11 +14,20 @@ use Symfony\Component\HttpFoundation\StreamedResponse;
final class ReportJobDownloadController final class ReportJobDownloadController
{ {
public function __invoke( public function __invoke(
Request $request,
ReportJob $report_job, ReportJob $report_job,
AdminReportJobService $service, AdminReportJobService $service,
AdminReportQueryService $queryService, AdminReportQueryService $queryService,
AdminReportSpreadsheetExporter $spreadsheetExporter, AdminReportSpreadsheetExporter $spreadsheetExporter,
): StreamedResponse { ): 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; $filterJson = is_array($report_job->filter_json) ? $report_job->filter_json : null;
$range = $queryService->resolveDateRange($filterJson); $range = $queryService->resolveDateRange($filterJson);
$dateFrom = $range['date_from']; $dateFrom = $range['date_from'];
@@ -30,8 +40,7 @@ final class ReportJobDownloadController
$dateTo, $dateTo,
); );
$filename = $label.'_'.$pathSuffix.'.'.$report_job->export_format; $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, $admin);
$rows = $service->reportRows((string) $report_job->report_type, $filterJson, $scopedAdmin);
if ((string) $report_job->export_format === 'xlsx') { if ((string) $report_job->export_format === 'xlsx') {
return $spreadsheetExporter->streamDownload($rows, $filename); return $spreadsheetExporter->streamDownload($rows, $filename);

View File

@@ -2,17 +2,21 @@
namespace App\Http\Controllers\Api\V1\Admin\Reports; namespace App\Http\Controllers\Api\V1\Admin\Reports;
use App\Http\Controllers\Controller;
use App\Models\ReportJob; use App\Models\ReportJob;
use Illuminate\Http\Request;
use App\Support\AdminApiList; use App\Support\AdminApiList;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use App\Http\Controllers\Controller;
use App\Support\AdminReportJobPolicy;
/** GET /api/v1/admin/report-jobs */ /** GET /api/v1/admin/report-jobs */
final class ReportJobIndexController extends Controller final class ReportJobIndexController extends Controller
{ {
public function __invoke(Request $request): JsonResponse public function __invoke(Request $request): JsonResponse
{ {
$admin = $request->lotteryAdmin();
abort_if($admin === null, 401);
$p = AdminApiList::readPaging($request); $p = AdminApiList::readPaging($request);
$reportType = trim((string) $request->query('report_type', '')); $reportType = trim((string) $request->query('report_type', ''));
@@ -23,6 +27,8 @@ final class ReportJobIndexController extends Controller
$query->where('report_type', $reportType); $query->where('report_type', $reportType);
} }
AdminReportJobPolicy::applyToJobsQuery($query, $admin);
$paginator = $query->paginate($p['perPage'], ['*'], 'page', $p['page']); $paginator = $query->paginate($p['perPage'], ['*'], 'page', $p['page']);
return AdminApiList::json($paginator, fn (ReportJob $j) => $this->row($j)); return AdminApiList::json($paginator, fn (ReportJob $j) => $this->row($j));

View File

@@ -2,16 +2,22 @@
namespace App\Http\Controllers\Api\V1\Admin\Reports; namespace App\Http\Controllers\Api\V1\Admin\Reports;
use App\Http\Controllers\Controller;
use App\Models\ReportJob; use App\Models\ReportJob;
use App\Support\ApiResponse; use App\Support\ApiResponse;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use App\Http\Controllers\Controller;
use App\Support\AdminReportJobPolicy;
/** GET /api/v1/admin/report-jobs/{report_job} */ /** GET /api/v1/admin/report-jobs/{report_job} */
final class ReportJobShowController extends Controller 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([ return ApiResponse::success([
'id' => (int) $report_job->id, 'id' => (int) $report_job->id,
'job_no' => $report_job->job_no, 'job_no' => $report_job->job_no,

View File

@@ -2,12 +2,13 @@
namespace App\Http\Controllers\Api\V1\Admin\Reports; 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\Models\AdminUser;
use App\Services\Admin\AdminReportJobService;
use App\Support\ApiResponse; use App\Support\ApiResponse;
use Illuminate\Http\JsonResponse; 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 */ /** POST /api/v1/admin/report-jobs */
final class ReportJobStoreController extends Controller final class ReportJobStoreController extends Controller
@@ -18,6 +19,10 @@ final class ReportJobStoreController extends Controller
$admin = $request->lotteryAdmin(); $admin = $request->lotteryAdmin();
$data = $request->validated(); $data = $request->validated();
abort_unless(
AdminReportJobPolicy::canExportReportType($admin, (string) $data['report_type']),
403,
);
$job = $service->enqueue( $job = $service->enqueue(
$admin, $admin,

View File

@@ -38,6 +38,8 @@ final class EnsurePlayerApi
// 使用 attributes避免与 Laravel 内置 input 混淆 // 使用 attributes避免与 Laravel 内置 input 混淆
$request->attributes->set('lottery_player', $player); $request->attributes->set('lottery_player', $player);
// 广播私有频道授权使用 Request::user() 读取当前玩家。
$request->setUserResolver(static fn () => $player);
return $next($request); return $next($request);
} }

View File

@@ -109,7 +109,7 @@ enum ErrorCode: int
/** 库中无对应玩家(未建档) */ /** 库中无对应玩家(未建档) */
case PlayerNotRegistered = 8003; case PlayerNotRegistered = 8003;
/** 未配置 `MAIN_SITE_SSO_JWT_SECRET`(通常 HTTP 503 */ /** SSO / 原生 JWT 密钥缺失或存在不安全的共用配置(通常 HTTP 503 */
case PlayerSsoSecretNotConfigured = 8004; case PlayerSsoSecretNotConfigured = 8004;
/** 账号已冻结或禁止登录status ≠ active */ /** 账号已冻结或禁止登录status ≠ active */

View File

@@ -3,16 +3,16 @@
namespace App\Models; namespace App\Models;
use Laravel\Sanctum\HasApiTokens; use Laravel\Sanctum\HasApiTokens;
use App\Support\AgentPlatformRole;
use App\Support\SuperAdminAccount;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use App\Support\PlatformSystemRoles;
use App\Support\AdminPermissionBridge; 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 Illuminate\Notifications\Notifiable;
use App\Support\AgentProfileCapabilityFilter;
use Illuminate\Validation\ValidationException;
use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Validation\ValidationException;
final class AdminUser extends Authenticatable 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 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 public function syncSystemRoleSlugsForSite(int $siteId, array $slugs): void
{ {
$slugs = array_values(array_unique($slugs)); $slugs = array_values(array_unique($slugs));
\App\Support\SuperAdminAccount::assertNotSiteRoleAssignment($slugs); SuperAdminAccount::assertNotSiteRoleAssignment($slugs);
if (in_array(\App\Support\PlatformSystemRoles::SLUG_AGENT, $slugs, true)) { if (in_array(PlatformSystemRoles::SLUG_AGENT, $slugs, true)) {
throw ValidationException::withMessages([ throw ValidationException::withMessages([
'role_slugs' => [trans('admin.agent_role_not_assignable_to_platform_account')], 'role_slugs' => [trans('admin.agent_role_not_assignable_to_platform_account')],
]); ]);

View File

@@ -5,11 +5,13 @@ namespace App\Providers;
use App\Models\Player; use App\Models\Player;
use App\Models\AdminUser; use App\Models\AdminUser;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use App\Contracts\WalletApiDnsResolver;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
use Illuminate\Cache\RateLimiting\Limit; use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\Facades\RateLimiter;
use App\Services\Wallet\MainSiteWalletGateway; use App\Services\Wallet\MainSiteWalletGateway;
use App\Services\Wallet\HttpMainSiteWalletGateway; use App\Services\Wallet\HttpMainSiteWalletGateway;
use App\Support\Integration\SystemWalletApiDnsResolver;
final class AppServiceProvider extends ServiceProvider final class AppServiceProvider extends ServiceProvider
{ {
@@ -19,6 +21,7 @@ final class AppServiceProvider extends ServiceProvider
public function register(): void public function register(): void
{ {
$this->app->singleton(MainSiteWalletGateway::class, HttpMainSiteWalletGateway::class); $this->app->singleton(MainSiteWalletGateway::class, HttpMainSiteWalletGateway::class);
$this->app->singleton(WalletApiDnsResolver::class, SystemWalletApiDnsResolver::class);
} }
/** /**

View File

@@ -4,10 +4,12 @@ namespace App\Services\Admin;
use App\Models\AdminUser; use App\Models\AdminUser;
use App\Models\ReportJob; use App\Models\ReportJob;
use App\Services\AuditLogger;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str; 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`(同步生成,可后续接队列)。 * 报表导出任务:落库 `report_jobs`(同步生成,可后续接队列)。
@@ -68,9 +70,13 @@ final class AdminReportJobService
/** /**
* @return list<array<int, string|int|float|null>> * @return list<array<int, string|int|float|null>>
*/ */
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 public function reportLabel(string $reportType): string

View File

@@ -2,23 +2,26 @@
namespace App\Services\Admin; namespace App\Services\Admin;
use App\Models\AdminUser; use Carbon\Carbon;
use App\Models\AuditLog;
use App\Support\AdminDataScope;
use App\Support\AdminScopeContext;
use App\Support\AdminScopeContextResolver;
use App\Models\Draw; use App\Models\Draw;
use App\Models\AuditLog;
use App\Models\RiskPool; use App\Models\RiskPool;
use App\Models\RiskPoolLockLog; use App\Models\AdminUser;
use App\Models\SettlementBatch; use App\Models\WalletTxn;
use App\Models\TicketItem; use App\Models\TicketItem;
use App\Models\TicketOrder; use App\Models\TicketOrder;
use App\Models\TransferOrder; use App\Models\TransferOrder;
use App\Models\WalletTxn; use App\Services\AuditLogger;
use Carbon\Carbon; 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\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Pagination\LengthAwarePaginator as PaginatorInstance; 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', '>=', $dateFrom)
->whereDate('d.business_date', '<=', $dateTo) ->whereDate('d.business_date', '<=', $dateTo)
->selectRaw($providerCodeSql.' as provider_code') ->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('COUNT(ti.id) as ticket_item_count')
->selectRaw('SUM(ti.actual_deduct_amount) as total_bet_minor') ->selectRaw('SUM(ti.actual_deduct_amount) as total_bet_minor')
->selectRaw('SUM(ti.win_amount + ti.jackpot_win_amount) as total_payout_minor') ->selectRaw('SUM(ti.win_amount + ti.jackpot_win_amount) as total_payout_minor')
@@ -514,7 +517,7 @@ final class AdminReportQueryService
/** /**
* @return list<array<int, string|int|float|null>> * @return list<array<int, string|int|float|null>>
*/ */
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); $range = $this->resolveDateRange($filterJson);
$dateFrom = $range['date_from']; $dateFrom = $range['date_from'];
@@ -526,7 +529,7 @@ final class AdminReportQueryService
'player_win_loss' => $this->playerWinLossExportRows($filterJson, $dateFrom, $dateTo, $scope), 'player_win_loss' => $this->playerWinLossExportRows($filterJson, $dateFrom, $dateTo, $scope),
'play_dimension_report' => $this->playDimensionExportRows($filterJson, $dateFrom, $dateTo, $scope), 'play_dimension_report' => $this->playDimensionExportRows($filterJson, $dateFrom, $dateTo, $scope),
'provider_profit_report' => $this->providerProfitExportRows($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_transfer_report', 'transfer_orders_daily' => $this->transferOrdersExportRows($filterJson, $dateFrom, $dateTo, $scope),
'wallet_txns_daily' => $this->walletTxnsExportRows($filterJson, $dateFrom, $dateTo, $scope), 'wallet_txns_daily' => $this->walletTxnsExportRows($filterJson, $dateFrom, $dateTo, $scope),
'hot_number_risk_report' => $this->hotNumberRiskExportRows($filterJson), '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) { 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]; $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 $rows;
} }
/** /**
* @return list<array<int, string|int|float|null>> * @return list<array<int, string|int|float|null>>
*/ */
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; $operatorId = isset($filterJson['operator_id']) ? (int) $filterJson['operator_id'] : null;
$rows = [ $rows = [
['ID', '操作者类型', '操作者ID', '模块', '操作', 'IP', '时间'], ['ID', '操作者类型', '操作者ID', '模块', '操作', 'IP', '时间'],
]; ];
$q = AuditLog::query()->orderByDesc('id'); $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) { if ($operatorId !== null && $operatorId > 0) {
$q->where('operator_id', $operatorId); $q->where('operator_id', $operatorId);
} }
$q->whereDate('created_at', '>=', $dateFrom) $q->whereDate('created_at', '>=', $dateFrom)
->whereDate('created_at', '<=', $dateTo); ->whereDate('created_at', '<=', $dateTo);
$limited = \App\Support\LimitedQuery::get($q, 5000); $limited = LimitedQuery::get($q, 5000);
foreach ($limited['rows'] as $log) { foreach ($limited['rows'] as $log) {
$rows[] = [ $rows[] = [
(int) $log->id, (int) $log->id,
@@ -685,7 +699,7 @@ final class AdminReportQueryService
return $rows; return $rows;
} }
/** @return \Illuminate\Database\Query\Builder */ /** @return Builder */
private function playerWinLossBaseQuery( private function playerWinLossBaseQuery(
?int $playerId, ?int $playerId,
string $dateFrom, string $dateFrom,
@@ -725,7 +739,7 @@ final class AdminReportQueryService
return $query; return $query;
} }
/** @return \Illuminate\Database\Query\Builder */ /** @return Builder */
private function playDimensionBaseQuery(?string $playCode, string $dateFrom, string $dateTo, AdminUser|AdminScopeContext|null $scope = null) private function playDimensionBaseQuery(?string $playCode, string $dateFrom, string $dateTo, AdminUser|AdminScopeContext|null $scope = null)
{ {
$context = $this->normalizeScope($scope); $context = $this->normalizeScope($scope);
@@ -755,9 +769,9 @@ final class AdminReportQueryService
/** /**
* @return list<array<int, string|int|float|null>> * @return list<array<int, string|int|float|null>>
*/ */
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); $draw = $this->resolveDrawForReport($filterJson);
if ($draw === null) { if ($draw === null) {
return [['提示', '请提供 draw_id 或 draw_no']]; return [['提示', '请提供 draw_id 或 draw_no']];
@@ -766,10 +780,8 @@ final class AdminReportQueryService
$drawId = (int) $draw->id; $drawId = (int) $draw->id;
$orderQuery = TicketOrder::query()->where('draw_id', $drawId); $orderQuery = TicketOrder::query()->where('draw_id', $drawId);
$itemQuery = TicketItem::query()->where('draw_id', $drawId); $itemQuery = TicketItem::query()->where('draw_id', $drawId);
if ($context !== null) { AdminDataScope::applyEloquentViaPlayer($orderQuery, $admin);
AdminDataScope::applyEloquentViaPlayer($orderQuery, $context->admin); AdminDataScope::applyEloquentViaPlayer($itemQuery, $admin);
AdminDataScope::applyEloquentViaPlayer($itemQuery, $context->admin);
}
$totalBetMinor = (int) $orderQuery->sum('total_actual_deduct'); $totalBetMinor = (int) $orderQuery->sum('total_actual_deduct');
$orderCount = (int) $orderQuery->count(); $orderCount = (int) $orderQuery->count();
@@ -805,11 +817,13 @@ final class AdminReportQueryService
], ],
]; ];
$batches = SettlementBatch::query() $batches = $admin->isSuperAdmin()
? SettlementBatch::query()
->where('draw_id', $drawId) ->where('draw_id', $drawId)
->orderByDesc('id') ->orderByDesc('id')
->limit(100) ->limit(100)
->get(); ->get()
: collect();
foreach ($batches as $batch) { foreach ($batches as $batch) {
$rows[] = [ $rows[] = [

View File

@@ -2,11 +2,10 @@
namespace App\Services\AgentSettlement; namespace App\Services\AgentSettlement;
use App\Models\Player;
use App\Models\TicketItem; use App\Models\TicketItem;
use App\Services\Player\PlayerCreditService;
use App\Support\PlayerFundingMode; use App\Support\PlayerFundingMode;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use App\Services\Player\PlayerCreditService;
/** /**
* 彩票注单游戏结算侧入账:快照、占成流水、回水计提、玩家已用额度。 * 彩票注单游戏结算侧入账:快照、占成流水、回水计提、玩家已用额度。
@@ -30,8 +29,12 @@ final class AgentGameSettlementRecorder
&& (int) ($player->agent_node_id ?? 0) > 0; && (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)) { if (! $this->shouldRecord($item)) {
return; return;
} }
@@ -77,7 +80,7 @@ final class AgentGameSettlementRecorder
$settledAt = now(); $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([ $item->forceFill([
'agent_node_id' => $snapshot['agent_node_id'], 'agent_node_id' => $snapshot['agent_node_id'],
'share_snapshot' => $shareSnapshot, 'share_snapshot' => $shareSnapshot,
@@ -134,13 +137,13 @@ final class AgentGameSettlementRecorder
$holdAmount = (int) $item->actual_deduct_amount; $holdAmount = (int) $item->actual_deduct_amount;
if ($holdAmount > 0) { if ($holdAmount > 0) {
$this->playerCreditService->releaseBetHold($player, $holdAmount, $item->id); $this->playerCreditService->releaseBetHold($player, $holdAmount, $item->id, $settlementVersion);
} }
if ($gameWinLoss > 0) { 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) { } elseif ($gameWinLoss < 0) {
$this->playerCreditService->applySettledWin($player, (int) round(abs($gameWinLoss)), $item->id); $this->playerCreditService->applySettledWin($player, (int) round(abs($gameWinLoss)), $item->id, $settlementVersion);
} }
}); });
} }

View File

@@ -4,10 +4,10 @@ namespace App\Services\AgentSettlement;
use App\Models\Player; use App\Models\Player;
use App\Models\TicketItem; use App\Models\TicketItem;
use App\Services\Player\PlayerCreditService;
use App\Support\PlayerFundingMode; use App\Support\PlayerFundingMode;
use Illuminate\Database\QueryException;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Database\QueryException;
use App\Services\Player\PlayerCreditService;
final class GameSettlementReversalService final class GameSettlementReversalService
{ {
@@ -15,16 +15,26 @@ final class GameSettlementReversalService
private readonly PlayerCreditService $playerCreditService, 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) { if ($ledger === null) {
return; return;
} }
$settledAt = now(); $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 索引, // 幂等闸门share_ledger.reversal_of_id 上有 partial unique 索引,
// 同一原账不允许插入多条反转记录。重复调用时唯一约束冲突即视为已反转、跳过。 // 同一原账不允许插入多条反转记录。重复调用时唯一约束冲突即视为已反转、跳过。
// 一次只让 reversal_of_id 出现一次写入确保所有后续副作用rebate、credit 冲正) // 一次只让 reversal_of_id 出现一次写入确保所有后续副作用rebate、credit 冲正)
@@ -79,8 +89,14 @@ final class GameSettlementReversalService
if ($player !== null && PlayerFundingMode::usesCredit($player)) { if ($player !== null && PlayerFundingMode::usesCredit($player)) {
$gameWinLoss = (int) $ledger->game_win_loss; $gameWinLoss = (int) $ledger->game_win_loss;
if ($gameWinLoss !== 0) { 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,
);
} }
}); });
} }

View File

@@ -0,0 +1,71 @@
<?php
namespace App\Services\Player;
use Throwable;
use App\Models\AdminSite;
use App\Lottery\ErrorCode;
use Illuminate\Support\Facades\Schema;
use App\Exceptions\PlayerAuthenticationException;
final class NativeJwtSecretGuard
{
public function validatedSecret(): string
{
$secret = config('lottery.player_auth.native.secret');
if (! is_string($secret) || $secret === '') {
$this->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,
);
}
}

View File

@@ -3,19 +3,20 @@
namespace App\Services\Player; namespace App\Services\Player;
use App\Models\Player; use App\Models\Player;
use App\Services\Agent\AgentUsedCreditSyncService;
use App\Support\AgentOverdueGuard; use App\Support\AgentOverdueGuard;
use App\Support\CreditAmountScale; use App\Support\CreditAmountScale;
use App\Support\PlayerFundingMode; use App\Support\PlayerFundingMode;
use Illuminate\Database\QueryException;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Database\QueryException;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
use App\Services\Agent\AgentUsedCreditSyncService;
final class PlayerCreditService final class PlayerCreditService
{ {
public function __construct( public function __construct(
private readonly AgentUsedCreditSyncService $usedCreditSync, private readonly AgentUsedCreditSyncService $usedCreditSync,
) {} ) {}
/** /**
* @param array{credit_limit?: int} $payload * @param array{credit_limit?: int} $payload
*/ */
@@ -163,8 +164,12 @@ final class PlayerCreditService
$this->syncAgentUsedCredit($player); $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) { if ($amountMinor <= 0) {
return; return;
} }
@@ -187,6 +192,7 @@ final class PlayerCreditService
'reason' => 'game_settlement_loss', 'reason' => 'game_settlement_loss',
'ref_type' => 'ticket_item', 'ref_type' => 'ticket_item',
'ref_id' => $ticketItemId, 'ref_id' => $ticketItemId,
'settlement_version' => $settlementVersion,
'created_at' => $now, 'created_at' => $now,
'updated_at' => $now, 'updated_at' => $now,
]); ]);
@@ -218,8 +224,12 @@ final class PlayerCreditService
$this->syncAgentUsedCredit($player); $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) { if ($amountMinor <= 0) {
return; return;
} }
@@ -228,31 +238,56 @@ final class PlayerCreditService
return; return;
} }
$applied = DB::transaction(function () use ($player, $amountMinor, $ticketItemId, $settlementVersion): bool {
$now = now(); $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 索引为幂等闸门 // 流水记录实际释放的额度,而非可能超过 used_credit 的名义中奖额
// 已存在同 (ticket_item, game_settlement_win) 的流水则直接返回,避免并发/重入场景下重复扣减 used_credit // 结算被驳回时必须按这个实际值恢复,否则会凭空增加玩家已用额度
try { try {
DB::table('credit_ledger')->insert([ DB::table('credit_ledger')->insert([
'owner_type' => 'player', 'owner_type' => 'player',
'owner_id' => $player->id, 'owner_id' => $player->id,
'amount' => $amountMinor, 'amount' => $appliedMinor,
'reason' => 'game_settlement_win', 'reason' => 'game_settlement_win',
'ref_type' => 'ticket_item', 'ref_type' => 'ticket_item',
'ref_id' => $ticketItemId, 'ref_id' => $ticketItemId,
'settlement_version' => $settlementVersion,
'created_at' => $now, 'created_at' => $now,
'updated_at' => $now, 'updated_at' => $now,
]); ]);
} catch (QueryException $e) { } catch (QueryException $e) {
if ($this->isUniqueViolation($e)) { if ($this->isUniqueViolation($e)) {
return; return false;
} }
throw $e; throw $e;
} }
$this->decreaseUsedCredit($player, $amountMinor); 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); $this->syncAgentUsedCredit($player);
} }
}
public function assertMayPlaceBet(Player $player, int $amountMinor): void 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)) { if ($amountMinor <= 0 || ! PlayerFundingMode::usesCredit($player)) {
return; return;
} }
@@ -308,6 +347,7 @@ final class PlayerCreditService
'reason' => 'bet_hold_release', 'reason' => 'bet_hold_release',
'ref_type' => 'ticket_item', 'ref_type' => 'ticket_item',
'ref_id' => $ticketItemId, 'ref_id' => $ticketItemId,
'settlement_version' => $settlementVersion,
'created_at' => $now, 'created_at' => $now,
'updated_at' => $now, 'updated_at' => $now,
]); ]);
@@ -354,14 +394,33 @@ final class PlayerCreditService
$this->syncAgentUsedCredit($player); $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)) { if ($gameWinLossSigned === 0 || ! PlayerFundingMode::usesCredit($player)) {
return; return;
} }
$now = now(); $now = now();
$amountMinor = abs($gameWinLossSigned); $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 索引为幂等闸门。 // 先写 credit_ledger以 (ref_type, ref_id, reason) partial unique 索引为幂等闸门。
try { try {
@@ -372,6 +431,7 @@ final class PlayerCreditService
'reason' => 'game_settlement_reversal', 'reason' => 'game_settlement_reversal',
'ref_type' => 'ticket_item', 'ref_type' => 'ticket_item',
'ref_id' => $ticketItemId, 'ref_id' => $ticketItemId,
'settlement_version' => $settlementVersion,
'created_at' => $now, 'created_at' => $now,
'updated_at' => $now, 'updated_at' => $now,
]); ]);
@@ -410,6 +470,56 @@ final class PlayerCreditService
$this->syncAgentUsedCredit($player); $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支持部分收付多笔递增。 * @param int $cumulativePaidMinor 账单累计已登记收付minor支持部分收付多笔递增。
*/ */

View File

@@ -11,6 +11,10 @@ use App\Exceptions\PlayerAuthenticationException;
final class PlayerNativeAuthService final class PlayerNativeAuthService
{ {
public function __construct(
private readonly NativeJwtSecretGuard $nativeJwtSecretGuard,
) {}
/** /**
* @return array{access_token: string, expires_in: int, token_type: string, player: array<string, mixed>} * @return array{access_token: string, expires_in: int, token_type: string, player: array<string, mixed>}
*/ */
@@ -85,14 +89,7 @@ final class PlayerNativeAuthService
public function issueToken(Player $player, ?int $ttlSeconds = null): string public function issueToken(Player $player, ?int $ttlSeconds = null): string
{ {
$secret = (string) config('lottery.player_auth.native.secret', ''); $secret = $this->nativeJwtSecretGuard->validatedSecret();
if ($secret === '') {
throw new PlayerAuthenticationException(
'原生登录未配置',
ErrorCode::PlayerSsoSecretNotConfigured->value,
503,
);
}
$ttl = $ttlSeconds ?? (int) config('lottery.player_auth.native.ttl_seconds', 28800); $ttl = $ttlSeconds ?? (int) config('lottery.player_auth.native.ttl_seconds', 28800);
$now = time(); $now = time();

View File

@@ -11,6 +11,7 @@ use App\Support\PlayerAuthSource;
use App\Support\PlayerFundingMode; use App\Support\PlayerFundingMode;
use App\Support\PlayerTokenAesUnwrap; use App\Support\PlayerTokenAesUnwrap;
use Illuminate\Database\QueryException; use Illuminate\Database\QueryException;
use App\Services\Player\NativeJwtSecretGuard;
use App\Support\PlayerAutoRegistrationDefaults; use App\Support\PlayerAutoRegistrationDefaults;
use App\Exceptions\PlayerAuthenticationException; use App\Exceptions\PlayerAuthenticationException;
use App\Services\Integration\PartnerSiteConfigResolver; use App\Services\Integration\PartnerSiteConfigResolver;
@@ -42,6 +43,7 @@ final class PlayerTokenResolver
public function __construct( public function __construct(
private readonly PartnerSiteConfigResolver $partnerSiteConfigResolver, private readonly PartnerSiteConfigResolver $partnerSiteConfigResolver,
private readonly NativeJwtSecretGuard $nativeJwtSecretGuard,
) {} ) {}
public function resolve(Request $request): Player public function resolve(Request $request): Player
@@ -158,14 +160,7 @@ final class PlayerTokenResolver
private function resolveNativeJwt(string $jwt): Player private function resolveNativeJwt(string $jwt): Player
{ {
$secret = (string) config('lottery.player_auth.native.secret', ''); $secret = $this->nativeJwtSecretGuard->validatedSecret();
if ($secret === '') {
throw new PlayerAuthenticationException(
'原生登录未配置',
ErrorCode::PlayerSsoSecretNotConfigured->value,
503,
);
}
$alg = (string) config('lottery.player_auth.jwt.algorithm', 'HS256'); $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) { if (! $player->wasRecentlyCreated) {
$player->forceFill(['last_login_at' => $now])->save(); $player->forceFill(['last_login_at' => $now])->save();
} }

View File

@@ -10,11 +10,11 @@ use App\Lottery\DrawStatus;
use App\Models\JackpotPool; use App\Models\JackpotPool;
use App\Models\TicketOrder; use App\Models\TicketOrder;
use App\Models\SettlementBatch; use App\Models\SettlementBatch;
use App\Support\PlayerFundingMode;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use App\Lottery\SettlementBatchStatus; use App\Lottery\SettlementBatchStatus;
use App\Services\AgentSettlement\GameSettlementReversalService;
use App\Services\Ticket\TicketWalletService; use App\Services\Ticket\TicketWalletService;
use App\Support\PlayerFundingMode; use App\Services\AgentSettlement\GameSettlementReversalService;
final class SettlementBatchWorkflowService final class SettlementBatchWorkflowService
{ {
@@ -67,7 +67,7 @@ final class SettlementBatchWorkflowService
if ($itemIds !== []) { if ($itemIds !== []) {
$items = TicketItem::query()->whereIn('id', $itemIds)->get(); $items = TicketItem::query()->whereIn('id', $itemIds)->get();
foreach ($items as $item) { foreach ($items as $item) {
$this->gameSettlementReversal->reverseTicketItem($item); $this->gameSettlementReversal->reverseTicketItem($item, (int) $locked->settle_version);
} }
TicketItem::query() TicketItem::query()
@@ -118,16 +118,23 @@ final class SettlementBatchWorkflowService
throw new \RuntimeException('draw_has_unsettled_tickets'); throw new \RuntimeException('draw_has_unsettled_tickets');
} }
if ($batchItemIds !== []) {
$orphanPendingPayout = TicketItem::query() $orphanPendingPayout = TicketItem::query()
->where('draw_id', $locked->draw_id) ->where('draw_id', $locked->draw_id)
->where('status', 'pending_payout') ->where('status', 'pending_payout')
->whereNotIn('id', $batchItemIds) ->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(); ->exists();
if ($orphanPendingPayout) { if ($orphanPendingPayout) {
throw new \RuntimeException('draw_has_unsettled_tickets'); throw new \RuntimeException('draw_has_unsettled_tickets');
} }
}
$details = $locked->details()->with(['ticketItem.order'])->get(); $details = $locked->details()->with(['ticketItem.order'])->get();
$playerCurrencyTotals = []; $playerCurrencyTotals = [];
@@ -193,10 +200,34 @@ final class SettlementBatchWorkflowService
'paid_at' => now(), 'paid_at' => now(),
])->save(); ])->save();
$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([ Draw::query()->whereKey($locked->draw_id)->update([
'status' => DrawStatus::Settled->value, 'status' => DrawStatus::Settled->value,
'settle_version' => (int) $locked->settle_version, 'settle_version' => $settleVersion,
]); ]);
}
return $locked->refresh(); return $locked->refresh();
}); });

View File

@@ -3,9 +3,9 @@
namespace App\Services\Settlement; namespace App\Services\Settlement;
use App\Models\Draw; use App\Models\Draw;
use App\Models\BetProvider;
use App\Models\TicketItem; use App\Models\TicketItem;
use App\Lottery\DrawStatus; use App\Lottery\DrawStatus;
use App\Models\BetProvider;
use App\Models\JackpotPool; use App\Models\JackpotPool;
use App\Models\DrawResultItem; use App\Models\DrawResultItem;
use App\Models\DrawResultBatch; use App\Models\DrawResultBatch;
@@ -14,10 +14,10 @@ use Illuminate\Support\Facades\DB;
use App\Lottery\DrawResultBatchStatus; use App\Lottery\DrawResultBatchStatus;
use App\Lottery\SettlementBatchStatus; use App\Lottery\SettlementBatchStatus;
use App\Models\TicketSettlementDetail; use App\Models\TicketSettlementDetail;
use App\Services\Draw\DrawHallSnapshotBuilder;
use App\Services\Draw\LotteryHallRealtimeBroadcaster;
use App\Services\Ticket\RiskPoolService; use App\Services\Ticket\RiskPoolService;
use App\Services\Draw\DrawHallSnapshotBuilder;
use App\Services\Jackpot\JackpotBurstAllocator; use App\Services\Jackpot\JackpotBurstAllocator;
use App\Services\Draw\LotteryHallRealtimeBroadcaster;
use App\Services\AgentSettlement\AgentGameSettlementRecorder; use App\Services\AgentSettlement\AgentGameSettlementRecorder;
/** /**
@@ -54,50 +54,46 @@ final class SettlementOrchestrator
return ['handled' => false, 'jackpot_bursts' => [], 'should_notify_status' => false]; 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 = []; $jackpotBursts = [];
$handled = false; $handled = false;
$latestSettleVersion = (int) $locked->settle_version; $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() $publishedBatches = DrawResultBatch::query()
->where('draw_id', $locked->id) ->where('draw_id', $locked->id)
->where('status', DrawResultBatchStatus::Published->value) ->where('status', DrawResultBatchStatus::Published->value)
->whereIn('provider_code', $providerCodes)
->orderBy('provider_code') ->orderBy('provider_code')
->orderByDesc('result_version') ->orderByDesc('result_version')
->orderByDesc('id') ->orderByDesc('id')
->get() ->get()
->unique('provider_code') ->unique(fn (DrawResultBatch $batch): string => strtoupper((string) $batch->provider_code))
->keyBy('provider_code'); ->keyBy(fn (DrawResultBatch $batch): string => strtoupper((string) $batch->provider_code));
/**
* @var array<string, array{
* published_batch: DrawResultBatch,
* settlement_batch: SettlementBatch,
* board: PublishedDrawResultBoard,
* prepared: list<array{item: TicketItem, gross_win: int, matched_tier: ?string, net_win: int, match_detail: mixed}>
* }> $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 */ /** @var DrawResultBatch|null $publishedBatch */
$publishedBatch = $publishedBatches->get($providerCode); $publishedBatch = $publishedBatches->get($providerCode);
if ($publishedBatch === null) { if ($publishedBatch === null) {
continue; return null;
} }
$existingDone = SettlementBatch::query() $batchRow = SettlementBatch::query()
->where('draw_id', $locked->id) ->where('draw_id', $locked->id)
->where('result_batch_id', $publishedBatch->id) ->where('result_batch_id', $publishedBatch->id)
->whereIn('status', [ ->whereIn('status', [
@@ -107,48 +103,109 @@ final class SettlementOrchestrator
SettlementBatchStatus::Paid->value, SettlementBatchStatus::Paid->value,
SettlementBatchStatus::Completed->value, SettlementBatchStatus::Completed->value,
]) ])
->orderByDesc('id')
->first(); ->first();
if ($existingDone !== null) { if ($batchRow !== null && in_array($batchRow->status, [
$handled = true; SettlementBatchStatus::Paid->value,
$latestSettleVersion = max($latestSettleVersion, (int) $existingDone->settle_version); SettlementBatchStatus::Completed->value,
continue; ], true)) {
throw new \RuntimeException('settlement_batch_already_finalized_with_pending_tickets');
} }
$items = DrawResultItem::query() if ($batchRow !== null) {
->where('result_batch_id', $publishedBatch->id) $handled = true;
->orderBy('id') $latestSettleVersion = max($latestSettleVersion, (int) $batchRow->settle_version);
->get(); $batchRow->forceFill([
$board = new PublishedDrawResultBoard($items); 'status' => SettlementBatchStatus::Running->value,
$nextSettleVersion = $latestSettleVersion + 1; 'review_status' => 'pending',
$latestSettleVersion = $nextSettleVersion; 'reviewed_by' => null,
'reviewed_at' => null,
'review_remark' => null,
'finished_at' => null,
])->save();
} else {
$latestSettleVersion++;
$batchRow = SettlementBatch::query()->create([ $batchRow = SettlementBatch::query()->create([
'draw_id' => $locked->id, 'draw_id' => $locked->id,
'result_batch_id' => $publishedBatch->id, 'result_batch_id' => $publishedBatch->id,
'settle_version' => $nextSettleVersion, 'settle_version' => $latestSettleVersion,
'status' => SettlementBatchStatus::Running->value, 'status' => SettlementBatchStatus::Running->value,
'review_status' => 'pending', 'review_status' => 'pending',
'started_at' => now(), '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();
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<array{item: TicketItem, gross_win: int, matched_tier: ?string, net_win: int, match_detail: mixed}> $prepared */
$prepared = [];
foreach ($providerTicketItems as $item) {
$matcher = $this->matchers->for((string) $item->play_code); $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']); $gross = max(0, (int) $result['win_amount']);
$tier = $result['matched_prize_tier'] ?? null; $tier = $result['matched_prize_tier'] ?? null;
$tier = is_string($tier) ? $tier : null; $providerContexts[$providerCode]['prepared'][] = [
$net = $this->payoutAdjuster->adjustGrossWin($gross, $item);
$prepared[] = [
'item' => $item, 'item' => $item,
'gross_win' => $gross, 'gross_win' => $gross,
'matched_tier' => $tier, 'matched_tier' => is_string($tier) ? $tier : null,
'net_win' => $net, 'net_win' => $this->payoutAdjuster->adjustGrossWin($gross, $item),
'match_detail' => $result['match_detail'], '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<array{item: TicketItem, gross_win: int, matched_tier: ?string, net_win: int, match_detail: mixed}> $prepared */
$prepared = $context['prepared'];
$allocations = []; $allocations = [];
$totalJackpotPayout = 0; $totalJackpotPayout = 0;
@@ -187,9 +244,10 @@ final class SettlementOrchestrator
} }
} }
$ticketCount = 0; $ticketCount = (int) $batchRow->total_ticket_count;
$winCount = 0; $winCount = (int) $batchRow->total_win_count;
$totalPayout = 0; $totalPayout = (int) $batchRow->total_payout_amount;
$totalJackpotPayout += (int) $batchRow->total_jackpot_payout_amount;
foreach ($prepared as $p) { foreach ($prepared as $p) {
/** @var TicketItem $item */ /** @var TicketItem $item */
@@ -216,7 +274,12 @@ final class SettlementOrchestrator
'status' => $terminalStatus, 'status' => $terminalStatus,
])->save(); ])->save();
$this->agentGameSettlement->recordForTicketItem($item, $net, $terminalStatus); $this->agentGameSettlement->recordForTicketItem(
$item,
$net,
$terminalStatus,
(int) $batchRow->settle_version,
);
if ($finalCredit > 0) { if ($finalCredit > 0) {
$winCount++; $winCount++;
@@ -239,17 +302,51 @@ final class SettlementOrchestrator
} }
$batchRow->forceFill([ $batchRow->forceFill([
'status' => SettlementBatchStatus::PendingReview->value, 'status' => SettlementBatchStatus::Running->value,
'total_ticket_count' => $ticketCount, 'total_ticket_count' => $ticketCount,
'total_win_count' => $winCount, 'total_win_count' => $winCount,
'total_payout_amount' => $totalPayout, 'total_payout_amount' => $totalPayout,
'total_jackpot_payout_amount' => $totalJackpotPayout, 'total_jackpot_payout_amount' => $totalJackpotPayout,
'finished_at' => now(), 'finished_at' => null,
])->save(); ])->save();
$handled = true; $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) { if (! $handled) {
return ['handled' => false, 'jackpot_bursts' => [], 'should_notify_status' => false]; return ['handled' => false, 'jackpot_bursts' => [], 'should_notify_status' => false];
} }

View File

@@ -2,11 +2,11 @@
namespace App\Services\Settlement; namespace App\Services\Settlement;
use App\Models\SettlementBatch;
use App\Lottery\SettlementBatchStatus;
use App\Services\AuditLogger; use App\Services\AuditLogger;
use App\Models\SettlementBatch;
use App\Services\LotterySettings; use App\Services\LotterySettings;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use App\Lottery\SettlementBatchStatus;
/** /**
* draw tick 在自动结算后,按系统设置自动审核并派彩入账。 * draw tick 在自动结算后,按系统设置自动审核并派彩入账。
@@ -24,15 +24,38 @@ final class SettlementTickFinalizer
$paid = 0; $paid = 0;
$payoutFailed = 0; $payoutFailed = 0;
if (! (bool) LotterySettings::get('settlement.auto_approve_on_tick', true)) { $autoApprove = (bool) LotterySettings::get('settlement.auto_approve_on_tick', true);
return ['approved' => 0, 'paid' => 0, 'payout_failed' => 0]; $pending = $autoApprove
} ? SettlementBatch::query()
$pending = SettlementBatch::query()
->where('status', SettlementBatchStatus::PendingReview->value) ->where('status', SettlementBatchStatus::PendingReview->value)
->orderBy('id') ->orderBy('id')
->limit((int) config('lottery.draw_tick_finalize_limit', 5)) ->limit((int) config('lottery.draw_tick_finalize_limit', 5))
->get(); ->get()
: collect();
// 除本轮待审核批次外,也恢复处理上轮已批准但尚未派彩的批次。
// 这样进程即使在 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))
->pluck('approved.draw_id');
$candidateDrawIds = $pending->pluck('draw_id')
->merge($approvedDrawIds)
->map(fn ($id): int => (int) $id)
->unique()
->values();
foreach ($pending as $batch) { foreach ($pending as $batch) {
try { try {
@@ -43,13 +66,33 @@ final class SettlementTickFinalizer
continue; 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; continue;
} }
$approvedBatches = SettlementBatch::query()
->where('draw_id', $drawId)
->where('status', SettlementBatchStatus::Approved->value)
->orderBy('id')
->get();
foreach ($approvedBatches as $batch) {
try { try {
$this->workflow->payout($batch->fresh()); $this->workflow->payout($batch);
$paid++; $paid++;
AuditLogger::recordForSystem( AuditLogger::recordForSystem(
moduleCode: 'settlement', moduleCode: 'settlement',
@@ -60,15 +103,16 @@ final class SettlementTickFinalizer
); );
} catch (\Throwable $e) { } catch (\Throwable $e) {
report($e); report($e);
$this->markAutoPayoutFailed($batch, $e); $this->recordAutoPayoutFailure($batch, $e);
$payoutFailed++; $payoutFailed++;
} }
} }
}
return ['approved' => $approved, 'paid' => $paid, 'payout_failed' => $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); $message = mb_substr($e->getMessage(), 0, 200);
@@ -79,7 +123,7 @@ final class SettlementTickFinalizer
} }
$locked->forceFill([ $locked->forceFill([
'status' => SettlementBatchStatus::Failed->value, 'auto_payout_attempts' => (int) $locked->auto_payout_attempts + 1,
'review_remark' => 'auto_payout_failed: '.$message, 'review_remark' => 'auto_payout_failed: '.$message,
])->save(); ])->save();

View File

@@ -5,10 +5,13 @@ namespace App\Services\Ticket;
use App\Models\Draw; use App\Models\Draw;
use App\Models\WalletTxn; use App\Models\WalletTxn;
use App\Models\TicketItem; use App\Models\TicketItem;
use App\Models\BetProvider;
use App\Models\TicketOrder; 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\Draw\DrawHallSnapshotBuilder;
use App\Services\Jackpot\JackpotContributionService; use App\Services\Jackpot\JackpotContributionService;
use Illuminate\Support\Facades\DB;
final class TicketPendingConfirmReconcileService final class TicketPendingConfirmReconcileService
{ {
@@ -17,6 +20,7 @@ final class TicketPendingConfirmReconcileService
private readonly JackpotContributionService $jackpotContribution, private readonly JackpotContributionService $jackpotContribution,
private readonly DrawHallSnapshotBuilder $drawHallSnapshot, private readonly DrawHallSnapshotBuilder $drawHallSnapshot,
private readonly TicketWalletService $ticketWallet, private readonly TicketWalletService $ticketWallet,
private readonly PlayerCreditService $playerCredit,
) {} ) {}
/** /**
@@ -45,6 +49,13 @@ final class TicketPendingConfirmReconcileService
return 'skipped'; return 'skipped';
} }
$player = $lockedOrder->player()->first();
if ($player !== null && PlayerFundingMode::usesCredit($player)) {
// 信用单的占额与 pending_confirm 在同一数据库事务提交;
// 能读到该状态即代表占额成功,无需依赖仅钱包盘才有的 bet_deduct 流水。
return $this->confirmOrder($lockedOrder);
}
$hasPostedDeduct = WalletTxn::query() $hasPostedDeduct = WalletTxn::query()
->where('biz_type', 'bet_deduct') ->where('biz_type', 'bet_deduct')
->where('biz_no', $lockedOrder->order_no) ->where('biz_no', $lockedOrder->order_no)
@@ -114,6 +125,17 @@ final class TicketPendingConfirmReconcileService
private function refundStalePendingOrder(TicketOrder $lockedOrder, string $reasonCode): string 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() $hasPostedDeduct = WalletTxn::query()
->where('biz_type', 'bet_deduct') ->where('biz_type', 'bet_deduct')
->where('biz_no', $lockedOrder->order_no) ->where('biz_no', $lockedOrder->order_no)
@@ -131,6 +153,15 @@ final class TicketPendingConfirmReconcileService
private function refundOrderWithoutDeduct(TicketOrder $lockedOrder): string 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'); return $this->refundPendingConfirmItems($lockedOrder, 'pending_confirm_timeout');
} }
@@ -155,7 +186,7 @@ final class TicketPendingConfirmReconcileService
if ($locks !== []) { if ($locks !== []) {
$this->riskPool->release( $this->riskPool->release(
(int) $lockedOrder->draw_id, (int) $lockedOrder->draw_id,
(string) ($item->provider_code ?: \App\Models\BetProvider::DEFAULT_CODE), (string) ($item->provider_code ?: BetProvider::DEFAULT_CODE),
$item, $item,
$locks, $locks,
); );

View File

@@ -3,9 +3,9 @@
namespace App\Services\Wallet; namespace App\Services\Wallet;
use App\Models\Player; 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\Services\Integration\PartnerSiteConfigResolver;
use App\Support\Integration\WalletApiUrlSanitizer;
/** /**
* 查询主站钱包余额(供玩家端余额接口填充 main_balance * 查询主站钱包余额(供玩家端余额接口填充 main_balance
@@ -14,6 +14,7 @@ final class HttpMainSiteWalletBalanceClient
{ {
public function __construct( public function __construct(
private readonly PartnerSiteConfigResolver $partnerSiteConfigResolver, private readonly PartnerSiteConfigResolver $partnerSiteConfigResolver,
private readonly WalletApiRequestGuard $walletApiRequestGuard,
) {} ) {}
public function fetch(Player $player, string $currencyCode): ?int public function fetch(Player $player, string $currencyCode): ?int
@@ -52,8 +53,11 @@ final class HttpMainSiteWalletBalanceClient
); );
} }
$base = WalletApiUrlSanitizer::normalizeAndValidate($config->walletApiUrl); $endpoint = $this->walletApiRequestGuard->guard(
if ($base === null) { $config->walletApiUrl,
$config->walletTimeoutSeconds,
);
if ($endpoint === null) {
return new MainSiteWalletBalanceProbeResult( return new MainSiteWalletBalanceProbeResult(
success: false, success: false,
mainBalanceMinor: null, mainBalanceMinor: null,
@@ -66,12 +70,11 @@ final class HttpMainSiteWalletBalanceClient
} }
$path = $config->walletBalancePath; $path = $config->walletBalancePath;
$url = $base.'/'.ltrim($path, '/'); $url = $endpoint->baseUrl.'/'.ltrim($path, '/');
$timeout = $config->walletTimeoutSeconds;
$apiKey = $config->walletApiKey; $apiKey = $config->walletApiKey;
if (app()->environment(['production']) if (app()->environment(['production'])
&& $config->source === \App\Services\Integration\PartnerSiteConfig::SOURCE_LEGACY_ENV && $config->source === PartnerSiteConfig::SOURCE_LEGACY_ENV
&& (! is_string($apiKey) || trim($apiKey) === '') && (! is_string($apiKey) || trim($apiKey) === '')
) { ) {
return new MainSiteWalletBalanceProbeResult( return new MainSiteWalletBalanceProbeResult(
@@ -97,8 +100,7 @@ final class HttpMainSiteWalletBalanceClient
]; ];
try { try {
$response = Http::withHeaders($headers) $response = $endpoint->request($headers)
->timeout($timeout)
->acceptJson() ->acceptJson()
->get($url, $query); ->get($url, $query);
} catch (\Throwable $e) { } catch (\Throwable $e) {

View File

@@ -3,10 +3,10 @@
namespace App\Services\Wallet; namespace App\Services\Wallet;
use App\Models\Player; use App\Models\Player;
use Illuminate\Support\Facades\Http;
use GuzzleHttp\Exception\ConnectException; use GuzzleHttp\Exception\ConnectException;
use App\Services\Integration\PartnerSiteConfig;
use App\Support\Integration\WalletApiRequestGuard;
use App\Services\Integration\PartnerSiteConfigResolver; use App\Services\Integration\PartnerSiteConfigResolver;
use App\Support\Integration\WalletApiUrlSanitizer;
/** /**
* 通过 HTTP 调用主站钱包 API路径见 config lottery.main_site.wallet_*_path * 通过 HTTP 调用主站钱包 API路径见 config lottery.main_site.wallet_*_path
@@ -15,6 +15,7 @@ final class HttpMainSiteWalletGateway implements MainSiteWalletGateway
{ {
public function __construct( public function __construct(
private readonly PartnerSiteConfigResolver $partnerSiteConfigResolver, private readonly PartnerSiteConfigResolver $partnerSiteConfigResolver,
private readonly WalletApiRequestGuard $walletApiRequestGuard,
) {} ) {}
public function debitMainForLotteryDeposit( public function debitMainForLotteryDeposit(
@@ -77,7 +78,7 @@ final class HttpMainSiteWalletGateway implements MainSiteWalletGateway
string $currencyCode, string $currencyCode,
int $amountMinor, int $amountMinor,
string $idempotentKey, string $idempotentKey,
\App\Services\Integration\PartnerSiteConfig $config, PartnerSiteConfig $config,
): MainSiteWalletResult { ): MainSiteWalletResult {
if (! $config->hasWalletApi()) { if (! $config->hasWalletApi()) {
$requestSnapshot = [ $requestSnapshot = [
@@ -108,8 +109,11 @@ final class HttpMainSiteWalletGateway implements MainSiteWalletGateway
); );
} }
$base = WalletApiUrlSanitizer::normalizeAndValidate($config->walletApiUrl); $endpoint = $this->walletApiRequestGuard->guard(
if ($base === null) { $config->walletApiUrl,
$config->walletTimeoutSeconds,
);
if ($endpoint === null) {
return MainSiteWalletResult::failure( return MainSiteWalletResult::failure(
'wallet_api_url_invalid', 'wallet_api_url_invalid',
['reason' => 'invalid_base_url'], ['reason' => 'invalid_base_url'],
@@ -142,12 +146,11 @@ final class HttpMainSiteWalletGateway implements MainSiteWalletGateway
], ],
]); ]);
$url = $base.'/'.ltrim($path, '/'); $url = $endpoint->baseUrl.'/'.ltrim($path, '/');
$timeout = $config->walletTimeoutSeconds;
$apiKey = $config->walletApiKey; $apiKey = $config->walletApiKey;
if (app()->environment(['production']) if (app()->environment(['production'])
&& $config->source === \App\Services\Integration\PartnerSiteConfig::SOURCE_LEGACY_ENV && $config->source === PartnerSiteConfig::SOURCE_LEGACY_ENV
&& (! is_string($apiKey) || trim($apiKey) === '') && (! is_string($apiKey) || trim($apiKey) === '')
) { ) {
return MainSiteWalletResult::failure( return MainSiteWalletResult::failure(
@@ -164,8 +167,7 @@ final class HttpMainSiteWalletGateway implements MainSiteWalletGateway
} }
try { try {
$response = Http::withHeaders($headers) $response = $endpoint->request($headers)
->timeout($timeout)
->acceptJson() ->acceptJson()
->asJson() ->asJson()
->post($url, $requestBody); ->post($url, $requestBody);

View File

@@ -3,15 +3,15 @@
namespace App\Services\Wallet; namespace App\Services\Wallet;
use App\Models\Player; use App\Models\Player;
use Illuminate\Support\Facades\Http; use App\Support\Integration\WalletApiRequestGuard;
use App\Services\Integration\PartnerSiteConfigResolver; use App\Services\Integration\PartnerSiteConfigResolver;
use App\Support\Integration\WalletApiUrlSanitizer;
/** 按幂等键查询主站钱包侧是否已有对应划转记录。 */ /** 按幂等键查询主站钱包侧是否已有对应划转记录。 */
final class HttpMainSiteWalletIdempotentProbeClient final class HttpMainSiteWalletIdempotentProbeClient
{ {
public function __construct( public function __construct(
private readonly PartnerSiteConfigResolver $partnerSiteConfigResolver, private readonly PartnerSiteConfigResolver $partnerSiteConfigResolver,
private readonly WalletApiRequestGuard $walletApiRequestGuard,
) {} ) {}
public function probe(Player $player, string $idempotentKey): MainSiteWalletIdempotentProbeResult public function probe(Player $player, string $idempotentKey): MainSiteWalletIdempotentProbeResult
@@ -32,8 +32,11 @@ final class HttpMainSiteWalletIdempotentProbeClient
); );
} }
$base = WalletApiUrlSanitizer::normalizeAndValidate($config->walletApiUrl); $endpoint = $this->walletApiRequestGuard->guard(
if ($base === null) { $config->walletApiUrl,
$config->walletTimeoutSeconds,
);
if ($endpoint === null) {
return new MainSiteWalletIdempotentProbeResult( return new MainSiteWalletIdempotentProbeResult(
status: MainSiteWalletIdempotentProbeResult::STATUS_UNAVAILABLE, status: MainSiteWalletIdempotentProbeResult::STATUS_UNAVAILABLE,
message: 'wallet_api_url_invalid', message: 'wallet_api_url_invalid',
@@ -41,7 +44,7 @@ final class HttpMainSiteWalletIdempotentProbeClient
} }
$path = $config->walletLookupIdempotentPath; $path = $config->walletLookupIdempotentPath;
$url = $base.'/'.ltrim($path, '/'); $url = $endpoint->baseUrl.'/'.ltrim($path, '/');
$headers = ['Accept' => 'application/json']; $headers = ['Accept' => 'application/json'];
if (is_string($config->walletApiKey) && $config->walletApiKey !== '') { if (is_string($config->walletApiKey) && $config->walletApiKey !== '') {
$headers['Authorization'] = 'Bearer '.$config->walletApiKey; $headers['Authorization'] = 'Bearer '.$config->walletApiKey;
@@ -54,8 +57,7 @@ final class HttpMainSiteWalletIdempotentProbeClient
]; ];
try { try {
$response = Http::withHeaders($headers) $response = $endpoint->request($headers)
->timeout($config->walletTimeoutSeconds)
->acceptJson() ->acceptJson()
->get($url, $query); ->get($url, $query);
} catch (\Throwable $e) { } catch (\Throwable $e) {

View File

@@ -3,7 +3,7 @@
namespace App\Services\Wallet; namespace App\Services\Wallet;
/** /**
* 主站 balance 探测结果(后台联调检测用,含诊断信息)。 * 主站 balance 探测结果(后台联调检测用)。
*/ */
final readonly class MainSiteWalletBalanceProbeResult final readonly class MainSiteWalletBalanceProbeResult
{ {
@@ -29,7 +29,6 @@ final readonly class MainSiteWalletBalanceProbeResult
'request_url' => $this->requestUrl, 'request_url' => $this->requestUrl,
'http_status' => $this->httpStatus, 'http_status' => $this->httpStatus,
'message' => $this->message, 'message' => $this->message,
'response_preview' => $this->responseBody,
]; ];
} }
} }

View File

@@ -0,0 +1,48 @@
<?php
namespace App\Support;
use App\Models\AdminUser;
use App\Models\ReportJob;
use Illuminate\Database\Eloquent\Builder;
/** 报表导出任务的所有权与报表类型能力门禁。 */
final class AdminReportJobPolicy
{
/**
* @param Builder<ReportJob> $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,
};
}
}

View File

@@ -0,0 +1,43 @@
<?php
namespace App\Support\Integration;
use Illuminate\Support\Facades\Http;
use Illuminate\Http\Client\PendingRequest;
final readonly class GuardedWalletApiEndpoint
{
public function __construct(
public string $baseUrl,
public string $hostname,
public int $port,
public string $pinnedIp,
public int $totalTimeoutSeconds,
public int $connectTimeoutSeconds,
) {}
/** @param array<string, string> $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;
}
}

View File

@@ -0,0 +1,31 @@
<?php
namespace App\Support\Integration;
use App\Contracts\WalletApiDnsResolver;
final class SystemWalletApiDnsResolver implements WalletApiDnsResolver
{
public function resolveAll(string $hostname): array
{
$records = dns_get_record($hostname, DNS_A | DNS_AAAA);
if (! is_array($records)) {
return [];
}
$addresses = [];
foreach ($records as $record) {
$address = match ($record['type'] ?? null) {
'A' => $record['ip'] ?? null,
'AAAA' => $record['ipv6'] ?? null,
default => null,
};
if (is_string($address) && $address !== '') {
$addresses[] = $address;
}
}
return array_values(array_unique($addresses));
}
}

View File

@@ -0,0 +1,74 @@
<?php
namespace App\Support\Integration;
use App\Contracts\WalletApiDnsResolver;
final readonly class WalletApiRequestGuard
{
private const MAX_CONNECT_TIMEOUT_SECONDS = 5;
public function __construct(
private WalletApiDnsResolver $dnsResolver,
) {}
public function guard(?string $rawBaseUrl, int $timeoutSeconds): ?GuardedWalletApiEndpoint
{
$baseUrl = WalletApiUrlSanitizer::normalizeAndValidate($rawBaseUrl);
if ($baseUrl === null) {
return null;
}
$parts = parse_url($baseUrl);
if (! is_array($parts) || ! is_string($parts['host'] ?? null)) {
return null;
}
$hostname = trim((string) $parts['host'], '[]');
$port = isset($parts['port']) ? (int) $parts['port'] : 443;
if (filter_var($hostname, FILTER_VALIDATE_IP) !== false) {
$addresses = [$hostname];
} else {
// Pinning is required for hostnames; without cURL, a second DNS lookup could rebind.
if (! extension_loaded('curl')
|| ! defined('CURLOPT_RESOLVE')
|| (! function_exists('curl_exec') && ! function_exists('curl_multi_exec'))
) {
return null;
}
try {
$addresses = $this->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),
);
}
}

View File

@@ -11,7 +11,7 @@ namespace App\Support\Integration;
* - 不允许除 / 以外的 path即仅允许根地址 * - 不允许除 / 以外的 path即仅允许根地址
* - 拒绝 localhost 与私网/保留网段IP 字面量层面) * - 拒绝 localhost 与私网/保留网段IP 字面量层面)
* *
* 说明:对 hostname 不做 DNS 解析(避免引入不确定性),但会拦截 localhost 及明显内网标识 * hostname DNS 解析与请求时固定解析由 WalletApiRequestGuard 负责
*/ */
final class WalletApiUrlSanitizer final class WalletApiUrlSanitizer
{ {
@@ -26,13 +26,6 @@ final class WalletApiUrlSanitizer
return null; 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/"); $raw = rtrim($raw, " \t\n\r\0\x0B/");
@@ -68,6 +61,8 @@ final class WalletApiUrlSanitizer
return null; return null;
} }
$host = trim($host, '[]');
// 明确拦截 localhost / 本地常见名 // 明确拦截 localhost / 本地常见名
if ($host === 'localhost' || $host === 'local' || $host === 'localdomain') { if ($host === 'localhost' || $host === 'local' || $host === 'localdomain') {
return null; return null;
@@ -76,7 +71,16 @@ final class WalletApiUrlSanitizer
// 拦截 IP 字面量私网 // 拦截 IP 字面量私网
$isIp = filter_var($host, FILTER_VALIDATE_IP) !== false; $isIp = filter_var($host, FILTER_VALIDATE_IP) !== false;
if ($isIp) { 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; 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'])) { if (isset($parts['port'])) {
$normalized .= ':'.(string) (int) $parts['port']; $normalized .= ':'.(string) (int) $parts['port'];
} }
@@ -97,18 +104,26 @@ final class WalletApiUrlSanitizer
return $normalized; 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)) { if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
$v = ip2long($ip); $v = ip2long($ip);
if ($v === false) { if ($v === false) {
return true; return false;
} }
// PHP 在 macOS 上 ip2long 可能为有符号,强转为 int64 统一处理 // PHP 在 macOS 上 ip2long 可能为有符号,强转为 int64 统一处理
$v = (int) $v; $v = (int) $v;
return self::ipInRangesV4((int) $v, [ return ! self::ipInRangesV4((int) $v, [
// 0.0.0.0/8 // 0.0.0.0/8
['base' => ip2long('0.0.0.0'), 'mask' => 0xFF000000], ['base' => ip2long('0.0.0.0'), 'mask' => 0xFF000000],
// 10.0.0.0/8 // 10.0.0.0/8
@@ -125,6 +140,11 @@ final class WalletApiUrlSanitizer
['base' => ip2long('100.64.0.0'), 'mask' => 0xFFC00000], ['base' => ip2long('100.64.0.0'), 'mask' => 0xFFC00000],
// 192.0.0.0/24 (IETF Protocol Assignments) // 192.0.0.0/24 (IETF Protocol Assignments)
['base' => ip2long('192.0.0.0'), 'mask' => 0xFFFFFF00], ['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) // 198.18.0.0/15 (benchmarking)
['base' => ip2long('198.18.0.0'), 'mask' => 0xFFFE0000], ['base' => ip2long('198.18.0.0'), 'mask' => 0xFFFE0000],
// 224.0.0.0/4 (multicast) // 224.0.0.0/4 (multicast)
@@ -138,17 +158,17 @@ final class WalletApiUrlSanitizer
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) { if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) {
$bin = inet_pton($ip); $bin = inet_pton($ip);
if ($bin === false) { if ($bin === false) {
return true; return false;
} }
// IPv6 ::1 (loopback) // IPv6 ::1 (loopback)
if (substr($bin, 0, 15) === str_repeat("\0", 15) && $bin[15] === "\1") { if (substr($bin, 0, 15) === str_repeat("\0", 15) && $bin[15] === "\1") {
return true; return false;
} }
// IPv6 unspecified :: // IPv6 unspecified ::
if ($bin === str_repeat("\0", 16)) { if ($bin === str_repeat("\0", 16)) {
return true; return false;
} }
$b0 = ord($bin[0]); $b0 = ord($bin[0]);
@@ -156,34 +176,52 @@ final class WalletApiUrlSanitizer
// ff00::/8 multicast // ff00::/8 multicast
if ($b0 === 0xFF) { if ($b0 === 0xFF) {
return true; return false;
} }
// fc00::/7 unique local => fc or fd // fc00::/7 unique local => fc or fd
if ($b0 === 0xFC || $b0 === 0xFD) { if ($b0 === 0xFC || $b0 === 0xFD) {
return true; return false;
} }
// fe80::/10 link-local => fe + (second byte & 0xC0) == 0x80 // fe80::/10 link-local => fe + (second byte & 0xC0) == 0x80
if ($b0 === 0xFE && (($b1 & 0xC0) === 0x80)) { if ($b0 === 0xFE && (($b1 & 0xC0) === 0x80)) {
return true; return false;
} }
// IPv4-mapped ::ffff:0:0/96 => 检查最后 4 字节映射的 IPv4 是否为私网 // IPv4-mapped ::ffff:0:0/96 => 检查最后 4 字节映射的 IPv4 是否为私网
if (substr($bin, 0, 10) === str_repeat("\0", 10) && substr($bin, 10, 2) === "\xFF\xFF") { if (substr($bin, 0, 10) === str_repeat("\0", 10) && substr($bin, 10, 2) === "\xFF\xFF") {
$v4bin = substr($bin, 12, 4); $v4bin = substr($bin, 12, 4);
$v4 = inet_ntop($v4bin); $v4 = inet_ntop($v4bin);
// inet_ntop 对 v4bin 有时返回 false这里保守返回 true // inet_ntop 对 v4bin 有时返回 false这里保守拒绝
if ($v4 === 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保守拒绝 // 非法 IP保守拒绝
return true; return false;
} }
private static function ipInRangesV4(int $v, array $ranges): bool private static function ipInRangesV4(int $v, array $ranges): bool
@@ -199,4 +237,3 @@ final class WalletApiUrlSanitizer
return false; return false;
} }
} }

View File

@@ -10,24 +10,24 @@
*/ */
use App\Lottery\ErrorCode; use App\Lottery\ErrorCode;
use App\Support\ApiResponse;
use App\Support\ApiMessage; use App\Support\ApiMessage;
use App\Support\ApiValidationErrors;
use Illuminate\Http\Request;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use App\Support\ApiResponse;
use Illuminate\Http\Request;
use App\Support\LotteryLocale; use App\Support\LotteryLocale;
use App\Support\ApiValidationErrors;
use Illuminate\Foundation\Application; use Illuminate\Foundation\Application;
use App\Http\Middleware\EnsureAdminApi; use App\Http\Middleware\EnsureAdminApi;
use App\Http\Middleware\EnsurePlayerApi; use App\Http\Middleware\EnsurePlayerApi;
use App\Http\Middleware\RecordAdminApiAudit;
use Illuminate\Console\Scheduling\Schedule; use Illuminate\Console\Scheduling\Schedule;
use App\Http\Middleware\RecordAdminApiAudit;
use Illuminate\Auth\AuthenticationException; use Illuminate\Auth\AuthenticationException;
use App\Http\Middleware\EnsureAdminApiResourcePermission;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
use App\Http\Middleware\NegotiateLotteryLocale; use App\Http\Middleware\NegotiateLotteryLocale;
use Illuminate\Foundation\Configuration\Exceptions; use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware; use Illuminate\Foundation\Configuration\Middleware;
use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Database\Eloquent\ModelNotFoundException;
use App\Http\Middleware\EnsureAdminApiResourcePermission;
use Symfony\Component\HttpKernel\Exception\HttpException; use Symfony\Component\HttpKernel\Exception\HttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\HttpKernel\Exception\TooManyRequestsHttpException; use Symfony\Component\HttpKernel\Exception\TooManyRequestsHttpException;
@@ -38,9 +38,15 @@ return Application::configure(basePath: dirname(__DIR__))
// 自动加前缀 `api` + middleware `api`,见 routes/api.php // 自动加前缀 `api` + middleware `api`,见 routes/api.php
api: __DIR__.'/../routes/api.php', api: __DIR__.'/../routes/api.php',
commands: __DIR__.'/../routes/console.php', commands: __DIR__.'/../routes/console.php',
channels: __DIR__.'/../routes/channels.php',
health: '/up', health: '/up',
) )
->withBroadcasting(
__DIR__.'/../routes/channels.php',
[
'prefix' => 'api',
'middleware' => ['api', 'lottery.player'],
],
)
->withMiddleware(function (Middleware $middleware): void { ->withMiddleware(function (Middleware $middleware): void {
// 多语言:必须在其他 api 中间件之前执行,以便鉴权失败时也能按语言返回 msg // 多语言:必须在其他 api 中间件之前执行,以便鉴权失败时也能按语言返回 msg
$middleware->api(prepend: [ $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/*')) { if (! $request->is('api/*')) {
return null; 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/*')) { if (! $request->is('api/*')) {
return null; return null;
} }

View File

@@ -18,6 +18,7 @@ return [
'settlement' => [ 'settlement' => [
'default_timezone' => env('LOTTERY_SETTLEMENT_TIMEZONE', env('APP_TIMEZONE', 'UTC')), '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验签通过后若无映射行则自动建档 | jwt.* :主站签发的 JWT验签通过后若无映射行则自动建档
| max_ttl_seconds :允许 (exp-iat) 最大秒数(默认 300=5 分钟),与「短效 Token」对齐 | max_ttl_seconds :允许 (exp-iat) 最大秒数(默认 300=5 分钟),与「短效 Token」对齐
| require_iat_claim true 时必须带 iat否则拒绝不建档 | require_iat_claim true 时必须带 iat否则拒绝不建档
| native.secret :原生玩家 JWT 专用密钥;必须显式配置且不得与任何 SSO 密钥共用
| |
| aes.key_base64 可选。32 字节原始密钥再做 Base64 写入 env LOTTERY_PLAYER_TOKEN_AES_KEY | aes.key_base64 可选。32 字节原始密钥再做 Base64 写入 env LOTTERY_PLAYER_TOKEN_AES_KEY
| 有值时 Bearer 串(非 xxx.yyy.zzz 外形)会先尝试 AES-GCM 解包为内层 JWT 再验签。 | 有值时 Bearer 串(非 xxx.yyy.zzz 外形)会先尝试 AES-GCM 解包为内层 JWT 再验签。
@@ -80,7 +82,7 @@ return [
'key_base64' => env('LOTTERY_PLAYER_TOKEN_AES_KEY'), 'key_base64' => env('LOTTERY_PLAYER_TOKEN_AES_KEY'),
], ],
'native' => [ '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))), 'ttl_seconds' => max(300, min(86400, (int) env('LOTTERY_NATIVE_JWT_TTL_SECONDS', 28800))),
'claim_player_id' => 'player_id', 'claim_player_id' => 'player_id',
'claim_auth_source' => 'auth_source', '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_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_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)), 'draw_tick_rng_limit' => max(1, (int) env('LOTTERY_DRAW_TICK_RNG_LIMIT', 10)),
]; ];

View File

@@ -1,5 +1,31 @@
<?php <?php
$configuredAllowedOrigins = trim((string) env('REVERB_ALLOWED_ORIGINS', ''));
if ($configuredAllowedOrigins === '') {
$configuredAllowedOrigins = trim((string) env('CORS_ALLOWED_ORIGINS', ''));
}
if ($configuredAllowedOrigins === '' && env('APP_ENV', 'production') === 'local') {
$configuredAllowedOrigins = 'localhost,127.0.0.1';
}
$allowedOrigins = array_values(array_unique(array_filter(array_map(
static function (string $origin): string {
$origin = trim($origin);
if ($origin === '' || $origin === '*') {
return '';
}
if (str_starts_with($origin, '*.')) {
return strtolower($origin);
}
$host = parse_url(str_contains($origin, '://') ? $origin : '//'.$origin, PHP_URL_HOST);
return is_string($host) ? strtolower($host) : '';
},
explode(',', $configuredAllowedOrigins),
))));
return [ return [
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
@@ -78,7 +104,7 @@ return [
'scheme' => env('REVERB_SCHEME', 'https'), 'scheme' => env('REVERB_SCHEME', 'https'),
'useTLS' => env('REVERB_SCHEME', 'https') === 'https', 'useTLS' => env('REVERB_SCHEME', 'https') === 'https',
], ],
'allowed_origins' => ['*'], 'allowed_origins' => $allowedOrigins,
'ping_interval' => env('REVERB_APP_PING_INTERVAL', 60), 'ping_interval' => env('REVERB_APP_PING_INTERVAL', 60),
'activity_timeout' => env('REVERB_APP_ACTIVITY_TIMEOUT', 30), 'activity_timeout' => env('REVERB_APP_ACTIVITY_TIMEOUT', 30),
'max_connections' => env('REVERB_APP_MAX_CONNECTIONS'), 'max_connections' => env('REVERB_APP_MAX_CONNECTIONS'),

View File

@@ -1,7 +1,7 @@
<?php <?php
use App\Support\InvalidPlatformAgentRoleCleanup;
use Illuminate\Database\Migrations\Migration; use Illuminate\Database\Migrations\Migration;
use App\Support\InvalidPlatformAgentRoleCleanup;
return new class extends Migration return new class extends Migration
{ {

View File

@@ -0,0 +1,85 @@
<?php
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
return new class extends Migration
{
private const INDEX = 'uk_credit_ledger_ref_reason';
public function up(): void
{
if (! Schema::hasTable('credit_ledger')) {
return;
}
if (! Schema::hasColumn('credit_ledger', 'settlement_version')) {
Schema::table('credit_ledger', function (Blueprint $table): void {
$table->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);
});
}
};

View File

@@ -0,0 +1,32 @@
<?php
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
return new class extends Migration
{
public function up(): void
{
if (! Schema::hasTable('settlement_batches')
|| Schema::hasColumn('settlement_batches', 'auto_payout_attempts')) {
return;
}
Schema::table('settlement_batches', function (Blueprint $table): void {
$table->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');
});
}
};

View File

@@ -1,7 +1,10 @@
<?php <?php
use App\Models\Player;
use Illuminate\Support\Facades\Broadcast; use Illuminate\Support\Facades\Broadcast;
Broadcast::channel('App.Models.User.{id}', function ($user, $id) { Broadcast::channel(
return (int) $user->id === (int) $id; 'player.{playerId}',
}); static fn (Player $player, string $playerId): bool => ctype_digit($playerId)
&& (string) $player->getKey() === $playerId,
);

View File

@@ -1,19 +1,21 @@
<?php <?php
use App\Models\AdminSite;
use App\Models\AuditLog;
use App\Models\AdminUser;
use App\Models\Player; use App\Models\Player;
use App\Services\Integration\PartnerSiteConfig; use App\Models\AuditLog;
use App\Services\Integration\PartnerSiteConfigResolver; use App\Models\AdminSite;
use App\Models\AdminUser;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Database\Seeders\CurrencySeeder;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Http;
use App\Services\Integration\PartnerSiteConfig;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use App\Services\Integration\PartnerSiteConfigResolver;
uses(RefreshDatabase::class); uses(RefreshDatabase::class);
beforeEach(function (): void { beforeEach(function (): void {
fakeWalletApiDns();
$this->artisan('lottery:admin-auth-sync')->assertExitCode(0); $this->artisan('lottery:admin-auth-sync')->assertExitCode(0);
}); });
@@ -221,7 +223,81 @@ test('connectivity test probes partner balance api', function (): void {
$response->assertOk() $response->assertOk()
->assertJsonPath('data.probe.success', true) ->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 { 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 { test('player list is filtered by admin site binding', function (): void {
$this->seed(\Database\Seeders\CurrencySeeder::class); $this->seed(CurrencySeeder::class);
Player::query()->create([ Player::query()->create([
'site_code' => 'site-a', 'site_code' => 'site-a',

View File

@@ -0,0 +1,234 @@
<?php
use App\Models\AdminRole;
use App\Models\AdminSite;
use App\Models\AdminUser;
use App\Models\ReportJob;
use Laravel\Sanctum\Sanctum;
use App\Services\AuditLogger;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function (): void {
$this->artisan('lottery:admin-auth-sync')->assertExitCode(0);
});
/**
* @param list<string> $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');
});

View File

@@ -1,10 +1,13 @@
<?php <?php
use App\Models\Draw;
use App\Models\Player; use App\Models\Player;
use App\Models\TicketItem; use App\Models\TicketItem;
use App\Services\AgentSettlement\GameSettlementReversalService; use App\Lottery\DrawStatus;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use App\Services\Player\PlayerCreditService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use App\Services\AgentSettlement\GameSettlementReversalService;
uses(RefreshDatabase::class); uses(RefreshDatabase::class);
@@ -31,11 +34,11 @@ test('reversal zeroes share ledger net and marks rebates reversed', function ():
'status' => 0, 'status' => 0,
]); ]);
$drawId = (int) \App\Models\Draw::query()->create([ $drawId = (int) Draw::query()->create([
'draw_no' => 'REV-DRAW-1', 'draw_no' => 'REV-DRAW-1',
'business_date' => now()->toDateString(), 'business_date' => now()->toDateString(),
'sequence_no' => 1, 'sequence_no' => 1,
'status' => \App\Lottery\DrawStatus::Open->value, 'status' => DrawStatus::Open->value,
'current_result_version' => 0, 'current_result_version' => 0,
'settle_version' => 0, 'settle_version' => 0,
'is_reopened' => false, 'is_reopened' => false,
@@ -148,11 +151,11 @@ test('reversal restores credit used for settled win on credit player', function
'updated_at' => now(), 'updated_at' => now(),
]); ]);
$drawId = (int) \App\Models\Draw::query()->create([ $drawId = (int) Draw::query()->create([
'draw_no' => 'REV-CREDIT-DRAW', 'draw_no' => 'REV-CREDIT-DRAW',
'business_date' => now()->toDateString(), 'business_date' => now()->toDateString(),
'sequence_no' => 1, 'sequence_no' => 1,
'status' => \App\Lottery\DrawStatus::Open->value, 'status' => DrawStatus::Open->value,
'current_result_version' => 0, 'current_result_version' => 0,
'settle_version' => 0, 'settle_version' => 0,
'is_reopened' => false, 'is_reopened' => false,
@@ -219,12 +222,52 @@ test('reversal restores credit used for settled win on credit player', function
'updated_at' => now(), '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]); DB::table('player_credit_accounts')->where('player_id', $player->id)->update(['used_credit' => 0]);
$item = TicketItem::query()->findOrFail($itemId); $item = TicketItem::query()->findOrFail($itemId);
app(GameSettlementReversalService::class)->reverseTicketItem($item); 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(); 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);
});

View File

@@ -116,6 +116,37 @@ test('jwt first successful login auto-registers player mapping', function () {
->and($player->nickname)->toBe($username); ->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 () { test('player me rejects non-active status with 8005', function () {
$code = ErrorCode::PlayerAccountSuspended->value; $code = ErrorCode::PlayerAccountSuspended->value;
$player = Player::query()->create([ $player = Player::query()->create([

View File

@@ -0,0 +1,174 @@
<?php
use Firebase\JWT\JWT;
use App\Models\Player;
use App\Lottery\ErrorCode;
use App\Support\PlayerAuthSource;
use App\Support\PlayerFundingMode;
use Illuminate\Support\Facades\DB;
use Database\Seeders\CurrencySeeder;
use Illuminate\Support\Facades\Hash;
use Database\Seeders\LotterySettingsSeeder;
use App\Services\Player\PlayerNativeAuthService;
use App\Exceptions\PlayerAuthenticationException;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function (): void {
config([
'lottery.player_auth.native.secret' => '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);
});

View File

@@ -1,21 +1,25 @@
<?php <?php
use App\Events\BalanceUpdateBroadcast;
use App\Events\PlayCatalogUpdatedBroadcast;
use App\Events\RiskSoldOutBroadcast;
use App\Events\RiskWarningBroadcast;
use App\Services\Config\RiskCapStreamService;
use App\Models\Draw; use App\Models\Draw;
use App\Models\Player; use App\Models\Player;
use App\Models\PlayerWallet;
use App\Models\RiskPool; use App\Models\RiskPool;
use App\Services\Ticket\RiskPoolService; use App\Models\AdminUser;
use App\Services\Wallet\LotteryTransferService; use App\Models\PlayerWallet;
use App\Services\Wallet\WalletBalanceRealtimeNotifier; use App\Events\RiskSoldOutBroadcast;
use App\Events\RiskWarningBroadcast;
use Database\Seeders\CurrencySeeder; use Database\Seeders\CurrencySeeder;
use Database\Seeders\LotterySettingsSeeder; use Illuminate\Support\Facades\Hash;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Event;
use App\Events\BalanceUpdateBroadcast;
use App\Services\Ticket\RiskPoolService;
use Illuminate\Support\Facades\Broadcast;
use App\Events\PlayCatalogUpdatedBroadcast;
use Database\Seeders\LotterySettingsSeeder;
use Illuminate\Broadcasting\PrivateChannel;
use App\Services\Config\RiskCapStreamService;
use App\Services\Wallet\LotteryTransferService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use App\Services\Wallet\WalletBalanceRealtimeNotifier;
uses(RefreshDatabase::class); uses(RefreshDatabase::class);
@@ -26,6 +30,55 @@ beforeEach(function (): void {
$this->seed(LotterySettingsSeeder::class); $this->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 { test('wallet balance notifier dispatches balance update broadcast', function (): void {
Event::fake([BalanceUpdateBroadcast::class]); 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 { test('risk cap publish dispatches play catalog updated broadcast', function (): void {
Event::fake([PlayCatalogUpdatedBroadcast::class]); Event::fake([PlayCatalogUpdatedBroadcast::class]);
$admin = \App\Models\AdminUser::query()->create([ $admin = AdminUser::query()->create([
'username' => 'risk_cap_admin', 'username' => 'risk_cap_admin',
'name' => 'Risk Cap QA', 'name' => 'Risk Cap QA',
'email' => null, 'email' => null,
'password' => \Illuminate\Support\Facades\Hash::make('secret-strong'), 'password' => Hash::make('secret-strong'),
'status' => 0, 'status' => 0,
]); ]);
grantSuperAdminRole($admin); grantSuperAdminRole($admin);

View File

@@ -0,0 +1,401 @@
<?php
use App\Models\Draw;
use App\Models\Player;
use App\Models\AdminUser;
use App\Models\TicketItem;
use App\Lottery\DrawStatus;
use App\Models\TicketOrder;
use App\Models\PlayerWallet;
use App\Models\DrawResultItem;
use App\Models\DrawResultBatch;
use App\Models\SettlementBatch;
use App\Models\TicketCombination;
use App\Services\LotterySettings;
use Illuminate\Support\Facades\Hash;
use App\Lottery\DrawResultBatchStatus;
use App\Lottery\SettlementBatchStatus;
use App\Services\Draw\DrawPrizeLayout;
use Illuminate\Foundation\Testing\RefreshDatabase;
use App\Services\Settlement\SettlementOrchestrator;
use App\Services\Settlement\SettlementTickFinalizer;
use App\Services\Settlement\SettlementBatchWorkflowService;
uses(RefreshDatabase::class);
function createChunkTestDraw(string $suffix): Draw
{
return Draw::query()->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);
});

View File

@@ -1,23 +1,24 @@
<?php <?php
use App\Models\Draw;
use App\Models\Player;
use App\Models\AdminRole; use App\Models\AdminRole;
use App\Models\AdminUser; use App\Models\AdminUser;
use App\Models\Draw;
use App\Models\DrawResultBatch;
use App\Models\Player;
use App\Models\SettlementBatch;
use App\Models\TicketItem; use App\Models\TicketItem;
use App\Lottery\DrawResultBatchStatus;
use App\Lottery\DrawStatus; use App\Lottery\DrawStatus;
use App\Lottery\SettlementBatchStatus; use App\Models\DrawResultBatch;
use App\Services\AgentSettlement\AgentGameSettlementRecorder; use App\Models\SettlementBatch;
use App\Services\AgentSettlement\GameSettlementReversalService;
use App\Services\Settlement\SettlementBatchWorkflowService;
use App\Services\Settlement\SettlementTickFinalizer;
use App\Support\PlayerFundingMode; use App\Support\PlayerFundingMode;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
use App\Lottery\DrawResultBatchStatus;
use App\Lottery\SettlementBatchStatus;
use App\Services\Player\PlayerCreditService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use App\Services\Settlement\SettlementTickFinalizer;
use App\Services\Settlement\SettlementBatchWorkflowService;
use App\Services\AgentSettlement\AgentGameSettlementRecorder;
use App\Services\AgentSettlement\GameSettlementReversalService;
uses(RefreshDatabase::class); uses(RefreshDatabase::class);
@@ -132,15 +133,63 @@ test('agent recorder allows re-settlement after share ledger reversal', function
$item->setRelation('player', $player); $item->setRelation('player', $player);
$recorder = app(AgentGameSettlementRecorder::class); $recorder = app(AgentGameSettlementRecorder::class);
$recorder->recordForTicketItem($item, 0, 'settled_lose'); DB::table('player_credit_accounts')->where('player_id', $player->id)->update(['used_credit' => 1]);
expect(countActiveShareLedgerRows($item->id))->toBe(1);
app(GameSettlementReversalService::class)->reverseTicketItem($item->fresh()); $recorder->recordForTicketItem($item, 0, 'settled_lose', 1);
expect(countActiveShareLedgerRows($item->id))->toBe(0); 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(); $item->forceFill(['agent_settled_at' => null])->save();
$recorder->recordForTicketItem($item->fresh(), 0, 'settled_lose'); $recorder->recordForTicketItem($item->fresh(), 0, 'settled_lose', 2);
expect(countActiveShareLedgerRows($item->id))->toBe(1); 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 { 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(); ->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'); $player = creditAgentPlayerForFixtures('tick-fail');
$draw = Draw::query()->create([ $draw = Draw::query()->create([
'draw_no' => 'FIX-FAIL-DRAW', 'draw_no' => 'FIX-FAIL-DRAW',
@@ -363,7 +412,7 @@ test('settlement tick finalizer marks approved batch failed when payout throws',
'updated_at' => now(), 'updated_at' => now(),
]); ]);
DB::table('ticket_items')->insert([ $orphanItemId = (int) DB::table('ticket_items')->insertGetId([
'ticket_no' => 'T-FIX-FAIL-ORPHAN', 'ticket_no' => 'T-FIX-FAIL-ORPHAN',
'order_id' => $orderId, 'order_id' => $orderId,
'player_id' => $player->id, 'player_id' => $player->id,
@@ -394,6 +443,13 @@ test('settlement tick finalizer marks approved batch failed when payout throws',
$result = app(SettlementTickFinalizer::class)->finalizePendingBatches(); $result = app(SettlementTickFinalizer::class)->finalizePendingBatches();
expect($result['payout_failed'])->toBe(1) 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'); ->and($batch->fresh()->review_remark)->toContain('auto_payout_failed');
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);
}); });

View File

@@ -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(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); ->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);
});

View File

@@ -4,12 +4,13 @@ use App\Models\Player;
use App\Lottery\ErrorCode; use App\Lottery\ErrorCode;
use App\Models\PlayerWallet; use App\Models\PlayerWallet;
use Database\Seeders\CurrencySeeder; use Database\Seeders\CurrencySeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Http;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class); uses(RefreshDatabase::class);
beforeEach(function (): void { beforeEach(function (): void {
fakeWalletApiDns();
$this->seed(CurrencySeeder::class); $this->seed(CurrencySeeder::class);
config(['lottery.main_site.wallet_api_url' => null]); config(['lottery.main_site.wallet_api_url' => null]);
}); });

View File

@@ -18,6 +18,7 @@ use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class); uses(RefreshDatabase::class);
beforeEach(function (): void { beforeEach(function (): void {
fakeWalletApiDns();
config(['lottery.main_site.wallet_api_url' => null]); config(['lottery.main_site.wallet_api_url' => null]);
$this->seed(CurrencySeeder::class); $this->seed(CurrencySeeder::class);
$this->seed(LotterySettingsSeeder::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); ->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 { test('transfer in main site timeout returns 1002 and pending_reconcile', function (): void {
Http::fake([ Http::fake([
'timeout-debit.test/*' => Http::response([], 504), 'timeout-debit.test/*' => Http::response([], 504),

View File

@@ -14,6 +14,7 @@ use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class); uses(RefreshDatabase::class);
beforeEach(function (): void { beforeEach(function (): void {
fakeWalletApiDns();
config(['lottery.main_site.wallet_api_url' => null]); config(['lottery.main_site.wallet_api_url' => null]);
$this->seed(CurrencySeeder::class); $this->seed(CurrencySeeder::class);
$this->seed(LotterySettingsSeeder::class); $this->seed(LotterySettingsSeeder::class);

View File

@@ -2,9 +2,24 @@
use Tests\TestCase; use Tests\TestCase;
use App\Models\AdminUser; use App\Models\AdminUser;
use App\Models\AgentProfile;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use App\Support\SuperAdminAccount;
use Illuminate\Support\Facades\DB; 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 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; use Illuminate\Foundation\Testing\RefreshDatabase;
/* /*
@@ -53,8 +68,8 @@ expect()->extend('toBeOne', function () {
/** 为后台测试账号挂上唯一超级管理员(不绑定站点)。 */ /** 为后台测试账号挂上唯一超级管理员(不绑定站点)。 */
function grantSuperAdminRole(AdminUser $admin): void function grantSuperAdminRole(AdminUser $admin): void
{ {
\App\Support\PlatformSystemRoles::ensureSuperAdminRole(); PlatformSystemRoles::ensureSuperAdminRole();
\App\Support\SuperAdminAccount::assign($admin); SuperAdminAccount::assign($admin);
} }
/** 为后台测试账号挂上代理节点(需已存在 agent_nodes / admin_user_agents 表)。 */ /** 为后台测试账号挂上代理节点(需已存在 agent_nodes / admin_user_agents 表)。 */
@@ -76,6 +91,37 @@ function agentChildPayload(array $overrides = []): array
], $overrides); ], $overrides);
} }
/**
* 为钱包 HTTP 测试提供离线 DNS避免测试依赖机器或公网解析状态。
*
* @param array<string, list<string>> $recordsByHost
* @param list<string> $defaultRecords
*/
function fakeWalletApiDns(
array $recordsByHost = [],
array $defaultRecords = ['93.184.216.34'],
): void {
app()->instance(WalletApiDnsResolver::class, new class($recordsByHost, $defaultRecords) implements WalletApiDnsResolver
{
/**
* @param array<string, list<string>> $recordsByHost
* @param list<string> $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 function bindAdminUserToAgent(AdminUser $admin, int $agentNodeId): void
{ {
DB::table('admin_user_agents')->updateOrInsert( DB::table('admin_user_agents')->updateOrInsert(
@@ -96,7 +142,7 @@ function ensureRootAgentProfileSeeded(): void
return; return;
} }
\App\Models\AgentProfile::query()->updateOrCreate( AgentProfile::query()->updateOrCreate(
['agent_node_id' => (int) $rootId], ['agent_node_id' => (int) $rootId],
[ [
'total_share_rate' => 100, 'total_share_rate' => 100,
@@ -154,15 +200,15 @@ function ensureAdminActionCatalogSeeded(): void
function allBroadcastEvents(): array function allBroadcastEvents(): array
{ {
return [ return [
\App\Events\BalanceUpdateBroadcast::class, BalanceUpdateBroadcast::class,
\App\Events\DrawCountdownBroadcast::class, DrawCountdownBroadcast::class,
\App\Events\DrawResultPublishedBroadcast::class, DrawResultPublishedBroadcast::class,
\App\Events\DrawStatusChangeBroadcast::class, DrawStatusChangeBroadcast::class,
\App\Events\JackpotBurstBroadcast::class, JackpotBurstBroadcast::class,
\App\Events\OddsUpdateBroadcast::class, OddsUpdateBroadcast::class,
\App\Events\PlayCatalogUpdatedBroadcast::class, PlayCatalogUpdatedBroadcast::class,
\App\Events\PlayToggleBroadcast::class, PlayToggleBroadcast::class,
\App\Events\RiskSoldOutBroadcast::class, RiskSoldOutBroadcast::class,
\App\Events\RiskWarningBroadcast::class, RiskWarningBroadcast::class,
]; ];
} }

View File

@@ -0,0 +1,74 @@
<?php
use Illuminate\Http\Client\PendingRequest;
use App\Support\Integration\WalletApiRequestGuard;
use App\Support\Integration\WalletApiUrlSanitizer;
test('wallet api sanitizer rejects private and reserved literal addresses', function (string $url): void {
expect(WalletApiUrlSanitizer::normalizeAndValidate($url))->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]');
});