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

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

View File

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

View File

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

View File

@@ -2,16 +2,22 @@
namespace App\Http\Controllers\Api\V1\Admin\Reports;
use App\Http\Controllers\Controller;
use App\Models\ReportJob;
use App\Support\ApiResponse;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
use App\Http\Controllers\Controller;
use App\Support\AdminReportJobPolicy;
/** GET /api/v1/admin/report-jobs/{report_job} */
final class ReportJobShowController extends Controller
{
public function __invoke(ReportJob $report_job): JsonResponse
public function __invoke(Request $request, ReportJob $report_job): JsonResponse
{
$admin = $request->lotteryAdmin();
abort_if($admin === null, 401);
abort_unless(AdminReportJobPolicy::jobAccessible($admin, $report_job), 403);
return ApiResponse::success([
'id' => (int) $report_job->id,
'job_no' => $report_job->job_no,

View File

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

View File

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

View File

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

View File

@@ -3,16 +3,16 @@
namespace App\Models;
use Laravel\Sanctum\HasApiTokens;
use App\Support\AgentPlatformRole;
use App\Support\SuperAdminAccount;
use Illuminate\Support\Facades\DB;
use App\Support\PlatformSystemRoles;
use App\Support\AdminPermissionBridge;
use App\Support\AgentProfileCapabilityFilter;
use App\Models\AdminRole;
use App\Models\AgentNode;
use App\Models\AgentProfile;
use Illuminate\Notifications\Notifiable;
use App\Support\AgentProfileCapabilityFilter;
use Illuminate\Validation\ValidationException;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Validation\ValidationException;
final class AdminUser extends Authenticatable
{
@@ -139,10 +139,10 @@ final class AdminUser extends Authenticatable
});
}
/** 经营代理主账号:仅平台角色 slug=agent见 {@see \App\Support\AgentPlatformRole})。 */
/** 经营代理主账号:仅平台角色 slug=agent见 {@see AgentPlatformRole})。 */
public function syncPrimaryPlatformAgentRole(int $agentNodeId): void
{
$this->syncAgentRoleIds($agentNodeId, [\App\Support\AgentPlatformRole::id()]);
$this->syncAgentRoleIds($agentNodeId, [AgentPlatformRole::id()]);
}
/**
@@ -250,8 +250,8 @@ final class AdminUser extends Authenticatable
public function syncSystemRoleSlugsForSite(int $siteId, array $slugs): void
{
$slugs = array_values(array_unique($slugs));
\App\Support\SuperAdminAccount::assertNotSiteRoleAssignment($slugs);
if (in_array(\App\Support\PlatformSystemRoles::SLUG_AGENT, $slugs, true)) {
SuperAdminAccount::assertNotSiteRoleAssignment($slugs);
if (in_array(PlatformSystemRoles::SLUG_AGENT, $slugs, true)) {
throw ValidationException::withMessages([
'role_slugs' => [trans('admin.agent_role_not_assignable_to_platform_account')],
]);

View File

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

View File

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

View File

@@ -2,23 +2,26 @@
namespace App\Services\Admin;
use App\Models\AdminUser;
use App\Models\AuditLog;
use App\Support\AdminDataScope;
use App\Support\AdminScopeContext;
use App\Support\AdminScopeContextResolver;
use Carbon\Carbon;
use App\Models\Draw;
use App\Models\AuditLog;
use App\Models\RiskPool;
use App\Models\RiskPoolLockLog;
use App\Models\SettlementBatch;
use App\Models\AdminUser;
use App\Models\WalletTxn;
use App\Models\TicketItem;
use App\Models\TicketOrder;
use App\Models\TransferOrder;
use App\Models\WalletTxn;
use Carbon\Carbon;
use App\Services\AuditLogger;
use App\Support\LimitedQuery;
use App\Models\RiskPoolLockLog;
use App\Models\SettlementBatch;
use App\Support\AdminDataScope;
use App\Support\AdminScopeContext;
use Illuminate\Support\Facades\DB;
use Illuminate\Database\Query\Builder;
use App\Support\AdminScopeContextResolver;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Pagination\LengthAwarePaginator as PaginatorInstance;
use Illuminate\Support\Facades\DB;
/**
* 报表中心聚合查询(模块十三)。
@@ -498,7 +501,7 @@ final class AdminReportQueryService
->whereDate('d.business_date', '>=', $dateFrom)
->whereDate('d.business_date', '<=', $dateTo)
->selectRaw($providerCodeSql.' as provider_code')
->selectRaw("COALESCE(NULLIF(MAX(ti.provider_name), ''), MAX(bp.name), ".$providerCodeSql.") as provider_name")
->selectRaw("COALESCE(NULLIF(MAX(ti.provider_name), ''), MAX(bp.name), ".$providerCodeSql.') as provider_name')
->selectRaw('COUNT(ti.id) as ticket_item_count')
->selectRaw('SUM(ti.actual_deduct_amount) as total_bet_minor')
->selectRaw('SUM(ti.win_amount + ti.jackpot_win_amount) as total_payout_minor')
@@ -514,7 +517,7 @@ final class AdminReportQueryService
/**
* @return list<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);
$dateFrom = $range['date_from'];
@@ -526,7 +529,7 @@ final class AdminReportQueryService
'player_win_loss' => $this->playerWinLossExportRows($filterJson, $dateFrom, $dateTo, $scope),
'play_dimension_report' => $this->playDimensionExportRows($filterJson, $dateFrom, $dateTo, $scope),
'provider_profit_report' => $this->providerProfitExportRows($dateFrom, $dateTo, $scope),
'audit_operation_report' => $this->auditExportRows($filterJson, $dateFrom, $dateTo),
'audit_operation_report' => $this->auditExportRows($filterJson, $dateFrom, $dateTo, $scope),
'wallet_transfer_report', 'transfer_orders_daily' => $this->transferOrdersExportRows($filterJson, $dateFrom, $dateTo, $scope),
'wallet_txns_daily' => $this->walletTxnsExportRows($filterJson, $dateFrom, $dateTo, $scope),
'hot_number_risk_report' => $this->hotNumberRiskExportRows($filterJson),
@@ -645,27 +648,38 @@ final class AdminReportQueryService
foreach ($this->providerProfitPaginated($dateFrom, $dateTo, 1, 10_000, $scope)->items() as $row) {
$rows[] = [(string) $row->provider_code, (string) $row->provider_name, (int) $row->ticket_item_count, (int) $row->total_bet_minor, (int) $row->total_payout_minor, (int) $row->approx_house_gross_minor];
}
return $rows;
}
/**
* @return list<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;
$rows = [
['ID', '操作者类型', '操作者ID', '模块', '操作', 'IP', '时间'],
];
$q = AuditLog::query()->orderByDesc('id');
if (! $admin->isSuperAdmin()) {
// audit_logs 没有可靠的站点快照;普通审计账号仅能导出自己的后台操作。
$q->where('operator_type', AuditLogger::OPERATOR_ADMIN)
->where('operator_id', (int) $admin->getKey());
}
if ($operatorId !== null && $operatorId > 0) {
$q->where('operator_id', $operatorId);
}
$q->whereDate('created_at', '>=', $dateFrom)
->whereDate('created_at', '<=', $dateTo);
$limited = \App\Support\LimitedQuery::get($q, 5000);
$limited = LimitedQuery::get($q, 5000);
foreach ($limited['rows'] as $log) {
$rows[] = [
(int) $log->id,
@@ -685,7 +699,7 @@ final class AdminReportQueryService
return $rows;
}
/** @return \Illuminate\Database\Query\Builder */
/** @return Builder */
private function playerWinLossBaseQuery(
?int $playerId,
string $dateFrom,
@@ -725,7 +739,7 @@ final class AdminReportQueryService
return $query;
}
/** @return \Illuminate\Database\Query\Builder */
/** @return Builder */
private function playDimensionBaseQuery(?string $playCode, string $dateFrom, string $dateTo, AdminUser|AdminScopeContext|null $scope = null)
{
$context = $this->normalizeScope($scope);
@@ -755,9 +769,9 @@ final class AdminReportQueryService
/**
* @return list<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);
if ($draw === null) {
return [['提示', '请提供 draw_id 或 draw_no']];
@@ -766,10 +780,8 @@ final class AdminReportQueryService
$drawId = (int) $draw->id;
$orderQuery = TicketOrder::query()->where('draw_id', $drawId);
$itemQuery = TicketItem::query()->where('draw_id', $drawId);
if ($context !== null) {
AdminDataScope::applyEloquentViaPlayer($orderQuery, $context->admin);
AdminDataScope::applyEloquentViaPlayer($itemQuery, $context->admin);
}
AdminDataScope::applyEloquentViaPlayer($orderQuery, $admin);
AdminDataScope::applyEloquentViaPlayer($itemQuery, $admin);
$totalBetMinor = (int) $orderQuery->sum('total_actual_deduct');
$orderCount = (int) $orderQuery->count();
@@ -805,11 +817,13 @@ final class AdminReportQueryService
],
];
$batches = SettlementBatch::query()
->where('draw_id', $drawId)
->orderByDesc('id')
->limit(100)
->get();
$batches = $admin->isSuperAdmin()
? SettlementBatch::query()
->where('draw_id', $drawId)
->orderByDesc('id')
->limit(100)
->get()
: collect();
foreach ($batches as $batch) {
$rows[] = [

View File

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

View File

@@ -4,10 +4,10 @@ namespace App\Services\AgentSettlement;
use App\Models\Player;
use App\Models\TicketItem;
use App\Services\Player\PlayerCreditService;
use App\Support\PlayerFundingMode;
use Illuminate\Database\QueryException;
use Illuminate\Support\Facades\DB;
use Illuminate\Database\QueryException;
use App\Services\Player\PlayerCreditService;
final class GameSettlementReversalService
{
@@ -15,16 +15,26 @@ final class GameSettlementReversalService
private readonly PlayerCreditService $playerCreditService,
) {}
public function reverseTicketItem(TicketItem $item): void
public function reverseTicketItem(TicketItem $item, int $settlementVersion = 0): void
{
$ledger = DB::table('share_ledger')->where('ticket_item_id', $item->id)->whereNull('reversal_of_id')->first();
$ledger = DB::table('share_ledger as sl')
->where('sl.ticket_item_id', $item->id)
->whereNull('sl.reversal_of_id')
->whereNotExists(function ($query): void {
$query->selectRaw('1')
->from('share_ledger as reversal')
->whereColumn('reversal.reversal_of_id', 'sl.id');
})
->orderByDesc('sl.id')
->select('sl.*')
->first();
if ($ledger === null) {
return;
}
$settledAt = now();
DB::transaction(function () use ($item, $ledger, $settledAt): void {
DB::transaction(function () use ($item, $ledger, $settledAt, $settlementVersion): void {
// 幂等闸门share_ledger.reversal_of_id 上有 partial unique 索引,
// 同一原账不允许插入多条反转记录。重复调用时唯一约束冲突即视为已反转、跳过。
// 一次只让 reversal_of_id 出现一次写入确保所有后续副作用rebate、credit 冲正)
@@ -79,8 +89,14 @@ final class GameSettlementReversalService
if ($player !== null && PlayerFundingMode::usesCredit($player)) {
$gameWinLoss = (int) $ledger->game_win_loss;
if ($gameWinLoss !== 0) {
$this->playerCreditService->reverseGameSettlement($player, $gameWinLoss, $item->id);
$this->playerCreditService->reverseGameSettlement($player, $gameWinLoss, $item->id, $settlementVersion);
}
$this->playerCreditService->restoreBetHoldAfterSettlementReversal(
$player,
(int) $item->actual_deduct_amount,
(int) $item->id,
$settlementVersion,
);
}
});
}

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;
use App\Models\Player;
use App\Services\Agent\AgentUsedCreditSyncService;
use App\Support\AgentOverdueGuard;
use App\Support\CreditAmountScale;
use App\Support\PlayerFundingMode;
use Illuminate\Database\QueryException;
use Illuminate\Support\Facades\DB;
use Illuminate\Database\QueryException;
use Illuminate\Validation\ValidationException;
use App\Services\Agent\AgentUsedCreditSyncService;
final class PlayerCreditService
{
public function __construct(
private readonly AgentUsedCreditSyncService $usedCreditSync,
) {}
/**
* @param array{credit_limit?: int} $payload
*/
@@ -163,8 +164,12 @@ final class PlayerCreditService
$this->syncAgentUsedCredit($player);
}
public function applySettledLoss(Player $player, int $amountMinor, int $ticketItemId): void
{
public function applySettledLoss(
Player $player,
int $amountMinor,
int $ticketItemId,
int $settlementVersion = 0,
): void {
if ($amountMinor <= 0) {
return;
}
@@ -187,6 +192,7 @@ final class PlayerCreditService
'reason' => 'game_settlement_loss',
'ref_type' => 'ticket_item',
'ref_id' => $ticketItemId,
'settlement_version' => $settlementVersion,
'created_at' => $now,
'updated_at' => $now,
]);
@@ -218,8 +224,12 @@ final class PlayerCreditService
$this->syncAgentUsedCredit($player);
}
public function applySettledWin(Player $player, int $amountMinor, int $ticketItemId): void
{
public function applySettledWin(
Player $player,
int $amountMinor,
int $ticketItemId,
int $settlementVersion = 0,
): void {
if ($amountMinor <= 0) {
return;
}
@@ -228,30 +238,55 @@ final class PlayerCreditService
return;
}
$now = now();
$applied = DB::transaction(function () use ($player, $amountMinor, $ticketItemId, $settlementVersion): bool {
$now = now();
$currency = (string) $player->default_currency;
$requestedMajor = CreditAmountScale::minorToMajor($amountMinor, $currency);
$account = DB::table('player_credit_accounts')
->where('player_id', $player->id)
->lockForUpdate()
->first();
$appliedMajor = $account === null
? 0
: min((int) $account->used_credit, $requestedMajor);
$appliedMinor = CreditAmountScale::majorToMinor($appliedMajor, $currency);
// 先写 credit_ledger以 (ref_type, ref_id, reason) partial unique 索引为幂等闸门
// 已存在同 (ticket_item, game_settlement_win) 的流水则直接返回,避免并发/重入场景下重复扣减 used_credit
try {
DB::table('credit_ledger')->insert([
'owner_type' => 'player',
'owner_id' => $player->id,
'amount' => $amountMinor,
'reason' => 'game_settlement_win',
'ref_type' => 'ticket_item',
'ref_id' => $ticketItemId,
'created_at' => $now,
'updated_at' => $now,
]);
} catch (QueryException $e) {
if ($this->isUniqueViolation($e)) {
return;
// 流水记录实际释放的额度,而非可能超过 used_credit 的名义中奖额
// 结算被驳回时必须按这个实际值恢复,否则会凭空增加玩家已用额度
try {
DB::table('credit_ledger')->insert([
'owner_type' => 'player',
'owner_id' => $player->id,
'amount' => $appliedMinor,
'reason' => 'game_settlement_win',
'ref_type' => 'ticket_item',
'ref_id' => $ticketItemId,
'settlement_version' => $settlementVersion,
'created_at' => $now,
'updated_at' => $now,
]);
} catch (QueryException $e) {
if ($this->isUniqueViolation($e)) {
return false;
}
throw $e;
}
throw $e;
}
$this->decreaseUsedCredit($player, $amountMinor);
$this->syncAgentUsedCredit($player);
if ($account !== null && $appliedMajor > 0) {
DB::table('player_credit_accounts')
->where('player_id', $player->id)
->update([
'used_credit' => (int) $account->used_credit - $appliedMajor,
'updated_at' => $now,
]);
}
return true;
});
if ($applied) {
$this->syncAgentUsedCredit($player);
}
}
public function assertMayPlaceBet(Player $player, int $amountMinor): void
@@ -290,8 +325,12 @@ final class PlayerCreditService
}
}
public function releaseBetHold(Player $player, int $amountMinor, int $ticketItemId): void
{
public function releaseBetHold(
Player $player,
int $amountMinor,
int $ticketItemId,
int $settlementVersion = 0,
): void {
if ($amountMinor <= 0 || ! PlayerFundingMode::usesCredit($player)) {
return;
}
@@ -308,6 +347,7 @@ final class PlayerCreditService
'reason' => 'bet_hold_release',
'ref_type' => 'ticket_item',
'ref_id' => $ticketItemId,
'settlement_version' => $settlementVersion,
'created_at' => $now,
'updated_at' => $now,
]);
@@ -354,14 +394,33 @@ final class PlayerCreditService
$this->syncAgentUsedCredit($player);
}
public function reverseGameSettlement(Player $player, int $gameWinLossSigned, int $ticketItemId): void
{
public function reverseGameSettlement(
Player $player,
int $gameWinLossSigned,
int $ticketItemId,
int $settlementVersion = 0,
): void {
if ($gameWinLossSigned === 0 || ! PlayerFundingMode::usesCredit($player)) {
return;
}
$now = now();
$amountMinor = abs($gameWinLossSigned);
if ($gameWinLossSigned < 0) {
// 中奖释放 used_credit 时可能受 0 下限截断;反转只能恢复当时实际释放的数额。
// 旧流水或人工修复数据可能不存在,保留名义值兜底以兼容历史记录。
$recordedAmount = DB::table('credit_ledger')
->where('owner_type', 'player')
->where('owner_id', $player->id)
->where('ref_type', 'ticket_item')
->where('ref_id', $ticketItemId)
->where('reason', 'game_settlement_win')
->where('settlement_version', $settlementVersion)
->value('amount');
if ($recordedAmount !== null) {
$amountMinor = abs((int) $recordedAmount);
}
}
// 先写 credit_ledger以 (ref_type, ref_id, reason) partial unique 索引为幂等闸门。
try {
@@ -372,6 +431,7 @@ final class PlayerCreditService
'reason' => 'game_settlement_reversal',
'ref_type' => 'ticket_item',
'ref_id' => $ticketItemId,
'settlement_version' => $settlementVersion,
'created_at' => $now,
'updated_at' => $now,
]);
@@ -410,6 +470,56 @@ final class PlayerCreditService
$this->syncAgentUsedCredit($player);
}
/** 驳回结算后恢复原注占额,使票回到 pending_draw 时额度状态与结算前一致。 */
public function restoreBetHoldAfterSettlementReversal(
Player $player,
int $amountMinor,
int $ticketItemId,
int $settlementVersion = 0,
): void {
if ($amountMinor <= 0 || ! PlayerFundingMode::usesCredit($player)) {
return;
}
$now = now();
try {
DB::table('credit_ledger')->insert([
'owner_type' => 'player',
'owner_id' => $player->id,
'amount' => -$amountMinor,
'reason' => 'bet_hold_restore',
'ref_type' => 'ticket_item',
'ref_id' => $ticketItemId,
'settlement_version' => $settlementVersion,
'created_at' => $now,
'updated_at' => $now,
]);
} catch (QueryException $e) {
if ($this->isUniqueViolation($e)) {
return;
}
throw $e;
}
$majorDelta = CreditAmountScale::minorToMajor($amountMinor, (string) $player->default_currency);
$row = DB::table('player_credit_accounts')
->where('player_id', $player->id)
->lockForUpdate()
->first();
if ($row === null) {
return;
}
DB::table('player_credit_accounts')
->where('player_id', $player->id)
->update([
'used_credit' => (int) $row->used_credit + $majorDelta,
'updated_at' => $now,
]);
$this->syncAgentUsedCredit($player);
}
/**
* @param int $cumulativePaidMinor 账单累计已登记收付minor支持部分收付多笔递增。
*/

