Compare commits

...

3 Commits

Author SHA1 Message Date
8ccf39dff5 refactor: 迁移彩票设置至 LotterySettings 服务
- 更新多个控制器和服务,使用 LotterySettings 服务获取彩票相关配置,如默认币种、开奖间隔、下注窗口等,提升代码一致性与可维护性。
- 移除 .env.example 中不再使用的配置项,建议通过后台管理进行设置。
2026-05-28 14:50:25 +08:00
5e73dc6ec1 feat: 添加 CORS 配置选项至 .env.example 文件
- 新增 CORS_ALLOWED_ORIGINS 和 CORS_ALLOWED_ORIGINS_PATTERNS 以支持跨域请求的来源白名单配置。
- 添加 CORS_MAX_AGE 和 CORS_SUPPORTS_CREDENTIALS 选项,增强跨域请求的灵活性与安全性。
2026-05-28 10:11:54 +08:00
fe0594beaa feat: 增强钱包 API URL 验证与配置
- 在 AdminIntegrationSiteStoreRequest 和 AdminIntegrationSiteUpdateRequest 中引入 WalletApiUrlRule,确保 wallet_api_url 字段符合 HTTPS 公开域名要求。
- 更新 HttpMainSiteWalletBalanceClient 和 HttpMainSiteWalletGateway,使用 WalletApiUrlSanitizer 进行 URL 规范化与验证,防止 SSRF 攻击。
- 新增测试用例,验证 wallet_api_url 的有效性,确保系统安全性与稳定性。
- 更新 .env.example 文件,添加 LOTTERY_RISK_POOL_USE_REDIS_LUA 配置项以支持 Redis Lua 原子扣减功能。
- 修改 package-lock.json 中的项目名称,确保一致性。
- 在 API 路由中新增 integration/runtime-origins 路由,提供运行时白名单功能。
2026-05-28 10:10:26 +08:00
35 changed files with 646 additions and 58 deletions

View File

@@ -40,6 +40,22 @@ APP_MAINTENANCE_DRIVER=file
# 内置 PHP 开发服务器 worker 数量(多核本机可酌情打开)
# PHP_CLI_SERVER_WORKERS=4
# =============================================================================
# CORS跨域config/cors.php
# =============================================================================
# 浏览器跨域访问 API 的来源白名单,逗号分隔(协议+域名+可选端口)
# 示例:
# CORS_ALLOWED_ORIGINS=https://admin.example.com,https://partner-a.com
CORS_ALLOWED_ORIGINS=
# 可选:来源正则模式(需要受控通配时再用)
# CORS_ALLOWED_ORIGINS_PATTERNS=^https://([a-z0-9-]+)\.partner\.example\.com$
CORS_ALLOWED_ORIGINS_PATTERNS=
# 预检缓存秒数0 表示不缓存
CORS_MAX_AGE=0
# 是否允许跨站 Cookie仅在确实需要浏览器跨站会话时设 true
CORS_SUPPORTS_CREDENTIALS=false
# =============================================================================
# 密码哈希config/hashing.php
# =============================================================================
@@ -123,6 +139,8 @@ FILESYSTEM_DISK=local
QUEUE_CONNECTION=redis
# 缓存存储file / database / redis 等;与 Redis 赔付池等能力对接前可用 database
CACHE_STORE=redis
# 号码赔付池是否使用 Redis Lua 原子扣减;本地无 Redis Lua 需求时可 false
LOTTERY_RISK_POOL_USE_REDIS_LUA=false
# 缓存键全局前缀;多环境共 Redis 时可用于隔离,一般可留空使用框架默认
# CACHE_PREFIX=
@@ -188,21 +206,13 @@ VITE_APP_NAME="${APP_NAME}"
# 彩票业务config/lottery.php、database/seeders密钥仅写本机 .env
# =============================================================================
# 默认结算币种(产品约定,如 NPR
LOTTERY_DEFAULT_CURRENCY=NPR
# 运营项(默认币种、开奖间隔/窗口/封盘提前、预生成期数、金额格式)已迁移后台配置 lottery_settings
# 建议在 /admin/settings 管理;此处不再提供对应 env 键。
# lottery_settings 表读缓存 TTL调小更易立即看到后台改值调大减库压
LOTTERY_SETTINGS_CACHE_TTL=60
# 开发绕过Authorization: Bearer dev:{players.id};仅当 APP_ENV 为 local 或 testing 且为 true 时生效PHPUnit 依赖 testing生产务必 false
LOTTERY_PLAYER_AUTH_DEV_BYPASS=false
# 未来期缓冲条数draw_time>now 的期数,分钟 tick 会补足);测试可 612生产可 48+
LOTTERY_DRAW_BUFFER_AHEAD=8
# 期号时刻统一为 UTCGMT见 config/lottery.php lottery.draw.timezone 与 docs/01-界面文档.md勿配置本地时区
# 开奖间隔(分钟)、下注窗(秒)、封盘提前(秒)见 config/lottery.php可按需覆盖
# LOTTERY_DRAW_INTERVAL_MINUTES=5
# LOTTERY_DRAW_BETTING_WINDOW_SECONDS=270
# LOTTERY_DRAW_CLOSE_BEFORE_SECONDS=30
# 校验主站 JWT 的算法(与签发方一致)
LOTTERY_JWT_ALGORITHM=HS256
# JWT 内表示站点编码的 claim 名
@@ -219,6 +229,7 @@ LOTTERY_JWT_REQUIRE_IAT=true
# 管理端登录Sanctum PAT 有效天数(签发时刻起),至少 1到期需重新登录
ADMIN_API_TOKEN_TTL_DAYS=7
# Legacy 主站兜底配置:正式接入站点以后台 admin_sites 为准;这里仅用于本地默认站/历史回退。
# 主站站点根 URLSSO、跳转等
MAIN_SITE_BASE_URL=
# 主站 JWT 验签密钥(与主站约定,勿泄露)

