Compare commits
3 Commits
a60ce8caad
...
8ccf39dff5
| Author | SHA1 | Date | |
|---|---|---|---|
| 8ccf39dff5 | |||
| 5e73dc6ec1 | |||
| fe0594beaa |
31
.env.example
31
.env.example
@@ -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 会补足);测试可 6–12,生产可 48+
|
||||
LOTTERY_DRAW_BUFFER_AHEAD=8
|
||||
# 期号时刻统一为 UTC(GMT),见 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 为准;这里仅用于本地默认站/历史回退。
|
||||
# 主站站点根 URL(SSO、跳转等)
|
||||
MAIN_SITE_BASE_URL=
|
||||
# 主站 JWT 验签密钥(与主站约定,勿泄露)
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -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')) {
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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'],
|
||||
|
||||
20
app/Rules/WalletApiUrlRule.php
Normal file
20
app/Rules/WalletApiUrlRule.php
Normal 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 与带路径/查询的地址。';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
195
app/Support/Integration/WalletApiUrlSanitizer.php
Normal file
195
app/Support/Integration/WalletApiUrlSanitizer.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
87
config/cors.php
Normal 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
2
package-lock.json
generated
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "llotterLaravel",
|
||||
"name": "lotterLaravel",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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 与带路径/查询的地址。');
|
||||
});
|
||||
|
||||
38
tests/Feature/PublicIntegrationRuntimeOriginsTest.php
Normal file
38
tests/Feature/PublicIntegrationRuntimeOriginsTest.php
Normal 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',
|
||||
]);
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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([
|
||||
|
||||
Reference in New Issue
Block a user