View File

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

View File

@@ -11,6 +11,7 @@ use App\Support\PlayerAuthSource;
use App\Support\PlayerFundingMode;
use App\Support\PlayerTokenAesUnwrap;
use Illuminate\Database\QueryException;
use App\Services\Player\NativeJwtSecretGuard;
use App\Support\PlayerAutoRegistrationDefaults;
use App\Exceptions\PlayerAuthenticationException;
use App\Services\Integration\PartnerSiteConfigResolver;
@@ -42,6 +43,7 @@ final class PlayerTokenResolver
public function __construct(
private readonly PartnerSiteConfigResolver $partnerSiteConfigResolver,
private readonly NativeJwtSecretGuard $nativeJwtSecretGuard,
) {}
public function resolve(Request $request): Player
@@ -158,14 +160,7 @@ final class PlayerTokenResolver
private function resolveNativeJwt(string $jwt): Player
{
$secret = (string) config('lottery.player_auth.native.secret', '');
if ($secret === '') {
throw new PlayerAuthenticationException(
'原生登录未配置',
ErrorCode::PlayerSsoSecretNotConfigured->value,
503,
);
}
$secret = $this->nativeJwtSecretGuard->validatedSecret();
$alg = (string) config('lottery.player_auth.jwt.algorithm', 'HS256');
@@ -256,6 +251,14 @@ final class PlayerTokenResolver
}
}
if ((string) $player->auth_source !== PlayerAuthSource::MAIN_SITE_SSO
|| (string) $player->funding_mode !== PlayerFundingMode::WALLET) {
throw new PlayerAuthenticationException(
'SSO 玩家映射与账号认证域冲突',
ErrorCode::PlayerTokenInvalid->value,
);
}
if (! $player->wasRecentlyCreated) {
$player->forceFill(['last_login_at' => $now])->save();
}