View File

@@ -5,6 +5,7 @@ namespace App\Console\Commands;
use App\Models\Draw;
use Carbon\Carbon;
use Illuminate\Console\Command;
use App\Services\LotterySettings;
use App\Services\Draw\DrawPlannerService;
/**
@@ -22,7 +23,7 @@ final class LotteryPerfDrawScheduleAuditCommand extends Command
{
$samples = max(2, (int) $this->option('samples'));
$tolerance = max(0, (int) $this->option('tolerance-seconds'));
$intervalMinutes = (int) config('lottery.draw.interval_minutes', 5);
$intervalMinutes = LotterySettings::drawIntervalMinutes();
$expectedSeconds = $intervalMinutes * 60;
$planner->ensureBuffer(Carbon::now('UTC'));

View File

@@ -7,6 +7,7 @@ use App\Lottery\ErrorCode;
use App\Support\ApiResponse;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\DB;
use App\Services\LotterySettings;
use App\Http\Controllers\Controller;
final class AdminCurrencyDestroyController extends Controller
@@ -15,7 +16,7 @@ final class AdminCurrencyDestroyController extends Controller
{
$code = strtoupper((string) $currency->code);
if ($code === strtoupper((string) config('lottery.default_currency', 'NPR'))) {
if ($code === LotterySettings::defaultCurrency()) {
return ApiResponse::error(
'默认币种不可删除',
ErrorCode::ValidationFailed->value,

View File

@@ -8,6 +8,7 @@ use App\Models\TicketItem;
use App\Models\TicketOrder;
use Illuminate\Http\Request;
use App\Support\AdminApiList;
use App\Services\LotterySettings;
use Illuminate\Http\JsonResponse;
use App\Http\Controllers\Controller;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
@@ -38,10 +39,10 @@ final class AdminDrawIndexController extends Controller
return AdminApiList::jsonWith($paginator, fn (Draw $row) => $this->row($row), [
'schedule' => [
'timezone' => (string) config('lottery.draw.timezone', 'UTC'),
'interval_minutes' => (int) config('lottery.draw.interval_minutes', 5),
'betting_window_seconds' => (int) config('lottery.draw.betting_window_seconds', 270),
'close_before_draw_seconds' => (int) config('lottery.draw.close_before_draw_seconds', 30),
'timezone' => LotterySettings::drawTimezone(),
'interval_minutes' => LotterySettings::drawIntervalMinutes(),
'betting_window_seconds' => LotterySettings::drawBettingWindowSeconds(),
'close_before_draw_seconds' => LotterySettings::drawCloseBeforeDrawSeconds(),
],
]);
}

View File

@@ -5,6 +5,7 @@ namespace App\Http\Controllers\Api\V1;
use App\Support\ApiResponse;
use Illuminate\Http\JsonResponse;
use App\Http\Controllers\Controller;
use App\Services\LotterySettings;
final class HealthController extends Controller
{
@@ -17,7 +18,7 @@ final class HealthController extends Controller
{
$payload = [
'app' => config('app.name'),
'default_currency' => config('lottery.default_currency'),
'default_currency' => LotterySettings::defaultCurrency(),
];
if (config('app.debug')) {

View File

@@ -0,0 +1,48 @@
<?php
namespace App\Http\Controllers\Api\V1\Integration;
use App\Http\Controllers\Controller;
use App\Models\AdminSite;
use App\Support\ApiResponse;
use Illuminate\Http\JsonResponse;
/**
* 玩家端 iframe 运行时白名单。
*
* 只公开启用站点的 origin不包含任何密钥或钱包地址。
*/
final class IntegrationRuntimeOriginsController extends Controller
{
public function __invoke(): JsonResponse
{
$origins = AdminSite::query()
->where('status', 1)
->pluck('iframe_allowed_origins')
->flatMap(static function (mixed $value): array {
if (is_string($value)) {
$decoded = json_decode($value, true);
$value = is_array($decoded) ? $decoded : [];
}
if (! is_array($value)) {
return [];
}
return array_values(array_filter(
array_map(
static fn (mixed $origin): string => is_string($origin) ? trim($origin) : '',
$value,
),
static fn (string $origin): bool => $origin !== '',
));
})
->unique()
->values()
->all();
return ApiResponse::success([
'iframe_allowed_origins' => $origins,
]);
}
}

View File

