Compare commits

..

6 Commits

Author SHA1 Message Date
wchino
15bd997c4e feat(core): harden sessions settlement and credit activity
Some checks failed
lotterLaravel CI / test (push) Has been cancelled
lotterLaravel E2E / e2e-api (push) Has been cancelled
2026-07-22 20:53:43 +08:00
wchino
35f6e46958 fix(core): harden settlement and wallet integration 2026-07-22 01:40:37 +08:00
wchino
150c3e7ebd test(config): align current odds and settlement contracts 2026-07-21 23:41:18 +08:00
wchino
457d2cc9e5 fix(auth): enforce password and agent role boundaries 2026-07-21 23:41:18 +08:00
wchino
fb15c64d9b chore(deps): secure PHP 8.3 dependency baseline 2026-07-21 23:41:18 +08:00
wchino
ba54444979 feat(admin): 增加玩家密码管理与角色边界 2026-07-21 21:11:07 +08:00
125 changed files with 7345 additions and 1048 deletions

View File

@@ -33,13 +33,16 @@ REVERB_SERVER_HOST=0.0.0.0
REVERB_HOST=localhost REVERB_HOST=localhost
REVERB_PORT=8080 REVERB_PORT=8080
REVERB_SCHEME=http REVERB_SCHEME=http
# WebSocket Origin 白名单逗号分隔支持域名、URL、*.example.com禁止 *
REVERB_ALLOWED_ORIGINS=localhost,127.0.0.1
QUEUE_CONNECTION=redis QUEUE_CONNECTION=redis
CACHE_STORE=redis CACHE_STORE=redis
LOTTERY_RISK_POOL_USE_REDIS_LUA=true LOTTERY_RISK_POOL_USE_REDIS_LUA=true
# 生产建议独立配置;留空则回落 MAIN_SITE_SSO_JWT_SECRET(勿在生产与 SSO 混用 # 原生玩家登录必须显式配置;须与 MAIN_SITE_SSO_JWT_SECRET 及所有站点保存的 SSO 密钥不同(包括停用站点
# LOTTERY_NATIVE_JWT_SECRET= # 可用 openssl rand -base64 48 生成;留空时原生登录与已签发原生 Token 验证均返回 503
LOTTERY_NATIVE_JWT_SECRET=
# 预发可设为 false禁止代理账期关账 # 预发可设为 false禁止代理账期关账
AGENT_SETTLEMENT_ALLOW_PRODUCTION_CLOSE=true AGENT_SETTLEMENT_ALLOW_PRODUCTION_CLOSE=true
@@ -64,6 +67,9 @@ LOTTERY_DRAW_INTERVAL_MINUTES=5
LOTTERY_DRAW_BETTING_WINDOW_SECONDS=270 LOTTERY_DRAW_BETTING_WINDOW_SECONDS=270
LOTTERY_DRAW_CLOSE_BEFORE_SECONDS=30 LOTTERY_DRAW_CLOSE_BEFORE_SECONDS=30
LOTTERY_DRAW_BUFFER_AHEAD=8 LOTTERY_DRAW_BUFFER_AHEAD=8
# 自动派彩失败后按 60s、120s、240s... 指数退避,最长每小时重试一次
LOTTERY_AUTO_PAYOUT_RETRY_BASE_SECONDS=60
LOTTERY_AUTO_PAYOUT_RETRY_MAX_SECONDS=3600
LOTTERY_PLAYER_AUTH_DEV_BYPASS=false LOTTERY_PLAYER_AUTH_DEV_BYPASS=false
ADMIN_API_TOKEN_TTL_DAYS=7 ADMIN_API_TOKEN_TTL_DAYS=7

View File

@@ -139,6 +139,8 @@ php artisan queue:work --tries=3 --timeout=120 --sleep=1
- `REVERB_HOST`:浏览器连接 Reverb 时看到的主机名或 IP - `REVERB_HOST`:浏览器连接 Reverb 时看到的主机名或 IP
- `SANCTUM_STATEFUL_DOMAINS`:允许带 Cookie 的前端来源列表 - `SANCTUM_STATEFUL_DOMAINS`:允许带 Cookie 的前端来源列表
原生玩家账号登录必须显式配置 `LOTTERY_NATIVE_JWT_SECRET`。该密钥必须是独立随机值,不得与 legacy `MAIN_SITE_SSO_JWT_SECRET` 或任一 `admin_sites` 站点保存的 SSO 密钥相同(包括停用站点);缺失或冲突时,原生 Token 签发与验证都会返回 HTTP 503。生成新密钥可使用 `openssl rand -base64 48`。修改后执行 `php artisan config:cache`,并重启 HTTP、queue、scheduler 与 Reverb 等常驻进程,确保签发和验证使用同一个新值。
## 生产性能基线 ## 生产性能基线
为避免调度锁、大厅快照缓存与业务表争抢同一数据库,生产环境请至少满足: 为避免调度锁、大厅快照缓存与业务表争抢同一数据库,生产环境请至少满足:

View File

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

View File

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

View File

@@ -0,0 +1,53 @@
<?php
namespace App\Events;
use Illuminate\Queue\SerializesModels;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
/** 通知旧客户端:同一原生玩家账号已有较新的登录会话。 */
final class PlayerSessionReplacedBroadcast implements ShouldBroadcast
{
use Dispatchable, InteractsWithSockets, SerializesModels;
public string $queue = 'broadcasts';
public int $tries = 3;
public int $timeout = 10;
public function __construct(
public readonly int $playerId,
public readonly int $sessionVersion,
public readonly int $emittedAtMs,
) {}
public function retryUntil(): \DateTimeInterface
{
return now()->addSeconds(30);
}
/** @return array<int, PrivateChannel> */
public function broadcastOn(): array
{
return [new PrivateChannel('player.'.$this->playerId)];
}
public function broadcastAs(): string
{
return 'session.replaced';
}
/** @return array{player_id: int, session_version: int, emitted_at_ms: int} */
public function broadcastWith(): array
{
return [
'player_id' => $this->playerId,
'session_version' => $this->sessionVersion,
'emitted_at_ms' => $this->emittedAtMs,
];
}
}

View File

@@ -8,6 +8,7 @@ use Illuminate\Support\Str;
use App\Support\ApiResponse; use App\Support\ApiResponse;
use App\Support\AdminAuthProfile; use App\Support\AdminAuthProfile;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\DB;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
use App\Services\AdminCaptchaService; use App\Services\AdminCaptchaService;
@@ -37,38 +38,59 @@ final class LoginController extends Controller
$normalizedAccount = Str::lower(trim($data['account'])); $normalizedAccount = Str::lower(trim($data['account']));
/** @var AdminUser|null $admin */
$admin = AdminUser::query()->where('username', $normalizedAccount)->first();
$passwordOk = $admin !== null && Hash::check($data['password'], $admin->password);
if (! $passwordOk) {
/** 统一措辞,弱化枚举用户 */
return ApiResponse::error(
trans('admin.invalid_credentials', [], $locale),
ErrorCode::AdminCredentialsInvalid->value,
null,
401,
);
}
if ((int) $admin->status !== 0) {
return ApiResponse::error(
trans('admin.account_disabled', [], $locale),
ErrorCode::AdminAccountDisabled->value,
null,
403,
);
}
$ttlDays = (int) config('lottery.admin_api.token_ttl_days', 7); $ttlDays = (int) config('lottery.admin_api.token_ttl_days', 7);
$plainToken = $admin->createToken(
'admin-api',
['*'],
now()->addDays(max(1, $ttlDays)),
)->plainTextToken;
$admin->forceFill(['last_login_at' => now()])->save(); /**
* 串行化同账号登录,确保并发登录也只有版本号最大的会话有效。
* 不立即删除旧 Token旧端下一次请求时才能收到明确的 8115 业务码。
*/
$loginResult = DB::transaction(function () use ($normalizedAccount, $data, $ttlDays, $locale): array|JsonResponse {
/** @var AdminUser|null $lockedAdmin */
$lockedAdmin = AdminUser::query()
->where('username', $normalizedAccount)
->lockForUpdate()
->first();
if ($lockedAdmin === null || ! Hash::check($data['password'], $lockedAdmin->password)) {
/** 统一措辞,弱化枚举用户 */
return ApiResponse::error(
trans('admin.invalid_credentials', [], $locale),
ErrorCode::AdminCredentialsInvalid->value,
null,
401,
);
}
if ((int) $lockedAdmin->status !== 0) {
return ApiResponse::error(
trans('admin.account_disabled', [], $locale),
ErrorCode::AdminAccountDisabled->value,
null,
403,
);
}
$sessionVersion = (int) $lockedAdmin->admin_session_version + 1;
$lockedAdmin->forceFill([
'admin_session_version' => $sessionVersion,
'last_login_at' => now(),
])->save();
$token = $lockedAdmin->createToken(
'admin-api',
['admin-session:'.$sessionVersion],
now()->addDays(max(1, $ttlDays)),
)->plainTextToken;
return [$token, $lockedAdmin];
});
if ($loginResult instanceof JsonResponse) {
return $loginResult;
}
[$plainToken, $admin] = $loginResult;
return ApiResponse::success([ return ApiResponse::success([
'token' => $plainToken, 'token' => $plainToken,
'token_type' => 'Bearer', 'token_type' => 'Bearer',

View File

@@ -0,0 +1,20 @@
<?php
namespace App\Http\Controllers\Api\V1\Admin\Auth;
use App\Support\ApiResponse;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
use App\Http\Controllers\Controller;
final class LogoutController extends Controller
{
public function __invoke(Request $request): JsonResponse
{
$request->lotteryAdmin()->tokens()
->where('name', 'admin-api')
->delete();
return ApiResponse::success(['logged_out' => true]);
}
}

View File

@@ -40,7 +40,7 @@ final class OddsItemsReplaceController extends Controller
'items.*.provider_code' => ['sometimes', 'string', 'max:32'], 'items.*.provider_code' => ['sometimes', 'string', 'max:32'],
'items.*.play_code' => ['required', 'string', 'max:32', Rule::exists('play_types', 'play_code')], 'items.*.play_code' => ['required', 'string', 'max:32', Rule::exists('play_types', 'play_code')],
'items.*.prize_scope' => ['required', 'string', 'max:32'], 'items.*.prize_scope' => ['required', 'string', 'max:32'],
'items.*.dimension' => ['sometimes', 'nullable', 'integer', 'in:2,3,4'], 'items.*.dimension' => ['sometimes', 'nullable', 'integer', 'in:2,3,4,5,6'],
'items.*.odds_value' => ['required', 'integer', 'min:0'], 'items.*.odds_value' => ['required', 'integer', 'min:0'],
'items.*.rebate_rate' => ['sometimes', 'numeric', 'between:0,1'], 'items.*.rebate_rate' => ['sometimes', 'numeric', 'between:0,1'],
'items.*.commission_rate' => ['sometimes', 'numeric', 'between:0,1'], 'items.*.commission_rate' => ['sometimes', 'numeric', 'between:0,1'],

View File

@@ -3,17 +3,18 @@
namespace App\Http\Controllers\Api\V1\Admin\Draw; namespace App\Http\Controllers\Api\V1\Admin\Draw;
use App\Models\Draw; use App\Models\Draw;
use App\Models\AdminUser;
use App\Lottery\ErrorCode;
use App\Models\TicketItem; use App\Models\TicketItem;
use App\Models\TicketOrder; use App\Models\TicketOrder;
use App\Models\AdminUser; use App\Support\ApiMessage;
use App\Support\ApiResponse; use App\Support\ApiResponse;
use Illuminate\Http\Request;
use App\Models\SettlementBatch; use App\Models\SettlementBatch;
use App\Support\AdminScopePolicy;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Lottery\ErrorCode;
use App\Support\AdminDrawResponsePolicy; use App\Support\AdminDrawResponsePolicy;
use App\Support\AdminScopePolicy;
use App\Support\ApiMessage;
/** /**
* GET /api/v1/admin/draws/{draw}/finance-summary 单期投注/派彩汇总(客服/财务视角PRD §15.4)。 * GET /api/v1/admin/draws/{draw}/finance-summary 单期投注/派彩汇总(客服/财务视角PRD §15.4)。
@@ -22,7 +23,7 @@ use App\Support\ApiMessage;
*/ */
final class AdminDrawFinanceSummaryController extends Controller final class AdminDrawFinanceSummaryController extends Controller
{ {
public function __invoke(\Illuminate\Http\Request $request, Draw $draw): JsonResponse public function __invoke(Request $request, Draw $draw): JsonResponse
{ {
$admin = $request->lotteryAdmin(); $admin = $request->lotteryAdmin();
abort_if(! $admin instanceof AdminUser, 401); abort_if(! $admin instanceof AdminUser, 401);
@@ -58,14 +59,19 @@ final class AdminDrawFinanceSummaryController extends Controller
$approxHouseGrossMinor = $totalBetMinor - $totalPayoutMinor; $approxHouseGrossMinor = $totalBetMinor - $totalPayoutMinor;
$batches = SettlementBatch::query() $batches = SettlementBatch::query()
->with('resultBatch:id,provider_code,provider_name,result_version')
->where('draw_id', $drawId) ->where('draw_id', $drawId)
->orderByDesc('id') ->orderByDesc('id')
->limit(30) ->limit(30)
->get(['id', 'status', 'total_ticket_count', 'total_win_count', 'total_payout_amount', 'total_jackpot_payout_amount', 'finished_at']); ->get(['id', 'result_batch_id', 'settle_version', 'status', 'total_ticket_count', 'total_win_count', 'total_payout_amount', 'total_jackpot_payout_amount', 'finished_at']);
$batchRows = $batches->map(static function (SettlementBatch $b): array { $batchRows = $batches->map(static function (SettlementBatch $b): array {
return [ return [
'id' => (int) $b->id, 'id' => (int) $b->id,
'provider_code' => $b->resultBatch?->provider_code,
'provider_name' => $b->resultBatch?->provider_name,
'result_version' => $b->resultBatch !== null ? (int) $b->resultBatch->result_version : null,
'settle_version' => (int) $b->settle_version,
'status' => $b->status, 'status' => $b->status,
'total_ticket_count' => (int) $b->total_ticket_count, 'total_ticket_count' => (int) $b->total_ticket_count,
'total_win_count' => (int) $b->total_win_count, 'total_win_count' => (int) $b->total_win_count,

View File

@@ -5,18 +5,21 @@ namespace App\Http\Controllers\Api\V1\Admin\Draw;
use App\Models\Draw; use App\Models\Draw;
use App\Models\AdminUser; use App\Models\AdminUser;
use App\Lottery\ErrorCode; use App\Lottery\ErrorCode;
use App\Support\ApiMessage;
use App\Support\ApiResponse; use App\Support\ApiResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Services\Settlement\SettlementOrchestrator; use App\Services\Settlement\SettlementOrchestrator;
use App\Services\Settlement\DrawSettlementStartService;
/** /**
* POST /api/v1/admin/draws/{draw}/settlement/run `settling` 期号执行结算(可关自动结算时手工触发) * POST /api/v1/admin/draws/{draw}/settlement/run 冷静期可提前结算,结算中可幂等重试
*/ */
final class DrawSettlementRunController extends Controller final class DrawSettlementRunController extends Controller
{ {
public function __construct( public function __construct(
private readonly DrawSettlementStartService $startService,
private readonly SettlementOrchestrator $orchestrator, private readonly SettlementOrchestrator $orchestrator,
) {} ) {}
@@ -32,7 +35,13 @@ final class DrawSettlementRunController extends Controller
); );
} }
$ran = $this->orchestrator->trySettleDraw($draw); try {
$started = $this->startService->start($draw);
} catch (\RuntimeException $e) {
return ApiMessage::runtimeErrorResponse($request, $e);
}
$ran = $this->orchestrator->trySettleDraw($started['draw']);
$draw->refresh(); $draw->refresh();
@@ -44,6 +53,8 @@ final class DrawSettlementRunController extends Controller
'draw_no' => $draw->draw_no, 'draw_no' => $draw->draw_no,
'status' => $draw->status, 'status' => $draw->status,
'settle_version' => (int) $draw->settle_version, 'settle_version' => (int) $draw->settle_version,
'cooldown_skipped' => $started['cooldown_skipped'],
'cooling_end_time' => $draw->cooling_end_time?->toIso8601String(),
], ],
409, 409,
); );
@@ -54,6 +65,8 @@ final class DrawSettlementRunController extends Controller
'draw_no' => $draw->draw_no, 'draw_no' => $draw->draw_no,
'status' => $draw->status, 'status' => $draw->status,
'settle_version' => (int) $draw->settle_version, 'settle_version' => (int) $draw->settle_version,
'cooldown_skipped' => $started['cooldown_skipped'],
'cooling_end_time' => $draw->cooling_end_time?->toIso8601String(),
]); ]);
} }
} }

View File

@@ -0,0 +1,35 @@
<?php
namespace App\Http\Controllers\Api\V1\Admin\Player;
use App\Models\Player;
use App\Support\ApiResponse;
use App\Support\AdminSiteScope;
use Illuminate\Http\JsonResponse;
use App\Http\Controllers\Controller;
use App\Services\Player\PlayerPasswordService;
use App\Http\Requests\Admin\AdminPlayerPasswordResetRequest;
/** PUT /api/v1/admin/players/{player}/password */
final class AdminPlayerPasswordResetController extends Controller
{
public function __invoke(
AdminPlayerPasswordResetRequest $request,
Player $player,
PlayerPasswordService $passwords,
): JsonResponse {
$admin = $request->lotteryAdmin();
abort_if($admin === null, 401);
if ($denied = AdminSiteScope::denyUnlessPlayerAccessible($admin, $player)) {
return $denied;
}
$updated = $passwords->reset($player, (string) $request->validated('password'));
return ApiResponse::success([
'password_reset' => true,
'player_id' => (int) $updated->id,
], request: $request);
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,11 +4,12 @@ namespace App\Http\Controllers\Api\V1\Admin\User;
use App\Models\AdminRole; use App\Models\AdminRole;
use App\Support\ApiResponse; use App\Support\ApiResponse;
use App\Support\AdminAuthorizationRegistry;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\DB;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Support\AdminRoleUserCounts;
use App\Support\PlatformSystemRoles;
use App\Support\AdminRoleApiPresenter; use App\Support\AdminRoleApiPresenter;
use App\Support\AdminAuthorizationRegistry;
/** GET /api/v1/admin/admin-user-permission-catalog */ /** GET /api/v1/admin/admin-user-permission-catalog */
final class AdminPermissionCatalogController extends Controller final class AdminPermissionCatalogController extends Controller
@@ -61,12 +62,26 @@ final class AdminPermissionCatalogController extends Controller
->where('scope_type', AdminRole::SCOPE_SYSTEM) ->where('scope_type', AdminRole::SCOPE_SYSTEM)
->orderBy('slug') ->orderBy('slug')
->get(['id', 'slug', 'name']); ->get(['id', 'slug', 'name']);
$userCounts = AdminRoleUserCounts::forRoleIds($roles->pluck('id'));
$presentedRoles = $roles->map(
static fn (AdminRole $role): array => AdminRoleApiPresenter::item(
$role,
$userCounts[(int) $role->id] ?? null,
)
)->values();
return ApiResponse::success([ return ApiResponse::success([
'permissions' => $permissions, 'permissions' => $permissions,
'permission_menu_groups' => $permissionMenuGroups, 'permission_menu_groups' => $permissionMenuGroups,
'navigation' => AdminAuthorizationRegistry::navigationItems(), 'navigation' => AdminAuthorizationRegistry::navigationItems(),
'roles' => $roles->map(static fn (AdminRole $role): array => AdminRoleApiPresenter::item($role))->values()->all(), 'roles' => $presentedRoles->all(),
'assignable_roles' => $presentedRoles
->reject(static fn (array $role): bool => in_array($role['slug'], [
PlatformSystemRoles::SLUG_AGENT,
PlatformSystemRoles::SLUG_SUPER_ADMIN,
], true))
->values()
->all(),
]); ]);
} }
} }

View File

@@ -6,7 +6,9 @@ use App\Models\AdminRole;
use App\Support\ApiResponse; use App\Support\ApiResponse;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Support\AdminRoleUserCounts;
use App\Support\AdminRoleApiPresenter; use App\Support\AdminRoleApiPresenter;
final class AdminRoleIndexController extends Controller final class AdminRoleIndexController extends Controller
{ {
public function __invoke(): JsonResponse public function __invoke(): JsonResponse
@@ -16,9 +18,15 @@ final class AdminRoleIndexController extends Controller
->orderBy('sort_order') ->orderBy('sort_order')
->orderBy('id') ->orderBy('id')
->get(); ->get();
$userCounts = AdminRoleUserCounts::forRoleIds($roles->pluck('id'));
return ApiResponse::success([ return ApiResponse::success([
'items' => $roles->map(static fn (AdminRole $role): array => AdminRoleApiPresenter::item($role))->values()->all(), 'items' => $roles->map(
static fn (AdminRole $role): array => AdminRoleApiPresenter::item(
$role,
$userCounts[(int) $role->id] ?? null,
)
)->values()->all(),
]); ]);
} }
} }

View File

@@ -3,10 +3,10 @@
namespace App\Http\Controllers\Api\V1\Admin\User; namespace App\Http\Controllers\Api\V1\Admin\User;
use App\Models\AdminUser; use App\Models\AdminUser;
use Illuminate\Support\Facades\DB;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use App\Support\AdminApiList; use App\Support\AdminApiList;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\DB;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Support\AdminUserApiPresenter; use App\Support\AdminUserApiPresenter;
@@ -17,6 +17,7 @@ final class AdminUserIndexController extends Controller
{ {
$p = AdminApiList::readPaging($request); $p = AdminApiList::readPaging($request);
$keyword = trim((string) $request->query('keyword', '')); $keyword = trim((string) $request->query('keyword', ''));
$roleSlug = trim((string) $request->query('role_slug', ''));
$q = AdminUser::query() $q = AdminUser::query()
->with(['roles']) ->with(['roles'])
@@ -35,6 +36,12 @@ final class AdminUserIndexController extends Controller
}); });
} }
if ($roleSlug !== '') {
$q->whereHas('roles', static function ($roles) use ($roleSlug): void {
$roles->where('admin_roles.slug', $roleSlug);
});
}
$paginator = $q->paginate($p['perPage'], ['*'], 'page', $p['page']); $paginator = $q->paginate($p['perPage'], ['*'], 'page', $p['page']);
return AdminApiList::json($paginator, fn (AdminUser $user): array => AdminUserApiPresenter::listItem($user)); return AdminApiList::json($paginator, fn (AdminUser $user): array => AdminUserApiPresenter::listItem($user));

View File

@@ -0,0 +1,46 @@
<?php
namespace App\Http\Controllers\Api\V1\Player;
use App\Support\ApiResponse;
use App\Services\AuditLogger;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\DB;
use App\Http\Controllers\Controller;
use App\Services\Player\PlayerPasswordService;
use App\Http\Requests\Player\PlayerPasswordUpdateRequest;
/** PUT /api/v1/player/password — 彩票端账号自助修改密码 */
final class PlayerPasswordUpdateController extends Controller
{
public function __invoke(
PlayerPasswordUpdateRequest $request,
PlayerPasswordService $passwords,
): JsonResponse {
$player = $request->lotteryPlayer();
abort_if($player === null, 500, 'lottery_player missing');
$updated = DB::transaction(function () use ($passwords, $player, $request) {
$updated = $passwords->change(
$player,
(string) $request->validated('current_password'),
(string) $request->validated('password'),
);
AuditLogger::recordForPlayer(
$updated,
$request,
'player_account',
'change_password',
'player',
(string) $updated->id,
null,
['native_token_version' => (int) $updated->native_token_version],
);
return $updated;
});
return ApiResponse::success(['password_changed' => true], request: $request);
}
}

View File

@@ -5,12 +5,12 @@ namespace App\Http\Controllers\Api\V1\Ticket;
use App\Models\Player; use App\Models\Player;
use App\Models\TicketItem; use App\Models\TicketItem;
use App\Support\ApiResponse; use App\Support\ApiResponse;
use App\Support\TicketItemListFilters;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use App\Support\PaginationTrait; use App\Support\PaginationTrait;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use App\Support\CurrencyFormatter; use App\Support\CurrencyFormatter;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Support\TicketItemListFilters;
/** /**
* `GET /api/v1/ticket/items` 我的注单(注项列表,支持 `draw_no` 筛选)。 * `GET /api/v1/ticket/items` 我的注单(注项列表,支持 `draw_no` 筛选)。
@@ -37,6 +37,7 @@ final class TicketItemsIndexController extends Controller
$statusInput, $statusInput,
))) : []; ))) : [];
$number = trim((string) $request->query('number', '')); $number = trim((string) $request->query('number', ''));
$ticketNo = trim((string) $request->query('ticket_no', ''));
$orderNo = trim((string) $request->query('order_no', '')); $orderNo = trim((string) $request->query('order_no', ''));
$startDate = $this->normalizeDate((string) $request->query('start_date', '')); $startDate = $this->normalizeDate((string) $request->query('start_date', ''));
$endDate = $this->normalizeDate((string) $request->query('end_date', '')); $endDate = $this->normalizeDate((string) $request->query('end_date', ''));
@@ -62,6 +63,10 @@ final class TicketItemsIndexController extends Controller
$query->whereHas('order', fn ($q) => $q->where('order_no', $orderNo)); $query->whereHas('order', fn ($q) => $q->where('order_no', $orderNo));
} }
if ($ticketNo !== '') {
$query->where('ticket_items.ticket_no', $ticketNo);
}
$this->applyTicketItemNumberSearch($query, $number); $this->applyTicketItemNumberSearch($query, $number);
$this->applyOrderPlacedDateRange($query, $startDate, $endDate); $this->applyOrderPlacedDateRange($query, $startDate, $endDate);

View File

@@ -7,6 +7,7 @@ use App\Models\AdminUser;
use App\Lottery\ErrorCode; use App\Lottery\ErrorCode;
use App\Support\ApiResponse; use App\Support\ApiResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Laravel\Sanctum\PersonalAccessToken;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
/** /**
@@ -37,6 +38,25 @@ final class EnsureAdminApi
); );
} }
$accessToken = $user->currentAccessToken();
if (
(int) $user->admin_session_version > 0
&& $accessToken instanceof PersonalAccessToken
&& $accessToken->name === 'admin-api'
) {
$expectedAbility = 'admin-session:'.(int) $user->admin_session_version;
$abilities = is_array($accessToken->abilities) ? $accessToken->abilities : [];
if (! in_array($expectedAbility, $abilities, true)) {
return ApiResponse::error(
trans('admin.session_replaced', [], $request->lotteryLocale()),
ErrorCode::AdminSessionReplaced->value,
null,
401,
);
}
}
$request->attributes->set('lottery_admin', $user); $request->attributes->set('lottery_admin', $user);
return $next($request); return $next($request);

View File

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

View File

@@ -0,0 +1,23 @@
<?php
namespace App\Http\Requests\Admin;
use App\Http\Requests\ApiFormRequest;
use App\Http\Requests\Admin\Concerns\AdminAccountFieldRules;
final class AdminPlayerPasswordResetRequest extends ApiFormRequest
{
use AdminAccountFieldRules;
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'password' => [...$this->nativePlayerPasswordRules(required: true), 'confirmed'],
];
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Http\Requests\Player;
use App\Http\Requests\ApiFormRequest;
use App\Http\Requests\Admin\Concerns\AdminAccountFieldRules;
final class PlayerPasswordUpdateRequest extends ApiFormRequest
{
use AdminAccountFieldRules;
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'current_password' => ['required', 'string', 'max:128'],
'password' => [...$this->nativePlayerPasswordRules(required: true), 'confirmed'],
];
}
}

View File

@@ -109,7 +109,7 @@ enum ErrorCode: int
/** 库中无对应玩家(未建档) */ /** 库中无对应玩家(未建档) */
case PlayerNotRegistered = 8003; case PlayerNotRegistered = 8003;
/** 未配置 `MAIN_SITE_SSO_JWT_SECRET`(通常 HTTP 503 */ /** SSO / 原生 JWT 密钥缺失或存在不安全的共用配置(通常 HTTP 503 */
case PlayerSsoSecretNotConfigured = 8004; case PlayerSsoSecretNotConfigured = 8004;
/** 账号已冻结或禁止登录status ≠ active */ /** 账号已冻结或禁止登录status ≠ active */
@@ -127,6 +127,9 @@ enum ErrorCode: int
/** 原生登录:验证码错误或过期 */ /** 原生登录:验证码错误或过期 */
case PlayerCaptchaInvalid = 8009; case PlayerCaptchaInvalid = 8009;
/** 原生登录:当前会话已被同账号的较新登录替换 */
case PlayerSessionReplaced = 8010;
/* ========== 81008199 管理端 API ========== */ /* ========== 81008199 管理端 API ========== */
/** 未登录或 Token 无效 */ /** 未登录或 Token 无效 */
@@ -144,6 +147,9 @@ enum ErrorCode: int
/** 已登录但无 RBAC 权限 */ /** 已登录但无 RBAC 权限 */
case AdminForbidden = 8114; case AdminForbidden = 8114;
/** 当前管理端会话已被同账号的较新登录替换 */
case AdminSessionReplaced = 8115;
/* ========== 90009999 系统 / 框架 ========== */ /* ========== 90009999 系统 / 框架 ========== */
/** 表单或 Query 校验失败ValidationException → 422 */ /** 表单或 Query 校验失败ValidationException → 422 */

View File

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

View File

@@ -4,8 +4,8 @@ namespace App\Models;
use App\Support\PlayerAuthSource; use App\Support\PlayerAuthSource;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/** /**
* 主站玩家在本地映射账号(表 players SSO JWT site_code + site_player_id 对应。 * 主站玩家在本地映射账号(表 players SSO JWT site_code + site_player_id 对应。
@@ -27,6 +27,8 @@ final class Player extends Model
'last_login_at', 'last_login_at',
'login_failed_count', 'login_failed_count',
'login_locked_until', 'login_locked_until',
'native_token_version',
'native_session_version',
]; ];
protected $hidden = [ protected $hidden = [
@@ -40,6 +42,8 @@ final class Player extends Model
'last_login_at' => 'datetime', 'last_login_at' => 'datetime',
'login_failed_count' => 'integer', 'login_failed_count' => 'integer',
'login_locked_until' => 'datetime', 'login_locked_until' => 'datetime',
'native_token_version' => 'integer',
'native_session_version' => 'integer',
'risk_tags' => 'array', 'risk_tags' => 'array',
]; ];
} }

View File

@@ -5,12 +5,13 @@ namespace App\Providers;
use App\Models\Player; use App\Models\Player;
use App\Models\AdminUser; use App\Models\AdminUser;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use App\Contracts\WalletApiDnsResolver;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
use Illuminate\Cache\RateLimiting\Limit; use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\Facades\RateLimiter;
use App\Services\Wallet\MainSiteWalletGateway; use App\Services\Wallet\MainSiteWalletGateway;
use App\Services\Wallet\HttpMainSiteWalletGateway; use App\Services\Wallet\HttpMainSiteWalletGateway;
use App\Services\Wallet\StubMainSiteWalletGateway; use App\Support\Integration\SystemWalletApiDnsResolver;
final class AppServiceProvider extends ServiceProvider final class AppServiceProvider extends ServiceProvider
{ {
@@ -20,6 +21,7 @@ final class AppServiceProvider extends ServiceProvider
public function register(): void public function register(): void
{ {
$this->app->singleton(MainSiteWalletGateway::class, HttpMainSiteWalletGateway::class); $this->app->singleton(MainSiteWalletGateway::class, HttpMainSiteWalletGateway::class);
$this->app->singleton(WalletApiDnsResolver::class, SystemWalletApiDnsResolver::class);
} }
/** /**
@@ -81,5 +83,15 @@ final class AppServiceProvider extends ServiceProvider
return Limit::perMinute(15)->by($request->ip()); return Limit::perMinute(15)->by($request->ip());
}); });
RateLimiter::for('player-password-change', function (Request $request) {
if ((bool) env('LOTTERY_E2E', false)) {
return Limit::none();
}
$playerId = $request->lotteryPlayer()?->getKey();
return Limit::perMinute(5)->by(($playerId ?? 'guest').'|'.$request->ip());
});
} }
} }

View File

@@ -4,10 +4,12 @@ namespace App\Services\Admin;
use App\Models\AdminUser; use App\Models\AdminUser;
use App\Models\ReportJob; use App\Models\ReportJob;
use App\Services\AuditLogger;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Illuminate\Http\Request;
use App\Services\AuditLogger;
use Illuminate\Support\Facades\DB;
use App\Support\AdminReportJobPolicy;
use Illuminate\Auth\Access\AuthorizationException;
/** /**
* 报表导出任务:落库 `report_jobs`(同步生成,可后续接队列)。 * 报表导出任务:落库 `report_jobs`(同步生成,可后续接队列)。
@@ -68,9 +70,13 @@ final class AdminReportJobService
/** /**
* @return list<array<int, string|int|float|null>> * @return list<array<int, string|int|float|null>>
*/ */
public function reportRows(string $reportType, ?array $filterJson, ?AdminUser $scopedAdmin = null): array public function reportRows(string $reportType, ?array $filterJson, AdminUser $admin): array
{ {
return $this->queryService->reportRows($reportType, $filterJson, $scopedAdmin); if (! AdminReportJobPolicy::canExportReportType($admin, $reportType)) {
throw new AuthorizationException;
}
return $this->queryService->reportRows($reportType, $filterJson, $admin);
} }
public function reportLabel(string $reportType): string public function reportLabel(string $reportType): string

View File

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

View File

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

View File

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

View File

@@ -13,9 +13,9 @@ use App\Services\AuditLogger;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use App\Support\OddsStandardScopes; use App\Support\OddsStandardScopes;
use App\Lottery\ConfigVersionStatus; use App\Lottery\ConfigVersionStatus;
use App\Services\Draw\LotteryHallRealtimeBroadcaster;
use App\Http\Middleware\RecordAdminApiAudit; use App\Http\Middleware\RecordAdminApiAudit;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
use App\Services\Draw\LotteryHallRealtimeBroadcaster;
use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Contracts\Pagination\LengthAwarePaginator;
/** 后台:赔率版本({@see odds_versions} / {@see odds_items} */ /** 后台:赔率版本({@see odds_versions} / {@see odds_items} */
@@ -269,8 +269,8 @@ final class OddsStreamService
$errors["items.$index.currency_code"][] = '币种不可下注'; $errors["items.$index.currency_code"][] = '币种不可下注';
} }
if ($dimension !== null && ! in_array($dimension, [2, 3, 4], true)) { if ($dimension !== null && ! in_array($dimension, [2, 3, 4, 5, 6], true)) {
$errors["items.$index.dimension"][] = '维度必须是 2、3 或 4'; $errors["items.$index.dimension"][] = '维度必须是 2、3、4、56';
} }
if (isset($seenKeys[$key])) { if (isset($seenKeys[$key])) {
@@ -278,8 +278,8 @@ final class OddsStreamService
} }
$seenKeys[$key] = true; $seenKeys[$key] = true;
if ($oddsValue <= 0) { if ($oddsValue < 0) {
$errors["items.$index.odds_value"][] = '赔率值必须大于 0'; $errors["items.$index.odds_value"][] = '赔率值不能小于 0';
} }
if ($rebateRate < 0 || $rebateRate > 1) { if ($rebateRate < 0 || $rebateRate > 1) {

View File

@@ -0,0 +1,71 @@
<?php
namespace App\Services\Player;
use Throwable;
use App\Models\AdminSite;
use App\Lottery\ErrorCode;
use Illuminate\Support\Facades\Schema;
use App\Exceptions\PlayerAuthenticationException;
final class NativeJwtSecretGuard
{
public function validatedSecret(): string
{
$secret = config('lottery.player_auth.native.secret');
if (! is_string($secret) || $secret === '') {
$this->rejectConfiguration('原生登录未配置');
}
$legacySsoSecret = config('lottery.main_site.sso_jwt_secret');
if ($this->matches($secret, $legacySsoSecret)) {
$this->rejectConfiguration('原生登录密钥不得与 legacy SSO 密钥相同');
}
if ($this->matchesStoredSiteSsoSecret($secret)) {
$this->rejectConfiguration('原生登录密钥不得与任何站点保存的 SSO 密钥相同');
}
return $secret;
}
private function matchesStoredSiteSsoSecret(string $nativeSecret): bool
{
try {
if (! Schema::hasTable('admin_sites')
|| ! Schema::hasColumn('admin_sites', 'sso_jwt_secret_encrypted')) {
return false;
}
$sites = AdminSite::query()
->whereNotNull('sso_jwt_secret_encrypted')
->get(['sso_jwt_secret_encrypted']);
} catch (Throwable) {
$this->rejectConfiguration('无法检查原生登录密钥是否与站点 SSO 密钥冲突');
}
foreach ($sites as $site) {
if ($this->matches($nativeSecret, $site->decryptedSsoJwtSecret())) {
return true;
}
}
return false;
}
private function matches(string $nativeSecret, mixed $candidate): bool
{
return is_string($candidate)
&& $candidate !== ''
&& hash_equals($nativeSecret, $candidate);
}
private function rejectConfiguration(string $message): never
{
throw new PlayerAuthenticationException(
$message,
ErrorCode::PlayerSsoSecretNotConfigured->value,
503,
);
}
}

View File

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

View File

@@ -2,16 +2,21 @@
namespace App\Services\Player; namespace App\Services\Player;
use App\Lottery\ErrorCode;
use App\Models\Player;
use App\Support\PlayerAuthSource;
use Firebase\JWT\JWT; use Firebase\JWT\JWT;
use App\Models\Player;
use App\Lottery\ErrorCode;
use App\Support\PlayerAuthSource;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
use App\Events\PlayerSessionReplacedBroadcast;
use App\Exceptions\PlayerAuthenticationException; use App\Exceptions\PlayerAuthenticationException;
final class PlayerNativeAuthService final class PlayerNativeAuthService
{ {
public function __construct(
private readonly NativeJwtSecretGuard $nativeJwtSecretGuard,
) {}
/** /**
* @return array{access_token: string, expires_in: int, token_type: string, player: array<string, mixed>} * @return array{access_token: string, expires_in: int, token_type: string, player: array<string, mixed>}
*/ */
@@ -60,17 +65,62 @@ final class PlayerNativeAuthService
); );
} }
$player->forceFill([
'login_failed_count' => 0,
'login_locked_until' => null,
'last_login_at' => now(),
])->save();
$ttl = (int) config('lottery.player_auth.native.ttl_seconds', 28800); $ttl = (int) config('lottery.player_auth.native.ttl_seconds', 28800);
$token = $this->issueToken($player, $ttl);
/** @var array{token: string, player: Player, session_version: int} $login */
$login = DB::transaction(function () use ($player, $password, $ttl): array {
$locked = Player::query()->lockForUpdate()->find($player->id);
if ($locked === null
|| ! $locked->isLotteryNative()
|| ! is_string($locked->password_hash)
|| ! Hash::check($password, $locked->password_hash)) {
throw new PlayerAuthenticationException(
'账号或密码错误',
ErrorCode::PlayerCredentialsInvalid->value,
);
}
if ($locked->login_locked_until !== null && $locked->login_locked_until->isFuture()) {
throw new PlayerAuthenticationException(
'登录已锁定',
ErrorCode::PlayerLoginLocked->value,
403,
);
}
if ((int) $locked->status !== 0) {
throw new PlayerAuthenticationException(
'账号已冻结',
ErrorCode::PlayerAccountSuspended->value,
403,
);
}
$sessionVersion = (int) ($locked->native_session_version ?? 0) + 1;
$locked->forceFill([
'login_failed_count' => 0,
'login_locked_until' => null,
'last_login_at' => now(),
'native_session_version' => $sessionVersion,
])->save();
return [
'token' => $this->issueToken($locked, $ttl),
'player' => $locked->refresh(),
'session_version' => $sessionVersion,
];
});
event(new PlayerSessionReplacedBroadcast(
(int) $login['player']->id,
$login['session_version'],
(int) floor(microtime(true) * 1000),
));
$player = $login['player'];
return [ return [
'access_token' => $token, 'access_token' => $login['token'],
'expires_in' => $ttl, 'expires_in' => $ttl,
'token_type' => 'Bearer', 'token_type' => 'Bearer',
'player' => [ 'player' => [
@@ -86,14 +136,7 @@ final class PlayerNativeAuthService
public function issueToken(Player $player, ?int $ttlSeconds = null): string public function issueToken(Player $player, ?int $ttlSeconds = null): string
{ {
$secret = (string) config('lottery.player_auth.native.secret', ''); $secret = $this->nativeJwtSecretGuard->validatedSecret();
if ($secret === '') {
throw new PlayerAuthenticationException(
'原生登录未配置',
ErrorCode::PlayerSsoSecretNotConfigured->value,
503,
);
}
$ttl = $ttlSeconds ?? (int) config('lottery.player_auth.native.ttl_seconds', 28800); $ttl = $ttlSeconds ?? (int) config('lottery.player_auth.native.ttl_seconds', 28800);
$now = time(); $now = time();
@@ -103,6 +146,8 @@ final class PlayerNativeAuthService
$payload = [ $payload = [
$playerIdKey => (int) $player->id, $playerIdKey => (int) $player->id,
$authKey => PlayerAuthSource::LOTTERY_NATIVE, $authKey => PlayerAuthSource::LOTTERY_NATIVE,
'token_version' => (int) ($player->native_token_version ?? 0),
'session_version' => (int) ($player->native_session_version ?? 0),
'site_code' => (string) $player->site_code, 'site_code' => (string) $player->site_code,
'iat' => $now, 'iat' => $now,
'exp' => $now + $ttl, 'exp' => $now + $ttl,

View File

@@ -0,0 +1,64 @@
<?php
namespace App\Services\Player;
use App\Models\Player;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\ValidationException;
final class PlayerPasswordService
{
public function change(Player $player, string $currentPassword, string $newPassword): Player
{
return DB::transaction(function () use ($player, $currentPassword, $newPassword): Player {
$locked = Player::query()->lockForUpdate()->findOrFail($player->id);
$this->assertPasswordManagedLocally($locked);
if (! is_string($locked->password_hash) || ! Hash::check($currentPassword, $locked->password_hash)) {
throw ValidationException::withMessages([
'current_password' => ['current_password_invalid'],
]);
}
if (Hash::check($newPassword, $locked->password_hash)) {
throw ValidationException::withMessages([
'password' => ['new_password_must_differ'],
]);
}
return $this->persist($locked, $newPassword);
});
}
public function reset(Player $player, string $newPassword): Player
{
return DB::transaction(function () use ($player, $newPassword): Player {
$locked = Player::query()->lockForUpdate()->findOrFail($player->id);
$this->assertPasswordManagedLocally($locked);
return $this->persist($locked, $newPassword);
});
}
private function assertPasswordManagedLocally(Player $player): void
{
if (! $player->isLotteryNative()) {
throw ValidationException::withMessages([
'password' => ['native_password_unavailable'],
]);
}
}
private function persist(Player $player, string $newPassword): Player
{
$player->forceFill([
'password_hash' => Hash::make($newPassword),
'native_token_version' => (int) $player->native_token_version + 1,
'login_failed_count' => 0,
'login_locked_until' => null,
])->save();
return $player->refresh();
}
}

View File

@@ -8,10 +8,11 @@ use App\Models\Player;
use App\Lottery\ErrorCode; use App\Lottery\ErrorCode;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use App\Support\PlayerAuthSource; use App\Support\PlayerAuthSource;
use App\Support\PlayerAutoRegistrationDefaults;
use App\Support\PlayerFundingMode; use App\Support\PlayerFundingMode;
use App\Support\PlayerTokenAesUnwrap; use App\Support\PlayerTokenAesUnwrap;
use Illuminate\Database\QueryException; use Illuminate\Database\QueryException;
use App\Services\Player\NativeJwtSecretGuard;
use App\Support\PlayerAutoRegistrationDefaults;
use App\Exceptions\PlayerAuthenticationException; use App\Exceptions\PlayerAuthenticationException;
use App\Services\Integration\PartnerSiteConfigResolver; use App\Services\Integration\PartnerSiteConfigResolver;
@@ -42,6 +43,7 @@ final class PlayerTokenResolver
public function __construct( public function __construct(
private readonly PartnerSiteConfigResolver $partnerSiteConfigResolver, private readonly PartnerSiteConfigResolver $partnerSiteConfigResolver,
private readonly NativeJwtSecretGuard $nativeJwtSecretGuard,
) {} ) {}
public function resolve(Request $request): Player public function resolve(Request $request): Player
@@ -158,14 +160,7 @@ final class PlayerTokenResolver
private function resolveNativeJwt(string $jwt): Player private function resolveNativeJwt(string $jwt): Player
{ {
$secret = (string) config('lottery.player_auth.native.secret', ''); $secret = $this->nativeJwtSecretGuard->validatedSecret();
if ($secret === '') {
throw new PlayerAuthenticationException(
'原生登录未配置',
ErrorCode::PlayerSsoSecretNotConfigured->value,
503,
);
}
$alg = (string) config('lottery.player_auth.jwt.algorithm', 'HS256'); $alg = (string) config('lottery.player_auth.jwt.algorithm', 'HS256');
@@ -192,6 +187,19 @@ final class PlayerTokenResolver
throw new PlayerAuthenticationException('玩家不存在', ErrorCode::PlayerNotRegistered->value); throw new PlayerAuthenticationException('玩家不存在', ErrorCode::PlayerNotRegistered->value);
} }
$tokenVersion = (int) data_get($claims, 'token_version', 0);
if ($tokenVersion !== (int) ($player->native_token_version ?? 0)) {
throw new PlayerAuthenticationException('Token 已因密码变更失效', ErrorCode::PlayerTokenInvalid->value);
}
$sessionVersion = (int) data_get($claims, 'session_version', 0);
if ($sessionVersion !== (int) ($player->native_session_version ?? 0)) {
throw new PlayerAuthenticationException(
'当前会话已被同账号的较新登录替换',
ErrorCode::PlayerSessionReplaced->value,
);
}
$player->forceFill(['last_login_at' => now()])->save(); $player->forceFill(['last_login_at' => now()])->save();
return $player->refresh(); return $player->refresh();
@@ -251,6 +259,14 @@ final class PlayerTokenResolver
} }
} }
if ((string) $player->auth_source !== PlayerAuthSource::MAIN_SITE_SSO
|| (string) $player->funding_mode !== PlayerFundingMode::WALLET) {
throw new PlayerAuthenticationException(
'SSO 玩家映射与账号认证域冲突',
ErrorCode::PlayerTokenInvalid->value,
);
}
if (! $player->wasRecentlyCreated) { if (! $player->wasRecentlyCreated) {
$player->forceFill(['last_login_at' => $now])->save(); $player->forceFill(['last_login_at' => $now])->save();
} }
@@ -288,9 +304,6 @@ final class PlayerTokenResolver
return is_string($decoded) ? $decoded : ''; return is_string($decoded) ? $decoded : '';
} }
/**
* @param object $claims
*/
private function assertNativeJwtTemporalPolicy(object $claims): void private function assertNativeJwtTemporalPolicy(object $claims): void
{ {
if (! isset($claims->exp) || ! is_numeric($claims->exp)) { if (! isset($claims->exp) || ! is_numeric($claims->exp)) {

View File

@@ -0,0 +1,57 @@
<?php
namespace App\Services\Settlement;
use App\Models\Draw;
use App\Lottery\DrawStatus;
use App\Models\DrawResultBatch;
use Illuminate\Support\Facades\DB;
use App\Lottery\DrawResultBatchStatus;
/**
* 将期号推进到可结算状态。
*
* 冷静期内的人工操作只跳过当前期剩余的核错窗口,不修改全局冷静期配置。
*/
final class DrawSettlementStartService
{
/**
* @return array{draw: Draw, cooldown_skipped: bool}
*/
public function start(Draw $draw): array
{
return DB::transaction(function () use ($draw): array {
/** @var Draw $locked */
$locked = Draw::query()->whereKey($draw->id)->lockForUpdate()->firstOrFail();
if (! in_array($locked->status, [
DrawStatus::Cooldown->value,
DrawStatus::Settling->value,
], true)) {
throw new \RuntimeException('draw_not_ready_for_settlement');
}
$hasPublishedResult = DrawResultBatch::query()
->where('draw_id', $locked->id)
->where('status', DrawResultBatchStatus::Published->value)
->exists();
if (! $hasPublishedResult) {
throw new \RuntimeException('draw_result_not_published');
}
$cooldownSkipped = $locked->status === DrawStatus::Cooldown->value;
if ($cooldownSkipped) {
$locked->forceFill([
'status' => DrawStatus::Settling->value,
'cooling_end_time' => now(),
])->save();
}
return [
'draw' => $locked->fresh(),
'cooldown_skipped' => $cooldownSkipped,
];
});
}
}

View File

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

View File

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

View File

@@ -2,11 +2,12 @@
namespace App\Services\Settlement; namespace App\Services\Settlement;
use App\Models\SettlementBatch;
use App\Lottery\SettlementBatchStatus;
use App\Services\AuditLogger; use App\Services\AuditLogger;
use Illuminate\Support\Carbon;
use App\Models\SettlementBatch;
use App\Services\LotterySettings; use App\Services\LotterySettings;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use App\Lottery\SettlementBatchStatus;
/** /**
* draw tick 在自动结算后,按系统设置自动审核并派彩入账。 * draw tick 在自动结算后,按系统设置自动审核并派彩入账。
@@ -23,16 +24,52 @@ final class SettlementTickFinalizer
$approved = 0; $approved = 0;
$paid = 0; $paid = 0;
$payoutFailed = 0; $payoutFailed = 0;
$finalizeLimit = (int) config('lottery.draw_tick_finalize_limit', 5);
if (! (bool) LotterySettings::get('settlement.auto_approve_on_tick', true)) { $autoApprove = (bool) LotterySettings::get('settlement.auto_approve_on_tick', true);
return ['approved' => 0, 'paid' => 0, 'payout_failed' => 0]; $pending = $autoApprove
} ? SettlementBatch::query()
->where('status', SettlementBatchStatus::PendingReview->value)
->orderBy('id')
->limit($finalizeLimit)
->get()
: collect();
$pending = SettlementBatch::query() // 除本轮待审核批次外,也恢复处理上轮已批准但尚未派彩的批次。
->where('status', SettlementBatchStatus::PendingReview->value) // 这样进程即使在 approve 提交后、payout 开始前退出,下一 tick 仍能续跑。
->orderBy('id') $retryBaseSeconds = (int) config('lottery.auto_payout_retry_base_seconds', 60);
->limit((int) config('lottery.draw_tick_finalize_limit', 5)) $approvedDrawCandidates = DB::table('settlement_batches as approved')
->get(); ->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')
->selectRaw('approved.draw_id, MAX(approved.auto_payout_attempts) AS max_auto_payout_attempts, MAX(approved.updated_at) AS last_attempt_at')
->havingRaw(
'(MAX(approved.auto_payout_attempts) = 0 OR MAX(approved.updated_at) <= ?)',
[now()->subSeconds($retryBaseSeconds)],
)
->orderByRaw('MAX(approved.auto_payout_attempts)')
->orderByRaw('MIN(approved.id)')
->cursor();
$approvedDrawIds = $approvedDrawCandidates
->filter(fn (object $candidate): bool => $this->autoPayoutRetryIsDue(
(int) $candidate->max_auto_payout_attempts,
$candidate->last_attempt_at !== null ? (string) $candidate->last_attempt_at : null,
))
->take($finalizeLimit)
->pluck('draw_id');
$candidateDrawIds = $pending->pluck('draw_id')
->merge($approvedDrawIds)
->map(fn ($id): int => (int) $id)
->unique()
->values();
foreach ($pending as $batch) { foreach ($pending as $batch) {
try { try {
@@ -43,32 +80,53 @@ final class SettlementTickFinalizer
continue; continue;
} }
}
if (! (bool) LotterySettings::get('settlement.auto_payout_on_tick', true)) { if (! (bool) LotterySettings::get('settlement.auto_payout_on_tick', true)) {
return ['approved' => $approved, 'paid' => 0, 'payout_failed' => 0];
}
foreach ($candidateDrawIds as $drawId) {
$hasUnapprovedBatch = SettlementBatch::query()
->where('draw_id', $drawId)
->whereIn('status', [
SettlementBatchStatus::Running->value,
SettlementBatchStatus::PendingReview->value,
])
->exists();
if ($hasUnapprovedBatch) {
continue; continue;
} }
try { $approvedBatches = SettlementBatch::query()
$this->workflow->payout($batch->fresh()); ->where('draw_id', $drawId)
$paid++; ->where('status', SettlementBatchStatus::Approved->value)
AuditLogger::recordForSystem( ->orderBy('id')
moduleCode: 'settlement', ->get();
actionCode: 'auto_payout',
targetType: 'settlement_batch', foreach ($approvedBatches as $batch) {
targetId: (string) $batch->id, try {
afterJson: ['draw_id' => (int) $batch->draw_id], $this->workflow->payout($batch);
); $paid++;
} catch (\Throwable $e) { AuditLogger::recordForSystem(
report($e); moduleCode: 'settlement',
$this->markAutoPayoutFailed($batch, $e); actionCode: 'auto_payout',
$payoutFailed++; 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]; return ['approved' => $approved, 'paid' => $paid, 'payout_failed' => $payoutFailed];
} }
private function markAutoPayoutFailed(SettlementBatch $batch, \Throwable $e): void private function recordAutoPayoutFailure(SettlementBatch $batch, \Throwable $e): void
{ {
$message = mb_substr($e->getMessage(), 0, 200); $message = mb_substr($e->getMessage(), 0, 200);
@@ -79,7 +137,7 @@ final class SettlementTickFinalizer
} }
$locked->forceFill([ $locked->forceFill([
'status' => SettlementBatchStatus::Failed->value, 'auto_payout_attempts' => (int) $locked->auto_payout_attempts + 1,
'review_remark' => 'auto_payout_failed: '.$message, 'review_remark' => 'auto_payout_failed: '.$message,
])->save(); ])->save();
@@ -95,4 +153,20 @@ final class SettlementTickFinalizer
); );
}); });
} }
}
private function autoPayoutRetryIsDue(int $attempts, ?string $lastAttemptAt): bool
{
if ($attempts <= 0 || $lastAttemptAt === null) {
return true;
}
$baseSeconds = (int) config('lottery.auto_payout_retry_base_seconds', 60);
$maxSeconds = max($baseSeconds, (int) config('lottery.auto_payout_retry_max_seconds', 3600));
$exponent = min($attempts - 1, 20);
$delaySeconds = min($maxSeconds, $baseSeconds * (2 ** $exponent));
return now()->greaterThanOrEqualTo(
Carbon::parse($lastAttemptAt)->addSeconds($delaySeconds),
);
}
}

View File

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

View File

@@ -341,7 +341,11 @@ final class TicketPlacementService
])->save(); ])->save();
if ($creditLine) { if ($creditLine) {
$this->playerCreditService->assertMayPlaceBet($player, $successTotalActualDeduct); $this->playerCreditService->assertMayPlaceBet(
$player,
$successTotalActualDeduct,
(int) $order->id,
);
} else { } else {
$this->ticketWalletService->reserveBetDeduct( $this->ticketWalletService->reserveBetDeduct(
$player, $player,

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,425 @@
<?php
namespace App\Services\Wallet;
use Carbon\Carbon;
use App\Models\Player;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
/**
* 将信用盘底层账务过程整理成玩家能理解的业务活动。
*
* 原始 credit_ledger 仍由管理端与审计使用;玩家端按订单、开奖结果和账期收付展示。
*/
final class PlayerCreditActivityService
{
/**
* @return list<array<string, mixed>>
*/
public function build(Player $player): array
{
$orders = DB::table('ticket_orders as o')
->leftJoin('draws as d', 'd.id', '=', 'o.draw_id')
->where('o.player_id', (int) $player->id)
->where('o.total_actual_deduct', '>', 0)
->orderByDesc('o.id')
->get([
'o.id',
'o.order_no',
'o.currency_code',
'o.total_actual_deduct',
'o.status',
'o.created_at',
'o.updated_at',
'd.draw_no',
]);
$orderActivities = $this->orderActivities($player, $orders);
$paymentActivities = $this->paymentActivities($player);
$activities = array_merge($orderActivities, $paymentActivities);
usort($activities, static function (array $left, array $right): int {
$leftTime = isset($left['created_at']) ? strtotime((string) $left['created_at']) : 0;
$rightTime = isset($right['created_at']) ? strtotime((string) $right['created_at']) : 0;
if ($leftTime === $rightTime) {
return strcmp((string) ($right['log_id'] ?? ''), (string) ($left['log_id'] ?? ''));
}
return $rightTime <=> $leftTime;
});
return $activities;
}
/**
* @param Collection<int, object> $orders
* @return list<array<string, mixed>>
*/
private function orderActivities(Player $player, Collection $orders): array
{
if ($orders->isEmpty()) {
return [];
}
$orderIds = $orders->pluck('id')->map(static fn ($id): int => (int) $id)->all();
$items = DB::table('ticket_items')
->whereIn('order_id', $orderIds)
->where('actual_deduct_amount', '>', 0)
->orderBy('id')
->get([
'id',
'order_id',
'ticket_no',
'actual_deduct_amount',
'status',
'win_amount',
'jackpot_win_amount',
'settled_at',
'updated_at',
]);
$itemsByOrder = $items->groupBy(static fn (object $item): int => (int) $item->order_id);
$ticketIds = $items->pluck('id')->map(static fn ($id): int => (int) $id)->all();
$activeShares = $this->activeShareRows($ticketIds)->keyBy('ticket_item_id');
$activeRebates = $this->activeRebateTotals($ticketIds);
$availableDeltas = $this->ticketAvailableDeltas((int) $player->id, $ticketIds);
$activities = [];
foreach ($orders as $order) {
/** @var Collection<int, object> $orderItems */
$orderItems = $itemsByOrder->get((int) $order->id, collect());
if ($orderItems->isEmpty()) {
continue;
}
$stakeAmount = (int) $orderItems->sum(
static fn (object $item): int => (int) $item->actual_deduct_amount,
);
$rebateAmount = 0;
$gameWinLoss = 0;
$winAmount = 0;
$availableDelta = 0;
$hasSettlement = false;
$hasPendingPayout = false;
$latestSettlementAt = null;
foreach ($orderItems as $item) {
$ticketId = (int) $item->id;
$share = $activeShares->get($ticketId);
if ($share !== null) {
$ticketGameWinLoss = (int) $share->game_win_loss;
$gameWinLoss += $ticketGameWinLoss;
$winAmount += max(0, -$ticketGameWinLoss);
$hasSettlement = true;
$latestSettlementAt = $this->latestTimestamp(
$latestSettlementAt,
$share->settled_at ?? $share->created_at ?? null,
);
} elseif (in_array((string) $item->status, ['settled_win', 'settled_lose', 'pending_payout'], true)) {
$fallbackWin = (int) $item->win_amount + (int) $item->jackpot_win_amount;
$ticketGameWinLoss = (string) $item->status === 'settled_lose'
? (int) $item->actual_deduct_amount
: -$fallbackWin;
$gameWinLoss += $ticketGameWinLoss;
$winAmount += max(0, -$ticketGameWinLoss);
$hasSettlement = true;
}
$rebateAmount += (int) ($activeRebates[$ticketId] ?? 0);
$availableDelta += (int) ($availableDeltas[$ticketId] ?? 0);
$hasPendingPayout = $hasPendingPayout || (string) $item->status === 'pending_payout';
$latestSettlementAt = $this->latestTimestamp(
$latestSettlementAt,
$item->settled_at ?? $item->updated_at ?? null,
);
}
$refunded = in_array((string) $order->status, ['refunded', 'cancelled'], true)
|| $orderItems->every(
static fn (object $item): bool => in_array((string) $item->status, ['refunded', 'failed'], true),
);
$ticketCount = $orderItems->count();
$firstTicketNo = $ticketCount === 1 ? (string) ($orderItems->first()->ticket_no ?? '') : null;
if ($refunded) {
$activities[] = $this->formatActivity(
logId: 'CA-ORDER-'.$order->id,
type: 'reversal',
bizType: 'bet_refund',
activityKind: 'refund',
activityStatus: 'reversed',
amount: $stakeAmount,
currency: (string) $order->currency_code,
availableDelta: 0,
createdAt: $this->isoTimestamp($order->updated_at ?? $order->created_at ?? null),
order: $order,
ticketCount: $ticketCount,
ticketNo: $firstTicketNo,
stakeAmount: $stakeAmount,
winAmount: 0,
rebateAmount: 0,
);
continue;
}
if ($hasSettlement) {
$netAmount = -$gameWinLoss + $rebateAmount;
$activities[] = $this->formatActivity(
logId: 'CA-ORDER-'.$order->id,
type: 'game_settlement',
bizType: $netAmount > 0 ? 'settled_win' : ($netAmount < 0 ? 'settled_loss' : 'settled_even'),
activityKind: 'draw_result',
activityStatus: $hasPendingPayout ? 'pending' : 'completed',
amount: $netAmount,
currency: (string) $order->currency_code,
availableDelta: $availableDelta,
createdAt: $this->isoTimestamp($latestSettlementAt ?? $order->updated_at ?? null),
order: $order,
ticketCount: $ticketCount,
ticketNo: $firstTicketNo,
stakeAmount: $stakeAmount,
winAmount: $winAmount,
rebateAmount: $rebateAmount,
);
continue;
}
$activities[] = $this->formatActivity(
logId: 'CA-ORDER-'.$order->id,
type: 'bet',
bizType: 'bet_pending',
activityKind: 'bet',
activityStatus: 'pending',
amount: -$stakeAmount,
currency: (string) $order->currency_code,
availableDelta: -$stakeAmount,
createdAt: $this->isoTimestamp($order->created_at ?? null),
order: $order,
ticketCount: $ticketCount,
ticketNo: $firstTicketNo,
stakeAmount: $stakeAmount,
winAmount: 0,
rebateAmount: 0,
);
}
return $activities;
}
/**
* @return list<array<string, mixed>>
*/
private function paymentActivities(Player $player): array
{
$rows = DB::table('payment_records as pr')
->join('settlement_bills as sb', 'sb.id', '=', 'pr.settlement_bill_id')
->where('sb.bill_type', 'player')
->where('sb.owner_type', 'player')
->where('sb.owner_id', (int) $player->id)
->where('pr.status', 'confirmed')
->orderByDesc('pr.id')
->get([
'pr.id',
'pr.settlement_bill_id',
'pr.payer_type',
'pr.payer_id',
'pr.payee_type',
'pr.payee_id',
'pr.amount',
'pr.confirmed_at',
'pr.created_at',
]);
$activities = [];
foreach ($rows as $row) {
$playerPaid = (string) $row->payer_type === 'player'
&& (int) $row->payer_id === (int) $player->id;
$playerReceived = (string) $row->payee_type === 'player'
&& (int) $row->payee_id === (int) $player->id;
if (! $playerPaid && ! $playerReceived) {
continue;
}
$amount = (int) $row->amount * ($playerReceived ? 1 : -1);
$activities[] = [
'log_id' => 'CA-PAYMENT-'.$row->id,
'type' => 'bill_settlement',
'biz_type' => $playerReceived ? 'period_received' : 'period_paid',
'activity_kind' => 'period_settlement',
'activity_status' => 'completed',
'amount' => $amount,
'amount_abs' => abs($amount),
'direction' => $amount >= 0 ? 'in' : 'out',
'currency_code' => (string) $player->default_currency,
'balance_after' => null,
'affects_available_credit' => $playerPaid,
'_available_delta' => $playerPaid ? (int) $row->amount : 0,
'ref_id' => 'settlement_bill#'.$row->settlement_bill_id,
'settlement_bill_id' => (int) $row->settlement_bill_id,
'order_no' => null,
'draw_no' => null,
'ticket_no' => null,
'ticket_count' => 0,
'stake_amount' => 0,
'win_amount' => 0,
'rebate_amount' => 0,
'net_amount' => $amount,
'idempotent_key' => null,
'external_ref_no' => null,
'status' => 'posted',
'remark' => null,
'created_at' => $this->isoTimestamp($row->confirmed_at ?? $row->created_at ?? null),
'ledger_source' => 'credit_activity',
'funding_mode' => (string) $player->funding_mode,
'auth_source' => $player->auth_source,
];
}
return $activities;
}
/**
* @param list<int> $ticketIds
* @return Collection<int, object>
*/
private function activeShareRows(array $ticketIds): Collection
{
if ($ticketIds === []) {
return collect();
}
return DB::table('share_ledger as sl')
->whereIn('sl.ticket_item_id', $ticketIds)
->whereNull('sl.reversal_of_id')
->whereNotExists(function ($query): void {
$query->selectRaw('1')
->from('share_ledger as reversal')
->whereColumn('reversal.reversal_of_id', 'sl.id');
})
->get(['sl.ticket_item_id', 'sl.game_win_loss', 'sl.settled_at', 'sl.created_at']);
}
/**
* @param list<int> $ticketIds
* @return array<int, int>
*/
private function activeRebateTotals(array $ticketIds): array
{
if ($ticketIds === []) {
return [];
}
return DB::table('rebate_records as rr')
->whereIn('rr.ticket_item_id', $ticketIds)
->whereNull('rr.reversal_of_id')
->whereNotExists(function ($query): void {
$query->selectRaw('1')
->from('rebate_records as reversal')
->whereColumn('reversal.reversal_of_id', 'rr.id');
})
->selectRaw('rr.ticket_item_id, SUM(rr.rebate_amount) as total_rebate')
->groupBy('rr.ticket_item_id')
->pluck('total_rebate', 'ticket_item_id')
->map(static fn ($amount): int => (int) $amount)
->all();
}
/**
* @param list<int> $ticketIds
* @return array<int, int>
*/
private function ticketAvailableDeltas(int $playerId, array $ticketIds): array
{
if ($ticketIds === []) {
return [];
}
return DB::table('credit_ledger')
->where('owner_type', 'player')
->where('owner_id', $playerId)
->where('ref_type', 'ticket_item')
->whereIn('ref_id', $ticketIds)
->whereIn('reason', ['game_settlement_loss', 'game_settlement_win', 'game_settlement_reversal'])
->selectRaw('ref_id, SUM(amount) as available_delta')
->groupBy('ref_id')
->pluck('available_delta', 'ref_id')
->map(static fn ($amount): int => (int) $amount)
->all();
}
/**
* @return array<string, mixed>
*/
private function formatActivity(
string $logId,
string $type,
string $bizType,
string $activityKind,
string $activityStatus,
int $amount,
string $currency,
int $availableDelta,
?string $createdAt,
object $order,
int $ticketCount,
?string $ticketNo,
int $stakeAmount,
int $winAmount,
int $rebateAmount,
): array {
return [
'log_id' => $logId,
'type' => $type,
'biz_type' => $bizType,
'activity_kind' => $activityKind,
'activity_status' => $activityStatus,
'amount' => $amount,
'amount_abs' => abs($amount),
'direction' => $amount >= 0 ? 'in' : 'out',
'currency_code' => $currency,
'balance_after' => null,
'affects_available_credit' => $availableDelta !== 0,
'_available_delta' => $availableDelta,
'ref_id' => (string) $order->order_no,
'order_no' => (string) $order->order_no,
'draw_no' => $order->draw_no !== null ? (string) $order->draw_no : null,
'ticket_no' => $ticketNo !== '' ? $ticketNo : null,
'ticket_count' => $ticketCount,
'stake_amount' => $stakeAmount,
'win_amount' => $winAmount,
'rebate_amount' => $rebateAmount,
'net_amount' => $amount,
'settlement_bill_id' => null,
'idempotent_key' => null,
'external_ref_no' => null,
'status' => $activityStatus === 'pending' ? 'pending_reconcile' : ($activityStatus === 'reversed' ? 'reversed' : 'posted'),
'remark' => null,
'created_at' => $createdAt,
'ledger_source' => 'credit_activity',
];
}
private function latestTimestamp(mixed $current, mixed $candidate): mixed
{
if ($candidate === null || $candidate === '') {
return $current;
}
if ($current === null || $current === '') {
return $candidate;
}
return strtotime((string) $candidate) > strtotime((string) $current) ? $candidate : $current;
}
private function isoTimestamp(mixed $value): ?string
{
if ($value === null || $value === '') {
return null;
}
return Carbon::parse((string) $value)->toIso8601String();
}
}

View File

@@ -3,19 +3,20 @@
namespace App\Services\Wallet; namespace App\Services\Wallet;
use Carbon\Carbon; use Carbon\Carbon;
use App\Models\AdminUser;
use App\Models\Player; use App\Models\Player;
use App\Models\AdminUser;
use App\Models\WalletTxn; use App\Models\WalletTxn;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use App\Support\AdminDataScope;
use App\Support\CurrencyFormatter;
use App\Support\LimitedQuery; use App\Support\LimitedQuery;
use App\Support\AdminDataScope;
use App\Support\CreditAmountScale;
use App\Support\CurrencyFormatter;
use App\Support\PlayerFundingMode; use App\Support\PlayerFundingMode;
use App\Services\AgentSettlement\CreditLedgerBetFlowPresenter; use Illuminate\Support\Facades\DB;
use App\Services\AgentSettlement\SettlementPartyEnrichment;
use App\Services\Player\PlayerCreditService; use App\Services\Player\PlayerCreditService;
use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\DB; use App\Services\AgentSettlement\SettlementPartyEnrichment;
use App\Services\AgentSettlement\CreditLedgerBetFlowPresenter;
/** /**
* 玩家流水:钱包玩家读 {@see wallet_txns},信用盘玩家读 {@see credit_ledger} * 玩家流水:钱包玩家读 {@see wallet_txns},信用盘玩家读 {@see credit_ledger}
@@ -48,6 +49,7 @@ final class PlayerLedgerLogsService
public function __construct( public function __construct(
private readonly PlayerCreditService $playerCreditService, private readonly PlayerCreditService $playerCreditService,
private readonly PlayerCreditActivityService $creditActivityService,
private readonly CreditLedgerBetFlowPresenter $betFlowPresenter, private readonly CreditLedgerBetFlowPresenter $betFlowPresenter,
private readonly SettlementPartyEnrichment $partyEnrichment, private readonly SettlementPartyEnrichment $partyEnrichment,
) {} ) {}
@@ -430,6 +432,127 @@ final class PlayerLedgerLogsService
int $page, int $page,
int $perPage, int $perPage,
string $typeFilterRaw, string $typeFilterRaw,
): array {
$activities = $this->creditActivityService->build($player);
if ($activities !== []) {
return $this->paginateCreditActivities($player, $activities, $page, $perPage, $typeFilterRaw);
}
return $this->paginateLegacyCreditLedger($player, $page, $perPage, $typeFilterRaw);
}
/**
* @param list<array<string, mixed>> $activities
* @return array{items: list<array<string, mixed>>, total: int, page: int, per_page: int}
*/
private function paginateCreditActivities(
Player $player,
array $activities,
int $page,
int $perPage,
string $typeFilterRaw,
): array {
$currency = (string) $player->default_currency;
$creditLimitMinor = $this->creditLimitMinor($player, $currency);
$runningMinor = $this->clampCreditAvailableMinor(
$this->playerCreditService->availableCreditMinor($player, $currency),
$creditLimitMinor,
);
// 先按完整活动时间线回推每笔的变更后额度,再做筛选和分页。
// 否则筛选掉的较新活动会让历史记录的 balance_after 偏离真实值。
foreach ($activities as &$activity) {
$activity['balance_after'] = $runningMinor;
$activity['balance_after_formatted'] = CurrencyFormatter::fromMinor($runningMinor);
$activity['funding_mode'] = (string) ($player->funding_mode ?? PlayerFundingMode::CREDIT);
$activity['auth_source'] = $player->auth_source;
$runningMinor = $this->clampCreditAvailableMinor(
$runningMinor - (int) ($activity['_available_delta'] ?? 0),
$creditLimitMinor,
);
unset($activity['_available_delta']);
}
unset($activity);
$activities = array_values(array_filter(
$activities,
fn (array $activity): bool => $this->creditActivityMatchesFilter($activity, $typeFilterRaw),
));
$offset = max(0, ($page - 1) * $perPage);
$items = array_slice($activities, $offset, $perPage);
return [
'items' => $items,
'total' => count($activities),
'page' => $page,
'per_page' => $perPage,
];
}
/**
* @param array<string, mixed> $activity
*/
private function creditActivityMatchesFilter(array $activity, string $typeFilterRaw): bool
{
$raw = trim($typeFilterRaw);
if ($raw === '') {
return true;
}
$parts = array_values(array_filter(array_map(
static fn (string $part): string => Str::lower(trim($part)),
explode(',', $raw),
)));
if ($parts === []) {
return true;
}
$kind = (string) ($activity['activity_kind'] ?? '');
$status = (string) ($activity['activity_status'] ?? '');
$netAmount = (int) ($activity['net_amount'] ?? 0);
$rebateAmount = (int) ($activity['rebate_amount'] ?? 0);
foreach ($parts as $part) {
if ($part === 'bet' && $kind === 'bet') {
return true;
}
if ($part === 'game_settlement' && $kind === 'draw_result') {
return true;
}
if ($part === 'bill_settlement' && $kind === 'period_settlement') {
return true;
}
if ($part === 'rebate' && $kind === 'draw_result' && $rebateAmount > 0) {
return true;
}
if ($part === 'reversal' && ($kind === 'refund' || $status === 'reversed')) {
return true;
}
if ($part === 'refund' && $kind === 'refund') {
return true;
}
if ($part === 'win_credit' && $kind === 'draw_result' && $netAmount > 0) {
return true;
}
if ($part === 'credit_release' && in_array($kind, ['draw_result', 'refund', 'period_settlement'], true)) {
return true;
}
}
return false;
}
/**
* 兼容没有订单领域记录的旧数据与孤立测试数据;真实玩家活动优先走聚合视图。
*
* @return array{items: list<array<string, mixed>>, total: int, page: int, per_page: int}
*/
private function paginateLegacyCreditLedger(
Player $player,
int $page,
int $perPage,
string $typeFilterRaw,
): array { ): array {
$reasonFilter = $this->resolveCreditReasonFilter($typeFilterRaw); $reasonFilter = $this->resolveCreditReasonFilter($typeFilterRaw);
$includeRebates = $this->creditFilterIncludesRebates($typeFilterRaw); $includeRebates = $this->creditFilterIncludesRebates($typeFilterRaw);
@@ -732,7 +855,7 @@ final class PlayerLedgerLogsService
return 0; return 0;
} }
return \App\Support\CreditAmountScale::majorToMinor((int) $credit->credit_limit, $currency); return CreditAmountScale::majorToMinor((int) $credit->credit_limit, $currency);
} }
private function clampCreditAvailableMinor(int $amountMinor, int $creditLimitMinor): int private function clampCreditAvailableMinor(int $amountMinor, int $creditLimitMinor): int

View File

@@ -407,6 +407,7 @@ final class AdminAuthorizationRegistry
['code' => 'admin.dashboard', 'module_code' => 'dashboard', 'name' => '后台仪表盘', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/dashboard', 'route_name' => 'api.v1.admin.dashboard', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['dashboard.view']], ['code' => 'admin.dashboard', 'module_code' => 'dashboard', 'name' => '后台仪表盘', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/dashboard', 'route_name' => 'api.v1.admin.dashboard', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['dashboard.view']],
['code' => 'admin.dashboard.analytics', 'module_code' => 'dashboard', 'name' => '仪表盘分析', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/dashboard/analytics', 'route_name' => 'api.v1.admin.dashboard.analytics', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['dashboard.view']], ['code' => 'admin.dashboard.analytics', 'module_code' => 'dashboard', 'name' => '仪表盘分析', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/dashboard/analytics', 'route_name' => 'api.v1.admin.dashboard.analytics', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['dashboard.view']],
['code' => 'admin.auth.me', 'module_code' => 'system', 'name' => '后台当前管理员摘要', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/auth/me', 'route_name' => 'api.v1.admin.auth.me', 'auth_mode' => 'login_only', 'is_audit_required' => false], ['code' => 'admin.auth.me', 'module_code' => 'system', 'name' => '后台当前管理员摘要', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/auth/me', 'route_name' => 'api.v1.admin.auth.me', 'auth_mode' => 'login_only', 'is_audit_required' => false],
['code' => 'admin.auth.logout', 'module_code' => 'system', 'name' => '后台退出登录', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/auth/logout', 'route_name' => 'api.v1.admin.auth.logout', 'auth_mode' => 'login_only', 'is_audit_required' => false],
['code' => 'admin.audit.index', 'module_code' => 'audit', 'name' => '审计日志查询', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/audit-logs', 'route_name' => 'api.v1.admin.audit-logs.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['service.audit.view']], ['code' => 'admin.audit.index', 'module_code' => 'audit', 'name' => '审计日志查询', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/audit-logs', 'route_name' => 'api.v1.admin.audit-logs.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['service.audit.view']],
['code' => 'admin.admin-users.index', 'module_code' => 'system', 'name' => '管理员列表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/admin-users', 'route_name' => 'api.v1.admin.admin-users.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.admin_user.manage']], ['code' => 'admin.admin-users.index', 'module_code' => 'system', 'name' => '管理员列表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/admin-users', 'route_name' => 'api.v1.admin.admin-users.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.admin_user.manage']],
@@ -527,7 +528,7 @@ final class AdminAuthorizationRegistry
['code' => 'admin.draws.risk-pools.recover', 'module_code' => 'risk', 'name' => '恢复风控池', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/draws/{draw}/risk-pools/{number_4d}/recover', 'route_name' => 'api.v1.admin.draws.risk-pools.recover', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.draw_result.manage', 'prd.risk.manage']], ['code' => 'admin.draws.risk-pools.recover', 'module_code' => 'risk', 'name' => '恢复风控池', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/draws/{draw}/risk-pools/{number_4d}/recover', 'route_name' => 'api.v1.admin.draws.risk-pools.recover', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.draw_result.manage', 'prd.risk.manage']],
['code' => 'admin.draws.cancel', 'module_code' => 'draw', 'name' => '取消开奖', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/draws/{draw}/cancel', 'route_name' => 'api.v1.admin.draws.cancel', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.draw_result.manage']], ['code' => 'admin.draws.cancel', 'module_code' => 'draw', 'name' => '取消开奖', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/draws/{draw}/cancel', 'route_name' => 'api.v1.admin.draws.cancel', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.draw_result.manage']],
['code' => 'admin.draws.rng', 'module_code' => 'draw', 'name' => '执行开奖 RNG', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/draws/{draw}/rng', 'route_name' => 'api.v1.admin.draws.rng', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.draw_result.manage']], ['code' => 'admin.draws.rng', 'module_code' => 'draw', 'name' => '执行开奖 RNG', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/draws/{draw}/rng', 'route_name' => 'api.v1.admin.draws.rng', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.draw_result.manage']],
['code' => 'admin.draws.settlement.run', 'module_code' => 'settlement', 'name' => '执行结算', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/draws/{draw}/settlement/run', 'route_name' => 'api.v1.admin.draws.settlement.run', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'permission_codes' => ['settlement.batch.manage', 'settlement.batch.review']], ['code' => 'admin.draws.settlement.run', 'module_code' => 'settlement', 'name' => '执行结算', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/draws/{draw}/settlement/run', 'route_name' => 'api.v1.admin.draws.settlement.run', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'permission_codes' => ['settlement.batch.manage']],
['code' => 'admin.settlement-batches.index', 'module_code' => 'settlement', 'name' => '结算批次列表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/settlement-batches', 'route_name' => 'api.v1.admin.settlement-batches.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.payout.manage', 'prd.payout.review', 'prd.payout.view']], ['code' => 'admin.settlement-batches.index', 'module_code' => 'settlement', 'name' => '结算批次列表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/settlement-batches', 'route_name' => 'api.v1.admin.settlement-batches.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.payout.manage', 'prd.payout.review', 'prd.payout.view']],
['code' => 'admin.settlement-batches.show', 'module_code' => 'settlement', 'name' => '结算批次详情', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/settlement-batches/{batch}', 'route_name' => 'api.v1.admin.settlement-batches.show', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.payout.manage', 'prd.payout.review', 'prd.payout.view']], ['code' => 'admin.settlement-batches.show', 'module_code' => 'settlement', 'name' => '结算批次详情', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/settlement-batches/{batch}', 'route_name' => 'api.v1.admin.settlement-batches.show', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.payout.manage', 'prd.payout.review', 'prd.payout.view']],
@@ -550,6 +551,7 @@ final class AdminAuthorizationRegistry
['code' => 'admin.players.store', 'module_code' => 'player_service', 'name' => '创建玩家', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/players', 'route_name' => 'api.v1.admin.players.store', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'permission_codes' => ['service.players.manage']], ['code' => 'admin.players.store', 'module_code' => 'player_service', 'name' => '创建玩家', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/players', 'route_name' => 'api.v1.admin.players.store', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'permission_codes' => ['service.players.manage']],
['code' => 'admin.players.show', 'module_code' => 'player_service', 'name' => '玩家详情', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/players/{player}', 'route_name' => 'api.v1.admin.players.show', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['service.players.manage', 'service.players.view']], ['code' => 'admin.players.show', 'module_code' => 'player_service', 'name' => '玩家详情', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/players/{player}', 'route_name' => 'api.v1.admin.players.show', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['service.players.manage', 'service.players.view']],
['code' => 'admin.players.update', 'module_code' => 'player_service', 'name' => '更新玩家', 'http_method' => 'PUT', 'uri_pattern' => '/api/v1/admin/players/{player}', 'route_name' => 'api.v1.admin.players.update', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'permission_codes' => ['service.players.manage']], ['code' => 'admin.players.update', 'module_code' => 'player_service', 'name' => '更新玩家', 'http_method' => 'PUT', 'uri_pattern' => '/api/v1/admin/players/{player}', 'route_name' => 'api.v1.admin.players.update', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'permission_codes' => ['service.players.manage']],
['code' => 'admin.players.password.reset', 'module_code' => 'player_service', 'name' => '重置玩家密码', 'http_method' => 'PUT', 'uri_pattern' => '/api/v1/admin/players/{player}/password', 'route_name' => 'api.v1.admin.players.password.reset', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'permission_codes' => ['service.players.manage']],
['code' => 'admin.players.destroy', 'module_code' => 'player_service', 'name' => '删除玩家', 'http_method' => 'DELETE', 'uri_pattern' => '/api/v1/admin/players/{player}', 'route_name' => 'api.v1.admin.players.destroy', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'permission_codes' => ['service.players.manage']], ['code' => 'admin.players.destroy', 'module_code' => 'player_service', 'name' => '删除玩家', 'http_method' => 'DELETE', 'uri_pattern' => '/api/v1/admin/players/{player}', 'route_name' => 'api.v1.admin.players.destroy', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'permission_codes' => ['service.players.manage']],
['code' => 'admin.players.freeze', 'module_code' => 'player_service', 'name' => '冻结玩家', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/players/{player}/freeze', 'route_name' => 'api.v1.admin.players.freeze', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'permission_codes' => ['service.players.freeze']], ['code' => 'admin.players.freeze', 'module_code' => 'player_service', 'name' => '冻结玩家', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/players/{player}/freeze', 'route_name' => 'api.v1.admin.players.freeze', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'permission_codes' => ['service.players.freeze']],
['code' => 'admin.players.unfreeze', 'module_code' => 'player_service', 'name' => '解冻玩家', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/players/{player}/unfreeze', 'route_name' => 'api.v1.admin.players.unfreeze', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'permission_codes' => ['service.players.freeze']], ['code' => 'admin.players.unfreeze', 'module_code' => 'player_service', 'name' => '解冻玩家', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/players/{player}/unfreeze', 'route_name' => 'api.v1.admin.players.unfreeze', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'permission_codes' => ['service.players.freeze']],

View File

@@ -0,0 +1,48 @@
<?php
namespace App\Support;
use App\Models\AdminUser;
use App\Models\ReportJob;
use Illuminate\Database\Eloquent\Builder;
/** 报表导出任务的所有权与报表类型能力门禁。 */
final class AdminReportJobPolicy
{
/**
* @param Builder<ReportJob> $query
*/
public static function applyToJobsQuery(Builder $query, AdminUser $admin): void
{
if ($admin->isSuperAdmin()) {
return;
}
$query->where('admin_user_id', (int) $admin->getKey());
}
public static function jobAccessible(AdminUser $admin, ReportJob $job): bool
{
if ($admin->isSuperAdmin()) {
return true;
}
return $job->admin_user_id !== null
&& (int) $job->admin_user_id === (int) $admin->getKey();
}
public static function canExportReportType(AdminUser $admin, string $reportType): bool
{
if (! $admin->hasPermissionCode('service.report.export')) {
return false;
}
return match ($reportType) {
'audit_operation_report' => $admin->hasPermissionCode('service.audit.view'),
// 风险池没有可靠的站点归属快照;在可证明隔离前只允许全局超管导出。
'hot_number_risk_report', 'sold_out_number_report' => $admin->isSuperAdmin()
&& $admin->hasPermissionCode('risk.monitor.view'),
default => true,
};
}
}

View File

@@ -7,8 +7,25 @@ use App\Models\AdminRole;
final class AdminRoleApiPresenter final class AdminRoleApiPresenter
{ {
/** @return array<string, mixed> */ /** @return array<string, mixed> */
public static function item(AdminRole $role): array public static function item(AdminRole $role, ?array $userCounts = null): array
{ {
if ($userCounts !== null) {
$counts = $userCounts;
} elseif (($role->scope_type ?? AdminRole::SCOPE_SYSTEM) === AdminRole::SCOPE_SYSTEM) {
$counts = AdminRoleUserCounts::forRoleIds([$role->id])[(int) $role->id] ?? [
'user_count' => 0,
'platform_user_count' => 0,
'agent_user_count' => 0,
];
} else {
$assignedCount = $role->assignedUserCount();
$counts = [
'user_count' => $assignedCount,
'platform_user_count' => 0,
'agent_user_count' => $assignedCount,
];
}
return [ return [
'id' => (int) $role->id, 'id' => (int) $role->id,
'slug' => $role->slug, 'slug' => $role->slug,
@@ -22,7 +39,9 @@ final class AdminRoleApiPresenter
'delegated_from_role_id' => $role->delegated_from_role_id !== null ? (int) $role->delegated_from_role_id : null, 'delegated_from_role_id' => $role->delegated_from_role_id !== null ? (int) $role->delegated_from_role_id : null,
'is_read_only_template' => $role->isReadOnlyTemplate(), 'is_read_only_template' => $role->isReadOnlyTemplate(),
'permission_slugs' => $role->legacyPermissionSlugs(), 'permission_slugs' => $role->legacyPermissionSlugs(),
'user_count' => $role->assignedUserCount(), 'user_count' => $counts['user_count'],
'platform_user_count' => $counts['platform_user_count'],
'agent_user_count' => $counts['agent_user_count'],
]; ];
} }
} }

View File

@@ -0,0 +1,46 @@
<?php
namespace App\Support;
use Illuminate\Support\Facades\DB;
final class AdminRoleUserCounts
{
/**
* @param iterable<int> $roleIds
* @return array<int, array{user_count: int, platform_user_count: int, agent_user_count: int}>
*/
public static function forRoleIds(iterable $roleIds): array
{
$ids = collect($roleIds)
->map(static fn ($id): int => (int) $id)
->unique()
->values();
if ($ids->isEmpty()) {
return [];
}
return DB::table('admin_user_site_roles as usr')
->leftJoin('admin_user_agents as uag', 'uag.admin_user_id', '=', 'usr.admin_user_id')
->whereIn('usr.role_id', $ids->all())
->groupBy('usr.role_id')
->selectRaw(
'usr.role_id, '
.'COUNT(DISTINCT CASE WHEN uag.admin_user_id IS NULL THEN usr.admin_user_id END) AS platform_user_count, '
.'COUNT(DISTINCT CASE WHEN uag.admin_user_id IS NOT NULL THEN usr.admin_user_id END) AS agent_user_count'
)
->get()
->mapWithKeys(static function (object $row): array {
$platformCount = (int) $row->platform_user_count;
$agentCount = (int) $row->agent_user_count;
return [(int) $row->role_id => [
'user_count' => $platformCount + $agentCount,
'platform_user_count' => $platformCount,
'agent_user_count' => $agentCount,
]];
})
->all();
}
}

View File

@@ -2,9 +2,9 @@
namespace App\Support; namespace App\Support;
use App\Models\Player;
use App\Models\AuditLog; use App\Models\AuditLog;
use App\Models\AdminUser; use App\Models\AdminUser;
use App\Models\Player;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Contracts\Pagination\LengthAwarePaginator;
@@ -59,6 +59,8 @@ final class AuditLogApiPresenter
'sync_permissions' => '同步权限', 'sync_permissions' => '同步权限',
'batch_update' => '批量更新设置', 'batch_update' => '批量更新设置',
'payout_adjustment' => '派彩调整', 'payout_adjustment' => '派彩调整',
'auto_payout' => '自动派彩成功',
'auto_payout_failed' => '自动派彩失败',
'rotate_secrets' => '轮换密钥', 'rotate_secrets' => '轮换密钥',
'toggle_active' => '切换启用状态', 'toggle_active' => '切换启用状态',
'enqueue' => '提交报表导出', 'enqueue' => '提交报表导出',
@@ -88,6 +90,7 @@ final class AuditLogApiPresenter
'play_config_item' => '玩法', 'play_config_item' => '玩法',
'play_config_version' => '玩法配置版本', 'play_config_version' => '玩法配置版本',
'settlement_batch_adjustment' => '结算调整单', 'settlement_batch_adjustment' => '结算调整单',
'settlement_batch' => '结算批次',
'report_job' => '报表任务', 'report_job' => '报表任务',
'reconcile_job' => '对账任务', 'reconcile_job' => '对账任务',
'transfer_no' => '转账单', 'transfer_no' => '转账单',

View File

@@ -0,0 +1,43 @@
<?php
namespace App\Support\Integration;
use Illuminate\Support\Facades\Http;
use Illuminate\Http\Client\PendingRequest;
final readonly class GuardedWalletApiEndpoint
{
public function __construct(
public string $baseUrl,
public string $hostname,
public int $port,
public string $pinnedIp,
public int $totalTimeoutSeconds,
public int $connectTimeoutSeconds,
) {}
/** @param array<string, string> $headers */
public function request(array $headers = []): PendingRequest
{
$pending = Http::withHeaders($headers)
->withoutRedirecting()
->connectTimeout($this->connectTimeoutSeconds)
->timeout($this->totalTimeoutSeconds)
// A proxy would resolve the hostname independently and defeat DNS pinning.
->withOptions(['proxy' => '']);
if (filter_var($this->hostname, FILTER_VALIDATE_IP) === false) {
$address = filter_var($this->pinnedIp, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)
? '['.$this->pinnedIp.']'
: $this->pinnedIp;
$pending->withOptions([
'curl' => [
CURLOPT_RESOLVE => [$this->hostname.':'.$this->port.':'.$address],
],
]);
}
return $pending;
}
}

View File

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

View File

@@ -0,0 +1,74 @@
<?php
namespace App\Support\Integration;
use App\Contracts\WalletApiDnsResolver;
final readonly class WalletApiRequestGuard
{
private const MAX_CONNECT_TIMEOUT_SECONDS = 5;
public function __construct(
private WalletApiDnsResolver $dnsResolver,
) {}
public function guard(?string $rawBaseUrl, int $timeoutSeconds): ?GuardedWalletApiEndpoint
{
$baseUrl = WalletApiUrlSanitizer::normalizeAndValidate($rawBaseUrl);
if ($baseUrl === null) {
return null;
}
$parts = parse_url($baseUrl);
if (! is_array($parts) || ! is_string($parts['host'] ?? null)) {
return null;
}
$hostname = trim((string) $parts['host'], '[]');
$port = isset($parts['port']) ? (int) $parts['port'] : 443;
if (filter_var($hostname, FILTER_VALIDATE_IP) !== false) {
$addresses = [$hostname];
} else {
// Pinning is required for hostnames; without cURL, a second DNS lookup could rebind.
if (! extension_loaded('curl')
|| ! defined('CURLOPT_RESOLVE')
|| (! function_exists('curl_exec') && ! function_exists('curl_multi_exec'))
) {
return null;
}
try {
$addresses = $this->dnsResolver->resolveAll($hostname);
} catch (\Throwable) {
return null;
}
}
$addresses = array_values(array_unique(array_filter(
$addresses,
static fn (mixed $address): bool => is_string($address) && $address !== '',
)));
if ($addresses === []) {
return null;
}
foreach ($addresses as $address) {
if (! WalletApiUrlSanitizer::isPublicIp($address)) {
return null;
}
}
$totalTimeoutSeconds = max(1, $timeoutSeconds);
return new GuardedWalletApiEndpoint(
baseUrl: $baseUrl,
hostname: $hostname,
port: $port,
pinnedIp: $addresses[0],
totalTimeoutSeconds: $totalTimeoutSeconds,
connectTimeoutSeconds: min(self::MAX_CONNECT_TIMEOUT_SECONDS, $totalTimeoutSeconds),
);
}
}

View File

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

View File

@@ -0,0 +1,32 @@
<?php
namespace App\Support;
use App\Models\AdminRole;
use Illuminate\Support\Facades\DB;
final class InvalidPlatformAgentRoleCleanup
{
public static function run(): int
{
$agentRoleId = DB::table('admin_roles')
->where('scope_type', AdminRole::SCOPE_SYSTEM)
->where('slug', PlatformSystemRoles::SLUG_AGENT)
->value('id');
if ($agentRoleId === null) {
return 0;
}
return DB::table('admin_user_site_roles')
->where('role_id', (int) $agentRoleId)
->whereNotExists(static function ($query): void {
$query->selectRaw('1')
->from('admin_user_agents as cleanup_uag')
->join('agent_nodes as cleanup_node', 'cleanup_node.id', '=', 'cleanup_uag.agent_node_id')
->whereColumn('cleanup_uag.admin_user_id', 'admin_user_site_roles.admin_user_id')
->whereColumn('cleanup_node.admin_site_id', 'admin_user_site_roles.site_id');
})
->delete();
}
}

View File

@@ -10,24 +10,24 @@
*/ */
use App\Lottery\ErrorCode; use App\Lottery\ErrorCode;
use App\Support\ApiResponse;
use App\Support\ApiMessage; use App\Support\ApiMessage;
use App\Support\ApiValidationErrors;
use Illuminate\Http\Request;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use App\Support\ApiResponse;
use Illuminate\Http\Request;
use App\Support\LotteryLocale; use App\Support\LotteryLocale;
use App\Support\ApiValidationErrors;
use Illuminate\Foundation\Application; use Illuminate\Foundation\Application;
use App\Http\Middleware\EnsureAdminApi; use App\Http\Middleware\EnsureAdminApi;
use App\Http\Middleware\EnsurePlayerApi; use App\Http\Middleware\EnsurePlayerApi;
use App\Http\Middleware\RecordAdminApiAudit;
use Illuminate\Console\Scheduling\Schedule; use Illuminate\Console\Scheduling\Schedule;
use App\Http\Middleware\RecordAdminApiAudit;
use Illuminate\Auth\AuthenticationException; use Illuminate\Auth\AuthenticationException;
use App\Http\Middleware\EnsureAdminApiResourcePermission;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
use App\Http\Middleware\NegotiateLotteryLocale; use App\Http\Middleware\NegotiateLotteryLocale;
use Illuminate\Foundation\Configuration\Exceptions; use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware; use Illuminate\Foundation\Configuration\Middleware;
use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Database\Eloquent\ModelNotFoundException;
use App\Http\Middleware\EnsureAdminApiResourcePermission;
use Symfony\Component\HttpKernel\Exception\HttpException; use Symfony\Component\HttpKernel\Exception\HttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\HttpKernel\Exception\TooManyRequestsHttpException; use Symfony\Component\HttpKernel\Exception\TooManyRequestsHttpException;
@@ -38,9 +38,15 @@ return Application::configure(basePath: dirname(__DIR__))
// 自动加前缀 `api` + middleware `api`,见 routes/api.php // 自动加前缀 `api` + middleware `api`,见 routes/api.php
api: __DIR__.'/../routes/api.php', api: __DIR__.'/../routes/api.php',
commands: __DIR__.'/../routes/console.php', commands: __DIR__.'/../routes/console.php',
channels: __DIR__.'/../routes/channels.php',
health: '/up', health: '/up',
) )
->withBroadcasting(
__DIR__.'/../routes/channels.php',
[
'prefix' => 'api',
'middleware' => ['api', 'lottery.player'],
],
)
->withMiddleware(function (Middleware $middleware): void { ->withMiddleware(function (Middleware $middleware): void {
// 多语言:必须在其他 api 中间件之前执行,以便鉴权失败时也能按语言返回 msg // 多语言:必须在其他 api 中间件之前执行,以便鉴权失败时也能按语言返回 msg
$middleware->api(prepend: [ $middleware->api(prepend: [
@@ -106,7 +112,7 @@ return Application::configure(basePath: dirname(__DIR__))
); );
}); });
$exceptions->render(function (\InvalidArgumentException $e, Request $request) use ($locale) { $exceptions->render(function (InvalidArgumentException $e, Request $request) use ($locale) {
if (! $request->is('api/*')) { if (! $request->is('api/*')) {
return null; return null;
} }
@@ -173,7 +179,7 @@ return Application::configure(basePath: dirname(__DIR__))
); );
}); });
$exceptions->render(function (HttpException $e, Request $request) use ($locale) { $exceptions->render(function (HttpException $e, Request $request) {
if (! $request->is('api/*')) { if (! $request->is('api/*')) {
return null; return null;
} }
@@ -261,10 +267,14 @@ return Application::configure(basePath: dirname(__DIR__))
$withSingleServerLock($schedule->command('settlement:mark-overdue-bills --days=7') $withSingleServerLock($schedule->command('settlement:mark-overdue-bills --days=7')
->dailyAt('02:00') ->dailyAt('02:00')
->withoutOverlapping()); ->withoutOverlapping());
/** @see docs/01-界面文档.md §2.1 `draw.countdown` */ /**
* @see docs/01-界面文档.md §2.1 `draw.countdown`
* 前端按服务器时间每秒本地计时;后端每 5 秒校准快照和期号边界,
* 避免每秒重复启动完整 Artisan 进程。
*/
if (config('lottery.realtime_hall_countdown', true)) { if (config('lottery.realtime_hall_countdown', true)) {
$withSingleServerLock($schedule->command('lottery:hall-countdown') $withSingleServerLock($schedule->command('lottery:hall-countdown')
->everySecond() ->everyFiveSeconds()
->withoutOverlapping(expiresAt: 5)); ->withoutOverlapping(expiresAt: 5));
} }
}) })

View File

@@ -10,8 +10,8 @@
"license": "MIT", "license": "MIT",
"require": { "require": {
"php": "^8.3", "php": "^8.3",
"firebase/php-jwt": "^6.11", "firebase/php-jwt": "^7.1",
"laravel/framework": "^13.7", "laravel/framework": "^13.21",
"laravel/reverb": "^1.10", "laravel/reverb": "^1.10",
"laravel/sanctum": "^4.3", "laravel/sanctum": "^4.3",
"laravel/tinker": "^3.0", "laravel/tinker": "^3.0",
@@ -101,6 +101,9 @@
} }
}, },
"config": { "config": {
"platform": {
"php": "8.3.0"
},
"optimize-autoloader": true, "optimize-autoloader": true,
"preferred-install": "dist", "preferred-install": "dist",
"sort-packages": true, "sort-packages": true,

2196
composer.lock generated
View File

@@ -4,22 +4,26 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "1574f70ce48caf9305956ad1513280ca", "content-hash": "2f6c814ed1a3cfad7de8857b9630837c",
"packages": [ "packages": [
{ {
"name": "brick/math", "name": "brick/math",
"version": "0.14.8", "version": "0.18.0",
"source": {
"type": "git",
"url": "https://github.com/brick/math.git",
"reference": "82944324d1c1bdb2c2618e89978d4e2ad78d69ad"
},
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://mirrors.cloud.tencent.com/repository/composer/brick/math/0.14.8/brick-math-0.14.8.zip", "url": "https://api.github.com/repos/brick/math/zipball/82944324d1c1bdb2c2618e89978d4e2ad78d69ad",
"reference": "63422359a44b7f06cae63c3b429b59e8efcc0629", "reference": "82944324d1c1bdb2c2618e89978d4e2ad78d69ad",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
"php": "^8.2" "php": "^8.2"
}, },
"require-dev": { "require-dev": {
"php-coveralls/php-coveralls": "^2.2",
"phpstan/phpstan": "2.1.22", "phpstan/phpstan": "2.1.22",
"phpunit/phpunit": "^11.5" "phpunit/phpunit": "^11.5"
}, },
@@ -29,6 +33,7 @@
"Brick\\Math\\": "src/" "Brick\\Math\\": "src/"
} }
}, },
"notification-url": "https://packagist.org/downloads/",
"license": [ "license": [
"MIT" "MIT"
], ],
@@ -50,16 +55,27 @@
], ],
"support": { "support": {
"issues": "https://github.com/brick/math/issues", "issues": "https://github.com/brick/math/issues",
"source": "https://github.com/brick/math/tree/0.14.8" "source": "https://github.com/brick/math/tree/0.18.0"
}, },
"time": "2026-02-10T14:33:43+00:00" "funding": [
{
"url": "https://github.com/BenMorel",
"type": "github"
}
],
"time": "2026-06-14T18:21:03+00:00"
}, },
{ {
"name": "carbonphp/carbon-doctrine-types", "name": "carbonphp/carbon-doctrine-types",
"version": "3.2.0", "version": "3.2.0",
"source": {
"type": "git",
"url": "https://github.com/CarbonPHP/carbon-doctrine-types.git",
"reference": "18ba5ddfec8976260ead6e866180bd5d2f71aa1d"
},
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://mirrors.cloud.tencent.com/repository/composer/carbonphp/carbon-doctrine-types/3.2.0/carbonphp-carbon-doctrine-types-3.2.0.zip", "url": "https://api.github.com/repos/CarbonPHP/carbon-doctrine-types/zipball/18ba5ddfec8976260ead6e866180bd5d2f71aa1d",
"reference": "18ba5ddfec8976260ead6e866180bd5d2f71aa1d", "reference": "18ba5ddfec8976260ead6e866180bd5d2f71aa1d",
"shasum": "" "shasum": ""
}, },
@@ -80,6 +96,7 @@
"Carbon\\Doctrine\\": "src/Carbon/Doctrine/" "Carbon\\Doctrine\\": "src/Carbon/Doctrine/"
} }
}, },
"notification-url": "https://packagist.org/downloads/",
"license": [ "license": [
"MIT" "MIT"
], ],
@@ -101,6 +118,20 @@
"issues": "https://github.com/CarbonPHP/carbon-doctrine-types/issues", "issues": "https://github.com/CarbonPHP/carbon-doctrine-types/issues",
"source": "https://github.com/CarbonPHP/carbon-doctrine-types/tree/3.2.0" "source": "https://github.com/CarbonPHP/carbon-doctrine-types/tree/3.2.0"
}, },
"funding": [
{
"url": "https://github.com/kylekatarnls",
"type": "github"
},
{
"url": "https://opencollective.com/Carbon",
"type": "open_collective"
},
{
"url": "https://tidelift.com/funding/github/packagist/nesbot/carbon",
"type": "tidelift"
}
],
"time": "2024-02-09T16:56:22+00:00" "time": "2024-02-09T16:56:22+00:00"
}, },
{ {
@@ -295,9 +326,14 @@
{ {
"name": "dflydev/dot-access-data", "name": "dflydev/dot-access-data",
"version": "v3.0.3", "version": "v3.0.3",
"source": {
"type": "git",
"url": "https://github.com/dflydev/dflydev-dot-access-data.git",
"reference": "a23a2bf4f31d3518f3ecb38660c95715dfead60f"
},
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://mirrors.cloud.tencent.com/repository/composer/dflydev/dot-access-data/v3.0.3/dflydev-dot-access-data-v3.0.3.zip", "url": "https://api.github.com/repos/dflydev/dflydev-dot-access-data/zipball/a23a2bf4f31d3518f3ecb38660c95715dfead60f",
"reference": "a23a2bf4f31d3518f3ecb38660c95715dfead60f", "reference": "a23a2bf4f31d3518f3ecb38660c95715dfead60f",
"shasum": "" "shasum": ""
}, },
@@ -322,6 +358,7 @@
"Dflydev\\DotAccessData\\": "src/" "Dflydev\\DotAccessData\\": "src/"
} }
}, },
"notification-url": "https://packagist.org/downloads/",
"license": [ "license": [
"MIT" "MIT"
], ],
@@ -364,9 +401,14 @@
{ {
"name": "doctrine/inflector", "name": "doctrine/inflector",
"version": "2.1.0", "version": "2.1.0",
"source": {
"type": "git",
"url": "https://github.com/doctrine/inflector.git",
"reference": "6d6c96277ea252fc1304627204c3d5e6e15faa3b"
},
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://mirrors.cloud.tencent.com/repository/composer/doctrine/inflector/2.1.0/doctrine-inflector-2.1.0.zip", "url": "https://api.github.com/repos/doctrine/inflector/zipball/6d6c96277ea252fc1304627204c3d5e6e15faa3b",
"reference": "6d6c96277ea252fc1304627204c3d5e6e15faa3b", "reference": "6d6c96277ea252fc1304627204c3d5e6e15faa3b",
"shasum": "" "shasum": ""
}, },
@@ -386,6 +428,7 @@
"Doctrine\\Inflector\\": "src" "Doctrine\\Inflector\\": "src"
} }
}, },
"notification-url": "https://packagist.org/downloads/",
"license": [ "license": [
"MIT" "MIT"
], ],
@@ -429,14 +472,33 @@
"issues": "https://github.com/doctrine/inflector/issues", "issues": "https://github.com/doctrine/inflector/issues",
"source": "https://github.com/doctrine/inflector/tree/2.1.0" "source": "https://github.com/doctrine/inflector/tree/2.1.0"
}, },
"funding": [
{
"url": "https://www.doctrine-project.org/sponsorship.html",
"type": "custom"
},
{
"url": "https://www.patreon.com/phpdoctrine",
"type": "patreon"
},
{
"url": "https://tidelift.com/funding/github/packagist/doctrine%2Finflector",
"type": "tidelift"
}
],
"time": "2025-08-10T19:31:58+00:00" "time": "2025-08-10T19:31:58+00:00"
}, },
{ {
"name": "doctrine/lexer", "name": "doctrine/lexer",
"version": "3.0.1", "version": "3.0.1",
"source": {
"type": "git",
"url": "https://github.com/doctrine/lexer.git",
"reference": "31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd"
},
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://mirrors.cloud.tencent.com/repository/composer/doctrine/lexer/3.0.1/doctrine-lexer-3.0.1.zip", "url": "https://api.github.com/repos/doctrine/lexer/zipball/31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd",
"reference": "31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd", "reference": "31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd",
"shasum": "" "shasum": ""
}, },
@@ -456,6 +518,7 @@
"Doctrine\\Common\\Lexer\\": "src" "Doctrine\\Common\\Lexer\\": "src"
} }
}, },
"notification-url": "https://packagist.org/downloads/",
"license": [ "license": [
"MIT" "MIT"
], ],
@@ -486,14 +549,33 @@
"issues": "https://github.com/doctrine/lexer/issues", "issues": "https://github.com/doctrine/lexer/issues",
"source": "https://github.com/doctrine/lexer/tree/3.0.1" "source": "https://github.com/doctrine/lexer/tree/3.0.1"
}, },
"funding": [
{
"url": "https://www.doctrine-project.org/sponsorship.html",
"type": "custom"
},
{
"url": "https://www.patreon.com/phpdoctrine",
"type": "patreon"
},
{
"url": "https://tidelift.com/funding/github/packagist/doctrine%2Flexer",
"type": "tidelift"
}
],
"time": "2024-02-05T11:56:58+00:00" "time": "2024-02-05T11:56:58+00:00"
}, },
{ {
"name": "dragonmantank/cron-expression", "name": "dragonmantank/cron-expression",
"version": "v3.6.0", "version": "v3.6.0",
"source": {
"type": "git",
"url": "https://github.com/dragonmantank/cron-expression.git",
"reference": "d61a8a9604ec1f8c3d150d09db6ce98b32675013"
},
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://mirrors.cloud.tencent.com/repository/composer/dragonmantank/cron-expression/v3.6.0/dragonmantank-cron-expression-v3.6.0.zip", "url": "https://api.github.com/repos/dragonmantank/cron-expression/zipball/d61a8a9604ec1f8c3d150d09db6ce98b32675013",
"reference": "d61a8a9604ec1f8c3d150d09db6ce98b32675013", "reference": "d61a8a9604ec1f8c3d150d09db6ce98b32675013",
"shasum": "" "shasum": ""
}, },
@@ -519,6 +601,7 @@
"Cron\\": "src/Cron/" "Cron\\": "src/Cron/"
} }
}, },
"notification-url": "https://packagist.org/downloads/",
"license": [ "license": [
"MIT" "MIT"
], ],
@@ -538,14 +621,25 @@
"issues": "https://github.com/dragonmantank/cron-expression/issues", "issues": "https://github.com/dragonmantank/cron-expression/issues",
"source": "https://github.com/dragonmantank/cron-expression/tree/v3.6.0" "source": "https://github.com/dragonmantank/cron-expression/tree/v3.6.0"
}, },
"funding": [
{
"url": "https://github.com/dragonmantank",
"type": "github"
}
],
"time": "2025-10-31T18:51:33+00:00" "time": "2025-10-31T18:51:33+00:00"
}, },
{ {
"name": "egulias/email-validator", "name": "egulias/email-validator",
"version": "4.0.4", "version": "4.0.4",
"source": {
"type": "git",
"url": "https://github.com/egulias/EmailValidator.git",
"reference": "d42c8731f0624ad6bdc8d3e5e9a4524f68801cfa"
},
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://mirrors.cloud.tencent.com/repository/composer/egulias/email-validator/4.0.4/egulias-email-validator-4.0.4.zip", "url": "https://api.github.com/repos/egulias/EmailValidator/zipball/d42c8731f0624ad6bdc8d3e5e9a4524f68801cfa",
"reference": "d42c8731f0624ad6bdc8d3e5e9a4524f68801cfa", "reference": "d42c8731f0624ad6bdc8d3e5e9a4524f68801cfa",
"shasum": "" "shasum": ""
}, },
@@ -572,6 +666,7 @@
"Egulias\\EmailValidator\\": "src" "Egulias\\EmailValidator\\": "src"
} }
}, },
"notification-url": "https://packagist.org/downloads/",
"license": [ "license": [
"MIT" "MIT"
], ],
@@ -593,6 +688,12 @@
"issues": "https://github.com/egulias/EmailValidator/issues", "issues": "https://github.com/egulias/EmailValidator/issues",
"source": "https://github.com/egulias/EmailValidator/tree/4.0.4" "source": "https://github.com/egulias/EmailValidator/tree/4.0.4"
}, },
"funding": [
{
"url": "https://github.com/egulias",
"type": "github"
}
],
"time": "2025-03-06T22:45:56+00:00" "time": "2025-03-06T22:45:56+00:00"
}, },
{ {
@@ -644,16 +745,16 @@
}, },
{ {
"name": "firebase/php-jwt", "name": "firebase/php-jwt",
"version": "v6.11.1", "version": "v7.1.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/googleapis/php-jwt.git", "url": "https://github.com/googleapis/php-jwt.git",
"reference": "d1e91ecf8c598d073d0995afa8cd5c75c6e19e66" "reference": "b374a5d1a4f1f67fadc2165cdb284645945e2fc0"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/googleapis/php-jwt/zipball/d1e91ecf8c598d073d0995afa8cd5c75c6e19e66", "url": "https://api.github.com/repos/googleapis/php-jwt/zipball/b374a5d1a4f1f67fadc2165cdb284645945e2fc0",
"reference": "d1e91ecf8c598d073d0995afa8cd5c75c6e19e66", "reference": "b374a5d1a4f1f67fadc2165cdb284645945e2fc0",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -661,6 +762,8 @@
}, },
"require-dev": { "require-dev": {
"guzzlehttp/guzzle": "^7.4", "guzzlehttp/guzzle": "^7.4",
"phpfastcache/phpfastcache": "^9.2",
"phpseclib/phpseclib": "~3.0",
"phpspec/prophecy-phpunit": "^2.0", "phpspec/prophecy-phpunit": "^2.0",
"phpunit/phpunit": "^9.5", "phpunit/phpunit": "^9.5",
"psr/cache": "^2.0||^3.0", "psr/cache": "^2.0||^3.0",
@@ -669,7 +772,8 @@
}, },
"suggest": { "suggest": {
"ext-sodium": "Support EdDSA (Ed25519) signatures", "ext-sodium": "Support EdDSA (Ed25519) signatures",
"paragonie/sodium_compat": "Support EdDSA (Ed25519) signatures when libsodium is not present" "paragonie/sodium_compat": "Support EdDSA (Ed25519) signatures when libsodium is not present",
"phpseclib/phpseclib": "Support PS256 (RSASSA-PSS) signatures"
}, },
"type": "library", "type": "library",
"autoload": { "autoload": {
@@ -694,23 +798,28 @@
} }
], ],
"description": "A simple library to encode and decode JSON Web Tokens (JWT) in PHP. Should conform to the current spec.", "description": "A simple library to encode and decode JSON Web Tokens (JWT) in PHP. Should conform to the current spec.",
"homepage": "https://github.com/firebase/php-jwt", "homepage": "https://github.com/googleapis/php-jwt",
"keywords": [ "keywords": [
"jwt", "jwt",
"php" "php"
], ],
"support": { "support": {
"issues": "https://github.com/googleapis/php-jwt/issues", "issues": "https://github.com/googleapis/php-jwt/issues",
"source": "https://github.com/googleapis/php-jwt/tree/v6.11.1" "source": "https://github.com/googleapis/php-jwt/tree/v7.1.0"
}, },
"time": "2025-04-09T20:32:01+00:00" "time": "2026-06-11T17:54:14+00:00"
}, },
{ {
"name": "fruitcake/php-cors", "name": "fruitcake/php-cors",
"version": "v1.4.0", "version": "v1.4.0",
"source": {
"type": "git",
"url": "https://github.com/fruitcake/php-cors.git",
"reference": "38aaa6c3fd4c157ffe2a4d10aa8b9b16ba8de379"
},
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://mirrors.cloud.tencent.com/repository/composer/fruitcake/php-cors/v1.4.0/fruitcake-php-cors-v1.4.0.zip", "url": "https://api.github.com/repos/fruitcake/php-cors/zipball/38aaa6c3fd4c157ffe2a4d10aa8b9b16ba8de379",
"reference": "38aaa6c3fd4c157ffe2a4d10aa8b9b16ba8de379", "reference": "38aaa6c3fd4c157ffe2a4d10aa8b9b16ba8de379",
"shasum": "" "shasum": ""
}, },
@@ -734,6 +843,7 @@
"Fruitcake\\Cors\\": "src/" "Fruitcake\\Cors\\": "src/"
} }
}, },
"notification-url": "https://packagist.org/downloads/",
"license": [ "license": [
"MIT" "MIT"
], ],
@@ -758,14 +868,29 @@
"issues": "https://github.com/fruitcake/php-cors/issues", "issues": "https://github.com/fruitcake/php-cors/issues",
"source": "https://github.com/fruitcake/php-cors/tree/v1.4.0" "source": "https://github.com/fruitcake/php-cors/tree/v1.4.0"
}, },
"funding": [
{
"url": "https://fruitcake.nl",
"type": "custom"
},
{
"url": "https://github.com/barryvdh",
"type": "github"
}
],
"time": "2025-12-03T09:33:47+00:00" "time": "2025-12-03T09:33:47+00:00"
}, },
{ {
"name": "graham-campbell/result-type", "name": "graham-campbell/result-type",
"version": "v1.1.4", "version": "v1.1.4",
"source": {
"type": "git",
"url": "https://github.com/GrahamCampbell/Result-Type.git",
"reference": "e01f4a821471308ba86aa202fed6698b6b695e3b"
},
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://mirrors.cloud.tencent.com/repository/composer/graham-campbell/result-type/v1.1.4/graham-campbell-result-type-v1.1.4.zip", "url": "https://api.github.com/repos/GrahamCampbell/Result-Type/zipball/e01f4a821471308ba86aa202fed6698b6b695e3b",
"reference": "e01f4a821471308ba86aa202fed6698b6b695e3b", "reference": "e01f4a821471308ba86aa202fed6698b6b695e3b",
"shasum": "" "shasum": ""
}, },
@@ -782,6 +907,7 @@
"GrahamCampbell\\ResultType\\": "src/" "GrahamCampbell\\ResultType\\": "src/"
} }
}, },
"notification-url": "https://packagist.org/downloads/",
"license": [ "license": [
"MIT" "MIT"
], ],
@@ -804,24 +930,40 @@
"issues": "https://github.com/GrahamCampbell/Result-Type/issues", "issues": "https://github.com/GrahamCampbell/Result-Type/issues",
"source": "https://github.com/GrahamCampbell/Result-Type/tree/v1.1.4" "source": "https://github.com/GrahamCampbell/Result-Type/tree/v1.1.4"
}, },
"funding": [
{
"url": "https://github.com/GrahamCampbell",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/graham-campbell/result-type",
"type": "tidelift"
}
],
"time": "2025-12-27T19:43:20+00:00" "time": "2025-12-27T19:43:20+00:00"
}, },
{ {
"name": "guzzlehttp/guzzle", "name": "guzzlehttp/guzzle",
"version": "7.10.0", "version": "7.15.1",
"source": {
"type": "git",
"url": "https://github.com/guzzle/guzzle.git",
"reference": "61443dfb33c62f308ee8add20f45b4d6e4bf8d2f"
},
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://mirrors.cloud.tencent.com/repository/composer/guzzlehttp/guzzle/7.10.0/guzzlehttp-guzzle-7.10.0.zip", "url": "https://api.github.com/repos/guzzle/guzzle/zipball/61443dfb33c62f308ee8add20f45b4d6e4bf8d2f",
"reference": "b51ac707cfa420b7bfd4e4d5e510ba8008e822b4", "reference": "61443dfb33c62f308ee8add20f45b4d6e4bf8d2f",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
"ext-json": "*", "ext-json": "*",
"guzzlehttp/promises": "^2.3", "guzzlehttp/promises": "^2.5.1",
"guzzlehttp/psr7": "^2.8", "guzzlehttp/psr7": "^2.13",
"php": "^7.2.5 || ^8.0", "php": "^7.2.5 || ^8.0",
"psr/http-client": "^1.0", "psr/http-client": "^1.0",
"symfony/deprecation-contracts": "^2.2 || ^3.0" "symfony/deprecation-contracts": "^2.5 || ^3.0",
"symfony/polyfill-php80": "^1.25"
}, },
"provide": { "provide": {
"psr/http-client-implementation": "1.0" "psr/http-client-implementation": "1.0"
@@ -829,9 +971,10 @@
"require-dev": { "require-dev": {
"bamarni/composer-bin-plugin": "^1.8.2", "bamarni/composer-bin-plugin": "^1.8.2",
"ext-curl": "*", "ext-curl": "*",
"guzzle/client-integration-tests": "3.0.2", "guzzle/client-integration-tests": "3.0.3",
"guzzlehttp/test-server": "^0.7",
"php-http/message-factory": "^1.1", "php-http/message-factory": "^1.1",
"phpunit/phpunit": "^8.5.39 || ^9.6.20", "phpunit/phpunit": "^8.5.52 || ^9.6.34",
"psr/log": "^1.1 || ^2.0 || ^3.0" "psr/log": "^1.1 || ^2.0 || ^3.0"
}, },
"suggest": { "suggest": {
@@ -854,6 +997,7 @@
"GuzzleHttp\\": "src/" "GuzzleHttp\\": "src/"
} }
}, },
"notification-url": "https://packagist.org/downloads/",
"license": [ "license": [
"MIT" "MIT"
], ],
@@ -908,25 +1052,45 @@
], ],
"support": { "support": {
"issues": "https://github.com/guzzle/guzzle/issues", "issues": "https://github.com/guzzle/guzzle/issues",
"source": "https://github.com/guzzle/guzzle/tree/7.10.0" "source": "https://github.com/guzzle/guzzle/tree/7.15.1"
}, },
"time": "2025-08-23T22:36:01+00:00" "funding": [
{
"url": "https://github.com/GrahamCampbell",
"type": "github"
},
{
"url": "https://github.com/Nyholm",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/guzzlehttp/guzzle",
"type": "tidelift"
}
],
"time": "2026-07-18T11:23:11+00:00"
}, },
{ {
"name": "guzzlehttp/promises", "name": "guzzlehttp/promises",
"version": "2.3.0", "version": "2.5.1",
"source": {
"type": "git",
"url": "https://github.com/guzzle/promises.git",
"reference": "9ad1e4fc607446a055b95870c7f668e93b5cff29"
},
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://mirrors.cloud.tencent.com/repository/composer/guzzlehttp/promises/2.3.0/guzzlehttp-promises-2.3.0.zip", "url": "https://api.github.com/repos/guzzle/promises/zipball/9ad1e4fc607446a055b95870c7f668e93b5cff29",
"reference": "481557b130ef3790cf82b713667b43030dc9c957", "reference": "9ad1e4fc607446a055b95870c7f668e93b5cff29",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
"php": "^7.2.5 || ^8.0" "php": "^7.2.5 || ^8.0",
"symfony/deprecation-contracts": "^2.5 || ^3.0"
}, },
"require-dev": { "require-dev": {
"bamarni/composer-bin-plugin": "^1.8.2", "bamarni/composer-bin-plugin": "^1.8.2",
"phpunit/phpunit": "^8.5.44 || ^9.6.25" "phpunit/phpunit": "^8.5.52 || ^9.6.34"
}, },
"type": "library", "type": "library",
"extra": { "extra": {
@@ -940,6 +1104,7 @@
"GuzzleHttp\\Promise\\": "src/" "GuzzleHttp\\Promise\\": "src/"
} }
}, },
"notification-url": "https://packagist.org/downloads/",
"license": [ "license": [
"MIT" "MIT"
], ],
@@ -971,24 +1136,45 @@
], ],
"support": { "support": {
"issues": "https://github.com/guzzle/promises/issues", "issues": "https://github.com/guzzle/promises/issues",
"source": "https://github.com/guzzle/promises/tree/2.3.0" "source": "https://github.com/guzzle/promises/tree/2.5.1"
}, },
"time": "2025-08-22T14:34:08+00:00" "funding": [
{
"url": "https://github.com/GrahamCampbell",
"type": "github"
},
{
"url": "https://github.com/Nyholm",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/guzzlehttp/promises",
"type": "tidelift"
}
],
"time": "2026-07-08T15:48:39+00:00"
}, },
{ {
"name": "guzzlehttp/psr7", "name": "guzzlehttp/psr7",
"version": "2.9.0", "version": "2.13.0",
"source": {
"type": "git",
"url": "https://github.com/guzzle/psr7.git",
"reference": "dad89620b7a6edb60c15858442eb2e408b45d8f4"
},
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://mirrors.cloud.tencent.com/repository/composer/guzzlehttp/psr7/2.9.0/guzzlehttp-psr7-2.9.0.zip", "url": "https://api.github.com/repos/guzzle/psr7/zipball/dad89620b7a6edb60c15858442eb2e408b45d8f4",
"reference": "7d0ed42f28e42d61352a7a79de682e5e67fec884", "reference": "dad89620b7a6edb60c15858442eb2e408b45d8f4",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
"php": "^7.2.5 || ^8.0", "php": "^7.2.5 || ^8.0",
"psr/http-factory": "^1.0", "psr/http-factory": "^1.0",
"psr/http-message": "^1.1 || ^2.0", "psr/http-message": "^1.1 || ^2.0",
"ralouphie/getallheaders": "^3.0" "ralouphie/getallheaders": "^3.0",
"symfony/deprecation-contracts": "^2.5 || ^3.0",
"symfony/polyfill-php80": "^1.25"
}, },
"provide": { "provide": {
"psr/http-factory-implementation": "1.0", "psr/http-factory-implementation": "1.0",
@@ -996,9 +1182,9 @@
}, },
"require-dev": { "require-dev": {
"bamarni/composer-bin-plugin": "^1.8.2", "bamarni/composer-bin-plugin": "^1.8.2",
"http-interop/http-factory-tests": "0.9.0", "http-interop/http-factory-tests": "1.1.0",
"jshttp/mime-db": "1.54.0.1", "jshttp/mime-db": "1.54.0.1",
"phpunit/phpunit": "^8.5.44 || ^9.6.25" "phpunit/phpunit": "^8.5.52 || ^9.6.34"
}, },
"suggest": { "suggest": {
"laminas/laminas-httphandlerrunner": "Emit PSR-7 responses" "laminas/laminas-httphandlerrunner": "Emit PSR-7 responses"
@@ -1015,6 +1201,7 @@
"GuzzleHttp\\Psr7\\": "src/" "GuzzleHttp\\Psr7\\": "src/"
} }
}, },
"notification-url": "https://packagist.org/downloads/",
"license": [ "license": [
"MIT" "MIT"
], ],
@@ -1068,26 +1255,45 @@
], ],
"support": { "support": {
"issues": "https://github.com/guzzle/psr7/issues", "issues": "https://github.com/guzzle/psr7/issues",
"source": "https://github.com/guzzle/psr7/tree/2.9.0" "source": "https://github.com/guzzle/psr7/tree/2.13.0"
}, },
"time": "2026-03-10T16:41:02+00:00" "funding": [
{
"url": "https://github.com/GrahamCampbell",
"type": "github"
},
{
"url": "https://github.com/Nyholm",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/guzzlehttp/psr7",
"type": "tidelift"
}
],
"time": "2026-07-16T22:23:49+00:00"
}, },
{ {
"name": "guzzlehttp/uri-template", "name": "guzzlehttp/uri-template",
"version": "v1.0.5", "version": "v1.0.10",
"source": {
"type": "git",
"url": "https://github.com/guzzle/uri-template.git",
"reference": "f6c24c21f42b990e9a58912b332d0874df6ba839"
},
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://mirrors.cloud.tencent.com/repository/composer/guzzlehttp/uri-template/v1.0.5/guzzlehttp-uri-template-v1.0.5.zip", "url": "https://api.github.com/repos/guzzle/uri-template/zipball/f6c24c21f42b990e9a58912b332d0874df6ba839",
"reference": "4f4bbd4e7172148801e76e3decc1e559bdee34e1", "reference": "f6c24c21f42b990e9a58912b332d0874df6ba839",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
"php": "^7.2.5 || ^8.0", "php": "^7.2.5 || ^8.0",
"symfony/polyfill-php80": "^1.24" "symfony/polyfill-php80": "^1.25"
}, },
"require-dev": { "require-dev": {
"bamarni/composer-bin-plugin": "^1.8.2", "bamarni/composer-bin-plugin": "^1.8.2",
"phpunit/phpunit": "^8.5.44 || ^9.6.25", "phpunit/phpunit": "^8.5.52 || ^9.6.34",
"uri-template/tests": "1.0.0" "uri-template/tests": "1.0.0"
}, },
"type": "library", "type": "library",
@@ -1102,6 +1308,7 @@
"GuzzleHttp\\UriTemplate\\": "src" "GuzzleHttp\\UriTemplate\\": "src"
} }
}, },
"notification-url": "https://packagist.org/downloads/",
"license": [ "license": [
"MIT" "MIT"
], ],
@@ -1134,21 +1341,40 @@
], ],
"support": { "support": {
"issues": "https://github.com/guzzle/uri-template/issues", "issues": "https://github.com/guzzle/uri-template/issues",
"source": "https://github.com/guzzle/uri-template/tree/v1.0.5" "source": "https://github.com/guzzle/uri-template/tree/v1.0.10"
}, },
"time": "2025-08-22T14:27:06+00:00" "funding": [
{
"url": "https://github.com/GrahamCampbell",
"type": "github"
},
{
"url": "https://github.com/Nyholm",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/guzzlehttp/uri-template",
"type": "tidelift"
}
],
"time": "2026-07-17T13:53:03+00:00"
}, },
{ {
"name": "laravel/framework", "name": "laravel/framework",
"version": "v13.8.0", "version": "v13.21.1",
"source": {
"type": "git",
"url": "https://github.com/laravel/framework.git",
"reference": "303b5f8dc899f89e8c38c350b639b8fbd193ed16"
},
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://mirrors.cloud.tencent.com/repository/composer/laravel/framework/v13.8.0/laravel-framework-v13.8.0.zip", "url": "https://api.github.com/repos/laravel/framework/zipball/303b5f8dc899f89e8c38c350b639b8fbd193ed16",
"reference": "e7db333a025a1e93ebca7744953069d7719f4bcf", "reference": "303b5f8dc899f89e8c38c350b639b8fbd193ed16",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
"brick/math": "^0.14.2 || ^0.15 || ^0.16 || ^0.17", "brick/math": "^0.14.2 || ^0.15 || ^0.16 || ^0.17 || ^0.18",
"composer-runtime-api": "^2.2", "composer-runtime-api": "^2.2",
"doctrine/inflector": "^2.0.5", "doctrine/inflector": "^2.0.5",
"dragonmantank/cron-expression": "^3.4", "dragonmantank/cron-expression": "^3.4",
@@ -1223,6 +1449,7 @@
"illuminate/filesystem": "self.version", "illuminate/filesystem": "self.version",
"illuminate/hashing": "self.version", "illuminate/hashing": "self.version",
"illuminate/http": "self.version", "illuminate/http": "self.version",
"illuminate/image": "self.version",
"illuminate/json-schema": "self.version", "illuminate/json-schema": "self.version",
"illuminate/log": "self.version", "illuminate/log": "self.version",
"illuminate/macroable": "self.version", "illuminate/macroable": "self.version",
@@ -1248,7 +1475,8 @@
"aws/aws-sdk-php": "^3.322.9", "aws/aws-sdk-php": "^3.322.9",
"ext-gmp": "*", "ext-gmp": "*",
"fakerphp/faker": "^1.24", "fakerphp/faker": "^1.24",
"guzzlehttp/psr7": "^2.4", "guzzlehttp/psr7": "^2.9",
"intervention/image": "^4.0",
"laravel/pint": "^1.18", "laravel/pint": "^1.18",
"league/flysystem-aws-s3-v3": "^3.25.1", "league/flysystem-aws-s3-v3": "^3.25.1",
"league/flysystem-ftp": "^3.25.1", "league/flysystem-ftp": "^3.25.1",
@@ -1285,6 +1513,7 @@
"ext-redis": "Required to use the Redis cache and queue drivers (^4.0 || ^5.0 || ^6.0).", "ext-redis": "Required to use the Redis cache and queue drivers (^4.0 || ^5.0 || ^6.0).",
"fakerphp/faker": "Required to generate fake data using the fake() helper (^1.23).", "fakerphp/faker": "Required to generate fake data using the fake() helper (^1.23).",
"filp/whoops": "Required for friendly error pages in development (^2.14.3).", "filp/whoops": "Required for friendly error pages in development (^2.14.3).",
"intervention/image": "Required to use the image processing features (^4.0).",
"laravel/tinker": "Required to use the tinker console command (^2.0).", "laravel/tinker": "Required to use the tinker console command (^2.0).",
"league/flysystem-aws-s3-v3": "Required to use the Flysystem S3 driver (^3.25.1).", "league/flysystem-aws-s3-v3": "Required to use the Flysystem S3 driver (^3.25.1).",
"league/flysystem-ftp": "Required to use the Flysystem FTP driver (^3.25.1).", "league/flysystem-ftp": "Required to use the Flysystem FTP driver (^3.25.1).",
@@ -1335,6 +1564,7 @@
] ]
} }
}, },
"notification-url": "https://packagist.org/downloads/",
"license": [ "license": [
"MIT" "MIT"
], ],
@@ -1354,15 +1584,20 @@
"issues": "https://github.com/laravel/framework/issues", "issues": "https://github.com/laravel/framework/issues",
"source": "https://github.com/laravel/framework" "source": "https://github.com/laravel/framework"
}, },
"time": "2026-05-05T21:01:14+00:00" "time": "2026-07-21T14:27:35+00:00"
}, },
{ {
"name": "laravel/prompts", "name": "laravel/prompts",
"version": "v0.3.17", "version": "v0.3.21",
"source": {
"type": "git",
"url": "https://github.com/laravel/prompts.git",
"reference": "7753c65c281c2550c7c183f14e18062073b7d821"
},
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://mirrors.cloud.tencent.com/repository/composer/laravel/prompts/v0.3.17/laravel-prompts-v0.3.17.zip", "url": "https://api.github.com/repos/laravel/prompts/zipball/7753c65c281c2550c7c183f14e18062073b7d821",
"reference": "6a82ac19a28b916ae0885828795dbd4c59d9a818", "reference": "7753c65c281c2550c7c183f14e18062073b7d821",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -1399,15 +1634,16 @@
"Laravel\\Prompts\\": "src/" "Laravel\\Prompts\\": "src/"
} }
}, },
"notification-url": "https://packagist.org/downloads/",
"license": [ "license": [
"MIT" "MIT"
], ],
"description": "Add beautiful and user-friendly forms to your command-line applications.", "description": "Add beautiful and user-friendly forms to your command-line applications.",
"support": { "support": {
"issues": "https://github.com/laravel/prompts/issues", "issues": "https://github.com/laravel/prompts/issues",
"source": "https://github.com/laravel/prompts/tree/v0.3.17" "source": "https://github.com/laravel/prompts/tree/v0.3.21"
}, },
"time": "2026-04-20T16:07:33+00:00" "time": "2026-06-26T00:11:25+00:00"
}, },
{ {
"name": "laravel/reverb", "name": "laravel/reverb",
@@ -1553,11 +1789,16 @@
}, },
{ {
"name": "laravel/serializable-closure", "name": "laravel/serializable-closure",
"version": "v2.0.13", "version": "v2.0.14",
"source": {
"type": "git",
"url": "https://github.com/laravel/serializable-closure.git",
"reference": "97a77d2cc80578c28bccf97829af73db658ffb97"
},
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://mirrors.cloud.tencent.com/repository/composer/laravel/serializable-closure/v2.0.13/laravel-serializable-closure-v2.0.13.zip", "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/97a77d2cc80578c28bccf97829af73db658ffb97",
"reference": "b566ee0dd251f3c4078bed003a7ce015f5ea6dce", "reference": "97a77d2cc80578c28bccf97829af73db658ffb97",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -1581,6 +1822,7 @@
"Laravel\\SerializableClosure\\": "src/" "Laravel\\SerializableClosure\\": "src/"
} }
}, },
"notification-url": "https://packagist.org/downloads/",
"license": [ "license": [
"MIT" "MIT"
], ],
@@ -1604,7 +1846,7 @@
"issues": "https://github.com/laravel/serializable-closure/issues", "issues": "https://github.com/laravel/serializable-closure/issues",
"source": "https://github.com/laravel/serializable-closure" "source": "https://github.com/laravel/serializable-closure"
}, },
"time": "2026-04-16T14:03:50+00:00" "time": "2026-06-24T18:49:39+00:00"
}, },
{ {
"name": "laravel/tinker", "name": "laravel/tinker",
@@ -1671,11 +1913,16 @@
}, },
{ {
"name": "league/commonmark", "name": "league/commonmark",
"version": "2.8.2", "version": "2.8.3",
"source": {
"type": "git",
"url": "https://github.com/thephpleague/commonmark.git",
"reference": "1902f60f984235023acbe03db6ad614a37b3c3e7"
},
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://mirrors.cloud.tencent.com/repository/composer/league/commonmark/2.8.2/league-commonmark-2.8.2.zip", "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/1902f60f984235023acbe03db6ad614a37b3c3e7",
"reference": "59fb075d2101740c337c7216e3f32b36c204218b", "reference": "1902f60f984235023acbe03db6ad614a37b3c3e7",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -1697,8 +1944,8 @@
"github/gfm": "0.29.0", "github/gfm": "0.29.0",
"michelf/php-markdown": "^1.4 || ^2.0", "michelf/php-markdown": "^1.4 || ^2.0",
"nyholm/psr7": "^1.5", "nyholm/psr7": "^1.5",
"phpstan/phpstan": "^1.8.2", "phpstan/phpstan": "^2.0.0",
"phpunit/phpunit": "^9.5.21 || ^10.5.9 || ^11.0.0", "phpunit/phpunit": "^9.5.21 || ^10.5.9 || ^11.0.0 || ^12.0.0 || ^13.0.0",
"scrutinizer/ocular": "^1.8.1", "scrutinizer/ocular": "^1.8.1",
"symfony/finder": "^5.3 | ^6.0 | ^7.0 || ^8.0", "symfony/finder": "^5.3 | ^6.0 | ^7.0 || ^8.0",
"symfony/process": "^5.4 | ^6.0 | ^7.0 || ^8.0", "symfony/process": "^5.4 | ^6.0 | ^7.0 || ^8.0",
@@ -1720,6 +1967,7 @@
"League\\CommonMark\\": "src" "League\\CommonMark\\": "src"
} }
}, },
"notification-url": "https://packagist.org/downloads/",
"license": [ "license": [
"BSD-3-Clause" "BSD-3-Clause"
], ],
@@ -1750,14 +1998,37 @@
"rss": "https://github.com/thephpleague/commonmark/releases.atom", "rss": "https://github.com/thephpleague/commonmark/releases.atom",
"source": "https://github.com/thephpleague/commonmark" "source": "https://github.com/thephpleague/commonmark"
}, },
"time": "2026-03-19T13:16:38+00:00" "funding": [
{
"url": "https://www.colinodell.com/sponsor",
"type": "custom"
},
{
"url": "https://www.paypal.me/colinpodell/10.00",
"type": "custom"
},
{
"url": "https://github.com/colinodell",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/league/commonmark",
"type": "tidelift"
}
],
"time": "2026-07-12T15:29:16+00:00"
}, },
{ {
"name": "league/config", "name": "league/config",
"version": "v1.2.0", "version": "v1.2.0",
"source": {
"type": "git",
"url": "https://github.com/thephpleague/config.git",
"reference": "754b3604fb2984c71f4af4a9cbe7b57f346ec1f3"
},
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://mirrors.cloud.tencent.com/repository/composer/league/config/v1.2.0/league-config-v1.2.0.zip", "url": "https://api.github.com/repos/thephpleague/config/zipball/754b3604fb2984c71f4af4a9cbe7b57f346ec1f3",
"reference": "754b3604fb2984c71f4af4a9cbe7b57f346ec1f3", "reference": "754b3604fb2984c71f4af4a9cbe7b57f346ec1f3",
"shasum": "" "shasum": ""
}, },
@@ -1784,6 +2055,7 @@
"League\\Config\\": "src" "League\\Config\\": "src"
} }
}, },
"notification-url": "https://packagist.org/downloads/",
"license": [ "license": [
"BSD-3-Clause" "BSD-3-Clause"
], ],
@@ -1812,15 +2084,34 @@
"rss": "https://github.com/thephpleague/config/releases.atom", "rss": "https://github.com/thephpleague/config/releases.atom",
"source": "https://github.com/thephpleague/config" "source": "https://github.com/thephpleague/config"
}, },
"funding": [
{
"url": "https://www.colinodell.com/sponsor",
"type": "custom"
},
{
"url": "https://www.paypal.me/colinpodell/10.00",
"type": "custom"
},
{
"url": "https://github.com/colinodell",
"type": "github"
}
],
"time": "2022-12-11T20:36:23+00:00" "time": "2022-12-11T20:36:23+00:00"
}, },
{ {
"name": "league/flysystem", "name": "league/flysystem",
"version": "3.33.0", "version": "3.35.2",
"source": {
"type": "git",
"url": "https://github.com/thephpleague/flysystem.git",
"reference": "b277b5dc3d56650b68904117124e79c851e12376"
},
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://mirrors.cloud.tencent.com/repository/composer/league/flysystem/3.33.0/league-flysystem-3.33.0.zip", "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/b277b5dc3d56650b68904117124e79c851e12376",
"reference": "570b8871e0ce693764434b29154c54b434905350", "reference": "b277b5dc3d56650b68904117124e79c851e12376",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -1862,6 +2153,7 @@
"League\\Flysystem\\": "src" "League\\Flysystem\\": "src"
} }
}, },
"notification-url": "https://packagist.org/downloads/",
"license": [ "license": [
"MIT" "MIT"
], ],
@@ -1887,16 +2179,21 @@
], ],
"support": { "support": {
"issues": "https://github.com/thephpleague/flysystem/issues", "issues": "https://github.com/thephpleague/flysystem/issues",
"source": "https://github.com/thephpleague/flysystem/tree/3.33.0" "source": "https://github.com/thephpleague/flysystem/tree/3.35.2"
}, },
"time": "2026-03-25T07:59:30+00:00" "time": "2026-07-06T14:42:07+00:00"
}, },
{ {
"name": "league/flysystem-local", "name": "league/flysystem-local",
"version": "3.31.0", "version": "3.31.0",
"source": {
"type": "git",
"url": "https://github.com/thephpleague/flysystem-local.git",
"reference": "2f669db18a4c20c755c2bb7d3a7b0b2340488079"
},
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://mirrors.cloud.tencent.com/repository/composer/league/flysystem-local/3.31.0/league-flysystem-local-3.31.0.zip", "url": "https://api.github.com/repos/thephpleague/flysystem-local/zipball/2f669db18a4c20c755c2bb7d3a7b0b2340488079",
"reference": "2f669db18a4c20c755c2bb7d3a7b0b2340488079", "reference": "2f669db18a4c20c755c2bb7d3a7b0b2340488079",
"shasum": "" "shasum": ""
}, },
@@ -1912,6 +2209,7 @@
"League\\Flysystem\\Local\\": "" "League\\Flysystem\\Local\\": ""
} }
}, },
"notification-url": "https://packagist.org/downloads/",
"license": [ "license": [
"MIT" "MIT"
], ],
@@ -1936,11 +2234,16 @@
}, },
{ {
"name": "league/mime-type-detection", "name": "league/mime-type-detection",
"version": "1.16.0", "version": "1.17.0",
"source": {
"type": "git",
"url": "https://github.com/thephpleague/mime-type-detection.git",
"reference": "f5f47eff7c48ed1003069a2ca67f316fb4021c76"
},
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://mirrors.cloud.tencent.com/repository/composer/league/mime-type-detection/1.16.0/league-mime-type-detection-1.16.0.zip", "url": "https://api.github.com/repos/thephpleague/mime-type-detection/zipball/f5f47eff7c48ed1003069a2ca67f316fb4021c76",
"reference": "2d6702ff215bf922936ccc1ad31007edc76451b9", "reference": "f5f47eff7c48ed1003069a2ca67f316fb4021c76",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -1950,7 +2253,7 @@
"require-dev": { "require-dev": {
"friendsofphp/php-cs-fixer": "^3.2", "friendsofphp/php-cs-fixer": "^3.2",
"phpstan/phpstan": "^0.12.68", "phpstan/phpstan": "^0.12.68",
"phpunit/phpunit": "^8.5.8 || ^9.3 || ^10.0" "phpunit/phpunit": "^8.5.8 || ^9.3 || ^10.0 || ^11.0 || ^12.0"
}, },
"type": "library", "type": "library",
"autoload": { "autoload": {
@@ -1958,6 +2261,7 @@
"League\\MimeTypeDetection\\": "src" "League\\MimeTypeDetection\\": "src"
} }
}, },
"notification-url": "https://packagist.org/downloads/",
"license": [ "license": [
"MIT" "MIT"
], ],
@@ -1970,16 +2274,31 @@
"description": "Mime-type detection for Flysystem", "description": "Mime-type detection for Flysystem",
"support": { "support": {
"issues": "https://github.com/thephpleague/mime-type-detection/issues", "issues": "https://github.com/thephpleague/mime-type-detection/issues",
"source": "https://github.com/thephpleague/mime-type-detection/tree/1.16.0" "source": "https://github.com/thephpleague/mime-type-detection/tree/1.17.0"
}, },
"time": "2024-09-21T08:32:55+00:00" "funding": [
{
"url": "https://github.com/frankdejonge",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/league/flysystem",
"type": "tidelift"
}
],
"time": "2026-07-09T11:49:27+00:00"
}, },
{ {
"name": "league/uri", "name": "league/uri",
"version": "7.8.1", "version": "7.8.1",
"source": {
"type": "git",
"url": "https://github.com/thephpleague/uri.git",
"reference": "08cf38e3924d4f56238125547b5720496fac8fd4"
},
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://mirrors.cloud.tencent.com/repository/composer/league/uri/7.8.1/league-uri-7.8.1.zip", "url": "https://api.github.com/repos/thephpleague/uri/zipball/08cf38e3924d4f56238125547b5720496fac8fd4",
"reference": "08cf38e3924d4f56238125547b5720496fac8fd4", "reference": "08cf38e3924d4f56238125547b5720496fac8fd4",
"shasum": "" "shasum": ""
}, },
@@ -2016,6 +2335,7 @@
"League\\Uri\\": "" "League\\Uri\\": ""
} }
}, },
"notification-url": "https://packagist.org/downloads/",
"license": [ "license": [
"MIT" "MIT"
], ],
@@ -2058,14 +2378,25 @@
"issues": "https://github.com/thephpleague/uri-src/issues", "issues": "https://github.com/thephpleague/uri-src/issues",
"source": "https://github.com/thephpleague/uri/tree/7.8.1" "source": "https://github.com/thephpleague/uri/tree/7.8.1"
}, },
"funding": [
{
"url": "https://github.com/sponsors/nyamsprod",
"type": "github"
}
],
"time": "2026-03-15T20:22:25+00:00" "time": "2026-03-15T20:22:25+00:00"
}, },
{ {
"name": "league/uri-interfaces", "name": "league/uri-interfaces",
"version": "7.8.1", "version": "7.8.1",
"source": {
"type": "git",
"url": "https://github.com/thephpleague/uri-interfaces.git",
"reference": "85d5c77c5d6d3af6c54db4a78246364908f3c928"
},
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://mirrors.cloud.tencent.com/repository/composer/league/uri-interfaces/7.8.1/league-uri-interfaces-7.8.1.zip", "url": "https://api.github.com/repos/thephpleague/uri-interfaces/zipball/85d5c77c5d6d3af6c54db4a78246364908f3c928",
"reference": "85d5c77c5d6d3af6c54db4a78246364908f3c928", "reference": "85d5c77c5d6d3af6c54db4a78246364908f3c928",
"shasum": "" "shasum": ""
}, },
@@ -2093,6 +2424,7 @@
"League\\Uri\\": "" "League\\Uri\\": ""
} }
}, },
"notification-url": "https://packagist.org/downloads/",
"license": [ "license": [
"MIT" "MIT"
], ],
@@ -2130,6 +2462,12 @@
"issues": "https://github.com/thephpleague/uri-src/issues", "issues": "https://github.com/thephpleague/uri-src/issues",
"source": "https://github.com/thephpleague/uri-interfaces/tree/7.8.1" "source": "https://github.com/thephpleague/uri-interfaces/tree/7.8.1"
}, },
"funding": [
{
"url": "https://github.com/sponsors/nyamsprod",
"type": "github"
}
],
"time": "2026-03-08T20:05:35+00:00" "time": "2026-03-08T20:05:35+00:00"
}, },
{ {
@@ -2320,9 +2658,14 @@
{ {
"name": "monolog/monolog", "name": "monolog/monolog",
"version": "3.10.0", "version": "3.10.0",
"source": {
"type": "git",
"url": "https://github.com/Seldaek/monolog.git",
"reference": "b321dd6749f0bf7189444158a3ce785cc16d69b0"
},
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://mirrors.cloud.tencent.com/repository/composer/monolog/monolog/3.10.0/monolog-monolog-3.10.0.zip", "url": "https://api.github.com/repos/Seldaek/monolog/zipball/b321dd6749f0bf7189444158a3ce785cc16d69b0",
"reference": "b321dd6749f0bf7189444158a3ce785cc16d69b0", "reference": "b321dd6749f0bf7189444158a3ce785cc16d69b0",
"shasum": "" "shasum": ""
}, },
@@ -2381,6 +2724,7 @@
"Monolog\\": "src/Monolog" "Monolog\\": "src/Monolog"
} }
}, },
"notification-url": "https://packagist.org/downloads/",
"license": [ "license": [
"MIT" "MIT"
], ],
@@ -2402,15 +2746,30 @@
"issues": "https://github.com/Seldaek/monolog/issues", "issues": "https://github.com/Seldaek/monolog/issues",
"source": "https://github.com/Seldaek/monolog/tree/3.10.0" "source": "https://github.com/Seldaek/monolog/tree/3.10.0"
}, },
"funding": [
{
"url": "https://github.com/Seldaek",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/monolog/monolog",
"type": "tidelift"
}
],
"time": "2026-01-02T08:56:05+00:00" "time": "2026-01-02T08:56:05+00:00"
}, },
{ {
"name": "nesbot/carbon", "name": "nesbot/carbon",
"version": "3.11.4", "version": "3.13.1",
"source": {
"type": "git",
"url": "https://github.com/CarbonPHP/carbon.git",
"reference": "2937ad3d1d2c506fd2bc97d571438a95641f44e2"
},
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://mirrors.cloud.tencent.com/repository/composer/nesbot/carbon/3.11.4/nesbot-carbon-3.11.4.zip", "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/2937ad3d1d2c506fd2bc97d571438a95641f44e2",
"reference": "e890471a3494740f7d9326d72ce6a8c559ffee60", "reference": "2937ad3d1d2c506fd2bc97d571438a95641f44e2",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -2461,6 +2820,7 @@
"Carbon\\": "src/Carbon/" "Carbon\\": "src/Carbon/"
} }
}, },
"notification-url": "https://packagist.org/downloads/",
"license": [ "license": [
"MIT" "MIT"
], ],
@@ -2487,14 +2847,33 @@
"issues": "https://github.com/CarbonPHP/carbon/issues", "issues": "https://github.com/CarbonPHP/carbon/issues",
"source": "https://github.com/CarbonPHP/carbon" "source": "https://github.com/CarbonPHP/carbon"
}, },
"time": "2026-04-07T09:57:54+00:00" "funding": [
{
"url": "https://github.com/sponsors/kylekatarnls",
"type": "github"
},
{
"url": "https://opencollective.com/Carbon#sponsor",
"type": "opencollective"
},
{
"url": "https://tidelift.com/subscription/pkg/packagist-nesbot-carbon?utm_source=packagist-nesbot-carbon&utm_medium=referral&utm_campaign=readme",
"type": "tidelift"
}
],
"time": "2026-07-09T18:23:49+00:00"
}, },
{ {
"name": "nette/schema", "name": "nette/schema",
"version": "v1.3.5", "version": "v1.3.5",
"source": {
"type": "git",
"url": "https://github.com/nette/schema.git",
"reference": "f0ab1a3cda782dbc5da270d28545236aa80c4002"
},
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://mirrors.cloud.tencent.com/repository/composer/nette/schema/v1.3.5/nette-schema-v1.3.5.zip", "url": "https://api.github.com/repos/nette/schema/zipball/f0ab1a3cda782dbc5da270d28545236aa80c4002",
"reference": "f0ab1a3cda782dbc5da270d28545236aa80c4002", "reference": "f0ab1a3cda782dbc5da270d28545236aa80c4002",
"shasum": "" "shasum": ""
}, },
@@ -2523,6 +2902,7 @@
"src/" "src/"
] ]
}, },
"notification-url": "https://packagist.org/downloads/",
"license": [ "license": [
"BSD-3-Clause", "BSD-3-Clause",
"GPL-2.0-only", "GPL-2.0-only",
@@ -2552,11 +2932,16 @@
}, },
{ {
"name": "nette/utils", "name": "nette/utils",
"version": "v4.1.3", "version": "v4.1.5",
"source": {
"type": "git",
"url": "https://github.com/nette/utils.git",
"reference": "b043439dbdf954e6c28b5ea7e34b0100f83165e0"
},
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://mirrors.cloud.tencent.com/repository/composer/nette/utils/v4.1.3/nette-utils-v4.1.3.zip", "url": "https://api.github.com/repos/nette/utils/zipball/b043439dbdf954e6c28b5ea7e34b0100f83165e0",
"reference": "bb3ea637e3d131d72acc033cfc2746ee893349fe", "reference": "b043439dbdf954e6c28b5ea7e34b0100f83165e0",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -2576,7 +2961,7 @@
}, },
"suggest": { "suggest": {
"ext-gd": "to use Image", "ext-gd": "to use Image",
"ext-iconv": "to use Strings::webalize(), toAscii(), chr() and reverse()", "ext-iconv": "to use Strings::chr(), ord() and reverse()",
"ext-intl": "to use Strings::webalize(), toAscii(), normalize() and compare()", "ext-intl": "to use Strings::webalize(), toAscii(), normalize() and compare()",
"ext-json": "to use Nette\\Utils\\Json", "ext-json": "to use Nette\\Utils\\Json",
"ext-mbstring": "to use Strings::lower() etc...", "ext-mbstring": "to use Strings::lower() etc...",
@@ -2596,6 +2981,7 @@
"src/" "src/"
] ]
}, },
"notification-url": "https://packagist.org/downloads/",
"license": [ "license": [
"BSD-3-Clause", "BSD-3-Clause",
"GPL-2.0-only", "GPL-2.0-only",
@@ -2631,9 +3017,9 @@
], ],
"support": { "support": {
"issues": "https://github.com/nette/utils/issues", "issues": "https://github.com/nette/utils/issues",
"source": "https://github.com/nette/utils/tree/v4.1.3" "source": "https://github.com/nette/utils/tree/v4.1.5"
}, },
"time": "2026-02-13T03:05:33+00:00" "time": "2026-07-17T23:02:45+00:00"
}, },
{ {
"name": "nikic/php-parser", "name": "nikic/php-parser",
@@ -2690,9 +3076,14 @@
{ {
"name": "nunomaduro/termwind", "name": "nunomaduro/termwind",
"version": "v2.4.0", "version": "v2.4.0",
"source": {
"type": "git",
"url": "https://github.com/nunomaduro/termwind.git",
"reference": "712a31b768f5daea284c2169a7d227031001b9a8"
},
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://mirrors.cloud.tencent.com/repository/composer/nunomaduro/termwind/v2.4.0/nunomaduro-termwind-v2.4.0.zip", "url": "https://api.github.com/repos/nunomaduro/termwind/zipball/712a31b768f5daea284c2169a7d227031001b9a8",
"reference": "712a31b768f5daea284c2169a7d227031001b9a8", "reference": "712a31b768f5daea284c2169a7d227031001b9a8",
"shasum": "" "shasum": ""
}, },
@@ -2730,6 +3121,7 @@
"Termwind\\": "src/" "Termwind\\": "src/"
} }
}, },
"notification-url": "https://packagist.org/downloads/",
"license": [ "license": [
"MIT" "MIT"
], ],
@@ -2752,6 +3144,20 @@
"issues": "https://github.com/nunomaduro/termwind/issues", "issues": "https://github.com/nunomaduro/termwind/issues",
"source": "https://github.com/nunomaduro/termwind/tree/v2.4.0" "source": "https://github.com/nunomaduro/termwind/tree/v2.4.0"
}, },
"funding": [
{
"url": "https://www.paypal.com/paypalme/enunomaduro",
"type": "custom"
},
{
"url": "https://github.com/nunomaduro",
"type": "github"
},
{
"url": "https://github.com/xiCO2k",
"type": "github"
}
],
"time": "2026-02-16T23:10:27+00:00" "time": "2026-02-16T23:10:27+00:00"
}, },
{ {
@@ -2959,9 +3365,14 @@
{ {
"name": "phpoption/phpoption", "name": "phpoption/phpoption",
"version": "1.9.5", "version": "1.9.5",
"source": {
"type": "git",
"url": "https://github.com/schmittjoh/php-option.git",
"reference": "75365b91986c2405cf5e1e012c5595cd487a98be"
},
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://mirrors.cloud.tencent.com/repository/composer/phpoption/phpoption/1.9.5/phpoption-phpoption-1.9.5.zip", "url": "https://api.github.com/repos/schmittjoh/php-option/zipball/75365b91986c2405cf5e1e012c5595cd487a98be",
"reference": "75365b91986c2405cf5e1e012c5595cd487a98be", "reference": "75365b91986c2405cf5e1e012c5595cd487a98be",
"shasum": "" "shasum": ""
}, },
@@ -2987,6 +3398,7 @@
"PhpOption\\": "src/PhpOption/" "PhpOption\\": "src/PhpOption/"
} }
}, },
"notification-url": "https://packagist.org/downloads/",
"license": [ "license": [
"Apache-2.0" "Apache-2.0"
], ],
@@ -3013,14 +3425,29 @@
"issues": "https://github.com/schmittjoh/php-option/issues", "issues": "https://github.com/schmittjoh/php-option/issues",
"source": "https://github.com/schmittjoh/php-option/tree/1.9.5" "source": "https://github.com/schmittjoh/php-option/tree/1.9.5"
}, },
"funding": [
{
"url": "https://github.com/GrahamCampbell",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/phpoption/phpoption",
"type": "tidelift"
}
],
"time": "2025-12-27T19:41:33+00:00" "time": "2025-12-27T19:41:33+00:00"
}, },
{ {
"name": "psr/clock", "name": "psr/clock",
"version": "1.0.0", "version": "1.0.0",
"source": {
"type": "git",
"url": "https://github.com/php-fig/clock.git",
"reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d"
},
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://mirrors.cloud.tencent.com/repository/composer/psr/clock/1.0.0/psr-clock-1.0.0.zip", "url": "https://api.github.com/repos/php-fig/clock/zipball/e41a24703d4560fd0acb709162f73b8adfc3aa0d",
"reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d", "reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d",
"shasum": "" "shasum": ""
}, },
@@ -3033,6 +3460,7 @@
"Psr\\Clock\\": "src/" "Psr\\Clock\\": "src/"
} }
}, },
"notification-url": "https://packagist.org/downloads/",
"license": [ "license": [
"MIT" "MIT"
], ],
@@ -3060,9 +3488,14 @@
{ {
"name": "psr/container", "name": "psr/container",
"version": "2.0.2", "version": "2.0.2",
"source": {
"type": "git",
"url": "https://github.com/php-fig/container.git",
"reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963"
},
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://mirrors.cloud.tencent.com/repository/composer/psr/container/2.0.2/psr-container-2.0.2.zip", "url": "https://api.github.com/repos/php-fig/container/zipball/c71ecc56dfe541dbd90c5360474fbc405f8d5963",
"reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963", "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963",
"shasum": "" "shasum": ""
}, },
@@ -3080,6 +3513,7 @@
"Psr\\Container\\": "src/" "Psr\\Container\\": "src/"
} }
}, },
"notification-url": "https://packagist.org/downloads/",
"license": [ "license": [
"MIT" "MIT"
], ],
@@ -3107,9 +3541,14 @@
{ {
"name": "psr/event-dispatcher", "name": "psr/event-dispatcher",
"version": "1.0.0", "version": "1.0.0",
"source": {
"type": "git",
"url": "https://github.com/php-fig/event-dispatcher.git",
"reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0"
},
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://mirrors.cloud.tencent.com/repository/composer/psr/event-dispatcher/1.0.0/psr-event-dispatcher-1.0.0.zip", "url": "https://api.github.com/repos/php-fig/event-dispatcher/zipball/dbefd12671e8a14ec7f180cab83036ed26714bb0",
"reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0", "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0",
"shasum": "" "shasum": ""
}, },
@@ -3127,6 +3566,7 @@
"Psr\\EventDispatcher\\": "src/" "Psr\\EventDispatcher\\": "src/"
} }
}, },
"notification-url": "https://packagist.org/downloads/",
"license": [ "license": [
"MIT" "MIT"
], ],
@@ -3151,9 +3591,14 @@
{ {
"name": "psr/http-client", "name": "psr/http-client",
"version": "1.0.3", "version": "1.0.3",
"source": {
"type": "git",
"url": "https://github.com/php-fig/http-client.git",
"reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90"
},
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://mirrors.cloud.tencent.com/repository/composer/psr/http-client/1.0.3/psr-http-client-1.0.3.zip", "url": "https://api.github.com/repos/php-fig/http-client/zipball/bb5906edc1c324c9a05aa0873d40117941e5fa90",
"reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90", "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90",
"shasum": "" "shasum": ""
}, },
@@ -3172,6 +3617,7 @@
"Psr\\Http\\Client\\": "src/" "Psr\\Http\\Client\\": "src/"
} }
}, },
"notification-url": "https://packagist.org/downloads/",
"license": [ "license": [
"MIT" "MIT"
], ],
@@ -3197,9 +3643,14 @@
{ {
"name": "psr/http-factory", "name": "psr/http-factory",
"version": "1.1.0", "version": "1.1.0",
"source": {
"type": "git",
"url": "https://github.com/php-fig/http-factory.git",
"reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a"
},
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://mirrors.cloud.tencent.com/repository/composer/psr/http-factory/1.1.0/psr-http-factory-1.1.0.zip", "url": "https://api.github.com/repos/php-fig/http-factory/zipball/2b4765fddfe3b508ac62f829e852b1501d3f6e8a",
"reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a", "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a",
"shasum": "" "shasum": ""
}, },
@@ -3218,6 +3669,7 @@
"Psr\\Http\\Message\\": "src/" "Psr\\Http\\Message\\": "src/"
} }
}, },
"notification-url": "https://packagist.org/downloads/",
"license": [ "license": [
"MIT" "MIT"
], ],
@@ -3246,9 +3698,14 @@
{ {
"name": "psr/http-message", "name": "psr/http-message",
"version": "2.0", "version": "2.0",
"source": {
"type": "git",
"url": "https://github.com/php-fig/http-message.git",
"reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71"
},
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://mirrors.cloud.tencent.com/repository/composer/psr/http-message/2.0/psr-http-message-2.0.zip", "url": "https://api.github.com/repos/php-fig/http-message/zipball/402d35bcb92c70c026d1a6a9883f06b2ead23d71",
"reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71", "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71",
"shasum": "" "shasum": ""
}, },
@@ -3266,6 +3723,7 @@
"Psr\\Http\\Message\\": "src/" "Psr\\Http\\Message\\": "src/"
} }
}, },
"notification-url": "https://packagist.org/downloads/",
"license": [ "license": [
"MIT" "MIT"
], ],
@@ -3293,9 +3751,14 @@
{ {
"name": "psr/log", "name": "psr/log",
"version": "3.0.2", "version": "3.0.2",
"source": {
"type": "git",
"url": "https://github.com/php-fig/log.git",
"reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3"
},
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://mirrors.cloud.tencent.com/repository/composer/psr/log/3.0.2/psr-log-3.0.2.zip", "url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3",
"reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3",
"shasum": "" "shasum": ""
}, },
@@ -3313,6 +3776,7 @@
"Psr\\Log\\": "src" "Psr\\Log\\": "src"
} }
}, },
"notification-url": "https://packagist.org/downloads/",
"license": [ "license": [
"MIT" "MIT"
], ],
@@ -3337,9 +3801,14 @@
{ {
"name": "psr/simple-cache", "name": "psr/simple-cache",
"version": "3.0.0", "version": "3.0.0",
"source": {
"type": "git",
"url": "https://github.com/php-fig/simple-cache.git",
"reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865"
},
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://mirrors.cloud.tencent.com/repository/composer/psr/simple-cache/3.0.0/psr-simple-cache-3.0.0.zip", "url": "https://api.github.com/repos/php-fig/simple-cache/zipball/764e0b3939f5ca87cb904f570ef9be2d78a07865",
"reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865", "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865",
"shasum": "" "shasum": ""
}, },
@@ -3357,6 +3826,7 @@
"Psr\\SimpleCache\\": "src/" "Psr\\SimpleCache\\": "src/"
} }
}, },
"notification-url": "https://packagist.org/downloads/",
"license": [ "license": [
"MIT" "MIT"
], ],
@@ -3516,9 +3986,14 @@
{ {
"name": "ralouphie/getallheaders", "name": "ralouphie/getallheaders",
"version": "3.0.3", "version": "3.0.3",
"source": {
"type": "git",
"url": "https://github.com/ralouphie/getallheaders.git",
"reference": "120b605dfeb996808c31b6477290a714d356e822"
},
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://mirrors.cloud.tencent.com/repository/composer/ralouphie/getallheaders/3.0.3/ralouphie-getallheaders-3.0.3.zip", "url": "https://api.github.com/repos/ralouphie/getallheaders/zipball/120b605dfeb996808c31b6477290a714d356e822",
"reference": "120b605dfeb996808c31b6477290a714d356e822", "reference": "120b605dfeb996808c31b6477290a714d356e822",
"shasum": "" "shasum": ""
}, },
@@ -3535,6 +4010,7 @@
"src/getallheaders.php" "src/getallheaders.php"
] ]
}, },
"notification-url": "https://packagist.org/downloads/",
"license": [ "license": [
"MIT" "MIT"
], ],
@@ -3554,9 +4030,14 @@
{ {
"name": "ramsey/collection", "name": "ramsey/collection",
"version": "2.1.1", "version": "2.1.1",
"source": {
"type": "git",
"url": "https://github.com/ramsey/collection.git",
"reference": "344572933ad0181accbf4ba763e85a0306a8c5e2"
},
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://mirrors.cloud.tencent.com/repository/composer/ramsey/collection/2.1.1/ramsey-collection-2.1.1.zip", "url": "https://api.github.com/repos/ramsey/collection/zipball/344572933ad0181accbf4ba763e85a0306a8c5e2",
"reference": "344572933ad0181accbf4ba763e85a0306a8c5e2", "reference": "344572933ad0181accbf4ba763e85a0306a8c5e2",
"shasum": "" "shasum": ""
}, },
@@ -3596,6 +4077,7 @@
"Ramsey\\Collection\\": "src/" "Ramsey\\Collection\\": "src/"
} }
}, },
"notification-url": "https://packagist.org/downloads/",
"license": [ "license": [
"MIT" "MIT"
], ],
@@ -3623,15 +4105,20 @@
}, },
{ {
"name": "ramsey/uuid", "name": "ramsey/uuid",
"version": "4.9.2", "version": "4.9.3",
"source": {
"type": "git",
"url": "https://github.com/ramsey/uuid.git",
"reference": "1df15849d00943a67d677dc9cfd80795f038c9f8"
},
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://mirrors.cloud.tencent.com/repository/composer/ramsey/uuid/4.9.2/ramsey-uuid-4.9.2.zip", "url": "https://api.github.com/repos/ramsey/uuid/zipball/1df15849d00943a67d677dc9cfd80795f038c9f8",
"reference": "8429c78ca35a09f27565311b98101e2826affde0", "reference": "1df15849d00943a67d677dc9cfd80795f038c9f8",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
"brick/math": "^0.8.16 || ^0.9 || ^0.10 || ^0.11 || ^0.12 || ^0.13 || ^0.14", "brick/math": ">=0.8.16 <=0.18",
"php": "^8.0", "php": "^8.0",
"ramsey/collection": "^1.2 || ^2.0" "ramsey/collection": "^1.2 || ^2.0"
}, },
@@ -3678,6 +4165,7 @@
"Ramsey\\Uuid\\": "src/" "Ramsey\\Uuid\\": "src/"
} }
}, },
"notification-url": "https://packagist.org/downloads/",
"license": [ "license": [
"MIT" "MIT"
], ],
@@ -3689,9 +4177,9 @@
], ],
"support": { "support": {
"issues": "https://github.com/ramsey/uuid/issues", "issues": "https://github.com/ramsey/uuid/issues",
"source": "https://github.com/ramsey/uuid/tree/4.9.2" "source": "https://github.com/ramsey/uuid/tree/4.9.3"
}, },
"time": "2025-12-14T04:43:48+00:00" "time": "2026-06-18T03:57:49+00:00"
}, },
{ {
"name": "ratchet/rfc6455", "name": "ratchet/rfc6455",
@@ -4284,16 +4772,22 @@
}, },
{ {
"name": "symfony/clock", "name": "symfony/clock",
"version": "v8.0.8", "version": "v7.4.8",
"source": {
"type": "git",
"url": "https://github.com/symfony/clock.git",
"reference": "674fa3b98e21531dd040e613479f5f6fa8f32111"
},
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://mirrors.cloud.tencent.com/repository/composer/symfony/clock/v8.0.8/symfony-clock-v8.0.8.zip", "url": "https://api.github.com/repos/symfony/clock/zipball/674fa3b98e21531dd040e613479f5f6fa8f32111",
"reference": "b55a638b189a6faa875e0ccdb00908fb87af95b3", "reference": "674fa3b98e21531dd040e613479f5f6fa8f32111",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
"php": ">=8.4", "php": ">=8.2",
"psr/clock": "^1.0" "psr/clock": "^1.0",
"symfony/polyfill-php83": "^1.28"
}, },
"provide": { "provide": {
"psr/clock-implementation": "1.0" "psr/clock-implementation": "1.0"
@@ -4310,6 +4804,7 @@
"/Tests/" "/Tests/"
] ]
}, },
"notification-url": "https://packagist.org/downloads/",
"license": [ "license": [
"MIT" "MIT"
], ],
@@ -4331,40 +4826,71 @@
"time" "time"
], ],
"support": { "support": {
"source": "https://github.com/symfony/clock/tree/v8.0.8" "source": "https://github.com/symfony/clock/tree/v7.4.8"
}, },
"time": "2026-03-30T15:14:47+00:00" "funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2026-03-24T13:12:05+00:00"
}, },
{ {
"name": "symfony/console", "name": "symfony/console",
"version": "v8.0.9", "version": "v7.4.14",
"source": {
"type": "git",
"url": "https://github.com/symfony/console.git",
"reference": "92f58bc4bf97a92ed1b9f367f0cd44f20bde0e87"
},
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://mirrors.cloud.tencent.com/repository/composer/symfony/console/v8.0.9/symfony-console-v8.0.9.zip", "url": "https://api.github.com/repos/symfony/console/zipball/92f58bc4bf97a92ed1b9f367f0cd44f20bde0e87",
"reference": "7113778e2e91f4709cb3194a75dfa9c0d028d94d", "reference": "92f58bc4bf97a92ed1b9f367f0cd44f20bde0e87",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
"php": ">=8.4", "php": ">=8.2",
"symfony/polyfill-mbstring": "^1.0", "symfony/deprecation-contracts": "^2.5|^3",
"symfony/polyfill-mbstring": "~1.0",
"symfony/service-contracts": "^2.5|^3", "symfony/service-contracts": "^2.5|^3",
"symfony/string": "^7.4|^8.0" "symfony/string": "^7.2|^8.0"
},
"conflict": {
"symfony/dependency-injection": "<6.4",
"symfony/dotenv": "<6.4",
"symfony/event-dispatcher": "<6.4",
"symfony/lock": "<6.4",
"symfony/process": "<6.4"
}, },
"provide": { "provide": {
"psr/log-implementation": "1.0|2.0|3.0" "psr/log-implementation": "1.0|2.0|3.0"
}, },
"require-dev": { "require-dev": {
"psr/log": "^1|^2|^3", "psr/log": "^1|^2|^3",
"symfony/config": "^7.4|^8.0", "symfony/config": "^6.4|^7.0|^8.0",
"symfony/dependency-injection": "^7.4|^8.0", "symfony/dependency-injection": "^6.4|^7.0|^8.0",
"symfony/event-dispatcher": "^7.4|^8.0", "symfony/event-dispatcher": "^6.4|^7.0|^8.0",
"symfony/http-foundation": "^7.4|^8.0", "symfony/http-foundation": "^6.4|^7.0|^8.0",
"symfony/http-kernel": "^7.4|^8.0", "symfony/http-kernel": "^6.4|^7.0|^8.0",
"symfony/lock": "^7.4|^8.0", "symfony/lock": "^6.4|^7.0|^8.0",
"symfony/messenger": "^7.4|^8.0", "symfony/messenger": "^6.4|^7.0|^8.0",
"symfony/process": "^7.4|^8.0", "symfony/process": "^6.4|^7.0|^8.0",
"symfony/stopwatch": "^7.4|^8.0", "symfony/stopwatch": "^6.4|^7.0|^8.0",
"symfony/var-dumper": "^7.4|^8.0" "symfony/var-dumper": "^6.4|^7.0|^8.0"
}, },
"type": "library", "type": "library",
"autoload": { "autoload": {
@@ -4375,6 +4901,7 @@
"/Tests/" "/Tests/"
] ]
}, },
"notification-url": "https://packagist.org/downloads/",
"license": [ "license": [
"MIT" "MIT"
], ],
@@ -4397,21 +4924,44 @@
"terminal" "terminal"
], ],
"support": { "support": {
"source": "https://github.com/symfony/console/tree/v8.0.9" "source": "https://github.com/symfony/console/tree/v7.4.14"
}, },
"time": "2026-04-29T15:02:55+00:00" "funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2026-06-16T11:50:14+00:00"
}, },
{ {
"name": "symfony/css-selector", "name": "symfony/css-selector",
"version": "v8.0.9", "version": "v7.4.9",
"source": {
"type": "git",
"url": "https://github.com/symfony/css-selector.git",
"reference": "b75663ed96cf4756e28e3105476f220f92886cc4"
},
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://mirrors.cloud.tencent.com/repository/composer/symfony/css-selector/v8.0.9/symfony-css-selector-v8.0.9.zip", "url": "https://api.github.com/repos/symfony/css-selector/zipball/b75663ed96cf4756e28e3105476f220f92886cc4",
"reference": "3665cfade90565430909b906394c73c8739e57d0", "reference": "b75663ed96cf4756e28e3105476f220f92886cc4",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
"php": ">=8.4" "php": ">=8.2"
}, },
"type": "library", "type": "library",
"autoload": { "autoload": {
@@ -4422,6 +4972,7 @@
"/Tests/" "/Tests/"
] ]
}, },
"notification-url": "https://packagist.org/downloads/",
"license": [ "license": [
"MIT" "MIT"
], ],
@@ -4442,17 +4993,40 @@
"description": "Converts CSS selectors to XPath expressions", "description": "Converts CSS selectors to XPath expressions",
"homepage": "https://symfony.com", "homepage": "https://symfony.com",
"support": { "support": {
"source": "https://github.com/symfony/css-selector/tree/v8.0.9" "source": "https://github.com/symfony/css-selector/tree/v7.4.9"
}, },
"time": "2026-04-18T13:51:42+00:00" "funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2026-04-18T13:18:21+00:00"
}, },
{ {
"name": "symfony/deprecation-contracts", "name": "symfony/deprecation-contracts",
"version": "v3.7.0", "version": "v3.7.1",
"source": {
"type": "git",
"url": "https://github.com/symfony/deprecation-contracts.git",
"reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d"
},
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://mirrors.cloud.tencent.com/repository/composer/symfony/deprecation-contracts/v3.7.0/symfony-deprecation-contracts-v3.7.0.zip", "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/f3202fa1b5097b0af062dc978b32ecf63404e31d",
"reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b", "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -4473,6 +5047,7 @@
"function.php" "function.php"
] ]
}, },
"notification-url": "https://packagist.org/downloads/",
"license": [ "license": [
"MIT" "MIT"
], ],
@@ -4489,33 +5064,57 @@
"description": "A generic function and convention to trigger deprecation notices", "description": "A generic function and convention to trigger deprecation notices",
"homepage": "https://symfony.com", "homepage": "https://symfony.com",
"support": { "support": {
"source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.0" "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.1"
}, },
"time": "2026-04-13T15:52:40+00:00" "funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2026-06-05T06:23:12+00:00"
}, },
{ {
"name": "symfony/error-handler", "name": "symfony/error-handler",
"version": "v8.0.8", "version": "v7.4.14",
"source": {
"type": "git",
"url": "https://github.com/symfony/error-handler.git",
"reference": "4e1a093b481f323e6e326451f9760c3868430673"
},
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://mirrors.cloud.tencent.com/repository/composer/symfony/error-handler/v8.0.8/symfony-error-handler-v8.0.8.zip", "url": "https://api.github.com/repos/symfony/error-handler/zipball/4e1a093b481f323e6e326451f9760c3868430673",
"reference": "c1119fe8dcfc3825ec74ec061b96ef0c8f281517", "reference": "4e1a093b481f323e6e326451f9760c3868430673",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
"php": ">=8.4", "php": ">=8.2",
"psr/log": "^1|^2|^3", "psr/log": "^1|^2|^3",
"symfony/polyfill-php85": "^1.32", "symfony/polyfill-php85": "^1.32",
"symfony/var-dumper": "^7.4|^8.0" "symfony/var-dumper": "^6.4|^7.0|^8.0"
}, },
"conflict": { "conflict": {
"symfony/deprecation-contracts": "<2.5" "symfony/deprecation-contracts": "<2.5",
"symfony/http-kernel": "<6.4"
}, },
"require-dev": { "require-dev": {
"symfony/console": "^7.4|^8.0", "symfony/console": "^6.4|^7.0|^8.0",
"symfony/deprecation-contracts": "^2.5|^3", "symfony/deprecation-contracts": "^2.5|^3",
"symfony/http-kernel": "^7.4|^8.0", "symfony/http-kernel": "^6.4|^7.0|^8.0",
"symfony/serializer": "^7.4|^8.0", "symfony/serializer": "^6.4|^7.0|^8.0",
"symfony/webpack-encore-bundle": "^1.0|^2.0" "symfony/webpack-encore-bundle": "^1.0|^2.0"
}, },
"bin": [ "bin": [
@@ -4530,6 +5129,7 @@
"/Tests/" "/Tests/"
] ]
}, },
"notification-url": "https://packagist.org/downloads/",
"license": [ "license": [
"MIT" "MIT"
], ],
@@ -4546,25 +5146,48 @@
"description": "Provides tools to manage errors and ease debugging PHP code", "description": "Provides tools to manage errors and ease debugging PHP code",
"homepage": "https://symfony.com", "homepage": "https://symfony.com",
"support": { "support": {
"source": "https://github.com/symfony/error-handler/tree/v8.0.8" "source": "https://github.com/symfony/error-handler/tree/v7.4.14"
}, },
"time": "2026-03-30T15:14:47+00:00" "funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2026-06-05T06:22:21+00:00"
}, },
{ {
"name": "symfony/event-dispatcher", "name": "symfony/event-dispatcher",
"version": "v8.0.9", "version": "v7.4.14",
"source": {
"type": "git",
"url": "https://github.com/symfony/event-dispatcher.git",
"reference": "51fe3d170227be8d1772214b82ae506e15ed78ff"
},
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://mirrors.cloud.tencent.com/repository/composer/symfony/event-dispatcher/v8.0.9/symfony-event-dispatcher-v8.0.9.zip", "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/51fe3d170227be8d1772214b82ae506e15ed78ff",
"reference": "0c3c1a17604c4dbbec4b93fe162c538482096e1f", "reference": "51fe3d170227be8d1772214b82ae506e15ed78ff",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
"php": ">=8.4", "php": ">=8.2",
"symfony/event-dispatcher-contracts": "^2.5|^3" "symfony/event-dispatcher-contracts": "^2.5|^3"
}, },
"conflict": { "conflict": {
"symfony/security-http": "<7.4", "symfony/dependency-injection": "<6.4",
"symfony/service-contracts": "<2.5" "symfony/service-contracts": "<2.5"
}, },
"provide": { "provide": {
@@ -4573,14 +5196,14 @@
}, },
"require-dev": { "require-dev": {
"psr/log": "^1|^2|^3", "psr/log": "^1|^2|^3",
"symfony/config": "^7.4|^8.0", "symfony/config": "^6.4|^7.0|^8.0",
"symfony/dependency-injection": "^7.4|^8.0", "symfony/dependency-injection": "^6.4|^7.0|^8.0",
"symfony/error-handler": "^7.4|^8.0", "symfony/error-handler": "^6.4|^7.0|^8.0",
"symfony/expression-language": "^7.4|^8.0", "symfony/expression-language": "^6.4|^7.0|^8.0",
"symfony/framework-bundle": "^7.4|^8.0", "symfony/framework-bundle": "^6.4|^7.0|^8.0",
"symfony/http-foundation": "^7.4|^8.0", "symfony/http-foundation": "^6.4|^7.0|^8.0",
"symfony/service-contracts": "^2.5|^3", "symfony/service-contracts": "^2.5|^3",
"symfony/stopwatch": "^7.4|^8.0" "symfony/stopwatch": "^6.4|^7.0|^8.0"
}, },
"type": "library", "type": "library",
"autoload": { "autoload": {
@@ -4591,6 +5214,7 @@
"/Tests/" "/Tests/"
] ]
}, },
"notification-url": "https://packagist.org/downloads/",
"license": [ "license": [
"MIT" "MIT"
], ],
@@ -4607,17 +5231,40 @@
"description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them",
"homepage": "https://symfony.com", "homepage": "https://symfony.com",
"support": { "support": {
"source": "https://github.com/symfony/event-dispatcher/tree/v8.0.9" "source": "https://github.com/symfony/event-dispatcher/tree/v7.4.14"
}, },
"time": "2026-04-18T13:51:42+00:00" "funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2026-06-06T11:10:32+00:00"
}, },
{ {
"name": "symfony/event-dispatcher-contracts", "name": "symfony/event-dispatcher-contracts",
"version": "v3.7.0", "version": "v3.7.1",
"source": {
"type": "git",
"url": "https://github.com/symfony/event-dispatcher-contracts.git",
"reference": "c7de7a00ffb67842132da02ea92988a39ccd9f4e"
},
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://mirrors.cloud.tencent.com/repository/composer/symfony/event-dispatcher-contracts/v3.7.0/symfony-event-dispatcher-contracts-v3.7.0.zip", "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/c7de7a00ffb67842132da02ea92988a39ccd9f4e",
"reference": "ccba7060602b7fed0b03c85bf025257f76d9ef32", "reference": "c7de7a00ffb67842132da02ea92988a39ccd9f4e",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -4639,6 +5286,7 @@
"Symfony\\Contracts\\EventDispatcher\\": "" "Symfony\\Contracts\\EventDispatcher\\": ""
} }
}, },
"notification-url": "https://packagist.org/downloads/",
"license": [ "license": [
"MIT" "MIT"
], ],
@@ -4663,24 +5311,47 @@
"standards" "standards"
], ],
"support": { "support": {
"source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.7.0" "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.7.1"
}, },
"time": "2026-01-05T13:30:16+00:00" "funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2026-06-05T06:23:12+00:00"
}, },
{ {
"name": "symfony/finder", "name": "symfony/finder",
"version": "v8.0.8", "version": "v7.4.14",
"source": {
"type": "git",
"url": "https://github.com/symfony/finder.git",
"reference": "13b38720174286f55d1761152b575a8d1436fc25"
},
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://mirrors.cloud.tencent.com/repository/composer/symfony/finder/v8.0.8/symfony-finder-v8.0.8.zip", "url": "https://api.github.com/repos/symfony/finder/zipball/13b38720174286f55d1761152b575a8d1436fc25",
"reference": "8da41214757b87d97f181e3d14a4179286151007", "reference": "13b38720174286f55d1761152b575a8d1436fc25",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
"php": ">=8.4" "php": ">=8.2"
}, },
"require-dev": { "require-dev": {
"symfony/filesystem": "^7.4|^8.0" "symfony/filesystem": "^6.4|^7.0|^8.0"
}, },
"type": "library", "type": "library",
"autoload": { "autoload": {
@@ -4691,6 +5362,7 @@
"/Tests/" "/Tests/"
] ]
}, },
"notification-url": "https://packagist.org/downloads/",
"license": [ "license": [
"MIT" "MIT"
], ],
@@ -4707,36 +5379,61 @@
"description": "Finds files and directories via an intuitive fluent interface", "description": "Finds files and directories via an intuitive fluent interface",
"homepage": "https://symfony.com", "homepage": "https://symfony.com",
"support": { "support": {
"source": "https://github.com/symfony/finder/tree/v8.0.8" "source": "https://github.com/symfony/finder/tree/v7.4.14"
}, },
"time": "2026-03-30T15:14:47+00:00" "funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2026-06-27T08:31:18+00:00"
}, },
{ {
"name": "symfony/http-foundation", "name": "symfony/http-foundation",
"version": "v8.0.8", "version": "v7.4.14",
"source": {
"type": "git",
"url": "https://github.com/symfony/http-foundation.git",
"reference": "06db5ae1552177bf8572f8908839f12e3c06aed3"
},
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://mirrors.cloud.tencent.com/repository/composer/symfony/http-foundation/v8.0.8/symfony-http-foundation-v8.0.8.zip", "url": "https://api.github.com/repos/symfony/http-foundation/zipball/06db5ae1552177bf8572f8908839f12e3c06aed3",
"reference": "02656f7ebeae5c155d659e946f6b3a33df24051b", "reference": "06db5ae1552177bf8572f8908839f12e3c06aed3",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
"php": ">=8.4", "php": ">=8.2",
"symfony/deprecation-contracts": "^2.5|^3",
"symfony/polyfill-mbstring": "^1.1" "symfony/polyfill-mbstring": "^1.1"
}, },
"conflict": { "conflict": {
"doctrine/dbal": "<4.3" "doctrine/dbal": "<3.6",
"symfony/cache": "<6.4.12|>=7.0,<7.1.5"
}, },
"require-dev": { "require-dev": {
"doctrine/dbal": "^4.3", "doctrine/dbal": "^3.6|^4",
"predis/predis": "^1.1|^2.0", "predis/predis": "^1.1|^2.0",
"symfony/cache": "^7.4|^8.0", "symfony/cache": "^6.4.12|^7.1.5|^8.0",
"symfony/clock": "^7.4|^8.0", "symfony/clock": "^6.4|^7.0|^8.0",
"symfony/dependency-injection": "^7.4|^8.0", "symfony/dependency-injection": "^6.4|^7.0|^8.0",
"symfony/expression-language": "^7.4|^8.0", "symfony/expression-language": "^6.4|^7.0|^8.0",
"symfony/http-kernel": "^7.4|^8.0", "symfony/http-kernel": "^6.4|^7.0|^8.0",
"symfony/mime": "^7.4|^8.0", "symfony/mime": "^6.4|^7.0|^8.0",
"symfony/rate-limiter": "^7.4|^8.0" "symfony/rate-limiter": "^6.4|^7.0|^8.0"
}, },
"type": "library", "type": "library",
"autoload": { "autoload": {
@@ -4747,6 +5444,7 @@
"/Tests/" "/Tests/"
] ]
}, },
"notification-url": "https://packagist.org/downloads/",
"license": [ "license": [
"MIT" "MIT"
], ],
@@ -4763,60 +5461,98 @@
"description": "Defines an object-oriented layer for the HTTP specification", "description": "Defines an object-oriented layer for the HTTP specification",
"homepage": "https://symfony.com", "homepage": "https://symfony.com",
"support": { "support": {
"source": "https://github.com/symfony/http-foundation/tree/v8.0.8" "source": "https://github.com/symfony/http-foundation/tree/v7.4.14"
}, },
"time": "2026-03-30T15:14:47+00:00" "funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2026-06-11T07:31:44+00:00"
}, },
{ {
"name": "symfony/http-kernel", "name": "symfony/http-kernel",
"version": "v8.0.10", "version": "v7.4.14",
"source": {
"type": "git",
"url": "https://github.com/symfony/http-kernel.git",
"reference": "e99af79b1e776646eda0e1c23b7b45c184ff99be"
},
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://mirrors.cloud.tencent.com/repository/composer/symfony/http-kernel/v8.0.10/symfony-http-kernel-v8.0.10.zip", "url": "https://api.github.com/repos/symfony/http-kernel/zipball/e99af79b1e776646eda0e1c23b7b45c184ff99be",
"reference": "fb3f65b3d4ca2dad31c80d323819a762ca31d6ac", "reference": "e99af79b1e776646eda0e1c23b7b45c184ff99be",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
"php": ">=8.4", "php": ">=8.2",
"psr/log": "^1|^2|^3", "psr/log": "^1|^2|^3",
"symfony/error-handler": "^7.4|^8.0", "symfony/deprecation-contracts": "^2.5|^3",
"symfony/event-dispatcher": "^7.4|^8.0", "symfony/error-handler": "^6.4|^7.0|^8.0",
"symfony/event-dispatcher": "^7.3|^8.0",
"symfony/http-foundation": "^7.4|^8.0", "symfony/http-foundation": "^7.4|^8.0",
"symfony/polyfill-ctype": "^1.8" "symfony/polyfill-ctype": "^1.8"
}, },
"conflict": { "conflict": {
"symfony/browser-kit": "<6.4",
"symfony/cache": "<6.4",
"symfony/config": "<6.4",
"symfony/console": "<6.4",
"symfony/dependency-injection": "<6.4",
"symfony/doctrine-bridge": "<6.4",
"symfony/flex": "<2.10", "symfony/flex": "<2.10",
"symfony/form": "<6.4",
"symfony/http-client": "<6.4",
"symfony/http-client-contracts": "<2.5", "symfony/http-client-contracts": "<2.5",
"symfony/mailer": "<6.4",
"symfony/messenger": "<6.4",
"symfony/translation": "<6.4",
"symfony/translation-contracts": "<2.5", "symfony/translation-contracts": "<2.5",
"twig/twig": "<3.21" "symfony/twig-bridge": "<6.4",
"symfony/validator": "<6.4",
"symfony/var-dumper": "<6.4",
"twig/twig": "<3.12"
}, },
"provide": { "provide": {
"psr/log-implementation": "1.0|2.0|3.0" "psr/log-implementation": "1.0|2.0|3.0"
}, },
"require-dev": { "require-dev": {
"psr/cache": "^1.0|^2.0|^3.0", "psr/cache": "^1.0|^2.0|^3.0",
"symfony/browser-kit": "^7.4|^8.0", "symfony/browser-kit": "^6.4|^7.0|^8.0",
"symfony/clock": "^7.4|^8.0", "symfony/clock": "^6.4|^7.0|^8.0",
"symfony/config": "^7.4|^8.0", "symfony/config": "^6.4|^7.0|^8.0",
"symfony/console": "^7.4|^8.0", "symfony/console": "^6.4|^7.0|^8.0",
"symfony/css-selector": "^7.4|^8.0", "symfony/css-selector": "^6.4|^7.0|^8.0",
"symfony/dependency-injection": "^7.4|^8.0", "symfony/dependency-injection": "^6.4.1|^7.0.1|^8.0",
"symfony/dom-crawler": "^7.4|^8.0", "symfony/dom-crawler": "^6.4|^7.0|^8.0",
"symfony/expression-language": "^7.4|^8.0", "symfony/expression-language": "^6.4|^7.0|^8.0",
"symfony/finder": "^7.4|^8.0", "symfony/finder": "^6.4|^7.0|^8.0",
"symfony/http-client-contracts": "^2.5|^3", "symfony/http-client-contracts": "^2.5|^3",
"symfony/process": "^7.4|^8.0", "symfony/process": "^6.4|^7.0|^8.0",
"symfony/property-access": "^7.4|^8.0", "symfony/property-access": "^7.1|^8.0",
"symfony/routing": "^7.4|^8.0", "symfony/routing": "^6.4|^7.0|^8.0",
"symfony/serializer": "^7.4|^8.0", "symfony/serializer": "^7.1|^8.0",
"symfony/stopwatch": "^7.4|^8.0", "symfony/stopwatch": "^6.4|^7.0|^8.0",
"symfony/translation": "^7.4|^8.0", "symfony/translation": "^6.4|^7.0|^8.0",
"symfony/translation-contracts": "^2.5|^3", "symfony/translation-contracts": "^2.5|^3",
"symfony/uid": "^7.4|^8.0", "symfony/uid": "^6.4|^7.0|^8.0",
"symfony/validator": "^7.4|^8.0", "symfony/validator": "^6.4|^7.0|^8.0",
"symfony/var-dumper": "^7.4|^8.0", "symfony/var-dumper": "^6.4|^7.0|^8.0",
"symfony/var-exporter": "^7.4|^8.0", "symfony/var-exporter": "^6.4|^7.0|^8.0",
"twig/twig": "^3.21" "twig/twig": "^3.12"
}, },
"type": "library", "type": "library",
"autoload": { "autoload": {
@@ -4827,6 +5563,7 @@
"/Tests/" "/Tests/"
] ]
}, },
"notification-url": "https://packagist.org/downloads/",
"license": [ "license": [
"MIT" "MIT"
], ],
@@ -4843,36 +5580,63 @@
"description": "Provides a structured process for converting a Request into a Response", "description": "Provides a structured process for converting a Request into a Response",
"homepage": "https://symfony.com", "homepage": "https://symfony.com",
"support": { "support": {
"source": "https://github.com/symfony/http-kernel/tree/v8.0.10" "source": "https://github.com/symfony/http-kernel/tree/v7.4.14"
}, },
"time": "2026-05-06T12:27:31+00:00" "funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2026-06-27T09:14:35+00:00"
}, },
{ {
"name": "symfony/mailer", "name": "symfony/mailer",
"version": "v8.0.8", "version": "v7.4.14",
"source": {
"type": "git",
"url": "https://github.com/symfony/mailer.git",
"reference": "f88ce03ae73e3edb5c176ce1f337709996e88495"
},
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://mirrors.cloud.tencent.com/repository/composer/symfony/mailer/v8.0.8/symfony-mailer-v8.0.8.zip", "url": "https://api.github.com/repos/symfony/mailer/zipball/f88ce03ae73e3edb5c176ce1f337709996e88495",
"reference": "ca5f6edaf8780ece814404b58a4482b22b509c56", "reference": "f88ce03ae73e3edb5c176ce1f337709996e88495",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
"egulias/email-validator": "^2.1.10|^3|^4", "egulias/email-validator": "^2.1.10|^3|^4",
"php": ">=8.4", "php": ">=8.2",
"psr/event-dispatcher": "^1", "psr/event-dispatcher": "^1",
"psr/log": "^1|^2|^3", "psr/log": "^1|^2|^3",
"symfony/event-dispatcher": "^7.4|^8.0", "symfony/event-dispatcher": "^6.4|^7.0|^8.0",
"symfony/mime": "^7.4|^8.0", "symfony/mime": "^7.2|^8.0",
"symfony/service-contracts": "^2.5|^3" "symfony/service-contracts": "^2.5|^3"
}, },
"conflict": { "conflict": {
"symfony/http-client-contracts": "<2.5" "symfony/http-client-contracts": "<2.5",
"symfony/http-kernel": "<6.4",
"symfony/messenger": "<6.4",
"symfony/mime": "<6.4",
"symfony/twig-bridge": "<6.4"
}, },
"require-dev": { "require-dev": {
"symfony/console": "^7.4|^8.0", "symfony/console": "^6.4|^7.0|^8.0",
"symfony/http-client": "^7.4|^8.0", "symfony/http-client": "^6.4|^7.0|^8.0",
"symfony/messenger": "^7.4|^8.0", "symfony/messenger": "^6.4|^7.0|^8.0",
"symfony/twig-bridge": "^7.4|^8.0" "symfony/twig-bridge": "^6.4|^7.0|^8.0"
}, },
"type": "library", "type": "library",
"autoload": { "autoload": {
@@ -4883,6 +5647,7 @@
"/Tests/" "/Tests/"
] ]
}, },
"notification-url": "https://packagist.org/downloads/",
"license": [ "license": [
"MIT" "MIT"
], ],
@@ -4899,38 +5664,64 @@
"description": "Helps sending emails", "description": "Helps sending emails",
"homepage": "https://symfony.com", "homepage": "https://symfony.com",
"support": { "support": {
"source": "https://github.com/symfony/mailer/tree/v8.0.8" "source": "https://github.com/symfony/mailer/tree/v7.4.14"
}, },
"time": "2026-03-30T15:14:47+00:00" "funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2026-06-13T08:51:35+00:00"
}, },
{ {
"name": "symfony/mime", "name": "symfony/mime",
"version": "v8.0.9", "version": "v7.4.13",
"source": {
"type": "git",
"url": "https://github.com/symfony/mime.git",
"reference": "a845722765c4f6b2ce88beaf4f4479975b186770"
},
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://mirrors.cloud.tencent.com/repository/composer/symfony/mime/v8.0.9/symfony-mime-v8.0.9.zip", "url": "https://api.github.com/repos/symfony/mime/zipball/a845722765c4f6b2ce88beaf4f4479975b186770",
"reference": "a9fcb293650c054b62a5b406f4e92e7b711ea333", "reference": "a845722765c4f6b2ce88beaf4f4479975b186770",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
"php": ">=8.4", "php": ">=8.2",
"symfony/deprecation-contracts": "^2.5|^3",
"symfony/polyfill-intl-idn": "^1.10", "symfony/polyfill-intl-idn": "^1.10",
"symfony/polyfill-mbstring": "^1.0" "symfony/polyfill-mbstring": "^1.0"
}, },
"conflict": { "conflict": {
"egulias/email-validator": "~3.0.0", "egulias/email-validator": "~3.0.0",
"phpdocumentor/reflection-docblock": "<5.2|>=7", "phpdocumentor/reflection-docblock": "<5.2|>=7",
"phpdocumentor/type-resolver": "<1.5.1" "phpdocumentor/type-resolver": "<1.5.1",
"symfony/mailer": "<6.4",
"symfony/serializer": "<6.4.3|>7.0,<7.0.3"
}, },
"require-dev": { "require-dev": {
"egulias/email-validator": "^2.1.10|^3.1|^4", "egulias/email-validator": "^2.1.10|^3.1|^4",
"league/html-to-markdown": "^5.0", "league/html-to-markdown": "^5.0",
"phpdocumentor/reflection-docblock": "^5.2|^6.0", "phpdocumentor/reflection-docblock": "^5.2|^6.0",
"symfony/dependency-injection": "^7.4|^8.0", "symfony/dependency-injection": "^6.4|^7.0|^8.0",
"symfony/process": "^7.4|^8.0", "symfony/process": "^6.4|^7.0|^8.0",
"symfony/property-access": "^7.4|^8.0", "symfony/property-access": "^6.4|^7.0|^8.0",
"symfony/property-info": "^7.4|^8.0", "symfony/property-info": "^6.4|^7.0|^8.0",
"symfony/serializer": "^7.4|^8.0" "symfony/serializer": "^6.4.3|^7.0.3|^8.0"
}, },
"type": "library", "type": "library",
"autoload": { "autoload": {
@@ -4941,6 +5732,7 @@
"/Tests/" "/Tests/"
] ]
}, },
"notification-url": "https://packagist.org/downloads/",
"license": [ "license": [
"MIT" "MIT"
], ],
@@ -4961,16 +5753,39 @@
"mime-type" "mime-type"
], ],
"support": { "support": {
"source": "https://github.com/symfony/mime/tree/v8.0.9" "source": "https://github.com/symfony/mime/tree/v7.4.13"
}, },
"time": "2026-04-29T15:02:55+00:00" "funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2026-05-23T16:22:37+00:00"
}, },
{ {
"name": "symfony/polyfill-ctype", "name": "symfony/polyfill-ctype",
"version": "v1.37.0", "version": "v1.37.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-ctype.git",
"reference": "141046a8f9477948ff284fa65be2095baafb94f2"
},
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://mirrors.cloud.tencent.com/repository/composer/symfony/polyfill-ctype/v1.37.0/symfony-polyfill-ctype-v1.37.0.zip", "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/141046a8f9477948ff284fa65be2095baafb94f2",
"reference": "141046a8f9477948ff284fa65be2095baafb94f2", "reference": "141046a8f9477948ff284fa65be2095baafb94f2",
"shasum": "" "shasum": ""
}, },
@@ -4998,6 +5813,7 @@
"Symfony\\Polyfill\\Ctype\\": "" "Symfony\\Polyfill\\Ctype\\": ""
} }
}, },
"notification-url": "https://packagist.org/downloads/",
"license": [ "license": [
"MIT" "MIT"
], ],
@@ -5022,15 +5838,38 @@
"support": { "support": {
"source": "https://github.com/symfony/polyfill-ctype/tree/v1.37.0" "source": "https://github.com/symfony/polyfill-ctype/tree/v1.37.0"
}, },
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2026-04-10T16:19:22+00:00" "time": "2026-04-10T16:19:22+00:00"
}, },
{ {
"name": "symfony/polyfill-intl-grapheme", "name": "symfony/polyfill-intl-grapheme",
"version": "v1.37.0", "version": "v1.38.1",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-intl-grapheme.git",
"reference": "e9247d281d694a5120554d9afaf54e070e88a603"
},
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://mirrors.cloud.tencent.com/repository/composer/symfony/polyfill-intl-grapheme/v1.37.0/symfony-polyfill-intl-grapheme-v1.37.0.zip", "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/e9247d281d694a5120554d9afaf54e070e88a603",
"reference": "4864388bfbd3001ce88e234fab652acd91fdc57e", "reference": "e9247d281d694a5120554d9afaf54e070e88a603",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -5054,6 +5893,7 @@
"Symfony\\Polyfill\\Intl\\Grapheme\\": "" "Symfony\\Polyfill\\Intl\\Grapheme\\": ""
} }
}, },
"notification-url": "https://packagist.org/downloads/",
"license": [ "license": [
"MIT" "MIT"
], ],
@@ -5078,17 +5918,40 @@
"shim" "shim"
], ],
"support": { "support": {
"source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.37.0" "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.38.1"
}, },
"time": "2026-04-26T13:13:48+00:00" "funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2026-05-26T05:58:03+00:00"
}, },
{ {
"name": "symfony/polyfill-intl-idn", "name": "symfony/polyfill-intl-idn",
"version": "v1.37.0", "version": "v1.38.1",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-intl-idn.git",
"reference": "dc21118016c039a66235cf93d96b435ffb282412"
},
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://mirrors.cloud.tencent.com/repository/composer/symfony/polyfill-intl-idn/v1.37.0/symfony-polyfill-intl-idn-v1.37.0.zip", "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/dc21118016c039a66235cf93d96b435ffb282412",
"reference": "9614ac4d8061dc257ecc64cba1b140873dce8ad3", "reference": "dc21118016c039a66235cf93d96b435ffb282412",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -5113,6 +5976,7 @@
"Symfony\\Polyfill\\Intl\\Idn\\": "" "Symfony\\Polyfill\\Intl\\Idn\\": ""
} }
}, },
"notification-url": "https://packagist.org/downloads/",
"license": [ "license": [
"MIT" "MIT"
], ],
@@ -5141,17 +6005,40 @@
"shim" "shim"
], ],
"support": { "support": {
"source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.37.0" "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.38.1"
}, },
"time": "2024-09-10T14:38:51+00:00" "funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2026-05-25T15:22:23+00:00"
}, },
{ {
"name": "symfony/polyfill-intl-normalizer", "name": "symfony/polyfill-intl-normalizer",
"version": "v1.37.0", "version": "v1.38.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-intl-normalizer.git",
"reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b"
},
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://mirrors.cloud.tencent.com/repository/composer/symfony/polyfill-intl-normalizer/v1.37.0/symfony-polyfill-intl-normalizer-v1.37.0.zip", "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/2d446c214bdbe5b71bde5011b060a05fece3ae6b",
"reference": "3833d7255cc303546435cb650316bff708a1c75c", "reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -5178,6 +6065,7 @@
"Resources/stubs" "Resources/stubs"
] ]
}, },
"notification-url": "https://packagist.org/downloads/",
"license": [ "license": [
"MIT" "MIT"
], ],
@@ -5202,17 +6090,40 @@
"shim" "shim"
], ],
"support": { "support": {
"source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.37.0" "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.38.0"
}, },
"time": "2024-09-09T11:45:10+00:00" "funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2026-05-25T13:48:31+00:00"
}, },
{ {
"name": "symfony/polyfill-mbstring", "name": "symfony/polyfill-mbstring",
"version": "v1.37.0", "version": "v1.38.2",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-mbstring.git",
"reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6"
},
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://mirrors.cloud.tencent.com/repository/composer/symfony/polyfill-mbstring/v1.37.0/symfony-polyfill-mbstring-v1.37.0.zip", "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6",
"reference": "6a21eb99c6973357967f6ce3708cd55a6bec6315", "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -5240,6 +6151,7 @@
"Symfony\\Polyfill\\Mbstring\\": "" "Symfony\\Polyfill\\Mbstring\\": ""
} }
}, },
"notification-url": "https://packagist.org/downloads/",
"license": [ "license": [
"MIT" "MIT"
], ],
@@ -5263,16 +6175,39 @@
"shim" "shim"
], ],
"support": { "support": {
"source": "https://github.com/symfony/polyfill-mbstring/tree/v1.37.0" "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.38.2"
}, },
"time": "2026-04-10T17:25:58+00:00" "funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2026-05-27T06:59:30+00:00"
}, },
{ {
"name": "symfony/polyfill-php80", "name": "symfony/polyfill-php80",
"version": "v1.37.0", "version": "v1.37.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-php80.git",
"reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411"
},
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://mirrors.cloud.tencent.com/repository/composer/symfony/polyfill-php80/v1.37.0/symfony-polyfill-php80-v1.37.0.zip", "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/dfb55726c3a76ea3b6459fcfda1ec2d80a682411",
"reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411", "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411",
"shasum": "" "shasum": ""
}, },
@@ -5297,6 +6232,7 @@
"Resources/stubs" "Resources/stubs"
] ]
}, },
"notification-url": "https://packagist.org/downloads/",
"license": [ "license": [
"MIT" "MIT"
], ],
@@ -5325,15 +6261,118 @@
"support": { "support": {
"source": "https://github.com/symfony/polyfill-php80/tree/v1.37.0" "source": "https://github.com/symfony/polyfill-php80/tree/v1.37.0"
}, },
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2026-04-10T16:19:22+00:00" "time": "2026-04-10T16:19:22+00:00"
}, },
{ {
"name": "symfony/polyfill-php84", "name": "symfony/polyfill-php83",
"version": "v1.37.0", "version": "v1.38.2",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-php83.git",
"reference": "796a26abb75ce49f3a84433cd81bf1009d73d5f8"
},
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://mirrors.cloud.tencent.com/repository/composer/symfony/polyfill-php84/v1.37.0/symfony-polyfill-php84-v1.37.0.zip", "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/796a26abb75ce49f3a84433cd81bf1009d73d5f8",
"reference": "88486db2c389b290bf87ff1de7ebc1e13e42bb06", "reference": "796a26abb75ce49f3a84433cd81bf1009d73d5f8",
"shasum": ""
},
"require": {
"php": ">=7.2"
},
"type": "library",
"extra": {
"thanks": {
"url": "https://github.com/symfony/polyfill",
"name": "symfony/polyfill"
}
},
"autoload": {
"files": [
"bootstrap.php"
],
"psr-4": {
"Symfony\\Polyfill\\Php83\\": ""
},
"classmap": [
"Resources/stubs"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Nicolas Grekas",
"email": "p@tchwork.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Symfony polyfill backporting some PHP 8.3+ features to lower PHP versions",
"homepage": "https://symfony.com",
"keywords": [
"compatibility",
"polyfill",
"portable",
"shim"
],
"support": {
"source": "https://github.com/symfony/polyfill-php83/tree/v1.38.2"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2026-05-27T06:51:48+00:00"
},
{
"name": "symfony/polyfill-php84",
"version": "v1.38.1",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-php84.git",
"reference": "f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa",
"reference": "f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -5357,6 +6396,7 @@
"Resources/stubs" "Resources/stubs"
] ]
}, },
"notification-url": "https://packagist.org/downloads/",
"license": [ "license": [
"MIT" "MIT"
], ],
@@ -5379,17 +6419,40 @@
"shim" "shim"
], ],
"support": { "support": {
"source": "https://github.com/symfony/polyfill-php84/tree/v1.37.0" "source": "https://github.com/symfony/polyfill-php84/tree/v1.38.1"
}, },
"time": "2026-04-10T18:47:49+00:00" "funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2026-05-26T12:51:13+00:00"
}, },
{ {
"name": "symfony/polyfill-php85", "name": "symfony/polyfill-php85",
"version": "v1.37.0", "version": "v1.38.1",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-php85.git",
"reference": "ba2ba04f3352cfa2dcbbcb90aee13ed967f505b1"
},
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://mirrors.cloud.tencent.com/repository/composer/symfony/polyfill-php85/v1.37.0/symfony-polyfill-php85-v1.37.0.zip", "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/ba2ba04f3352cfa2dcbbcb90aee13ed967f505b1",
"reference": "fcfa4973a9917cef23f2e38774da74a2b7d115ee", "reference": "ba2ba04f3352cfa2dcbbcb90aee13ed967f505b1",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -5413,6 +6476,7 @@
"Resources/stubs" "Resources/stubs"
] ]
}, },
"notification-url": "https://packagist.org/downloads/",
"license": [ "license": [
"MIT" "MIT"
], ],
@@ -5435,17 +6499,40 @@
"shim" "shim"
], ],
"support": { "support": {
"source": "https://github.com/symfony/polyfill-php85/tree/v1.37.0" "source": "https://github.com/symfony/polyfill-php85/tree/v1.38.1"
}, },
"time": "2026-04-26T13:10:57+00:00" "funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2026-05-26T02:25:22+00:00"
}, },
{ {
"name": "symfony/polyfill-php86", "name": "symfony/polyfill-php86",
"version": "v1.37.0", "version": "v1.38.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-php86.git",
"reference": "fcec68d64f46dc84e1f6ffcf2c6dda40ff3143ad"
},
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://mirrors.cloud.tencent.com/repository/composer/symfony/polyfill-php86/v1.37.0/symfony-polyfill-php86-v1.37.0.zip", "url": "https://api.github.com/repos/symfony/polyfill-php86/zipball/fcec68d64f46dc84e1f6ffcf2c6dda40ff3143ad",
"reference": "33d8fc5a705481e21fe3a81212b26f9b1f61749c", "reference": "fcec68d64f46dc84e1f6ffcf2c6dda40ff3143ad",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -5469,6 +6556,7 @@
"Resources/stubs" "Resources/stubs"
] ]
}, },
"notification-url": "https://packagist.org/downloads/",
"license": [ "license": [
"MIT" "MIT"
], ],
@@ -5491,16 +6579,39 @@
"shim" "shim"
], ],
"support": { "support": {
"source": "https://github.com/symfony/polyfill-php86/tree/v1.37.0" "source": "https://github.com/symfony/polyfill-php86/tree/v1.38.0"
}, },
"time": "2026-04-26T13:13:48+00:00" "funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2026-05-25T11:52:35+00:00"
}, },
{ {
"name": "symfony/polyfill-uuid", "name": "symfony/polyfill-uuid",
"version": "v1.37.0", "version": "v1.37.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-uuid.git",
"reference": "26dfec253c4cf3e51b541b52ddf7e42cb0908e94"
},
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://mirrors.cloud.tencent.com/repository/composer/symfony/polyfill-uuid/v1.37.0/symfony-polyfill-uuid-v1.37.0.zip", "url": "https://api.github.com/repos/symfony/polyfill-uuid/zipball/26dfec253c4cf3e51b541b52ddf7e42cb0908e94",
"reference": "26dfec253c4cf3e51b541b52ddf7e42cb0908e94", "reference": "26dfec253c4cf3e51b541b52ddf7e42cb0908e94",
"shasum": "" "shasum": ""
}, },
@@ -5528,6 +6639,7 @@
"Symfony\\Polyfill\\Uuid\\": "" "Symfony\\Polyfill\\Uuid\\": ""
} }
}, },
"notification-url": "https://packagist.org/downloads/",
"license": [ "license": [
"MIT" "MIT"
], ],
@@ -5552,19 +6664,42 @@
"support": { "support": {
"source": "https://github.com/symfony/polyfill-uuid/tree/v1.37.0" "source": "https://github.com/symfony/polyfill-uuid/tree/v1.37.0"
}, },
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2026-04-10T16:19:22+00:00" "time": "2026-04-10T16:19:22+00:00"
}, },
{ {
"name": "symfony/process", "name": "symfony/process",
"version": "v8.0.8", "version": "v7.4.13",
"source": {
"type": "git",
"url": "https://github.com/symfony/process.git",
"reference": "f5804be144caceb570f6747519999636b664f24c"
},
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://mirrors.cloud.tencent.com/repository/composer/symfony/process/v8.0.8/symfony-process-v8.0.8.zip", "url": "https://api.github.com/repos/symfony/process/zipball/f5804be144caceb570f6747519999636b664f24c",
"reference": "cb8939aff03470d1a9d1d1b66d08c6fa71b3bbdc", "reference": "f5804be144caceb570f6747519999636b664f24c",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
"php": ">=8.4" "php": ">=8.2"
}, },
"type": "library", "type": "library",
"autoload": { "autoload": {
@@ -5575,6 +6710,7 @@
"/Tests/" "/Tests/"
] ]
}, },
"notification-url": "https://packagist.org/downloads/",
"license": [ "license": [
"MIT" "MIT"
], ],
@@ -5591,30 +6727,58 @@
"description": "Executes commands in sub-processes", "description": "Executes commands in sub-processes",
"homepage": "https://symfony.com", "homepage": "https://symfony.com",
"support": { "support": {
"source": "https://github.com/symfony/process/tree/v8.0.8" "source": "https://github.com/symfony/process/tree/v7.4.13"
}, },
"time": "2026-03-30T15:14:47+00:00" "funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2026-05-23T16:05:06+00:00"
}, },
{ {
"name": "symfony/routing", "name": "symfony/routing",
"version": "v8.0.9", "version": "v7.4.13",
"source": {
"type": "git",
"url": "https://github.com/symfony/routing.git",
"reference": "3a162171bb008e5e0f15dce6581373a4c0e8390d"
},
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://mirrors.cloud.tencent.com/repository/composer/symfony/routing/v8.0.9/symfony-routing-v8.0.9.zip", "url": "https://api.github.com/repos/symfony/routing/zipball/3a162171bb008e5e0f15dce6581373a4c0e8390d",
"reference": "75d1bd8e5da3424e4db2fc3ff0222cb4d0c73038", "reference": "3a162171bb008e5e0f15dce6581373a4c0e8390d",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
"php": ">=8.4", "php": ">=8.2",
"symfony/deprecation-contracts": "^2.5|^3" "symfony/deprecation-contracts": "^2.5|^3"
}, },
"conflict": {
"symfony/config": "<6.4",
"symfony/dependency-injection": "<6.4",
"symfony/yaml": "<6.4"
},
"require-dev": { "require-dev": {
"psr/log": "^1|^2|^3", "psr/log": "^1|^2|^3",
"symfony/config": "^7.4|^8.0", "symfony/config": "^6.4|^7.0|^8.0",
"symfony/dependency-injection": "^7.4|^8.0", "symfony/dependency-injection": "^6.4|^7.0|^8.0",
"symfony/expression-language": "^7.4|^8.0", "symfony/expression-language": "^6.4|^7.0|^8.0",
"symfony/http-foundation": "^7.4|^8.0", "symfony/http-foundation": "^6.4|^7.0|^8.0",
"symfony/yaml": "^7.4|^8.0" "symfony/yaml": "^6.4|^7.0|^8.0"
}, },
"type": "library", "type": "library",
"autoload": { "autoload": {
@@ -5625,6 +6789,7 @@
"/Tests/" "/Tests/"
] ]
}, },
"notification-url": "https://packagist.org/downloads/",
"license": [ "license": [
"MIT" "MIT"
], ],
@@ -5647,17 +6812,40 @@
"url" "url"
], ],
"support": { "support": {
"source": "https://github.com/symfony/routing/tree/v8.0.9" "source": "https://github.com/symfony/routing/tree/v7.4.13"
}, },
"time": "2026-04-29T15:02:55+00:00" "funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2026-05-24T11:20:33+00:00"
}, },
{ {
"name": "symfony/service-contracts", "name": "symfony/service-contracts",
"version": "v3.7.0", "version": "v3.7.1",
"source": {
"type": "git",
"url": "https://github.com/symfony/service-contracts.git",
"reference": "c0a284bab1ed8aa0417e3d69250ab437739563a0"
},
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://mirrors.cloud.tencent.com/repository/composer/symfony/service-contracts/v3.7.0/symfony-service-contracts-v3.7.0.zip", "url": "https://api.github.com/repos/symfony/service-contracts/zipball/c0a284bab1ed8aa0417e3d69250ab437739563a0",
"reference": "d25d82433a80eba6aa0e6c24b61d7370d99e444a", "reference": "c0a284bab1ed8aa0417e3d69250ab437739563a0",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -5686,6 +6874,7 @@
"/Test/" "/Test/"
] ]
}, },
"notification-url": "https://packagist.org/downloads/",
"license": [ "license": [
"MIT" "MIT"
], ],
@@ -5710,35 +6899,59 @@
"standards" "standards"
], ],
"support": { "support": {
"source": "https://github.com/symfony/service-contracts/tree/v3.7.0" "source": "https://github.com/symfony/service-contracts/tree/v3.7.1"
}, },
"time": "2026-03-28T09:44:51+00:00" "funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2026-06-16T09:55:08+00:00"
}, },
{ {
"name": "symfony/string", "name": "symfony/string",
"version": "v8.0.8", "version": "v7.4.13",
"source": {
"type": "git",
"url": "https://github.com/symfony/string.git",
"reference": "961683010db3b27ec6ebcd7308e6e1ee8fa7ffde"
},
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://mirrors.cloud.tencent.com/repository/composer/symfony/string/v8.0.8/symfony-string-v8.0.8.zip", "url": "https://api.github.com/repos/symfony/string/zipball/961683010db3b27ec6ebcd7308e6e1ee8fa7ffde",
"reference": "ae9488f874d7603f9d2dfbf120203882b645d963", "reference": "961683010db3b27ec6ebcd7308e6e1ee8fa7ffde",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
"php": ">=8.4", "php": ">=8.2",
"symfony/polyfill-ctype": "^1.8", "symfony/deprecation-contracts": "^2.5|^3.0",
"symfony/polyfill-intl-grapheme": "^1.33", "symfony/polyfill-ctype": "~1.8",
"symfony/polyfill-intl-normalizer": "^1.0", "symfony/polyfill-intl-grapheme": "~1.33",
"symfony/polyfill-mbstring": "^1.0" "symfony/polyfill-intl-normalizer": "~1.0",
"symfony/polyfill-mbstring": "~1.0"
}, },
"conflict": { "conflict": {
"symfony/translation-contracts": "<2.5" "symfony/translation-contracts": "<2.5"
}, },
"require-dev": { "require-dev": {
"symfony/emoji": "^7.4|^8.0", "symfony/emoji": "^7.1|^8.0",
"symfony/http-client": "^7.4|^8.0", "symfony/http-client": "^6.4|^7.0|^8.0",
"symfony/intl": "^7.4|^8.0", "symfony/intl": "^6.4|^7.0|^8.0",
"symfony/translation-contracts": "^2.5|^3.0", "symfony/translation-contracts": "^2.5|^3.0",
"symfony/var-exporter": "^7.4|^8.0" "symfony/var-exporter": "^6.4|^7.0|^8.0"
}, },
"type": "library", "type": "library",
"autoload": { "autoload": {
@@ -5752,6 +6965,7 @@
"/Tests/" "/Tests/"
] ]
}, },
"notification-url": "https://packagist.org/downloads/",
"license": [ "license": [
"MIT" "MIT"
], ],
@@ -5776,28 +6990,58 @@
"utf8" "utf8"
], ],
"support": { "support": {
"source": "https://github.com/symfony/string/tree/v8.0.8" "source": "https://github.com/symfony/string/tree/v7.4.13"
}, },
"time": "2026-03-30T15:14:47+00:00" "funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2026-05-23T15:23:29+00:00"
}, },
{ {
"name": "symfony/translation", "name": "symfony/translation",
"version": "v8.0.10", "version": "v7.4.14",
"source": {
"type": "git",
"url": "https://github.com/symfony/translation.git",
"reference": "a1af4dacb24eb7ef4f1ca71b94da8ddbce572281"
},
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://mirrors.cloud.tencent.com/repository/composer/symfony/translation/v8.0.10/symfony-translation-v8.0.10.zip", "url": "https://api.github.com/repos/symfony/translation/zipball/a1af4dacb24eb7ef4f1ca71b94da8ddbce572281",
"reference": "f63e9342e12646a57c91ef8a366a4f9d8e557b67", "reference": "a1af4dacb24eb7ef4f1ca71b94da8ddbce572281",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
"php": ">=8.4", "php": ">=8.2",
"symfony/polyfill-mbstring": "^1.0", "symfony/deprecation-contracts": "^2.5|^3",
"symfony/translation-contracts": "^3.6.1" "symfony/polyfill-mbstring": "~1.0",
"symfony/translation-contracts": "^2.5.3|^3.3"
}, },
"conflict": { "conflict": {
"nikic/php-parser": "<5.0", "nikic/php-parser": "<5.0",
"symfony/config": "<6.4",
"symfony/console": "<6.4",
"symfony/dependency-injection": "<6.4",
"symfony/http-client-contracts": "<2.5", "symfony/http-client-contracts": "<2.5",
"symfony/service-contracts": "<2.5" "symfony/http-kernel": "<6.4",
"symfony/service-contracts": "<2.5",
"symfony/twig-bundle": "<6.4",
"symfony/yaml": "<6.4"
}, },
"provide": { "provide": {
"symfony/translation-implementation": "2.3|3.0" "symfony/translation-implementation": "2.3|3.0"
@@ -5805,17 +7049,17 @@
"require-dev": { "require-dev": {
"nikic/php-parser": "^5.0", "nikic/php-parser": "^5.0",
"psr/log": "^1|^2|^3", "psr/log": "^1|^2|^3",
"symfony/config": "^7.4|^8.0", "symfony/config": "^6.4|^7.0|^8.0",
"symfony/console": "^7.4|^8.0", "symfony/console": "^6.4|^7.0|^8.0",
"symfony/dependency-injection": "^7.4|^8.0", "symfony/dependency-injection": "^6.4|^7.0|^8.0",
"symfony/finder": "^7.4|^8.0", "symfony/finder": "^6.4|^7.0|^8.0",
"symfony/http-client-contracts": "^2.5|^3.0", "symfony/http-client-contracts": "^2.5|^3.0",
"symfony/http-kernel": "^7.4|^8.0", "symfony/http-kernel": "^6.4|^7.0|^8.0",
"symfony/intl": "^7.4|^8.0", "symfony/intl": "^6.4|^7.0|^8.0",
"symfony/polyfill-intl-icu": "^1.21", "symfony/polyfill-intl-icu": "^1.21",
"symfony/routing": "^7.4|^8.0", "symfony/routing": "^6.4|^7.0|^8.0",
"symfony/service-contracts": "^2.5|^3", "symfony/service-contracts": "^2.5|^3",
"symfony/yaml": "^7.4|^8.0" "symfony/yaml": "^6.4|^7.0|^8.0"
}, },
"type": "library", "type": "library",
"autoload": { "autoload": {
@@ -5829,6 +7073,7 @@
"/Tests/" "/Tests/"
] ]
}, },
"notification-url": "https://packagist.org/downloads/",
"license": [ "license": [
"MIT" "MIT"
], ],
@@ -5845,17 +7090,40 @@
"description": "Provides tools to internationalize your application", "description": "Provides tools to internationalize your application",
"homepage": "https://symfony.com", "homepage": "https://symfony.com",
"support": { "support": {
"source": "https://github.com/symfony/translation/tree/v8.0.10" "source": "https://github.com/symfony/translation/tree/v7.4.14"
}, },
"time": "2026-05-06T11:30:54+00:00" "funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2026-06-06T09:33:19+00:00"
}, },
{ {
"name": "symfony/translation-contracts", "name": "symfony/translation-contracts",
"version": "v3.7.0", "version": "v3.7.1",
"source": {
"type": "git",
"url": "https://github.com/symfony/translation-contracts.git",
"reference": "ccb206b98faccc511ebae8e5fad50f2dc0b30621"
},
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://mirrors.cloud.tencent.com/repository/composer/symfony/translation-contracts/v3.7.0/symfony-translation-contracts-v3.7.0.zip", "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/ccb206b98faccc511ebae8e5fad50f2dc0b30621",
"reference": "0ab302977a952b42fd51475c4ebac81f8da0a95d", "reference": "ccb206b98faccc511ebae8e5fad50f2dc0b30621",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -5879,6 +7147,7 @@
"/Test/" "/Test/"
] ]
}, },
"notification-url": "https://packagist.org/downloads/",
"license": [ "license": [
"MIT" "MIT"
], ],
@@ -5903,25 +7172,48 @@
"standards" "standards"
], ],
"support": { "support": {
"source": "https://github.com/symfony/translation-contracts/tree/v3.7.0" "source": "https://github.com/symfony/translation-contracts/tree/v3.7.1"
}, },
"time": "2026-01-05T13:30:16+00:00" "funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2026-06-05T06:23:12+00:00"
}, },
{ {
"name": "symfony/uid", "name": "symfony/uid",
"version": "v8.0.9", "version": "v7.4.9",
"source": {
"type": "git",
"url": "https://github.com/symfony/uid.git",
"reference": "2676b524340abcfe4d6151ec698463cebafee439"
},
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://mirrors.cloud.tencent.com/repository/composer/symfony/uid/v8.0.9/symfony-uid-v8.0.9.zip", "url": "https://api.github.com/repos/symfony/uid/zipball/2676b524340abcfe4d6151ec698463cebafee439",
"reference": "4d9d6510bbe88ebb4608b7200d18606cdf80825c", "reference": "2676b524340abcfe4d6151ec698463cebafee439",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
"php": ">=8.4", "php": ">=8.2",
"symfony/polyfill-uuid": "^1.15" "symfony/polyfill-uuid": "^1.15"
}, },
"require-dev": { "require-dev": {
"symfony/console": "^7.4|^8.0" "symfony/console": "^6.4|^7.0|^8.0"
}, },
"type": "library", "type": "library",
"autoload": { "autoload": {
@@ -5932,6 +7224,7 @@
"/Tests/" "/Tests/"
] ]
}, },
"notification-url": "https://packagist.org/downloads/",
"license": [ "license": [
"MIT" "MIT"
], ],
@@ -5957,32 +7250,55 @@
"uuid" "uuid"
], ],
"support": { "support": {
"source": "https://github.com/symfony/uid/tree/v8.0.9" "source": "https://github.com/symfony/uid/tree/v7.4.9"
}, },
"time": "2026-04-30T16:10:06+00:00" "funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2026-04-30T15:19:22+00:00"
}, },
{ {
"name": "symfony/var-dumper", "name": "symfony/var-dumper",
"version": "v8.0.8", "version": "v7.4.14",
"source": {
"type": "git",
"url": "https://github.com/symfony/var-dumper.git",
"reference": "9a3a56a4a1e65a5cb4f8d13801fe8ab0a170e358"
},
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://mirrors.cloud.tencent.com/repository/composer/symfony/var-dumper/v8.0.8/symfony-var-dumper-v8.0.8.zip", "url": "https://api.github.com/repos/symfony/var-dumper/zipball/9a3a56a4a1e65a5cb4f8d13801fe8ab0a170e358",
"reference": "cfb7badd53bf4177f6e9416cfbbccc13c0e773a1", "reference": "9a3a56a4a1e65a5cb4f8d13801fe8ab0a170e358",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
"php": ">=8.4", "php": ">=8.2",
"symfony/polyfill-mbstring": "^1.0" "symfony/deprecation-contracts": "^2.5|^3",
"symfony/polyfill-mbstring": "~1.0"
}, },
"conflict": { "conflict": {
"symfony/console": "<7.4", "symfony/console": "<6.4"
"symfony/error-handler": "<7.4"
}, },
"require-dev": { "require-dev": {
"symfony/console": "^7.4|^8.0", "symfony/console": "^6.4|^7.0|^8.0",
"symfony/http-kernel": "^7.4|^8.0", "symfony/http-kernel": "^6.4|^7.0|^8.0",
"symfony/process": "^7.4|^8.0", "symfony/process": "^6.4|^7.0|^8.0",
"symfony/uid": "^7.4|^8.0", "symfony/uid": "^6.4|^7.0|^8.0",
"twig/twig": "^3.12" "twig/twig": "^3.12"
}, },
"bin": [ "bin": [
@@ -6000,6 +7316,7 @@
"/Tests/" "/Tests/"
] ]
}, },
"notification-url": "https://packagist.org/downloads/",
"license": [ "license": [
"MIT" "MIT"
], ],
@@ -6020,16 +7337,39 @@
"dump" "dump"
], ],
"support": { "support": {
"source": "https://github.com/symfony/var-dumper/tree/v8.0.8" "source": "https://github.com/symfony/var-dumper/tree/v7.4.14"
}, },
"time": "2026-03-31T07:15:36+00:00" "funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2026-06-08T20:24:16+00:00"
}, },
{ {
"name": "tijsverkoyen/css-to-inline-styles", "name": "tijsverkoyen/css-to-inline-styles",
"version": "v2.4.0", "version": "v2.4.0",
"source": {
"type": "git",
"url": "https://github.com/tijsverkoyen/CssToInlineStyles.git",
"reference": "f0292ccf0ec75843d65027214426b6b163b48b41"
},
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://mirrors.cloud.tencent.com/repository/composer/tijsverkoyen/css-to-inline-styles/v2.4.0/tijsverkoyen-css-to-inline-styles-v2.4.0.zip", "url": "https://api.github.com/repos/tijsverkoyen/CssToInlineStyles/zipball/f0292ccf0ec75843d65027214426b6b163b48b41",
"reference": "f0292ccf0ec75843d65027214426b6b163b48b41", "reference": "f0292ccf0ec75843d65027214426b6b163b48b41",
"shasum": "" "shasum": ""
}, },
@@ -6055,6 +7395,7 @@
"TijsVerkoyen\\CssToInlineStyles\\": "src" "TijsVerkoyen\\CssToInlineStyles\\": "src"
} }
}, },
"notification-url": "https://packagist.org/downloads/",
"license": [ "license": [
"BSD-3-Clause" "BSD-3-Clause"
], ],
@@ -6075,11 +7416,16 @@
}, },
{ {
"name": "vlucas/phpdotenv", "name": "vlucas/phpdotenv",
"version": "v5.6.3", "version": "v5.6.4",
"source": {
"type": "git",
"url": "https://github.com/vlucas/phpdotenv.git",
"reference": "416df702837983f8d5ff48c9c3fee4f5f57b980b"
},
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://mirrors.cloud.tencent.com/repository/composer/vlucas/phpdotenv/v5.6.3/vlucas-phpdotenv-v5.6.3.zip", "url": "https://api.github.com/repos/vlucas/phpdotenv/zipball/416df702837983f8d5ff48c9c3fee4f5f57b980b",
"reference": "955e7815d677a3eaa7075231212f2110983adecc", "reference": "416df702837983f8d5ff48c9c3fee4f5f57b980b",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -6114,6 +7460,7 @@
"Dotenv\\": "src/" "Dotenv\\": "src/"
} }
}, },
"notification-url": "https://packagist.org/downloads/",
"license": [ "license": [
"BSD-3-Clause" "BSD-3-Clause"
], ],
@@ -6137,16 +7484,31 @@
], ],
"support": { "support": {
"issues": "https://github.com/vlucas/phpdotenv/issues", "issues": "https://github.com/vlucas/phpdotenv/issues",
"source": "https://github.com/vlucas/phpdotenv/tree/v5.6.3" "source": "https://github.com/vlucas/phpdotenv/tree/v5.6.4"
}, },
"time": "2025-12-27T19:49:13+00:00" "funding": [
{
"url": "https://github.com/GrahamCampbell",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/vlucas/phpdotenv",
"type": "tidelift"
}
],
"time": "2026-07-06T19:11:50+00:00"
}, },
{ {
"name": "voku/portable-ascii", "name": "voku/portable-ascii",
"version": "2.1.1", "version": "2.1.1",
"source": {
"type": "git",
"url": "https://github.com/voku/portable-ascii.git",
"reference": "8e1051fe39379367aecf014f41744ce7539a856f"
},
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://mirrors.cloud.tencent.com/repository/composer/voku/portable-ascii/2.1.1/voku-portable-ascii-2.1.1.zip", "url": "https://api.github.com/repos/voku/portable-ascii/zipball/8e1051fe39379367aecf014f41744ce7539a856f",
"reference": "8e1051fe39379367aecf014f41744ce7539a856f", "reference": "8e1051fe39379367aecf014f41744ce7539a856f",
"shasum": "" "shasum": ""
}, },
@@ -6165,6 +7527,7 @@
"voku\\": "src/voku/" "voku\\": "src/voku/"
} }
}, },
"notification-url": "https://packagist.org/downloads/",
"license": [ "license": [
"MIT" "MIT"
], ],
@@ -6185,6 +7548,28 @@
"issues": "https://github.com/voku/portable-ascii/issues", "issues": "https://github.com/voku/portable-ascii/issues",
"source": "https://github.com/voku/portable-ascii/tree/2.1.1" "source": "https://github.com/voku/portable-ascii/tree/2.1.1"
}, },
"funding": [
{
"url": "https://www.paypal.me/moelleken",
"type": "custom"
},
{
"url": "https://github.com/voku",
"type": "github"
},
{
"url": "https://opencollective.com/portable-ascii",
"type": "open_collective"
},
{
"url": "https://www.patreon.com/voku",
"type": "patreon"
},
{
"url": "https://tidelift.com/funding/github/packagist/voku/portable-ascii",
"type": "tidelift"
}
],
"time": "2026-04-26T05:33:54+00:00" "time": "2026-04-26T05:33:54+00:00"
} }
], ],
@@ -6315,9 +7700,14 @@
{ {
"name": "doctrine/deprecations", "name": "doctrine/deprecations",
"version": "1.1.6", "version": "1.1.6",
"source": {
"type": "git",
"url": "https://github.com/doctrine/deprecations.git",
"reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca"
},
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://mirrors.cloud.tencent.com/repository/composer/doctrine/deprecations/1.1.6/doctrine-deprecations-1.1.6.zip", "url": "https://api.github.com/repos/doctrine/deprecations/zipball/d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca",
"reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca", "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca",
"shasum": "" "shasum": ""
}, },
@@ -6343,6 +7733,7 @@
"Doctrine\\Deprecations\\": "src" "Doctrine\\Deprecations\\": "src"
} }
}, },
"notification-url": "https://packagist.org/downloads/",
"license": [ "license": [
"MIT" "MIT"
], ],
@@ -8888,11 +10279,16 @@
}, },
{ {
"name": "webmozart/assert", "name": "webmozart/assert",
"version": "2.3.0", "version": "2.4.1",
"source": {
"type": "git",
"url": "https://github.com/webmozarts/assert.git",
"reference": "2ccb7c2e821038c03a3e6e1700c570c158c55f70"
},
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://mirrors.cloud.tencent.com/repository/composer/webmozart/assert/2.3.0/webmozart-assert-2.3.0.zip", "url": "https://api.github.com/repos/webmozarts/assert/zipball/2ccb7c2e821038c03a3e6e1700c570c158c55f70",
"reference": "eb0d790f735ba6cff25c683a85a1da0eadeff9e4", "reference": "2ccb7c2e821038c03a3e6e1700c570c158c55f70",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -8908,7 +10304,11 @@
}, },
"type": "library", "type": "library",
"extra": { "extra": {
"psalm": {
"pluginClass": "Webmozart\\Assert\\PsalmPlugin"
},
"branch-alias": { "branch-alias": {
"dev-master": "2.0-dev",
"dev-feature/2-0": "2.0-dev" "dev-feature/2-0": "2.0-dev"
} }
}, },
@@ -8917,6 +10317,7 @@
"Webmozart\\Assert\\": "src/" "Webmozart\\Assert\\": "src/"
} }
}, },
"notification-url": "https://packagist.org/downloads/",
"license": [ "license": [
"MIT" "MIT"
], ],
@@ -8938,19 +10339,22 @@
], ],
"support": { "support": {
"issues": "https://github.com/webmozarts/assert/issues", "issues": "https://github.com/webmozarts/assert/issues",
"source": "https://github.com/webmozarts/assert/tree/2.3.0" "source": "https://github.com/webmozarts/assert/tree/2.4.1"
}, },
"time": "2026-04-11T10:33:05+00:00" "time": "2026-06-15T15:31:57+00:00"
} }
], ],
"aliases": [], "aliases": [],
"minimum-stability": "stable", "minimum-stability": "stable",
"stability-flags": [], "stability-flags": {},
"prefer-stable": true, "prefer-stable": true,
"prefer-lowest": false, "prefer-lowest": false,
"platform": { "platform": {
"php": "^8.3" "php": "^8.3"
}, },
"platform-dev": [], "platform-dev": {},
"plugin-api-version": "2.6.0" "platform-overrides": {
"php": "8.3.0"
},
"plugin-api-version": "2.9.0"
} }

View File

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

View File

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

View File

@@ -0,0 +1,24 @@
<?php
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
return new class extends Migration
{
public function up(): void
{
Schema::table('players', function (Blueprint $table): void {
$table->unsignedInteger('native_token_version')
->default(0)
->after('login_locked_until');
});
}
public function down(): void
{
Schema::table('players', function (Blueprint $table): void {
$table->dropColumn('native_token_version');
});
}
};

View File

@@ -0,0 +1,17 @@
<?php
use Illuminate\Database\Migrations\Migration;
use App\Support\InvalidPlatformAgentRoleCleanup;
return new class extends Migration
{
public function up(): void
{
InvalidPlatformAgentRoleCleanup::run();
}
public function down(): void
{
// 非法的平台账号代理角色绑定无法可靠还原,回滚时保持清理结果。
}
};

View File

@@ -0,0 +1,17 @@
<?php
use Illuminate\Database\Migrations\Migration;
use App\Support\InvalidPlatformAgentRoleCleanup;
return new class extends Migration
{
public function up(): void
{
InvalidPlatformAgentRoleCleanup::run();
}
public function down(): void
{
// 不回滚:恢复跨站非法代理角色会重新扩大权限。
}
};

View File

@@ -0,0 +1,85 @@
<?php
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
return new class extends Migration
{
private const INDEX = 'uk_credit_ledger_ref_reason';
public function up(): void
{
if (! Schema::hasTable('credit_ledger')) {
return;
}
if (! Schema::hasColumn('credit_ledger', 'settlement_version')) {
Schema::table('credit_ledger', function (Blueprint $table): void {
$table->unsignedInteger('settlement_version')->default(0)->after('ref_id');
});
}
$this->dropIndex();
$this->createIndex(includeSettlementVersion: true);
}
public function down(): void
{
if (! Schema::hasTable('credit_ledger')) {
return;
}
$this->dropIndex();
$this->createIndex(includeSettlementVersion: false);
if (Schema::hasColumn('credit_ledger', 'settlement_version')) {
Schema::table('credit_ledger', function (Blueprint $table): void {
$table->dropColumn('settlement_version');
});
}
}
private function dropIndex(): void
{
if (! Schema::hasIndex('credit_ledger', self::INDEX)) {
return;
}
$driver = Schema::getConnection()->getDriverName();
if (in_array($driver, ['pgsql', 'sqlite'], true)) {
DB::statement('DROP INDEX IF EXISTS '.self::INDEX);
return;
}
Schema::table('credit_ledger', function (Blueprint $table): void {
$table->dropUnique(self::INDEX);
});
}
private function createIndex(bool $includeSettlementVersion): void
{
$columns = $includeSettlementVersion
? 'ref_type, ref_id, reason, settlement_version'
: 'ref_type, ref_id, reason';
$driver = Schema::getConnection()->getDriverName();
if (in_array($driver, ['pgsql', 'sqlite'], true)) {
DB::statement(
'CREATE UNIQUE INDEX '.self::INDEX.' ON credit_ledger ('.$columns.') WHERE ref_id IS NOT NULL',
);
return;
}
Schema::table('credit_ledger', function (Blueprint $table) use ($includeSettlementVersion): void {
$columns = ['ref_type', 'ref_id', 'reason'];
if ($includeSettlementVersion) {
$columns[] = 'settlement_version';
}
$table->unique($columns, self::INDEX);
});
}
};

View File

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

View File

@@ -0,0 +1,24 @@
<?php
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
return new class extends Migration
{
public function up(): void
{
Schema::table('players', function (Blueprint $table): void {
$table->unsignedInteger('native_session_version')
->default(0)
->after('native_token_version');
});
}
public function down(): void
{
Schema::table('players', function (Blueprint $table): void {
$table->dropColumn('native_session_version');
});
}
};

View File

@@ -0,0 +1,24 @@
<?php
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
return new class extends Migration
{
public function up(): void
{
Schema::table('admin_users', function (Blueprint $table): void {
$table->unsignedInteger('admin_session_version')
->default(0)
->after('last_login_at');
});
}
public function down(): void
{
Schema::table('admin_users', function (Blueprint $table): void {
$table->dropColumn('admin_session_version');
});
}
};

View File

@@ -5,6 +5,7 @@ return [
'invalid_captcha' => 'Invalid or expired captcha.', 'invalid_captcha' => 'Invalid or expired captcha.',
'invalid_credentials' => 'Invalid account or password.', 'invalid_credentials' => 'Invalid account or password.',
'account_disabled' => 'This account has been disabled.', 'account_disabled' => 'This account has been disabled.',
'session_replaced' => 'This account was signed in elsewhere. The current session has been signed out.',
'permission_denied' => 'You do not have permission to perform this action.', 'permission_denied' => 'You do not have permission to perform this action.',
'forbidden' => 'You do not have permission to perform this action.', 'forbidden' => 'You do not have permission to perform this action.',
'settlement_run_skipped' => 'Settlement was not run for this draw (check draw status and published result batch).', 'settlement_run_skipped' => 'Settlement was not run for this draw (check draw status and published result batch).',
@@ -45,6 +46,7 @@ return [
'agent_account_managed_in_agents' => 'Agent accounts must be managed in Agent Operations, not in the platform accounts page.', 'agent_account_managed_in_agents' => 'Agent accounts must be managed in Agent Operations, not in the platform accounts page.',
'agent_role_managed_in_agents' => 'Agent roles must be managed in Agent Operations, not in the platform roles page.', 'agent_role_managed_in_agents' => 'Agent roles must be managed in Agent Operations, not in the platform roles page.',
'system_roles_only' => 'Platform accounts can only be assigned platform roles.', 'system_roles_only' => 'Platform accounts can only be assigned platform roles.',
'agent_role_not_assignable_to_platform_account' => 'The Agent role cannot be assigned to a platform account. Create or bind agent accounts in Agent Management.',
'user_cannot_delete_self' => 'Cannot delete your own account.', 'user_cannot_delete_self' => 'Cannot delete your own account.',
'user_cannot_delete_last_super_admin' => 'Cannot delete the last super admin.', 'user_cannot_delete_last_super_admin' => 'Cannot delete the last super admin.',
'super_admin_only_for_roles' => 'Only super admins can manage roles.', 'super_admin_only_for_roles' => 'Only super admins can manage roles.',

View File

@@ -41,6 +41,7 @@ return [
'jackpot_already_allocated_for_draw' => 'Jackpot was already allocated for this draw.', 'jackpot_already_allocated_for_draw' => 'Jackpot was already allocated for this draw.',
'draw_not_ready_for_jackpot_burst' => 'Draw is not in settling or settled status.', 'draw_not_ready_for_jackpot_burst' => 'Draw is not in settling or settled status.',
'draw_result_not_published' => 'Draw results have not been published.', 'draw_result_not_published' => 'Draw results have not been published.',
'draw_not_ready_for_settlement' => 'This draw cannot be settled in its current state. Settlement may only be started early during cooldown or retried while settling.',
'settlement_batch_not_found' => 'Settlement batch not found for this draw.', 'settlement_batch_not_found' => 'Settlement batch not found for this draw.',
'settlement_not_pending_review' => 'Settlement batch is not pending review.', 'settlement_not_pending_review' => 'Settlement batch is not pending review.',
'settlement_not_approved' => 'Settlement batch is not approved.', 'settlement_not_approved' => 'Settlement batch is not approved.',

View File

@@ -16,4 +16,5 @@ return [
'8007' => 'Too many failed attempts. Try again later', '8007' => 'Too many failed attempts. Try again later',
'8008' => 'Please sign in through the main site', '8008' => 'Please sign in through the main site',
'8009' => 'Invalid or expired captcha.', '8009' => 'Invalid or expired captcha.',
'8010' => 'This account was signed in on another device. Please sign in again.',
]; ];

View File

@@ -168,4 +168,6 @@ return [
'supports_multi_number' => 'supports multi-number', 'supports_multi_number' => 'supports multi-number',
'reserved_rule_json' => 'reserved rules', 'reserved_rule_json' => 'reserved rules',
'extra_config_json' => 'extra config', 'extra_config_json' => 'extra config',
'current_password' => 'current password',
'password_confirmation' => 'password confirmation',
]; ];

View File

@@ -23,6 +23,9 @@ return [
'agent_profile_required' => 'Configure share rate and credit on the agent profile first.', 'agent_profile_required' => 'Configure share rate and credit on the agent profile first.',
'parent_profile_required' => 'Configure share rate and credit on the parent agent profile first.', 'parent_profile_required' => 'Configure share rate and credit on the parent agent profile first.',
'wallet_player_prohibited' => 'Wallet players cannot have credit limits or rebate settings.', 'wallet_player_prohibited' => 'Wallet players cannot have credit limits or rebate settings.',
'current_password_invalid' => 'The current password is incorrect.',
'new_password_must_differ' => 'The new password must differ from the current password.',
'native_password_unavailable' => 'Main-site SSO players do not use a lottery password.',
'exceeds_unpaid' => 'Payment amount cannot exceed the unpaid balance on this bill.', 'exceeds_unpaid' => 'Payment amount cannot exceed the unpaid balance on this bill.',
'invalid_range' => 'Share rate must be between 0 and 100.', 'invalid_range' => 'Share rate must be between 0 and 100.',
'below_allocated' => 'Credit limit cannot be lower than credit already allocated to sub-agents.', 'below_allocated' => 'Credit limit cannot be lower than credit already allocated to sub-agents.',

View File

@@ -5,6 +5,7 @@ return [
'invalid_captcha' => 'क्याप्चा गलत वा म्याद सकिएको छ।', 'invalid_captcha' => 'क्याप्चा गलत वा म्याद सकिएको छ।',
'invalid_credentials' => 'खाता वा पासवर्ड गलत छ।', 'invalid_credentials' => 'खाता वा पासवर्ड गलत छ।',
'account_disabled' => 'यो खाता निष्क्रिय गरिएको छ।', 'account_disabled' => 'यो खाता निष्क्रिय गरिएको छ।',
'session_replaced' => 'यो खाता अर्को स्थानमा लगइन भएको छ। हालको सत्र लगआउट गरिएको छ।',
'permission_denied' => 'यो कार्य गर्ने अनुमति छैन।', 'permission_denied' => 'यो कार्य गर्ने अनुमति छैन।',
'forbidden' => 'यो कार्य गर्ने अनुमति छैन।', 'forbidden' => 'यो कार्य गर्ने अनुमति छैन।',
'settlement_run_skipped' => 'यो ड्रको सेटलमेन्ट चलाइएन (ड्र स्थिति र प्रकाशित नतिजा जाँच गर्नुहोस्)।', 'settlement_run_skipped' => 'यो ड्रको सेटलमेन्ट चलाइएन (ड्र स्थिति र प्रकाशित नतिजा जाँच गर्नुहोस्)।',
@@ -41,6 +42,7 @@ return [
'agent_account_managed_in_agents' => 'एजेन्ट खाता प्लेटफर्म खाता पृष्ठबाट होइन, एजेन्ट अपरेसनबाट व्यवस्थापन गर्नुपर्छ।', 'agent_account_managed_in_agents' => 'एजेन्ट खाता प्लेटफर्म खाता पृष्ठबाट होइन, एजेन्ट अपरेसनबाट व्यवस्थापन गर्नुपर्छ।',
'agent_role_managed_in_agents' => 'एजेन्ट भूमिका प्लेटफर्म भूमिका पृष्ठबाट होइन, एजेन्ट अपरेसनबाट व्यवस्थापन गर्नुपर्छ।', 'agent_role_managed_in_agents' => 'एजेन्ट भूमिका प्लेटफर्म भूमिका पृष्ठबाट होइन, एजेन्ट अपरेसनबाट व्यवस्थापन गर्नुपर्छ।',
'system_roles_only' => 'प्लेटफर्म खातामा प्लेटफर्म भूमिका मात्र बाँड्न सकिन्छ।', 'system_roles_only' => 'प्लेटफर्म खातामा प्लेटफर्म भूमिका मात्र बाँड्न सकिन्छ।',
'agent_role_not_assignable_to_platform_account' => 'प्लेटफर्म खातामा “एजेन्ट” भूमिका दिन मिल्दैन। एजेन्ट व्यवस्थापनमा एजेन्ट खाता सिर्जना वा बाँध्नुहोस्।',
'user_cannot_delete_self' => 'आफ्नै खाता मेटाउन मिल्दैन।', 'user_cannot_delete_self' => 'आफ्नै खाता मेटाउन मिल्दैन।',
'user_cannot_delete_last_super_admin' => 'अन्तिम सुपर एडमिन मेटाउन मिल्दैन।', 'user_cannot_delete_last_super_admin' => 'अन्तिम सुपर एडमिन मेटाउन मिल्दैन।',
'super_admin_only_for_roles' => 'भूमिका व्यवस्थापन केवल सुपर एडमिनले गर्न सक्छ।', 'super_admin_only_for_roles' => 'भूमिका व्यवस्थापन केवल सुपर एडमिनले गर्न सक्छ।',

View File

@@ -41,6 +41,7 @@ return [
'jackpot_already_allocated_for_draw' => 'यो ड्रमा ज्याकपोट पहिले नै बाँडिएको छ।', 'jackpot_already_allocated_for_draw' => 'यो ड्रमा ज्याकपोट पहिले नै बाँडिएको छ।',
'draw_not_ready_for_jackpot_burst' => 'ड्र settling वा settled अवस्थामा छैन।', 'draw_not_ready_for_jackpot_burst' => 'ड्र settling वा settled अवस्थामा छैन।',
'draw_result_not_published' => 'ड्र नतिजा प्रकाशित भएको छैन।', 'draw_result_not_published' => 'ड्र नतिजा प्रकाशित भएको छैन।',
'draw_not_ready_for_settlement' => 'हालको अवस्थामा यो ड्र सेटल गर्न मिल्दैन। कूलडाउनमा मात्र छिटो सेटल गर्न वा सेटल हुँदै गर्दा पुनः प्रयास गर्न सकिन्छ।',
'settlement_batch_not_found' => 'यो ड्रको सेटलमेन्ट ब्याच फेला परेन।', 'settlement_batch_not_found' => 'यो ड्रको सेटलमेन्ट ब्याच फेला परेन।',
'settlement_not_pending_review' => 'सेटलमेन्ट ब्याच समीक्षामा छैन।', 'settlement_not_pending_review' => 'सेटलमेन्ट ब्याच समीक्षामा छैन।',
'settlement_not_approved' => 'सेटलमेन्ट ब्याच स्वीकृत छैन।', 'settlement_not_approved' => 'सेटलमेन्ट ब्याच स्वीकृत छैन।',

View File

@@ -13,4 +13,5 @@ return [
'8007' => 'धेरै असफल प्रयास। पछि फेरि प्रयास गर्नुहोस्', '8007' => 'धेरै असफल प्रयास। पछि फेरि प्रयास गर्नुहोस्',
'8008' => 'कृपया मुख्य साइटबाट लगइन गर्नुहोस्', '8008' => 'कृपया मुख्य साइटबाट लगइन गर्नुहोस्',
'8009' => 'क्याप्चा गलत वा म्याद सकिएको छ।', '8009' => 'क्याप्चा गलत वा म्याद सकिएको छ।',
'8010' => 'यो खाता अर्को उपकरणमा लगइन गरिएको छ। कृपया फेरि लगइन गर्नुहोस्।',
]; ];

View File

@@ -168,4 +168,6 @@ return [
'supports_multi_number' => 'बहु-नम्बर समर्थन', 'supports_multi_number' => 'बहु-नम्बर समर्थन',
'reserved_rule_json' => 'आरक्षित नियमहरू', 'reserved_rule_json' => 'आरक्षित नियमहरू',
'extra_config_json' => 'अतिरिक्त कन्फिग', 'extra_config_json' => 'अतिरिक्त कन्फिग',
'current_password' => 'हालको पासवर्ड',
'password_confirmation' => 'पासवर्ड पुष्टि',
]; ];

View File

@@ -23,6 +23,9 @@ return [
'agent_profile_required' => 'पहिले एजेन्ट प्रोफाइलमा शेयर दर र क्रेडिट कन्फिगर गर्नुहोस्।', 'agent_profile_required' => 'पहिले एजेन्ट प्रोफाइलमा शेयर दर र क्रेडिट कन्फिगर गर्नुहोस्।',
'parent_profile_required' => 'पहिले माथिल्लो एजेन्ट प्रोफाइलमा शेयर दर र क्रेडिट कन्फिगर गर्नुहोस्।', 'parent_profile_required' => 'पहिले माथिल्लो एजेन्ट प्रोफाइलमा शेयर दर र क्रेडिट कन्फिगर गर्नुहोस्।',
'wallet_player_prohibited' => 'वालेट खेलाडीहरूसँग क्रेडिट सीमा वा रिबेट सेटिङ हुन सक्दैन।', 'wallet_player_prohibited' => 'वालेट खेलाडीहरूसँग क्रेडिट सीमा वा रिबेट सेटिङ हुन सक्दैन।',
'current_password_invalid' => 'हालको पासवर्ड गलत छ।',
'new_password_must_differ' => 'नयाँ पासवर्ड हालको पासवर्डभन्दा फरक हुनुपर्छ।',
'native_password_unavailable' => 'मुख्य साइट SSO खेलाडीले लटरी पासवर्ड प्रयोग गर्दैन।',
'exceeds_unpaid' => 'भुक्तानी रकम यस बिलको बाँकी रकम भन्दा बढी हुन सक्दैन।', 'exceeds_unpaid' => 'भुक्तानी रकम यस बिलको बाँकी रकम भन्दा बढी हुन सक्दैन।',
'invalid_range' => 'शेयर दर र १०० बीचमा हुनुपर्छ।', 'invalid_range' => 'शेयर दर र १०० बीचमा हुनुपर्छ।',
'below_allocated' => 'क्रेडिट सीमा तलका एजेन्टहरूलाई पहिले नै आवण्टित क्रेडिट भन्दा कम हुन सक्दैन।', 'below_allocated' => 'क्रेडिट सीमा तलका एजेन्टहरूलाई पहिले नै आवण्टित क्रेडिट भन्दा कम हुन सक्दैन।',

View File

@@ -5,6 +5,7 @@ return [
'invalid_captcha' => '验证码错误或已过期,请重试。', 'invalid_captcha' => '验证码错误或已过期,请重试。',
'invalid_credentials' => '账号或密码错误。', 'invalid_credentials' => '账号或密码错误。',
'account_disabled' => '该账号已被禁用。', 'account_disabled' => '该账号已被禁用。',
'session_replaced' => '该账号已在其他地方登录,当前会话已退出。',
'permission_denied' => '当前账号无此操作权限。', 'permission_denied' => '当前账号无此操作权限。',
'forbidden' => '当前账号无此操作权限。', 'forbidden' => '当前账号无此操作权限。',
'settlement_run_skipped' => '本期未执行结算(请检查期号状态与已发布开奖批次)。', 'settlement_run_skipped' => '本期未执行结算(请检查期号状态与已发布开奖批次)。',
@@ -45,6 +46,7 @@ return [
'agent_account_managed_in_agents' => '代理账号请到「代理经营」中管理,平台账号页不再支持此操作。', 'agent_account_managed_in_agents' => '代理账号请到「代理经营」中管理,平台账号页不再支持此操作。',
'agent_role_managed_in_agents' => '代理角色请到「代理经营」中管理,平台角色页不再支持此操作。', 'agent_role_managed_in_agents' => '代理角色请到「代理经营」中管理,平台角色页不再支持此操作。',
'system_roles_only' => '平台账号只能分配平台角色。', 'system_roles_only' => '平台账号只能分配平台角色。',
'agent_role_not_assignable_to_platform_account' => '平台账号不能分配“代理”角色;请在“代理管理”中创建或绑定代理账号。',
'user_cannot_delete_self' => '不能删除当前登录账号。', 'user_cannot_delete_self' => '不能删除当前登录账号。',
'user_cannot_delete_last_super_admin' => '不能删除最后一个超级管理员。', 'user_cannot_delete_last_super_admin' => '不能删除最后一个超级管理员。',
'super_admin_only_for_roles' => '仅超级管理员可管理角色。', 'super_admin_only_for_roles' => '仅超级管理员可管理角色。',

View File

@@ -41,6 +41,7 @@ return [
'jackpot_already_allocated_for_draw' => '该期已分配过奖池派彩。', 'jackpot_already_allocated_for_draw' => '该期已分配过奖池派彩。',
'draw_not_ready_for_jackpot_burst' => '期号尚未进入结算中或已结算,无法手动爆池。', 'draw_not_ready_for_jackpot_burst' => '期号尚未进入结算中或已结算,无法手动爆池。',
'draw_result_not_published' => '该期开奖结果尚未发布。', 'draw_result_not_published' => '该期开奖结果尚未发布。',
'draw_not_ready_for_settlement' => '当前期号状态不允许结算;仅可在冷静期提前结算,或在结算中重新触发。',
'settlement_batch_not_found' => '未找到该期的结算批次。', 'settlement_batch_not_found' => '未找到该期的结算批次。',
'settlement_not_pending_review' => '结算批次不在待审核状态。', 'settlement_not_pending_review' => '结算批次不在待审核状态。',
'settlement_not_approved' => '结算批次尚未审核通过。', 'settlement_not_approved' => '结算批次尚未审核通过。',

View File

@@ -13,4 +13,5 @@ return [
'8007' => '登录失败次数过多,请稍后再试', '8007' => '登录失败次数过多,请稍后再试',
'8008' => '请使用主站登录进入彩票', '8008' => '请使用主站登录进入彩票',
'8009' => '验证码错误或已过期,请重试。', '8009' => '验证码错误或已过期,请重试。',
'8010' => '该账号已在其他设备登录,请重新登录。',
]; ];

View File

@@ -169,4 +169,6 @@ return [
'can_grant_extra_rebate' => '允许额外回水', 'can_grant_extra_rebate' => '允许额外回水',
'can_create_child_agent' => '允许创建下级代理', 'can_create_child_agent' => '允许创建下级代理',
'can_create_player' => '允许创建玩家', 'can_create_player' => '允许创建玩家',
'current_password' => '当前密码',
'password_confirmation' => '确认密码',
]; ];

View File

@@ -24,6 +24,9 @@ return [
'exceeds_default_rebate_limit' => '默认玩家回水不能超过本节点回水上限。', 'exceeds_default_rebate_limit' => '默认玩家回水不能超过本节点回水上限。',
'not_allowed' => '当前代理未开放该能力,无法设置。', 'not_allowed' => '当前代理未开放该能力,无法设置。',
'wallet_player_prohibited' => '主站钱包玩家不支持授信额度与回水设置。', 'wallet_player_prohibited' => '主站钱包玩家不支持授信额度与回水设置。',
'current_password_invalid' => '当前密码不正确。',
'new_password_must_differ' => '新密码不能与当前密码相同。',
'native_password_unavailable' => '主站 SSO 玩家不使用彩票端密码。',
'exceeds_unpaid' => '收付金额不能超过账单未结金额。', 'exceeds_unpaid' => '收付金额不能超过账单未结金额。',
'invalid_range' => '占成比例必须在 0100 之间。', 'invalid_range' => '占成比例必须在 0100 之间。',
'below_allocated' => '代理授信额度不能低于已下发给下级代理与玩家的总额。', 'below_allocated' => '代理授信额度不能低于已下发给下级代理与玩家的总额。',

View File

@@ -21,6 +21,7 @@
<php> <php>
<ini name="memory_limit" value="512M"/> <ini name="memory_limit" value="512M"/>
<env name="APP_ENV" value="testing"/> <env name="APP_ENV" value="testing"/>
<env name="APP_KEY" value="base64:MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY="/>
<env name="APP_MAINTENANCE_DRIVER" value="file"/> <env name="APP_MAINTENANCE_DRIVER" value="file"/>
<env name="BCRYPT_ROUNDS" value="4"/> <env name="BCRYPT_ROUNDS" value="4"/>
<env name="BROADCAST_CONNECTION" value="null"/> <env name="BROADCAST_CONNECTION" value="null"/>

View File

@@ -2,6 +2,7 @@
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
use App\Http\Controllers\Api\V1\Admin\Auth\MeController; use App\Http\Controllers\Api\V1\Admin\Auth\MeController;
use App\Http\Controllers\Api\V1\Admin\Auth\LogoutController;
use App\Http\Controllers\Api\V1\Admin\Audit\AuditLogIndexController; use App\Http\Controllers\Api\V1\Admin\Audit\AuditLogIndexController;
use App\Http\Controllers\Api\V1\Admin\Dashboard\AdminDashboardAnalyticsController; use App\Http\Controllers\Api\V1\Admin\Dashboard\AdminDashboardAnalyticsController;
use App\Http\Controllers\Api\V1\Admin\Dashboard\AdminDashboardController; use App\Http\Controllers\Api\V1\Admin\Dashboard\AdminDashboardController;
@@ -30,6 +31,10 @@ Route::get('auth/me', MeController::class)
->middleware('admin.api-resource') ->middleware('admin.api-resource')
->name('api.v1.admin.auth.me'); ->name('api.v1.admin.auth.me');
Route::post('auth/logout', LogoutController::class)
->middleware('admin.api-resource')
->name('api.v1.admin.auth.logout');
// 审计日志 // 审计日志
Route::middleware('admin.api-resource') Route::middleware('admin.api-resource')
->get('audit-logs', AuditLogIndexController::class) ->get('audit-logs', AuditLogIndexController::class)

View File

@@ -1,14 +1,15 @@
<?php <?php
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
use App\Http\Controllers\Api\V1\Admin\Player\AdminPlayerShowController;
use App\Http\Controllers\Api\V1\Admin\Player\AdminPlayerIndexController; use App\Http\Controllers\Api\V1\Admin\Player\AdminPlayerIndexController;
use App\Http\Controllers\Api\V1\Admin\Player\AdminPlayerStoreController; use App\Http\Controllers\Api\V1\Admin\Player\AdminPlayerStoreController;
use App\Http\Controllers\Api\V1\Admin\Player\AdminPlayerShowController; use App\Http\Controllers\Api\V1\Admin\Player\PlayerWalletShowController;
use App\Http\Controllers\Api\V1\Admin\Player\AdminPlayerFreezeController;
use App\Http\Controllers\Api\V1\Admin\Player\AdminPlayerUpdateController; use App\Http\Controllers\Api\V1\Admin\Player\AdminPlayerUpdateController;
use App\Http\Controllers\Api\V1\Admin\Player\AdminPlayerDestroyController; use App\Http\Controllers\Api\V1\Admin\Player\AdminPlayerDestroyController;
use App\Http\Controllers\Api\V1\Admin\Player\AdminPlayerFreezeController;
use App\Http\Controllers\Api\V1\Admin\Player\AdminPlayerUnfreezeController; use App\Http\Controllers\Api\V1\Admin\Player\AdminPlayerUnfreezeController;
use App\Http\Controllers\Api\V1\Admin\Player\PlayerWalletShowController; use App\Http\Controllers\Api\V1\Admin\Player\AdminPlayerPasswordResetController;
use App\Http\Controllers\Api\V1\Admin\Player\AdminPlayerTicketItemsIndexController; use App\Http\Controllers\Api\V1\Admin\Player\AdminPlayerTicketItemsIndexController;
/** /**
@@ -24,6 +25,8 @@ Route::middleware('admin.api-resource')
->name('api.v1.admin.players.show'); ->name('api.v1.admin.players.show');
Route::put('players/{player}', AdminPlayerUpdateController::class) Route::put('players/{player}', AdminPlayerUpdateController::class)
->name('api.v1.admin.players.update'); ->name('api.v1.admin.players.update');
Route::put('players/{player}/password', AdminPlayerPasswordResetController::class)
->name('api.v1.admin.players.password.reset');
Route::post('players/{player}/freeze', AdminPlayerFreezeController::class) Route::post('players/{player}/freeze', AdminPlayerFreezeController::class)
->name('api.v1.admin.players.freeze'); ->name('api.v1.admin.players.freeze');
Route::post('players/{player}/unfreeze', AdminPlayerUnfreezeController::class) Route::post('players/{player}/unfreeze', AdminPlayerUnfreezeController::class)

View File

@@ -6,13 +6,14 @@ use App\Http\Controllers\Api\V1\Wallet\WalletLogsController;
use App\Http\Controllers\Api\V1\Ticket\TicketPlaceController; use App\Http\Controllers\Api\V1\Ticket\TicketPlaceController;
use App\Http\Controllers\Api\V1\Ticket\TicketPreviewController; use App\Http\Controllers\Api\V1\Ticket\TicketPreviewController;
use App\Http\Controllers\Api\V1\Wallet\WalletBalanceController; use App\Http\Controllers\Api\V1\Wallet\WalletBalanceController;
use App\Http\Controllers\Api\V1\Wallet\WalletSettlementBillsController;
use App\Http\Controllers\Api\V1\Ticket\TicketItemShowController; use App\Http\Controllers\Api\V1\Ticket\TicketItemShowController;
use App\Http\Controllers\Api\V1\Ticket\TicketItemsIndexController; use App\Http\Controllers\Api\V1\Ticket\TicketItemsIndexController;
use App\Http\Controllers\Api\V1\Wallet\WalletTransferInController; use App\Http\Controllers\Api\V1\Wallet\WalletTransferInController;
use App\Http\Controllers\Api\V1\Ticket\TicketDrawMyMatchController; use App\Http\Controllers\Api\V1\Ticket\TicketDrawMyMatchController;
use App\Http\Controllers\Api\V1\Wallet\WalletTransferOutController; use App\Http\Controllers\Api\V1\Wallet\WalletTransferOutController;
use App\Http\Controllers\Api\V1\Player\PlayerPasswordUpdateController;
use App\Http\Controllers\Api\V1\BetProvider\BetProviderIndexController; use App\Http\Controllers\Api\V1\BetProvider\BetProviderIndexController;
use App\Http\Controllers\Api\V1\Wallet\WalletSettlementBillsController;
/** /**
* 玩家端路由(需 middleware lottery.player * 玩家端路由(需 middleware lottery.player
@@ -23,6 +24,9 @@ Route::middleware('lottery.player')->group(function (): void {
->name('api.v1.player.') ->name('api.v1.player.')
->group(function (): void { ->group(function (): void {
Route::get('me', MeController::class)->name('me'); Route::get('me', MeController::class)->name('me');
Route::put('password', PlayerPasswordUpdateController::class)
->middleware('throttle:player-password-change')
->name('password.update');
}); });
// 钱包 // 钱包
@@ -47,7 +51,7 @@ Route::middleware('lottery.player')->group(function (): void {
->where('ticket_no', 'TK[0-9]+') ->where('ticket_no', 'TK[0-9]+')
->name('items.show'); ->name('items.show');
Route::get('draws/{draw_no}/my-match', TicketDrawMyMatchController::class) Route::get('draws/{draw_no}/my-match', TicketDrawMyMatchController::class)
->where('draw_no', '[0-9]{8}-[0-9]{3}') ->where('draw_no', '[0-9]{8}-[0-9]{3,}')
->name('draws.my-match'); ->name('draws.my-match');
}); });

View File

@@ -4,14 +4,14 @@ use Illuminate\Support\Facades\Route;
use App\Http\Controllers\Api\V1\HealthController; use App\Http\Controllers\Api\V1\HealthController;
use App\Http\Controllers\Api\V1\Draw\DrawCurrentController; use App\Http\Controllers\Api\V1\Draw\DrawCurrentController;
use App\Http\Controllers\Api\V1\Draw\DrawResultShowController; use App\Http\Controllers\Api\V1\Draw\DrawResultShowController;
use App\Http\Controllers\Api\V1\Setting\SettingIndexController;
use App\Http\Controllers\Api\V1\Draw\DrawResultsIndexController; use App\Http\Controllers\Api\V1\Draw\DrawResultsIndexController;
use App\Http\Controllers\Api\V1\Currency\CurrencyIndexController; use App\Http\Controllers\Api\V1\Currency\CurrencyIndexController;
use App\Http\Controllers\Api\V1\Jackpot\JackpotSummaryController; use App\Http\Controllers\Api\V1\Jackpot\JackpotSummaryController;
use App\Http\Controllers\Api\V1\Player\PlayerAuthLoginController;
use App\Http\Controllers\Api\V1\Player\PlayerAuthCaptchaController;
use App\Http\Controllers\Api\V1\Play\PlayEffectiveCatalogController; use App\Http\Controllers\Api\V1\Play\PlayEffectiveCatalogController;
use App\Http\Controllers\Api\V1\Player\PingController as PlayerPingController; use App\Http\Controllers\Api\V1\Player\PingController as PlayerPingController;
use App\Http\Controllers\Api\V1\Player\PlayerAuthCaptchaController;
use App\Http\Controllers\Api\V1\Player\PlayerAuthLoginController;
use App\Http\Controllers\Api\V1\Setting\SettingIndexController;
use App\Http\Controllers\Api\V1\Integration\IntegrationRuntimeOriginsController; use App\Http\Controllers\Api\V1\Integration\IntegrationRuntimeOriginsController;
/** /**
@@ -28,7 +28,7 @@ Route::get('currencies', CurrencyIndexController::class)->name('api.v1.currencie
Route::get('draw/current', DrawCurrentController::class)->name('api.v1.draw.current'); Route::get('draw/current', DrawCurrentController::class)->name('api.v1.draw.current');
Route::get('draw/results', DrawResultsIndexController::class)->name('api.v1.draw.results'); Route::get('draw/results', DrawResultsIndexController::class)->name('api.v1.draw.results');
Route::get('draw/results/{draw_no}', DrawResultShowController::class) Route::get('draw/results/{draw_no}', DrawResultShowController::class)
->where('draw_no', '[0-9]{8}-[0-9]{3}') ->where('draw_no', '[0-9]{8}-[0-9]{3,}')
->name('api.v1.draw.results.show'); ->name('api.v1.draw.results.show');
// 奖池水位(公开) // 奖池水位(公开)

View File

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

View File

@@ -1,6 +1,7 @@
<?php <?php
use App\Models\AdminUser; use App\Models\AdminUser;
use App\Lottery\ErrorCode;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
@@ -112,7 +113,8 @@ test('agent line provision rejects site that already has root', function (): voi
'password' => 'secret-strong', 'password' => 'secret-strong',
]) ])
->assertStatus(422) ->assertStatus(422)
->assertJsonPath('data.errors.site_code.0', 'site_root_exists'); ->assertJsonPath('code', ErrorCode::ValidationFailed->value)
->assertJsonStructure(['data' => ['errors' => ['site_code']]]);
}); });
test('integration manager with site.manage can create integration site', function (): void { test('integration manager with site.manage can create integration site', function (): void {

View File

@@ -1,8 +1,10 @@
<?php <?php
use App\Models\Draw;
use App\Models\AdminRole; use App\Models\AdminRole;
use App\Models\AdminUser; use App\Models\AdminUser;
use App\Lottery\ErrorCode; use App\Lottery\ErrorCode;
use App\Lottery\DrawStatus;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
use App\Support\AdminPermissionBridge; use App\Support\AdminPermissionBridge;
@@ -95,6 +97,25 @@ test('admin api resource middleware denies wallet reconcile resource without per
->assertJsonPath('code', ErrorCode::AdminForbidden->value); ->assertJsonPath('code', ErrorCode::AdminForbidden->value);
}); });
test('admin api resource middleware denies settlement review only account from running draw settlement', function (): void {
$token = mintAdminTokenWithLegacySlugs('resource_settlement_reviewer', ['prd.payout.review']);
$draw = Draw::query()->create([
'draw_no' => '20260722-2001',
'business_date' => '2026-07-22',
'sequence_no' => 2001,
'status' => DrawStatus::Cooldown->value,
'cooling_end_time' => now()->addMinutes(10),
'settle_version' => 0,
]);
$this->withHeader('Authorization', 'Bearer '.$token)
->postJson("/api/v1/admin/draws/{$draw->id}/settlement/run")
->assertForbidden()
->assertJsonPath('code', ErrorCode::AdminForbidden->value);
expect($draw->fresh()->status)->toBe(DrawStatus::Cooldown->value);
});
test('admin api resource middleware allows wallet reconcile resource with mapped permission', function (): void { test('admin api resource middleware allows wallet reconcile resource with mapped permission', function (): void {
$token = mintAdminTokenWithLegacySlugs('resource_wallet_viewer', ['prd.wallet_reconcile.view']); $token = mintAdminTokenWithLegacySlugs('resource_wallet_viewer', ['prd.wallet_reconcile.view']);

View File

@@ -2,11 +2,12 @@
use App\Models\AuditLog; use App\Models\AuditLog;
use App\Models\AdminUser; use App\Models\AdminUser;
use App\Support\AuditLogApiPresenter;
use App\Lottery\ErrorCode; use App\Lottery\ErrorCode;
use App\Services\AuditLogger;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
use App\Services\AuditLogger; use App\Support\AuditLogApiPresenter;
use App\Services\Agent\AgentNodeService;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class); uses(RefreshDatabase::class);
@@ -36,6 +37,24 @@ test('audit log presenter maps business action and entity target', function ():
->and($payload['summary_label'])->toBe('代理管理 · 同步代理角色权限(角色 #5'); ->and($payload['summary_label'])->toBe('代理管理 · 同步代理角色权限(角色 #5');
}); });
test('audit log presenter translates automatic payout failures', function (): void {
$row = new AuditLog([
'operator_type' => 'system',
'operator_id' => 0,
'module_code' => 'settlement',
'action_code' => 'auto_payout_failed',
'target_type' => 'settlement_batch',
'target_id' => '1107',
]);
$row->id = 2;
$payload = AuditLogApiPresenter::row($row);
expect($payload['action_label'])->toBe('自动派彩失败')
->and($payload['target_label'])->toBe('结算批次 #1107')
->and($payload['summary_label'])->toBe('派彩结算 · 自动派彩失败(结算批次 #1107');
});
test('audit log presenter uses admin api resource name for middleware style rows', function (): void { test('audit log presenter uses admin api resource name for middleware style rows', function (): void {
$resourceName = (string) DB::table('admin_api_resources') $resourceName = (string) DB::table('admin_api_resources')
->where('code', 'admin.agent-roles.permissions.sync') ->where('code', 'admin.agent-roles.permissions.sync')
@@ -63,7 +82,7 @@ test('audit log presenter uses admin api resource name for middleware style rows
test('agent role permission sync records one business audit and skips middleware duplicate', function (): void { test('agent role permission sync records one business audit and skips middleware duplicate', function (): void {
$siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id'); $siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id');
$rootId = (int) DB::table('agent_nodes')->where('admin_site_id', $siteId)->where('depth', 0)->value('id'); $rootId = (int) DB::table('agent_nodes')->where('admin_site_id', $siteId)->where('depth', 0)->value('id');
$service = app(\App\Services\Agent\AgentNodeService::class); $service = app(AgentNodeService::class);
$super = AdminUser::query()->create([ $super = AdminUser::query()->create([
'username' => 'audit_dedup_super', 'username' => 'audit_dedup_super',

View File

@@ -2,8 +2,8 @@
use App\Models\AdminUser; use App\Models\AdminUser;
use App\Lottery\ErrorCode; use App\Lottery\ErrorCode;
use App\Support\SitePlatformRole;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use App\Support\SitePlatformRole;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
@@ -92,6 +92,89 @@ test('admin login returns bearer token when captcha passes validation', function
->assertJsonPath('data.scope', 'admin'); ->assertJsonPath('data.scope', 'admin');
}); });
test('later admin login replaces the previous browser session', function () {
$admin = AdminUser::query()->create([
'username' => 'single_session_admin',
'name' => '单会话管理员',
'email' => null,
'password' => 'secret-strong',
'status' => 0,
]);
grantSuperAdminRole($admin);
$login = function () {
$captchaKey = (string) Str::uuid();
Cache::put(
'admin_captcha:'.$captchaKey,
hash_hmac('sha256', 'xwz2', (string) config('app.key')),
now()->addSeconds(120),
);
return $this->postJson('/api/v1/admin/auth/login', [
'account' => 'single_session_admin',
'password' => 'secret-strong',
'captcha_key' => $captchaKey,
'captcha_code' => 'xwz2',
])->assertOk();
};
$firstToken = (string) $login()->json('data.token');
$this->withHeader('Authorization', 'Bearer '.$firstToken)
->getJson('/api/v1/admin/ping')
->assertOk();
$secondToken = (string) $login()->json('data.token');
app('auth')->forgetGuards();
$this->withHeader('Authorization', 'Bearer '.$firstToken)
->getJson('/api/v1/admin/ping')
->assertUnauthorized()
->assertJsonPath('code', ErrorCode::AdminSessionReplaced->value);
app('auth')->forgetGuards();
$this->withHeader('Authorization', 'Bearer '.$secondToken)
->getJson('/api/v1/admin/ping')
->assertOk()
->assertJsonPath('code', ErrorCode::Success->value);
expect($admin->fresh()->admin_session_version)->toBe(2)
->and($admin->tokens()->where('name', 'admin-api')->count())->toBe(2);
});
test('admin logout revokes browser sessions without deleting programmatic tokens', function () {
$admin = AdminUser::query()->create([
'username' => 'logout_admin',
'name' => '退出管理员',
'email' => null,
'password' => 'secret-strong',
'status' => 0,
'admin_session_version' => 1,
]);
$browserToken = $admin->createToken(
'admin-api',
['admin-session:1'],
now()->addDay(),
)->plainTextToken;
$admin->createToken('test', ['*'], now()->addDay());
$this->withHeader('Authorization', 'Bearer '.$browserToken)
->postJson('/api/v1/admin/auth/logout')
->assertOk()
->assertJsonPath('code', ErrorCode::Success->value)
->assertJsonPath('data.logged_out', true);
expect($admin->tokens()->where('name', 'admin-api')->count())->toBe(0)
->and($admin->tokens()->where('name', 'test')->count())->toBe(1);
app('auth')->forgetGuards();
$this->withHeader('Authorization', 'Bearer '.$browserToken)
->getJson('/api/v1/admin/ping')
->assertUnauthorized()
->assertJsonPath('code', ErrorCode::AdminUnauthenticated->value);
});
test('agent operator auth me omits platform-only navigation', function (): void { test('agent operator auth me omits platform-only navigation', function (): void {
$this->artisan('lottery:admin-auth-sync')->assertExitCode(0); $this->artisan('lottery:admin-auth-sync')->assertExitCode(0);

View File

@@ -1,19 +1,21 @@
<?php <?php
use App\Models\AdminSite;
use App\Models\AuditLog;
use App\Models\AdminUser;
use App\Models\Player; use App\Models\Player;
use App\Services\Integration\PartnerSiteConfig; use App\Models\AuditLog;
use App\Services\Integration\PartnerSiteConfigResolver; use App\Models\AdminSite;
use App\Models\AdminUser;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Database\Seeders\CurrencySeeder;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Http;
use App\Services\Integration\PartnerSiteConfig;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use App\Services\Integration\PartnerSiteConfigResolver;
uses(RefreshDatabase::class); uses(RefreshDatabase::class);
beforeEach(function (): void { beforeEach(function (): void {
fakeWalletApiDns();
$this->artisan('lottery:admin-auth-sync')->assertExitCode(0); $this->artisan('lottery:admin-auth-sync')->assertExitCode(0);
}); });
@@ -221,7 +223,81 @@ test('connectivity test probes partner balance api', function (): void {
$response->assertOk() $response->assertOk()
->assertJsonPath('data.probe.success', true) ->assertJsonPath('data.probe.success', true)
->assertJsonPath('data.probe.main_balance_minor', 12345); ->assertJsonPath('data.probe.main_balance_minor', 12345)
->assertJsonMissingPath('data.probe.response_preview');
});
test('connectivity test rejects hostname resolving to a private address before sending', function (): void {
fakeWalletApiDns([
'wallet.private.test' => ['93.184.216.34', '10.0.0.8'],
], []);
Http::preventStrayRequests();
$token = integrationAdminToken();
$create = $this->withHeader('Authorization', 'Bearer '.$token)
->postJson('/api/v1/admin/integration-sites', [
'code' => 'private-probe-site',
'name' => 'Private Probe',
'wallet_api_url' => 'https://wallet.private.test',
'admin_account' => [
'username' => 'private_probe_admin',
'nickname' => 'Private Probe Admin',
'password' => 'secret-strong',
],
]);
$this->withHeader('Authorization', 'Bearer '.$token)
->postJson('/api/v1/admin/integration-sites/'.(int) $create->json('data.id').'/connectivity-test', [
'site_player_id' => '10001',
'currency_code' => 'NPR',
])
->assertOk()
->assertJsonPath('data.probe.success', false)
->assertJsonPath('data.probe.http_status', null)
->assertJsonPath('data.probe.message', 'wallet_api_url 无效(拒绝以防 SSRF')
->assertJsonMissingPath('data.probe.response_preview');
Http::assertSentCount(0);
});
test('connectivity test does not follow redirects or expose response preview', function (): void {
fakeWalletApiDns([
'wallet.redirect.test' => ['93.184.216.34'],
], []);
Http::fake([
'https://wallet.redirect.test/*' => Http::response(
['secret' => 'must-not-be-returned'],
302,
['Location' => 'https://169.254.169.254/latest/meta-data'],
),
'https://169.254.169.254/*' => Http::response(['role' => 'internal'], 200),
]);
$token = integrationAdminToken();
$create = $this->withHeader('Authorization', 'Bearer '.$token)
->postJson('/api/v1/admin/integration-sites', [
'code' => 'redirect-probe-site',
'name' => 'Redirect Probe',
'wallet_api_url' => 'https://wallet.redirect.test',
'admin_account' => [
'username' => 'redirect_probe_admin',
'nickname' => 'Redirect Probe Admin',
'password' => 'secret-strong',
],
]);
$this->withHeader('Authorization', 'Bearer '.$token)
->postJson('/api/v1/admin/integration-sites/'.(int) $create->json('data.id').'/connectivity-test', [
'site_player_id' => '10001',
'currency_code' => 'NPR',
])
->assertOk()
->assertJsonPath('data.probe.success', false)
->assertJsonPath('data.probe.http_status', 302)
->assertJsonMissingPath('data.probe.response_preview')
->assertJsonMissing(['must-not-be-returned']);
Http::assertSentCount(1);
}); });
test('export parameter sheet excludes plaintext secrets', function (): void { test('export parameter sheet excludes plaintext secrets', function (): void {
@@ -329,7 +405,7 @@ test('site scoped admin only sees bound integration sites', function (): void {
}); });
test('player list is filtered by admin site binding', function (): void { test('player list is filtered by admin site binding', function (): void {
$this->seed(\Database\Seeders\CurrencySeeder::class); $this->seed(CurrencySeeder::class);
Player::query()->create([ Player::query()->create([
'site_code' => 'site-a', 'site_code' => 'site-a',

View File

@@ -0,0 +1,250 @@
<?php
use App\Models\AdminRole;
use App\Models\AdminUser;
use App\Models\AgentNode;
use App\Lottery\ErrorCode;
use Illuminate\Support\Facades\DB;
use App\Support\PlatformSystemRoles;
use Illuminate\Support\Facades\Hash;
use App\Services\Agent\AgentNodeService;
use App\Support\InvalidPlatformAgentRoleCleanup;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
function makePlatformRoleBoundaryToken(string $username): string
{
$admin = AdminUser::query()->create([
'username' => $username,
'name' => 'Role Boundary Manager',
'email' => null,
'password' => Hash::make('secret-strong'),
'status' => 0,
]);
$role = AdminRole::query()->create([
'slug' => 'boundary_'.$username,
'name' => 'Boundary Manager',
'scope_type' => AdminRole::SCOPE_SYSTEM,
]);
$role->syncLegacyPermissionSlugs(['prd.admin_user.manage', 'prd.admin_role.manage']);
$admin->roles()->sync([
(int) $role->id => [
'site_id' => AdminUser::requireDefaultAdminSiteId(),
'granted_at' => now(),
],
]);
return $admin->createToken('test', ['*'], now()->addDay())->plainTextToken;
}
function createPlatformRoleBoundaryAgent(string $username): AdminUser
{
$siteId = AdminUser::requireDefaultAdminSiteId();
$root = AgentNode::query()
->where('admin_site_id', $siteId)
->where('depth', 0)
->firstOrFail();
$super = AdminUser::query()->create([
'username' => 'super_'.$username,
'name' => 'Super',
'email' => null,
'password' => Hash::make('secret-strong'),
'status' => 0,
]);
grantSuperAdminRole($super);
$node = app(AgentNodeService::class)->createChild($super, [
'parent_id' => (int) $root->id,
'code' => 'node-'.$username,
'name' => 'Agent '.$username,
'username' => $username,
'password' => 'secret-strong',
]);
$adminUserId = DB::table('admin_user_agents')
->where('agent_node_id', $node->id)
->where('is_primary', true)
->value('admin_user_id');
return AdminUser::query()->findOrFail((int) $adminUserId);
}
test('permission catalog keeps all roles but only exposes platform assignable roles', function (): void {
$token = makePlatformRoleBoundaryToken('catalog_boundary');
$data = $this->withHeader('Authorization', 'Bearer '.$token)
->getJson('/api/v1/admin/admin-user-permission-catalog')
->assertOk()
->json('data');
expect(collect($data['roles'])->pluck('slug')->all())
->toContain(PlatformSystemRoles::SLUG_AGENT, PlatformSystemRoles::SLUG_SUPER_ADMIN);
expect(collect($data['assignable_roles'])->pluck('slug')->all())
->not->toContain(PlatformSystemRoles::SLUG_AGENT, PlatformSystemRoles::SLUG_SUPER_ADMIN);
});
test('platform account create and role update reject the agent role', function (): void {
$token = makePlatformRoleBoundaryToken('assignment_boundary');
$siteId = AdminUser::requireDefaultAdminSiteId();
$this->withHeader('Authorization', 'Bearer '.$token)
->withHeader('X-Locale', 'zh')
->postJson('/api/v1/admin/admin-users', [
'username' => 'illegal_agent_platform',
'nickname' => 'Illegal Agent Platform',
'email' => null,
'password' => 'secret-strong',
'status' => 0,
'admin_site_id' => $siteId,
'role_slugs' => [PlatformSystemRoles::SLUG_AGENT],
])
->assertStatus(422)
->assertJsonPath('code', ErrorCode::ValidationFailed->value)
->assertJsonPath('msg', '平台账号不能分配“代理”角色;请在“代理管理”中创建或绑定代理账号。');
expect(AdminUser::query()->where('username', 'illegal_agent_platform')->exists())->toBeFalse();
$target = AdminUser::query()->create([
'username' => 'platform_role_target',
'name' => 'Platform Role Target',
'email' => null,
'password' => Hash::make('secret-strong'),
'status' => 0,
]);
$this->withHeader('Authorization', 'Bearer '.$token)
->putJson('/api/v1/admin/admin-users/'.$target->id.'/roles', [
'admin_site_id' => $siteId,
'role_slugs' => [PlatformSystemRoles::SLUG_AGENT],
])
->assertStatus(422)
->assertJsonPath('code', ErrorCode::ValidationFailed->value);
});
test('agent creation keeps its agent role and cleanup removes only unbound platform assignments', function (): void {
$agentUser = createPlatformRoleBoundaryAgent('legal_agent_boundary');
$agentRole = AdminRole::query()
->where('scope_type', AdminRole::SCOPE_SYSTEM)
->where('slug', PlatformSystemRoles::SLUG_AGENT)
->firstOrFail();
$siteId = AdminUser::requireDefaultAdminSiteId();
expect(DB::table('admin_user_agents')->where('admin_user_id', $agentUser->id)->exists())->toBeTrue();
expect(DB::table('admin_user_site_roles')
->where('admin_user_id', $agentUser->id)
->where('role_id', $agentRole->id)
->exists())->toBeTrue();
$illegalUser = AdminUser::query()->create([
'username' => 'unbound_agent_role',
'name' => 'Unbound Agent Role',
'email' => null,
'password' => Hash::make('secret-strong'),
'status' => 0,
]);
DB::table('admin_user_site_roles')->insert([
'admin_user_id' => $illegalUser->id,
'site_id' => $siteId,
'role_id' => $agentRole->id,
'granted_at' => now(),
]);
$otherSiteId = (int) DB::table('admin_sites')->insertGetId([
'code' => 'boundary-other-site',
'name' => 'Boundary Other Site',
'is_default' => false,
'created_at' => now(),
'updated_at' => now(),
]);
DB::table('admin_user_site_roles')->insert([
'admin_user_id' => $agentUser->id,
'site_id' => $otherSiteId,
'role_id' => $agentRole->id,
'granted_at' => now(),
]);
$ordinaryRole = AdminRole::query()->create([
'slug' => 'cross_site_ordinary_role',
'name' => 'Cross Site Ordinary Role',
'scope_type' => AdminRole::SCOPE_SYSTEM,
]);
DB::table('admin_user_site_roles')->insert([
'admin_user_id' => $agentUser->id,
'site_id' => $otherSiteId,
'role_id' => $ordinaryRole->id,
'granted_at' => now(),
]);
expect(InvalidPlatformAgentRoleCleanup::run())->toBe(2);
expect(DB::table('admin_user_site_roles')
->where('admin_user_id', $illegalUser->id)
->where('role_id', $agentRole->id)
->exists())->toBeFalse();
expect(DB::table('admin_user_site_roles')
->where('admin_user_id', $agentUser->id)
->where('site_id', $siteId)
->where('role_id', $agentRole->id)
->exists())->toBeTrue();
expect(DB::table('admin_user_site_roles')
->where('admin_user_id', $agentUser->id)
->where('site_id', $otherSiteId)
->where('role_id', $agentRole->id)
->exists())->toBeFalse();
expect(DB::table('admin_user_site_roles')
->where('admin_user_id', $agentUser->id)
->where('site_id', $otherSiteId)
->where('role_id', $ordinaryRole->id)
->exists())->toBeTrue();
expect(AdminUser::query()->whereKey($illegalUser->id)->exists())->toBeTrue();
});
test('role counts classify platform and agent accounts and role filter never leaks agents', function (): void {
$token = makePlatformRoleBoundaryToken('count_boundary');
$siteId = AdminUser::requireDefaultAdminSiteId();
$role = AdminRole::query()->create([
'slug' => 'counted_role',
'name' => 'Counted Role',
'scope_type' => AdminRole::SCOPE_SYSTEM,
]);
$platformUser = AdminUser::query()->create([
'username' => 'counted_platform',
'name' => 'Counted Platform',
'email' => null,
'password' => Hash::make('secret-strong'),
'status' => 0,
]);
$platformUser->roles()->sync([
(int) $role->id => [
'site_id' => $siteId,
'granted_at' => now(),
],
]);
$agentUser = createPlatformRoleBoundaryAgent('counted_agent');
DB::table('admin_user_site_roles')->insert([
'admin_user_id' => $agentUser->id,
'site_id' => $siteId,
'role_id' => $role->id,
'granted_at' => now(),
]);
$roleRow = collect($this->withHeader('Authorization', 'Bearer '.$token)
->getJson('/api/v1/admin/admin-roles')
->assertOk()
->json('data.items'))
->firstWhere('slug', 'counted_role');
expect($roleRow)
->not->toBeNull()
->and($roleRow['platform_user_count'])->toBe(1)
->and($roleRow['agent_user_count'])->toBe(1)
->and($roleRow['user_count'])->toBe(2);
$items = $this->withHeader('Authorization', 'Bearer '.$token)
->getJson('/api/v1/admin/admin-users?role_slug=counted_role')
->assertOk()
->json('data.items');
expect(collect($items)->pluck('username')->all())
->toBe(['counted_platform'])
->not->toContain('counted_agent');
});

View File

@@ -1,21 +1,28 @@
<?php <?php
use App\Models\AgentProfile;
use App\Models\Player; use App\Models\Player;
use App\Models\AuditLog; use App\Models\AuditLog;
use App\Models\AdminRole; use App\Models\AdminRole;
use App\Models\AdminUser; use App\Models\AdminUser;
use App\Lottery\ErrorCode;
use App\Models\AgentProfile;
use App\Models\PlayerWallet; use App\Models\PlayerWallet;
use App\Support\PlayerAuthSource; use App\Support\PlayerAuthSource;
use App\Support\PlayerFundingMode; use App\Support\PlayerFundingMode;
use Illuminate\Support\Facades\DB;
use Database\Seeders\CurrencySeeder; use Database\Seeders\CurrencySeeder;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\DB; use App\Services\Agent\AgentNodeService;
use App\Services\Player\PlayerNativeAuthService;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class); uses(RefreshDatabase::class);
beforeEach(function (): void { beforeEach(function (): void {
config([
'lottery.player_auth.native.secret' => 'test-native-jwt-secret-32bytes!!',
'lottery.player_auth.native.ttl_seconds' => 3600,
]);
$this->seed(CurrencySeeder::class); $this->seed(CurrencySeeder::class);
$this->artisan('lottery:admin-auth-sync')->assertExitCode(0); $this->artisan('lottery:admin-auth-sync')->assertExitCode(0);
}); });
@@ -251,7 +258,7 @@ test('agent players list can filter direct players without including downline pl
]); ]);
grantSuperAdminRole($super); grantSuperAdminRole($super);
$child = app(\App\Services\Agent\AgentNodeService::class)->createChild($super, [ $child = app(AgentNodeService::class)->createChild($super, [
'parent_id' => $rootId, 'parent_id' => $rootId,
'code' => 'direct-child', 'code' => 'direct-child',
'name' => 'Direct Child', 'name' => 'Direct Child',
@@ -355,7 +362,7 @@ test('admin cannot change credit player default currency', function (): void {
'default_currency' => 'USD', 'default_currency' => 'USD',
]) ])
->assertStatus(422) ->assertStatus(422)
->assertJsonPath('code', \App\Lottery\ErrorCode::ValidationFailed->value); ->assertJsonPath('code', ErrorCode::ValidationFailed->value);
$this->assertDatabaseHas('players', [ $this->assertDatabaseHas('players', [
'id' => $player->id, 'id' => $player->id,
@@ -444,14 +451,14 @@ test('wallet player update rejects credit limit and rebate', function (): void {
'credit_limit' => 1000, 'credit_limit' => 1000,
]) ])
->assertStatus(422) ->assertStatus(422)
->assertJsonPath('code', \App\Lottery\ErrorCode::ValidationFailed->value); ->assertJsonPath('code', ErrorCode::ValidationFailed->value);
$this->withHeader('Authorization', 'Bearer '.$token) $this->withHeader('Authorization', 'Bearer '.$token)
->putJson('/api/v1/admin/players/'.$player->id, [ ->putJson('/api/v1/admin/players/'.$player->id, [
'rebate_rate' => 1, 'rebate_rate' => 1,
]) ])
->assertStatus(422) ->assertStatus(422)
->assertJsonPath('code', \App\Lottery\ErrorCode::ValidationFailed->value); ->assertJsonPath('code', ErrorCode::ValidationFailed->value);
}); });
test('native player create rejects chinese username', function (): void { test('native player create rejects chinese username', function (): void {
@@ -467,7 +474,7 @@ test('native player create rejects chinese username', function (): void {
'default_currency' => 'NPR', 'default_currency' => 'NPR',
]) ])
->assertStatus(422) ->assertStatus(422)
->assertJsonPath('code', \App\Lottery\ErrorCode::ValidationFailed->value); ->assertJsonPath('code', ErrorCode::ValidationFailed->value);
}); });
test('partial rebate update preserves the other rebate field', function (): void { test('partial rebate update preserves the other rebate field', function (): void {
@@ -555,3 +562,99 @@ test('partial rebate update preserves the other rebate field', function (): void
'extra_rebate_rate' => 0.001, 'extra_rebate_rate' => 0.001,
]); ]);
}); });
test('admin can reset native player password and invalidate active tokens', function (): void {
$siteCode = DB::table('admin_sites')->where('is_default', true)->value('code');
$siteCode = is_string($siteCode) && $siteCode !== '' ? $siteCode : 'default_site';
$rootId = (int) DB::table('agent_nodes')->where('depth', 0)->value('id');
$player = Player::query()->create([
'site_code' => $siteCode,
'agent_node_id' => $rootId,
'site_player_id' => 'native-admin-reset',
'auth_source' => PlayerAuthSource::LOTTERY_NATIVE,
'funding_mode' => PlayerFundingMode::CREDIT,
'username' => 'native_admin_reset',
'password_hash' => Hash::make('old-secret'),
'default_currency' => 'NPR',
'status' => 0,
]);
$oldToken = app(PlayerNativeAuthService::class)->issueToken($player);
$adminToken = playerManageAdminToken();
$this->withHeader('Authorization', 'Bearer '.$adminToken)
->putJson('/api/v1/admin/players/'.$player->id.'/password', [
'password' => 'reset-secret',
'password_confirmation' => 'reset-secret',
])
->assertOk()
->assertJsonPath('data.password_reset', true)
->assertJsonPath('data.player_id', $player->id);
$player->refresh();
expect(Hash::check('reset-secret', (string) $player->password_hash))->toBeTrue()
->and($player->native_token_version)->toBe(1);
$this->withHeader('Authorization', 'Bearer '.$oldToken)
->getJson('/api/v1/player/me')
->assertStatus(401)
->assertJsonPath('code', ErrorCode::PlayerTokenInvalid->value);
$this->assertDatabaseHas('audit_logs', [
'operator_type' => 'admin',
'module_code' => 'player_service',
'action_code' => 'reset',
'target_id' => (string) $player->id,
]);
});
test('admin cannot reset password for sso player', function (): void {
$siteCode = DB::table('admin_sites')->where('is_default', true)->value('code');
$siteCode = is_string($siteCode) && $siteCode !== '' ? $siteCode : 'default_site';
$player = Player::query()->create([
'site_code' => $siteCode,
'site_player_id' => 'sso-admin-reset-blocked',
'auth_source' => PlayerAuthSource::MAIN_SITE_SSO,
'funding_mode' => PlayerFundingMode::WALLET,
'username' => 'sso_admin_reset_blocked',
'default_currency' => 'NPR',
'status' => 0,
]);
$this->withHeader('Authorization', 'Bearer '.playerManageAdminToken())
->putJson('/api/v1/admin/players/'.$player->id.'/password', [
'password' => 'reset-secret',
'password_confirmation' => 'reset-secret',
])
->assertStatus(422)
->assertJsonPath('msg', 'Main-site SSO players do not use a lottery password.');
});
test('player view permission cannot reset native player password', function (): void {
$siteCode = DB::table('admin_sites')->where('is_default', true)->value('code');
$siteCode = is_string($siteCode) && $siteCode !== '' ? $siteCode : 'default_site';
$rootId = (int) DB::table('agent_nodes')->where('depth', 0)->value('id');
$player = Player::query()->create([
'site_code' => $siteCode,
'agent_node_id' => $rootId,
'site_player_id' => 'native-view-reset-blocked',
'auth_source' => PlayerAuthSource::LOTTERY_NATIVE,
'funding_mode' => PlayerFundingMode::CREDIT,
'username' => 'native_view_reset_blocked',
'password_hash' => Hash::make('old-secret'),
'default_currency' => 'NPR',
'status' => 0,
]);
$viewToken = playerPermissionAdminToken('player_password_view_only', ['prd.users.view']);
playerPermissionRequest($this, $viewToken)
->putJson('/api/v1/admin/players/'.$player->id.'/password', [
'password' => 'reset-secret',
'password_confirmation' => 'reset-secret',
])
->assertStatus(403);
expect(Hash::check('old-secret', (string) $player->fresh()->password_hash))->toBeTrue();
});

View File

@@ -0,0 +1,234 @@
<?php
use App\Models\AdminRole;
use App\Models\AdminSite;
use App\Models\AdminUser;
use App\Models\ReportJob;
use Laravel\Sanctum\Sanctum;
use App\Services\AuditLogger;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function (): void {
$this->artisan('lottery:admin-auth-sync')->assertExitCode(0);
});
/**
* @param list<string> $permissionCodes
*/
function makeReportScopeAdmin(string $username, int $siteId, array $permissionCodes): AdminUser
{
$admin = AdminUser::query()->create([
'username' => $username,
'name' => $username,
'email' => null,
'password' => Hash::make('secret-strong'),
'status' => 0,
]);
$role = AdminRole::query()->create([
'slug' => 'report_scope_'.$username,
'code' => 'report_scope_'.$username,
'name' => 'Report Scope '.$username,
'scope_type' => AdminRole::SCOPE_SYSTEM,
'status' => 1,
'is_system' => false,
'sort_order' => 0,
]);
$actionIds = DB::table('admin_menu_actions')
->whereIn('permission_code', $permissionCodes)
->pluck('id');
foreach ($actionIds as $actionId) {
DB::table('admin_role_menu_actions')->insert([
'role_id' => (int) $role->id,
'menu_action_id' => (int) $actionId,
]);
}
DB::table('admin_user_site_roles')->insert([
'admin_user_id' => (int) $admin->id,
'site_id' => $siteId,
'role_id' => (int) $role->id,
'granted_at' => now(),
]);
return $admin;
}
function makeReportScopeJob(AdminUser $owner, string $jobNo, string $reportType = 'daily_profit_summary'): ReportJob
{
return ReportJob::query()->create([
'job_no' => $jobNo,
'admin_user_id' => (int) $owner->id,
'report_type' => $reportType,
'export_format' => 'csv',
'filter_json' => [
'date_from' => now()->toDateString(),
'date_to' => now()->toDateString(),
],
'status' => 'completed',
'output_path' => 'reports/'.$jobNo.'.csv',
'finished_at' => now(),
]);
}
function makeReportScopeSuperAdmin(): AdminUser
{
$admin = AdminUser::query()->create([
'username' => 'report_scope_super',
'name' => 'Report Scope Super',
'email' => null,
'password' => Hash::make('secret-strong'),
'status' => 0,
]);
grantSuperAdminRole($admin);
return $admin;
}
test('report job list show and download are owner only with an explicit super admin exception', function (): void {
$siteAId = (int) DB::table('admin_sites')->where('is_default', true)->value('id');
$siteB = AdminSite::query()->create([
'code' => 'report-scope-b',
'name' => 'Report Scope B',
'currency_code' => 'NPR',
'status' => 1,
'is_default' => false,
]);
$permissions = ['service.report.view', 'service.report.export'];
$owner = makeReportScopeAdmin('report_owner', $siteAId, $permissions);
$sameSitePeer = makeReportScopeAdmin('report_same_site_peer', $siteAId, $permissions);
$crossSitePeer = makeReportScopeAdmin('report_cross_site_peer', (int) $siteB->id, $permissions);
$ownedJob = makeReportScopeJob($owner, 'RPT-SCOPE-OWN');
$sameSiteJob = makeReportScopeJob($sameSitePeer, 'RPT-SCOPE-SAME');
$crossSiteJob = makeReportScopeJob($crossSitePeer, 'RPT-SCOPE-CROSS');
$deletedCreator = makeReportScopeAdmin('report_deleted_creator', $siteAId, $permissions);
$orphanedJob = makeReportScopeJob($deletedCreator, 'RPT-SCOPE-ORPHAN');
$deletedCreator->delete();
$orphanedJob->refresh();
expect($orphanedJob->admin_user_id)->toBeNull();
Sanctum::actingAs($owner, ['*']);
$visibleIds = collect($this->getJson('/api/v1/admin/report-jobs')
->assertOk()
->json('data.items'))
->pluck('id')
->all();
expect($visibleIds)->toBe([(int) $ownedJob->id]);
$this->getJson('/api/v1/admin/report-jobs/'.$ownedJob->id)->assertOk();
$this->get('/api/v1/admin/report-jobs/'.$ownedJob->id.'/download')->assertOk();
foreach ([$sameSiteJob, $crossSiteJob, $orphanedJob] as $deniedJob) {
$this->getJson('/api/v1/admin/report-jobs/'.$deniedJob->id)->assertForbidden();
$this->get('/api/v1/admin/report-jobs/'.$deniedJob->id.'/download')->assertForbidden();
}
$super = makeReportScopeSuperAdmin();
Sanctum::actingAs($super, ['*']);
$superVisibleIds = collect($this->getJson('/api/v1/admin/report-jobs')
->assertOk()
->json('data.items'))
->pluck('id')
->all();
expect($superVisibleIds)->toContain(
(int) $ownedJob->id,
(int) $sameSiteJob->id,
(int) $crossSiteJob->id,
(int) $orphanedJob->id,
);
$this->getJson('/api/v1/admin/report-jobs/'.$orphanedJob->id)->assertOk();
$this->get('/api/v1/admin/report-jobs/'.$orphanedJob->id.'/download')->assertOk();
});
test('sensitive report types require their own capabilities and risk export is super admin only', function (): void {
$siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id');
$base = makeReportScopeAdmin('report_base_exporter', $siteId, [
'service.report.view',
'service.report.export',
]);
$auditor = makeReportScopeAdmin('report_auditor', $siteId, [
'service.report.view',
'service.report.export',
'service.audit.view',
]);
$riskViewer = makeReportScopeAdmin('report_risk_viewer', $siteId, [
'service.report.view',
'service.report.export',
'risk.monitor.view',
]);
Sanctum::actingAs($base, ['*']);
$this->postJson('/api/v1/admin/report-jobs', [
'report_type' => 'audit_operation_report',
])->assertForbidden();
$legacyAuditJob = makeReportScopeJob($base, 'RPT-SENSITIVE-AUDIT', 'audit_operation_report');
$this->get('/api/v1/admin/report-jobs/'.$legacyAuditJob->id.'/download')->assertForbidden();
Sanctum::actingAs($auditor, ['*']);
$this->postJson('/api/v1/admin/report-jobs', [
'report_type' => 'audit_operation_report',
])->assertOk();
Sanctum::actingAs($riskViewer, ['*']);
$legacyRiskJob = makeReportScopeJob($riskViewer, 'RPT-SENSITIVE-RISK', 'hot_number_risk_report');
foreach (['hot_number_risk_report', 'sold_out_number_report'] as $reportType) {
$this->postJson('/api/v1/admin/report-jobs', [
'report_type' => $reportType,
])->assertForbidden();
}
$this->get('/api/v1/admin/report-jobs/'.$legacyRiskJob->id.'/download')->assertForbidden();
$super = makeReportScopeSuperAdmin();
Sanctum::actingAs($super, ['*']);
foreach (['hot_number_risk_report', 'sold_out_number_report'] as $reportType) {
$created = $this->postJson('/api/v1/admin/report-jobs', [
'report_type' => $reportType,
])->assertOk();
$this->get('/api/v1/admin/report-jobs/'.(int) $created->json('data.id').'/download')->assertOk();
}
});
test('non super audit export is limited to the current actor', function (): void {
$siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id');
$auditor = makeReportScopeAdmin('report_audit_owner', $siteId, [
'service.report.view',
'service.report.export',
'service.audit.view',
]);
$other = makeReportScopeAdmin('report_audit_other', $siteId, [
'service.report.view',
'service.report.export',
'service.audit.view',
]);
AuditLogger::recordForAdmin($auditor, null, 'audit_scope_own', 'own_action', null, null, null, null);
AuditLogger::recordForAdmin($other, null, 'audit_scope_other', 'other_action', null, null, null, null);
Sanctum::actingAs($auditor, ['*']);
$create = $this->postJson('/api/v1/admin/report-jobs', [
'report_type' => 'audit_operation_report',
'export_format' => 'csv',
'parameters' => [
'date_from' => now()->toDateString(),
'date_to' => now()->toDateString(),
],
])->assertOk();
$content = $this->get('/api/v1/admin/report-jobs/'.(int) $create->json('data.id').'/download')
->assertOk()
->streamedContent();
expect($content)->toContain('audit_scope_own')
->not->toContain('audit_scope_other');
});

View File

@@ -1,19 +1,26 @@
<?php <?php
use App\Models\AdminUser;
use App\Models\Draw; use App\Models\Draw;
use App\Models\Player; use App\Models\Player;
use App\Models\AdminUser;
use App\Lottery\DrawStatus; use App\Lottery\DrawStatus;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
use Illuminate\Foundation\Testing\RefreshDatabase;
use App\Services\AgentSettlement\SettlementPeriodOpenHintsService;
uses(RefreshDatabase::class); uses(RefreshDatabase::class);
beforeEach(function (): void { beforeEach(function (): void {
Carbon::setTestNow('2026-07-09 12:00:00');
$this->artisan('lottery:admin-auth-sync')->assertExitCode(0); $this->artisan('lottery:admin-auth-sync')->assertExitCode(0);
}); });
afterEach(function (): void {
Carbon::setTestNow();
});
test('settlement period open hints api resource is configured after migrations', function (): void { test('settlement period open hints api resource is configured after migrations', function (): void {
expect( expect(
DB::table('admin_api_resources') DB::table('admin_api_resources')
@@ -295,7 +302,7 @@ test('settlement period open hints uses site timezone for orphan activity dates'
'updated_at' => '2026-07-09 18:00:00', 'updated_at' => '2026-07-09 18:00:00',
]); ]);
$hints = app(\App\Services\AgentSettlement\SettlementPeriodOpenHintsService::class)->hints($siteId); $hints = app(SettlementPeriodOpenHintsService::class)->hints($siteId);
expect($hints['settlement_timezone'])->toBe('Asia/Kathmandu') expect($hints['settlement_timezone'])->toBe('Asia/Kathmandu')
->and($hints['occupied_period_dates'])->toContain('2026-07-09') ->and($hints['occupied_period_dates'])->toContain('2026-07-09')

View File

@@ -3,10 +3,11 @@
use App\Models\Player; use App\Models\Player;
use App\Support\PlayerAuthSource; use App\Support\PlayerAuthSource;
use App\Support\PlayerFundingMode; use App\Support\PlayerFundingMode;
use Illuminate\Support\Facades\DB;
use Database\Seeders\CurrencySeeder; use Database\Seeders\CurrencySeeder;
use Database\Seeders\LotterySettingsSeeder; use Database\Seeders\LotterySettingsSeeder;
use App\Services\Player\PlayerCreditService;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
uses(RefreshDatabase::class); uses(RefreshDatabase::class);
@@ -345,3 +346,505 @@ test('credit wallet logs page 2 balance_after continues from page 1 baseline', f
// Page 2 must continue the running balance from page 1, capped by credit limit. // Page 2 must continue the running balance from page 1, capped by credit limit.
expect($page2FirstBalanceAfter)->toBe(min($page1LastBalanceAfter + 1000, 50000)); expect($page2FirstBalanceAfter)->toBe(min($page1LastBalanceAfter + 1000, 50000));
}); });
test('credit player activity merges hold release loss and rebate into one order result', function (): void {
$player = Player::query()->create([
'site_code' => 'default_site',
'site_player_id' => 'native:activity-loss',
'auth_source' => PlayerAuthSource::LOTTERY_NATIVE,
'funding_mode' => PlayerFundingMode::CREDIT,
'username' => 'credit_activity_loss',
'default_currency' => 'NPR',
'status' => 0,
]);
DB::table('player_credit_accounts')->insert([
'player_id' => $player->id,
'credit_limit' => 500,
'used_credit' => 21,
'frozen_credit' => 0,
'created_at' => now(),
'updated_at' => now(),
]);
$drawId = (int) DB::table('draws')->insertGetId([
'draw_no' => 'ACTIVITY-LOSS-001',
'business_date' => now()->toDateString(),
'sequence_no' => 1,
'status' => 'settled',
'created_at' => now()->subMinutes(10),
'updated_at' => now(),
]);
$orderId = (int) DB::table('ticket_orders')->insertGetId([
'order_no' => 'ORDER-ACTIVITY-LOSS-001',
'player_id' => $player->id,
'draw_id' => $drawId,
'currency_code' => 'NPR',
'total_bet_amount' => 2100,
'total_rebate_amount' => 0,
'total_actual_deduct' => 2100,
'total_estimated_payout' => 50000,
'status' => 'placed',
'created_at' => now()->subMinutes(10),
'updated_at' => now(),
]);
$ticketItemId = (int) DB::table('ticket_items')->insertGetId([
'ticket_no' => 'TICKET-ACTIVITY-LOSS-001',
'order_id' => $orderId,
'player_id' => $player->id,
'draw_id' => $drawId,
'original_number' => '1234',
'normalized_number' => '1234',
'play_code' => '4d',
'total_bet_amount' => 2100,
'actual_deduct_amount' => 2100,
'status' => 'settled_lose',
'win_amount' => 0,
'jackpot_win_amount' => 0,
'settled_at' => now(),
'created_at' => now()->subMinutes(10),
'updated_at' => now(),
]);
DB::table('share_ledger')->insert([
'ticket_item_id' => $ticketItemId,
'player_id' => $player->id,
'agent_node_id' => null,
'game_win_loss' => 2100,
'basic_rebate' => 11,
'shared_net_win_loss' => 2089,
'settled_at' => now(),
'created_at' => now(),
'updated_at' => now(),
]);
DB::table('rebate_records')->insert([
'player_id' => $player->id,
'ticket_item_id' => $ticketItemId,
'game_type' => '4d',
'valid_bet_amount' => 2100,
'rebate_rate' => 0.0052,
'rebate_amount' => 11,
'rebate_type' => 'basic',
'owner_agent_id' => null,
'status' => 'accrued',
'created_at' => now(),
'updated_at' => now(),
]);
DB::table('credit_ledger')->insert([
[
'owner_type' => 'player',
'owner_id' => $player->id,
'amount' => -2100,
'reason' => 'bet_hold',
'ref_type' => 'ticket_order',
'ref_id' => $orderId,
'created_at' => now()->subMinutes(10),
'updated_at' => now()->subMinutes(10),
],
[
'owner_type' => 'player',
'owner_id' => $player->id,
'amount' => 2100,
'reason' => 'bet_hold_release',
'ref_type' => 'ticket_item',
'ref_id' => $ticketItemId,
'created_at' => now(),
'updated_at' => now(),
],
[
'owner_type' => 'player',
'owner_id' => $player->id,
'amount' => -2100,
'reason' => 'game_settlement_loss',
'ref_type' => 'ticket_item',
'ref_id' => $ticketItemId,
'created_at' => now(),
'updated_at' => now(),
],
]);
$response = $this->withHeader('Authorization', 'Bearer dev:'.$player->id)
->getJson('/api/v1/wallet/logs?page=1&size=10')
->assertOk()
->assertJsonPath('data.total', 1)
->assertJsonPath('data.items.0.activity_kind', 'draw_result')
->assertJsonPath('data.items.0.biz_type', 'settled_loss')
->assertJsonPath('data.items.0.order_no', 'ORDER-ACTIVITY-LOSS-001')
->assertJsonPath('data.items.0.draw_no', 'ACTIVITY-LOSS-001')
->assertJsonPath('data.items.0.ticket_no', 'TICKET-ACTIVITY-LOSS-001')
->assertJsonPath('data.items.0.stake_amount', 2100)
->assertJsonPath('data.items.0.rebate_amount', 11)
->assertJsonPath('data.items.0.net_amount', -2089)
->assertJsonPath('data.items.0.balance_after', 47900);
expect($response->json('data.items'))->toHaveCount(1);
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
->getJson('/api/v1/wallet/logs?type=bet')
->assertOk()
->assertJsonPath('data.total', 0);
$newerOrderId = (int) DB::table('ticket_orders')->insertGetId([
'order_no' => 'ORDER-ACTIVITY-PENDING-001',
'player_id' => $player->id,
'draw_id' => $drawId,
'currency_code' => 'NPR',
'total_bet_amount' => 100,
'total_rebate_amount' => 0,
'total_actual_deduct' => 100,
'total_estimated_payout' => 1000,
'status' => 'placed',
'created_at' => now()->addSecond(),
'updated_at' => now()->addSecond(),
]);
DB::table('ticket_items')->insert([
'ticket_no' => 'TICKET-ACTIVITY-PENDING-001',
'order_id' => $newerOrderId,
'player_id' => $player->id,
'draw_id' => $drawId,
'original_number' => '5678',
'normalized_number' => '5678',
'play_code' => '4d',
'total_bet_amount' => 100,
'actual_deduct_amount' => 100,
'status' => 'pending_draw',
'created_at' => now()->addSecond(),
'updated_at' => now()->addSecond(),
]);
DB::table('player_credit_accounts')
->where('player_id', $player->id)
->update(['used_credit' => 22, 'updated_at' => now()]);
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
->getJson('/api/v1/wallet/logs?type=game_settlement')
->assertOk()
->assertJsonPath('data.total', 1)
->assertJsonPath('data.items.0.balance_after', 47900);
});
test('credit player activities describe wins refunds and settlement reversals in player terms', function (): void {
$player = Player::query()->create([
'site_code' => 'default_site',
'site_player_id' => 'native:activity-outcomes',
'auth_source' => PlayerAuthSource::LOTTERY_NATIVE,
'funding_mode' => PlayerFundingMode::CREDIT,
'username' => 'credit_activity_outcomes',
'default_currency' => 'NPR',
'status' => 0,
]);
DB::table('player_credit_accounts')->insert([
'player_id' => $player->id,
'credit_limit' => 1000,
'used_credit' => 7,
'frozen_credit' => 0,
'created_at' => now(),
'updated_at' => now(),
]);
$drawId = (int) DB::table('draws')->insertGetId([
'draw_no' => 'ACTIVITY-OUTCOMES-001',
'business_date' => now()->toDateString(),
'sequence_no' => 1,
'status' => 'open',
'created_at' => now(),
'updated_at' => now(),
]);
$createOrder = function (
string $orderNo,
string $ticketNo,
string $orderStatus,
string $ticketStatus,
int $stake,
int $minutesAgo,
) use ($player, $drawId): array {
$createdAt = now()->subMinutes($minutesAgo);
$orderId = (int) DB::table('ticket_orders')->insertGetId([
'order_no' => $orderNo,
'player_id' => $player->id,
'draw_id' => $drawId,
'currency_code' => 'NPR',
'total_bet_amount' => $stake,
'total_rebate_amount' => 0,
'total_actual_deduct' => $stake,
'total_estimated_payout' => 5000,
'status' => $orderStatus,
'created_at' => $createdAt,
'updated_at' => $createdAt,
]);
$ticketId = (int) DB::table('ticket_items')->insertGetId([
'ticket_no' => $ticketNo,
'order_id' => $orderId,
'player_id' => $player->id,
'draw_id' => $drawId,
'original_number' => '1234',
'normalized_number' => '1234',
'play_code' => '4d',
'total_bet_amount' => $stake,
'actual_deduct_amount' => $stake,
'status' => $ticketStatus,
'win_amount' => $ticketStatus === 'settled_win' ? 4000 : 0,
'jackpot_win_amount' => 0,
'settled_at' => $ticketStatus === 'settled_win' ? $createdAt : null,
'created_at' => $createdAt,
'updated_at' => $createdAt,
]);
return [$orderId, $ticketId];
};
[, $winningTicketId] = $createOrder(
'ORDER-ACTIVITY-WIN-001',
'TICKET-ACTIVITY-WIN-001',
'placed',
'settled_win',
1000,
3,
);
DB::table('share_ledger')->insert([
'ticket_item_id' => $winningTicketId,
'player_id' => $player->id,
'agent_node_id' => null,
'game_win_loss' => -4000,
'basic_rebate' => 0,
'shared_net_win_loss' => -4000,
'settled_at' => now()->subMinutes(3),
'created_at' => now()->subMinutes(3),
'updated_at' => now()->subMinutes(3),
]);
$createOrder(
'ORDER-ACTIVITY-REFUND-001',
'TICKET-ACTIVITY-REFUND-001',
'refunded',
'refunded',
500,
2,
);
[, $reversedTicketId] = $createOrder(
'ORDER-ACTIVITY-REVERSED-001',
'TICKET-ACTIVITY-REVERSED-001',
'placed',
'pending_draw',
700,
1,
);
$originalShareId = (int) DB::table('share_ledger')->insertGetId([
'ticket_item_id' => $reversedTicketId,
'player_id' => $player->id,
'agent_node_id' => null,
'game_win_loss' => 700,
'basic_rebate' => 0,
'shared_net_win_loss' => 700,
'settled_at' => now()->subMinute(),
'created_at' => now()->subMinute(),
'updated_at' => now()->subMinute(),
]);
DB::table('share_ledger')->insert([
'ticket_item_id' => $reversedTicketId,
'player_id' => $player->id,
'agent_node_id' => null,
'game_win_loss' => -700,
'basic_rebate' => 0,
'shared_net_win_loss' => -700,
'reversal_of_id' => $originalShareId,
'settled_at' => now(),
'created_at' => now(),
'updated_at' => now(),
]);
$items = collect($this->withHeader('Authorization', 'Bearer dev:'.$player->id)
->getJson('/api/v1/wallet/logs?page=1&size=10')
->assertOk()
->assertJsonPath('data.total', 3)
->json('data.items'))
->keyBy('order_no');
expect($items['ORDER-ACTIVITY-WIN-001']['activity_kind'])->toBe('draw_result')
->and($items['ORDER-ACTIVITY-WIN-001']['biz_type'])->toBe('settled_win')
->and($items['ORDER-ACTIVITY-WIN-001']['win_amount'])->toBe(4000)
->and($items['ORDER-ACTIVITY-WIN-001']['net_amount'])->toBe(4000)
->and($items['ORDER-ACTIVITY-REFUND-001']['activity_kind'])->toBe('refund')
->and($items['ORDER-ACTIVITY-REFUND-001']['biz_type'])->toBe('bet_refund')
->and($items['ORDER-ACTIVITY-REFUND-001']['net_amount'])->toBe(500)
->and($items['ORDER-ACTIVITY-REVERSED-001']['activity_kind'])->toBe('bet')
->and($items['ORDER-ACTIVITY-REVERSED-001']['activity_status'])->toBe('pending')
->and($items['ORDER-ACTIVITY-REVERSED-001']['net_amount'])->toBe(-700);
});
test('credit hold records the real ticket order reference when provided', function (): void {
$player = Player::query()->create([
'site_code' => 'default_site',
'site_player_id' => 'native:hold-order-ref',
'auth_source' => PlayerAuthSource::LOTTERY_NATIVE,
'funding_mode' => PlayerFundingMode::CREDIT,
'username' => 'credit_hold_order_ref',
'default_currency' => 'NPR',
'status' => 0,
]);
DB::table('player_credit_accounts')->insert([
'player_id' => $player->id,
'credit_limit' => 500,
'used_credit' => 0,
'frozen_credit' => 0,
'created_at' => now(),
'updated_at' => now(),
]);
app(PlayerCreditService::class)->holdForBet($player, 2100, 77);
$row = DB::table('credit_ledger')->where('owner_id', $player->id)->first();
expect($row?->ref_type)->toBe('ticket_order')
->and((int) $row?->ref_id)->toBe(77);
});
test('credit player activity shows each partial period payment as one player-facing record', function (): void {
$player = Player::query()->create([
'site_code' => 'default_site',
'site_player_id' => 'native:period-payments',
'auth_source' => PlayerAuthSource::LOTTERY_NATIVE,
'funding_mode' => PlayerFundingMode::CREDIT,
'username' => 'credit_period_payments',
'default_currency' => 'NPR',
'status' => 0,
]);
DB::table('player_credit_accounts')->insert([
'player_id' => $player->id,
'credit_limit' => 500,
'used_credit' => 0,
'frozen_credit' => 0,
'created_at' => now(),
'updated_at' => now(),
]);
$siteId = (int) DB::table('admin_sites')->where('code', 'default_site')->value('id');
$periodId = (int) DB::table('settlement_periods')->insertGetId([
'admin_site_id' => $siteId,
'period_start' => now()->subWeek(),
'period_end' => now(),
'status' => 'closed',
'created_at' => now(),
'updated_at' => now(),
]);
$billId = (int) DB::table('settlement_bills')->insertGetId([
'settlement_period_id' => $periodId,
'bill_type' => 'player',
'owner_type' => 'player',
'owner_id' => $player->id,
'counterparty_type' => 'agent',
'counterparty_id' => 0,
'net_amount' => 300,
'paid_amount' => 300,
'unpaid_amount' => 0,
'status' => 'settled',
'created_at' => now(),
'updated_at' => now(),
]);
DB::table('payment_records')->insert([
[
'settlement_bill_id' => $billId,
'payer_type' => 'player',
'payer_id' => $player->id,
'payee_type' => 'agent',
'payee_id' => 0,
'amount' => 100,
'status' => 'confirmed',
'confirmed_at' => now()->subMinute(),
'created_at' => now()->subMinute(),
'updated_at' => now()->subMinute(),
],
[
'settlement_bill_id' => $billId,
'payer_type' => 'player',
'payer_id' => $player->id,
'payee_type' => 'agent',
'payee_id' => 0,
'amount' => 200,
'status' => 'confirmed',
'confirmed_at' => now(),
'created_at' => now(),
'updated_at' => now(),
],
]);
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
->getJson('/api/v1/wallet/logs?type=bill_settlement')
->assertOk()
->assertJsonPath('data.total', 2)
->assertJsonPath('data.items.0.activity_kind', 'period_settlement')
->assertJsonPath('data.items.0.biz_type', 'period_paid')
->assertJsonPath('data.items.0.net_amount', -200)
->assertJsonPath('data.items.1.net_amount', -100);
});
test('credit player activity paginates after grouping orders', function (): void {
$player = Player::query()->create([
'site_code' => 'default_site',
'site_player_id' => 'native:activity-pages',
'auth_source' => PlayerAuthSource::LOTTERY_NATIVE,
'funding_mode' => PlayerFundingMode::CREDIT,
'username' => 'credit_activity_pages',
'default_currency' => 'NPR',
'status' => 0,
]);
DB::table('player_credit_accounts')->insert([
'player_id' => $player->id,
'credit_limit' => 500,
'used_credit' => 11,
'frozen_credit' => 0,
'created_at' => now(),
'updated_at' => now(),
]);
$drawId = (int) DB::table('draws')->insertGetId([
'draw_no' => 'ACTIVITY-PAGE-001',
'business_date' => now()->toDateString(),
'sequence_no' => 1,
'status' => 'open',
'created_at' => now(),
'updated_at' => now(),
]);
for ($i = 1; $i <= 11; $i++) {
$placedAt = now()->subMinutes(11 - $i);
$orderId = (int) DB::table('ticket_orders')->insertGetId([
'order_no' => 'ORDER-ACTIVITY-PAGE-'.$i,
'player_id' => $player->id,
'draw_id' => $drawId,
'currency_code' => 'NPR',
'total_bet_amount' => 100,
'total_rebate_amount' => 0,
'total_actual_deduct' => 100,
'total_estimated_payout' => 1000,
'status' => 'placed',
'created_at' => $placedAt,
'updated_at' => $placedAt,
]);
DB::table('ticket_items')->insert([
'ticket_no' => 'TICKET-ACTIVITY-PAGE-'.$i,
'order_id' => $orderId,
'player_id' => $player->id,
'draw_id' => $drawId,
'original_number' => '1234',
'normalized_number' => '1234',
'play_code' => '4d',
'total_bet_amount' => 100,
'actual_deduct_amount' => 100,
'status' => 'pending_draw',
'created_at' => $placedAt,
'updated_at' => $placedAt,
]);
}
$page1 = $this->withHeader('Authorization', 'Bearer dev:'.$player->id)
->getJson('/api/v1/wallet/logs?page=1&size=10')
->assertOk()
->json('data');
$page2 = $this->withHeader('Authorization', 'Bearer dev:'.$player->id)
->getJson('/api/v1/wallet/logs?page=2&size=10')
->assertOk()
->json('data');
expect($page1['total'])->toBe(11)
->and($page1['items'])->toHaveCount(10)
->and($page2['items'])->toHaveCount(1)
->and($page2['items'][0]['activity_kind'])->toBe('bet');
});

View File

@@ -1,8 +1,8 @@
<?php <?php
use App\Models\Draw; use App\Models\Draw;
use App\Models\BetProvider;
use App\Lottery\DrawStatus; use App\Lottery\DrawStatus;
use App\Models\BetProvider;
use App\Models\DrawResultItem; use App\Models\DrawResultItem;
use App\Models\DrawResultBatch; use App\Models\DrawResultBatch;
use App\Lottery\DrawResultBatchStatus; use App\Lottery\DrawResultBatchStatus;
@@ -134,6 +134,29 @@ test('draw result show includes neighbor draw numbers', function (): void {
->assertJsonPath('data.next_draw_no', '20260509-102'); ->assertJsonPath('data.next_draw_no', '20260509-102');
}); });
test('draw result show accepts sequence numbers longer than three digits', function (): void {
seedMinimalPublishedDraw([
'draw_no' => '20260509-1001',
'business_date' => '2026-05-09',
'sequence_no' => 1001,
'status' => DrawStatus::Settled->value,
'start_time' => now()->subHour(),
'close_time' => now()->subMinutes(45),
'draw_time' => now()->subMinutes(30),
'cooling_end_time' => null,
'result_source' => 'rng',
'current_result_version' => 1,
'settle_version' => 1,
'is_reopened' => false,
], '4');
$this->getJson('/api/v1/draw/results/20260509-1001')
->assertOk()
->assertJsonPath('code', 0)
->assertJsonPath('data.draw_no', '20260509-1001')
->assertJsonPath('data.results.1st', '4444');
});
test('draw results include all published provider batches for the same draw', function (): void { test('draw results include all published provider batches for the same draw', function (): void {
BetProvider::query()->updateOrCreate(['code' => 'SG'], ['name' => 'Singapore', 'is_enabled' => true, 'sort_order' => 10]); BetProvider::query()->updateOrCreate(['code' => 'SG'], ['name' => 'Singapore', 'is_enabled' => true, 'sort_order' => 10]);
BetProvider::query()->updateOrCreate(['code' => 'MY'], ['name' => 'Malaysia', 'is_enabled' => true, 'sort_order' => 20]); BetProvider::query()->updateOrCreate(['code' => 'MY'], ['name' => 'Malaysia', 'is_enabled' => true, 'sort_order' => 20]);

View File

@@ -0,0 +1,158 @@
<?php
use App\Models\Draw;
use App\Models\AuditLog;
use App\Models\AdminUser;
use App\Lottery\DrawStatus;
use App\Models\DrawResultBatch;
use App\Models\SettlementBatch;
use Illuminate\Support\Facades\Hash;
use App\Lottery\DrawResultBatchStatus;
use App\Support\AdminAuthorizationRegistry;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
function createDrawForManualSettlement(
string $status = 'cooldown',
bool $withPublishedResult = true,
): Draw {
$draw = Draw::query()->create([
'draw_no' => '20260722-'.random_int(1000, 9999),
'business_date' => '2026-07-22',
'sequence_no' => random_int(1000, 9999),
'status' => $status,
'start_time' => now()->subMinutes(20),
'close_time' => now()->subMinutes(10),
'draw_time' => now()->subMinutes(9),
'cooling_end_time' => now()->addMinutes(10),
'current_result_version' => $withPublishedResult ? 1 : 0,
'settle_version' => 0,
'is_reopened' => false,
]);
if ($withPublishedResult) {
DrawResultBatch::query()->create([
'draw_id' => $draw->id,
'provider_code' => 'SG',
'provider_name' => 'Singapore',
'result_version' => 1,
'source_type' => 'rng',
'status' => DrawResultBatchStatus::Published->value,
'confirmed_at' => now(),
]);
}
return $draw;
}
function manualSettlementAdminToken(): string
{
$admin = AdminUser::query()->create([
'username' => 'manual_settle_'.bin2hex(random_bytes(3)),
'name' => 'Manual Settlement',
'email' => null,
'password' => Hash::make('secret-strong'),
'status' => 0,
]);
grantSuperAdminRole($admin);
return $admin->createToken('test', ['*'], now()->addDay())->plainTextToken;
}
test('payout manager may skip cooldown and settlement retry is idempotent', function (): void {
$draw = createDrawForManualSettlement();
$originalCoolingEnd = $draw->cooling_end_time;
$token = manualSettlementAdminToken();
$this->withHeader('Authorization', 'Bearer '.$token)
->postJson("/api/v1/admin/draws/{$draw->id}/settlement/run")
->assertOk()
->assertJsonPath('data.ran', true)
->assertJsonPath('data.cooldown_skipped', true)
->assertJsonPath('data.status', DrawStatus::Settling->value)
->assertJsonPath('data.settle_version', 1);
$draw->refresh();
expect($draw->status)->toBe(DrawStatus::Settling->value)
->and($draw->cooling_end_time)->not->toBeNull()
->and($draw->cooling_end_time->lessThan($originalCoolingEnd))->toBeTrue()
->and(SettlementBatch::query()->where('draw_id', $draw->id)->count())->toBe(1);
$this->withHeader('Authorization', 'Bearer '.$token)
->postJson("/api/v1/admin/draws/{$draw->id}/settlement/run")
->assertOk()
->assertJsonPath('data.ran', true)
->assertJsonPath('data.cooldown_skipped', false)
->assertJsonPath('data.settle_version', 1);
expect(SettlementBatch::query()->where('draw_id', $draw->id)->count())->toBe(1);
});
test('manual settlement rejects missing results and invalid draw states without mutation', function (): void {
$token = manualSettlementAdminToken();
$missingResult = createDrawForManualSettlement(withPublishedResult: false);
$this->withHeader('Authorization', 'Bearer '.$token)
->postJson("/api/v1/admin/draws/{$missingResult->id}/settlement/run")
->assertStatus(409)
->assertJsonPath('data.reason', 'draw_result_not_published');
expect($missingResult->fresh()->status)->toBe(DrawStatus::Cooldown->value)
->and(SettlementBatch::query()->where('draw_id', $missingResult->id)->exists())->toBeFalse();
$openDraw = createDrawForManualSettlement(status: DrawStatus::Open->value);
$this->withHeader('Authorization', 'Bearer '.$token)
->postJson("/api/v1/admin/draws/{$openDraw->id}/settlement/run")
->assertStatus(409)
->assertJsonPath('data.reason', 'draw_not_ready_for_settlement');
expect($openDraw->fresh()->status)->toBe(DrawStatus::Open->value)
->and(SettlementBatch::query()->where('draw_id', $openDraw->id)->exists())->toBeFalse();
});
test('manual settlement resource is restricted to settlement management and audited', function (): void {
$resource = collect(AdminAuthorizationRegistry::resources())
->firstWhere('code', 'admin.draws.settlement.run');
expect($resource)->not->toBeNull()
->and($resource['permission_codes'])->toBe(['settlement.batch.manage'])
->and($resource['is_audit_required'])->toBeTrue();
$draw = createDrawForManualSettlement();
$token = manualSettlementAdminToken();
$before = AuditLog::query()->count();
$this->withHeader('Authorization', 'Bearer '.$token)
->postJson("/api/v1/admin/draws/{$draw->id}/settlement/run")
->assertOk();
expect(AuditLog::query()->count())->toBe($before + 1);
});
test('draw finance summary exposes provider and result settlement versions', function (): void {
$draw = createDrawForManualSettlement(status: DrawStatus::Settling->value);
$result = DrawResultBatch::query()->where('draw_id', $draw->id)->firstOrFail();
SettlementBatch::query()->create([
'draw_id' => $draw->id,
'result_batch_id' => $result->id,
'settle_version' => 3,
'status' => 'pending_review',
'total_ticket_count' => 0,
'total_win_count' => 0,
'total_payout_amount' => 0,
'total_jackpot_payout_amount' => 0,
'started_at' => now(),
'finished_at' => now(),
]);
$token = manualSettlementAdminToken();
$this->withHeader('Authorization', 'Bearer '.$token)
->getJson("/api/v1/admin/draws/{$draw->id}/finance-summary")
->assertOk()
->assertJsonPath('data.settlement_batches.0.provider_code', 'SG')
->assertJsonPath('data.settlement_batches.0.provider_name', 'Singapore')
->assertJsonPath('data.settlement_batches.0.result_version', 1)
->assertJsonPath('data.settlement_batches.0.settle_version', 3);
});

View File

@@ -1,10 +1,13 @@
<?php <?php
use App\Models\Draw;
use App\Models\Player; use App\Models\Player;
use App\Models\TicketItem; use App\Models\TicketItem;
use App\Services\AgentSettlement\GameSettlementReversalService; use App\Lottery\DrawStatus;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use App\Services\Player\PlayerCreditService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use App\Services\AgentSettlement\GameSettlementReversalService;
uses(RefreshDatabase::class); uses(RefreshDatabase::class);
@@ -31,11 +34,11 @@ test('reversal zeroes share ledger net and marks rebates reversed', function ():
'status' => 0, 'status' => 0,
]); ]);
$drawId = (int) \App\Models\Draw::query()->create([ $drawId = (int) Draw::query()->create([
'draw_no' => 'REV-DRAW-1', 'draw_no' => 'REV-DRAW-1',
'business_date' => now()->toDateString(), 'business_date' => now()->toDateString(),
'sequence_no' => 1, 'sequence_no' => 1,
'status' => \App\Lottery\DrawStatus::Open->value, 'status' => DrawStatus::Open->value,
'current_result_version' => 0, 'current_result_version' => 0,
'settle_version' => 0, 'settle_version' => 0,
'is_reopened' => false, 'is_reopened' => false,
@@ -148,11 +151,11 @@ test('reversal restores credit used for settled win on credit player', function
'updated_at' => now(), 'updated_at' => now(),
]); ]);
$drawId = (int) \App\Models\Draw::query()->create([ $drawId = (int) Draw::query()->create([
'draw_no' => 'REV-CREDIT-DRAW', 'draw_no' => 'REV-CREDIT-DRAW',
'business_date' => now()->toDateString(), 'business_date' => now()->toDateString(),
'sequence_no' => 1, 'sequence_no' => 1,
'status' => \App\Lottery\DrawStatus::Open->value, 'status' => DrawStatus::Open->value,
'current_result_version' => 0, 'current_result_version' => 0,
'settle_version' => 0, 'settle_version' => 0,
'is_reopened' => false, 'is_reopened' => false,
@@ -219,12 +222,52 @@ test('reversal restores credit used for settled win on credit player', function
'updated_at' => now(), 'updated_at' => now(),
]); ]);
// Simulate post-win used_credit (win decreased used by 10 major for 1000 minor). // 模拟结算前已有 11 主单位占额:释放本注 1再以中奖额释放 10结算后为 0。
DB::table('player_credit_accounts')->where('player_id', $player->id)->update(['used_credit' => 0]); DB::table('player_credit_accounts')->where('player_id', $player->id)->update(['used_credit' => 0]);
$item = TicketItem::query()->findOrFail($itemId); $item = TicketItem::query()->findOrFail($itemId);
app(GameSettlementReversalService::class)->reverseTicketItem($item); app(GameSettlementReversalService::class)->reverseTicketItem($item);
expect((int) DB::table('player_credit_accounts')->where('player_id', $player->id)->value('used_credit'))->toBe(10); expect((int) DB::table('player_credit_accounts')->where('player_id', $player->id)->value('used_credit'))->toBe(11);
expect(DB::table('credit_ledger')->where('reason', 'game_settlement_reversal')->where('owner_id', $player->id)->exists())->toBeTrue(); expect(DB::table('credit_ledger')->where('reason', 'game_settlement_reversal')->where('owner_id', $player->id)->exists())->toBeTrue();
}); });
test('reversal restores only the credit actually released by an oversized win', function (): void {
$site = DB::table('admin_sites')->where('is_default', true)->first();
$player = Player::query()->create([
'site_code' => (string) $site->code,
'agent_node_id' => (int) DB::table('agent_nodes')->where('depth', 0)->value('id'),
'site_player_id' => 'rev-credit-clipped',
'auth_source' => 'lottery_native',
'funding_mode' => 'credit',
'username' => 'revcreditclipped',
'nickname' => null,
'default_currency' => 'NPR',
'status' => 0,
]);
DB::table('player_credit_accounts')->insert([
'player_id' => $player->id,
'credit_limit' => 10000,
'used_credit' => 1,
'frozen_credit' => 0,
'created_at' => now(),
'updated_at' => now(),
]);
$credit = app(PlayerCreditService::class);
$credit->releaseBetHold($player, 100, 99881, 1);
$credit->applySettledWin($player, 1000, 99881, 1);
expect((int) DB::table('player_credit_accounts')->where('player_id', $player->id)->value('used_credit'))->toBe(0)
->and((int) DB::table('credit_ledger')
->where('ref_id', 99881)
->where('reason', 'game_settlement_win')
->where('settlement_version', 1)
->value('amount'))->toBe(0);
$credit->reverseGameSettlement($player, -1000, 99881, 1);
$credit->restoreBetHoldAfterSettlementReversal($player, 100, 99881, 1);
expect((int) DB::table('player_credit_accounts')->where('player_id', $player->id)->value('used_credit'))->toBe(1);
});

View File

@@ -11,19 +11,20 @@ use App\Models\TicketOrder;
use App\Models\PlayerWallet; use App\Models\PlayerWallet;
use App\Models\DrawResultItem; use App\Models\DrawResultItem;
use App\Models\DrawResultBatch; use App\Models\DrawResultBatch;
use App\Models\JackpotPayoutLog;
use App\Models\SettlementBatch; use App\Models\SettlementBatch;
use Illuminate\Support\Facades\Event; use App\Models\JackpotPayoutLog;
use App\Events\JackpotBurstBroadcast;
use App\Services\Draw\DrawResultViewService;
use App\Models\JackpotContribution; use App\Models\JackpotContribution;
use Illuminate\Support\Facades\Hash; use App\Support\OddsStandardScopes;
use App\Lottery\ConfigVersionStatus; use App\Lottery\ConfigVersionStatus;
use Database\Seeders\CurrencySeeder; use Database\Seeders\CurrencySeeder;
use Database\Seeders\PlayTypeSeeder; use Database\Seeders\PlayTypeSeeder;
use Illuminate\Support\Facades\Hash;
use App\Events\JackpotBurstBroadcast;
use Illuminate\Support\Facades\Event;
use App\Lottery\DrawResultBatchStatus; use App\Lottery\DrawResultBatchStatus;
use App\Services\Draw\DrawPrizeLayout; use App\Services\Draw\DrawPrizeLayout;
use Database\Seeders\LotterySettingsSeeder; use Database\Seeders\LotterySettingsSeeder;
use App\Services\Draw\DrawResultViewService;
use Database\Seeders\OperationalConfigV1Seeder; use Database\Seeders\OperationalConfigV1Seeder;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use App\Services\Settlement\SettlementOrchestrator; use App\Services\Settlement\SettlementOrchestrator;
@@ -239,7 +240,8 @@ test('jackpot contributes on place and bursts on settle for first-prize straight
app(SettlementBatchWorkflowService::class)->payout($settlementBatch->fresh()); app(SettlementBatchWorkflowService::class)->payout($settlementBatch->fresh());
$item = TicketItem::query()->where('draw_id', $draw->id)->firstOrFail(); $item = TicketItem::query()->where('draw_id', $draw->id)->firstOrFail();
expect((int) $item->win_amount)->toBe(250_000); $expectedWin = (int) floor(10_000 * OddsStandardScopes::presetOddsForPlay('straight')['first'] / 10_000);
expect((int) $item->win_amount)->toBe($expectedWin);
expect((int) $item->jackpot_win_amount)->toBe(1_000); expect((int) $item->jackpot_win_amount)->toBe(1_000);
$poolAfterSettle = JackpotPool::query()->where('currency_code', 'NPR')->firstOrFail(); $poolAfterSettle = JackpotPool::query()->where('currency_code', 'NPR')->firstOrFail();

View File

@@ -9,9 +9,9 @@ use App\Models\PlayConfigItem;
use App\Models\PlayConfigVersion; use App\Models\PlayConfigVersion;
use App\Support\OddsStandardScopes; use App\Support\OddsStandardScopes;
use App\Lottery\ConfigVersionStatus; use App\Lottery\ConfigVersionStatus;
use App\Services\Ticket\PlayCatalogResolver;
use Database\Seeders\CurrencySeeder; use Database\Seeders\CurrencySeeder;
use Database\Seeders\PlayTypeSeeder; use Database\Seeders\PlayTypeSeeder;
use App\Services\Ticket\PlayCatalogResolver;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class); uses(RefreshDatabase::class);
@@ -59,7 +59,7 @@ test('syncMissingForVersion adds five scopes from default-only rows', function (
->where('prize_scope', $scope) ->where('prize_scope', $scope)
->firstOrFail(); ->firstOrFail();
expect((int) $row->odds_value)->toBe(OddsStandardScopes::PRESET_ODDS_BY_SCOPE[$scope]); expect((int) $row->odds_value)->toBe(OddsStandardScopes::presetOddsForPlay((string) $pt->play_code)[$scope]);
/** @var string $rebate Eloquent `decimal:4` cast */ /** @var string $rebate Eloquent `decimal:4` cast */
$rebate = (string) $row->rebate_rate; $rebate = (string) $row->rebate_rate;
expect($rebate)->toBe('0.0100'); expect($rebate)->toBe('0.0100');

View File

@@ -18,15 +18,15 @@ use App\Models\AdminUser;
use App\Lottery\DrawStatus; use App\Lottery\DrawStatus;
use App\Models\OddsVersion; use App\Models\OddsVersion;
use App\Models\RiskCapVersion; use App\Models\RiskCapVersion;
use App\Services\Ticket\PlayCatalogResolver; use Illuminate\Support\Facades\DB;
use App\Events\OddsUpdateBroadcast; use App\Events\OddsUpdateBroadcast;
use App\Events\PlayToggleBroadcast; use App\Events\PlayToggleBroadcast;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Event;
use App\Lottery\ConfigVersionStatus; use App\Lottery\ConfigVersionStatus;
use Database\Seeders\CurrencySeeder; use Database\Seeders\CurrencySeeder;
use Database\Seeders\PlayTypeSeeder; use Database\Seeders\PlayTypeSeeder;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Event;
use App\Services\Ticket\PlayCatalogResolver;
use Database\Seeders\OperationalConfigV1Seeder; use Database\Seeders\OperationalConfigV1Seeder;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
@@ -55,8 +55,10 @@ function acceptanceMintAdminToken(): string
function acceptanceOddsPutPayloadFromDetail(array $items): array function acceptanceOddsPutPayloadFromDetail(array $items): array
{ {
return collect($items)->map(fn (array $r) => [ return collect($items)->map(fn (array $r) => [
'provider_code' => $r['provider_code'] ?? 'GLOBAL',
'play_code' => $r['play_code'], 'play_code' => $r['play_code'],
'prize_scope' => $r['prize_scope'], 'prize_scope' => $r['prize_scope'],
'dimension' => $r['dimension'] ?? null,
'odds_value' => (int) $r['odds_value'], 'odds_value' => (int) $r['odds_value'],
'rebate_rate' => (float) $r['rebate_rate'], 'rebate_rate' => (float) $r['rebate_rate'],
'commission_rate' => (float) $r['commission_rate'], 'commission_rate' => (float) $r['commission_rate'],

View File

@@ -61,7 +61,7 @@ test('api unknown route returns unified not_found json without hitting locale mi
test('player me works with main site jwt when dev bypass is off', function () { test('player me works with main site jwt when dev bypass is off', function () {
config(['lottery.player_auth.dev_bypass' => false]); config(['lottery.player_auth.dev_bypass' => false]);
config(['lottery.main_site.sso_jwt_secret' => 'jwt-test-secret']); config(['lottery.main_site.sso_jwt_secret' => 'jwt-test-secret-at-least-32-bytes-long']);
$player = Player::query()->create([ $player = Player::query()->create([
'site_code' => 'main', 'site_code' => 'main',
@@ -78,7 +78,7 @@ test('player me works with main site jwt when dev bypass is off', function () {
'site_player_id' => 'jwt-user-1', 'site_player_id' => 'jwt-user-1',
'iat' => $now, 'iat' => $now,
'exp' => $now + 300, 'exp' => $now + 300,
], 'jwt-test-secret', 'HS256'); ], 'jwt-test-secret-at-least-32-bytes-long', 'HS256');
$this->withHeader('Authorization', 'Bearer '.$jwt) $this->withHeader('Authorization', 'Bearer '.$jwt)
->getJson('/api/v1/player/me') ->getJson('/api/v1/player/me')
@@ -88,7 +88,7 @@ test('player me works with main site jwt when dev bypass is off', function () {
test('jwt first successful login auto-registers player mapping', function () { test('jwt first successful login auto-registers player mapping', function () {
config(['lottery.player_auth.dev_bypass' => false]); config(['lottery.player_auth.dev_bypass' => false]);
config(['lottery.main_site.sso_jwt_secret' => 'jwt-test-secret']); config(['lottery.main_site.sso_jwt_secret' => 'jwt-test-secret-at-least-32-bytes-long']);
expect(Player::query()->count())->toBe(0); expect(Player::query()->count())->toBe(0);
@@ -98,7 +98,7 @@ test('jwt first successful login auto-registers player mapping', function () {
'site_player_id' => 'brand-new-sso-1', 'site_player_id' => 'brand-new-sso-1',
'iat' => $now, 'iat' => $now,
'exp' => $now + 300, 'exp' => $now + 300,
], 'jwt-test-secret', 'HS256'); ], 'jwt-test-secret-at-least-32-bytes-long', 'HS256');
$response = $this->withHeader('Authorization', 'Bearer '.$jwt) $response = $this->withHeader('Authorization', 'Bearer '.$jwt)
->getJson('/api/v1/player/me') ->getJson('/api/v1/player/me')
@@ -116,6 +116,37 @@ test('jwt first successful login auto-registers player mapping', function () {
->and($player->nickname)->toBe($username); ->and($player->nickname)->toBe($username);
}); });
test('main site sso jwt cannot take over an existing native credit player mapping', function (): void {
config(['lottery.player_auth.dev_bypass' => false]);
config(['lottery.main_site.sso_jwt_secret' => 'jwt-test-secret-at-least-32-bytes-long']);
$native = Player::query()->create([
'site_code' => 'main',
'site_player_id' => 'native-sso-collision',
'auth_source' => 'lottery_native',
'funding_mode' => 'credit',
'username' => 'native_collision',
'nickname' => null,
'default_currency' => 'NPR',
'status' => 0,
]);
$now = time();
$jwt = JWT::encode([
'site_code' => 'main',
'site_player_id' => $native->site_player_id,
'iat' => $now,
'exp' => $now + 300,
], 'jwt-test-secret-at-least-32-bytes-long', 'HS256');
$this->withHeader('Authorization', 'Bearer '.$jwt)
->getJson('/api/v1/player/me')
->assertUnauthorized()
->assertJsonPath('code', ErrorCode::PlayerTokenInvalid->value);
expect($native->fresh()->last_login_at)->toBeNull();
});
test('player me rejects non-active status with 8005', function () { test('player me rejects non-active status with 8005', function () {
$code = ErrorCode::PlayerAccountSuspended->value; $code = ErrorCode::PlayerAccountSuspended->value;
$player = Player::query()->create([ $player = Player::query()->create([

View File

@@ -1,15 +1,21 @@
<?php <?php
use App\Models\Player; use App\Models\Player;
use App\Lottery\ErrorCode;
use Illuminate\Support\Str;
use App\Models\PlayerWallet;
use App\Support\PlayerAuthSource; use App\Support\PlayerAuthSource;
use App\Support\PlayerFundingMode; use App\Support\PlayerFundingMode;
use Database\Seeders\CurrencySeeder;
use Database\Seeders\LotterySettingsSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Database\Seeders\CurrencySeeder;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str; use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Event;
use Database\Seeders\LotterySettingsSeeder;
use Illuminate\Support\Facades\RateLimiter;
use App\Events\PlayerSessionReplacedBroadcast;
use App\Services\Player\PlayerNativeAuthService;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class); uses(RefreshDatabase::class);
@@ -240,6 +246,63 @@ test('native player can login and access me', function (): void {
->assertJsonPath('data.auth_source', PlayerAuthSource::LOTTERY_NATIVE); ->assertJsonPath('data.auth_source', PlayerAuthSource::LOTTERY_NATIVE);
}); });
test('latest native login replaces the previous device session', function (): void {
Event::fake([PlayerSessionReplacedBroadcast::class]);
$site = DB::table('admin_sites')->where('is_default', true)->first();
$rootId = (int) DB::table('agent_nodes')->where('depth', 0)->value('id');
$player = Player::query()->create([
'site_code' => (string) $site->code,
'agent_node_id' => $rootId,
'site_player_id' => 'native:single-session',
'auth_source' => PlayerAuthSource::LOTTERY_NATIVE,
'funding_mode' => PlayerFundingMode::CREDIT,
'username' => 'single_session_player',
'password_hash' => Hash::make('secret-pass'),
'nickname' => null,
'default_currency' => 'NPR',
'status' => 0,
]);
$firstLogin = $this->postJson('/api/v1/player/auth/login', array_merge([
'username' => 'single_session_player',
'password' => 'secret-pass',
], playerLoginCaptcha()))->assertOk();
$firstToken = (string) $firstLogin->json('data.access_token');
expect($player->fresh()->native_session_version)->toBe(1);
$this->withHeader('Authorization', 'Bearer '.$firstToken)
->getJson('/api/v1/player/me')
->assertOk();
$secondLogin = $this->postJson('/api/v1/player/auth/login', array_merge([
'username' => 'single_session_player',
'password' => 'secret-pass',
], playerLoginCaptcha()))->assertOk();
$secondToken = (string) $secondLogin->json('data.access_token');
expect($secondToken)->not->toBe($firstToken)
->and($player->fresh()->native_session_version)->toBe(2);
$this->withHeader('Authorization', 'Bearer '.$firstToken)
->getJson('/api/v1/player/me')
->assertStatus(401)
->assertJsonPath('code', ErrorCode::PlayerSessionReplaced->value);
$this->withHeader('Authorization', 'Bearer '.$secondToken)
->getJson('/api/v1/player/me')
->assertOk()
->assertJsonPath('data.id', $player->id);
Event::assertDispatchedTimes(PlayerSessionReplacedBroadcast::class, 2);
Event::assertDispatched(
PlayerSessionReplacedBroadcast::class,
fn (PlayerSessionReplacedBroadcast $event): bool => $event->playerId === (int) $player->id
&& $event->sessionVersion === 2,
);
});
test('credit player wallet transfer in is rejected', function (): void { test('credit player wallet transfer in is rejected', function (): void {
$site = DB::table('admin_sites')->where('is_default', true)->first(); $site = DB::table('admin_sites')->where('is_default', true)->first();
$rootId = (int) DB::table('agent_nodes')->where('depth', 0)->value('id'); $rootId = (int) DB::table('agent_nodes')->where('depth', 0)->value('id');
@@ -257,7 +320,7 @@ test('credit player wallet transfer in is rejected', function (): void {
'status' => 0, 'status' => 0,
]); ]);
$auth = app(\App\Services\Player\PlayerNativeAuthService::class); $auth = app(PlayerNativeAuthService::class);
$token = $auth->issueToken($player); $token = $auth->issueToken($player);
$response = $this->withHeader('Authorization', 'Bearer '.$token) $response = $this->withHeader('Authorization', 'Bearer '.$token)
@@ -293,7 +356,7 @@ test('sso wallet player balance does not use credit when site credit mode on', f
'status' => 0, 'status' => 0,
]); ]);
\App\Models\PlayerWallet::query()->create([ PlayerWallet::query()->create([
'player_id' => $player->id, 'player_id' => $player->id,
'wallet_type' => 'lottery', 'wallet_type' => 'lottery',
'currency_code' => 'NPR', 'currency_code' => 'NPR',
@@ -311,3 +374,152 @@ test('sso wallet player balance does not use credit when site credit mode on', f
->assertJsonPath('data.funding_mode', PlayerFundingMode::WALLET) ->assertJsonPath('data.funding_mode', PlayerFundingMode::WALLET)
->assertJsonPath('data.available_balance', 12000); ->assertJsonPath('data.available_balance', 12000);
}); });
test('native player can change password and previous tokens are invalidated', function (): void {
$site = DB::table('admin_sites')->where('is_default', true)->first();
$rootId = (int) DB::table('agent_nodes')->where('depth', 0)->value('id');
$player = Player::query()->create([
'site_code' => (string) $site->code,
'agent_node_id' => $rootId,
'site_player_id' => 'native:password-change',
'auth_source' => PlayerAuthSource::LOTTERY_NATIVE,
'funding_mode' => PlayerFundingMode::CREDIT,
'username' => 'password_change_user',
'password_hash' => Hash::make('old-secret'),
'default_currency' => 'NPR',
'status' => 0,
]);
$oldToken = app(PlayerNativeAuthService::class)->issueToken($player);
$this->withHeader('Authorization', 'Bearer '.$oldToken)
->putJson('/api/v1/player/password', [
'current_password' => 'old-secret',
'password' => 'new-secret',
'password_confirmation' => 'new-secret',
])
->assertOk()
->assertJsonPath('data.password_changed', true);
$player->refresh();
expect(Hash::check('new-secret', (string) $player->password_hash))->toBeTrue()
->and($player->native_token_version)->toBe(1);
$this->withHeader('Authorization', 'Bearer '.$oldToken)
->getJson('/api/v1/player/me')
->assertStatus(401)
->assertJsonPath('code', ErrorCode::PlayerTokenInvalid->value);
$this->postJson('/api/v1/player/auth/login', array_merge([
'username' => 'password_change_user',
'password' => 'old-secret',
], playerLoginCaptcha()))
->assertStatus(401)
->assertJsonPath('code', ErrorCode::PlayerCredentialsInvalid->value);
$this->postJson('/api/v1/player/auth/login', array_merge([
'username' => 'password_change_user',
'password' => 'new-secret',
], playerLoginCaptcha()))
->assertOk()
->assertJsonPath('data.player.id', $player->id);
});
test('native player password change validates current password', function (): void {
$site = DB::table('admin_sites')->where('is_default', true)->first();
$rootId = (int) DB::table('agent_nodes')->where('depth', 0)->value('id');
$player = Player::query()->create([
'site_code' => (string) $site->code,
'agent_node_id' => $rootId,
'site_player_id' => 'native:password-current',
'auth_source' => PlayerAuthSource::LOTTERY_NATIVE,
'funding_mode' => PlayerFundingMode::CREDIT,
'username' => 'password_current_user',
'password_hash' => Hash::make('old-secret'),
'default_currency' => 'NPR',
'status' => 0,
]);
$token = app(PlayerNativeAuthService::class)->issueToken($player);
$this->withHeader('Authorization', 'Bearer '.$token)
->putJson('/api/v1/player/password', [
'current_password' => 'wrong-secret',
'password' => 'new-secret',
'password_confirmation' => 'new-secret',
])
->assertStatus(422)
->assertJsonPath('msg', 'The current password is incorrect.');
expect($player->fresh()->native_token_version)->toBe(0)
->and(Hash::check('old-secret', (string) $player->fresh()->password_hash))->toBeTrue();
});
test('native player password change is rate limited by player and ip', function (): void {
$site = DB::table('admin_sites')->where('is_default', true)->first();
$rootId = (int) DB::table('agent_nodes')->where('depth', 0)->value('id');
$player = Player::query()->create([
'site_code' => (string) $site->code,
'agent_node_id' => $rootId,
'site_player_id' => 'native:password-rate-limit',
'auth_source' => PlayerAuthSource::LOTTERY_NATIVE,
'funding_mode' => PlayerFundingMode::CREDIT,
'username' => 'password_rate_limit_user',
'password_hash' => Hash::make('old-secret'),
'default_currency' => 'NPR',
'status' => 0,
]);
$ip = '198.51.100.25';
$token = app(PlayerNativeAuthService::class)->issueToken($player);
RateLimiter::clear($player->id.'|'.$ip);
for ($attempt = 0; $attempt < 5; $attempt++) {
$this->withServerVariables(['REMOTE_ADDR' => $ip])
->withHeader('Authorization', 'Bearer '.$token)
->putJson('/api/v1/player/password', [
'current_password' => 'wrong-secret',
'password' => 'new-secret',
'password_confirmation' => 'new-secret',
])
->assertStatus(422);
}
$this->withServerVariables(['REMOTE_ADDR' => $ip])
->withHeader('Authorization', 'Bearer '.$token)
->putJson('/api/v1/player/password', [
'current_password' => 'wrong-secret',
'password' => 'new-secret',
'password_confirmation' => 'new-secret',
])
->assertStatus(429)
->assertJsonPath('code', ErrorCode::TooManyRequests->value);
expect(Hash::check('old-secret', (string) $player->fresh()->password_hash))->toBeTrue();
});
test('sso player cannot use native password management', function (): void {
$site = DB::table('admin_sites')->where('is_default', true)->first();
$player = Player::query()->create([
'site_code' => (string) $site->code,
'site_player_id' => 'sso-no-password',
'auth_source' => PlayerAuthSource::MAIN_SITE_SSO,
'funding_mode' => PlayerFundingMode::WALLET,
'username' => 'sso_no_password',
'default_currency' => 'NPR',
'status' => 0,
]);
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
->putJson('/api/v1/player/password', [
'current_password' => 'old-secret',
'password' => 'new-secret',
'password_confirmation' => 'new-secret',
])
->assertStatus(422)
->assertJsonPath('msg', 'Main-site SSO players do not use a lottery password.');
});

View File

@@ -0,0 +1,174 @@
<?php
use Firebase\JWT\JWT;
use App\Models\Player;
use App\Lottery\ErrorCode;
use App\Support\PlayerAuthSource;
use App\Support\PlayerFundingMode;
use Illuminate\Support\Facades\DB;
use Database\Seeders\CurrencySeeder;
use Illuminate\Support\Facades\Hash;
use Database\Seeders\LotterySettingsSeeder;
use App\Services\Player\PlayerNativeAuthService;
use App\Exceptions\PlayerAuthenticationException;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function (): void {
config([
'lottery.player_auth.native.secret' => 'independent-native-secret-32bytes!!',
'lottery.player_auth.native.ttl_seconds' => 3600,
'lottery.main_site.sso_jwt_secret' => null,
'lottery.main_site.wallet_api_url' => null,
]);
$this->seed(CurrencySeeder::class);
$this->seed(LotterySettingsSeeder::class);
});
function nativeSecretGuardPlayer(string $suffix): Player
{
$site = DB::table('admin_sites')->where('is_default', true)->first();
$rootId = (int) DB::table('agent_nodes')->where('depth', 0)->value('id');
return Player::query()->create([
'site_code' => (string) $site->code,
'agent_node_id' => $rootId,
'site_player_id' => 'native:secret-guard-'.$suffix,
'auth_source' => PlayerAuthSource::LOTTERY_NATIVE,
'funding_mode' => PlayerFundingMode::CREDIT,
'username' => 'secret_guard_'.$suffix,
'password_hash' => Hash::make('secret-pass'),
'default_currency' => 'NPR',
'status' => 0,
]);
}
function nativeSecretGuardToken(Player $player, string $secret): string
{
$now = time();
return JWT::encode([
'player_id' => (int) $player->id,
'auth_source' => PlayerAuthSource::LOTTERY_NATIVE,
'token_version' => (int) ($player->native_token_version ?? 0),
'site_code' => (string) $player->site_code,
'iat' => $now,
'exp' => $now + 3600,
], $secret, 'HS256');
}
function expectNativeSecretConfigurationRejected(Closure $callback): void
{
try {
$callback();
test()->fail('Expected native JWT secret configuration to be rejected.');
} catch (PlayerAuthenticationException $exception) {
expect($exception->lotteryCode)->toBe(ErrorCode::PlayerSsoSecretNotConfigured->value)
->and($exception->httpStatus)->toBe(503);
}
}
test('missing native secret rejects token issuance and verification with 503', function (): void {
$player = nativeSecretGuardPlayer('missing');
config(['lottery.player_auth.native.secret' => null]);
expectNativeSecretConfigurationRejected(
fn () => app(PlayerNativeAuthService::class)->issueToken($player),
);
$token = nativeSecretGuardToken($player, 'previous-native-secret-32bytes!!');
$this->withHeader('Authorization', 'Bearer '.$token)
->getJson('/api/v1/player/me')
->assertStatus(503)
->assertJsonPath('code', ErrorCode::PlayerSsoSecretNotConfigured->value);
});
test('native secret matching legacy sso secret rejects token issuance and verification', function (): void {
$player = nativeSecretGuardPlayer('legacy-match');
$sharedSecret = 'shared-legacy-native-secret-32bytes!!';
config([
'lottery.player_auth.native.secret' => $sharedSecret,
'lottery.main_site.sso_jwt_secret' => $sharedSecret,
]);
expectNativeSecretConfigurationRejected(
fn () => app(PlayerNativeAuthService::class)->issueToken($player),
);
$token = nativeSecretGuardToken($player, $sharedSecret);
$this->withHeader('Authorization', 'Bearer '.$token)
->getJson('/api/v1/player/me')
->assertStatus(503)
->assertJsonPath('code', ErrorCode::PlayerSsoSecretNotConfigured->value);
});
test('native secret matching enabled database site sso secret rejects issuance and verification', function (): void {
$player = nativeSecretGuardPlayer('database-match');
$sharedSecret = 'shared-database-native-secret-32bytes!!';
config([
'lottery.player_auth.native.secret' => $sharedSecret,
'lottery.main_site.sso_jwt_secret' => 'different-legacy-sso-secret-32bytes!!',
]);
DB::table('admin_sites')->where('is_default', true)->update([
'status' => 1,
'sso_jwt_secret_encrypted' => encrypt($sharedSecret),
'updated_at' => now(),
]);
expectNativeSecretConfigurationRejected(
fn () => app(PlayerNativeAuthService::class)->issueToken($player),
);
$token = nativeSecretGuardToken($player, $sharedSecret);
$this->withHeader('Authorization', 'Bearer '.$token)
->getJson('/api/v1/player/me')
->assertStatus(503)
->assertJsonPath('code', ErrorCode::PlayerSsoSecretNotConfigured->value);
});
test('native secret matching disabled database site sso secret rejects issuance and verification', function (): void {
$player = nativeSecretGuardPlayer('disabled-database-match');
$sharedSecret = 'shared-disabled-site-secret-32bytes!!';
config([
'lottery.player_auth.native.secret' => $sharedSecret,
'lottery.main_site.sso_jwt_secret' => 'different-legacy-sso-secret-32bytes!!',
]);
DB::table('admin_sites')->where('is_default', true)->update([
'status' => 0,
'sso_jwt_secret_encrypted' => encrypt($sharedSecret),
'updated_at' => now(),
]);
expectNativeSecretConfigurationRejected(
fn () => app(PlayerNativeAuthService::class)->issueToken($player),
);
$token = nativeSecretGuardToken($player, $sharedSecret);
$this->withHeader('Authorization', 'Bearer '.$token)
->getJson('/api/v1/player/me')
->assertStatus(503)
->assertJsonPath('code', ErrorCode::PlayerSsoSecretNotConfigured->value);
});
test('independent native secret still issues and verifies tokens', function (): void {
$player = nativeSecretGuardPlayer('independent');
config([
'lottery.player_auth.native.secret' => 'independent-native-secret-32bytes!!',
'lottery.main_site.sso_jwt_secret' => 'different-legacy-sso-secret-32bytes!!',
]);
DB::table('admin_sites')->where('is_default', true)->update([
'status' => 1,
'sso_jwt_secret_encrypted' => encrypt('different-database-sso-secret-32bytes!!'),
'updated_at' => now(),
]);
$token = app(PlayerNativeAuthService::class)->issueToken($player);
$this->withHeader('Authorization', 'Bearer '.$token)
->getJson('/api/v1/player/me')
->assertOk()
->assertJsonPath('data.id', $player->id)
->assertJsonPath('data.auth_source', PlayerAuthSource::LOTTERY_NATIVE);
});

View File

@@ -1,21 +1,26 @@
<?php <?php
use App\Events\BalanceUpdateBroadcast;
use App\Events\PlayCatalogUpdatedBroadcast;
use App\Events\RiskSoldOutBroadcast;
use App\Events\RiskWarningBroadcast;
use App\Services\Config\RiskCapStreamService;
use App\Models\Draw; use App\Models\Draw;
use App\Models\Player; use App\Models\Player;
use App\Models\PlayerWallet;
use App\Models\RiskPool; use App\Models\RiskPool;
use App\Services\Ticket\RiskPoolService; use App\Models\AdminUser;
use App\Services\Wallet\LotteryTransferService; use App\Models\PlayerWallet;
use App\Services\Wallet\WalletBalanceRealtimeNotifier; use App\Events\RiskSoldOutBroadcast;
use App\Events\RiskWarningBroadcast;
use Database\Seeders\CurrencySeeder; use Database\Seeders\CurrencySeeder;
use Database\Seeders\LotterySettingsSeeder; use Illuminate\Support\Facades\Hash;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Event;
use App\Events\BalanceUpdateBroadcast;
use App\Services\Ticket\RiskPoolService;
use Illuminate\Support\Facades\Broadcast;
use App\Events\PlayCatalogUpdatedBroadcast;
use Database\Seeders\LotterySettingsSeeder;
use Illuminate\Broadcasting\PrivateChannel;
use App\Services\Config\RiskCapStreamService;
use App\Events\PlayerSessionReplacedBroadcast;
use App\Services\Wallet\LotteryTransferService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use App\Services\Wallet\WalletBalanceRealtimeNotifier;
uses(RefreshDatabase::class); uses(RefreshDatabase::class);
@@ -26,6 +31,70 @@ beforeEach(function (): void {
$this->seed(LotterySettingsSeeder::class); $this->seed(LotterySettingsSeeder::class);
}); });
test('balance update broadcasts only on the player private channel', function (): void {
$event = new BalanceUpdateBroadcast(42, 'NPR', 10_000, -500, 'bet', 1_234_567);
$channels = $event->broadcastOn();
expect($channels)->toHaveCount(1)
->and($channels[0])->toBeInstanceOf(PrivateChannel::class)
->and($channels[0]->name)->toBe('private-player.42');
});
test('session replacement broadcasts the monotonic version on the player private channel', function (): void {
$event = new PlayerSessionReplacedBroadcast(42, 3, 1_234_567);
$channels = $event->broadcastOn();
expect($channels)->toHaveCount(1)
->and($channels[0])->toBeInstanceOf(PrivateChannel::class)
->and($channels[0]->name)->toBe('private-player.42')
->and($event->broadcastAs())->toBe('session.replaced')
->and($event->broadcastWith())->toBe([
'player_id' => 42,
'session_version' => 3,
'emitted_at_ms' => 1_234_567,
]);
});
test('player private channel auth allows only the matching bearer player', function (): void {
config([
'broadcasting.default' => 'reverb',
'broadcasting.connections.reverb.key' => 'test-reverb-key',
'broadcasting.connections.reverb.secret' => 'test-reverb-secret',
'broadcasting.connections.reverb.app_id' => 'test-reverb-app',
]);
Broadcast::purge('reverb');
require base_path('routes/channels.php');
$player = Player::query()->create([
'site_code' => 'test',
'site_player_id' => 'ws-auth-owner',
'default_currency' => 'NPR',
'status' => 0,
]);
$otherPlayer = Player::query()->create([
'site_code' => 'test',
'site_player_id' => 'ws-auth-other',
'default_currency' => 'NPR',
'status' => 0,
]);
$payload = [
'socket_id' => '1234.5678',
'channel_name' => 'private-player.'.$player->id,
];
$this->postJson('/api/broadcasting/auth', $payload)
->assertUnauthorized();
$this->withHeader('Authorization', 'Bearer dev:'.$otherPlayer->id)
->postJson('/api/broadcasting/auth', $payload)
->assertForbidden();
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
->postJson('/api/broadcasting/auth', $payload)
->assertOk()
->assertJsonStructure(['auth']);
});
test('wallet balance notifier dispatches balance update broadcast', function (): void { test('wallet balance notifier dispatches balance update broadcast', function (): void {
Event::fake([BalanceUpdateBroadcast::class]); Event::fake([BalanceUpdateBroadcast::class]);
@@ -109,11 +178,11 @@ test('risk pool acquire dispatches warning and sold out broadcasts', function ()
test('risk cap publish dispatches play catalog updated broadcast', function (): void { test('risk cap publish dispatches play catalog updated broadcast', function (): void {
Event::fake([PlayCatalogUpdatedBroadcast::class]); Event::fake([PlayCatalogUpdatedBroadcast::class]);
$admin = \App\Models\AdminUser::query()->create([ $admin = AdminUser::query()->create([
'username' => 'risk_cap_admin', 'username' => 'risk_cap_admin',
'name' => 'Risk Cap QA', 'name' => 'Risk Cap QA',
'email' => null, 'email' => null,
'password' => \Illuminate\Support\Facades\Hash::make('secret-strong'), 'password' => Hash::make('secret-strong'),
'status' => 0, 'status' => 0,
]); ]);
grantSuperAdminRole($admin); grantSuperAdminRole($admin);

View File

@@ -1,6 +1,7 @@
<?php <?php
use App\Lottery\ErrorCode; use App\Lottery\ErrorCode;
use App\Models\LotterySetting;
use Database\Seeders\CurrencySeeder; use Database\Seeders\CurrencySeeder;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
@@ -18,14 +19,14 @@ test('public settings requires allowed group', function (): void {
$this->getJson('/api/v1/settings?group=wallet') $this->getJson('/api/v1/settings?group=wallet')
->assertStatus(400) ->assertStatus(400)
->assertJsonPath('code', ErrorCode::ClientHttpError->value); ->assertJsonPath('code', ErrorCode::ClientHttpError->value);
$this->getJson('/api/v1/settings?group=currency') $this->getJson('/api/v1/settings?group=currency')
->assertStatus(400) ->assertOk()
->assertJsonPath('code', ErrorCode::ClientHttpError->value); ->assertJsonPath('code', ErrorCode::Success->value);
}); });
test('public settings returns frontend group only', function (): void { test('public settings returns an allowed public group', function (): void {
\App\Models\LotterySetting::query()->updateOrCreate( LotterySetting::query()->updateOrCreate(
['setting_key' => 'frontend.play_rules_html_zh'], ['setting_key' => 'frontend.play_rules_html_zh'],
[ [
'group_name' => 'frontend', 'group_name' => 'frontend',

View File

@@ -0,0 +1,401 @@
<?php
use App\Models\Draw;
use App\Models\Player;
use App\Models\AdminUser;
use App\Models\TicketItem;
use App\Lottery\DrawStatus;
use App\Models\TicketOrder;
use App\Models\PlayerWallet;
use App\Models\DrawResultItem;
use App\Models\DrawResultBatch;
use App\Models\SettlementBatch;
use App\Models\TicketCombination;
use App\Services\LotterySettings;
use Illuminate\Support\Facades\Hash;
use App\Lottery\DrawResultBatchStatus;
use App\Lottery\SettlementBatchStatus;
use App\Services\Draw\DrawPrizeLayout;
use Illuminate\Foundation\Testing\RefreshDatabase;
use App\Services\Settlement\SettlementOrchestrator;
use App\Services\Settlement\SettlementTickFinalizer;
use App\Services\Settlement\SettlementBatchWorkflowService;
uses(RefreshDatabase::class);
function createChunkTestDraw(string $suffix): Draw
{
return Draw::query()->create([
'draw_no' => 'CHUNK-'.$suffix,
'business_date' => '2026-07-22',
'sequence_no' => random_int(1000, 9999),
'status' => DrawStatus::Settling->value,
'start_time' => now()->subMinutes(10),
'close_time' => now()->subMinutes(2),
'draw_time' => now()->subMinute(),
'current_result_version' => 1,
'settle_version' => 0,
'is_reopened' => false,
]);
}
function createChunkTestPlayer(string $suffix): Player
{
$player = Player::query()->create([
'site_code' => 'test',
'site_player_id' => 'chunk-'.$suffix,
'username' => 'chunk_'.$suffix,
'default_currency' => 'NPR',
'status' => 0,
]);
PlayerWallet::query()->create([
'player_id' => $player->id,
'wallet_type' => 'lottery',
'currency_code' => 'NPR',
'balance' => 0,
'frozen_balance' => 0,
'status' => 0,
'version' => 0,
]);
return $player;
}
function createChunkTestResult(Draw $draw, string $providerCode): DrawResultBatch
{
$batch = DrawResultBatch::query()->create([
'draw_id' => $draw->id,
'provider_code' => $providerCode,
'provider_name' => $providerCode,
'result_version' => 1,
'source_type' => 'manual',
'status' => DrawResultBatchStatus::Published->value,
'confirmed_at' => now(),
]);
foreach (DrawPrizeLayout::slots() as $slot) {
$number = $slot['prize_type'] === 'first' ? '1234' : '5678';
DrawResultItem::query()->create([
'draw_id' => $draw->id,
'result_batch_id' => $batch->id,
'prize_type' => $slot['prize_type'],
'prize_index' => $slot['prize_index'],
'number_4d' => $number,
'suffix_3d' => substr($number, -3),
'suffix_2d' => substr($number, -2),
'head_digit' => (int) $number[0],
'tail_digit' => (int) $number[3],
]);
}
return $batch;
}
function createChunkTestTicket(
Draw $draw,
Player $player,
TicketOrder $order,
string $providerCode,
string $suffix,
): TicketItem {
$item = TicketItem::query()->create([
'ticket_no' => 'CHUNK-TICKET-'.$suffix,
'order_id' => $order->id,
'player_id' => $player->id,
'draw_id' => $draw->id,
'provider_code' => $providerCode,
'provider_name' => $providerCode,
'original_number' => '1234',
'normalized_number' => '1234',
'play_code' => 'pos_4a',
'bet_mode' => 'unit',
'unit_bet_amount' => 10_000,
'total_bet_amount' => 10_000,
'actual_deduct_amount' => 10_000,
'odds_snapshot_json' => [['prize_scope' => 'first', 'odds_value' => 100_000]],
'rule_snapshot_json' => [],
'combination_count' => 1,
'estimated_max_payout' => 100_000,
'risk_locked_amount' => 0,
'status' => 'pending_draw',
'win_amount' => 0,
'jackpot_win_amount' => 0,
]);
TicketCombination::query()->create([
'ticket_item_id' => $item->id,
'combination_no' => 1,
'number_4d' => '1234',
'bet_amount' => 10_000,
'estimated_payout' => 100_000,
]);
return $item;
}
function createChunkTestOrder(Draw $draw, Player $player, string $suffix, int $ticketCount): TicketOrder
{
return TicketOrder::query()->create([
'order_no' => 'CHUNK-ORDER-'.$suffix,
'player_id' => $player->id,
'draw_id' => $draw->id,
'currency_code' => 'NPR',
'total_bet_amount' => 10_000 * $ticketCount,
'total_rebate_amount' => 0,
'total_actual_deduct' => 10_000 * $ticketCount,
'total_estimated_payout' => 100_000 * $ticketCount,
'status' => 'placed',
'submit_source' => 'test',
'client_trace_id' => 'chunk-trace-'.$suffix,
]);
}
test('settlement processes every ticket chunk into one provider batch', function (): void {
config()->set('lottery.settlement.ticket_chunk_size', 2);
$suffix = bin2hex(random_bytes(4));
$draw = createChunkTestDraw($suffix);
$player = createChunkTestPlayer($suffix);
$order = createChunkTestOrder($draw, $player, $suffix, 3);
createChunkTestResult($draw, 'SG');
for ($i = 1; $i <= 3; $i++) {
createChunkTestTicket($draw, $player, $order, 'SG', $suffix.'-'.$i);
}
expect(app(SettlementOrchestrator::class)->trySettleDraw($draw))->toBeTrue();
$batch = SettlementBatch::query()->where('draw_id', $draw->id)->firstOrFail();
expect(SettlementBatch::query()->where('draw_id', $draw->id)->count())->toBe(1)
->and($batch->status)->toBe(SettlementBatchStatus::PendingReview->value)
->and((int) $batch->total_ticket_count)->toBe(3)
->and((int) $batch->total_win_count)->toBe(3)
->and((int) $batch->total_payout_amount)->toBe(300_000)
->and($batch->details()->count())->toBe(3)
->and(TicketItem::query()->where('draw_id', $draw->id)->where('status', 'pending_draw')->exists())->toBeFalse();
});
test('settlement resumes a legacy partial provider batch instead of skipping remaining tickets', function (): void {
config()->set('lottery.settlement.ticket_chunk_size', 2);
$suffix = bin2hex(random_bytes(4));
$draw = createChunkTestDraw($suffix);
$player = createChunkTestPlayer($suffix);
$order = createChunkTestOrder($draw, $player, $suffix, 3);
createChunkTestResult($draw, 'SG');
createChunkTestTicket($draw, $player, $order, 'SG', $suffix.'-1');
createChunkTestTicket($draw, $player, $order, 'SG', $suffix.'-2');
app(SettlementOrchestrator::class)->trySettleDraw($draw);
$existingBatch = SettlementBatch::query()->where('draw_id', $draw->id)->firstOrFail();
createChunkTestTicket($draw, $player, $order, 'SG', $suffix.'-3');
expect(app(SettlementOrchestrator::class)->trySettleDraw($draw->fresh()))->toBeTrue();
$resumedBatch = SettlementBatch::query()->where('draw_id', $draw->id)->firstOrFail();
expect((int) $resumedBatch->id)->toBe((int) $existingBatch->id)
->and(SettlementBatch::query()->where('draw_id', $draw->id)->count())->toBe(1)
->and($resumedBatch->status)->toBe(SettlementBatchStatus::PendingReview->value)
->and((int) $resumedBatch->total_ticket_count)->toBe(3)
->and($resumedBatch->details()->count())->toBe(3)
->and(TicketItem::query()->where('draw_id', $draw->id)->where('status', 'pending_draw')->exists())->toBeFalse();
});
test('approved provider batches payout independently and settle draw after the last provider', function (): void {
config()->set('lottery.settlement.ticket_chunk_size', 1);
$suffix = bin2hex(random_bytes(4));
$draw = createChunkTestDraw($suffix);
$player = createChunkTestPlayer($suffix);
$order = createChunkTestOrder($draw, $player, $suffix, 2);
foreach (['SG', 'MY'] as $providerCode) {
createChunkTestResult($draw, $providerCode);
createChunkTestTicket($draw, $player, $order, $providerCode, $suffix.'-'.$providerCode);
}
expect(app(SettlementOrchestrator::class)->trySettleDraw($draw))->toBeTrue();
$batches = SettlementBatch::query()->where('draw_id', $draw->id)->orderBy('id')->get();
expect($batches)->toHaveCount(2)
->and($batches->every(fn (SettlementBatch $batch): bool => $batch->status === SettlementBatchStatus::PendingReview->value))->toBeTrue();
$admin = AdminUser::query()->create([
'username' => 'chunk_reviewer_'.$suffix,
'name' => 'Chunk Reviewer',
'password' => Hash::make('secret-strong'),
'status' => 0,
]);
$workflow = app(SettlementBatchWorkflowService::class);
foreach ($batches as $batch) {
$workflow->approve($batch, $admin);
}
expect(app(SettlementOrchestrator::class)->trySettleDraw($draw->fresh()))->toBeTrue()
->and(SettlementBatch::query()->where('draw_id', $draw->id)->where('status', SettlementBatchStatus::Approved->value)->count())->toBe(2);
$workflow->payout($batches[0]->fresh());
expect($draw->fresh()->status)->toBe(DrawStatus::Settling->value)
->and(SettlementBatch::query()->whereKey($batches[0]->id)->value('status'))->toBe(SettlementBatchStatus::Paid->value)
->and(TicketItem::query()->where('draw_id', $draw->id)->where('status', 'pending_payout')->count())->toBe(1);
$workflow->payout($batches[1]->fresh());
expect($draw->fresh()->status)->toBe(DrawStatus::Settled->value)
->and(SettlementBatch::query()->where('draw_id', $draw->id)->where('status', SettlementBatchStatus::Paid->value)->count())->toBe(2)
->and(TicketItem::query()->where('draw_id', $draw->id)->where('status', 'settled_win')->count())->toBe(2)
->and($order->fresh()->status)->toBe('settled');
});
test('tick finalizer approves all provider batches before paying them', function (): void {
$suffix = bin2hex(random_bytes(4));
$draw = createChunkTestDraw($suffix);
$player = createChunkTestPlayer($suffix);
$order = createChunkTestOrder($draw, $player, $suffix, 2);
foreach (['SG', 'MY'] as $providerCode) {
createChunkTestResult($draw, $providerCode);
createChunkTestTicket($draw, $player, $order, $providerCode, $suffix.'-'.$providerCode);
}
app(SettlementOrchestrator::class)->trySettleDraw($draw);
$result = app(SettlementTickFinalizer::class)->finalizePendingBatches();
expect($result)->toMatchArray(['approved' => 2, 'paid' => 2, 'payout_failed' => 0])
->and($draw->fresh()->status)->toBe(DrawStatus::Settled->value)
->and(SettlementBatch::query()->where('draw_id', $draw->id)->where('status', SettlementBatchStatus::Paid->value)->count())->toBe(2)
->and(TicketItem::query()->where('draw_id', $draw->id)->where('status', 'settled_win')->count())->toBe(2);
});
test('tick finalizer resumes approved batches left before payout', function (): void {
$suffix = bin2hex(random_bytes(4));
$draw = createChunkTestDraw($suffix);
$player = createChunkTestPlayer($suffix);
$order = createChunkTestOrder($draw, $player, $suffix, 2);
foreach (['SG', 'MY'] as $providerCode) {
createChunkTestResult($draw, $providerCode);
createChunkTestTicket($draw, $player, $order, $providerCode, $suffix.'-'.$providerCode);
}
app(SettlementOrchestrator::class)->trySettleDraw($draw);
$workflow = app(SettlementBatchWorkflowService::class);
foreach (SettlementBatch::query()->where('draw_id', $draw->id)->get() as $batch) {
$workflow->approveBySystem($batch, 'simulate restart after approval');
}
expect(SettlementBatch::query()->where('draw_id', $draw->id)->where('status', SettlementBatchStatus::PendingReview->value)->exists())->toBeFalse()
->and(SettlementBatch::query()->where('draw_id', $draw->id)->where('status', SettlementBatchStatus::Approved->value)->count())->toBe(2);
$result = app(SettlementTickFinalizer::class)->finalizePendingBatches();
expect($result)->toMatchArray(['approved' => 0, 'paid' => 2, 'payout_failed' => 0])
->and($draw->fresh()->status)->toBe(DrawStatus::Settled->value)
->and(SettlementBatch::query()->where('draw_id', $draw->id)->where('status', SettlementBatchStatus::Paid->value)->count())->toBe(2)
->and(TicketItem::query()->where('draw_id', $draw->id)->where('status', 'settled_win')->count())->toBe(2);
});
test('blocked approved draw does not starve a later eligible draw at the scan limit', function (): void {
config()->set('lottery.draw_tick_finalize_limit', 1);
$blockedSuffix = bin2hex(random_bytes(4));
$blockedDraw = createChunkTestDraw($blockedSuffix);
$blockedResult = createChunkTestResult($blockedDraw, 'BLOCKED');
foreach ([SettlementBatchStatus::Approved, SettlementBatchStatus::Running] as $status) {
SettlementBatch::query()->create([
'draw_id' => $blockedDraw->id,
'result_batch_id' => $blockedResult->id,
'settle_version' => 1,
'status' => $status->value,
'total_ticket_count' => 0,
'total_win_count' => 0,
'total_payout_amount' => 0,
'total_jackpot_payout_amount' => 0,
'review_status' => $status === SettlementBatchStatus::Approved ? 'approved' : 'pending',
'reviewed_at' => $status === SettlementBatchStatus::Approved ? now() : null,
'started_at' => now(),
'finished_at' => $status === SettlementBatchStatus::Approved ? now() : null,
]);
}
$eligibleSuffix = bin2hex(random_bytes(4));
$eligibleDraw = createChunkTestDraw($eligibleSuffix);
$player = createChunkTestPlayer($eligibleSuffix);
$order = createChunkTestOrder($eligibleDraw, $player, $eligibleSuffix, 1);
createChunkTestResult($eligibleDraw, 'SG');
createChunkTestTicket($eligibleDraw, $player, $order, 'SG', $eligibleSuffix.'-SG');
app(SettlementOrchestrator::class)->trySettleDraw($eligibleDraw);
$eligibleBatch = SettlementBatch::query()->where('draw_id', $eligibleDraw->id)->firstOrFail();
app(SettlementBatchWorkflowService::class)->approveBySystem($eligibleBatch, 'simulate approved backlog');
$result = app(SettlementTickFinalizer::class)->finalizePendingBatches();
expect($result)->toMatchArray(['approved' => 0, 'paid' => 1, 'payout_failed' => 0])
->and($eligibleBatch->fresh()->status)->toBe(SettlementBatchStatus::Paid->value)
->and($eligibleDraw->fresh()->status)->toBe(DrawStatus::Settled->value)
->and(SettlementBatch::query()->where('draw_id', $blockedDraw->id)->where('status', SettlementBatchStatus::Approved->value)->count())->toBe(1);
});
test('manual approval still allows automatic payout when auto approval is disabled', function (): void {
$approvedSuffix = bin2hex(random_bytes(4));
$approvedDraw = createChunkTestDraw($approvedSuffix);
$approvedPlayer = createChunkTestPlayer($approvedSuffix);
$approvedOrder = createChunkTestOrder($approvedDraw, $approvedPlayer, $approvedSuffix, 1);
createChunkTestResult($approvedDraw, 'SG');
createChunkTestTicket($approvedDraw, $approvedPlayer, $approvedOrder, 'SG', $approvedSuffix.'-SG');
app(SettlementOrchestrator::class)->trySettleDraw($approvedDraw);
$approvedBatch = SettlementBatch::query()->where('draw_id', $approvedDraw->id)->firstOrFail();
app(SettlementBatchWorkflowService::class)->approveBySystem($approvedBatch, 'manual approval simulation');
$pendingSuffix = bin2hex(random_bytes(4));
$pendingDraw = createChunkTestDraw($pendingSuffix);
$pendingPlayer = createChunkTestPlayer($pendingSuffix);
$pendingOrder = createChunkTestOrder($pendingDraw, $pendingPlayer, $pendingSuffix, 1);
createChunkTestResult($pendingDraw, 'MY');
createChunkTestTicket($pendingDraw, $pendingPlayer, $pendingOrder, 'MY', $pendingSuffix.'-MY');
app(SettlementOrchestrator::class)->trySettleDraw($pendingDraw);
$pendingBatch = SettlementBatch::query()->where('draw_id', $pendingDraw->id)->firstOrFail();
LotterySettings::put('settlement.auto_approve_on_tick', false, 'settlement', 'test manual approval mode');
LotterySettings::put('settlement.auto_payout_on_tick', true, 'settlement', 'test automatic payout mode');
$result = app(SettlementTickFinalizer::class)->finalizePendingBatches();
expect($result)->toMatchArray(['approved' => 0, 'paid' => 1, 'payout_failed' => 0])
->and($approvedBatch->fresh()->status)->toBe(SettlementBatchStatus::Paid->value)
->and($approvedDraw->fresh()->status)->toBe(DrawStatus::Settled->value)
->and($pendingBatch->fresh()->status)->toBe(SettlementBatchStatus::PendingReview->value)
->and($pendingDraw->fresh()->status)->toBe(DrawStatus::Settling->value);
});
test('a repeatedly failing approved draw does not starve a later healthy draw', function (): void {
config()->set('lottery.draw_tick_finalize_limit', 1);
$poisonSuffix = bin2hex(random_bytes(4));
$poisonDraw = createChunkTestDraw($poisonSuffix);
$poisonPlayer = createChunkTestPlayer($poisonSuffix);
$poisonOrder = createChunkTestOrder($poisonDraw, $poisonPlayer, $poisonSuffix, 1);
createChunkTestResult($poisonDraw, 'SG');
createChunkTestTicket($poisonDraw, $poisonPlayer, $poisonOrder, 'SG', $poisonSuffix.'-SG');
app(SettlementOrchestrator::class)->trySettleDraw($poisonDraw);
$poisonBatch = SettlementBatch::query()->where('draw_id', $poisonDraw->id)->firstOrFail();
app(SettlementBatchWorkflowService::class)->approveBySystem($poisonBatch, 'simulate poison batch');
createChunkTestTicket($poisonDraw, $poisonPlayer, $poisonOrder, 'SG', $poisonSuffix.'-ORPHAN');
$healthySuffix = bin2hex(random_bytes(4));
$healthyDraw = createChunkTestDraw($healthySuffix);
$healthyPlayer = createChunkTestPlayer($healthySuffix);
$healthyOrder = createChunkTestOrder($healthyDraw, $healthyPlayer, $healthySuffix, 1);
createChunkTestResult($healthyDraw, 'MY');
createChunkTestTicket($healthyDraw, $healthyPlayer, $healthyOrder, 'MY', $healthySuffix.'-MY');
app(SettlementOrchestrator::class)->trySettleDraw($healthyDraw);
$healthyBatch = SettlementBatch::query()->where('draw_id', $healthyDraw->id)->firstOrFail();
app(SettlementBatchWorkflowService::class)->approveBySystem($healthyBatch, 'simulate healthy backlog');
$first = app(SettlementTickFinalizer::class)->finalizePendingBatches();
expect($first)->toMatchArray(['approved' => 0, 'paid' => 0, 'payout_failed' => 1])
->and($poisonBatch->fresh()->status)->toBe(SettlementBatchStatus::Approved->value)
->and((int) $poisonBatch->fresh()->auto_payout_attempts)->toBe(1)
->and($healthyBatch->fresh()->status)->toBe(SettlementBatchStatus::Approved->value);
$second = app(SettlementTickFinalizer::class)->finalizePendingBatches();
expect($second)->toMatchArray(['approved' => 0, 'paid' => 1, 'payout_failed' => 0])
->and($healthyBatch->fresh()->status)->toBe(SettlementBatchStatus::Paid->value)
->and($healthyDraw->fresh()->status)->toBe(DrawStatus::Settled->value)
->and($poisonBatch->fresh()->status)->toBe(SettlementBatchStatus::Approved->value);
});

View File

@@ -1,23 +1,25 @@
<?php <?php
use App\Models\Draw;
use App\Models\Player;
use App\Models\AuditLog;
use App\Models\AdminRole; use App\Models\AdminRole;
use App\Models\AdminUser; use App\Models\AdminUser;
use App\Models\Draw;
use App\Models\DrawResultBatch;
use App\Models\Player;
use App\Models\SettlementBatch;
use App\Models\TicketItem; use App\Models\TicketItem;
use App\Lottery\DrawResultBatchStatus;
use App\Lottery\DrawStatus; use App\Lottery\DrawStatus;
use App\Lottery\SettlementBatchStatus; use App\Models\DrawResultBatch;
use App\Services\AgentSettlement\AgentGameSettlementRecorder; use App\Models\SettlementBatch;
use App\Services\AgentSettlement\GameSettlementReversalService;
use App\Services\Settlement\SettlementBatchWorkflowService;
use App\Services\Settlement\SettlementTickFinalizer;
use App\Support\PlayerFundingMode; use App\Support\PlayerFundingMode;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
use App\Lottery\DrawResultBatchStatus;
use App\Lottery\SettlementBatchStatus;
use App\Services\Player\PlayerCreditService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use App\Services\Settlement\SettlementTickFinalizer;
use App\Services\Settlement\SettlementBatchWorkflowService;
use App\Services\AgentSettlement\AgentGameSettlementRecorder;
use App\Services\AgentSettlement\GameSettlementReversalService;
uses(RefreshDatabase::class); uses(RefreshDatabase::class);
@@ -132,15 +134,63 @@ test('agent recorder allows re-settlement after share ledger reversal', function
$item->setRelation('player', $player); $item->setRelation('player', $player);
$recorder = app(AgentGameSettlementRecorder::class); $recorder = app(AgentGameSettlementRecorder::class);
$recorder->recordForTicketItem($item, 0, 'settled_lose'); DB::table('player_credit_accounts')->where('player_id', $player->id)->update(['used_credit' => 1]);
expect(countActiveShareLedgerRows($item->id))->toBe(1);
app(GameSettlementReversalService::class)->reverseTicketItem($item->fresh()); $recorder->recordForTicketItem($item, 0, 'settled_lose', 1);
expect(countActiveShareLedgerRows($item->id))->toBe(0); expect(countActiveShareLedgerRows($item->id))->toBe(1)
->and((int) DB::table('player_credit_accounts')->where('player_id', $player->id)->value('used_credit'))->toBe(1);
app(GameSettlementReversalService::class)->reverseTicketItem($item->fresh(), 1);
expect(countActiveShareLedgerRows($item->id))->toBe(0)
->and((int) DB::table('player_credit_accounts')->where('player_id', $player->id)->value('used_credit'))->toBe(1);
$item->forceFill(['agent_settled_at' => null])->save(); $item->forceFill(['agent_settled_at' => null])->save();
$recorder->recordForTicketItem($item->fresh(), 0, 'settled_lose'); $recorder->recordForTicketItem($item->fresh(), 0, 'settled_lose', 2);
expect(countActiveShareLedgerRows($item->id))->toBe(1); expect(countActiveShareLedgerRows($item->id))->toBe(1)
->and((int) DB::table('player_credit_accounts')->where('player_id', $player->id)->value('used_credit'))->toBe(1)
->and(DB::table('credit_ledger')
->where('ref_type', 'ticket_item')
->where('ref_id', $item->id)
->where('reason', 'game_settlement_loss')
->count())->toBe(2);
app(GameSettlementReversalService::class)->reverseTicketItem($item->fresh(), 2);
expect(countActiveShareLedgerRows($item->id))->toBe(0)
->and((int) DB::table('player_credit_accounts')->where('player_id', $player->id)->value('used_credit'))->toBe(1)
->and(DB::table('credit_ledger')
->where('ref_type', 'ticket_item')
->where('ref_id', $item->id)
->where('reason', 'game_settlement_reversal')
->count())->toBe(2)
->and(DB::table('credit_ledger')
->where('ref_type', 'ticket_item')
->where('ref_id', $item->id)
->where('reason', 'bet_hold_restore')
->count())->toBe(2);
});
test('credit win re-settlement applies the same direction again for a new version', function (): void {
$player = creditAgentPlayerForFixtures('win-re-settle');
DB::table('player_credit_accounts')->where('player_id', $player->id)->update(['used_credit' => 10]);
$credit = app(PlayerCreditService::class);
$credit->releaseBetHold($player, 100, 99001, 1);
$credit->applySettledWin($player, 300, 99001, 1);
expect((int) DB::table('player_credit_accounts')->where('player_id', $player->id)->value('used_credit'))->toBe(6);
$credit->reverseGameSettlement($player, -300, 99001, 1);
$credit->restoreBetHoldAfterSettlementReversal($player, 100, 99001, 1);
expect((int) DB::table('player_credit_accounts')->where('player_id', $player->id)->value('used_credit'))->toBe(10);
$credit->releaseBetHold($player, 100, 99001, 2);
$credit->applySettledWin($player, 300, 99001, 2);
expect((int) DB::table('player_credit_accounts')->where('player_id', $player->id)->value('used_credit'))->toBe(6)
->and(DB::table('credit_ledger')
->where('ref_type', 'ticket_item')
->where('ref_id', 99001)
->where('reason', 'game_settlement_win')
->count())->toBe(2);
}); });
test('credit player payout skips lottery wallet settle_payout txn', function (): void { test('credit player payout skips lottery wallet settle_payout txn', function (): void {
@@ -279,7 +329,7 @@ test('settlement report show rejects inaccessible settlement period', function (
->assertForbidden(); ->assertForbidden();
}); });
test('settlement tick finalizer marks approved batch failed when payout throws', function (): void { test('settlement tick finalizer keeps approved batch retryable when payout throws', function (): void {
$player = creditAgentPlayerForFixtures('tick-fail'); $player = creditAgentPlayerForFixtures('tick-fail');
$draw = Draw::query()->create([ $draw = Draw::query()->create([
'draw_no' => 'FIX-FAIL-DRAW', 'draw_no' => 'FIX-FAIL-DRAW',
@@ -363,7 +413,7 @@ test('settlement tick finalizer marks approved batch failed when payout throws',
'updated_at' => now(), 'updated_at' => now(),
]); ]);
DB::table('ticket_items')->insert([ $orphanItemId = (int) DB::table('ticket_items')->insertGetId([
'ticket_no' => 'T-FIX-FAIL-ORPHAN', 'ticket_no' => 'T-FIX-FAIL-ORPHAN',
'order_id' => $orderId, 'order_id' => $orderId,
'player_id' => $player->id, 'player_id' => $player->id,
@@ -394,6 +444,28 @@ test('settlement tick finalizer marks approved batch failed when payout throws',
$result = app(SettlementTickFinalizer::class)->finalizePendingBatches(); $result = app(SettlementTickFinalizer::class)->finalizePendingBatches();
expect($result['payout_failed'])->toBe(1) expect($result['payout_failed'])->toBe(1)
->and($batch->fresh()->status)->toBe(SettlementBatchStatus::Failed->value) ->and($batch->fresh()->status)->toBe(SettlementBatchStatus::Approved->value)
->and($batch->fresh()->review_remark)->toContain('auto_payout_failed'); ->and($batch->fresh()->review_remark)->toContain('auto_payout_failed')
}); ->and((int) $batch->fresh()->auto_payout_attempts)->toBe(1)
->and(AuditLog::query()
->where('action_code', 'auto_payout_failed')
->where('target_id', (string) $batch->id)
->count())->toBe(1);
$immediateRetry = app(SettlementTickFinalizer::class)->finalizePendingBatches();
expect($immediateRetry)->toMatchArray(['approved' => 0, 'paid' => 0, 'payout_failed' => 0])
->and((int) $batch->fresh()->auto_payout_attempts)->toBe(1)
->and(AuditLog::query()
->where('action_code', 'auto_payout_failed')
->where('target_id', (string) $batch->id)
->count())->toBe(1);
DB::table('ticket_items')->where('id', $orphanItemId)->delete();
$this->travel((int) config('lottery.auto_payout_retry_base_seconds'))->seconds();
$retry = app(SettlementTickFinalizer::class)->finalizePendingBatches();
expect($retry)->toMatchArray(['approved' => 0, 'paid' => 1, 'payout_failed' => 0])
->and($batch->fresh()->status)->toBe(SettlementBatchStatus::Paid->value)
->and($draw->fresh()->status)->toBe(DrawStatus::Settled->value);
});

View File

@@ -10,14 +10,23 @@ uses(RefreshDatabase::class);
beforeEach(fn () => $this->seed(PlayTypeSeeder::class)); beforeEach(fn () => $this->seed(PlayTypeSeeder::class));
test('every play_types.play_code maps to a non-noop settlement matcher', function (): void { test('every enabled play type maps to a non-noop settlement matcher', function (): void {
$reg = app(SettlementMatcherRegistry::class); $reg = app(SettlementMatcherRegistry::class);
foreach (PlayType::query()->orderBy('play_code')->pluck('play_code') as $code) { foreach (PlayType::query()->where('is_enabled', true)->orderBy('play_code')->pluck('play_code') as $code) {
$matcher = $reg->for((string) $code); $matcher = $reg->for((string) $code);
expect($matcher)->not->toBeInstanceOf(NoopSettlementMatcher::class); expect($matcher)->not->toBeInstanceOf(NoopSettlementMatcher::class);
} }
}); });
test('independent draw plays stay disabled until their settlement matchers exist', function (): void {
$reg = app(SettlementMatcherRegistry::class);
foreach (['five_d', 'six_d'] as $code) {
expect(PlayType::query()->where('play_code', $code)->value('is_enabled'))->toBeFalse()
->and($reg->for($code))->toBeInstanceOf(NoopSettlementMatcher::class);
}
});
test('half_box reuses the same matcher instance as big spread', function (): void { test('half_box reuses the same matcher instance as big spread', function (): void {
$reg = app(SettlementMatcherRegistry::class); $reg = app(SettlementMatcherRegistry::class);
expect($reg->for('half_box'))->toBe($reg->for('big')); expect($reg->for('half_box'))->toBe($reg->for('big'));

View File

@@ -6,34 +6,34 @@
use App\Models\Draw; use App\Models\Draw;
use App\Models\Player; use App\Models\Player;
use App\Models\AdminUser; use App\Models\OddsItem;
use App\Models\RiskPool; use App\Models\RiskPool;
use App\Models\AdminUser;
use App\Models\WalletTxn; use App\Models\WalletTxn;
use App\Lottery\ErrorCode; use App\Lottery\ErrorCode;
use App\Models\TicketItem; use App\Models\TicketItem;
use App\Lottery\DrawStatus; use App\Lottery\DrawStatus;
use App\Models\JackpotPool; use App\Models\JackpotPool;
use App\Models\TicketOrder; use App\Models\TicketOrder;
use App\Models\OddsItem;
use App\Models\PlayerWallet; use App\Models\PlayerWallet;
use App\Models\DrawResultItem; use App\Models\DrawResultItem;
use App\Models\DrawResultBatch; use App\Models\DrawResultBatch;
use App\Models\JackpotPayoutLog;
use App\Models\SettlementBatch; use App\Models\SettlementBatch;
use App\Models\JackpotPayoutLog;
use App\Models\TicketCombination; use App\Models\TicketCombination;
use App\Models\JackpotContribution; use App\Models\JackpotContribution;
use App\Support\OddsStandardScopes; use App\Support\OddsStandardScopes;
use Database\Seeders\CurrencySeeder; use Database\Seeders\CurrencySeeder;
use Database\Seeders\PlayTypeSeeder; use Database\Seeders\PlayTypeSeeder;
use Illuminate\Support\Facades\Hash;
use App\Lottery\DrawResultBatchStatus; use App\Lottery\DrawResultBatchStatus;
use App\Models\TicketSettlementDetail; use App\Models\TicketSettlementDetail;
use App\Services\Settlement\SettlementBatchWorkflowService;
use App\Services\Draw\DrawPrizeLayout; use App\Services\Draw\DrawPrizeLayout;
use Illuminate\Support\Facades\Hash;
use Database\Seeders\LotterySettingsSeeder; use Database\Seeders\LotterySettingsSeeder;
use Database\Seeders\OperationalConfigV1Seeder; use Database\Seeders\OperationalConfigV1Seeder;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use App\Services\Settlement\SettlementOrchestrator; use App\Services\Settlement\SettlementOrchestrator;
use App\Services\Settlement\SettlementBatchWorkflowService;
uses(RefreshDatabase::class); uses(RefreshDatabase::class);
@@ -228,7 +228,7 @@ test('§14.5 small hits second tier only', function (): void {
$item = TicketItem::query()->where('draw_id', $draw->id)->firstOrFail(); $item = TicketItem::query()->where('draw_id', $draw->id)->firstOrFail();
$deduct = (int) $item->actual_deduct_amount; $deduct = (int) $item->actual_deduct_amount;
$expectedWin = (int) floor(10_000 * OddsStandardScopes::PRESET_ODDS_BY_SCOPE['second'] / 10_000); $expectedWin = (int) floor(10_000 * OddsStandardScopes::presetOddsForPlay('small')['second'] / 10_000);
p145_publish_board($draw, function (string $t, int $i): string { p145_publish_board($draw, function (string $t, int $i): string {
return match ($t) { return match ($t) {
@@ -321,7 +321,7 @@ test('§14.5 pos_4b pos_3a pos_2a pos_4e each settle with expected win', functio
$item = TicketItem::query()->where('draw_id', $draw->id)->firstOrFail(); $item = TicketItem::query()->where('draw_id', $draw->id)->firstOrFail();
$deduct = (int) $item->actual_deduct_amount; $deduct = (int) $item->actual_deduct_amount;
$odds = OddsStandardScopes::PRESET_ODDS_BY_SCOPE[$case['scope']]; $odds = OddsStandardScopes::presetOddsForPlay($case['play'])[$case['scope']];
$perComboWin = (int) floor(10_000 * $odds / 10_000); $perComboWin = (int) floor(10_000 * $odds / 10_000);
$expectedWin = $perComboWin; $expectedWin = $perComboWin;
@@ -357,26 +357,6 @@ test('module 6 suffix plays settle once per ticket item instead of once per expa
'board' => fn (string $t, int $i): string => $t === 'first' ? '1234' : p145_board_without_8888($t, $i), 'board' => fn (string $t, int $i): string => $t === 'first' ? '1234' : p145_board_without_8888($t, $i),
'scope' => 'first', 'scope' => 'first',
], ],
[
'play' => 'pos_3abc',
'number' => '567',
'board' => fn (string $t, int $i): string => match ($t) {
'first' => '4567',
default => p145_board_without_8888($t, $i),
},
'scope' => 'first',
],
[
'play' => 'pos_2abc',
'number' => '99',
'board' => fn (string $t, int $i): string => match ($t) {
'first' => '8899',
'second' => '2299',
'third' => '1199',
default => p145_board_without_8888($t, $i),
},
'scope' => 'first',
],
]; ];
foreach ($cases as $case) { foreach ($cases as $case) {
@@ -397,7 +377,7 @@ test('module 6 suffix plays settle once per ticket item instead of once per expa
$item = TicketItem::query()->where('draw_id', $draw->id)->firstOrFail(); $item = TicketItem::query()->where('draw_id', $draw->id)->firstOrFail();
expect((int) $item->combination_count)->toBeIn([10, 100]); expect((int) $item->combination_count)->toBeIn([10, 100]);
$expectedWin = (int) floor(10_000 * OddsStandardScopes::PRESET_ODDS_BY_SCOPE[$case['scope']] / 10_000); $expectedWin = (int) floor(10_000 * OddsStandardScopes::presetOddsForPlay($case['play'])[$case['scope']] / 10_000);
p145_publish_board($draw, $case['board']); p145_publish_board($draw, $case['board']);
$draw->forceFill([ $draw->forceFill([
@@ -414,77 +394,28 @@ test('module 6 suffix plays settle once per ticket item instead of once per expa
} }
}); });
test('module 6 abc suffix plays pick best tier when multiple prize tiers share the same suffix', function (): void { test('legacy abc suffix plays stay closed when absent from the active catalog', function (): void {
$cases = [ $cases = [
[ ['play' => 'pos_3abc', 'number' => '234'],
'play' => 'pos_3abc', ['play' => 'pos_2abc', 'number' => '99'],
'number' => '234',
'board' => fn (string $t, int $i): string => match ($t) {
'first' => '1234',
'second' => '5234',
'third' => '9234',
default => p145_board_without_8888($t, $i),
},
'expected_tier' => 'first',
],
[
'play' => 'pos_3abc',
'number' => '234',
'board' => fn (string $t, int $i): string => match ($t) {
'first' => '1567',
'second' => '5234',
'third' => '8234',
default => p145_board_without_8888($t, $i),
},
'expected_tier' => 'second',
],
[
'play' => 'pos_2abc',
'number' => '99',
'board' => fn (string $t, int $i): string => match ($t) {
'first' => '8899',
'second' => '2299',
'third' => '1199',
default => p145_board_without_8888($t, $i),
},
'expected_tier' => 'first',
],
]; ];
foreach ($cases as $case) { $player = p145_player(80_000_000);
$player = p145_player(80_000_000); $drawNo = p145_next_draw_no();
$drawNo = p145_next_draw_no(); p145_draw($drawNo, random_int(1, 99_999));
$draw = p145_draw($drawNo, random_int(1, 99_999));
foreach ($cases as $case) {
$this->withHeader('Authorization', 'Bearer dev:'.$player->id) $this->withHeader('Authorization', 'Bearer dev:'.$player->id)
->postJson('/api/v1/ticket/place', [ ->postJson('/api/v1/ticket/place', [
'draw_id' => $drawNo, 'draw_id' => $drawNo,
'currency_code' => 'NPR', 'currency_code' => 'NPR',
'client_trace_id' => 'module6-multi-tier-'.$case['play'].'-'.$case['expected_tier'].'-'.uniqid('', true), 'client_trace_id' => 'module6-legacy-closed-'.$case['play'].'-'.uniqid('', true),
'lines' => [ 'lines' => [
['number' => $case['number'], 'play_code' => $case['play'], 'amount' => 10_000], ['number' => $case['number'], 'play_code' => $case['play'], 'amount' => 10_000],
], ],
]) ])
->assertOk(); ->assertStatus(400)
->assertJsonPath('code', ErrorCode::PlayModeClosed->value);
$item = TicketItem::query()->where('draw_id', $draw->id)->firstOrFail();
$expectedWin = (int) floor(10_000 * OddsStandardScopes::PRESET_ODDS_BY_SCOPE[$case['expected_tier']] / 10_000);
p145_publish_board($draw, $case['board']);
$draw->forceFill([
'status' => DrawStatus::Settling->value,
'current_result_version' => 1,
])->save();
expect(app(SettlementOrchestrator::class)->trySettleDraw($draw->fresh()), $case['play'])->toBeTrue();
p145_approve_and_payout($draw);
$item->refresh();
$detail = TicketSettlementDetail::query()->where('ticket_item_id', $item->id)->firstOrFail();
expect($item->status)->toBe('settled_win', $case['play'])
->and((int) $item->win_amount)->toBe($expectedWin, $case['play'])
->and($detail->matched_prize_tier)->toBe($case['expected_tier'], $case['play']);
} }
}); });
@@ -509,8 +440,8 @@ test('module 6 ibox sums payout across combinations hitting different prize tier
expect($deduct)->toBe(600) expect($deduct)->toBe(600)
->and((int) $item->combination_count)->toBe(6); ->and((int) $item->combination_count)->toBe(6);
$unitWinFirst = (int) floor(100 * OddsStandardScopes::PRESET_ODDS_BY_SCOPE['first'] / 10_000); $unitWinFirst = (int) floor(100 * OddsStandardScopes::presetOddsForPlay('ibox')['first'] / 10_000);
$unitWinStarter = (int) floor(100 * OddsStandardScopes::PRESET_ODDS_BY_SCOPE['starter'] / 10_000); $unitWinStarter = (int) floor(100 * OddsStandardScopes::presetOddsForPlay('ibox')['starter'] / 10_000);
$expectedWin = $unitWinFirst + $unitWinStarter; $expectedWin = $unitWinFirst + $unitWinStarter;
p145_publish_board($draw, function (string $t, int $i): string { p145_publish_board($draw, function (string $t, int $i): string {
@@ -574,7 +505,7 @@ test('module 6 mbox remainder deducts floored total and settles win on per-combi
->and((int) $item->actual_deduct_amount)->toBe($expectedDeduct) ->and((int) $item->actual_deduct_amount)->toBe($expectedDeduct)
->and($ruleSnapshot['rounding_refund_amount'] ?? null)->toBe($expectedRemainder); ->and($ruleSnapshot['rounding_refund_amount'] ?? null)->toBe($expectedRemainder);
$expectedWin = (int) floor($unitBet * OddsStandardScopes::PRESET_ODDS_BY_SCOPE['first'] / 10_000); $expectedWin = (int) floor($unitBet * OddsStandardScopes::presetOddsForPlay('mbox')['first'] / 10_000);
p145_publish_board($draw, fn (string $t, int $i): string => $t === 'first' ? '1234' : p145_board_without_8888($t, $i)); p145_publish_board($draw, fn (string $t, int $i): string => $t === 'first' ? '1234' : p145_board_without_8888($t, $i));
$draw->forceFill([ $draw->forceFill([
@@ -699,9 +630,9 @@ test('§14.5 placement partial failure only deducts successful lines when mid-or
RiskPool::query()->create([ RiskPool::query()->create([
'draw_id' => $draw->id, 'draw_id' => $draw->id,
'normalized_number' => '1234', 'normalized_number' => '1234',
'total_cap_amount' => 5000, 'total_cap_amount' => 500_000,
'locked_amount' => 0, 'locked_amount' => 0,
'remaining_amount' => 5000, 'remaining_amount' => 500_000,
'sold_out_status' => 0, 'sold_out_status' => 0,
'version' => 0, 'version' => 0,
]); ]);
@@ -732,8 +663,8 @@ test('§14.5 placement partial failure only deducts successful lines when mid-or
->where('draw_id', $draw->id) ->where('draw_id', $draw->id)
->where('normalized_number', '1234') ->where('normalized_number', '1234')
->firstOrFail(); ->firstOrFail();
expect((int) $pool->remaining_amount)->toBe(2000); expect((int) $pool->remaining_amount)->toBe(200_000);
expect((int) $pool->locked_amount)->toBe(3000); expect((int) $pool->locked_amount)->toBe(300_000);
}); });
test('§14.5 settlement uses odds snapshot even if odds config changes after placement', function (): void { test('§14.5 settlement uses odds snapshot even if odds config changes after placement', function (): void {
@@ -977,18 +908,6 @@ test('§14.5 straight roll box ibox mbox head tail odd even digit pos variants s
'scope' => 'third', 'scope' => 'third',
'comboMultiplier' => 1, 'comboMultiplier' => 1,
], ],
[
'play' => 'pos_3abc',
'line' => ['number' => '567', 'play_code' => 'pos_3abc', 'amount' => 10_000],
'board' => fn (string $t, int $i): string => match ($t) {
'first' => '4567',
'second' => '8123',
'third' => '9234',
default => p145_board_without_8888($t, $i),
},
'scope' => 'first',
'comboMultiplier' => 1,
],
[ [
'play' => 'pos_2b', 'play' => 'pos_2b',
'line' => ['number' => '56', 'play_code' => 'pos_2b', 'amount' => 10_000], 'line' => ['number' => '56', 'play_code' => 'pos_2b', 'amount' => 10_000],
@@ -1012,18 +931,6 @@ test('§14.5 straight roll box ibox mbox head tail odd even digit pos variants s
'scope' => 'third', 'scope' => 'third',
'comboMultiplier' => 1, 'comboMultiplier' => 1,
], ],
[
'play' => 'pos_2abc',
'line' => ['number' => '99', 'play_code' => 'pos_2abc', 'amount' => 100],
'board' => fn (string $t, int $i): string => match ($t) {
'first' => '8899',
'second' => '2299',
'third' => '1199',
default => p145_board_without_8888($t, $i),
},
'scope' => 'first',
'comboMultiplier' => 1,
],
]; ];
foreach ($cases as $case) { foreach ($cases as $case) {
@@ -1042,7 +949,7 @@ test('§14.5 straight roll box ibox mbox head tail odd even digit pos variants s
$item = TicketItem::query()->where('draw_id', $draw->id)->firstOrFail(); $item = TicketItem::query()->where('draw_id', $draw->id)->firstOrFail();
$deduct = (int) $item->actual_deduct_amount; $deduct = (int) $item->actual_deduct_amount;
$odds = OddsStandardScopes::PRESET_ODDS_BY_SCOPE[$case['scope']]; $odds = OddsStandardScopes::presetOddsForPlay($case['play'])[$case['scope']];
$unitOnTicket = (int) $item->unit_bet_amount; $unitOnTicket = (int) $item->unit_bet_amount;
$perComboWin = (int) floor($unitOnTicket * $odds / 10_000); $perComboWin = (int) floor($unitOnTicket * $odds / 10_000);
$expectedWin = $perComboWin * (int) $case['comboMultiplier']; $expectedWin = $perComboWin * (int) $case['comboMultiplier'];

View File

@@ -7,15 +7,15 @@ use App\Models\WalletTxn;
use App\Lottery\ErrorCode; use App\Lottery\ErrorCode;
use App\Models\TicketItem; use App\Models\TicketItem;
use App\Lottery\DrawStatus; use App\Lottery\DrawStatus;
use App\Models\JackpotPool;
use App\Models\OddsVersion; use App\Models\OddsVersion;
use App\Models\TicketOrder; use App\Models\TicketOrder;
use App\Models\JackpotPool;
use App\Models\PlayerWallet; use App\Models\PlayerWallet;
use App\Models\TicketCombination;
use App\Models\PlayConfigItem; use App\Models\PlayConfigItem;
use App\Models\PlayConfigVersion; use App\Models\PlayConfigVersion;
use App\Lottery\ConfigVersionStatus; use App\Models\TicketCombination;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use App\Lottery\ConfigVersionStatus;
use Database\Seeders\CurrencySeeder; use Database\Seeders\CurrencySeeder;
use Database\Seeders\PlayTypeSeeder; use Database\Seeders\PlayTypeSeeder;
use Database\Seeders\LotterySettingsSeeder; use Database\Seeders\LotterySettingsSeeder;
@@ -574,9 +574,9 @@ test('ticket place can return mixed success and failed risk results', function (
RiskPool::query()->create([ RiskPool::query()->create([
'draw_id' => $draw->id, 'draw_id' => $draw->id,
'normalized_number' => '1234', 'normalized_number' => '1234',
'total_cap_amount' => 5000, 'total_cap_amount' => 500_000,
'locked_amount' => 0, 'locked_amount' => 0,
'remaining_amount' => 5000, 'remaining_amount' => 500_000,
'sold_out_status' => 0, 'sold_out_status' => 0,
'version' => 0, 'version' => 0,
]); ]);
@@ -709,9 +709,9 @@ test('ticket preview reports high risk warning without deducting wallet or creat
RiskPool::query()->create([ RiskPool::query()->create([
'draw_id' => $draw->id, 'draw_id' => $draw->id,
'normalized_number' => '1234', 'normalized_number' => '1234',
'total_cap_amount' => 4000, 'total_cap_amount' => 500_000,
'locked_amount' => 0, 'locked_amount' => 0,
'remaining_amount' => 4000, 'remaining_amount' => 500_000,
'sold_out_status' => 0, 'sold_out_status' => 0,
'version' => 0, 'version' => 0,
]); ]);
@@ -734,7 +734,7 @@ test('ticket preview reports high risk warning without deducting wallet or creat
expect((int) $wallet->balance)->toBe(200_000) expect((int) $wallet->balance)->toBe(200_000)
->and((int) $pool->locked_amount)->toBe(0) ->and((int) $pool->locked_amount)->toBe(0)
->and((int) $pool->remaining_amount)->toBe(4000) ->and((int) $pool->remaining_amount)->toBe(500_000)
->and(TicketOrder::query()->count())->toBe(0) ->and(TicketOrder::query()->count())->toBe(0)
->and(WalletTxn::query()->where('biz_type', 'bet_deduct')->count())->toBe(0); ->and(WalletTxn::query()->where('biz_type', 'bet_deduct')->count())->toBe(0);
}); });
@@ -818,7 +818,7 @@ test('ticket preview rejects invalid line amount per validation rules', function
/** /**
* §10.1.2 混合成功失败:同一订单两行共享号码时,首条占用成功,第二条额度不足则该注项失败。 * §10.1.2 混合成功失败:同一订单两行共享号码时,首条占用成功,第二条额度不足则该注项失败。
* big + amount 120 estimated_payout 3000maxOdds 250000 / 10000 = 25)。 * big + amount 120 estimated_payout 300000maxOdds 25000000 / 10000 = 2500)。
*/ */
test('ticket place records partial failed when mid-order acquire fails', function (): void { test('ticket place records partial failed when mid-order acquire fails', function (): void {
$player = ticketPlayerWithWallet(500_000); $player = ticketPlayerWithWallet(500_000);
@@ -827,9 +827,9 @@ test('ticket place records partial failed when mid-order acquire fails', functio
RiskPool::query()->create([ RiskPool::query()->create([
'draw_id' => $draw->id, 'draw_id' => $draw->id,
'normalized_number' => '1234', 'normalized_number' => '1234',
'total_cap_amount' => 5000, 'total_cap_amount' => 500_000,
'locked_amount' => 0, 'locked_amount' => 0,
'remaining_amount' => 5000, 'remaining_amount' => 500_000,
'sold_out_status' => 0, 'sold_out_status' => 0,
'version' => 0, 'version' => 0,
]); ]);
@@ -862,8 +862,8 @@ test('ticket place records partial failed when mid-order acquire fails', functio
->where('draw_id', $draw->id) ->where('draw_id', $draw->id)
->where('normalized_number', '1234') ->where('normalized_number', '1234')
->firstOrFail(); ->firstOrFail();
expect((int) $pool->remaining_amount)->toBe(2000); expect((int) $pool->remaining_amount)->toBe(200_000);
expect((int) $pool->locked_amount)->toBe(3000); expect((int) $pool->locked_amount)->toBe(300_000);
}); });
/** §13.5 并发下注(顺序挤出):先成功者占用额度,后一盘同一号码收到售罄。 */ /** §13.5 并发下注(顺序挤出):先成功者占用额度,后一盘同一号码收到售罄。 */
@@ -875,9 +875,9 @@ test('ticket place sold out for second player after first consumes shared pool',
RiskPool::query()->create([ RiskPool::query()->create([
'draw_id' => $draw->id, 'draw_id' => $draw->id,
'normalized_number' => '1234', 'normalized_number' => '1234',
'total_cap_amount' => 5000, 'total_cap_amount' => 500_000,
'locked_amount' => 0, 'locked_amount' => 0,
'remaining_amount' => 5000, 'remaining_amount' => 500_000,
'sold_out_status' => 0, 'sold_out_status' => 0,
'version' => 0, 'version' => 0,
]); ]);
@@ -907,7 +907,7 @@ test('ticket place sold out for second player after first consumes shared pool',
->where('draw_id', $draw->id) ->where('draw_id', $draw->id)
->where('normalized_number', '1234') ->where('normalized_number', '1234')
->firstOrFail(); ->firstOrFail();
expect((int) $pool->remaining_amount)->toBe(2000); expect((int) $pool->remaining_amount)->toBe(200_000);
}); });
test('ticket preview and place apply base rebate plus player add-on rebate for wallet player', function (): void { test('ticket preview and place apply base rebate plus player add-on rebate for wallet player', function (): void {
@@ -1446,3 +1446,126 @@ test('ticket pending confirmation reconcile refunds when draw no longer accepts
->and(WalletTxn::query()->where('biz_type', 'bet_reverse')->where('biz_no', 'TO-PENDING-CLOSED')->count())->toBe(1) ->and(WalletTxn::query()->where('biz_type', 'bet_reverse')->where('biz_no', 'TO-PENDING-CLOSED')->count())->toBe(1)
->and((int) RiskPool::query()->where('draw_id', $draw->id)->where('normalized_number', '1234')->value('locked_amount'))->toBe(0); ->and((int) RiskPool::query()->where('draw_id', $draw->id)->where('normalized_number', '1234')->value('locked_amount'))->toBe(0);
}); });
test('ticket pending confirmation reconcile releases credit hold when draw no longer accepts bets', function (): void {
$draw = ticketOpenDraw('20260511-credit-stale');
$draw->forceFill([
'status' => DrawStatus::Closed->value,
'close_time' => now()->subMinute(),
'draw_time' => now()->subMinute(),
])->save();
$site = DB::table('admin_sites')->where('is_default', true)->first();
$player = Player::query()->create([
'site_code' => (string) $site->code,
'agent_node_id' => (int) DB::table('agent_nodes')->where('depth', 0)->value('id'),
'site_player_id' => 'native:stale-credit-hold',
'auth_source' => 'lottery_native',
'funding_mode' => 'credit',
'username' => 'stale_credit_hold',
'nickname' => null,
'default_currency' => 'NPR',
'status' => 0,
]);
DB::table('player_credit_accounts')->insert([
'player_id' => $player->id,
'credit_limit' => 1000,
'used_credit' => 1,
'frozen_credit' => 0,
'created_at' => now(),
'updated_at' => now(),
]);
$order = TicketOrder::query()->create([
'order_no' => 'TO-PENDING-CREDIT-CLOSED',
'player_id' => $player->id,
'draw_id' => $draw->id,
'currency_code' => 'NPR',
'total_bet_amount' => 100,
'total_rebate_amount' => 0,
'total_actual_deduct' => 100,
'total_estimated_payout' => 3000,
'status' => 'pending_confirm',
'submit_source' => 'h5',
'client_trace_id' => 'pending-credit-on-closed-draw',
'created_at' => now()->subMinutes(20),
'updated_at' => now()->subMinutes(20),
]);
TicketOrder::query()->whereKey($order->id)->update(['updated_at' => now()->subMinutes(20)]);
$item = TicketItem::query()->create([
'ticket_no' => 'TK-PENDING-CREDIT-CLOSED',
'order_id' => $order->id,
'player_id' => $player->id,
'draw_id' => $draw->id,
'original_number' => '1234',
'normalized_number' => '1234',
'play_code' => 'big',
'dimension' => 4,
'digit_slot' => null,
'bet_mode' => 'straight',
'unit_bet_amount' => 100,
'total_bet_amount' => 100,
'rebate_rate_snapshot' => 0,
'commission_rate_snapshot' => 0,
'actual_deduct_amount' => 100,
'odds_snapshot_json' => [],
'rule_snapshot_json' => [],
'combination_count' => 1,
'estimated_max_payout' => 3000,
'risk_locked_amount' => 3000,
'status' => 'pending_confirm',
'fail_reason_code' => null,
'fail_reason_text' => null,
'win_amount' => 0,
'jackpot_win_amount' => 0,
'settled_at' => null,
'created_at' => now()->subMinutes(20),
'updated_at' => now()->subMinutes(20),
]);
TicketCombination::query()->create([
'ticket_item_id' => $item->id,
'combination_no' => 1,
'number_4d' => '1234',
'bet_amount' => 100,
'estimated_payout' => 3000,
'created_at' => now()->subMinutes(20),
]);
RiskPool::query()->create([
'draw_id' => $draw->id,
'normalized_number' => '1234',
'total_cap_amount' => 5000,
'locked_amount' => 3000,
'remaining_amount' => 2000,
'sold_out_status' => 0,
'version' => 1,
]);
$this->artisan('lottery:ticket-pending-confirm-reconcile --stale-minutes=15 --limit=100')
->expectsOutputToContain('refunded: 1')
->assertExitCode(0);
expect($order->fresh()->status)->toBe('refunded')
->and($item->fresh()->status)->toBe('refunded')
->and((int) DB::table('player_credit_accounts')->where('player_id', $player->id)->value('used_credit'))->toBe(0)
->and(DB::table('credit_ledger')
->where('owner_id', $player->id)
->where('reason', 'bet_hold_release')
->where('ref_type', 'ticket_order')
->where('ref_id', $order->id)
->count())->toBe(1)
->and((int) RiskPool::query()->where('draw_id', $draw->id)->where('normalized_number', '1234')->value('locked_amount'))->toBe(0);
$this->artisan('lottery:ticket-pending-confirm-reconcile --stale-minutes=15 --limit=100')
->assertExitCode(0);
expect(DB::table('credit_ledger')
->where('owner_id', $player->id)
->where('reason', 'bet_hold_release')
->where('ref_type', 'ticket_order')
->where('ref_id', $order->id)
->count())->toBe(1);
});

View File

@@ -2,24 +2,26 @@
use App\Models\Draw; use App\Models\Draw;
use App\Models\Player; use App\Models\Player;
use App\Models\AdminUser;
use App\Models\TicketItem;
use App\Lottery\DrawStatus; use App\Lottery\DrawStatus;
use App\Models\JackpotPool; use App\Models\JackpotPool;
use App\Models\TicketOrder;
use App\Models\PlayerWallet; use App\Models\PlayerWallet;
use App\Models\DrawResultItem; use App\Models\DrawResultItem;
use App\Models\DrawResultBatch; use App\Models\DrawResultBatch;
use App\Models\TicketOrder;
use App\Models\AdminUser;
use App\Models\SettlementBatch; use App\Models\SettlementBatch;
use App\Services\Draw\DrawPrizeLayout; use App\Models\TicketCombination;
use App\Services\Settlement\SettlementOrchestrator;
use App\Services\Settlement\SettlementBatchWorkflowService;
use Database\Seeders\CurrencySeeder; use Database\Seeders\CurrencySeeder;
use Database\Seeders\PlayTypeSeeder; use Database\Seeders\PlayTypeSeeder;
use App\Lottery\DrawResultBatchStatus;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
use App\Lottery\DrawResultBatchStatus;
use App\Services\Draw\DrawPrizeLayout;
use Database\Seeders\LotterySettingsSeeder; use Database\Seeders\LotterySettingsSeeder;
use Database\Seeders\OperationalConfigV1Seeder; use Database\Seeders\OperationalConfigV1Seeder;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use App\Services\Settlement\SettlementOrchestrator;
use App\Services\Settlement\SettlementBatchWorkflowService;
uses(RefreshDatabase::class); uses(RefreshDatabase::class);
@@ -171,6 +173,12 @@ test('ticket items index returns placed ticket for player', function (): void {
->assertJsonPath('data.ticket_no', $ticketNo) ->assertJsonPath('data.ticket_no', $ticketNo)
->assertJsonPath('data.combinations.0.number_4d', '1234'); ->assertJsonPath('data.combinations.0.number_4d', '1234');
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
->getJson('/api/v1/ticket/items?ticket_no='.urlencode($ticketNo))
->assertOk()
->assertJsonPath('data.total', 1)
->assertJsonPath('data.items.0.ticket_no', $ticketNo);
$this->withHeader('Authorization', 'Bearer dev:'.$player->id) $this->withHeader('Authorization', 'Bearer dev:'.$player->id)
->getJson('/api/v1/ticket/items?draw_no='.urlencode('20260511-777')) ->getJson('/api/v1/ticket/items?draw_no='.urlencode('20260511-777'))
->assertOk() ->assertOk()
@@ -313,7 +321,7 @@ test('ticket item show returns match result and timeline', function (): void {
ticketItemsPublishAndSettle($draw, '1234'); ticketItemsPublishAndSettle($draw, '1234');
$ticketNo = \App\Models\TicketItem::query()->where('draw_id', $draw->id)->value('ticket_no'); $ticketNo = TicketItem::query()->where('draw_id', $draw->id)->value('ticket_no');
$this->withHeader('Authorization', 'Bearer dev:'.$player->id) $this->withHeader('Authorization', 'Bearer dev:'.$player->id)
->getJson('/api/v1/ticket/items/'.$ticketNo) ->getJson('/api/v1/ticket/items/'.$ticketNo)
@@ -348,9 +356,9 @@ test('my-match returns hit numbers when draw settled with winning ticket', funct
]); ]);
$draw = Draw::query()->create([ $draw = Draw::query()->create([
'draw_no' => '20260511-778', 'draw_no' => '20260511-1778',
'business_date' => '2026-05-11', 'business_date' => '2026-05-11',
'sequence_no' => 778, 'sequence_no' => 1778,
'status' => DrawStatus::Open->value, 'status' => DrawStatus::Open->value,
'start_time' => now()->subMinutes(2), 'start_time' => now()->subMinutes(2),
'close_time' => now()->addMinutes(5), 'close_time' => now()->addMinutes(5),
@@ -364,7 +372,7 @@ test('my-match returns hit numbers when draw settled with winning ticket', funct
$this->withHeader('Authorization', 'Bearer dev:'.$player->id) $this->withHeader('Authorization', 'Bearer dev:'.$player->id)
->postJson('/api/v1/ticket/place', [ ->postJson('/api/v1/ticket/place', [
'draw_id' => '20260511-778', 'draw_id' => '20260511-1778',
'currency_code' => 'NPR', 'currency_code' => 'NPR',
'client_trace_id' => 'match-trace-1', 'client_trace_id' => 'match-trace-1',
'lines' => [ 'lines' => [
@@ -376,7 +384,7 @@ test('my-match returns hit numbers when draw settled with winning ticket', funct
ticketItemsPublishAndSettle($draw, '1234'); ticketItemsPublishAndSettle($draw, '1234');
$this->withHeader('Authorization', 'Bearer dev:'.$player->id) $this->withHeader('Authorization', 'Bearer dev:'.$player->id)
->getJson('/api/v1/ticket/draws/20260511-778/my-match') ->getJson('/api/v1/ticket/draws/20260511-1778/my-match')
->assertOk() ->assertOk()
->assertJsonPath('data.has_bets', true) ->assertJsonPath('data.has_bets', true)
->assertJsonPath('data.winning_ticket_count', 1) ->assertJsonPath('data.winning_ticket_count', 1)
@@ -441,7 +449,7 @@ test('my-match only highlights settled winning tickets', function (): void {
'client_trace_id' => 'pending-match', 'client_trace_id' => 'pending-match',
]); ]);
$item = \App\Models\TicketItem::query()->create([ $item = TicketItem::query()->create([
'ticket_no' => 'TKPENDINGMATCH', 'ticket_no' => 'TKPENDINGMATCH',
'order_id' => $order->id, 'order_id' => $order->id,
'player_id' => $player->id, 'player_id' => $player->id,
@@ -467,7 +475,7 @@ test('my-match only highlights settled winning tickets', function (): void {
'jackpot_win_amount' => 0, 'jackpot_win_amount' => 0,
]); ]);
\App\Models\TicketCombination::query()->create([ TicketCombination::query()->create([
'ticket_item_id' => $item->id, 'ticket_item_id' => $item->id,
'combination_no' => 0, 'combination_no' => 0,
'number_4d' => '1234', 'number_4d' => '1234',

View File

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

View File

@@ -18,6 +18,7 @@ use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class); uses(RefreshDatabase::class);
beforeEach(function (): void { beforeEach(function (): void {
fakeWalletApiDns();
config(['lottery.main_site.wallet_api_url' => null]); config(['lottery.main_site.wallet_api_url' => null]);
$this->seed(CurrencySeeder::class); $this->seed(CurrencySeeder::class);
$this->seed(LotterySettingsSeeder::class); $this->seed(LotterySettingsSeeder::class);
@@ -46,14 +47,14 @@ test('wallet transfer-out with empty bearer token returns 8001', function (): vo
test('wallet transfer-in rejects expired jwt when dev bypass is off', function (): void { test('wallet transfer-in rejects expired jwt when dev bypass is off', function (): void {
config(['lottery.player_auth.dev_bypass' => false]); config(['lottery.player_auth.dev_bypass' => false]);
config(['lottery.main_site.sso_jwt_secret' => 'unit-test-jwt-secret-for-expiry']); config(['lottery.main_site.sso_jwt_secret' => 'unit-test-jwt-secret-for-expiry-32-bytes']);
$token = JWT::encode([ $token = JWT::encode([
'site_code' => 'main', 'site_code' => 'main',
'site_player_id' => 'expired-jwt-user', 'site_player_id' => 'expired-jwt-user',
'iat' => now()->subHours(2)->timestamp, 'iat' => now()->subHours(2)->timestamp,
'exp' => now()->subMinute()->timestamp, 'exp' => now()->subMinute()->timestamp,
], 'unit-test-jwt-secret-for-expiry', 'HS256'); ], 'unit-test-jwt-secret-for-expiry-32-bytes', 'HS256');
$this->withHeader('Authorization', 'Bearer '.$token) $this->withHeader('Authorization', 'Bearer '.$token)
->postJson('/api/v1/wallet/transfer-in', [ ->postJson('/api/v1/wallet/transfer-in', [
@@ -139,6 +140,38 @@ test('transfer in main site explicit failure returns 1009 and marks order failed
->and(WalletTxn::query()->where('player_id', $player->id)->count())->toBe(0); ->and(WalletTxn::query()->where('player_id', $player->id)->count())->toBe(0);
}); });
test('transfer in rejects private dns answer before sending wallet bearer request', function (): void {
fakeWalletApiDns([
'private-debit.test' => ['10.0.0.8'],
], []);
Http::preventStrayRequests();
config(['lottery.main_site.wallet_api_url' => 'https://private-debit.test']);
config(['lottery.main_site.wallet_debit_path' => 'debit']);
$player = Player::query()->create([
'site_code' => 'main',
'site_player_id' => 'private-dns-in',
'username' => null,
'nickname' => null,
'default_currency' => 'NPR',
'status' => 0,
]);
$key = 'private-dns-in-'.uniqid('', true);
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
->postJson('/api/v1/wallet/transfer-in', [
'amount' => 500,
'currency' => 'NPR',
'idempotent_key' => $key,
])
->assertStatus(400)
->assertJsonPath('code', ErrorCode::WalletExternalRejected->value);
expect(TransferOrder::query()->where('idempotent_key', $key)->value('status'))->toBe('failed');
Http::assertSentCount(0);
});
test('transfer in main site timeout returns 1002 and pending_reconcile', function (): void { test('transfer in main site timeout returns 1002 and pending_reconcile', function (): void {
Http::fake([ Http::fake([
'timeout-debit.test/*' => Http::response([], 504), 'timeout-debit.test/*' => Http::response([], 504),

View File

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

View File

@@ -2,9 +2,25 @@
use Tests\TestCase; use Tests\TestCase;
use App\Models\AdminUser; use App\Models\AdminUser;
use App\Models\AgentProfile;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use App\Support\SuperAdminAccount;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use App\Events\OddsUpdateBroadcast;
use App\Events\PlayToggleBroadcast;
use App\Events\RiskSoldOutBroadcast;
use App\Events\RiskWarningBroadcast;
use App\Support\PlatformSystemRoles;
use App\Events\JackpotBurstBroadcast;
use App\Events\BalanceUpdateBroadcast;
use App\Events\DrawCountdownBroadcast;
use Illuminate\Support\Facades\Schema; use Illuminate\Support\Facades\Schema;
use App\Contracts\WalletApiDnsResolver;
use App\Events\DrawStatusChangeBroadcast;
use App\Events\PlayCatalogUpdatedBroadcast;
use App\Events\DrawResultPublishedBroadcast;
use App\Events\PlayerSessionReplacedBroadcast;
use App\Support\Integration\WalletApiRequestGuard;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
/* /*
@@ -53,8 +69,8 @@ expect()->extend('toBeOne', function () {
/** 为后台测试账号挂上唯一超级管理员(不绑定站点)。 */ /** 为后台测试账号挂上唯一超级管理员(不绑定站点)。 */
function grantSuperAdminRole(AdminUser $admin): void function grantSuperAdminRole(AdminUser $admin): void
{ {
\App\Support\PlatformSystemRoles::ensureSuperAdminRole(); PlatformSystemRoles::ensureSuperAdminRole();
\App\Support\SuperAdminAccount::assign($admin); SuperAdminAccount::assign($admin);
} }
/** 为后台测试账号挂上代理节点(需已存在 agent_nodes / admin_user_agents 表)。 */ /** 为后台测试账号挂上代理节点(需已存在 agent_nodes / admin_user_agents 表)。 */
@@ -76,6 +92,37 @@ function agentChildPayload(array $overrides = []): array
], $overrides); ], $overrides);
} }
/**
* 为钱包 HTTP 测试提供离线 DNS避免测试依赖机器或公网解析状态。
*
* @param array<string, list<string>> $recordsByHost
* @param list<string> $defaultRecords
*/
function fakeWalletApiDns(
array $recordsByHost = [],
array $defaultRecords = ['93.184.216.34'],
): void {
app()->instance(WalletApiDnsResolver::class, new class($recordsByHost, $defaultRecords) implements WalletApiDnsResolver
{
/**
* @param array<string, list<string>> $recordsByHost
* @param list<string> $defaultRecords
*/
public function __construct(
private readonly array $recordsByHost,
private readonly array $defaultRecords,
) {}
public function resolveAll(string $hostname): array
{
return $this->recordsByHost[$hostname] ?? $this->defaultRecords;
}
});
// Guard 可能已在当前测试中解析过,清除后确保使用刚绑定的 DNS resolver。
app()->forgetInstance(WalletApiRequestGuard::class);
}
function bindAdminUserToAgent(AdminUser $admin, int $agentNodeId): void function bindAdminUserToAgent(AdminUser $admin, int $agentNodeId): void
{ {
DB::table('admin_user_agents')->updateOrInsert( DB::table('admin_user_agents')->updateOrInsert(
@@ -96,7 +143,7 @@ function ensureRootAgentProfileSeeded(): void
return; return;
} }
\App\Models\AgentProfile::query()->updateOrCreate( AgentProfile::query()->updateOrCreate(
['agent_node_id' => (int) $rootId], ['agent_node_id' => (int) $rootId],
[ [
'total_share_rate' => 100, 'total_share_rate' => 100,
@@ -154,15 +201,16 @@ function ensureAdminActionCatalogSeeded(): void
function allBroadcastEvents(): array function allBroadcastEvents(): array
{ {
return [ return [
\App\Events\BalanceUpdateBroadcast::class, BalanceUpdateBroadcast::class,
\App\Events\DrawCountdownBroadcast::class, DrawCountdownBroadcast::class,
\App\Events\DrawResultPublishedBroadcast::class, DrawResultPublishedBroadcast::class,
\App\Events\DrawStatusChangeBroadcast::class, DrawStatusChangeBroadcast::class,
\App\Events\JackpotBurstBroadcast::class, JackpotBurstBroadcast::class,
\App\Events\OddsUpdateBroadcast::class, OddsUpdateBroadcast::class,
\App\Events\PlayCatalogUpdatedBroadcast::class, PlayCatalogUpdatedBroadcast::class,
\App\Events\PlayToggleBroadcast::class, PlayToggleBroadcast::class,
\App\Events\RiskSoldOutBroadcast::class, PlayerSessionReplacedBroadcast::class,
\App\Events\RiskWarningBroadcast::class, RiskSoldOutBroadcast::class,
RiskWarningBroadcast::class,
]; ];
} }

View File

@@ -0,0 +1,74 @@
<?php
use Illuminate\Http\Client\PendingRequest;
use App\Support\Integration\WalletApiRequestGuard;
use App\Support\Integration\WalletApiUrlSanitizer;
test('wallet api sanitizer rejects private and reserved literal addresses', function (string $url): void {
expect(WalletApiUrlSanitizer::normalizeAndValidate($url))->toBeNull();
})->with([
'ipv4 loopback' => 'https://127.0.0.1',
'ipv4 shortened notation' => 'https://127.1',
'ipv4 decimal integer notation' => 'https://2130706433',
'ipv4 hexadecimal notation' => 'https://0x7f000001',
'ipv4 octal notation' => 'https://0177.0.0.1',
'ipv4 private' => 'https://10.0.0.1',
'ipv4 link local' => 'https://169.254.169.254',
'ipv4 cgnat' => 'https://100.64.0.1',
'ipv4 documentation' => 'https://192.0.2.1',
'ipv6 loopback' => 'https://[::1]',
'ipv6 unique local' => 'https://[fd00::1]',
'ipv6 link local' => 'https://[fe80::1]',
'ipv6 nat64 private target' => 'https://[64:ff9b::a00:1]',
'ipv6 protocol assignment' => 'https://[2001::1]',
'ipv6 documentation' => 'https://[2001:db8::1]',
]);
test('wallet api guard rejects hostname when any dns answer is non-public', function (): void {
fakeWalletApiDns([
'wallet.example.test' => ['93.184.216.34', '10.0.0.8'],
], []);
expect(app(WalletApiRequestGuard::class)->guard('https://wallet.example.test', 10))
->toBeNull();
});
test('wallet api guard rejects unresolved hostname', function (): void {
fakeWalletApiDns([], []);
expect(app(WalletApiRequestGuard::class)->guard('https://missing.example.test', 10))
->toBeNull();
});
test('wallet api guard accepts public ipv4 and ipv6 dns answers and bounds connect timeout', function (): void {
fakeWalletApiDns([
'wallet.example.test' => ['93.184.216.34', '2606:4700:4700::1111'],
], []);
$endpoint = app(WalletApiRequestGuard::class)->guard('https://wallet.example.test:8443', 120);
expect($endpoint)->not->toBeNull()
->and($endpoint?->baseUrl)->toBe('https://wallet.example.test:8443')
->and($endpoint?->port)->toBe(8443)
->and($endpoint?->pinnedIp)->toBe('93.184.216.34')
->and($endpoint?->totalTimeoutSeconds)->toBe(120)
->and($endpoint?->connectTimeoutSeconds)->toBe(5);
$pending = $endpoint?->request(['Authorization' => 'Bearer secret']);
$optionsProperty = new ReflectionProperty(PendingRequest::class, 'options');
$options = $optionsProperty->getValue($pending);
expect($options['allow_redirects'] ?? null)->toBeFalse()
->and($options['proxy'] ?? null)->toBe('')
->and($options['timeout'] ?? null)->toBe(120)
->and($options['connect_timeout'] ?? null)->toBe(5)
->and($options['curl'][CURLOPT_RESOLVE] ?? null)
->toBe(['wallet.example.test:8443:93.184.216.34']);
});
test('wallet api sanitizer accepts normal public literals', function (): void {
expect(WalletApiUrlSanitizer::normalizeAndValidate('https://93.184.216.34'))
->toBe('https://93.184.216.34')
->and(WalletApiUrlSanitizer::normalizeAndValidate('https://[2606:4700:4700::1111]'))
->toBe('https://[2606:4700:4700::1111]');
});