View File

@@ -10,11 +10,11 @@ use App\Lottery\DrawStatus;
use App\Models\JackpotPool;
use App\Models\TicketOrder;
use App\Models\SettlementBatch;
use App\Support\PlayerFundingMode;
use Illuminate\Support\Facades\DB;
use App\Lottery\SettlementBatchStatus;
use App\Services\AgentSettlement\GameSettlementReversalService;
use App\Services\Ticket\TicketWalletService;
use App\Support\PlayerFundingMode;
use App\Services\AgentSettlement\GameSettlementReversalService;
final class SettlementBatchWorkflowService
{
@@ -67,7 +67,7 @@ final class SettlementBatchWorkflowService
if ($itemIds !== []) {
$items = TicketItem::query()->whereIn('id', $itemIds)->get();
foreach ($items as $item) {
$this->gameSettlementReversal->reverseTicketItem($item);
$this->gameSettlementReversal->reverseTicketItem($item, (int) $locked->settle_version);
}
TicketItem::query()
@@ -118,15 +118,22 @@ final class SettlementBatchWorkflowService
throw new \RuntimeException('draw_has_unsettled_tickets');
}
if ($batchItemIds !== []) {
$orphanPendingPayout = TicketItem::query()
->where('draw_id', $locked->draw_id)
->where('status', 'pending_payout')
->whereNotIn('id', $batchItemIds)
->exists();
if ($orphanPendingPayout) {
throw new \RuntimeException('draw_has_unsettled_tickets');
}
$orphanPendingPayout = TicketItem::query()
->where('draw_id', $locked->draw_id)
->where('status', 'pending_payout')
->whereNotIn('id', $batchItemIds)
->whereNotExists(function ($query): void {
$query->selectRaw('1')
->from('ticket_settlement_details as other_details')
->join('settlement_batches as other_batches', 'other_batches.id', '=', 'other_details.settlement_batch_id')
->whereColumn('other_details.ticket_item_id', 'ticket_items.id')
->whereColumn('other_batches.draw_id', 'ticket_items.draw_id')
->where('other_batches.status', SettlementBatchStatus::Approved->value)
->where('other_batches.review_status', 'approved');
})
->exists();
if ($orphanPendingPayout) {
throw new \RuntimeException('draw_has_unsettled_tickets');
}
$details = $locked->details()->with(['ticketItem.order'])->get();
@@ -193,10 +200,34 @@ final class SettlementBatchWorkflowService
'paid_at' => now(),
])->save();
Draw::query()->whereKey($locked->draw_id)->update([
'status' => DrawStatus::Settled->value,
'settle_version' => (int) $locked->settle_version,
]);
$hasIncompleteProviderBatch = SettlementBatch::query()
->where('draw_id', $locked->draw_id)
->whereIn('status', [
SettlementBatchStatus::Running->value,
SettlementBatchStatus::PendingReview->value,
SettlementBatchStatus::Approved->value,
])
->exists();
$hasPendingTicket = TicketItem::query()
->where('draw_id', $locked->draw_id)
->whereIn('status', [
'pending_confirm',
'partial_pending_confirm',
'pending_draw',
'pending_payout',
])
->exists();
if (! $hasIncompleteProviderBatch && ! $hasPendingTicket) {
$settleVersion = (int) SettlementBatch::query()
->where('draw_id', $locked->draw_id)
->where('status', SettlementBatchStatus::Paid->value)
->max('settle_version');
Draw::query()->whereKey($locked->draw_id)->update([
'status' => DrawStatus::Settled->value,
'settle_version' => $settleVersion,
]);
}
return $locked->refresh();
});