@@ -6,6 +6,7 @@ use App\Support\ApiResponse;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
use App\Http\Controllers\Controller;
use App\Services\LotterySettings;
use App\Services\Jackpot\JackpotSummaryService;
/**
@@ -21,7 +22,7 @@ final class JackpotSummaryController extends Controller
{
$currencyCode = strtoupper(trim((string) $request->query(
'currency_code',
(string) config('lottery.default_currency', 'NPR'),
LotterySettings::defaultCurrency(),
)));
return ApiResponse::success($this->summary->summary($currencyCode));

View File

@@ -4,6 +4,7 @@ namespace App\Http\Requests\Admin;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use App\Rules\WalletApiUrlRule;
final class AdminIntegrationSiteStoreRequest extends FormRequest
{
@@ -20,7 +21,7 @@ final class AdminIntegrationSiteStoreRequest extends FormRequest
'name' => ['required', 'string', 'max:128'],
'currency_code' => ['sometimes', 'string', 'max:16'],
'status' => ['sometimes', 'integer', 'in:0,1'],
'wallet_api_url' => ['nullable', 'string', 'max:512'],
'wallet_api_url' => ['nullable', 'string', 'max:512', new WalletApiUrlRule()],
'wallet_debit_path' => ['sometimes', 'string', 'max:128'],
'wallet_credit_path' => ['sometimes', 'string', 'max:128'],
'wallet_balance_path' => ['sometimes', 'string', 'max:128'],

View File

@@ -3,6 +3,7 @@
namespace App\Http\Requests\Admin;
use Illuminate\Foundation\Http\FormRequest;
use App\Rules\WalletApiUrlRule;
final class AdminIntegrationSiteUpdateRequest extends FormRequest
{
@@ -18,7 +19,7 @@ final class AdminIntegrationSiteUpdateRequest extends FormRequest
'name' => ['required', 'string', 'max:128'],
'currency_code' => ['sometimes', 'string', 'max:16'],
'status' => ['sometimes', 'integer', 'in:0,1'],
'wallet_api_url' => ['nullable', 'string', 'max:512'],
'wallet_api_url' => ['nullable', 'string', 'max:512', new WalletApiUrlRule()],
'wallet_debit_path' => ['sometimes', 'string', 'max:128'],
'wallet_credit_path' => ['sometimes', 'string', 'max:128'],
'wallet_balance_path' => ['sometimes', 'string', 'max:128'],

View File

@@ -0,0 +1,20 @@
<?php
namespace App\Rules;
use App\Support\Integration\WalletApiUrlSanitizer;
use Illuminate\Contracts\Validation\Rule;
final class WalletApiUrlRule implements Rule
{
public function passes($attribute, $value): bool
{
return WalletApiUrlSanitizer::normalizeAndValidate($value) !== null;
}
public function message(): string
{
return 'wallet_api_url 必须是 https 的公开域名根地址,并拒绝 localhost/内网 IP 与带路径/查询的地址。';
}
}

View File

@@ -26,8 +26,16 @@ final class AdminReportQueryService
*/
public function resolveDateRange(?array $filters): array
{
$dateFrom = (string) ($filters['date_from'] ?? now()->toDateString());
$dateTo = (string) ($filters['date_to'] ?? $dateFrom);
$fromRaw = trim((string) ($filters['date_from'] ?? ''));
$toRaw = trim((string) ($filters['date_to'] ?? ''));
// 未传日期时按历史全量范围导出/查询,避免默认“仅今天”导致空数据。
if ($fromRaw === '' && $toRaw === '') {
return $this->lifetimeBusinessDateBounds();
}
$dateFrom = $fromRaw !== '' ? $fromRaw : $toRaw;
$dateTo = $toRaw !== '' ? $toRaw : $dateFrom;
if ($dateFrom > $dateTo) {
[$dateFrom, $dateTo] = [$dateTo, $dateFrom];

View File

@@ -10,6 +10,7 @@ use App\Models\DrawResultItem;
use App\Models\DrawResultBatch;
use App\Lottery\DrawResultBatchStatus;
use App\Services\Jackpot\JackpotSummaryService;
use App\Services\LotterySettings;
/**
* `GET draw/current` 与大厅 WS 快照共用数据结构。
@@ -237,7 +238,7 @@ final class DrawHallSnapshotBuilder
$effectiveStatus = $this->effectiveHallDisplayStatus($target, $nowUtc);
$scheduleTz = (string) config('lottery.draw.timezone', 'UTC');
$scheduleTz = LotterySettings::drawTimezone();
$payload = [
'schedule_timezone' => $scheduleTz,
@@ -324,6 +325,6 @@ final class DrawHallSnapshotBuilder
return $code;
}
return strtoupper(substr(trim((string) config('lottery.default_currency', 'NPR')), 0, 16));
return LotterySettings::defaultCurrency();
}
}

View File

@@ -4,6 +4,7 @@ namespace App\Services\Draw;
use Carbon\Carbon;
use App\Models\Draw;
use App\Services\LotterySettings;
use Illuminate\Support\Facades\DB;
/**
@@ -27,7 +28,7 @@ final class DrawManualCreateService
*/
public function create(array $input, ?Carbon $now = null): Draw
{
$tz = (string) config('lottery.draw.timezone', 'UTC');
$tz = LotterySettings::drawTimezone();
$nowUtc = ($now ?? Carbon::now())->utc();
$drawLocal = $this->parseInTimezone((string) $input['draw_time'], $tz);

View File

@@ -6,6 +6,7 @@ use Carbon\Carbon;
use App\Models\Draw;
use App\Models\TicketOrder;
use App\Lottery\DrawStatus;
use App\Services\LotterySettings;
use Illuminate\Support\Facades\DB;
/**
@@ -29,7 +30,7 @@ final class DrawManualUpdateService
*/
public function update(Draw $draw, array $input, ?Carbon $now = null): Draw
{
$tz = (string) config('lottery.draw.timezone', 'UTC');
$tz = LotterySettings::drawTimezone();
$nowUtc = ($now ?? Carbon::now())->utc();
return DB::transaction(function () use ($draw, $input, $tz, $nowUtc): Draw {

View File

@@ -5,6 +5,7 @@ namespace App\Services\Draw;
use Carbon\Carbon;
use App\Models\Draw;
use App\Lottery\DrawStatus;
use App\Services\LotterySettings;
use Illuminate\Support\Facades\DB;
use Illuminate\Database\QueryException;
@@ -21,9 +22,9 @@ final class DrawPlannerService
public function ensureBuffer(?Carbon $now = null): array
{
$nowUtc = ($now ?? Carbon::now())->utc();
$tz = (string) config('lottery.draw.timezone', 'UTC');
$interval = (int) config('lottery.draw.interval_minutes', 5);
$buffer = (int) config('lottery.draw.buffer_draws_ahead', 8);
$tz = LotterySettings::drawTimezone();
$interval = LotterySettings::drawIntervalMinutes();
$buffer = LotterySettings::drawBufferDrawsAhead();
$maxSeq = intdiv(24 * 60, $interval);
$upcoming = Draw::query()

View File

@@ -79,10 +79,7 @@ final class DrawPublishService
private function applyPublishedToDraw(Draw $draw, DrawResultBatch $batch): Draw
{
$cooldownMinutes = max(0, (int) LotterySettings::get(
'draw.cooldown_minutes',
(int) config('lottery.draw.cooldown_minutes', 15),
));
$cooldownMinutes = LotterySettings::drawCooldownMinutes();
if ($cooldownMinutes > 0) {
$draw->forceFill([
'status' => DrawStatus::Cooldown->value,

View File

@@ -9,6 +9,7 @@ use App\Models\DrawResultBatch;
use Illuminate\Support\Collection;
use App\Lottery\DrawResultBatchStatus;
use App\Services\Jackpot\JackpotSummaryService;
use App\Services\LotterySettings;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
/**
@@ -194,6 +195,6 @@ final class DrawResultViewService
return $code;
}
return strtoupper(substr(trim((string) config('lottery.default_currency', 'NPR')), 0, 16));
return LotterySettings::defaultCurrency();
}
}

View File

@@ -28,10 +28,7 @@ final class DrawRngRunner
'status' => DrawStatus::Drawing->value,
])->save();
$manualReview = (bool) LotterySettings::get(
'draw.require_manual_review',
(bool) config('lottery.draw.require_manual_review', false),
);
$manualReview = LotterySettings::drawRequireManualReview();
$seedHex = DrawRngSeedDerivation::generateSeedHex();
$rngSeedHash = DrawRngSeedDerivation::hashSeedHex($seedHex);
$rawSeedEncrypted = DrawRngSeedDerivation::encryptSeedHex($seedHex);

View File

@@ -4,6 +4,7 @@ namespace App\Services\Draw;
use Carbon\Carbon;
use App\Lottery\DrawStatus;
use App\Services\LotterySettings;
/**
* 由开奖时刻推导下注窗口、封盘时刻与期号初始状态。
@@ -12,12 +13,12 @@ final class DrawTimelineBuilder
{
public function closeBeforeDrawSeconds(): int
{
return (int) config('lottery.draw.close_before_draw_seconds', 30);
return LotterySettings::drawCloseBeforeDrawSeconds();
}
public function bettingWindowSeconds(): int
{
return (int) config('lottery.draw.betting_window_seconds', 270);
return LotterySettings::drawBettingWindowSeconds();
}
/**

View File

@@ -13,6 +13,84 @@ use Illuminate\Support\Facades\Cache;
*/
final class LotterySettings
{
public static function defaultCurrency(): string
{
$fallback = (string) config('lottery.default_currency', 'NPR');
$value = self::get('currency.default_code', $fallback);
return strtoupper(substr(trim((string) $value), 0, 16));
}
public static function drawTimezone(): string
{
return (string) self::get('draw.timezone', (string) config('lottery.draw.timezone', 'UTC'));
}
public static function drawIntervalMinutes(): int
{
$fallback = (int) config('lottery.draw.interval_minutes', 5);
return max(1, min(1440, (int) self::get('draw.interval_minutes', $fallback)));
}
public static function drawBufferDrawsAhead(): int
{
$fallback = (int) config('lottery.draw.buffer_draws_ahead', 8);
return max(1, (int) self::get('draw.buffer_draws_ahead', $fallback));
}
public static function drawBettingWindowSeconds(): int
{
$fallback = (int) config('lottery.draw.betting_window_seconds', 270);
return max(10, (int) self::get('draw.betting_window_seconds', $fallback));
}
public static function drawCloseBeforeDrawSeconds(): int
{
$fallback = (int) config('lottery.draw.close_before_draw_seconds', 30);
return max(5, (int) self::get('draw.close_before_draw_seconds', $fallback));
}
public static function drawRequireManualReview(): bool
{
$fallback = (bool) config('lottery.draw.require_manual_review', true);
return (bool) self::get('draw.require_manual_review', $fallback);
}
public static function drawCooldownMinutes(): int
{
$fallback = (int) config('lottery.draw.cooldown_minutes', 15);
return max(0, (int) self::get('draw.cooldown_minutes', $fallback));
}
public static function currencyDisplayDecimals(): int
{
$fallback = (int) config('lottery.ui.format.currency.decimals', 2);
return max(0, min(12, (int) self::get('currency.display_decimals', $fallback)));
}
public static function currencyDecimalSeparator(): string
{
return (string) self::get(
'currency.decimal_separator',
(string) config('lottery.ui.format.currency.decimal_separator', '.')
);
}
public static function currencyThousandsSeparator(): string
{
return (string) self::get(
'currency.thousands_separator',
(string) config('lottery.ui.format.currency.thousands_separator', ',')
);
}
public static function cacheTtlSeconds(): int
{
return max(5, (int) config('lottery.settings.cache_ttl_seconds', 60));

View File

@@ -178,7 +178,7 @@ final class PlayerTokenResolver
$defaults = [
'username' => null,
'nickname' => null,
'default_currency' => (string) config('lottery.default_currency', 'NPR'),
'default_currency' => LotterySettings::defaultCurrency(),
'status' => self::PLAYER_STATUS_ACTIVE,
'last_login_at' => $now,
];

View File

@@ -5,6 +5,7 @@ namespace App\Services\Wallet;
use App\Models\Player;
use Illuminate\Support\Facades\Http;
use App\Services\Integration\PartnerSiteConfigResolver;
use App\Support\Integration\WalletApiUrlSanitizer;
/**
* 查询主站钱包余额(供玩家端余额接口填充 main_balance
@@ -51,7 +52,19 @@ final class HttpMainSiteWalletBalanceClient
);
}
$base = rtrim((string) $config->walletApiUrl, '/');
$base = WalletApiUrlSanitizer::normalizeAndValidate($config->walletApiUrl);
if ($base === null) {
return new MainSiteWalletBalanceProbeResult(
success: false,
mainBalanceMinor: null,
currencyCode: $currencyCode,
requestUrl: '',
httpStatus: null,
message: 'wallet_api_url 无效(拒绝以防 SSRF',
responseBody: null,
);
}
$path = $config->walletBalancePath;
$url = $base.'/'.ltrim($path, '/');
$timeout = $config->walletTimeoutSeconds;

View File

@@ -6,6 +6,7 @@ use App\Models\Player;
use Illuminate\Support\Facades\Http;
use GuzzleHttp\Exception\ConnectException;
use App\Services\Integration\PartnerSiteConfigResolver;
use App\Support\Integration\WalletApiUrlSanitizer;
/**
* 通过 HTTP 调用主站钱包 API路径见 config lottery.main_site.wallet_*_path
@@ -61,7 +62,7 @@ final class HttpMainSiteWalletGateway implements MainSiteWalletGateway
\App\Services\Integration\PartnerSiteConfig $config,
): MainSiteWalletResult {
if (! $config->hasWalletApi()) {
return MainSiteWalletResult::success(null, ['stub' => true, 'reason' => 'wallet_api_not_configured'], [
$requestSnapshot = [
'site_code' => $player->site_code,
'site_player_id' => $player->site_player_id,
'player_id' => $player->id,
@@ -69,10 +70,44 @@ final class HttpMainSiteWalletGateway implements MainSiteWalletGateway
'amount_minor' => $amountMinor,
'idempotent_key' => $idempotentKey,
'_meta' => ['stub' => true],
]);
];
// 生产环境不允许把“主站未配置钱包 API”当作成功从而避免资金链路失真。
if (app()->environment(['production'])) {
return MainSiteWalletResult::failure(
'wallet_api_not_configured',
['stub' => false, 'reason' => 'wallet_api_not_configured'],
false,
$requestSnapshot,
);
}
// 本地/测试环境允许 stub 成功,方便联调与自动化用例覆盖资金链路流程。
return MainSiteWalletResult::success(
null,
['stub' => true, 'reason' => 'wallet_api_not_configured'],
$requestSnapshot,
);
}
$base = WalletApiUrlSanitizer::normalizeAndValidate($config->walletApiUrl);
if ($base === null) {
return MainSiteWalletResult::failure(
'wallet_api_url_invalid',
['reason' => 'invalid_base_url'],
false,
[
'site_code' => $player->site_code,
'site_player_id' => $player->site_player_id,
'player_id' => $player->id,
'currency_code' => $currencyCode,
'amount_minor' => $amountMinor,
'idempotent_key' => $idempotentKey,
'_meta' => ['stub' => true, 'operation' => 'invalid_wallet_api_url'],
],
);
}
$base = rtrim((string) $config->walletApiUrl, '/');
$url = $base.'/'.ltrim($path, '/');
$timeout = $config->walletTimeoutSeconds;
$apiKey = $config->walletApiKey;

View File

@@ -4,6 +4,8 @@ declare(strict_types=1);
namespace App\Support;
use App\Services\LotterySettings;
/**
* 将「最小货币单位」整数格式化为展示用字符串(不改变业务数字,仅格式化)。
*
@@ -22,9 +24,9 @@ final class CurrencyFormatter
private static function formatMinorInt(int $minorUnits): string
{
$decimals = max(0, min(12, (int) config('lottery.ui.format.currency.decimals', 2)));
$decSep = (string) config('lottery.ui.format.currency.decimal_separator', '.');
$thousandsSep = (string) config('lottery.ui.format.currency.thousands_separator', ',');
$decimals = LotterySettings::currencyDisplayDecimals();
$decSep = LotterySettings::currencyDecimalSeparator();
$thousandsSep = LotterySettings::currencyThousandsSeparator();
$divisor = (int) max(1, 10 ** $decimals);
$negative = $minorUnits < 0;

View File

@@ -7,6 +7,7 @@ namespace App\Support;
use App\Models\Player;
use App\Models\Currency;
use Illuminate\Http\Request;
use App\Services\LotterySettings;
/**
* 币种码解析工具。
@@ -36,7 +37,7 @@ final class CurrencyResolver
} else {
$fallback = $default
?? $player?->default_currency
?? config('lottery.default_currency', 'NPR');
?? LotterySettings::defaultCurrency();
$code = strtoupper(substr(trim((string) $fallback), 0, 16));
}

View File

@@ -0,0 +1,195 @@
<?php
namespace App\Support\Integration;
/**
* SSRF对主站钱包基地址做严格归一化校验。
*
* 规则(保守):
* - 仅允许 https
* - 不允许 user/pass、query、fragment
* - 不允许除 / 以外的 path即仅允许根地址
* - 拒绝 localhost 与私网/保留网段IP 字面量层面)
*
* 说明:对 hostname 不做 DNS 解析(避免引入不确定性),但会拦截 localhost 及明显内网标识。
*/
final class WalletApiUrlSanitizer
{
public static function normalizeAndValidate(?string $raw): ?string
{
if (! is_string($raw)) {
return null;
}
$raw = trim($raw);
if ($raw === '') {
return null;
}
// 允许尾部 /,归一化后移除
$raw = rtrim($raw, " \t\n\r\0\x0B/");
$parts = parse_url($raw);
if (! is_array($parts)) {
return null;
}
$scheme = strtolower((string) ($parts['scheme'] ?? ''));
if ($scheme !== 'https') {
return null;
}
if (isset($parts['user']) || isset($parts['pass'])) {
return null;
}
if (! isset($parts['host']) || ! is_string($parts['host']) || $parts['host'] === '') {
return null;
}
if (isset($parts['query']) || isset($parts['fragment'])) {
return null;
}
$path = (string) ($parts['path'] ?? '');
if ($path !== '' && $path !== '/') {
return null;
}
$host = strtolower(trim((string) $parts['host']));
if ($host === '' || str_contains($host, ' ') || str_contains($host, "\t") || str_contains($host, "\n")) {
return null;
}
// 明确拦截 localhost / 本地常见名
if ($host === 'localhost' || $host === 'local' || $host === 'localdomain') {
return null;
}
// 拦截 IP 字面量私网
$isIp = filter_var($host, FILTER_VALIDATE_IP) !== false;
if ($isIp) {
if (self::ipIsPrivateOrReserved($host)) {
return null;
}
}
// 端口校验(允许不写 port
if (isset($parts['port'])) {
$port = (int) $parts['port'];
if ($port < 1 || $port > 65535) {
return null;
}
}
$normalized = 'https://'.$host;
if (isset($parts['port'])) {
$normalized .= ':'.(string) (int) $parts['port'];
}
return $normalized;
}
private static function ipIsPrivateOrReserved(string $ip): bool
{
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
$v = ip2long($ip);
if ($v === false) {
return true;
}
// PHP 在 macOS 上 ip2long 可能为有符号,强转为 int64 统一处理
$v = (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
['base' => ip2long('10.0.0.0'), 'mask' => 0xFF000000],
// 127.0.0.0/8
['base' => ip2long('127.0.0.0'), 'mask' => 0xFF000000],
// 169.254.0.0/16
['base' => ip2long('169.254.0.0'), 'mask' => 0xFFFF0000],
// 172.16.0.0/12
['base' => ip2long('172.16.0.0'), 'mask' => 0xFFF00000],
// 192.168.0.0/16
['base' => ip2long('192.168.0.0'), 'mask' => 0xFFFF0000],
// 100.64.0.0/10 (CGNAT)
['base' => ip2long('100.64.0.0'), 'mask' => 0xFFC00000],
// 192.0.0.0/24 (IETF Protocol Assignments)
['base' => ip2long('192.0.0.0'), 'mask' => 0xFFFFFF00],
// 198.18.0.0/15 (benchmarking)
['base' => ip2long('198.18.0.0'), 'mask' => 0xFFFE0000],
// 224.0.0.0/4 (multicast)
['base' => ip2long('224.0.0.0'), 'mask' => 0xF0000000],
// 240.0.0.0/4 (reserved for future use)
['base' => ip2long('240.0.0.0'), 'mask' => 0xF0000000],
]);
}
// IPv6仅做关键保守段拦截避免复杂数值比较引入 bug
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) {
$bin = inet_pton($ip);
if ($bin === false) {
return true;
}
// IPv6 ::1 (loopback)
if (substr($bin, 0, 15) === str_repeat("\0", 15) && $bin[15] === "\1") {
return true;
}
// IPv6 unspecified ::
if ($bin === str_repeat("\0", 16)) {
return true;
}
$b0 = ord($bin[0]);
$b1 = ord($bin[1]);
// ff00::/8 multicast
if ($b0 === 0xFF) {
return true;
}
// fc00::/7 unique local => fc or fd
if ($b0 === 0xFC || $b0 === 0xFD) {
return true;
}
// fe80::/10 link-local => fe + (second byte & 0xC0) == 0x80
if ($b0 === 0xFE && (($b1 & 0xC0) === 0x80)) {
return true;
}
// 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
if ($v4 === false) {
return true;
}
return self::ipIsPrivateOrReserved($v4);
}
}
// 非法 IP保守拒绝
return true;
}
private static function ipInRangesV4(int $v, array $ranges): bool
{
foreach ($ranges as $r) {
$base = (int) $r['base'];
$mask = (int) $r['mask'];
if (($v & $mask) === ($base & $mask)) {
return true;
}
}
return false;
}
}

View File

@@ -3,6 +3,7 @@
namespace App\Support;
use Carbon\Carbon;
use App\Services\LotterySettings;
use Illuminate\Database\Eloquent\Builder;
/**
@@ -40,7 +41,7 @@ trait TicketItemListFilters
private function scheduleTimezone(): string
{
return (string) config('lottery.draw.timezone', 'UTC');
return LotterySettings::drawTimezone();
}
private function scheduleDateStartUtc(string $ymd): Carbon

87
config/cors.php Normal file
View File

@@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
$allowedOrigins = array_values(array_filter(array_map(
static fn (string $origin): string => trim($origin),
explode(',', (string) env('CORS_ALLOWED_ORIGINS', ''))
), static fn (string $origin): bool => $origin !== ''));
$allowedOriginsPatterns = array_values(array_filter(array_map(
static fn (string $pattern): string => trim($pattern),
explode(',', (string) env('CORS_ALLOWED_ORIGINS_PATTERNS', ''))
), static fn (string $pattern): bool => $pattern !== ''));
return [
/*
|--------------------------------------------------------------------------
| CORS Paths
|--------------------------------------------------------------------------
|
| Only API and broadcasting/auth endpoints need CORS handling.
|
*/
'paths' => ['api/*', 'broadcasting/auth'],
/*
|--------------------------------------------------------------------------
| Allowed Methods
|--------------------------------------------------------------------------
*/
'allowed_methods' => ['*'],
/*
|--------------------------------------------------------------------------
| Allowed Origins
|--------------------------------------------------------------------------
|
| Use explicit domain whitelist in production. Do not use *.
|
*/
'allowed_origins' => $allowedOrigins,
/*
|--------------------------------------------------------------------------
| Allowed Origin Patterns
|--------------------------------------------------------------------------
|
| Optional regex-style patterns for controlled wildcard subdomains.
|
*/
'allowed_origins_patterns' => $allowedOriginsPatterns,
/*
|--------------------------------------------------------------------------
| Allowed Headers
|--------------------------------------------------------------------------
*/
'allowed_headers' => ['*'],
/*
|--------------------------------------------------------------------------
| Exposed Headers
|--------------------------------------------------------------------------
*/
'exposed_headers' => [],
/*
|--------------------------------------------------------------------------
| Max Age
|--------------------------------------------------------------------------
*/
'max_age' => (int) env('CORS_MAX_AGE', 0),
/*
|--------------------------------------------------------------------------
| Supports Credentials
|--------------------------------------------------------------------------
|
| Enable only if browser cross-site cookie auth is required.
|
*/
'supports_credentials' => filter_var(
env('CORS_SUPPORTS_CREDENTIALS', false),
FILTER_VALIDATE_BOOL
),
];

2
package-lock.json generated
View File

@@ -1,5 +1,5 @@
{
"name": "llotterLaravel",
"name": "lotterLaravel",
"lockfileVersion": 3,
"requires": true,
"packages": {

View File

@@ -10,6 +10,7 @@ use App\Http\Controllers\Api\V1\Jackpot\JackpotSummaryController;
use App\Http\Controllers\Api\V1\Play\PlayEffectiveCatalogController;
use App\Http\Controllers\Api\V1\Player\PingController as PlayerPingController;
use App\Http\Controllers\Api\V1\Setting\SettingIndexController;
use App\Http\Controllers\Api\V1\Integration\IntegrationRuntimeOriginsController;
/**
* 公开路由(无需登录)。
@@ -43,3 +44,7 @@ Route::prefix('player')
// 系统公共配置(如前端规则等)
Route::get('settings', SettingIndexController::class)->name('api.v1.settings.index');
// iframe 运行时白名单(只公开启用接入站点的 origin不公开密钥
Route::get('integration/runtime-origins', IntegrationRuntimeOriginsController::class)
->name('api.v1.integration.runtime-origins');

View File

@@ -323,3 +323,42 @@ test('player list is filtered by admin site binding', function (): void {
$siteCodes = collect($response->json('data.items'))->pluck('site_code')->unique()->values()->all();
expect($siteCodes)->toBe(['site-a']);
});
test('wallet_api_url rejects non-https', function (): void {
$token = integrationAdminToken();
$this->withHeader('Authorization', 'Bearer '.$token)
->postJson('/api/v1/admin/integration-sites', [
'code' => 'bad-https-1',
'name' => 'Bad HTTPS 1',
'wallet_api_url' => 'http://wallet.bad.test',
])
->assertStatus(422)
->assertJsonPath('data.errors.wallet_api_url.0', 'wallet_api_url 必须是 https 的公开域名根地址,并拒绝 localhost/内网 IP 与带路径/查询的地址。');
});
test('wallet_api_url rejects localhost', function (): void {
$token = integrationAdminToken();
$this->withHeader('Authorization', 'Bearer '.$token)
->postJson('/api/v1/admin/integration-sites', [
'code' => 'bad-https-2',
'name' => 'Bad HTTPS 2',
'wallet_api_url' => 'https://localhost:8080',
])
->assertStatus(422)
->assertJsonPath('data.errors.wallet_api_url.0', 'wallet_api_url 必须是 https 的公开域名根地址,并拒绝 localhost/内网 IP 与带路径/查询的地址。');
});
test('wallet_api_url rejects private ip with path', function (): void {
$token = integrationAdminToken();
$this->withHeader('Authorization', 'Bearer '.$token)
->postJson('/api/v1/admin/integration-sites', [
'code' => 'bad-https-3',
'name' => 'Bad HTTPS 3',
'wallet_api_url' => 'https://127.0.0.1/wallet',
])
->assertStatus(422)
->assertJsonPath('data.errors.wallet_api_url.0', 'wallet_api_url 必须是 https 的公开域名根地址,并拒绝 localhost/内网 IP 与带路径/查询的地址。');
});

View File

@@ -0,0 +1,38 @@
<?php
use App\Models\AdminSite;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
test('public runtime origins returns enabled integration site iframe origins only', function (): void {
AdminSite::query()->create([
'code' => 'enabled-a',
'name' => 'Enabled A',
'currency_code' => 'NPR',
'status' => 1,
'iframe_allowed_origins' => [
'https://main.example.test',
'https://main.example.test',
' https://shell.example.test ',
],
]);
AdminSite::query()->create([
'code' => 'disabled-a',
'name' => 'Disabled A',
'currency_code' => 'NPR',
'status' => 0,
'iframe_allowed_origins' => [
'https://disabled.example.test',
],
]);
$this->getJson('/api/v1/integration/runtime-origins')
->assertOk()
->assertJsonPath('code', 0)
->assertJsonPath('data.iframe_allowed_origins', [
'https://main.example.test',
'https://shell.example.test',
]);
});

View File

@@ -59,13 +59,13 @@ test('wallet balance rejects illegal currency query', function () {
test('wallet balance returns main_balance when main site wallet api is configured', function () {
config([
'lottery.main_site.wallet_api_url' => 'http://fake-main.test',
'lottery.main_site.wallet_api_url' => 'https://fake-main.test',
'lottery.main_site.wallet_balance_path' => '/wallet/balance',
]);
Http::preventStrayRequests();
Http::fake([
'http://fake-main.test/wallet/balance*' => Http::response([
'https://fake-main.test/wallet/balance*' => Http::response([
'success' => true,
'data' => [
'main_balance' => 499_900,

View File

@@ -111,7 +111,7 @@ test('transfer in main site explicit failure returns 1009 and marks order failed
Http::fake([
'reject-debit.test/*' => Http::response(['success' => false, 'message' => 'main_insufficient'], 200),
]);
config(['lottery.main_site.wallet_api_url' => 'http://reject-debit.test']);
config(['lottery.main_site.wallet_api_url' => 'https://reject-debit.test']);
config(['lottery.main_site.wallet_debit_path' => 'debit']);
$player = Player::query()->create([
@@ -143,7 +143,7 @@ test('transfer in main site timeout returns 1002 and pending_reconcile', functio
Http::fake([
'timeout-debit.test/*' => Http::response([], 504),
]);
config(['lottery.main_site.wallet_api_url' => 'http://timeout-debit.test']);
config(['lottery.main_site.wallet_api_url' => 'https://timeout-debit.test']);
config(['lottery.main_site.wallet_debit_path' => 'debit']);
$player = Player::query()->create([
@@ -210,7 +210,7 @@ test('transfer out main site failure refunds lottery and returns 1009', function
Http::fake([
'reject-credit.test/*' => Http::response(['success' => false, 'message' => 'credit_denied'], 200),
]);
config(['lottery.main_site.wallet_api_url' => 'http://reject-credit.test']);
config(['lottery.main_site.wallet_api_url' => 'https://reject-credit.test']);
config(['lottery.main_site.wallet_credit_path' => 'credit']);
$player = Player::query()->create([
@@ -251,7 +251,7 @@ test('transfer out main site timeout returns 1002 and pending_reconcile on order
Http::fake([
'timeout-credit.test/*' => Http::response([], 504),
]);
config(['lottery.main_site.wallet_api_url' => 'http://timeout-credit.test']);
config(['lottery.main_site.wallet_api_url' => 'https://timeout-credit.test']);
config(['lottery.main_site.wallet_credit_path' => 'credit']);
$player = Player::query()->create([

View File

@@ -256,7 +256,7 @@ test('transfer in http 504 marks order pending reconcile', function () {
Http::fake([
'fake-main.test/*' => Http::response([], 504),
]);
config(['lottery.main_site.wallet_api_url' => 'http://fake-main.test']);
config(['lottery.main_site.wallet_api_url' => 'https://fake-main.test']);
config(['lottery.main_site.wallet_debit_path' => 'debit']);
$player = Player::query()->create([