fix(core): harden settlement and wallet integration
This commit is contained in:
9
app/Contracts/WalletApiDnsResolver.php
Normal file
9
app/Contracts/WalletApiDnsResolver.php
Normal file
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
namespace App\Contracts;
|
||||
|
||||
interface WalletApiDnsResolver
|
||||
{
|
||||
/** @return list<string> */
|
||||
public function resolveAll(string $hostname): array;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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) */
|
||||
|
||||
@@ -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')],
|
||||
]);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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[] = [
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
71
app/Services/Player/NativeJwtSecretGuard.php
Normal file
71
app/Services/Player/NativeJwtSecretGuard.php
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),支持部分收付多笔递增。
|
||||
*/
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
48
app/Support/AdminReportJobPolicy.php
Normal file
48
app/Support/AdminReportJobPolicy.php
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
43
app/Support/Integration/GuardedWalletApiEndpoint.php
Normal file
43
app/Support/Integration/GuardedWalletApiEndpoint.php
Normal 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;
|
||||
}
|
||||
}
|
||||
31
app/Support/Integration/SystemWalletApiDnsResolver.php
Normal file
31
app/Support/Integration/SystemWalletApiDnsResolver.php
Normal 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));
|
||||
}
|
||||
}
|
||||
74
app/Support/Integration/WalletApiRequestGuard.php
Normal file
74
app/Support/Integration/WalletApiRequestGuard.php
Normal 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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user