View File

@@ -3,9 +3,9 @@
namespace App\Services\Settlement;
use App\Models\Draw;
use App\Models\BetProvider;
use App\Models\TicketItem;
use App\Lottery\DrawStatus;
use App\Models\BetProvider;
use App\Models\JackpotPool;
use App\Models\DrawResultItem;
use App\Models\DrawResultBatch;
@@ -14,10 +14,10 @@ use Illuminate\Support\Facades\DB;
use App\Lottery\DrawResultBatchStatus;
use App\Lottery\SettlementBatchStatus;
use App\Models\TicketSettlementDetail;
use App\Services\Draw\DrawHallSnapshotBuilder;
use App\Services\Draw\LotteryHallRealtimeBroadcaster;
use App\Services\Ticket\RiskPoolService;
use App\Services\Draw\DrawHallSnapshotBuilder;
use App\Services\Jackpot\JackpotBurstAllocator;
use App\Services\Draw\LotteryHallRealtimeBroadcaster;
use App\Services\AgentSettlement\AgentGameSettlementRecorder;
/**
@@ -54,50 +54,46 @@ final class SettlementOrchestrator
return ['handled' => false, 'jackpot_bursts' => [], 'should_notify_status' => false];
}
$ticketItems = TicketItem::query()
->where('draw_id', $locked->id)
->where('status', 'pending_draw')
->with(['combinations', 'order'])
->orderBy('id')
->limit(10_000)
->get();
if ($ticketItems->count() >= 10_000) {
\Illuminate\Support\Facades\Log::warning('SettlementOrchestrator: ticket_items hit safety cap', [
'draw_id' => $locked->id,
'draw_no' => $locked->draw_no,
'loaded_count' => $ticketItems->count(),
]);
}
$jackpotBursts = [];
$handled = false;
$latestSettleVersion = (int) $locked->settle_version;
$ticketItemsByProvider = $ticketItems->isEmpty()
? collect([BetProvider::DEFAULT_CODE => collect()])
: $ticketItems->groupBy(
fn (TicketItem $item): string => strtoupper((string) ($item->provider_code ?: BetProvider::DEFAULT_CODE)),
);
$providerCodes = $ticketItemsByProvider->keys()->values()->all();
$publishedBatches = DrawResultBatch::query()
->where('draw_id', $locked->id)
->where('status', DrawResultBatchStatus::Published->value)
->whereIn('provider_code', $providerCodes)
->orderBy('provider_code')
->orderByDesc('result_version')
->orderByDesc('id')
->get()
->unique('provider_code')
->keyBy('provider_code');
->unique(fn (DrawResultBatch $batch): string => strtoupper((string) $batch->provider_code))
->keyBy(fn (DrawResultBatch $batch): string => strtoupper((string) $batch->provider_code));
/**
* @var array<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 */
$publishedBatch = $publishedBatches->get($providerCode);
if ($publishedBatch === null) {
continue;
return null;
}
$existingDone = SettlementBatch::query()
$batchRow = SettlementBatch::query()
->where('draw_id', $locked->id)
->where('result_batch_id', $publishedBatch->id)
->whereIn('status', [
@@ -107,48 +103,109 @@ final class SettlementOrchestrator
SettlementBatchStatus::Paid->value,
SettlementBatchStatus::Completed->value,
])
->orderByDesc('id')
->first();
if ($existingDone !== null) {
$handled = true;
$latestSettleVersion = max($latestSettleVersion, (int) $existingDone->settle_version);
continue;
if ($batchRow !== null && in_array($batchRow->status, [
SettlementBatchStatus::Paid->value,
SettlementBatchStatus::Completed->value,
], true)) {
throw new \RuntimeException('settlement_batch_already_finalized_with_pending_tickets');
}
$items = DrawResultItem::query()
->where('result_batch_id', $publishedBatch->id)
if ($batchRow !== null) {
$handled = true;
$latestSettleVersion = max($latestSettleVersion, (int) $batchRow->settle_version);
$batchRow->forceFill([
'status' => SettlementBatchStatus::Running->value,
'review_status' => 'pending',
'reviewed_by' => null,
'reviewed_at' => null,
'review_remark' => null,
'finished_at' => null,
])->save();
} else {
$latestSettleVersion++;
$batchRow = SettlementBatch::query()->create([
'draw_id' => $locked->id,
'result_batch_id' => $publishedBatch->id,
'settle_version' => $latestSettleVersion,
'status' => SettlementBatchStatus::Running->value,
'review_status' => 'pending',
'started_at' => now(),
]);
}
$providerContexts[$providerCode] = [
'published_batch' => $publishedBatch,
'settlement_batch' => $batchRow,
'board' => new PublishedDrawResultBoard(
DrawResultItem::query()
->where('result_batch_id', $publishedBatch->id)
->orderBy('id')
->get(),
),
'prepared' => [],
];
return $providerContexts[$providerCode];
};
$chunkSize = max(1, (int) config('lottery.settlement.ticket_chunk_size', 10_000));
$lastTicketItemId = 0;
$sawPendingTicket = false;
while (true) {
$ticketItems = TicketItem::query()
->where('draw_id', $locked->id)
->where('status', 'pending_draw')
->where('id', '>', $lastTicketItemId)
->with(['combinations', 'order'])
->orderBy('id')
->limit($chunkSize)
->get();
$board = new PublishedDrawResultBoard($items);
$nextSettleVersion = $latestSettleVersion + 1;
$latestSettleVersion = $nextSettleVersion;
$batchRow = SettlementBatch::query()->create([
'draw_id' => $locked->id,
'result_batch_id' => $publishedBatch->id,
'settle_version' => $nextSettleVersion,
'status' => SettlementBatchStatus::Running->value,
'review_status' => 'pending',
'started_at' => now(),
]);
if ($ticketItems->isEmpty()) {
break;
}
$sawPendingTicket = true;
$lastTicketItemId = (int) $ticketItems->last()->id;
foreach ($ticketItems as $item) {
$providerCode = strtoupper((string) ($item->provider_code ?: BetProvider::DEFAULT_CODE));
$context = $openProviderContext($providerCode);
if ($context === null) {
continue;
}
/** @var list<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);
$result = $matcher->match($item, $board, $item->combinations);
$result = $matcher->match($item, $context['board'], $item->combinations);
$gross = max(0, (int) $result['win_amount']);
$tier = $result['matched_prize_tier'] ?? null;
$tier = is_string($tier) ? $tier : null;
$net = $this->payoutAdjuster->adjustGrossWin($gross, $item);
$prepared[] = [
$providerContexts[$providerCode]['prepared'][] = [
'item' => $item,
'gross_win' => $gross,
'matched_tier' => $tier,
'net_win' => $net,
'matched_tier' => is_string($tier) ? $tier : null,
'net_win' => $this->payoutAdjuster->adjustGrossWin($gross, $item),
'match_detail' => $result['match_detail'],
];
}
}
if (! $sawPendingTicket
&& $publishedBatches->has(BetProvider::DEFAULT_CODE)
&& ! SettlementBatch::query()->where('draw_id', $locked->id)->exists()
) {
$openProviderContext(BetProvider::DEFAULT_CODE);
}
foreach ($providerContexts as $providerCode => $context) {
$board = $context['board'];
$batchRow = $context['settlement_batch'];
/** @var list<array{item: TicketItem, gross_win: int, matched_tier: ?string, net_win: int, match_detail: mixed}> $prepared */
$prepared = $context['prepared'];
$allocations = [];
$totalJackpotPayout = 0;
@@ -187,9 +244,10 @@ final class SettlementOrchestrator
}
}
$ticketCount = 0;
$winCount = 0;
$totalPayout = 0;
$ticketCount = (int) $batchRow->total_ticket_count;
$winCount = (int) $batchRow->total_win_count;
$totalPayout = (int) $batchRow->total_payout_amount;
$totalJackpotPayout += (int) $batchRow->total_jackpot_payout_amount;
foreach ($prepared as $p) {
/** @var TicketItem $item */
@@ -216,7 +274,12 @@ final class SettlementOrchestrator
'status' => $terminalStatus,
])->save();
$this->agentGameSettlement->recordForTicketItem($item, $net, $terminalStatus);
$this->agentGameSettlement->recordForTicketItem(
$item,
$net,
$terminalStatus,
(int) $batchRow->settle_version,
);
if ($finalCredit > 0) {
$winCount++;
@@ -239,17 +302,51 @@ final class SettlementOrchestrator
}
$batchRow->forceFill([
'status' => SettlementBatchStatus::PendingReview->value,
'status' => SettlementBatchStatus::Running->value,
'total_ticket_count' => $ticketCount,
'total_win_count' => $winCount,
'total_payout_amount' => $totalPayout,
'total_jackpot_payout_amount' => $totalJackpotPayout,
'finished_at' => now(),
'finished_at' => null,
])->save();
$handled = true;
}
$hasPendingDrawTickets = TicketItem::query()
->where('draw_id', $locked->id)
->where('status', 'pending_draw')
->exists();
if (! $hasPendingDrawTickets) {
$finishedBatchCount = SettlementBatch::query()
->where('draw_id', $locked->id)
->where('status', SettlementBatchStatus::Running->value)
->update([
'status' => SettlementBatchStatus::PendingReview->value,
'finished_at' => now(),
'updated_at' => now(),
]);
$handled = $handled || $finishedBatchCount > 0;
}
if (! $handled) {
$activeBatch = SettlementBatch::query()
->where('draw_id', $locked->id)
->whereIn('status', [
SettlementBatchStatus::PendingReview->value,
SettlementBatchStatus::Approved->value,
SettlementBatchStatus::Paid->value,
SettlementBatchStatus::Completed->value,
])
->orderByDesc('settle_version')
->first();
if ($activeBatch !== null) {
$handled = true;
$latestSettleVersion = max($latestSettleVersion, (int) $activeBatch->settle_version);
}
}
if (! $handled) {
return ['handled' => false, 'jackpot_bursts' => [], 'should_notify_status' => false];
}

View File

@@ -2,11 +2,11 @@
namespace App\Services\Settlement;
use App\Models\SettlementBatch;
use App\Lottery\SettlementBatchStatus;
use App\Services\AuditLogger;
use App\Models\SettlementBatch;
use App\Services\LotterySettings;
use Illuminate\Support\Facades\DB;
use App\Lottery\SettlementBatchStatus;
/**
* draw tick 在自动结算后,按系统设置自动审核并派彩入账。
@@ -24,15 +24,38 @@ final class SettlementTickFinalizer
$paid = 0;
$payoutFailed = 0;
if (! (bool) LotterySettings::get('settlement.auto_approve_on_tick', true)) {
return ['approved' => 0, 'paid' => 0, 'payout_failed' => 0];
}
$autoApprove = (bool) LotterySettings::get('settlement.auto_approve_on_tick', true);
$pending = $autoApprove
? SettlementBatch::query()
->where('status', SettlementBatchStatus::PendingReview->value)
->orderBy('id')
->limit((int) config('lottery.draw_tick_finalize_limit', 5))
->get()
: collect();
$pending = SettlementBatch::query()
->where('status', SettlementBatchStatus::PendingReview->value)
->orderBy('id')
// 除本轮待审核批次外,也恢复处理上轮已批准但尚未派彩的批次。
// 这样进程即使在 approve 提交后、payout 开始前退出,下一 tick 仍能续跑。
$approvedDrawIds = DB::table('settlement_batches as approved')
->where('approved.status', SettlementBatchStatus::Approved->value)
->whereNotExists(function ($query): void {
$query->selectRaw('1')
->from('settlement_batches as sibling')
->whereColumn('sibling.draw_id', 'approved.draw_id')
->whereIn('sibling.status', [
SettlementBatchStatus::Running->value,
SettlementBatchStatus::PendingReview->value,
]);
})
->groupBy('approved.draw_id')
->orderByRaw('MAX(approved.auto_payout_attempts)')
->orderByRaw('MIN(approved.id)')
->limit((int) config('lottery.draw_tick_finalize_limit', 5))
->get();
->pluck('approved.draw_id');
$candidateDrawIds = $pending->pluck('draw_id')
->merge($approvedDrawIds)
->map(fn ($id): int => (int) $id)
->unique()
->values();
foreach ($pending as $batch) {
try {
@@ -43,32 +66,53 @@ final class SettlementTickFinalizer
continue;
}
}
if (! (bool) LotterySettings::get('settlement.auto_payout_on_tick', true)) {
if (! (bool) LotterySettings::get('settlement.auto_payout_on_tick', true)) {
return ['approved' => $approved, 'paid' => 0, 'payout_failed' => 0];
}
foreach ($candidateDrawIds as $drawId) {
$hasUnapprovedBatch = SettlementBatch::query()
->where('draw_id', $drawId)
->whereIn('status', [
SettlementBatchStatus::Running->value,
SettlementBatchStatus::PendingReview->value,
])
->exists();
if ($hasUnapprovedBatch) {
continue;
}
try {
$this->workflow->payout($batch->fresh());
$paid++;
AuditLogger::recordForSystem(
moduleCode: 'settlement',
actionCode: 'auto_payout',
targetType: 'settlement_batch',
targetId: (string) $batch->id,
afterJson: ['draw_id' => (int) $batch->draw_id],
);
} catch (\Throwable $e) {
report($e);
$this->markAutoPayoutFailed($batch, $e);
$payoutFailed++;
$approvedBatches = SettlementBatch::query()
->where('draw_id', $drawId)
->where('status', SettlementBatchStatus::Approved->value)
->orderBy('id')
->get();
foreach ($approvedBatches as $batch) {
try {
$this->workflow->payout($batch);
$paid++;
AuditLogger::recordForSystem(
moduleCode: 'settlement',
actionCode: 'auto_payout',
targetType: 'settlement_batch',
targetId: (string) $batch->id,
afterJson: ['draw_id' => (int) $batch->draw_id],
);
} catch (\Throwable $e) {
report($e);
$this->recordAutoPayoutFailure($batch, $e);
$payoutFailed++;
}
}
}
return ['approved' => $approved, 'paid' => $paid, 'payout_failed' => $payoutFailed];
}
private function markAutoPayoutFailed(SettlementBatch $batch, \Throwable $e): void
private function recordAutoPayoutFailure(SettlementBatch $batch, \Throwable $e): void
{
$message = mb_substr($e->getMessage(), 0, 200);
@@ -79,7 +123,7 @@ final class SettlementTickFinalizer
}
$locked->forceFill([
'status' => SettlementBatchStatus::Failed->value,
'auto_payout_attempts' => (int) $locked->auto_payout_attempts + 1,
'review_remark' => 'auto_payout_failed: '.$message,
])->save();
@@ -95,4 +139,4 @@ final class SettlementTickFinalizer
);
});
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

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即仅允许根地址
* - 拒绝 localhost 与私网/保留网段IP 字面量层面)
*
* 说明:对 hostname 不做 DNS 解析(避免引入不确定性),但会拦截 localhost 及明显内网标识
* hostname DNS 解析与请求时固定解析由 WalletApiRequestGuard 负责
*/
final class WalletApiUrlSanitizer
{
@@ -26,13 +26,6 @@ final class WalletApiUrlSanitizer
return null;
}
// E2E允许本地 mock 主站钱包http://127.0.0.1:port生产 LOTTERY_E2E 默认 false。
if ((bool) env('LOTTERY_E2E', false) && app()->environment(['local', 'testing'])) {
if (preg_match('#^https?://127\.0\.0\.1:\d{1,5}$#', rtrim($raw, '/')) === 1) {
return rtrim($raw, '/');
}
}
// 允许尾部 /,归一化后移除
$raw = rtrim($raw, " \t\n\r\0\x0B/");
@@ -68,6 +61,8 @@ final class WalletApiUrlSanitizer
return null;
}
$host = trim($host, '[]');
// 明确拦截 localhost / 本地常见名
if ($host === 'localhost' || $host === 'local' || $host === 'localdomain') {
return null;
@@ -76,7 +71,16 @@ final class WalletApiUrlSanitizer
// 拦截 IP 字面量私网
$isIp = filter_var($host, FILTER_VALIDATE_IP) !== false;
if ($isIp) {
if (self::ipIsPrivateOrReserved($host)) {
if (! self::isPublicIp($host)) {
return null;
}
} else {
$host = rtrim($host, '.');
if (! str_contains($host, '.')
|| preg_match('/^[0-9.]+$/D', $host) === 1
|| preg_match('/(^|\.)(?:0x[0-9a-f]+|0[0-7]+)(?:\.|$)/iD', $host) === 1
|| filter_var($host, FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME) === false
) {
return null;
}
}
@@ -89,7 +93,10 @@ final class WalletApiUrlSanitizer
}
}
$normalized = 'https://'.$host;
$normalizedHost = filter_var($host, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)
? '['.$host.']'
: $host;
$normalized = 'https://'.$normalizedHost;
if (isset($parts['port'])) {
$normalized .= ':'.(string) (int) $parts['port'];
}
@@ -97,18 +104,26 @@ final class WalletApiUrlSanitizer
return $normalized;
}
private static function ipIsPrivateOrReserved(string $ip): bool
public static function isPublicIp(string $ip): bool
{
if (filter_var(
$ip,
FILTER_VALIDATE_IP,
FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE,
) === false) {
return false;
}
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
$v = ip2long($ip);
if ($v === false) {
return true;
return false;
}
// PHP 在 macOS 上 ip2long 可能为有符号,强转为 int64 统一处理
$v = (int) $v;
return self::ipInRangesV4((int) $v, [
return ! self::ipInRangesV4((int) $v, [
// 0.0.0.0/8
['base' => ip2long('0.0.0.0'), 'mask' => 0xFF000000],
// 10.0.0.0/8
@@ -125,6 +140,11 @@ final class WalletApiUrlSanitizer
['base' => ip2long('100.64.0.0'), 'mask' => 0xFFC00000],
// 192.0.0.0/24 (IETF Protocol Assignments)
['base' => ip2long('192.0.0.0'), 'mask' => 0xFFFFFF00],
// Documentation and deprecated relay ranges
['base' => ip2long('192.0.2.0'), 'mask' => 0xFFFFFF00],
['base' => ip2long('192.88.99.0'), 'mask' => 0xFFFFFF00],
['base' => ip2long('198.51.100.0'), 'mask' => 0xFFFFFF00],
['base' => ip2long('203.0.113.0'), 'mask' => 0xFFFFFF00],
// 198.18.0.0/15 (benchmarking)
['base' => ip2long('198.18.0.0'), 'mask' => 0xFFFE0000],
// 224.0.0.0/4 (multicast)
@@ -138,17 +158,17 @@ final class WalletApiUrlSanitizer
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) {
$bin = inet_pton($ip);
if ($bin === false) {
return true;
return false;
}
// IPv6 ::1 (loopback)
if (substr($bin, 0, 15) === str_repeat("\0", 15) && $bin[15] === "\1") {
return true;
return false;
}
// IPv6 unspecified ::
if ($bin === str_repeat("\0", 16)) {
return true;
return false;
}
$b0 = ord($bin[0]);
@@ -156,34 +176,52 @@ final class WalletApiUrlSanitizer
// ff00::/8 multicast
if ($b0 === 0xFF) {
return true;
return false;
}
// fc00::/7 unique local => fc or fd
if ($b0 === 0xFC || $b0 === 0xFD) {
return true;
return false;
}
// fe80::/10 link-local => fe + (second byte & 0xC0) == 0x80
if ($b0 === 0xFE && (($b1 & 0xC0) === 0x80)) {
return true;
return false;
}
// IPv4-mapped ::ffff:0:0/96 => 检查最后 4 字节映射的 IPv4 是否为私网
if (substr($bin, 0, 10) === str_repeat("\0", 10) && substr($bin, 10, 2) === "\xFF\xFF") {
$v4bin = substr($bin, 12, 4);
$v4 = inet_ntop($v4bin);
// inet_ntop 对 v4bin 有时返回 false这里保守返回 true
// inet_ntop 对 v4bin 有时返回 false这里保守拒绝
if ($v4 === false) {
return true;
return false;
}
return self::ipIsPrivateOrReserved($v4);
return self::isPublicIp($v4);
}
// IPv4 translation prefixes can otherwise tunnel a private IPv4 target.
$isIpv4Translation = substr($bin, 0, 12) === "\x00\x64\xFF\x9B\x00\x00\x00\x00\x00\x00\x00\x00"
|| substr($bin, 0, 6) === "\x00\x64\xFF\x9B\x00\x01";
// 100::/64 discard-only, 2001::/23 protocol assignments,
// 2001:db8::/32 and 3fff::/20 documentation, 2002::/16 deprecated 6to4.
if ($isIpv4Translation
|| substr($bin, 0, 8) === "\x00\x64\x00\x00\x00\x00\x00\x00"
|| ($b0 === 0x20 && $b1 === 0x01 && (ord($bin[2]) & 0xFE) === 0)
|| substr($bin, 0, 4) === "\x20\x01\x0D\xB8"
|| ($b0 === 0x3F && ($b1 & 0xF0) === 0xF0)
|| ($b0 === 0x20 && $b1 === 0x02)
) {
return false;
}
return true;
}
// 非法 IP保守拒绝
return true;
return false;
}
private static function ipInRangesV4(int $v, array $ranges): bool
@@ -199,4 +237,3 @@ final class WalletApiUrlSanitizer
return false;
}
}