feat: 更新玩法配置管理,简化字段并增强功能

- 将玩法相关的显示名称字段统一为 `display_name`,移除多语言字段。
- 在 `PlayTypePatchController` 中新增即时切换玩法开关的功能,并推送大厅更新。
- 优化多个控制器和服务中的权限检查与数据处理逻辑,提升代码可读性与维护性。
This commit is contained in:
2026-05-25 14:34:24 +08:00
parent 270d2e9af1
commit e27a00f260
74 changed files with 4469 additions and 280 deletions

View File

@@ -0,0 +1,96 @@
<?php
namespace App\Console\Commands;
use App\Models\Draw;
use Carbon\Carbon;
use Illuminate\Console\Command;
use App\Services\Draw\DrawPlannerService;
/**
* PRD §17.2:校验期号计划相邻开奖时刻间隔是否符合 interval_minutes默认 5 分钟)。
*/
final class LotteryPerfDrawScheduleAuditCommand extends Command
{
protected $signature = 'lottery:perf-draw-schedule-audit
{--samples=48 : 抽检相邻期数}
{--tolerance-seconds=60 : 允许偏差(秒)}';
protected $description = 'Audit draw_time spacing for schedule punctuality (§17.2)';
public function handle(DrawPlannerService $planner): int
{
$samples = max(2, (int) $this->option('samples'));
$tolerance = max(0, (int) $this->option('tolerance-seconds'));
$intervalMinutes = (int) config('lottery.draw.interval_minutes', 5);
$expectedSeconds = $intervalMinutes * 60;
$planner->ensureBuffer(Carbon::now('UTC'));
$nowUtc = Carbon::now('UTC');
$horizon = $nowUtc->copy()->addDays(14);
$businessDate = Draw::query()
->whereNotNull('draw_time')
->where('draw_time', '>', $nowUtc)
->where('draw_time', '<=', $horizon)
->where('business_date', '<', '2090-01-01')
->orderByDesc('business_date')
->value('business_date');
if ($businessDate === null) {
$this->error('No upcoming draws found.');
return self::FAILURE;
}
$times = Draw::query()
->where('business_date', $businessDate)
->whereNotNull('draw_time')
->orderBy('sequence_no')
->limit($samples + 1)
->pluck('draw_time')
->map(fn ($t) => Carbon::parse($t)->utc())
->values()
->all();
$this->line('business_date='.$businessDate);
if (count($times) < 2) {
$this->error('Not enough draws to audit.');
return self::FAILURE;
}
$violations = [];
for ($i = 1; $i < count($times); $i++) {
$delta = $times[$i]->diffInSeconds($times[$i - 1], absolute: true);
if (abs($delta - $expectedSeconds) > $tolerance) {
$violations[] = [
'pair' => ($i - 1).'→'.$i,
'delta_seconds' => $delta,
'expected_seconds' => $expectedSeconds,
];
}
}
$this->info(sprintf(
'interval_minutes=%d expected_gap=%ds tolerance=±%ds pairs_checked=%d',
$intervalMinutes,
$expectedSeconds,
$tolerance,
count($times) - 1,
));
if ($violations === []) {
$this->info('PASS — all checked gaps within tolerance.');
return self::SUCCESS;
}
$this->error('FAIL — spacing violations:');
$this->table(['pair', 'delta_seconds', 'expected_seconds'], $violations);
return self::FAILURE;
}
}

View File

@@ -0,0 +1,243 @@
<?php
namespace App\Console\Commands;
use App\Models\Draw;
use App\Models\Player;
use App\Models\TicketItem;
use App\Models\TicketOrder;
use App\Models\PlayerWallet;
use App\Models\DrawResultItem;
use App\Models\DrawResultBatch;
use App\Lottery\DrawStatus;
use Illuminate\Console\Command;
use App\Services\Draw\DrawPrizeLayout;
use Illuminate\Support\Facades\DB;
use App\Lottery\DrawResultBatchStatus;
use App\Services\Settlement\SettlementOrchestrator;
/**
* PRD §17.2:单期万级注单结算耗时验收(仅结算编排,不含派彩入账)。
*/
final class LotteryPerfSettlementBenchmarkCommand extends Command
{
protected $signature = 'lottery:perf-settlement-benchmark
{--items=10000 : 待结算 ticket_items 数量}
{--max-seconds=30 : 通过阈值(秒)}';
protected $description = 'Benchmark SettlementOrchestrator for N pending_draw items (§17.2)';
public function handle(SettlementOrchestrator $orchestrator): int
{
if (ini_get('memory_limit') !== '-1' && $this->byteMemoryLimit(ini_get('memory_limit')) < 512 * 1024 * 1024) {
ini_set('memory_limit', '512M');
}
return $this->runBenchmark($orchestrator);
}
private function runBenchmark(SettlementOrchestrator $orchestrator): int
{
$itemCount = max(1, (int) $this->option('items'));
$maxSeconds = max(1, (int) $this->option('max-seconds'));
$maxMs = $maxSeconds * 1000;
$this->info(sprintf('Seeding %d ticket items…', $itemCount));
$fixture = $this->seedFixture($itemCount);
$draw = $fixture['draw'];
$this->info('Running trySettleDraw…');
$started = hrtime(true);
$ran = $orchestrator->trySettleDraw($draw->fresh());
$elapsedMs = (int) ((hrtime(true) - $started) / 1_000_000);
if (! $ran) {
$this->error('Settlement did not run (check draw status / published result batch).');
return self::FAILURE;
}
$settled = TicketItem::query()
->where('draw_id', $draw->id)
->whereIn('status', ['pending_payout', 'settled_lose', 'settled_win'])
->count();
$pass = $elapsedMs <= $maxMs && $settled === $itemCount;
$this->table(
['metric', 'value'],
[
['items', (string) $itemCount],
['settled_rows', (string) $settled],
['elapsed_ms', (string) $elapsedMs],
['threshold_ms', (string) $maxMs],
['result', $pass ? 'PASS' : 'FAIL'],
],
);
return $pass ? self::SUCCESS : self::FAILURE;
}
private function byteMemoryLimit(string $value): int
{
$value = trim($value);
if ($value === '' || $value === '-1') {
return PHP_INT_MAX;
}
$unit = strtolower(substr($value, -1));
$number = (int) $value;
return match ($unit) {
'g' => $number * 1024 * 1024 * 1024,
'm' => $number * 1024 * 1024,
'k' => $number * 1024,
default => $number,
};
}
/**
* @return array{draw: Draw}
*/
private function seedFixture(int $itemCount): array
{
return DB::transaction(function () use ($itemCount): array {
$uniq = bin2hex(random_bytes(4));
$player = Player::query()->create([
'site_code' => 'perf',
'site_player_id' => 'perf-settle-'.$uniq,
'username' => 'perf_'.$uniq,
'nickname' => null,
'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,
]);
$draw = Draw::query()->create([
'draw_no' => '20990101-'.str_pad((string) random_int(1, 999), 3, '0', STR_PAD_LEFT),
'business_date' => '2099-01-01',
'sequence_no' => 1,
'status' => DrawStatus::Settling->value,
'start_time' => now()->subHour(),
'close_time' => now()->subMinutes(30),
'draw_time' => now()->subMinutes(20),
'cooling_end_time' => now()->subMinutes(5),
'result_source' => 'rng',
'current_result_version' => 1,
'settle_version' => 0,
'is_reopened' => false,
]);
$batch = DrawResultBatch::query()->create([
'draw_id' => $draw->id,
'result_version' => 1,
'source_type' => 'rng',
'rng_seed_hash' => 'perf-bench',
'raw_seed_encrypted' => null,
'status' => DrawResultBatchStatus::Published->value,
'created_by' => null,
'confirmed_by' => null,
'confirmed_at' => now(),
]);
foreach (DrawPrizeLayout::slots() as $slot) {
DrawResultItem::query()->create([
'draw_id' => $draw->id,
'result_batch_id' => $batch->id,
'prize_type' => $slot['prize_type'],
'prize_index' => $slot['prize_index'],
'number_4d' => '0001',
'suffix_3d' => '001',
'suffix_2d' => '01',
'head_digit' => 0,
'tail_digit' => 1,
]);
}
$order = TicketOrder::query()->create([
'order_no' => 'TO-PERF-'.$uniq,
'player_id' => $player->id,
'draw_id' => $draw->id,
'currency_code' => 'NPR',
'total_bet_amount' => $itemCount * 10,
'total_rebate_amount' => 0,
'total_actual_deduct' => $itemCount * 10,
'total_estimated_payout' => 0,
'status' => 'placed',
'submit_source' => 'perf',
'client_trace_id' => 'perf-settle-'.$uniq,
]);
$now = now();
$oddsJson = json_encode(['1st' => 250000], JSON_THROW_ON_ERROR);
$ruleJson = json_encode([], JSON_THROW_ON_ERROR);
$chunk = 500;
for ($offset = 0; $offset < $itemCount; $offset += $chunk) {
$size = min($chunk, $itemCount - $offset);
$itemRows = [];
$ticketNos = [];
for ($i = 0; $i < $size; $i++) {
$seq = $offset + $i + 1;
$num = str_pad((string) ($seq % 10000), 4, '0', STR_PAD_LEFT);
$ticketNo = sprintf('TK-PERF-%s-%06d', $uniq, $seq);
$ticketNos[] = $ticketNo;
$itemRows[] = [
'ticket_no' => $ticketNo,
'order_id' => $order->id,
'player_id' => $player->id,
'draw_id' => $draw->id,
'original_number' => $num,
'normalized_number' => $num,
'play_code' => 'big',
'dimension' => 4,
'digit_slot' => null,
'bet_mode' => 'straight',
'unit_bet_amount' => 10,
'total_bet_amount' => 10,
'rebate_rate_snapshot' => 0,
'commission_rate_snapshot' => 0,
'actual_deduct_amount' => 10,
'odds_snapshot_json' => $oddsJson,
'rule_snapshot_json' => $ruleJson,
'combination_count' => 1,
'estimated_max_payout' => 250,
'risk_locked_amount' => 0,
'status' => 'pending_draw',
'created_at' => $now,
'updated_at' => $now,
];
}
DB::table('ticket_items')->insert($itemRows);
$comboRows = DB::table('ticket_items')
->whereIn('ticket_no', $ticketNos)
->get(['id', 'normalized_number'])
->map(fn ($row): array => [
'ticket_item_id' => $row->id,
'combination_no' => 0,
'number_4d' => $row->normalized_number,
'bet_amount' => 10,
'estimated_payout' => 250,
'created_at' => $now,
])
->all();
DB::table('ticket_combinations')->insert($comboRows);
}
return ['draw' => $draw];
});
}
}

View File

@@ -40,9 +40,7 @@ final class PlayConfigItemsReplaceController extends Controller
'items.*.category' => ['required', 'string', 'max:16'], 'items.*.category' => ['required', 'string', 'max:16'],
'items.*.dimension' => ['nullable', 'integer', 'min:0', 'max:255'], 'items.*.dimension' => ['nullable', 'integer', 'min:0', 'max:255'],
'items.*.bet_mode' => ['nullable', 'string', 'max:32'], 'items.*.bet_mode' => ['nullable', 'string', 'max:32'],
'items.*.display_name_zh' => ['required', 'string', 'max:64'], 'items.*.display_name' => ['required', 'string', 'max:64'],
'items.*.display_name_en' => ['nullable', 'string', 'max:64'],
'items.*.display_name_ne' => ['nullable', 'string', 'max:64'],
'items.*.is_enabled' => ['sometimes', 'boolean'], 'items.*.is_enabled' => ['sometimes', 'boolean'],
'items.*.min_bet_amount' => ['required', 'integer', 'min:0'], 'items.*.min_bet_amount' => ['required', 'integer', 'min:0'],
'items.*.max_bet_amount' => ['required', 'integer', 'min:0'], 'items.*.max_bet_amount' => ['required', 'integer', 'min:0'],

View File

@@ -0,0 +1,47 @@
<?php
namespace App\Http\Controllers\Api\V1\Admin\Dashboard;
use App\Models\AdminUser;
use App\Lottery\ErrorCode;
use App\Support\ApiResponse;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\DashboardAnalyticsRequest;
use App\Services\Admin\AdminDashboardAnalyticsBuilder;
/**
* GET /api/v1/admin/dashboard/analytics 可筛选区间的财务趋势与玩法拆解。
*/
final class AdminDashboardAnalyticsController extends Controller
{
public function __construct(
private readonly AdminDashboardAnalyticsBuilder $analytics,
) {}
public function __invoke(DashboardAnalyticsRequest $request): JsonResponse
{
$admin = $request->lotteryAdmin();
if (! $admin instanceof AdminUser) {
return ApiResponse::error(
trans('admin.unauthenticated', [], $request->lotteryLocale()),
ErrorCode::AdminUnauthenticated->value,
null,
401,
);
}
$payload = $this->analytics->build($admin, $request->filters());
if ($payload === null) {
return ApiResponse::error(
trans('admin.forbidden', [], $request->lotteryLocale()),
ErrorCode::AdminForbidden->value,
null,
403,
);
}
return ApiResponse::success($payload);
}
}

View File

@@ -2,62 +2,56 @@
namespace App\Http\Controllers\Api\V1\Admin\Jackpot; namespace App\Http\Controllers\Api\V1\Admin\Jackpot;
use App\Models\AdminUser;
use App\Models\JackpotPool; use App\Models\JackpotPool;
use App\Support\ApiResponse; use App\Support\ApiResponse;
use App\Lottery\ErrorCode;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use App\Models\JackpotPayoutLog;
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\Services\Jackpot\JackpotManualBurstService;
final class AdminJackpotPoolManualBurstController extends Controller final class AdminJackpotPoolManualBurstController extends Controller
{ {
public function __construct(
private readonly JackpotManualBurstService $service,
) {}
public function __invoke(Request $request, JackpotPool $pool): JsonResponse public function __invoke(Request $request, JackpotPool $pool): JsonResponse
{ {
$admin = $request->user();
if (! $admin instanceof AdminUser) {
return ApiResponse::error(
trans('admin.unauthenticated', [], $request->lotteryLocale()),
ErrorCode::AdminUnauthenticated->value,
null,
401,
);
}
if (! $admin->isSuperAdmin()) {
return ApiResponse::error(
trans('admin.permission_denied', [], $request->lotteryLocale()),
ErrorCode::AdminForbidden->value,
['required_any' => [AdminUser::ROLE_SUPER_ADMIN]],
403,
);
}
$data = $request->validate([ $data = $request->validate([
'draw_id' => 'required|integer|exists:draws,id', 'draw_id' => 'required|integer|exists:draws,id',
'amount' => 'nullable|integer|min:1',
]); ]);
$payload = DB::transaction(function () use ($pool, $data): array { try {
/** @var JackpotPool $locked */ $payload = $this->service->execute($pool, (int) $data['draw_id']);
$locked = JackpotPool::query()->whereKey($pool->id)->lockForUpdate()->firstOrFail(); } catch (\RuntimeException $e) {
$poolBefore = (int) $locked->current_amount; return ApiResponse::error(
$amount = isset($data['amount']) ? min((int) $data['amount'], $poolBefore) : $poolBefore; trans('api.jackpot_manual_burst_failed', ['reason' => $e->getMessage()], $request->lotteryLocale()),
ErrorCode::ClientHttpError->value,
if ($amount <= 0) { ['reason' => $e->getMessage()],
return [ 409,
'current_amount' => $poolBefore, );
'burst_amount' => 0, }
'log_id' => null,
];
}
$drawId = (int) $data['draw_id'];
$locked->forceFill([
'current_amount' => $poolBefore - $amount,
'last_trigger_draw_id' => $drawId,
])->save();
$log = JackpotPayoutLog::query()->create([
'draw_id' => $drawId,
'jackpot_pool_id' => $locked->id,
'trigger_type' => 'manual',
'total_payout_amount' => $amount,
'winner_count' => 0,
'trigger_snapshot_json' => [
'pool_amount_before' => $poolBefore,
'manual' => true,
],
]);
return [
'current_amount' => (int) $locked->current_amount,
'burst_amount' => $amount,
'log_id' => (int) $log->id,
];
});
return ApiResponse::success($payload); return ApiResponse::success($payload);
} }

View File

@@ -3,30 +3,46 @@
namespace App\Http\Controllers\Api\V1\Admin; namespace App\Http\Controllers\Api\V1\Admin;
use App\Models\PlayType; use App\Models\PlayType;
use App\Models\AdminUser;
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\Support\AdminConfigPresenter; use App\Support\AdminConfigPresenter;
use App\Services\Config\PlayConfigStreamService;
/** PATCH /api/v1/admin/play-types/{play_code} — 主目录层开关与展示名(不等同于版本化 items。 */ /**
* PATCH /api/v1/admin/play-types/{play_code}
*
* is_enabled 写入当前生效玩法配置并即时推送大厅;其余字段仅更新主目录 play_types。
*/
final class PlayTypePatchController extends Controller final class PlayTypePatchController extends Controller
{ {
public function __construct(
private readonly PlayConfigStreamService $playConfig,
) {}
public function __invoke(Request $request, string $play_code): JsonResponse public function __invoke(Request $request, string $play_code): JsonResponse
{ {
/** @var AdminUser $admin */
$admin = $request->lotteryAdmin();
/** @var PlayType $type */ /** @var PlayType $type */
$type = PlayType::query()->where('play_code', $play_code)->firstOrFail(); $type = PlayType::query()->where('play_code', $play_code)->firstOrFail();
$data = $request->validate([ $data = $request->validate([
'is_enabled' => ['sometimes', 'boolean'], 'is_enabled' => ['sometimes', 'boolean'],
'sort_order' => ['sometimes', 'integer'], 'sort_order' => ['sometimes', 'integer'],
'display_name_zh' => ['sometimes', 'nullable', 'string', 'max:64'], 'display_name' => ['sometimes', 'nullable', 'string', 'max:64'],
'display_name_en' => ['sometimes', 'nullable', 'string', 'max:64'],
'display_name_ne' => ['sometimes', 'nullable', 'string', 'max:64'],
'supports_multi_number' => ['sometimes', 'boolean'], 'supports_multi_number' => ['sometimes', 'boolean'],
'reserved_rule_json' => ['sometimes', 'nullable', 'array'], 'reserved_rule_json' => ['sometimes', 'nullable', 'array'],
]); ]);
if (array_key_exists('is_enabled', $data)) {
$this->playConfig->patchActivePlayToggle($admin, $play_code, (bool) $data['is_enabled'], $request);
unset($data['is_enabled']);
}
if ($data !== []) { if ($data !== []) {
$type->fill($data); $type->fill($data);
$type->save(); $type->save();

View File

@@ -2,6 +2,7 @@
namespace App\Http\Controllers\Api\V1\Admin\Wallet; namespace App\Http\Controllers\Api\V1\Admin\Wallet;
use App\Models\AdminUser;
use App\Support\ApiResponse; use App\Support\ApiResponse;
use App\Models\TransferOrder; use App\Models\TransferOrder;
use App\Support\PaginationTrait; use App\Support\PaginationTrait;
@@ -80,8 +81,11 @@ final class TransferOrderListController extends Controller
} }
} }
$admin = $request->lotteryAdmin();
$paginator = $query->paginate($perPage, ['*'], 'page', $page); $paginator = $query->paginate($perPage, ['*'], 'page', $page);
$items = $paginator->getCollection()->map(fn (TransferOrder $o) => $this->formatRow($o)); $items = $paginator->getCollection()->map(
fn (TransferOrder $o) => $this->formatRow($o, $admin instanceof AdminUser ? $admin : null),
);
return ApiResponse::success([ return ApiResponse::success([
'items' => $items, 'items' => $items,
@@ -94,10 +98,14 @@ final class TransferOrderListController extends Controller
/** /**
* @return array<string, mixed> * @return array<string, mixed>
*/ */
private function formatRow(TransferOrder $o): array private function formatRow(TransferOrder $o, ?AdminUser $admin): array
{ {
$p = $o->player; $p = $o->player;
$amount = (int) $o->amount; $amount = (int) $o->amount;
$canWriteWallet = $admin !== null && (
$admin->hasAdminPermission('prd.wallet_adjust.manage')
|| $admin->hasAdminPermission('prd.wallet_reconcile.manage')
);
return [ return [
'id' => $o->id, 'id' => $o->id,
@@ -113,8 +121,8 @@ final class TransferOrderListController extends Controller
'amount_formatted' => CurrencyFormatter::fromMinor($amount), 'amount_formatted' => CurrencyFormatter::fromMinor($amount),
'idempotent_key' => $o->idempotent_key, 'idempotent_key' => $o->idempotent_key,
'status' => $o->status, 'status' => $o->status,
'can_reverse' => $o->status === 'pending_reconcile', 'can_reverse' => $canWriteWallet && $o->status === 'pending_reconcile',
'can_manually_process' => in_array($o->status, ['processing', 'failed', 'pending_reconcile'], true), 'can_manually_process' => $canWriteWallet && in_array($o->status, ['processing', 'failed', 'pending_reconcile'], true),
'external_ref_no' => $o->external_ref_no, 'external_ref_no' => $o->external_ref_no,
'external_request_payload' => $o->external_request_payload, 'external_request_payload' => $o->external_request_payload,
'external_response_payload' => $o->external_response_payload, 'external_response_payload' => $o->external_response_payload,

View File

@@ -0,0 +1,143 @@
<?php
namespace App\Http\Middleware;
use Closure;
use App\Models\AdminUser;
use App\Services\AuditLogger;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Symfony\Component\HttpFoundation\Response;
/**
* admin_api_resources.is_audit_required=true 的变更类请求,在成功响应后写入审计日志。
*
* 若请求已设置 {@see Request::ATTRIBUTE_AUDIT_RECORDED}(业务层已写更细快照),则跳过。
*/
final class RecordAdminApiAudit
{
public const ATTRIBUTE_AUDIT_RECORDED = 'admin_audit_recorded';
private const MUTATING_METHODS = ['POST', 'PUT', 'PATCH', 'DELETE'];
public function handle(Request $request, Closure $next): Response
{
$response = $next($request);
if (! $this->shouldRecord($request, $response)) {
return $response;
}
$admin = $request->user();
if (! $admin instanceof AdminUser) {
return $response;
}
$resource = $this->resolveResource($request);
if ($resource === null || ! (bool) $resource->is_audit_required) {
return $response;
}
$targetId = $this->resolveTargetId($request);
$actionCode = $this->resolveActionCode((string) $resource->code);
AuditLogger::recordForAdmin(
$admin,
$request,
moduleCode: (string) $resource->module_code,
actionCode: $actionCode,
targetType: (string) $resource->code,
targetId: $targetId,
beforeJson: null,
afterJson: [
'http_method' => $request->method(),
'route_name' => $this->normalizeRouteName((string) ($request->route()?->getName() ?? '')),
'status' => $response->getStatusCode(),
'payload' => $this->sanitizedPayload($request),
],
);
return $response;
}
private function shouldRecord(Request $request, Response $response): bool
{
if ($request->attributes->get(self::ATTRIBUTE_AUDIT_RECORDED) === true) {
return false;
}
if (! in_array(strtoupper($request->method()), self::MUTATING_METHODS, true)) {
return false;
}
$status = $response->getStatusCode();
return $status >= 200 && $status < 300;
}
private function resolveResource(Request $request): ?object
{
$routeName = $request->route()?->getName();
if (! is_string($routeName) || $routeName === '') {
return null;
}
return DB::table('admin_api_resources')
->where('route_name', $this->normalizeRouteName($routeName))
->where('status', 1)
->first(['code', 'module_code', 'is_audit_required']);
}
private function normalizeRouteName(string $routeName): string
{
return preg_replace('/^(api\.v1\.admin\.)+/', 'api.v1.admin.', $routeName) ?? $routeName;
}
private function resolveActionCode(string $resourceCode): string
{
$pos = strrpos($resourceCode, '.');
if ($pos === false) {
return $resourceCode;
}
return substr($resourceCode, $pos + 1);
}
private function resolveTargetId(Request $request): ?string
{
$route = $request->route();
if ($route === null) {
return null;
}
foreach (['batch', 'draw', 'transfer_no', 'player', 'admin_user', 'admin_role', 'id', 'play_code', 'number_4d', 'key'] as $key) {
$value = $route->parameter($key);
if ($value === null) {
continue;
}
if (is_object($value) && method_exists($value, 'getKey')) {
return (string) $value->getKey();
}
return (string) $value;
}
return null;
}
/**
* @return array<string, mixed>|null
*/
private function sanitizedPayload(Request $request): ?array
{
$data = $request->except([
'password',
'password_confirmation',
'current_password',
'token',
]);
return $data === [] ? null : $data;
}
}

View File

@@ -0,0 +1,45 @@
<?php
namespace App\Http\Requests\Admin;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
final class DashboardAnalyticsRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/** @return array<string, mixed> */
public function rules(): array
{
return [
'period' => ['sometimes', 'string', Rule::in([
'today',
'last_7_days',
'last_30_days',
'this_month',
'lifetime',
'custom',
])],
'date_from' => ['nullable', 'date_format:Y-m-d', 'required_if:period,custom'],
'date_to' => ['nullable', 'date_format:Y-m-d', 'required_if:period,custom', 'after_or_equal:date_from'],
'metric' => ['sometimes', 'string', Rule::in(['overview', 'bet', 'payout', 'profit'])],
'play_code' => ['nullable', 'string', 'max:64'],
];
}
/** @return array<string, mixed> */
public function filters(): array
{
return [
'period' => (string) $this->input('period', 'last_7_days'),
'date_from' => $this->input('date_from'),
'date_to' => $this->input('date_to'),
'metric' => (string) $this->input('metric', 'overview'),
'play_code' => $this->input('play_code'),
];
}
}

View File

@@ -14,9 +14,7 @@ final class PlayConfigItem extends Model
'category', 'category',
'dimension', 'dimension',
'bet_mode', 'bet_mode',
'display_name_zh', 'display_name',
'display_name_en',
'display_name_ne',
'is_enabled', 'is_enabled',
'min_bet_amount', 'min_bet_amount',
'max_bet_amount', 'max_bet_amount',

View File

@@ -12,9 +12,7 @@ final class PlayType extends Model
'category', 'category',
'dimension', 'dimension',
'bet_mode', 'bet_mode',
'display_name_zh', 'display_name',
'display_name_en',
'display_name_ne',
'is_enabled', 'is_enabled',
'sort_order', 'sort_order',
'supports_multi_number', 'supports_multi_number',

View File

@@ -0,0 +1,82 @@
<?php
namespace App\Services\Admin;
use App\Models\AdminUser;
/**
* 仪表盘可筛选分析数据:区间汇总、日趋势、玩法拆解。
*/
final class AdminDashboardAnalyticsBuilder
{
public function __construct(
private readonly AdminReportQueryService $reportQuery,
) {}
/**
* @param array{
* period?: string,
* date_from?: string|null,
* date_to?: string|null,
* metric?: string,
* play_code?: string|null
* } $filters
*
* @return array<string, mixed>|null
*/
public function build(AdminUser $admin, array $filters): ?array
{
if (! $this->canView($admin)) {
return null;
}
$period = (string) ($filters['period'] ?? 'last_7_days');
$metric = (string) ($filters['metric'] ?? 'overview');
$playCode = isset($filters['play_code']) && $filters['play_code'] !== ''
? (string) $filters['play_code']
: null;
$range = $this->reportQuery->resolveDashboardPeriod(
$period,
isset($filters['date_from']) ? (string) $filters['date_from'] : null,
isset($filters['date_to']) ? (string) $filters['date_to'] : null,
);
$dateFrom = $range['date_from'];
$dateTo = $range['date_to'];
$trend = $this->reportQuery->dailyProfitSeriesFilled($dateFrom, $dateTo);
return [
'period' => $period,
'metric' => $metric,
'play_code' => $playCode,
'date_from' => $dateFrom,
'date_to' => $dateTo,
'currency_code' => $this->reportQuery->resolvePeriodCurrencyCode($dateFrom, $dateTo),
'summary' => $this->reportQuery->periodFinanceTotals($dateFrom, $dateTo),
'daily_series' => $trend['series'],
'chart_meta' => [
'chart_date_from' => $trend['chart_date_from'],
'chart_date_to' => $trend['chart_date_to'],
'truncated' => $trend['truncated'],
'span_days' => $trend['span_days'],
],
'play_breakdown' => $this->reportQuery->playDimensionBreakdownRows(
$dateFrom,
$dateTo,
$playCode,
),
];
}
private function canView(AdminUser $admin): bool
{
return $admin->hasAdminPermission('prd.dashboard.view')
|| $admin->hasAdminPermission('prd.draw_result.manage')
|| $admin->hasAdminPermission('prd.draw_result.view')
|| $admin->hasAdminPermission('prd.risk.view')
|| $admin->hasAdminPermission('prd.risk.manage')
|| $admin->hasAdminPermission('prd.report.view');
}
}

View File

@@ -22,6 +22,7 @@ final class AdminDashboardSnapshotBuilder
{ {
public function __construct( public function __construct(
private readonly DrawHallSnapshotBuilder $hallSnapshot, private readonly DrawHallSnapshotBuilder $hallSnapshot,
private readonly AdminReportQueryService $reportQuery,
) {} ) {}
/** @return array<string, mixed> */ /** @return array<string, mixed> */
@@ -34,6 +35,8 @@ final class AdminDashboardSnapshotBuilder
$out = [ $out = [
'hall' => $hall, 'hall' => $hall,
'resolved_draw' => null, 'resolved_draw' => null,
'today_finance' => null,
'lifetime_finance' => null,
'finance' => null, 'finance' => null,
'draw' => null, 'draw' => null,
'risk' => null, 'risk' => null,
@@ -67,6 +70,8 @@ final class AdminDashboardSnapshotBuilder
]; ];
if ($canDraw) { if ($canDraw) {
$out['today_finance'] = $this->todayFinanceSummary();
$out['lifetime_finance'] = $this->reportQuery->platformLifetimeTotals();
$out['finance'] = $this->financeSummary($draw); $out['finance'] = $this->financeSummary($draw);
$out['draw'] = $this->drawPanel($draw); $out['draw'] = $this->drawPanel($draw);
$out['risk'] = $this->riskPanel($draw); $out['risk'] = $this->riskPanel($draw);
@@ -81,8 +86,11 @@ final class AdminDashboardSnapshotBuilder
private function canDrawFinanceAndRisk(AdminUser $admin): bool private function canDrawFinanceAndRisk(AdminUser $admin): bool
{ {
return $admin->hasAdminPermission('prd.draw_result.manage') return $admin->hasAdminPermission('prd.dashboard.view')
|| $admin->hasAdminPermission('prd.draw_result.view'); || $admin->hasAdminPermission('prd.draw_result.manage')
|| $admin->hasAdminPermission('prd.draw_result.view')
|| $admin->hasAdminPermission('prd.risk.view')
|| $admin->hasAdminPermission('prd.risk.manage');
} }
private function canWalletReconcile(AdminUser $admin): bool private function canWalletReconcile(AdminUser $admin): bool
@@ -99,6 +107,36 @@ final class AdminDashboardSnapshotBuilder
->count(); ->count();
} }
/**
* 按业务日汇总「今日」投注/派彩/盈亏(与报表中心 daily-profit 口径一致)。
*
* @return array<string, mixed>
*/
private function todayFinanceSummary(): array
{
$today = now()->toDateString();
$rows = $this->reportQuery->dailyProfitRows($today, $today);
$row = $rows[0] ?? [
'business_date' => $today,
'total_bet_minor' => 0,
'total_payout_minor' => 0,
'approx_house_gross_minor' => 0,
];
$currencyCode = (string) (TicketOrder::query()
->join('draws', 'draws.id', '=', 'ticket_orders.draw_id')
->where('draws.business_date', $today)
->value('ticket_orders.currency_code') ?? '');
return [
'business_date' => (string) $row['business_date'],
'currency_code' => $currencyCode !== '' ? $currencyCode : null,
'total_bet_minor' => (int) $row['total_bet_minor'],
'total_payout_minor' => (int) $row['total_payout_minor'],
'approx_house_gross_minor' => (int) $row['approx_house_gross_minor'],
];
}
/** @return array<string, mixed> */ /** @return array<string, mixed> */
private function financeSummary(Draw $draw): array private function financeSummary(Draw $draw): array
{ {

View File

@@ -28,6 +28,180 @@ final class AdminReportQueryService
return ['date_from' => $dateFrom, 'date_to' => $dateTo]; return ['date_from' => $dateFrom, 'date_to' => $dateTo];
} }
/**
* @return array{date_from: string, date_to: string}
*/
public function resolveDashboardPeriod(string $period, ?string $dateFrom, ?string $dateTo): array
{
$today = now()->toDateString();
$range = match ($period) {
'today' => ['date_from' => $today, 'date_to' => $today],
'last_7_days' => [
'date_from' => now()->subDays(6)->toDateString(),
'date_to' => $today,
],
'last_30_days' => [
'date_from' => now()->subDays(29)->toDateString(),
'date_to' => $today,
],
'this_month' => [
'date_from' => now()->startOfMonth()->toDateString(),
'date_to' => $today,
],
'lifetime' => $this->lifetimeBusinessDateBounds(),
'custom' => [
'date_from' => $dateFrom !== null && $dateFrom !== '' ? $dateFrom : $today,
'date_to' => $dateTo !== null && $dateTo !== '' ? $dateTo : $today,
],
default => ['date_from' => $today, 'date_to' => $today],
};
$from = $range['date_from'];
$to = $range['date_to'];
if ($from > $to) {
[$from, $to] = [$to, $from];
}
return ['date_from' => $from, 'date_to' => $to];
}
/**
* @return array{date_from: string, date_to: string}
*/
private function lifetimeBusinessDateBounds(): array
{
$today = now()->toDateString();
$bounds = DB::table('draws as d')
->join('ticket_orders as o', 'o.draw_id', '=', 'd.id')
->selectRaw('MIN(d.business_date) as date_from')
->selectRaw('MAX(d.business_date) as date_to')
->first();
$from = $this->formatBusinessDateValue($bounds?->date_from) ?? $today;
$to = $this->formatBusinessDateValue($bounds?->date_to) ?? $today;
return ['date_from' => $from, 'date_to' => $to];
}
/**
* @return array{
* total_bet_minor: int,
* total_payout_minor: int,
* approx_house_gross_minor: int,
* draw_count: int,
* business_day_count: int
* }
*/
public function periodFinanceTotals(string $dateFrom, string $dateTo): array
{
$rows = $this->dailyProfitRows($dateFrom, $dateTo);
$totalBet = 0;
$totalPayout = 0;
$totalGross = 0;
foreach ($rows as $row) {
$totalBet += (int) $row['total_bet_minor'];
$totalPayout += (int) $row['total_payout_minor'];
$totalGross += (int) $row['approx_house_gross_minor'];
}
$activity = DB::table('draws as d')
->join('ticket_orders as o', 'o.draw_id', '=', 'd.id')
->whereBetween('d.business_date', [$dateFrom, $dateTo])
->selectRaw('COUNT(DISTINCT d.id) as draw_count')
->selectRaw('COUNT(DISTINCT d.business_date) as business_day_count')
->first();
return [
'total_bet_minor' => $totalBet,
'total_payout_minor' => $totalPayout,
'approx_house_gross_minor' => $totalGross,
'draw_count' => (int) ($activity->draw_count ?? 0),
'business_day_count' => (int) ($activity->business_day_count ?? 0),
];
}
/**
* 连续业务日序列(无数据日补零),用于趋势图。
*
* @return list<array<string, mixed>>
*/
public function dailyProfitSeriesFilled(string $dateFrom, string $dateTo, int $maxDays = 90): array
{
$from = Carbon::parse($dateFrom)->startOfDay();
$to = Carbon::parse($dateTo)->startOfDay();
$spanDays = (int) $from->diffInDays($to) + 1;
$chartFrom = $dateFrom;
$chartTo = $dateTo;
$truncated = false;
if ($spanDays > $maxDays) {
$chartFrom = $to->copy()->subDays($maxDays - 1)->format('Y-m-d');
$truncated = true;
}
$indexed = collect($this->dailyProfitRows($chartFrom, $chartTo))->keyBy('business_date');
$cursor = Carbon::parse($chartFrom)->startOfDay();
$end = Carbon::parse($chartTo)->startOfDay();
$series = [];
while ($cursor <= $end) {
$key = $cursor->format('Y-m-d');
$series[] = $indexed[$key] ?? [
'business_date' => $key,
'total_bet_minor' => 0,
'total_payout_minor' => 0,
'approx_house_gross_minor' => 0,
];
$cursor->addDay();
}
return [
'series' => $series,
'chart_date_from' => $chartFrom,
'chart_date_to' => $chartTo,
'truncated' => $truncated,
'span_days' => $spanDays,
];
}
/**
* @return list<array<string, mixed>>
*/
public function playDimensionBreakdownRows(
string $dateFrom,
string $dateTo,
?string $playCode = null,
int $limit = 12,
): array {
return $this->playDimensionBaseQuery($playCode, $dateFrom, $dateTo)
->orderByDesc('total_bet_minor')
->limit($limit)
->get()
->map(static function (object $row): array {
return [
'play_code' => (string) $row->play_code,
'dimension' => (int) $row->dimension,
'total_bet_minor' => (int) $row->total_bet_minor,
'total_payout_minor' => (int) $row->total_payout_minor,
'approx_house_gross_minor' => (int) $row->approx_house_gross_minor,
];
})
->values()
->all();
}
public function resolvePeriodCurrencyCode(string $dateFrom, string $dateTo): ?string
{
$currencyCode = (string) (DB::table('ticket_orders as o')
->join('draws as d', 'd.id', '=', 'o.draw_id')
->whereBetween('d.business_date', [$dateFrom, $dateTo])
->orderByDesc('o.id')
->value('o.currency_code') ?? '');
return $currencyCode !== '' ? $currencyCode : null;
}
public function dailyProfitPaginated(string $dateFrom, string $dateTo, int $page, int $perPage): LengthAwarePaginator public function dailyProfitPaginated(string $dateFrom, string $dateTo, int $page, int $perPage): LengthAwarePaginator
{ {
$rows = $this->dailyProfitRows($dateFrom, $dateTo); $rows = $this->dailyProfitRows($dateFrom, $dateTo);
@@ -80,6 +254,57 @@ final class AdminReportQueryService
->all(); ->all();
} }
/**
* 全平台历史累计投注/派彩/盈亏(与 daily-profit 同口径,不限业务日)。
*
* @return array{
* currency_code: ?string,
* total_bet_minor: int,
* total_payout_minor: int,
* approx_house_gross_minor: int,
* draw_count: int,
* business_day_count: int,
* date_from: ?string,
* date_to: ?string
* }
*/
public function platformLifetimeTotals(): array
{
$totalBetMinor = (int) DB::table('ticket_orders')->sum('total_actual_deduct');
$payoutAgg = DB::table('ticket_items')
->selectRaw('COALESCE(SUM(win_amount), 0) as win_minor, COALESCE(SUM(jackpot_win_amount), 0) as jackpot_minor')
->first();
$totalPayoutMinor = (int) ($payoutAgg->win_minor ?? 0) + (int) ($payoutAgg->jackpot_minor ?? 0);
$activity = DB::table('draws as d')
->join('ticket_orders as o', 'o.draw_id', '=', 'd.id')
->selectRaw('COUNT(DISTINCT d.id) as draw_count')
->selectRaw('COUNT(DISTINCT d.business_date) as business_day_count')
->selectRaw('MIN(d.business_date) as date_from')
->selectRaw('MAX(d.business_date) as date_to')
->first();
$drawCount = (int) ($activity->draw_count ?? 0);
$businessDayCount = (int) ($activity->business_day_count ?? 0);
$dateFrom = $this->formatBusinessDateValue($activity?->date_from);
$dateTo = $this->formatBusinessDateValue($activity?->date_to);
$currencyCode = (string) (DB::table('ticket_orders')->orderByDesc('id')->value('currency_code') ?? '');
return [
'currency_code' => $currencyCode !== '' ? $currencyCode : null,
'total_bet_minor' => $totalBetMinor,
'total_payout_minor' => $totalPayoutMinor,
'approx_house_gross_minor' => $totalBetMinor - $totalPayoutMinor,
'draw_count' => $drawCount,
'business_day_count' => $businessDayCount,
'date_from' => $dateFrom,
'date_to' => $dateTo,
];
}
public function playerWinLossPaginated( public function playerWinLossPaginated(
?int $playerId, ?int $playerId,
string $dateFrom, string $dateFrom,
@@ -340,4 +565,26 @@ final class AdminReportQueryService
return $query; return $query;
} }
private function formatBusinessDateValue(mixed $value): ?string
{
if ($value === null) {
return null;
}
if ($value instanceof Carbon) {
return $value->format('Y-m-d');
}
$raw = trim((string) $value);
if ($raw === '') {
return null;
}
if (preg_match('/^\d{4}-\d{2}-\d{2}/', $raw, $m) === 1) {
return substr($m[0], 0, 10);
}
return $raw;
}
} }

View File

@@ -69,9 +69,7 @@ final class EffectivePlayCatalogService
'category' => $c->category, 'category' => $c->category,
'dimension' => $c->dimension === null ? null : (int) $c->dimension, 'dimension' => $c->dimension === null ? null : (int) $c->dimension,
'bet_mode' => $c->bet_mode, 'bet_mode' => $c->bet_mode,
'display_name_zh' => $c->display_name_zh, 'display_name' => $c->display_name,
'display_name_en' => $c->display_name_en,
'display_name_ne' => $c->display_name_ne,
'sort_order' => (int) $c->display_order, 'sort_order' => (int) $c->display_order,
'supports_multi_number' => (bool) $c->supports_multi_number, 'supports_multi_number' => (bool) $c->supports_multi_number,
'master_enabled' => (bool) $c->is_enabled, 'master_enabled' => (bool) $c->is_enabled,
@@ -148,9 +146,7 @@ final class EffectivePlayCatalogService
'category' => $r->category, 'category' => $r->category,
'dimension' => $r->dimension === null ? null : (int) $r->dimension, 'dimension' => $r->dimension === null ? null : (int) $r->dimension,
'bet_mode' => $r->bet_mode, 'bet_mode' => $r->bet_mode,
'display_name_zh' => $r->display_name_zh, 'display_name' => $r->display_name,
'display_name_en' => $r->display_name_en,
'display_name_ne' => $r->display_name_ne,
'is_enabled' => (bool) $r->is_enabled, 'is_enabled' => (bool) $r->is_enabled,
'min_bet_amount' => (int) $r->min_bet_amount, 'min_bet_amount' => (int) $r->min_bet_amount,
'max_bet_amount' => (int) $r->max_bet_amount, 'max_bet_amount' => (int) $r->max_bet_amount,

View File

@@ -13,6 +13,7 @@ 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\Services\Draw\LotteryHallRealtimeBroadcaster;
use App\Http\Middleware\RecordAdminApiAudit;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Contracts\Pagination\LengthAwarePaginator;
@@ -162,6 +163,7 @@ final class OddsStreamService
beforeJson: $before, beforeJson: $before,
afterJson: $after, afterJson: $after,
); );
$request?->attributes->set(RecordAdminApiAudit::ATTRIBUTE_AUDIT_RECORDED, true);
} }
public function deleteVersion(OddsVersion $version, AdminUser $admin, ?Request $request = null): void public function deleteVersion(OddsVersion $version, AdminUser $admin, ?Request $request = null): void
@@ -182,6 +184,7 @@ final class OddsStreamService
beforeJson: $before, beforeJson: $before,
afterJson: null, afterJson: null,
); );
$request?->attributes->set(RecordAdminApiAudit::ATTRIBUTE_AUDIT_RECORDED, true);
} }
/** @return array<string, mixed> */ /** @return array<string, mixed> */

View File

@@ -11,6 +11,7 @@ use App\Models\PlayConfigVersion;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use App\Lottery\ConfigVersionStatus; use App\Lottery\ConfigVersionStatus;
use App\Services\Draw\LotteryHallRealtimeBroadcaster; use App\Services\Draw\LotteryHallRealtimeBroadcaster;
use App\Http\Middleware\RecordAdminApiAudit;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Contracts\Pagination\LengthAwarePaginator;
@@ -62,9 +63,7 @@ final class PlayConfigStreamService
'category' => $row->category, 'category' => $row->category,
'dimension' => $row->dimension, 'dimension' => $row->dimension,
'bet_mode' => $row->bet_mode, 'bet_mode' => $row->bet_mode,
'display_name_zh' => $row->display_name_zh, 'display_name' => $row->display_name,
'display_name_en' => $row->display_name_en,
'display_name_ne' => $row->display_name_ne,
'is_enabled' => $row->is_enabled, 'is_enabled' => $row->is_enabled,
'min_bet_amount' => $row->min_bet_amount, 'min_bet_amount' => $row->min_bet_amount,
'max_bet_amount' => $row->max_bet_amount, 'max_bet_amount' => $row->max_bet_amount,
@@ -85,9 +84,7 @@ final class PlayConfigStreamService
'category' => $pt->category, 'category' => $pt->category,
'dimension' => $pt->dimension, 'dimension' => $pt->dimension,
'bet_mode' => $pt->bet_mode, 'bet_mode' => $pt->bet_mode,
'display_name_zh' => $pt->display_name_zh, 'display_name' => $pt->display_name,
'display_name_en' => $pt->display_name_en,
'display_name_ne' => $pt->display_name_ne,
'is_enabled' => (bool) $pt->is_enabled, 'is_enabled' => (bool) $pt->is_enabled,
'min_bet_amount' => 100, 'min_bet_amount' => 100,
'max_bet_amount' => 500_000_000, 'max_bet_amount' => 500_000_000,
@@ -109,6 +106,53 @@ final class PlayConfigStreamService
/** /**
* @param array<int, array<string, mixed>> $items * @param array<int, array<string, mixed>> $items
*/ */
/**
* 即时切换当前生效玩法开关(无需发布草稿),并推送大厅 WS。
*/
public function patchActivePlayToggle(AdminUser $admin, string $playCode, bool $enabled, ?Request $request = null): PlayConfigItem
{
/** @var PlayConfigVersion $active */
$active = PlayConfigVersion::query()
->where('status', ConfigVersionStatus::Active->value)
->firstOrFail();
/** @var PlayConfigItem $item */
$item = PlayConfigItem::query()
->where('version_id', $active->id)
->where('play_code', $playCode)
->firstOrFail();
$before = ['is_enabled' => (bool) $item->is_enabled];
if ($before['is_enabled'] === $enabled) {
return $item;
}
DB::transaction(function () use ($item, $enabled, $active, $admin, $playCode): void {
$item->forceFill(['is_enabled' => $enabled])->save();
$active->forceFill(['updated_by' => $admin->id])->save();
PlayType::query()
->where('play_code', $playCode)
->update(['is_enabled' => $enabled]);
});
$this->hallRealtime->notifyPlayToggle($playCode, $enabled, 'play toggle applied to active config');
AuditLogger::recordForAdmin(
$admin,
$request,
moduleCode: 'play_config',
actionCode: 'toggle_active',
targetType: 'play_config_item',
targetId: $playCode,
beforeJson: $before,
afterJson: ['is_enabled' => $enabled, 'active_version_id' => $active->id],
);
$request?->attributes->set(RecordAdminApiAudit::ATTRIBUTE_AUDIT_RECORDED, true);
return $item->refresh();
}
public function replaceItems(PlayConfigVersion $draft, array $items, AdminUser $admin): void public function replaceItems(PlayConfigVersion $draft, array $items, AdminUser $admin): void
{ {
DB::transaction(function () use ($draft, $items, $admin): void { DB::transaction(function () use ($draft, $items, $admin): void {
@@ -121,9 +165,7 @@ final class PlayConfigStreamService
'category' => $row['category'] ?? null, 'category' => $row['category'] ?? null,
'dimension' => $row['dimension'] ?? null, 'dimension' => $row['dimension'] ?? null,
'bet_mode' => $row['bet_mode'] ?? null, 'bet_mode' => $row['bet_mode'] ?? null,
'display_name_zh' => $row['display_name_zh'] ?? null, 'display_name' => $row['display_name'] ?? null,
'display_name_en' => $row['display_name_en'] ?? null,
'display_name_ne' => $row['display_name_ne'] ?? null,
'is_enabled' => (bool) ($row['is_enabled'] ?? true), 'is_enabled' => (bool) ($row['is_enabled'] ?? true),
'min_bet_amount' => (int) ($row['min_bet_amount'] ?? 0), 'min_bet_amount' => (int) ($row['min_bet_amount'] ?? 0),
'max_bet_amount' => (int) ($row['max_bet_amount'] ?? 0), 'max_bet_amount' => (int) ($row['max_bet_amount'] ?? 0),
@@ -183,6 +225,7 @@ final class PlayConfigStreamService
beforeJson: $before, beforeJson: $before,
afterJson: $after, afterJson: $after,
); );
$request?->attributes->set(RecordAdminApiAudit::ATTRIBUTE_AUDIT_RECORDED, true);
} }
/** /**
@@ -223,6 +266,7 @@ final class PlayConfigStreamService
beforeJson: $before, beforeJson: $before,
afterJson: null, afterJson: null,
); );
$request?->attributes->set(RecordAdminApiAudit::ATTRIBUTE_AUDIT_RECORDED, true);
} }
/** @return array<string, mixed> */ /** @return array<string, mixed> */
@@ -278,8 +322,8 @@ final class PlayConfigStreamService
$errors["items.$index.max_bet_amount"][] = '最大下注额不能小于最小下注额'; $errors["items.$index.max_bet_amount"][] = '最大下注额不能小于最小下注额';
} }
if ($row->display_name_zh === null || $row->display_name_zh === '') { if ($row->display_name === null || $row->display_name === '') {
$errors["items.$index.display_name_zh"][] = '显示名称不能为空'; $errors["items.$index.display_name"][] = '显示名称不能为空';
} }
if ($row->display_order === null) { if ($row->display_order === null) {

View File

@@ -8,6 +8,7 @@ use Illuminate\Http\Request;
use App\Services\AuditLogger; use App\Services\AuditLogger;
use App\Models\RiskCapVersion; use App\Models\RiskCapVersion;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use App\Http\Middleware\RecordAdminApiAudit;
use App\Lottery\ConfigVersionStatus; use App\Lottery\ConfigVersionStatus;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Contracts\Pagination\LengthAwarePaginator;
@@ -129,6 +130,7 @@ final class RiskCapStreamService
beforeJson: $before, beforeJson: $before,
afterJson: $after, afterJson: $after,
); );
$request?->attributes->set(RecordAdminApiAudit::ATTRIBUTE_AUDIT_RECORDED, true);
} }
public function deleteVersion(RiskCapVersion $version, AdminUser $admin, ?Request $request = null): void public function deleteVersion(RiskCapVersion $version, AdminUser $admin, ?Request $request = null): void
@@ -149,6 +151,7 @@ final class RiskCapStreamService
beforeJson: $before, beforeJson: $before,
afterJson: null, afterJson: null,
); );
$request?->attributes->set(RecordAdminApiAudit::ATTRIBUTE_AUDIT_RECORDED, true);
} }
/** @return array<string, mixed> */ /** @return array<string, mixed> */

View File

@@ -30,6 +30,19 @@ final class DrawHallSnapshotBuilder
public function effectiveHallDisplayStatus(Draw $target, Carbon $nowUtc): string public function effectiveHallDisplayStatus(Draw $target, Carbon $nowUtc): string
{ {
$db = (string) $target->status; $db = (string) $target->status;
if ($db === DrawStatus::Pending->value) {
$startUtc = $target->start_time;
if ($startUtc instanceof Carbon && $startUtc <= $nowUtc) {
$closeUtc = $target->close_time;
if ($closeUtc === null || $closeUtc > $nowUtc) {
$db = DrawStatus::Open->value;
}
} else {
return $db;
}
}
if ($db !== DrawStatus::Open->value) { if ($db !== DrawStatus::Open->value) {
return $db; return $db;
} }
@@ -62,7 +75,14 @@ final class DrawHallSnapshotBuilder
$nowUtc = ($nowUtc ?? Carbon::now())->utc(); $nowUtc = ($nowUtc ?? Carbon::now())->utc();
$bettingOpen = Draw::query() $bettingOpen = Draw::query()
->where('status', DrawStatus::Open->value) ->where(function ($q) use ($nowUtc): void {
$q->where('status', DrawStatus::Open->value)
->orWhere(function ($q2) use ($nowUtc): void {
$q2->where('status', DrawStatus::Pending->value)
->whereNotNull('start_time')
->where('start_time', '<=', $nowUtc);
});
})
->where(function ($q) use ($nowUtc): void { ->where(function ($q) use ($nowUtc): void {
$q->whereNull('close_time') $q->whereNull('close_time')
->orWhere('close_time', '>', $nowUtc); ->orWhere('close_time', '>', $nowUtc);
@@ -70,6 +90,10 @@ final class DrawHallSnapshotBuilder
->orderBy('draw_time') ->orderBy('draw_time')
->first(); ->first();
if ($bettingOpen !== null) {
return $bettingOpen;
}
$chronological = Draw::query() $chronological = Draw::query()
->whereNotIn('status', [ ->whereNotIn('status', [
DrawStatus::Settled->value, DrawStatus::Settled->value,
@@ -78,7 +102,29 @@ final class DrawHallSnapshotBuilder
->orderBy('draw_time') ->orderBy('draw_time')
->first(); ->first();
return $bettingOpen ?? $chronological; if ($chronological !== null && $this->isCooldownExpired($chronological, $nowUtc)) {
$next = Draw::query()
->whereNotIn('status', [
DrawStatus::Settled->value,
DrawStatus::Cancelled->value,
])
->where('draw_time', '>', $chronological->draw_time)
->orderBy('draw_time')
->first();
if ($next !== null) {
return $next;
}
}
return $chronological;
}
private function isCooldownExpired(Draw $draw, Carbon $nowUtc): bool
{
return (string) $draw->status === DrawStatus::Cooldown->value
&& $draw->cooling_end_time instanceof Carbon
&& $draw->cooling_end_time <= $nowUtc;
} }
/** /**

View File

@@ -31,6 +31,17 @@ final class DrawResultViewService
* consolation: array<int, string> * consolation: array<int, string>
* } * }
*/ */
/** 已发布批次的头奖 4D 号码;未发布或缺失时返回空字符串。 */
public function firstPrizeNumber4dForDraw(Draw $draw): string
{
$summary = $this->summarizeDraw($draw);
if ($summary === null) {
return '';
}
return (string) ($summary['results']['1st'] ?? '');
}
public function numbersFromItems(Collection $items): array public function numbersFromItems(Collection $items): array
{ {
$byType = [ $byType = [

View File

@@ -32,8 +32,10 @@ final class DrawRngRunner
'draw.require_manual_review', 'draw.require_manual_review',
(bool) config('lottery.draw.require_manual_review', false), (bool) config('lottery.draw.require_manual_review', false),
); );
$seedMaterial = bin2hex(random_bytes(32)); $seedHex = DrawRngSeedDerivation::generateSeedHex();
$rngSeedHash = hash('sha256', $seedMaterial); $rngSeedHash = DrawRngSeedDerivation::hashSeedHex($seedHex);
$rawSeedEncrypted = DrawRngSeedDerivation::encryptSeedHex($seedHex);
$derivedRows = DrawRngSeedDerivation::deriveAllSlotRows($seedHex, (int) $draw->id);
$nextVersion = max(1, (int) $draw->current_result_version + 1); $nextVersion = max(1, (int) $draw->current_result_version + 1);
@@ -42,28 +44,24 @@ final class DrawRngRunner
'result_version' => $nextVersion, 'result_version' => $nextVersion,
'source_type' => DrawResultSourceType::Rng->value, 'source_type' => DrawResultSourceType::Rng->value,
'rng_seed_hash' => $rngSeedHash, 'rng_seed_hash' => $rngSeedHash,
'raw_seed_encrypted' => null, 'raw_seed_encrypted' => $rawSeedEncrypted,
'status' => $manualReview ? DrawResultBatchStatus::PendingReview->value : DrawResultBatchStatus::Published->value, 'status' => $manualReview ? DrawResultBatchStatus::PendingReview->value : DrawResultBatchStatus::Published->value,
'created_by' => null, 'created_by' => null,
'confirmed_by' => null, 'confirmed_by' => null,
'confirmed_at' => $manualReview ? null : now(), 'confirmed_at' => $manualReview ? null : now(),
]); ]);
foreach (DrawPrizeLayout::slots() as $slot) { foreach ($derivedRows as $row) {
$num = str_pad((string) random_int(0, 9999), 4, '0', STR_PAD_LEFT);
$suffix3 = substr($num, -3);
$suffix2 = substr($num, -2);
DrawResultItem::query()->create([ DrawResultItem::query()->create([
'draw_id' => $draw->id, 'draw_id' => $draw->id,
'result_batch_id' => $batch->id, 'result_batch_id' => $batch->id,
'prize_type' => $slot['prize_type'], 'prize_type' => $row['prize_type'],
'prize_index' => $slot['prize_index'], 'prize_index' => $row['prize_index'],
'number_4d' => $num, 'number_4d' => $row['number_4d'],
'suffix_3d' => $suffix3, 'suffix_3d' => $row['suffix_3d'],
'suffix_2d' => $suffix2, 'suffix_2d' => $row['suffix_2d'],
'head_digit' => $num !== '' ? (int) substr($num, 0, 1) : null, 'head_digit' => $row['head_digit'],
'tail_digit' => $num !== '' ? (int) substr($num, 3, 1) : null, 'tail_digit' => $row['tail_digit'],
]); ]);
} }

View File

@@ -0,0 +1,126 @@
<?php
namespace App\Services\Draw;
use App\Models\Draw;
use App\Models\DrawResultBatch;
use App\Models\DrawResultItem;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Contracts\Encryption\DecryptException;
/**
* RNG 种子CSPRNG 采集、SHA-256 摘要、Laravel 加密落库、确定性派生 4 位号码(算法 v1
*
* 验收:解密种子 sha256(seed_hex) === rng_seed_hash 复算 23 组号码与 draw_result_items 一致。
*/
final class DrawRngSeedDerivation
{
public const ALGORITHM_VERSION = 'v1';
/** 生成 32 字节随机种子十六进制64 字符) */
public static function generateSeedHex(): string
{
return bin2hex(random_bytes(32));
}
public static function hashSeedHex(string $seedHex): string
{
return hash('sha256', $seedHex);
}
public static function encryptSeedHex(string $seedHex): string
{
return Crypt::encryptString($seedHex);
}
public static function decryptSeedHex(string $encrypted): string
{
try {
return Crypt::decryptString($encrypted);
} catch (DecryptException $e) {
throw new \InvalidArgumentException('RNG seed decrypt failed', 0, $e);
}
}
/**
* 由种子确定性派生第 $slotIndex 槽位的 4 位号码00009999
*/
public static function deriveNumber4d(string $seedHex, int $drawId, int $slotIndex): string
{
$seedBinary = hex2bin($seedHex);
if ($seedBinary === false || strlen($seedBinary) !== 32) {
throw new \InvalidArgumentException('RNG seed must be 64 hex chars (32 bytes).');
}
$message = self::ALGORITHM_VERSION.'|draw:'.$drawId.'|slot:'.$slotIndex;
$digest = hash_hmac('sha256', $message, $seedBinary, true);
$chunk = substr($digest, 0, 4);
$unpacked = unpack('V', $chunk);
$value = ((int) ($unpacked[1] ?? 0)) % 10_000;
return str_pad((string) $value, 4, '0', STR_PAD_LEFT);
}
/**
* @return list<array{prize_type: string, prize_index: int, number_4d: string, suffix_3d: string, suffix_2d: string, head_digit: int|null, tail_digit: int|null}>
*/
public static function deriveAllSlotRows(string $seedHex, int $drawId): array
{
$rows = [];
foreach (DrawPrizeLayout::slots() as $slotIndex => $slot) {
$num = self::deriveNumber4d($seedHex, $drawId, $slotIndex);
$rows[] = [
'prize_type' => $slot['prize_type'],
'prize_index' => $slot['prize_index'],
'number_4d' => $num,
'suffix_3d' => substr($num, -3),
'suffix_2d' => substr($num, -2),
'head_digit' => $num !== '' ? (int) substr($num, 0, 1) : null,
'tail_digit' => $num !== '' ? (int) substr($num, 3, 1) : null,
];
}
return $rows;
}
/** 审计:校验批次种子摘要、密文可解密且号码可由种子复算。 */
public static function verifyBatchAudit(DrawResultBatch $batch, Draw $draw): bool
{
if ($batch->source_type !== 'rng') {
return false;
}
$encrypted = $batch->raw_seed_encrypted;
$hash = $batch->rng_seed_hash;
if (! is_string($encrypted) || $encrypted === '' || ! is_string($hash) || $hash === '') {
return false;
}
try {
$seedHex = self::decryptSeedHex($encrypted);
} catch (\InvalidArgumentException) {
return false;
}
if (self::hashSeedHex($seedHex) !== $hash) {
return false;
}
$expected = self::deriveAllSlotRows($seedHex, (int) $draw->id);
$items = $batch->items()->get();
if ($items->count() !== count($expected)) {
return false;
}
foreach ($expected as $row) {
$item = $items->first(fn (DrawResultItem $i) => $i->prize_type === $row['prize_type']
&& (int) $i->prize_index === $row['prize_index']);
if ($item === null || $item->number_4d !== $row['number_4d']) {
return false;
}
}
return true;
}
}

View File

@@ -7,6 +7,7 @@ use App\Models\Draw;
use App\Lottery\DrawStatus; use App\Lottery\DrawStatus;
use App\Services\LotterySettings; use App\Services\LotterySettings;
use App\Services\Settlement\SettlementOrchestrator; use App\Services\Settlement\SettlementOrchestrator;
use App\Services\Settlement\SettlementTickFinalizer;
/** /**
* 每分钟调度:期号状态推进 RNG若到期号 冷静期结束时进入结算态 补齐未来缓冲。 * 每分钟调度:期号状态推进 RNG若到期号 冷静期结束时进入结算态 补齐未来缓冲。
@@ -21,11 +22,14 @@ final class DrawTickService
private readonly DrawHallSnapshotBuilder $hallSnapshot, private readonly DrawHallSnapshotBuilder $hallSnapshot,
private readonly LotteryHallRealtimeBroadcaster $hallRealtime, private readonly LotteryHallRealtimeBroadcaster $hallRealtime,
private readonly SettlementOrchestrator $settlementOrchestrator, private readonly SettlementOrchestrator $settlementOrchestrator,
private readonly SettlementTickFinalizer $settlementFinalizer,
) {} ) {}
/** /**
* @return array{ * @return array{
* status_updates: array<string, int>, * status_updates: array<string, int>,
* settling_settled: int,
* settlement_finalized: array{approved: int, paid: int},
* rng_rung: int, * rng_rung: int,
* rng_errors: array<int, string>, * rng_errors: array<int, string>,
* planned: array<string, int> * planned: array<string, int>
@@ -45,6 +49,7 @@ final class DrawTickService
]; ];
$settlingSettled = $this->settleSettlingDraws(); $settlingSettled = $this->settleSettlingDraws();
$settlementFinalized = $this->settlementFinalizer->finalizePendingBatches();
$rngOutcome = $this->rng->runDue($nowUtc); $rngOutcome = $this->rng->runDue($nowUtc);
$planned = $this->planner->ensureBuffer($nowUtc); $planned = $this->planner->ensureBuffer($nowUtc);
@@ -52,6 +57,7 @@ final class DrawTickService
$report = [ $report = [
'status_updates' => $statusUpdates, 'status_updates' => $statusUpdates,
'settling_settled' => $settlingSettled, 'settling_settled' => $settlingSettled,
'settlement_finalized' => $settlementFinalized,
'rng_rung' => $rngOutcome['rung'], 'rng_rung' => $rngOutcome['rung'],
'rng_errors' => $rngOutcome['errors'], 'rng_errors' => $rngOutcome['errors'],
'planned' => $planned, 'planned' => $planned,

View File

@@ -22,7 +22,7 @@ final class LotteryHallRealtimeBroadcaster
private readonly DrawHallSnapshotBuilder $snapshot, private readonly DrawHallSnapshotBuilder $snapshot,
) {} ) {}
/** 每秒调度:`draw.countdown` 仅发送轻量心跳,不重查全量大厅快照。 */ /** 每秒调度:`draw.countdown` 推送大厅快照(与 GET draw/current 一致),避免仅本地倒计时无法切期。 */
public function countdownPulse(): void public function countdownPulse(): void
{ {
if (! $this->driverSupportsRealtime()) { if (! $this->driverSupportsRealtime()) {
@@ -31,7 +31,7 @@ final class LotteryHallRealtimeBroadcaster
$ms = (int) floor(microtime(true) * 1000); $ms = (int) floor(microtime(true) * 1000);
broadcast(new DrawCountdownBroadcast(null, $ms)); broadcast(new DrawCountdownBroadcast($this->snapshot->build(), $ms));
} }
/** /**

View File

@@ -10,7 +10,7 @@ use App\Models\JackpotPayoutLog;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
/** /**
* 产品文档 §5.11.25.11.3:中头奖且满足阈值或连续未爆期数 按比例释放奖池,按注项 `total_bet_amount` 比例分配。 * 产品文档 §5.11.25.11.3:中头奖且满足阈值或连续未爆期数 按比例/全额释放奖池,按注项 `total_bet_amount` 比例分配。
*/ */
final class JackpotBurstAllocator final class JackpotBurstAllocator
{ {
@@ -36,36 +36,72 @@ final class JackpotBurstAllocator
} }
$trigger = $thresholdOk ? 'threshold' : ($gapOk ? 'forced_gap' : 'play_combo'); $trigger = $thresholdOk ? 'threshold' : ($gapOk ? 'forced_gap' : 'play_combo');
$releaseFullPool = $trigger === 'forced_gap';
$winnerItems = $winners->map(fn (array $r): TicketItem => $r['item'])->values();
return $this->burstToWinners(
$draw,
$pool,
$winnerItems,
$trigger,
$releaseFullPool,
[
'threshold_ok' => $thresholdOk,
'gap_ok' => $gapOk,
'combo_ok' => $comboOk,
'combo_trigger_play_codes' => $this->comboTriggerPlayCodes($pool),
],
);
}
/**
* 超管手动爆池:跳过头奖触发条件校验,仍要求存在头奖中奖注单,并按配置派彩比例释放奖池。
*
* @param Collection<int, TicketItem> $winnerItems
* @return array{allocations: array<int, int>, pool_payout: int, trigger: string, log_id: int}
*/
public function burstManual(Draw $draw, JackpotPool $pool, Collection $winnerItems): array
{
if ($winnerItems->isEmpty()) {
throw new \RuntimeException('jackpot_manual_no_first_prize_winners');
}
$out = $this->burstToWinners($draw, $pool, $winnerItems, 'manual', false, ['manual' => true]);
return [
'allocations' => $out['allocations'],
'pool_payout' => $out['pool_payout'],
'trigger' => 'manual',
'log_id' => (int) ($out['log_id'] ?? 0),
];
}
/**
* @param Collection<int, TicketItem> $winnerItems
* @param array<string, mixed> $snapshotExtra
* @return array{allocations: array<int, int>, pool_payout: int, trigger: string, log_id: int}
*/
private function burstToWinners(
Draw $draw,
JackpotPool $pool,
Collection $winnerItems,
string $trigger,
bool $releaseFullPool,
array $snapshotExtra,
): array {
$poolBefore = (int) $pool->current_amount; $poolBefore = (int) $pool->current_amount;
$poolPayout = (int) floor($poolBefore * (float) $pool->payout_rate); $poolPayout = $releaseFullPool
? $poolBefore
: (int) floor($poolBefore * (float) $pool->payout_rate);
if ($poolPayout <= 0) { if ($poolPayout <= 0) {
return ['allocations' => [], 'pool_payout' => 0, 'trigger' => null]; return ['allocations' => [], 'pool_payout' => 0, 'trigger' => $trigger, 'log_id' => 0];
} }
$list = $winners->values()->all(); $allocations = $this->distributeByBetWeight($winnerItems, $poolPayout);
$weightTotal = 0; if ($allocations === []) {
foreach ($list as $r) { return ['allocations' => [], 'pool_payout' => 0, 'trigger' => $trigger, 'log_id' => 0];
$weightTotal += (int) $r['item']->total_bet_amount;
}
if ($weightTotal <= 0) {
return ['allocations' => [], 'pool_payout' => 0, 'trigger' => null];
}
$allocations = [];
$remaining = $poolPayout;
$n = count($list);
foreach ($list as $idx => $r) {
/** @var TicketItem $item */
$item = $r['item'];
$w = (int) $item->total_bet_amount;
if ($idx === $n - 1) {
$share = max(0, $remaining);
} else {
$share = (int) floor($poolPayout * $w / $weightTotal);
$remaining -= $share;
}
$allocations[(int) $item->id] = $share;
} }
$pool->forceFill([ $pool->forceFill([
@@ -73,23 +109,59 @@ final class JackpotBurstAllocator
'last_trigger_draw_id' => $draw->id, 'last_trigger_draw_id' => $draw->id,
])->save(); ])->save();
JackpotPayoutLog::query()->create([ $log = JackpotPayoutLog::query()->create([
'draw_id' => $draw->id, 'draw_id' => $draw->id,
'jackpot_pool_id' => $pool->id, 'jackpot_pool_id' => $pool->id,
'trigger_type' => $trigger, 'trigger_type' => $trigger,
'total_payout_amount' => $poolPayout, 'total_payout_amount' => $poolPayout,
'winner_count' => count($allocations), 'winner_count' => count($allocations),
'trigger_snapshot_json' => [ 'trigger_snapshot_json' => array_merge($snapshotExtra, [
'threshold_ok' => $thresholdOk,
'gap_ok' => $gapOk,
'combo_ok' => $comboOk,
'combo_trigger_play_codes' => $this->comboTriggerPlayCodes($pool),
'pool_amount_before' => $poolBefore, 'pool_amount_before' => $poolBefore,
'payout_rate' => (string) $pool->payout_rate, 'payout_rate' => (string) $pool->payout_rate,
], 'release_full_pool' => $releaseFullPool,
]),
]); ]);
return ['allocations' => $allocations, 'pool_payout' => $poolPayout, 'trigger' => $trigger]; return [
'allocations' => $allocations,
'pool_payout' => $poolPayout,
'trigger' => $trigger,
'log_id' => (int) $log->id,
];
}
/**
* @param Collection<int, TicketItem> $winnerItems
* @return array<int, int>
*/
private function distributeByBetWeight(Collection $winnerItems, int $poolPayout): array
{
$list = $winnerItems->values()->all();
$weightTotal = 0;
foreach ($list as $item) {
$weightTotal += (int) $item->total_bet_amount;
}
if ($weightTotal <= 0) {
return [];
}
$allocations = [];
$remaining = $poolPayout;
$n = count($list);
foreach ($list as $idx => $item) {
$w = (int) $item->total_bet_amount;
if ($idx === $n - 1) {
$share = max(0, $remaining);
} else {
$share = (int) floor($poolPayout * $w / $weightTotal);
$remaining -= $share;
}
if ($share > 0) {
$allocations[(int) $item->id] = $share;
}
}
return $allocations;
} }
private function gapTriggerMet(JackpotPool $pool): bool private function gapTriggerMet(JackpotPool $pool): bool

View File

@@ -0,0 +1,264 @@
<?php
namespace App\Services\Jackpot;
use App\Models\Draw;
use App\Models\Player;
use App\Models\TicketItem;
use App\Lottery\DrawStatus;
use App\Models\JackpotPool;
use App\Models\JackpotPayoutLog;
use App\Models\SettlementBatch;
use App\Models\TicketSettlementDetail;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use App\Lottery\SettlementBatchStatus;
use App\Lottery\DrawResultBatchStatus;
use App\Models\DrawResultBatch;
use App\Services\Draw\DrawHallSnapshotBuilder;
use App\Services\Draw\DrawResultViewService;
use App\Services\Draw\LotteryHallRealtimeBroadcaster;
use App\Services\Ticket\TicketWalletService;
/**
* 产品文档:超管紧急手动爆池 —— 对已结算期号的头奖中奖者按奖池派彩比例分配,合并入账并广播动画。
*/
final class JackpotManualBurstService
{
public function __construct(
private readonly JackpotBurstAllocator $allocator,
private readonly TicketWalletService $wallet,
private readonly LotteryHallRealtimeBroadcaster $hallRealtime,
private readonly DrawHallSnapshotBuilder $hallSnapshot,
private readonly DrawResultViewService $drawResults,
) {}
/**
* @return array{
* current_amount: int,
* burst_amount: int,
* log_id: int|null,
* winner_count: int,
* draw_no: string,
* wallet_credited: bool
* }
*/
public function execute(JackpotPool $pool, int $drawId): array
{
return DB::transaction(function () use ($pool, $drawId): array {
/** @var JackpotPool $locked */
$locked = JackpotPool::query()->whereKey($pool->id)->lockForUpdate()->firstOrFail();
if ((int) $locked->status !== 1) {
throw new \RuntimeException('jackpot_disabled');
}
if ((int) $locked->current_amount <= 0) {
throw new \RuntimeException('jackpot_pool_empty');
}
$draw = Draw::query()->whereKey($drawId)->firstOrFail();
$this->assertDrawReady($draw);
if (JackpotPayoutLog::query()
->where('jackpot_pool_id', $locked->id)
->where('draw_id', $drawId)
->exists()) {
throw new \RuntimeException('jackpot_already_burst_for_draw');
}
$batch = $this->resolveSettlementBatch($draw);
$winnerItems = $this->firstPrizeWinnerItems($batch);
if ($winnerItems->isEmpty()) {
throw new \RuntimeException('jackpot_manual_no_first_prize_winners');
}
$existingJackpot = (int) $batch->total_jackpot_payout_amount;
if ($existingJackpot > 0) {
throw new \RuntimeException('jackpot_already_allocated_for_draw');
}
$burst = $this->allocator->burstManual($draw, $locked, $winnerItems);
$poolPayout = (int) $burst['pool_payout'];
if ($poolPayout <= 0) {
return [
'current_amount' => (int) $locked->current_amount,
'burst_amount' => 0,
'log_id' => null,
'winner_count' => 0,
'draw_no' => (string) $draw->draw_no,
'wallet_credited' => false,
];
}
$allocations = $burst['allocations'];
$this->applyAllocationsToSettlement($batch, $allocations);
$walletCredited = $this->creditWalletsIfAlreadyPaid($batch, $allocations, (int) $burst['log_id'], $locked->currency_code);
$locked->refresh();
$firstPrizeNumber = $this->drawResults->firstPrizeNumber4dForDraw($draw);
if ($firstPrizeNumber === '') {
$firstPrizeNumber = '----';
}
$this->hallRealtime->notifyJackpotBurst(
(int) $draw->id,
(string) $draw->draw_no,
$firstPrizeNumber,
(string) $locked->currency_code,
$poolPayout,
count($allocations),
'manual',
(int) $locked->current_amount,
);
$this->hallRealtime->notifyStatusChange($this->hallSnapshot->build());
return [
'current_amount' => (int) $locked->current_amount,
'burst_amount' => $poolPayout,
'log_id' => (int) $burst['log_id'],
'winner_count' => count($allocations),
'draw_no' => (string) $draw->draw_no,
'wallet_credited' => $walletCredited,
];
});
}
private function assertDrawReady(Draw $draw): void
{
$allowed = [
DrawStatus::Settling->value,
DrawStatus::Settled->value,
];
if (! in_array($draw->status, $allowed, true)) {
throw new \RuntimeException('draw_not_ready_for_jackpot_burst');
}
$hasPublished = DrawResultBatch::query()
->where('draw_id', $draw->id)
->where('status', DrawResultBatchStatus::Published->value)
->where('result_version', (int) $draw->current_result_version)
->exists();
if (! $hasPublished) {
throw new \RuntimeException('draw_result_not_published');
}
}
private function resolveSettlementBatch(Draw $draw): SettlementBatch
{
$batch = SettlementBatch::query()
->where('draw_id', $draw->id)
->whereIn('status', [
SettlementBatchStatus::PendingReview->value,
SettlementBatchStatus::Approved->value,
SettlementBatchStatus::Paid->value,
SettlementBatchStatus::Completed->value,
])
->orderByDesc('id')
->first();
if ($batch === null) {
throw new \RuntimeException('settlement_batch_not_found');
}
return $batch;
}
/**
* @return Collection<int, TicketItem>
*/
private function firstPrizeWinnerItems(SettlementBatch $batch): Collection
{
$details = TicketSettlementDetail::query()
->where('settlement_batch_id', $batch->id)
->where('matched_prize_tier', 'first')
->where('win_amount', '>', 0)
->with('ticketItem')
->get();
return $details
->map(fn (TicketSettlementDetail $d) => $d->ticketItem)
->filter(fn (?TicketItem $item): bool => $item instanceof TicketItem)
->values();
}
/**
* @param array<int, int> $allocations
*/
private function applyAllocationsToSettlement(SettlementBatch $batch, array $allocations): void
{
$addedJackpot = 0;
foreach ($allocations as $ticketItemId => $share) {
$detail = TicketSettlementDetail::query()
->where('settlement_batch_id', $batch->id)
->where('ticket_item_id', $ticketItemId)
->first();
if ($detail === null) {
continue;
}
$detail->forceFill(['jackpot_allocation_amount' => $share])->save();
$item = $detail->ticketItem;
if ($item !== null) {
$item->forceFill(['jackpot_win_amount' => $share])->save();
}
$addedJackpot += $share;
}
if ($addedJackpot > 0) {
$batch->forceFill([
'total_jackpot_payout_amount' => (int) $batch->total_jackpot_payout_amount + $addedJackpot,
'total_payout_amount' => (int) $batch->total_payout_amount + $addedJackpot,
])->save();
}
}
/**
* 若结算批次已派彩,则补发 Jackpot 份额到玩家钱包。
*
* @param array<int, int> $allocations
*/
private function creditWalletsIfAlreadyPaid(
SettlementBatch $batch,
array $allocations,
int $jackpotLogId,
string $currencyCode,
): bool {
if (! in_array($batch->status, [SettlementBatchStatus::Paid->value, SettlementBatchStatus::Completed->value], true)) {
return false;
}
$playerTotals = [];
foreach ($allocations as $ticketItemId => $share) {
if ($share <= 0) {
continue;
}
$item = TicketItem::query()->whereKey($ticketItemId)->first();
if ($item === null) {
continue;
}
$pid = (int) $item->player_id;
$playerTotals[$pid] = ($playerTotals[$pid] ?? 0) + $share;
}
foreach ($playerTotals as $playerId => $amount) {
$player = Player::query()->whereKey($playerId)->firstOrFail();
$this->wallet->creditJackpotManualPayout(
$player,
$currencyCode,
$amount,
(int) $batch->id,
$jackpotLogId,
);
}
return $playerTotals !== [];
}
}

View File

@@ -21,7 +21,17 @@ final class SettlementBatchWorkflowService
public function approve(SettlementBatch $batch, AdminUser $admin, ?string $remark = null): SettlementBatch public function approve(SettlementBatch $batch, AdminUser $admin, ?string $remark = null): SettlementBatch
{ {
return DB::transaction(function () use ($batch, $admin, $remark): SettlementBatch { return $this->approveInternal($batch, $admin->id, $remark);
}
public function approveBySystem(SettlementBatch $batch, ?string $remark = null): SettlementBatch
{
return $this->approveInternal($batch, null, $remark);
}
private function approveInternal(SettlementBatch $batch, ?int $reviewedBy, ?string $remark): SettlementBatch
{
return DB::transaction(function () use ($batch, $reviewedBy, $remark): SettlementBatch {
/** @var SettlementBatch $locked */ /** @var SettlementBatch $locked */
$locked = SettlementBatch::query()->whereKey($batch->id)->lockForUpdate()->firstOrFail(); $locked = SettlementBatch::query()->whereKey($batch->id)->lockForUpdate()->firstOrFail();
if ($locked->status !== SettlementBatchStatus::PendingReview->value) { if ($locked->status !== SettlementBatchStatus::PendingReview->value) {
@@ -31,7 +41,7 @@ final class SettlementBatchWorkflowService
$locked->forceFill([ $locked->forceFill([
'status' => SettlementBatchStatus::Approved->value, 'status' => SettlementBatchStatus::Approved->value,
'review_status' => 'approved', 'review_status' => 'approved',
'reviewed_by' => $admin->id, 'reviewed_by' => $reviewedBy,
'reviewed_at' => now(), 'reviewed_at' => now(),
'review_remark' => $remark, 'review_remark' => $remark,
])->save(); ])->save();

View File

@@ -13,6 +13,7 @@ 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\Draw\LotteryHallRealtimeBroadcaster;
use App\Services\Ticket\RiskPoolService; use App\Services\Ticket\RiskPoolService;
use App\Services\Jackpot\JackpotBurstAllocator; use App\Services\Jackpot\JackpotBurstAllocator;
@@ -30,6 +31,7 @@ final class SettlementOrchestrator
private readonly JackpotBurstAllocator $jackpotBurst, private readonly JackpotBurstAllocator $jackpotBurst,
private readonly RiskPoolService $riskPool, private readonly RiskPoolService $riskPool,
private readonly LotteryHallRealtimeBroadcaster $hallRealtime, private readonly LotteryHallRealtimeBroadcaster $hallRealtime,
private readonly DrawHallSnapshotBuilder $hallSnapshot,
) {} ) {}
/** /**
@@ -214,6 +216,7 @@ final class SettlementOrchestrator
$jackpotTrigger, $jackpotTrigger,
(int) $jackpotPoolAfter, (int) $jackpotPoolAfter,
); );
$this->hallRealtime->notifyStatusChange($this->hallSnapshot->build());
} }
return true; return true;

View File

@@ -0,0 +1,65 @@
<?php
namespace App\Services\Settlement;
use App\Models\SettlementBatch;
use App\Lottery\SettlementBatchStatus;
use App\Services\AuditLogger;
use App\Services\LotterySettings;
/**
* draw tick 在自动结算后,按系统设置自动审核并派彩入账。
*/
final class SettlementTickFinalizer
{
public function __construct(
private readonly SettlementBatchWorkflowService $workflow,
) {}
/** @return array{approved: int, paid: int} */
public function finalizePendingBatches(): array
{
$approved = 0;
$paid = 0;
if (! (bool) LotterySettings::get('settlement.auto_approve_on_tick', true)) {
return ['approved' => 0, 'paid' => 0];
}
$pending = SettlementBatch::query()
->where('status', SettlementBatchStatus::PendingReview->value)
->orderBy('id')
->get();
foreach ($pending as $batch) {
try {
$this->workflow->approveBySystem($batch, 'auto approve on draw tick');
$approved++;
} catch (\Throwable $e) {
report($e);
continue;
}
if (! (bool) LotterySettings::get('settlement.auto_payout_on_tick', true)) {
continue;
}
try {
$this->workflow->payout($batch->fresh());
$paid++;
AuditLogger::recordForSystem(
moduleCode: 'settlement',
actionCode: 'auto_payout',
targetType: 'settlement_batch',
targetId: (string) $batch->id,
afterJson: ['draw_id' => (int) $batch->draw_id],
);
} catch (\Throwable $e) {
report($e);
}
}
return ['approved' => $approved, 'paid' => $paid];
}
}

View File

@@ -119,6 +119,71 @@ final class TicketWalletService
/** /**
* 结算派彩入账(产品文档:派彩写入钱包流水;幂等键按结算批次 + 玩家)。 * 结算派彩入账(产品文档:派彩写入钱包流水;幂等键按结算批次 + 玩家)。
*/ */
/**
* 手动爆池补发:结算批次已派彩后,将 Jackpot 份额追加入账(幂等键按批次 + 玩家 + 爆池日志)。
*/
public function creditJackpotManualPayout(
Player $player,
string $currencyCode,
int $amountMinor,
int $settlementBatchId,
int $jackpotPayoutLogId,
): void {
if ($amountMinor <= 0) {
return;
}
$idempotentKey = 'jackpot-manual:'.$settlementBatchId.':'.$player->id.':'.$jackpotPayoutLogId;
if (WalletTxn::query()->where('idempotent_key', $idempotentKey)->exists()) {
return;
}
$currency = strtoupper($currencyCode);
$wallet = PlayerWallet::query()
->where('player_id', $player->id)
->where('wallet_type', 'lottery')
->where('currency_code', $currency)
->lockForUpdate()
->first();
if ($wallet === null) {
$wallet = PlayerWallet::query()->create([
'player_id' => $player->id,
'wallet_type' => 'lottery',
'currency_code' => $currency,
'balance' => 0,
'frozen_balance' => 0,
'status' => 0,
'version' => 0,
]);
$wallet = PlayerWallet::query()->whereKey($wallet->id)->lockForUpdate()->firstOrFail();
}
$before = (int) $wallet->balance;
$after = $before + $amountMinor;
$wallet->forceFill([
'balance' => $after,
'version' => (int) $wallet->version + 1,
])->save();
WalletTxn::query()->create([
'txn_no' => $this->newTxnNo(),
'player_id' => $player->id,
'wallet_id' => $wallet->id,
'biz_type' => 'jackpot_manual_payout',
'biz_no' => 'JP'.$jackpotPayoutLogId,
'direction' => self::TXN_DIR_IN,
'amount' => $amountMinor,
'balance_before' => $before,
'balance_after' => $after,
'status' => self::TXN_POSTED,
'external_ref_no' => null,
'idempotent_key' => $idempotentKey,
'remark' => 'manual_jackpot_burst',
]);
}
public function creditSettlementPayout(Player $player, string $currencyCode, int $amountMinor, int $settlementBatchId): void public function creditSettlementPayout(Player $player, string $currencyCode, int $amountMinor, int $settlementBatchId): void
{ {
if ($amountMinor <= 0) { if ($amountMinor <= 0) {

View File

@@ -20,32 +20,40 @@ final class AdminAuthorizationRegistry
public static function permissionDefinitions(): array public static function permissionDefinitions(): array
{ {
return [ return [
['slug' => 'prd.dashboard.view', 'name' => '仪表盘·查看', 'nav_segment' => 'dashboard', 'permission_codes' => ['dashboard.view']],
['slug' => 'prd.admin_user.manage', 'name' => '管理员列表·可管理', 'nav_segment' => 'admin_users', 'permission_codes' => ['system.admin_user.manage']], ['slug' => 'prd.admin_user.manage', 'name' => '管理员列表·可管理', 'nav_segment' => 'admin_users', 'permission_codes' => ['system.admin_user.manage']],
['slug' => 'prd.admin_role.manage', 'name' => '角色管理·可管理', 'nav_segment' => 'admin_roles', 'permission_codes' => ['system.admin_role.manage']], ['slug' => 'prd.admin_role.manage', 'name' => '角色管理·可管理', 'nav_segment' => 'admin_roles', 'permission_codes' => ['system.admin_role.manage']],
['slug' => 'prd.users.manage', 'name' => '用户管理·可管理', 'nav_segment' => 'players', 'permission_codes' => ['service.players.manage']], ['slug' => 'prd.users.manage', 'name' => '用户管理·可管理', 'nav_segment' => 'players', 'permission_codes' => ['service.players.manage']],
['slug' => 'prd.users.view_finance', 'name' => '用户管理·财务查看', 'nav_segment' => 'players', 'permission_codes' => ['service.players.view', 'service.wallet.view']], ['slug' => 'prd.users.view_finance', 'name' => '用户管理·财务查看', 'nav_segment' => 'players', 'permission_codes' => ['service.players.view', 'service.wallet.view']],
['slug' => 'prd.users.view_cs', 'name' => '用户管理·客服单用户', 'nav_segment' => 'players', 'permission_codes' => ['service.players.view', 'service.tickets.view']], ['slug' => 'prd.users.view_cs', 'name' => '用户管理·客服单用户', 'nav_segment' => 'players', 'permission_codes' => ['service.players.view']],
['slug' => 'prd.tickets.view', 'name' => '玩家注单·查看', 'nav_segment' => 'tickets', 'permission_codes' => ['service.tickets.view']],
['slug' => 'prd.player_freeze.manage', 'name' => '冻结/解冻玩家·可管理', 'nav_segment' => 'players', 'permission_codes' => ['service.players.freeze']], ['slug' => 'prd.player_freeze.manage', 'name' => '冻结/解冻玩家·可管理', 'nav_segment' => 'players', 'permission_codes' => ['service.players.freeze']],
['slug' => 'prd.currency.manage', 'name' => '币种管理·可管理', 'nav_segment' => 'settings', 'permission_codes' => ['service.currency.manage']], ['slug' => 'prd.currency.manage', 'name' => '币种管理·可管理', 'nav_segment' => 'settings', 'permission_codes' => ['service.currency.manage']],
['slug' => 'prd.wallet_reconcile.manage', 'name' => '钱包对账·可管理', 'nav_segment' => 'wallet', 'permission_codes' => ['service.wallet.manage', 'service.reconcile.manage']], ['slug' => 'prd.wallet_reconcile.manage', 'name' => '钱包对账·可管理', 'nav_segment' => 'wallet', 'permission_codes' => ['service.wallet.manage', 'service.reconcile.manage', 'service.wallet.adjust']],
['slug' => 'prd.wallet_reconcile.view', 'name' => '钱包对账·查看', 'nav_segment' => 'wallet', 'permission_codes' => ['service.wallet.view', 'service.reconcile.view']], ['slug' => 'prd.wallet_reconcile.view', 'name' => '钱包对账·查看', 'nav_segment' => 'wallet', 'permission_codes' => ['service.wallet.view', 'service.reconcile.view']],
['slug' => 'prd.wallet_reconcile.view_cs', 'name' => '钱包对账·客服单用户', 'nav_segment' => 'wallet', 'permission_codes' => ['service.wallet.view', 'service.reconcile.view']], ['slug' => 'prd.wallet_reconcile.view_cs', 'name' => '钱包对账·客服单用户', 'nav_segment' => 'wallet', 'permission_codes' => ['service.wallet.view', 'service.reconcile.view']],
['slug' => 'prd.wallet_adjust.manage', 'name' => '补单/冲正·可管理', 'nav_segment' => 'wallet', 'permission_codes' => ['service.wallet.manage']], ['slug' => 'prd.wallet_adjust.manage', 'name' => '补单/冲正·可管理', 'nav_segment' => 'wallet', 'permission_codes' => ['service.wallet.adjust']],
['slug' => 'prd.draw_result.manage', 'name' => '开奖结果录入·可管理', 'nav_segment' => 'draws', 'permission_codes' => ['draw.results.view', 'draw.review.review', 'draw.review.publish', 'risk.monitor.view']], ['slug' => 'prd.draw_result.manage', 'name' => '开奖结果录入·可管理', 'nav_segment' => 'draws', 'permission_codes' => ['draw.results.view', 'draw.review.review', 'draw.review.publish']],
['slug' => 'prd.draw_result.view', 'name' => '开奖结果·查看', 'nav_segment' => 'draws', 'permission_codes' => ['draw.results.view', 'risk.monitor.view']], ['slug' => 'prd.draw_result.view', 'name' => '开奖结果·查看', 'nav_segment' => 'draws', 'permission_codes' => ['draw.results.view']],
['slug' => 'prd.draw_reopen.manage', 'name' => '开奖结果重开·可管理', 'nav_segment' => 'draws', 'permission_codes' => ['draw.review.publish']], ['slug' => 'prd.draw_reopen.manage', 'name' => '开奖结果重开·可管理', 'nav_segment' => 'draws', 'permission_codes' => ['draw.review.publish']],
['slug' => 'prd.risk.view', 'name' => '风控中心·查看', 'nav_segment' => 'risk', 'permission_codes' => ['risk.monitor.view']],
['slug' => 'prd.risk.manage', 'name' => '风控中心·可管理', 'nav_segment' => 'risk', 'permission_codes' => ['risk.monitor.manage']],
['slug' => 'prd.play_switch.manage', 'name' => '玩法开关·可管理', 'nav_segment' => 'rules_plays', 'permission_codes' => ['config.play.manage']], ['slug' => 'prd.play_switch.manage', 'name' => '玩法开关·可管理', 'nav_segment' => 'rules_plays', 'permission_codes' => ['config.play.manage']],
['slug' => 'prd.odds.manage', 'name' => '赔率配置·可管理', 'nav_segment' => 'rules_odds', 'permission_codes' => ['config.odds.manage']], ['slug' => 'prd.odds.manage', 'name' => '赔率配置·可管理', 'nav_segment' => 'rules_odds', 'permission_codes' => ['config.odds.manage']],
['slug' => 'prd.odds.view', 'name' => '赔率配置·查看', 'nav_segment' => 'rules_odds', 'permission_codes' => ['config.odds.view']],
['slug' => 'prd.risk_cap.manage', 'name' => '封顶配置·可管理', 'nav_segment' => 'risk_cap', 'permission_codes' => ['config.risk_cap.manage']], ['slug' => 'prd.risk_cap.manage', 'name' => '封顶配置·可管理', 'nav_segment' => 'risk_cap', 'permission_codes' => ['config.risk_cap.manage']],
['slug' => 'prd.risk_cap.view', 'name' => '封顶配置·查看', 'nav_segment' => 'risk_cap', 'permission_codes' => ['config.risk_cap.view']], ['slug' => 'prd.risk_cap.view', 'name' => '封顶配置·查看', 'nav_segment' => 'risk_cap', 'permission_codes' => ['config.risk_cap.view']],
['slug' => 'prd.rebate.manage', 'name' => '佣金/回水·可管理', 'nav_segment' => 'rules_odds', 'permission_codes' => ['config.odds.manage']], ['slug' => 'prd.rebate.manage', 'name' => '佣金/回水·可管理', 'nav_segment' => 'rules_odds', 'permission_codes' => ['config.odds.manage']],
['slug' => 'prd.rebate.view', 'name' => '佣金/回水·查看', 'nav_segment' => 'rules_odds', 'permission_codes' => ['config.odds.manage']], ['slug' => 'prd.rebate.view', 'name' => '佣金/回水·查看', 'nav_segment' => 'rules_odds', 'permission_codes' => ['config.odds.view']],
['slug' => 'prd.jackpot.manage', 'name' => 'Jackpot 配置·可管理', 'nav_segment' => 'jackpot', 'permission_codes' => ['config.jackpot.manage']], ['slug' => 'prd.jackpot.manage', 'name' => 'Jackpot 配置·可管理', 'nav_segment' => 'jackpot', 'permission_codes' => ['config.jackpot.manage']],
['slug' => 'prd.jackpot.view', 'name' => 'Jackpot 配置·查看', 'nav_segment' => 'jackpot', 'permission_codes' => ['config.jackpot.view']], ['slug' => 'prd.jackpot.view', 'name' => 'Jackpot 配置·查看', 'nav_segment' => 'jackpot', 'permission_codes' => ['config.jackpot.view']],
['slug' => 'prd.jackpot.manual_burst', 'name' => 'Jackpot 手动爆池·仅超管', 'nav_segment' => 'jackpot', 'permission_codes' => ['jackpot.pool.manual_burst']],
['slug' => 'prd.payout.manage', 'name' => '派彩确认·可管理', 'nav_segment' => 'settlement', 'permission_codes' => ['settlement.batch.manage', 'settlement.batch.view']], ['slug' => 'prd.payout.manage', 'name' => '派彩确认·可管理', 'nav_segment' => 'settlement', 'permission_codes' => ['settlement.batch.manage', 'settlement.batch.view']],
['slug' => 'prd.payout.review', 'name' => '派彩确认·可审核', 'nav_segment' => 'settlement', 'permission_codes' => ['settlement.batch.review', 'settlement.batch.view']], ['slug' => 'prd.payout.review', 'name' => '派彩确认·可审核', 'nav_segment' => 'settlement', 'permission_codes' => ['settlement.batch.review', 'settlement.batch.view']],
@@ -54,6 +62,7 @@ final class AdminAuthorizationRegistry
['slug' => 'prd.audit.view', 'name' => '审计日志·查看', 'nav_segment' => 'audit', 'permission_codes' => ['service.audit.view']], ['slug' => 'prd.audit.view', 'name' => '审计日志·查看', 'nav_segment' => 'audit', 'permission_codes' => ['service.audit.view']],
['slug' => 'prd.report.view', 'name' => '报表中心·查看', 'nav_segment' => 'reports', 'permission_codes' => ['service.report.view']], ['slug' => 'prd.report.view', 'name' => '报表中心·查看', 'nav_segment' => 'reports', 'permission_codes' => ['service.report.view']],
['slug' => 'prd.report.export', 'name' => '报表中心·导出', 'nav_segment' => 'reports', 'permission_codes' => ['service.report.export']],
]; ];
} }
@@ -110,13 +119,13 @@ final class AdminAuthorizationRegistry
{ {
return [ return [
// 总览 // 总览
['segment' => 'dashboard', 'label' => 'Dashboard', 'href' => '/admin'], ['segment' => 'dashboard', 'label' => 'Dashboard', 'href' => '/admin', 'requiredAny' => ['prd.dashboard.view']],
// 日常运营:开奖 → 注单 → 玩家 // 日常运营:开奖 → 注单 → 玩家
['segment' => 'draws', 'label' => 'Draws', 'href' => '/admin/draws', 'requiredAny' => ['prd.draw_result.manage', 'prd.draw_result.view', 'prd.draw_reopen.manage']], ['segment' => 'draws', 'label' => 'Draws', 'href' => '/admin/draws', 'requiredAny' => ['prd.draw_result.manage', 'prd.draw_result.view', 'prd.draw_reopen.manage']],
['segment' => 'tickets', 'label' => 'Tickets', 'href' => '/admin/tickets', 'requiredAny' => ['prd.users.view_cs', 'prd.users.manage', 'prd.users.view_finance', 'prd.draw_result.view', 'prd.draw_result.manage', 'prd.payout.view', 'prd.payout.review', 'prd.payout.manage']], ['segment' => 'tickets', 'label' => 'Tickets', 'href' => '/admin/tickets', 'requiredAny' => ['prd.tickets.view']],
['segment' => 'players', 'label' => 'Players', 'href' => '/admin/players', 'requiredAny' => ['prd.users.manage', 'prd.users.view_finance', 'prd.users.view_cs', 'prd.player_freeze.manage']], ['segment' => 'players', 'label' => 'Players', 'href' => '/admin/players', 'requiredAny' => ['prd.users.manage', 'prd.users.view_finance', 'prd.users.view_cs', 'prd.player_freeze.manage']],
// 规则与参数 // 规则与参数
['segment' => 'rules_plays', 'label' => 'Play rules', 'href' => '/admin/rules/plays', 'requiredAny' => ['prd.play_switch.manage', 'prd.odds.manage']], ['segment' => 'rules_plays', 'label' => 'Play rules', 'href' => '/admin/rules/plays', 'requiredAny' => ['prd.play_switch.manage', 'prd.odds.manage', 'prd.odds.view']],
['segment' => 'rules_odds', 'label' => 'Odds & rebate', 'href' => '/admin/rules/odds', 'requiredAny' => ['prd.odds.manage', 'prd.rebate.manage', 'prd.rebate.view']], ['segment' => 'rules_odds', 'label' => 'Odds & rebate', 'href' => '/admin/rules/odds', 'requiredAny' => ['prd.odds.manage', 'prd.rebate.manage', 'prd.rebate.view']],
['segment' => 'jackpot', 'label' => 'Jackpot', 'href' => '/admin/jackpot', 'activeMatchPrefix' => '/admin/jackpot', 'requiredAny' => ['prd.jackpot.manage', 'prd.jackpot.view']], ['segment' => 'jackpot', 'label' => 'Jackpot', 'href' => '/admin/jackpot', 'activeMatchPrefix' => '/admin/jackpot', 'requiredAny' => ['prd.jackpot.manage', 'prd.jackpot.view']],
['segment' => 'risk_cap', 'label' => 'Risk cap rules', 'href' => '/admin/risk/cap', 'activeMatchPrefix' => '/admin/risk/cap', 'requiredAny' => ['prd.risk_cap.manage', 'prd.risk_cap.view']], ['segment' => 'risk_cap', 'label' => 'Risk cap rules', 'href' => '/admin/risk/cap', 'activeMatchPrefix' => '/admin/risk/cap', 'requiredAny' => ['prd.risk_cap.manage', 'prd.risk_cap.view']],
@@ -129,7 +138,7 @@ final class AdminAuthorizationRegistry
// 权限与系统 // 权限与系统
['segment' => 'admin_users', 'label' => 'Admin Users', 'href' => '/admin/admin-users', 'requiredAny' => ['prd.admin_user.manage']], ['segment' => 'admin_users', 'label' => 'Admin Users', 'href' => '/admin/admin-users', 'requiredAny' => ['prd.admin_user.manage']],
['segment' => 'admin_roles', 'label' => 'Admin Roles', 'href' => '/admin/admin-roles', 'requiredAny' => ['prd.admin_role.manage']], ['segment' => 'admin_roles', 'label' => 'Admin Roles', 'href' => '/admin/admin-roles', 'requiredAny' => ['prd.admin_role.manage']],
['segment' => 'risk', 'label' => 'Risk', 'href' => '/admin/risk', 'requiredAny' => ['prd.draw_result.view', 'prd.draw_result.manage']], ['segment' => 'risk', 'label' => 'Risk', 'href' => '/admin/risk', 'requiredAny' => ['prd.risk.view', 'prd.risk.manage']],
['segment' => 'audit', 'label' => 'Audit Logs', 'href' => '/admin/audit-logs', 'requiredAny' => ['prd.audit.view']], ['segment' => 'audit', 'label' => 'Audit Logs', 'href' => '/admin/audit-logs', 'requiredAny' => ['prd.audit.view']],
['segment' => 'settings', 'label' => 'Settings', 'href' => '/admin/settings', 'requiredAny' => ['prd.wallet_reconcile.manage', 'prd.currency.manage']], ['segment' => 'settings', 'label' => 'Settings', 'href' => '/admin/settings', 'requiredAny' => ['prd.wallet_reconcile.manage', 'prd.currency.manage']],
]; ];
@@ -185,23 +194,24 @@ final class AdminAuthorizationRegistry
private static function permissionSlugsForNavigationSegment(string $segment): array private static function permissionSlugsForNavigationSegment(string $segment): array
{ {
$explicit = [ $explicit = [
'dashboard' => ['prd.dashboard.view'],
'admin_users' => ['prd.admin_user.manage'], 'admin_users' => ['prd.admin_user.manage'],
'admin_roles' => ['prd.admin_role.manage'], 'admin_roles' => ['prd.admin_role.manage'],
'players' => ['prd.users.manage', 'prd.users.view_finance', 'prd.users.view_cs', 'prd.player_freeze.manage'], 'players' => ['prd.users.manage', 'prd.users.view_finance', 'prd.users.view_cs', 'prd.player_freeze.manage'],
'currencies' => ['prd.currency.manage'], 'currencies' => ['prd.currency.manage'],
'wallet' => ['prd.wallet_reconcile.manage', 'prd.wallet_reconcile.view', 'prd.wallet_reconcile.view_cs', 'prd.wallet_adjust.manage', 'prd.users.view_finance'], 'wallet' => ['prd.wallet_reconcile.manage', 'prd.wallet_reconcile.view', 'prd.wallet_reconcile.view_cs', 'prd.wallet_adjust.manage', 'prd.users.view_finance'],
'draws' => ['prd.draw_result.manage', 'prd.draw_result.view', 'prd.draw_reopen.manage'], 'draws' => ['prd.draw_result.manage', 'prd.draw_result.view', 'prd.draw_reopen.manage'],
'rules_plays' => ['prd.play_switch.manage', 'prd.odds.manage'], 'rules_plays' => ['prd.play_switch.manage', 'prd.odds.manage', 'prd.odds.view'],
'rules_odds' => ['prd.odds.manage', 'prd.rebate.manage', 'prd.rebate.view'], 'rules_odds' => ['prd.odds.manage', 'prd.rebate.manage', 'prd.rebate.view'],
'jackpot' => ['prd.jackpot.manage', 'prd.jackpot.view'], 'jackpot' => ['prd.jackpot.manage', 'prd.jackpot.view'],
'risk_cap' => ['prd.risk_cap.manage', 'prd.risk_cap.view'], 'risk_cap' => ['prd.risk_cap.manage', 'prd.risk_cap.view'],
'risk' => ['prd.draw_result.manage', 'prd.draw_result.view'], 'risk' => ['prd.risk.view', 'prd.risk.manage'],
'settlement' => ['prd.payout.manage', 'prd.payout.review', 'prd.payout.view'], 'settlement' => ['prd.payout.manage', 'prd.payout.review', 'prd.payout.view'],
'reconcile' => ['prd.wallet_reconcile.manage', 'prd.wallet_reconcile.view', 'prd.wallet_reconcile.view_cs'], 'reconcile' => ['prd.wallet_reconcile.manage', 'prd.wallet_reconcile.view', 'prd.wallet_reconcile.view_cs'],
'reports' => ['prd.report.view'], 'reports' => ['prd.report.view', 'prd.report.export'],
'tickets' => ['prd.users.view_cs', 'prd.users.manage'], 'tickets' => ['prd.tickets.view'],
'audit' => ['prd.audit.view'], 'audit' => ['prd.audit.view'],
'settings' => [], 'settings' => ['prd.wallet_reconcile.manage', 'prd.currency.manage'],
]; ];
if (isset($explicit[$segment])) { if (isset($explicit[$segment])) {
@@ -331,7 +341,8 @@ final class AdminAuthorizationRegistry
{ {
return [ return [
['code' => 'admin.ping', 'module_code' => 'system', 'name' => '后台连通性探测', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/ping', 'route_name' => 'api.v1.admin.ping', 'auth_mode' => 'login_only', 'is_audit_required' => false], ['code' => 'admin.ping', 'module_code' => 'system', 'name' => '后台连通性探测', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/ping', 'route_name' => 'api.v1.admin.ping', 'auth_mode' => 'login_only', 'is_audit_required' => false],
['code' => 'admin.dashboard', 'module_code' => 'dashboard', 'name' => '后台仪表盘', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/dashboard', 'route_name' => 'api.v1.admin.dashboard', 'auth_mode' => 'login_only', 'is_audit_required' => false], ['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.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.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']],
@@ -349,26 +360,26 @@ final class AdminAuthorizationRegistry
['code' => 'admin.admin-roles.destroy', 'module_code' => 'system', 'name' => '删除角色', 'http_method' => 'DELETE', 'uri_pattern' => '/api/v1/admin/admin-roles/{admin_role}', 'route_name' => 'api.v1.admin.admin-roles.destroy', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.admin_role.manage']], ['code' => 'admin.admin-roles.destroy', 'module_code' => 'system', 'name' => '删除角色', 'http_method' => 'DELETE', 'uri_pattern' => '/api/v1/admin/admin-roles/{admin_role}', 'route_name' => 'api.v1.admin.admin-roles.destroy', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.admin_role.manage']],
['code' => 'admin.admin-roles.permissions.sync', 'module_code' => 'system', 'name' => '角色权限同步', 'http_method' => 'PUT', 'uri_pattern' => '/api/v1/admin/admin-roles/{admin_role}/permissions', 'route_name' => 'api.v1.admin.admin-roles.permissions.sync', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.admin_role.manage']], ['code' => 'admin.admin-roles.permissions.sync', 'module_code' => 'system', 'name' => '角色权限同步', 'http_method' => 'PUT', 'uri_pattern' => '/api/v1/admin/admin-roles/{admin_role}/permissions', 'route_name' => 'api.v1.admin.admin-roles.permissions.sync', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.admin_role.manage']],
['code' => 'admin.play-types.index', 'module_code' => 'config', 'name' => '玩法类型列表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/play-types', 'route_name' => 'api.v1.admin.play-types.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.play_switch.manage', 'prd.odds.manage']], ['code' => 'admin.play-types.index', 'module_code' => 'config', 'name' => '玩法类型列表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/play-types', 'route_name' => 'api.v1.admin.play-types.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.play_switch.manage', 'prd.odds.manage', 'prd.odds.view', 'prd.rebate.manage', 'prd.rebate.view']],
['code' => 'admin.play-types.patch', 'module_code' => 'config', 'name' => '玩法类型切换', 'http_method' => 'PATCH', 'uri_pattern' => '/api/v1/admin/play-types/{play_code}', 'route_name' => 'api.v1.admin.play-types.patch', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.play_switch.manage', 'prd.odds.manage', 'prd.risk_cap.manage', 'prd.rebate.manage', 'prd.jackpot.manage']], ['code' => 'admin.play-types.patch', 'module_code' => 'config', 'name' => '玩法类型切换', 'http_method' => 'PATCH', 'uri_pattern' => '/api/v1/admin/play-types/{play_code}', 'route_name' => 'api.v1.admin.play-types.patch', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.play_switch.manage']],
['code' => 'admin.config.play-versions.index', 'module_code' => 'config', 'name' => '玩法版本列表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/config/play-versions', 'route_name' => 'api.v1.admin.config.play-versions.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.play_switch.manage', 'prd.odds.manage']], ['code' => 'admin.config.play-versions.index', 'module_code' => 'config', 'name' => '玩法版本列表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/config/play-versions', 'route_name' => 'api.v1.admin.config.play-versions.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.play_switch.manage', 'prd.odds.manage', 'prd.odds.view']],
['code' => 'admin.config.play-versions.show', 'module_code' => 'config', 'name' => '玩法版本详情', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/config/play-versions/{id}', 'route_name' => 'api.v1.admin.config.play-versions.show', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.play_switch.manage', 'prd.odds.manage']], ['code' => 'admin.config.play-versions.show', 'module_code' => 'config', 'name' => '玩法版本详情', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/config/play-versions/{id}', 'route_name' => 'api.v1.admin.config.play-versions.show', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.play_switch.manage', 'prd.odds.manage', 'prd.odds.view']],
['code' => 'admin.config.play-versions.store', 'module_code' => 'config', 'name' => '创建玩法版本', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/config/play-versions', 'route_name' => 'api.v1.admin.config.play-versions.store', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.play_switch.manage', 'prd.odds.manage', 'prd.risk_cap.manage', 'prd.rebate.manage', 'prd.jackpot.manage']], ['code' => 'admin.config.play-versions.store', 'module_code' => 'config', 'name' => '创建玩法版本', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/config/play-versions', 'route_name' => 'api.v1.admin.config.play-versions.store', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.play_switch.manage']],
['code' => 'admin.config.play-versions.items.replace', 'module_code' => 'config', 'name' => '替换玩法版本条目', 'http_method' => 'PUT', 'uri_pattern' => '/api/v1/admin/config/play-versions/{id}/items', 'route_name' => 'api.v1.admin.config.play-versions.items.replace', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.play_switch.manage', 'prd.odds.manage', 'prd.risk_cap.manage', 'prd.rebate.manage', 'prd.jackpot.manage']], ['code' => 'admin.config.play-versions.items.replace', 'module_code' => 'config', 'name' => '替换玩法版本条目', 'http_method' => 'PUT', 'uri_pattern' => '/api/v1/admin/config/play-versions/{id}/items', 'route_name' => 'api.v1.admin.config.play-versions.items.replace', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.play_switch.manage']],
['code' => 'admin.config.play-versions.publish', 'module_code' => 'config', 'name' => '发布玩法版本', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/config/play-versions/{id}/publish', 'route_name' => 'api.v1.admin.config.play-versions.publish', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.play_switch.manage', 'prd.odds.manage', 'prd.risk_cap.manage', 'prd.rebate.manage', 'prd.jackpot.manage']], ['code' => 'admin.config.play-versions.publish', 'module_code' => 'config', 'name' => '发布玩法版本', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/config/play-versions/{id}/publish', 'route_name' => 'api.v1.admin.config.play-versions.publish', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.play_switch.manage']],
['code' => 'admin.config.play-versions.destroy', 'module_code' => 'config', 'name' => '删除玩法版本', 'http_method' => 'DELETE', 'uri_pattern' => '/api/v1/admin/config/play-versions/{id}', 'route_name' => 'api.v1.admin.config.play-versions.destroy', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.play_switch.manage', 'prd.odds.manage', 'prd.risk_cap.manage', 'prd.rebate.manage', 'prd.jackpot.manage']], ['code' => 'admin.config.play-versions.destroy', 'module_code' => 'config', 'name' => '删除玩法版本', 'http_method' => 'DELETE', 'uri_pattern' => '/api/v1/admin/config/play-versions/{id}', 'route_name' => 'api.v1.admin.config.play-versions.destroy', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.play_switch.manage']],
['code' => 'admin.config.odds-versions.index', 'module_code' => 'config', 'name' => '赔率版本列表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/config/odds-versions', 'route_name' => 'api.v1.admin.config.odds-versions.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.odds.manage', 'prd.rebate.manage', 'prd.rebate.view']], ['code' => 'admin.config.odds-versions.index', 'module_code' => 'config', 'name' => '赔率版本列表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/config/odds-versions', 'route_name' => 'api.v1.admin.config.odds-versions.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['config.odds.manage', 'config.odds.view']],
['code' => 'admin.config.odds-versions.show', 'module_code' => 'config', 'name' => '赔率版本详情', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/config/odds-versions/{id}', 'route_name' => 'api.v1.admin.config.odds-versions.show', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.odds.manage', 'prd.rebate.manage', 'prd.rebate.view']], ['code' => 'admin.config.odds-versions.show', 'module_code' => 'config', 'name' => '赔率版本详情', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/config/odds-versions/{id}', 'route_name' => 'api.v1.admin.config.odds-versions.show', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['config.odds.manage', 'config.odds.view']],
['code' => 'admin.config.odds-versions.store', 'module_code' => 'config', 'name' => '创建赔率版本', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/config/odds-versions', 'route_name' => 'api.v1.admin.config.odds-versions.store', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.play_switch.manage', 'prd.odds.manage', 'prd.risk_cap.manage', 'prd.rebate.manage', 'prd.jackpot.manage']], ['code' => 'admin.config.odds-versions.store', 'module_code' => 'config', 'name' => '创建赔率版本', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/config/odds-versions', 'route_name' => 'api.v1.admin.config.odds-versions.store', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.odds.manage', 'prd.rebate.manage']],
['code' => 'admin.config.odds-versions.items.replace', 'module_code' => 'config', 'name' => '替换赔率版本条目', 'http_method' => 'PUT', 'uri_pattern' => '/api/v1/admin/config/odds-versions/{id}/items', 'route_name' => 'api.v1.admin.config.odds-versions.items.replace', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.play_switch.manage', 'prd.odds.manage', 'prd.risk_cap.manage', 'prd.rebate.manage', 'prd.jackpot.manage']], ['code' => 'admin.config.odds-versions.items.replace', 'module_code' => 'config', 'name' => '替换赔率版本条目', 'http_method' => 'PUT', 'uri_pattern' => '/api/v1/admin/config/odds-versions/{id}/items', 'route_name' => 'api.v1.admin.config.odds-versions.items.replace', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.odds.manage', 'prd.rebate.manage']],
['code' => 'admin.config.odds-versions.publish', 'module_code' => 'config', 'name' => '发布赔率版本', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/config/odds-versions/{id}/publish', 'route_name' => 'api.v1.admin.config.odds-versions.publish', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.play_switch.manage', 'prd.odds.manage', 'prd.risk_cap.manage', 'prd.rebate.manage', 'prd.jackpot.manage']], ['code' => 'admin.config.odds-versions.publish', 'module_code' => 'config', 'name' => '发布赔率版本', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/config/odds-versions/{id}/publish', 'route_name' => 'api.v1.admin.config.odds-versions.publish', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.odds.manage', 'prd.rebate.manage']],
['code' => 'admin.config.odds-versions.destroy', 'module_code' => 'config', 'name' => '删除赔率版本', 'http_method' => 'DELETE', 'uri_pattern' => '/api/v1/admin/config/odds-versions/{id}', 'route_name' => 'api.v1.admin.config.odds-versions.destroy', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.play_switch.manage', 'prd.odds.manage', 'prd.risk_cap.manage', 'prd.rebate.manage', 'prd.jackpot.manage']], ['code' => 'admin.config.odds-versions.destroy', 'module_code' => 'config', 'name' => '删除赔率版本', 'http_method' => 'DELETE', 'uri_pattern' => '/api/v1/admin/config/odds-versions/{id}', 'route_name' => 'api.v1.admin.config.odds-versions.destroy', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.odds.manage', 'prd.rebate.manage']],
['code' => 'admin.config.risk-cap-versions.index', 'module_code' => 'config', 'name' => '封顶版本列表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/config/risk-cap-versions', 'route_name' => 'api.v1.admin.config.risk-cap-versions.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.risk_cap.manage', 'prd.risk_cap.view']], ['code' => 'admin.config.risk-cap-versions.index', 'module_code' => 'config', 'name' => '封顶版本列表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/config/risk-cap-versions', 'route_name' => 'api.v1.admin.config.risk-cap-versions.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['config.risk_cap.manage', 'config.risk_cap.view']],
['code' => 'admin.config.risk-cap-versions.show', 'module_code' => 'config', 'name' => '封顶版本详情', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/config/risk-cap-versions/{id}', 'route_name' => 'api.v1.admin.config.risk-cap-versions.show', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.risk_cap.manage', 'prd.risk_cap.view']], ['code' => 'admin.config.risk-cap-versions.show', 'module_code' => 'config', 'name' => '封顶版本详情', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/config/risk-cap-versions/{id}', 'route_name' => 'api.v1.admin.config.risk-cap-versions.show', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['config.risk_cap.manage', 'config.risk_cap.view']],
['code' => 'admin.config.risk-cap-versions.store', 'module_code' => 'config', 'name' => '创建封顶版本', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/config/risk-cap-versions', 'route_name' => 'api.v1.admin.config.risk-cap-versions.store', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.play_switch.manage', 'prd.odds.manage', 'prd.risk_cap.manage', 'prd.rebate.manage', 'prd.jackpot.manage']], ['code' => 'admin.config.risk-cap-versions.store', 'module_code' => 'config', 'name' => '创建封顶版本', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/config/risk-cap-versions', 'route_name' => 'api.v1.admin.config.risk-cap-versions.store', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.risk_cap.manage']],
['code' => 'admin.config.risk-cap-versions.items.replace', 'module_code' => 'config', 'name' => '替换封顶版本条目', 'http_method' => 'PUT', 'uri_pattern' => '/api/v1/admin/config/risk-cap-versions/{id}/items', 'route_name' => 'api.v1.admin.config.risk-cap-versions.items.replace', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.play_switch.manage', 'prd.odds.manage', 'prd.risk_cap.manage', 'prd.rebate.manage', 'prd.jackpot.manage']], ['code' => 'admin.config.risk-cap-versions.items.replace', 'module_code' => 'config', 'name' => '替换封顶版本条目', 'http_method' => 'PUT', 'uri_pattern' => '/api/v1/admin/config/risk-cap-versions/{id}/items', 'route_name' => 'api.v1.admin.config.risk-cap-versions.items.replace', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.risk_cap.manage']],
['code' => 'admin.config.risk-cap-versions.publish', 'module_code' => 'config', 'name' => '发布封顶版本', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/config/risk-cap-versions/{id}/publish', 'route_name' => 'api.v1.admin.config.risk-cap-versions.publish', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.play_switch.manage', 'prd.odds.manage', 'prd.risk_cap.manage', 'prd.rebate.manage', 'prd.jackpot.manage']], ['code' => 'admin.config.risk-cap-versions.publish', 'module_code' => 'config', 'name' => '发布封顶版本', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/config/risk-cap-versions/{id}/publish', 'route_name' => 'api.v1.admin.config.risk-cap-versions.publish', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.risk_cap.manage']],
['code' => 'admin.config.risk-cap-versions.destroy', 'module_code' => 'config', 'name' => '删除封顶版本', 'http_method' => 'DELETE', 'uri_pattern' => '/api/v1/admin/config/risk-cap-versions/{id}', 'route_name' => 'api.v1.admin.config.risk-cap-versions.destroy', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.play_switch.manage', 'prd.odds.manage', 'prd.risk_cap.manage', 'prd.rebate.manage', 'prd.jackpot.manage']], ['code' => 'admin.config.risk-cap-versions.destroy', 'module_code' => 'config', 'name' => '删除封顶版本', 'http_method' => 'DELETE', 'uri_pattern' => '/api/v1/admin/config/risk-cap-versions/{id}', 'route_name' => 'api.v1.admin.config.risk-cap-versions.destroy', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.risk_cap.manage']],
['code' => 'admin.settings.index', 'module_code' => 'settings', 'name' => '系统设置列表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/settings', 'route_name' => 'api.v1.admin.settings.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.wallet_reconcile.manage']], ['code' => 'admin.settings.index', 'module_code' => 'settings', 'name' => '系统设置列表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/settings', 'route_name' => 'api.v1.admin.settings.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.wallet_reconcile.manage']],
['code' => 'admin.settings.update', 'module_code' => 'settings', 'name' => '系统设置更新', 'http_method' => 'PUT', 'uri_pattern' => '/api/v1/admin/settings/{key}', 'route_name' => 'api.v1.admin.settings.update', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.wallet_reconcile.manage']], ['code' => 'admin.settings.update', 'module_code' => 'settings', 'name' => '系统设置更新', 'http_method' => 'PUT', 'uri_pattern' => '/api/v1/admin/settings/{key}', 'route_name' => 'api.v1.admin.settings.update', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.wallet_reconcile.manage']],
['code' => 'admin.currencies.index', 'module_code' => 'settings', 'name' => '币种列表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/currencies', 'route_name' => 'api.v1.admin.currencies.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.currency.manage']], ['code' => 'admin.currencies.index', 'module_code' => 'settings', 'name' => '币种列表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/currencies', 'route_name' => 'api.v1.admin.currencies.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.currency.manage']],
@@ -380,16 +391,16 @@ final class AdminAuthorizationRegistry
['code' => 'admin.draws.show', 'module_code' => 'draw', 'name' => '期开奖详情', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/draws/{draw}', 'route_name' => 'api.v1.admin.draws.show', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.draw_result.manage', 'prd.draw_result.view']], ['code' => 'admin.draws.show', 'module_code' => 'draw', 'name' => '期开奖详情', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/draws/{draw}', 'route_name' => 'api.v1.admin.draws.show', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.draw_result.manage', 'prd.draw_result.view']],
['code' => 'admin.draws.finance-summary', 'module_code' => 'draw', 'name' => '期开奖资金摘要', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/draws/{draw}/finance-summary', 'route_name' => 'api.v1.admin.draws.finance-summary', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.draw_result.manage', 'prd.draw_result.view']], ['code' => 'admin.draws.finance-summary', 'module_code' => 'draw', 'name' => '期开奖资金摘要', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/draws/{draw}/finance-summary', 'route_name' => 'api.v1.admin.draws.finance-summary', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.draw_result.manage', 'prd.draw_result.view']],
['code' => 'admin.draws.result-batches.index', 'module_code' => 'draw', 'name' => '开奖结果批次列表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/draws/{draw}/result-batches', 'route_name' => 'api.v1.admin.draws.result-batches.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.draw_result.manage', 'prd.draw_result.view']], ['code' => 'admin.draws.result-batches.index', 'module_code' => 'draw', 'name' => '开奖结果批次列表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/draws/{draw}/result-batches', 'route_name' => 'api.v1.admin.draws.result-batches.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.draw_result.manage', 'prd.draw_result.view']],
['code' => 'admin.draws.risk-pool-lock-logs.index', 'module_code' => 'risk', 'name' => '风控锁池日志列表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/draws/{draw}/risk-pool-lock-logs', 'route_name' => 'api.v1.admin.draws.risk-pool-lock-logs.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.draw_result.manage', 'prd.draw_result.view']], ['code' => 'admin.draws.risk-pool-lock-logs.index', 'module_code' => 'risk', 'name' => '风控锁池日志列表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/draws/{draw}/risk-pool-lock-logs', 'route_name' => 'api.v1.admin.draws.risk-pool-lock-logs.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.draw_result.manage', 'prd.draw_result.view', 'prd.risk.view', 'prd.risk.manage']],
['code' => 'admin.draws.risk-pools.index', 'module_code' => 'risk', 'name' => '风控池列表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/draws/{draw}/risk-pools', 'route_name' => 'api.v1.admin.draws.risk-pools.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.draw_result.manage', 'prd.draw_result.view']], ['code' => 'admin.draws.risk-pools.index', 'module_code' => 'risk', 'name' => '风控池列表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/draws/{draw}/risk-pools', 'route_name' => 'api.v1.admin.draws.risk-pools.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.draw_result.manage', 'prd.draw_result.view', 'prd.risk.view', 'prd.risk.manage']],
['code' => 'admin.draws.risk-pools.show', 'module_code' => 'risk', 'name' => '风控池详情', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/draws/{draw}/risk-pools/{number_4d}', 'route_name' => 'api.v1.admin.draws.risk-pools.show', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.draw_result.manage', 'prd.draw_result.view']], ['code' => 'admin.draws.risk-pools.show', 'module_code' => 'risk', 'name' => '风控池详情', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/draws/{draw}/risk-pools/{number_4d}', 'route_name' => 'api.v1.admin.draws.risk-pools.show', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.draw_result.manage', 'prd.draw_result.view', 'prd.risk.view', 'prd.risk.manage']],
['code' => 'admin.draws.result-batches.store', 'module_code' => 'draw', 'name' => '创建开奖结果批次', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/draws/{draw}/result-batches', 'route_name' => 'api.v1.admin.draws.result-batches.store', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.draw_result.manage']], ['code' => 'admin.draws.result-batches.store', 'module_code' => 'draw', 'name' => '创建开奖结果批次', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/draws/{draw}/result-batches', 'route_name' => 'api.v1.admin.draws.result-batches.store', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.draw_result.manage']],
['code' => 'admin.draws.result-batches.publish', 'module_code' => 'draw', 'name' => '发布开奖结果批次', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/draws/{draw}/result-batches/{batch}/publish', 'route_name' => 'api.v1.admin.draws.result-batches.publish', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.draw_result.manage']], ['code' => 'admin.draws.result-batches.publish', 'module_code' => 'draw', 'name' => '发布开奖结果批次', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/draws/{draw}/result-batches/{batch}/publish', 'route_name' => 'api.v1.admin.draws.result-batches.publish', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.draw_result.manage']],
['code' => 'admin.draws.reopen', 'module_code' => 'draw', 'name' => '重开开奖', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/draws/{draw}/reopen', 'route_name' => 'api.v1.admin.draws.reopen', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.draw_reopen.manage']], ['code' => 'admin.draws.reopen', 'module_code' => 'draw', 'name' => '重开开奖', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/draws/{draw}/reopen', 'route_name' => 'api.v1.admin.draws.reopen', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.draw_reopen.manage']],
['code' => 'admin.draws.generate-plan', 'module_code' => 'draw', 'name' => '生成开奖计划', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/draws/generate-plan', 'route_name' => 'api.v1.admin.draws.generate-plan', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.draw_result.manage']], ['code' => 'admin.draws.generate-plan', 'module_code' => 'draw', 'name' => '生成开奖计划', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/draws/generate-plan', 'route_name' => 'api.v1.admin.draws.generate-plan', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.draw_result.manage']],
['code' => 'admin.draws.manual-close', 'module_code' => 'draw', 'name' => '人工封盘', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/draws/{draw}/manual-close', 'route_name' => 'api.v1.admin.draws.manual-close', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.draw_result.manage']], ['code' => 'admin.draws.manual-close', 'module_code' => 'draw', 'name' => '人工封盘', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/draws/{draw}/manual-close', 'route_name' => 'api.v1.admin.draws.manual-close', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.draw_result.manage']],
['code' => 'admin.draws.risk-pools.manual-close', 'module_code' => 'risk', 'name' => '人工关闭风控池', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/draws/{draw}/risk-pools/{number_4d}/manual-close', 'route_name' => 'api.v1.admin.draws.risk-pools.manual-close', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.draw_result.manage']], ['code' => 'admin.draws.risk-pools.manual-close', 'module_code' => 'risk', 'name' => '人工关闭风控池', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/draws/{draw}/risk-pools/{number_4d}/manual-close', 'route_name' => 'api.v1.admin.draws.risk-pools.manual-close', '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']], ['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, 'legacy_permission_slugs' => ['prd.payout.manage', 'prd.payout.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, 'legacy_permission_slugs' => ['prd.payout.manage', 'prd.payout.review']],
@@ -406,7 +417,7 @@ final class AdminAuthorizationRegistry
['code' => 'admin.jackpot.payout-logs.index', 'module_code' => 'jackpot', 'name' => '奖池派彩日志', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/jackpot/payout-logs', 'route_name' => 'api.v1.admin.jackpot.payout-logs.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.jackpot.manage', 'prd.jackpot.view']], ['code' => 'admin.jackpot.payout-logs.index', 'module_code' => 'jackpot', 'name' => '奖池派彩日志', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/jackpot/payout-logs', 'route_name' => 'api.v1.admin.jackpot.payout-logs.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.jackpot.manage', 'prd.jackpot.view']],
['code' => 'admin.jackpot.contributions.index', 'module_code' => 'jackpot', 'name' => '奖池注入记录', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/jackpot/contributions', 'route_name' => 'api.v1.admin.jackpot.contributions.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.jackpot.manage', 'prd.jackpot.view']], ['code' => 'admin.jackpot.contributions.index', 'module_code' => 'jackpot', 'name' => '奖池注入记录', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/jackpot/contributions', 'route_name' => 'api.v1.admin.jackpot.contributions.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.jackpot.manage', 'prd.jackpot.view']],
['code' => 'admin.jackpot.pools.update', 'module_code' => 'jackpot', 'name' => '更新奖池', 'http_method' => 'PUT', 'uri_pattern' => '/api/v1/admin/jackpot/pools/{pool}', 'route_name' => 'api.v1.admin.jackpot.pools.update', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.jackpot.manage']], ['code' => 'admin.jackpot.pools.update', 'module_code' => 'jackpot', 'name' => '更新奖池', 'http_method' => 'PUT', 'uri_pattern' => '/api/v1/admin/jackpot/pools/{pool}', 'route_name' => 'api.v1.admin.jackpot.pools.update', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.jackpot.manage']],
['code' => 'admin.jackpot.pools.manual-burst', 'module_code' => 'jackpot', 'name' => '手动爆池', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/jackpot/pools/{pool}/manual-burst', 'route_name' => 'api.v1.admin.jackpot.pools.manual-burst', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.jackpot.manage']], ['code' => 'admin.jackpot.pools.manual-burst', 'module_code' => 'jackpot', 'name' => '手动爆池', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/jackpot/pools/{pool}/manual-burst', 'route_name' => 'api.v1.admin.jackpot.pools.manual-burst', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.jackpot.manual_burst']],
['code' => 'admin.players.index', 'module_code' => 'player_service', 'name' => '玩家列表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/players', 'route_name' => 'api.v1.admin.players.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['service.players.manage', 'service.players.view']], ['code' => 'admin.players.index', 'module_code' => 'player_service', 'name' => '玩家列表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/players', 'route_name' => 'api.v1.admin.players.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['service.players.manage', 'service.players.view']],
['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, 'legacy_permission_slugs' => ['prd.users.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, 'legacy_permission_slugs' => ['prd.users.manage']],
@@ -417,11 +428,12 @@ final class AdminAuthorizationRegistry
['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']],
['code' => 'admin.players.wallets', 'module_code' => 'player_service', 'name' => '玩家钱包查看', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/players/{player}/wallets', 'route_name' => 'api.v1.admin.players.wallets', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['service.players.manage', 'service.wallet.view']], ['code' => 'admin.players.wallets', 'module_code' => 'player_service', 'name' => '玩家钱包查看', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/players/{player}/wallets', 'route_name' => 'api.v1.admin.players.wallets', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['service.players.manage', 'service.wallet.view']],
['code' => 'admin.players.ticket-items', 'module_code' => 'player_service', 'name' => '玩家注单查看', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/players/{player}/ticket-items', 'route_name' => 'api.v1.admin.players.ticket-items.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['service.players.manage', 'service.tickets.view']], ['code' => 'admin.players.ticket-items', 'module_code' => 'player_service', 'name' => '玩家注单查看', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/players/{player}/ticket-items', 'route_name' => 'api.v1.admin.players.ticket-items.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['service.players.manage', 'service.tickets.view']],
['code' => 'admin.tickets.index', 'module_code' => 'ticket', 'name' => '后台注单列表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/tickets', 'route_name' => 'api.v1.admin.tickets.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.tickets.view']],
['code' => 'admin.wallet.transfer-orders', 'module_code' => 'wallet', 'name' => '转账单查询', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/wallet/transfer-orders', 'route_name' => 'api.v1.admin.wallet.transfer-orders', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.wallet_reconcile.manage', 'prd.wallet_reconcile.view', 'prd.wallet_reconcile.view_cs']], ['code' => 'admin.wallet.transfer-orders', 'module_code' => 'wallet', 'name' => '转账单查询', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/wallet/transfer-orders', 'route_name' => 'api.v1.admin.wallet.transfer-orders', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.wallet_reconcile.manage', 'prd.wallet_reconcile.view', 'prd.wallet_reconcile.view_cs', 'prd.wallet_adjust.manage', 'prd.users.manage', 'prd.users.view_finance']],
['code' => 'admin.wallet.transactions', 'module_code' => 'wallet', 'name' => '钱包流水查询', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/wallet/transactions', 'route_name' => 'api.v1.admin.wallet.transactions', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.wallet_reconcile.manage', 'prd.wallet_reconcile.view', 'prd.wallet_reconcile.view_cs']], ['code' => 'admin.wallet.transactions', 'module_code' => 'wallet', 'name' => '钱包流水查询', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/wallet/transactions', 'route_name' => 'api.v1.admin.wallet.transactions', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.wallet_reconcile.manage', 'prd.wallet_reconcile.view', 'prd.wallet_reconcile.view_cs']],
['code' => 'admin.wallet.transfer-orders.reverse', 'module_code' => 'wallet', 'name' => '冲正转账单', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/wallet/transfer-orders/{transfer_no}/reverse', 'route_name' => 'api.v1.admin.wallet.transfer-orders.reverse', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.wallet_reconcile.manage']], ['code' => 'admin.wallet.transfer-orders.reverse', 'module_code' => 'wallet', 'name' => '冲正转账单', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/wallet/transfer-orders/{transfer_no}/reverse', 'route_name' => 'api.v1.admin.wallet.transfer-orders.reverse', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.wallet_adjust.manage', 'prd.wallet_reconcile.manage']],
['code' => 'admin.wallet.transfer-orders.manually-process', 'module_code' => 'wallet', 'name' => '手工处理转账单', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/wallet/transfer-orders/{transfer_no}/manually-process', 'route_name' => 'api.v1.admin.wallet.transfer-orders.manually-process', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.wallet_reconcile.manage']], ['code' => 'admin.wallet.transfer-orders.manually-process', 'module_code' => 'wallet', 'name' => '手工处理转账单', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/wallet/transfer-orders/{transfer_no}/manually-process', 'route_name' => 'api.v1.admin.wallet.transfer-orders.manually-process', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.wallet_adjust.manage', 'prd.wallet_reconcile.manage']],
['code' => 'admin.reconcile-jobs.index', 'module_code' => 'reconcile', 'name' => '对账任务列表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/reconcile-jobs', 'route_name' => 'api.v1.admin.reconcile-jobs.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.wallet_reconcile.manage', 'prd.wallet_reconcile.view', 'prd.wallet_reconcile.view_cs']], ['code' => 'admin.reconcile-jobs.index', 'module_code' => 'reconcile', 'name' => '对账任务列表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/reconcile-jobs', 'route_name' => 'api.v1.admin.reconcile-jobs.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.wallet_reconcile.manage', 'prd.wallet_reconcile.view', 'prd.wallet_reconcile.view_cs']],
['code' => 'admin.reconcile-jobs.show', 'module_code' => 'reconcile', 'name' => '对账任务详情', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/reconcile-jobs/{reconcile_job}', 'route_name' => 'api.v1.admin.reconcile-jobs.show', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.wallet_reconcile.manage', 'prd.wallet_reconcile.view', 'prd.wallet_reconcile.view_cs']], ['code' => 'admin.reconcile-jobs.show', 'module_code' => 'reconcile', 'name' => '对账任务详情', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/reconcile-jobs/{reconcile_job}', 'route_name' => 'api.v1.admin.reconcile-jobs.show', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.wallet_reconcile.manage', 'prd.wallet_reconcile.view', 'prd.wallet_reconcile.view_cs']],
['code' => 'admin.reconcile-jobs.items.index', 'module_code' => 'reconcile', 'name' => '对账任务明细', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/reconcile-jobs/{reconcile_job}/items', 'route_name' => 'api.v1.admin.reconcile-jobs.items.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.wallet_reconcile.manage', 'prd.wallet_reconcile.view', 'prd.wallet_reconcile.view_cs']], ['code' => 'admin.reconcile-jobs.items.index', 'module_code' => 'reconcile', 'name' => '对账任务明细', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/reconcile-jobs/{reconcile_job}/items', 'route_name' => 'api.v1.admin.reconcile-jobs.items.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.wallet_reconcile.manage', 'prd.wallet_reconcile.view', 'prd.wallet_reconcile.view_cs']],
@@ -432,9 +444,9 @@ final class AdminAuthorizationRegistry
['code' => 'admin.reports.play-dimension', 'module_code' => 'report', 'name' => '玩法维度报表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/reports/play-dimension', 'route_name' => 'api.v1.admin.reports.play-dimension', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['service.report.view']], ['code' => 'admin.reports.play-dimension', 'module_code' => 'report', 'name' => '玩法维度报表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/reports/play-dimension', 'route_name' => 'api.v1.admin.reports.play-dimension', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['service.report.view']],
['code' => 'admin.reports.rebate-commission', 'module_code' => 'report', 'name' => '佣金回水报表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/reports/rebate-commission', 'route_name' => 'api.v1.admin.reports.rebate-commission', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['service.report.view']], ['code' => 'admin.reports.rebate-commission', 'module_code' => 'report', 'name' => '佣金回水报表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/reports/rebate-commission', 'route_name' => 'api.v1.admin.reports.rebate-commission', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['service.report.view']],
['code' => 'admin.report-jobs.index', 'module_code' => 'report', 'name' => '报表任务列表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/report-jobs', 'route_name' => 'api.v1.admin.report-jobs.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['service.report.view']], ['code' => 'admin.report-jobs.index', 'module_code' => 'report', 'name' => '报表任务列表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/report-jobs', 'route_name' => 'api.v1.admin.report-jobs.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['service.report.view']],
['code' => 'admin.report-jobs.store', 'module_code' => 'report', 'name' => '创建报表任务', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/report-jobs', 'route_name' => 'api.v1.admin.report-jobs.store', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'permission_codes' => ['service.report.view']], ['code' => 'admin.report-jobs.store', 'module_code' => 'report', 'name' => '创建报表任务', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/report-jobs', 'route_name' => 'api.v1.admin.report-jobs.store', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'permission_codes' => ['service.report.export']],
['code' => 'admin.report-jobs.show', 'module_code' => 'report', 'name' => '报表任务详情', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/report-jobs/{report_job}', 'route_name' => 'api.v1.admin.report-jobs.show', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['service.report.view']], ['code' => 'admin.report-jobs.show', 'module_code' => 'report', 'name' => '报表任务详情', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/report-jobs/{report_job}', 'route_name' => 'api.v1.admin.report-jobs.show', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['service.report.view', 'service.report.export']],
['code' => 'admin.report-jobs.download', 'module_code' => 'report', 'name' => '下载报表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/report-jobs/{report_job}/download', 'route_name' => 'api.v1.admin.report-jobs.download', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['service.report.view']], ['code' => 'admin.report-jobs.download', 'module_code' => 'report', 'name' => '下载报表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/report-jobs/{report_job}/download', 'route_name' => 'api.v1.admin.report-jobs.download', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['service.report.export']],
]; ];
} }

View File

@@ -22,9 +22,7 @@ final class AdminConfigPresenter
'category' => $t->category, 'category' => $t->category,
'dimension' => $t->dimension, 'dimension' => $t->dimension,
'bet_mode' => $t->bet_mode, 'bet_mode' => $t->bet_mode,
'display_name_zh' => $t->display_name_zh, 'display_name' => $t->display_name,
'display_name_en' => $t->display_name_en,
'display_name_ne' => $t->display_name_ne,
'is_enabled' => (bool) $t->is_enabled, 'is_enabled' => (bool) $t->is_enabled,
'sort_order' => (int) $t->sort_order, 'sort_order' => (int) $t->sort_order,
'supports_multi_number' => (bool) $t->supports_multi_number, 'supports_multi_number' => (bool) $t->supports_multi_number,
@@ -66,9 +64,7 @@ final class AdminConfigPresenter
'category' => $r->category, 'category' => $r->category,
'dimension' => $r->dimension === null ? null : (int) $r->dimension, 'dimension' => $r->dimension === null ? null : (int) $r->dimension,
'bet_mode' => $r->bet_mode, 'bet_mode' => $r->bet_mode,
'display_name_zh' => $r->display_name_zh, 'display_name' => $r->display_name,
'display_name_en' => $r->display_name_en,
'display_name_ne' => $r->display_name_ne,
'is_enabled' => (bool) $r->is_enabled, 'is_enabled' => (bool) $r->is_enabled,
'min_bet_amount' => (int) $r->min_bet_amount, 'min_bet_amount' => (int) $r->min_bet_amount,
'max_bet_amount' => (int) $r->max_bet_amount, 'max_bet_amount' => (int) $r->max_bet_amount,

View File

@@ -16,6 +16,7 @@ use App\Support\LotteryLocale;
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 Illuminate\Auth\AuthenticationException; use Illuminate\Auth\AuthenticationException;
use App\Http\Middleware\EnsureAdminApiResourcePermission; use App\Http\Middleware\EnsureAdminApiResourcePermission;
@@ -48,6 +49,7 @@ return Application::configure(basePath: dirname(__DIR__))
// 后台 API 预留Sanctum / RBAC // 后台 API 预留Sanctum / RBAC
'lottery.admin' => EnsureAdminApi::class, 'lottery.admin' => EnsureAdminApi::class,
'admin.api-resource' => EnsureAdminApiResourcePermission::class, 'admin.api-resource' => EnsureAdminApiResourcePermission::class,
'admin.audit' => RecordAdminApiAudit::class,
]); ]);
}) })
->withExceptions(function (Exceptions $exceptions): void { ->withExceptions(function (Exceptions $exceptions): void {

View File

@@ -297,7 +297,6 @@ return new class extends Migration
$actionIds = DB::table('admin_action_catalog')->pluck('id', 'code'); $actionIds = DB::table('admin_action_catalog')->pluck('id', 'code');
$menuActions = [ $menuActions = [
['menu_code' => 'dashboard', 'action_code' => 'view', 'permission_code' => 'dashboard.view', 'name' => '仪表盘查看'],
['menu_code' => 'draw.results', 'action_code' => 'view', 'permission_code' => 'draw.results.view', 'name' => '开奖结果查看'], ['menu_code' => 'draw.results', 'action_code' => 'view', 'permission_code' => 'draw.results.view', 'name' => '开奖结果查看'],
['menu_code' => 'draw.review', 'action_code' => 'review', 'permission_code' => 'draw.review.review', 'name' => '开奖审核'], ['menu_code' => 'draw.review', 'action_code' => 'review', 'permission_code' => 'draw.review.review', 'name' => '开奖审核'],
['menu_code' => 'draw.review', 'action_code' => 'publish', 'permission_code' => 'draw.review.publish', 'name' => '开奖发布'], ['menu_code' => 'draw.review', 'action_code' => 'publish', 'permission_code' => 'draw.review.publish', 'name' => '开奖发布'],
@@ -340,7 +339,7 @@ return new class extends Migration
private function seedApiResources(Carbon $now): void private function seedApiResources(Carbon $now): void
{ {
$resources = [ $resources = [
['code' => 'admin.dashboard', 'module_code' => 'dashboard', 'name' => '后台仪表盘', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/dashboard', 'route_name' => 'api.v1.admin.dashboard', 'auth_mode' => 'login_only', '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' => 'login_only', 'is_audit_required' => false, 'permission_codes' => []],
['code' => 'admin.draws.index', 'module_code' => 'draw', 'name' => '期开奖列表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/draws', 'route_name' => 'api.v1.admin.draws.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['draw.results.view']], ['code' => 'admin.draws.index', 'module_code' => 'draw', 'name' => '期开奖列表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/draws', 'route_name' => 'api.v1.admin.draws.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['draw.results.view']],
['code' => 'admin.draws.show', 'module_code' => 'draw', 'name' => '期开奖详情', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/draws/{draw}', 'route_name' => 'api.v1.admin.draws.show', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['draw.results.view']], ['code' => 'admin.draws.show', 'module_code' => 'draw', 'name' => '期开奖详情', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/draws/{draw}', 'route_name' => 'api.v1.admin.draws.show', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['draw.results.view']],
['code' => 'admin.draws.publish', 'module_code' => 'draw', 'name' => '开奖发布', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/draws/{draw}/result-batches/{batch}/publish', 'route_name' => 'api.v1.admin.draws.result-batches.publish', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'permission_codes' => ['draw.review.publish']], ['code' => 'admin.draws.publish', 'module_code' => 'draw', 'name' => '开奖发布', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/draws/{draw}/result-batches/{batch}/publish', 'route_name' => 'api.v1.admin.draws.result-batches.publish', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'permission_codes' => ['draw.review.publish']],
@@ -454,7 +453,7 @@ return new class extends Migration
foreach ($legacyRoles as $role) { foreach ($legacyRoles as $role) {
$roleId = (int) $role->id; $roleId = (int) $role->id;
$grantedPermissions = ['dashboard.view' => true]; $grantedPermissions = [];
foreach ($legacyRolePermissions->get((int) $role->id, collect()) as $pivot) { foreach ($legacyRolePermissions->get((int) $role->id, collect()) as $pivot) {
$permissionId = (int) $pivot->permission_id; $permissionId = (int) $pivot->permission_id;
$legacySlug = $legacyPermissionById[$permissionId] ?? null; $legacySlug = $legacyPermissionById[$permissionId] ?? null;

View File

@@ -0,0 +1,54 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
foreach (['play_types', 'play_config_items'] as $table) {
Schema::table($table, function (Blueprint $blueprint): void {
$blueprint->string('display_name', 64)->nullable()->after('bet_mode');
});
DB::table($table)->update([
'display_name' => DB::raw(
"COALESCE(
NULLIF(TRIM(display_name_zh), ''),
NULLIF(TRIM(display_name_en), ''),
NULLIF(TRIM(display_name_ne), ''),
play_code
)",
),
]);
Schema::table($table, function (Blueprint $blueprint): void {
$blueprint->dropColumn(['display_name_zh', 'display_name_en', 'display_name_ne']);
});
}
}
public function down(): void
{
foreach (['play_types', 'play_config_items'] as $table) {
Schema::table($table, function (Blueprint $blueprint): void {
$blueprint->string('display_name_zh', 64)->nullable()->after('bet_mode');
$blueprint->string('display_name_en', 64)->nullable()->after('display_name_zh');
$blueprint->string('display_name_ne', 64)->nullable()->after('display_name_en');
});
DB::table($table)->update([
'display_name_zh' => DB::raw('display_name'),
'display_name_en' => DB::raw('display_name'),
'display_name_ne' => DB::raw('display_name'),
]);
Schema::table($table, function (Blueprint $blueprint): void {
$blueprint->dropColumn('display_name');
});
}
}
};

View File

@@ -0,0 +1,62 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
public function up(): void
{
$now = Carbon::now();
$actionIds = DB::table('admin_action_catalog')->pluck('id', 'code');
$menuIds = DB::table('admin_menus')->pluck('id', 'code');
$walletMenuId = $menuIds['service.wallet'] ?? null;
$updateActionId = $actionIds['update'] ?? null;
if ($walletMenuId !== null && $updateActionId !== null) {
$exists = DB::table('admin_menu_actions')
->where('permission_code', 'service.wallet.adjust')
->exists();
if (! $exists) {
DB::table('admin_menu_actions')->insert([
'menu_id' => $walletMenuId,
'action_id' => $updateActionId,
'permission_code' => 'service.wallet.adjust',
'name' => '钱包补单/冲正',
'status' => 1,
'created_at' => $now,
'updated_at' => $now,
]);
}
}
$oddsMenuId = $menuIds['config.odds'] ?? null;
$viewActionId = $actionIds['view'] ?? null;
if ($oddsMenuId !== null && $viewActionId !== null) {
$exists = DB::table('admin_menu_actions')
->where('permission_code', 'config.odds.view')
->exists();
if (! $exists) {
DB::table('admin_menu_actions')->insert([
'menu_id' => $oddsMenuId,
'action_id' => $viewActionId,
'permission_code' => 'config.odds.view',
'name' => '赔率配置查看',
'status' => 1,
'created_at' => $now,
'updated_at' => $now,
]);
}
}
}
public function down(): void
{
DB::table('admin_menu_actions')
->whereIn('permission_code', ['service.wallet.adjust', 'config.odds.view'])
->delete();
}
};

View File

@@ -0,0 +1,48 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
/** @var list<string> */
private const STALE_PERMISSION_CODES = [
'dashboard.view',
'service.reports.view',
'service.reports.export',
];
public function up(): void
{
$menuActionIds = DB::table('admin_menu_actions')
->whereIn('permission_code', self::STALE_PERMISSION_CODES)
->pluck('id');
if ($menuActionIds->isNotEmpty()) {
DB::table('admin_menu_actions')
->whereIn('id', $menuActionIds->all())
->delete();
}
$staleReportMenuId = DB::table('admin_menus')
->where('code', 'service.reports')
->value('id');
if ($staleReportMenuId !== null) {
$hasActions = DB::table('admin_menu_actions')
->where('menu_id', (int) $staleReportMenuId)
->exists();
if (! $hasActions) {
DB::table('admin_menus')
->where('id', (int) $staleReportMenuId)
->delete();
}
}
}
public function down(): void
{
// 数据清理迁移,不回滚以免再现僵尸 permission_code。
}
};

View File

@@ -0,0 +1,94 @@
<?php
use App\Support\AdminAuthorizationRegistry;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
public function up(): void
{
$now = Carbon::now();
$menuActionId = DB::table('admin_menu_actions')
->where('permission_code', 'dashboard.view')
->value('id');
if ($menuActionId === null) {
return;
}
$resource = collect(AdminAuthorizationRegistry::resources())
->firstWhere('code', 'admin.dashboard.analytics');
if ($resource === null) {
return;
}
$resourceId = DB::table('admin_api_resources')
->where('code', 'admin.dashboard.analytics')
->value('id');
$payload = [
'module_code' => $resource['module_code'],
'name' => $resource['name'],
'http_method' => $resource['http_method'],
'uri_pattern' => $resource['uri_pattern'],
'route_name' => $resource['route_name'],
'auth_mode' => $resource['auth_mode'],
'is_audit_required' => $resource['is_audit_required'],
'status' => 1,
'meta_json' => null,
'updated_at' => $now,
];
if ($resourceId === null) {
$resourceId = DB::table('admin_api_resources')->insertGetId($payload + [
'code' => 'admin.dashboard.analytics',
'created_at' => $now,
]);
} else {
DB::table('admin_api_resources')
->where('id', (int) $resourceId)
->update($payload);
}
DB::table('admin_api_resource_bindings')
->where('api_resource_id', (int) $resourceId)
->delete();
foreach ($resource['permission_codes'] as $permissionCode) {
$actionId = DB::table('admin_menu_actions')
->where('permission_code', $permissionCode)
->value('id');
if ($actionId === null) {
continue;
}
DB::table('admin_api_resource_bindings')->insert([
'api_resource_id' => (int) $resourceId,
'menu_action_id' => (int) $actionId,
'created_at' => $now,
'updated_at' => $now,
]);
}
}
public function down(): void
{
$resourceId = DB::table('admin_api_resources')
->where('code', 'admin.dashboard.analytics')
->value('id');
if ($resourceId === null) {
return;
}
DB::table('admin_api_resource_bindings')
->where('api_resource_id', (int) $resourceId)
->delete();
DB::table('admin_api_resources')
->where('id', (int) $resourceId)
->delete();
}
};

View File

@@ -0,0 +1,51 @@
<?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('settlement_batches', function (Blueprint $table): void {
if (! Schema::hasColumn('settlement_batches', 'review_status')) {
$table->string('review_status', 32)->default('pending')->after('total_jackpot_payout_amount');
}
if (! Schema::hasColumn('settlement_batches', 'reviewed_by')) {
$table->foreignId('reviewed_by')->nullable()->after('review_status')->constrained('admin_users')->nullOnDelete();
}
if (! Schema::hasColumn('settlement_batches', 'reviewed_at')) {
$table->timestamp('reviewed_at')->nullable()->after('reviewed_by');
}
if (! Schema::hasColumn('settlement_batches', 'review_remark')) {
$table->string('review_remark', 255)->nullable()->after('reviewed_at');
}
if (! Schema::hasColumn('settlement_batches', 'paid_at')) {
$table->timestamp('paid_at')->nullable()->after('review_remark');
}
});
}
public function down(): void
{
Schema::table('settlement_batches', function (Blueprint $table): void {
if (Schema::hasColumn('settlement_batches', 'paid_at')) {
$table->dropColumn('paid_at');
}
if (Schema::hasColumn('settlement_batches', 'review_remark')) {
$table->dropColumn('review_remark');
}
if (Schema::hasColumn('settlement_batches', 'reviewed_at')) {
$table->dropColumn('reviewed_at');
}
if (Schema::hasColumn('settlement_batches', 'reviewed_by')) {
$table->dropForeign(['reviewed_by']);
$table->dropColumn('reviewed_by');
}
if (Schema::hasColumn('settlement_batches', 'review_status')) {
$table->dropColumn('review_status');
}
});
}
};

View File

@@ -0,0 +1,216 @@
<?php
use App\Support\AdminPermissionBridge;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
/** @var list<array{menu_code: string, action_code: string, permission_code: string, name: string}> */
private const NEW_MENU_ACTIONS = [
['menu_code' => 'dashboard', 'action_code' => 'view', 'permission_code' => 'dashboard.view', 'name' => '仪表盘查看'],
['menu_code' => 'service.report', 'action_code' => 'export', 'permission_code' => 'service.report.export', 'name' => '报表导出'],
['menu_code' => 'risk.monitor', 'action_code' => 'manage', 'permission_code' => 'risk.monitor.manage', 'name' => '风控监控管理'],
];
public function up(): void
{
$now = Carbon::now();
$menuIds = DB::table('admin_menus')->pluck('id', 'code');
$actionIds = DB::table('admin_action_catalog')->pluck('id', 'code');
$menuActionIds = [];
foreach (self::NEW_MENU_ACTIONS as $row) {
$menuId = $menuIds[$row['menu_code']] ?? null;
$actionId = $actionIds[$row['action_code']] ?? null;
if ($menuId === null || $actionId === null) {
continue;
}
$exists = DB::table('admin_menu_actions')
->where('permission_code', $row['permission_code'])
->exists();
if ($exists) {
$menuActionIds[$row['permission_code']] = (int) DB::table('admin_menu_actions')
->where('permission_code', $row['permission_code'])
->value('id');
continue;
}
$menuActionIds[$row['permission_code']] = (int) DB::table('admin_menu_actions')->insertGetId([
'menu_id' => (int) $menuId,
'action_id' => (int) $actionId,
'permission_code' => $row['permission_code'],
'name' => $row['name'],
'status' => 1,
'created_at' => $now,
'updated_at' => $now,
]);
}
$this->grantMenuActionsToAllRoles($menuActionIds, $now);
$this->grantReportExportToReportViewRoles($menuActionIds['service.report.export'] ?? null, $now);
$this->grantTicketsViewToLegacyRoles($menuActionIds, $now);
}
public function down(): void
{
$codes = array_column(self::NEW_MENU_ACTIONS, 'permission_code');
$ids = DB::table('admin_menu_actions')->whereIn('permission_code', $codes)->pluck('id');
foreach ($ids as $id) {
DB::table('admin_role_menu_actions')->where('menu_action_id', (int) $id)->delete();
DB::table('admin_user_menu_actions')->where('menu_action_id', (int) $id)->delete();
DB::table('admin_api_resource_bindings')->where('menu_action_id', (int) $id)->delete();
DB::table('admin_menu_actions')->where('id', (int) $id)->delete();
}
}
/**
* @param array<string, int> $menuActionIds
*/
private function grantMenuActionsToAllRoles(array $menuActionIds, Carbon $now): void
{
$dashboardId = $menuActionIds['dashboard.view'] ?? null;
if ($dashboardId === null) {
return;
}
$roleIds = DB::table('admin_roles')->pluck('id');
foreach ($roleIds as $roleId) {
DB::table('admin_role_menu_actions')->updateOrInsert([
'role_id' => (int) $roleId,
'menu_action_id' => $dashboardId,
]);
}
}
private function grantReportExportToReportViewRoles(?int $exportMenuActionId, Carbon $now): void
{
if ($exportMenuActionId === null) {
return;
}
$viewMenuActionId = DB::table('admin_menu_actions')
->where('permission_code', 'service.report.view')
->value('id');
if ($viewMenuActionId === null) {
return;
}
$roleIds = DB::table('admin_role_menu_actions')
->where('menu_action_id', (int) $viewMenuActionId)
->distinct()
->pluck('role_id');
foreach ($roleIds as $roleId) {
DB::table('admin_role_menu_actions')->updateOrInsert([
'role_id' => (int) $roleId,
'menu_action_id' => $exportMenuActionId,
]);
}
}
/**
* 原注单入口依赖多种 prd.*;迁移为独立的 prd.tickets.view。
*
* @param array<string, int> $menuActionIds
*/
private function grantTicketsViewToLegacyRoles(array $menuActionIds, Carbon $now): void
{
$ticketsViewId = DB::table('admin_menu_actions')
->where('permission_code', 'service.tickets.view')
->value('id');
if ($ticketsViewId === null) {
return;
}
$legacySlugs = [
'prd.users.view_cs',
'prd.users.manage',
'prd.users.view_finance',
'prd.draw_result.view',
'prd.draw_result.manage',
'prd.payout.view',
'prd.payout.review',
'prd.payout.manage',
];
$roleIds = $this->roleIdsWithAnyLegacySlug($legacySlugs);
foreach ($roleIds as $roleId) {
DB::table('admin_role_menu_actions')->updateOrInsert([
'role_id' => (int) $roleId,
'menu_action_id' => (int) $ticketsViewId,
]);
}
$riskViewId = DB::table('admin_menu_actions')
->where('permission_code', 'risk.monitor.view')
->value('id');
$riskManageId = $menuActionIds['risk.monitor.manage'] ?? null;
if ($riskManageId === null) {
return;
}
$riskRoleIds = $this->roleIdsWithAnyLegacySlug([
'prd.draw_result.manage',
'prd.draw_result.view',
'prd.risk.manage',
'prd.risk.view',
]);
foreach ($riskRoleIds as $roleId) {
if ($riskViewId !== null) {
DB::table('admin_role_menu_actions')->updateOrInsert([
'role_id' => (int) $roleId,
'menu_action_id' => (int) $riskViewId,
]);
}
DB::table('admin_role_menu_actions')->updateOrInsert([
'role_id' => (int) $roleId,
'menu_action_id' => (int) $riskManageId,
]);
}
}
/**
* 通过角色已授权的 menu_action 反推曾拥有指定 prd.* 的角色legacy 表已废弃)。
*
* @param list<string> $legacySlugs
* @return list<int>
*/
private function roleIdsWithAnyLegacySlug(array $legacySlugs): array
{
$codes = [];
foreach ($legacySlugs as $slug) {
$codes = array_merge($codes, AdminPermissionBridge::menuActionCodesForLegacy($slug));
}
$codes = array_values(array_unique($codes));
if ($codes === []) {
return [];
}
$menuActionIds = DB::table('admin_menu_actions')
->whereIn('permission_code', $codes)
->where('status', 1)
->pluck('id');
if ($menuActionIds->isEmpty()) {
return [];
}
return DB::table('admin_role_menu_actions')
->whereIn('menu_action_id', $menuActionIds->map(fn ($id) => (int) $id)->all())
->distinct()
->pluck('role_id')
->map(fn ($id) => (int) $id)
->all();
}
};

View File

@@ -0,0 +1,112 @@
<?php
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Migrations\Migration;
return new class extends Migration
{
public function up(): void
{
$now = Carbon::now();
if (! DB::table('admin_action_catalog')->where('code', 'manual_burst')->exists()) {
DB::table('admin_action_catalog')->insert([
'code' => 'manual_burst',
'name' => '手动爆池',
'sort_order' => 85,
'status' => 1,
'created_at' => $now,
'updated_at' => $now,
]);
}
$jackpotMenuId = (int) DB::table('admin_menus')->where('code', 'config.jackpot')->value('id');
$manualBurstActionId = (int) DB::table('admin_action_catalog')->where('code', 'manual_burst')->value('id');
if ($jackpotMenuId <= 0 || $manualBurstActionId <= 0) {
return;
}
DB::table('admin_menu_actions')->updateOrInsert(
['permission_code' => 'jackpot.pool.manual_burst'],
[
'menu_id' => $jackpotMenuId,
'action_id' => $manualBurstActionId,
'name' => 'Jackpot 手动爆池',
'status' => 1,
'created_at' => $now,
'updated_at' => $now,
],
);
if (Schema::hasTable('admin_permissions')) {
DB::table('admin_permissions')->updateOrInsert(
['slug' => 'prd.jackpot.manual_burst'],
[
'name' => 'Jackpot 手动爆池·仅超管',
'created_at' => $now,
'updated_at' => $now,
],
);
}
$menuActionId = (int) DB::table('admin_menu_actions')
->where('permission_code', 'jackpot.pool.manual_burst')
->value('id');
$superRoleId = (int) DB::table('admin_roles')->where('slug', 'super_admin')->value('id');
if ($superRoleId > 0 && $menuActionId > 0) {
if (Schema::hasTable('admin_role_legacy_permissions')) {
DB::table('admin_role_legacy_permissions')->updateOrInsert(
[
'role_id' => $superRoleId,
'permission_slug' => 'prd.jackpot.manual_burst',
],
[
'created_at' => $now,
'updated_at' => $now,
],
);
}
DB::table('admin_role_menu_actions')->updateOrInsert(
[
'role_id' => $superRoleId,
'menu_action_id' => $menuActionId,
],
[],
);
}
$resourceId = DB::table('admin_api_resources')
->where('code', 'admin.jackpot.pools.manual-burst')
->value('id');
if ($resourceId !== null && $menuActionId > 0) {
DB::table('admin_api_resource_bindings')
->where('api_resource_id', (int) $resourceId)
->delete();
DB::table('admin_api_resource_bindings')->insert([
'api_resource_id' => (int) $resourceId,
'menu_action_id' => $menuActionId,
'created_at' => $now,
'updated_at' => $now,
]);
if ($superRoleId > 0 && Schema::hasTable('admin_role_api_resources')) {
DB::table('admin_role_api_resources')->updateOrInsert([
'role_id' => $superRoleId,
'api_resource_id' => (int) $resourceId,
], []);
}
}
}
public function down(): void
{
// 避免误删线上已调整的授权绑定。
}
};

View File

@@ -39,17 +39,22 @@ final class AdminRbacAndUserSeeder extends Seeder
['name' => '风控运营员'], ['name' => '风控运营员'],
); );
$this->syncRolePermissions($risk, [ $this->syncRolePermissions($risk, [
'prd.dashboard.view',
'prd.play_switch.manage', 'prd.play_switch.manage',
'prd.odds.manage', 'prd.odds.manage',
'prd.risk_cap.manage', 'prd.risk_cap.manage',
'prd.rebate.manage', 'prd.rebate.manage',
'prd.jackpot.manage', 'prd.jackpot.manage',
'prd.draw_result.manage', 'prd.draw_result.manage',
'prd.risk.view',
'prd.risk.manage',
'prd.payout.review', 'prd.payout.review',
'prd.tickets.view',
'prd.wallet_reconcile.view', 'prd.wallet_reconcile.view',
'prd.audit.view', 'prd.audit.view',
'prd.player_freeze.manage', 'prd.player_freeze.manage',
'prd.report.view', 'prd.report.view',
'prd.report.export',
]); ]);
$finance = AdminRole::query()->updateOrCreate( $finance = AdminRole::query()->updateOrCreate(
@@ -57,16 +62,19 @@ final class AdminRbacAndUserSeeder extends Seeder
['name' => '财务/对账员'], ['name' => '财务/对账员'],
); );
$this->syncRolePermissions($finance, [ $this->syncRolePermissions($finance, [
'prd.dashboard.view',
'prd.users.view_finance', 'prd.users.view_finance',
'prd.risk_cap.view', 'prd.risk_cap.view',
'prd.rebate.view', 'prd.rebate.view',
'prd.jackpot.view', 'prd.jackpot.view',
'prd.draw_result.view', 'prd.draw_result.view',
'prd.payout.view', 'prd.payout.view',
'prd.tickets.view',
'prd.wallet_reconcile.manage', 'prd.wallet_reconcile.manage',
'prd.wallet_adjust.manage', 'prd.wallet_adjust.manage',
'prd.audit.view', 'prd.audit.view',
'prd.report.view', 'prd.report.view',
'prd.report.export',
]); ]);
$cs = AdminRole::query()->updateOrCreate( $cs = AdminRole::query()->updateOrCreate(
@@ -74,7 +82,9 @@ final class AdminRbacAndUserSeeder extends Seeder
['name' => '客服人员'], ['name' => '客服人员'],
); );
$this->syncRolePermissions($cs, [ $this->syncRolePermissions($cs, [
'prd.dashboard.view',
'prd.users.view_cs', 'prd.users.view_cs',
'prd.tickets.view',
'prd.draw_result.view', 'prd.draw_result.view',
'prd.wallet_reconcile.view_cs', 'prd.wallet_reconcile.view_cs',
'prd.report.view', 'prd.report.view',

View File

@@ -11,6 +11,8 @@ use App\Lottery\DrawStatus;
use App\Models\TicketOrder; use App\Models\TicketOrder;
use App\Models\TransferOrder; use App\Models\TransferOrder;
use App\Models\DrawResultBatch; use App\Models\DrawResultBatch;
use App\Models\SettlementBatch;
use App\Lottery\SettlementBatchStatus;
use Illuminate\Database\Seeder; use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use App\Lottery\DrawResultBatchStatus; use App\Lottery\DrawResultBatchStatus;
@@ -54,7 +56,8 @@ final class DashboardHallFixtureSeeder extends Seeder
DB::transaction(function () use ($draw, $player): void { DB::transaction(function () use ($draw, $player): void {
$this->seedRiskPools($draw); $this->seedRiskPools($draw);
$this->seedTicketOrders($draw, $player); $this->seedTicketOrders($draw, $player);
$this->seedPendingReviewBatch($draw); $resultBatch = $this->seedPendingReviewBatch($draw);
$this->seedSettlementBatches($draw, $resultBatch);
$this->seedAbnormalTransfers($player); $this->seedAbnormalTransfers($player);
}); });
@@ -269,12 +272,12 @@ final class DashboardHallFixtureSeeder extends Seeder
); );
} }
private function seedPendingReviewBatch(Draw $draw): void private function seedPendingReviewBatch(Draw $draw): DrawResultBatch
{ {
$max = (int) DrawResultBatch::query()->where('draw_id', $draw->id)->max('result_version'); $max = (int) DrawResultBatch::query()->where('draw_id', $draw->id)->max('result_version');
$next = max(1, $max + 1); $next = max(1, $max + 1);
DrawResultBatch::query()->firstOrCreate( return DrawResultBatch::query()->firstOrCreate(
[ [
'draw_id' => $draw->id, 'draw_id' => $draw->id,
'result_version' => $next, 'result_version' => $next,
@@ -291,6 +294,35 @@ final class DashboardHallFixtureSeeder extends Seeder
); );
} }
private function seedSettlementBatches(Draw $draw, DrawResultBatch $resultBatch): void
{
$now = Carbon::now()->utc();
$rows = [
[1, SettlementBatchStatus::PendingReview, 3, 1, 15_000, 5_000, $now->copy()->subHours(2), null],
[2, SettlementBatchStatus::Approved, 3, 2, 20_000, 5_000, $now->copy()->subHour(), $now->copy()->subMinutes(30)],
[3, SettlementBatchStatus::Completed, 3, 2, 20_000, 5_000, $now->copy()->subMinutes(45), $now->copy()->subMinutes(10)],
];
foreach ($rows as [$version, $status, $tickets, $wins, $payout, $jackpot, $started, $finished]) {
SettlementBatch::query()->firstOrCreate(
[
'draw_id' => $draw->id,
'settle_version' => $version,
],
[
'result_batch_id' => (int) $resultBatch->id,
'status' => $status->value,
'total_ticket_count' => $tickets,
'total_win_count' => $wins,
'total_payout_amount' => $payout,
'total_jackpot_payout_amount' => $jackpot,
'started_at' => $started,
'finished_at' => $finished,
],
);
}
}
private function seedAbnormalTransfers(Player $player): void private function seedAbnormalTransfers(Player $player): void
{ {
$rows = [ $rows = [

View File

@@ -82,6 +82,20 @@ final class LotterySettingsSeeder extends Seeder
'是否在 draw tick 中自动对 `settling` 期号跑结算false 时仅能通过后台 POST settlement/run 触发', '是否在 draw tick 中自动对 `settling` 期号跑结算false 时仅能通过后台 POST settlement/run 触发',
); );
LotterySettings::put(
'settlement.auto_approve_on_tick',
true,
'settlement',
'冷静期结束后自动结算批次是否由 tick 自动审核通过false 时需人工审核',
);
LotterySettings::put(
'settlement.auto_payout_on_tick',
true,
'settlement',
'结算批次审核通过后是否由 tick 自动派彩入账false 时需人工执行 payout',
);
LotterySettings::put( LotterySettings::put(
'settlement.apply_rebate_to_payout', 'settlement.apply_rebate_to_payout',
false, false,

View File

@@ -69,9 +69,7 @@ final class OperationalConfigV1Seeder extends Seeder
'category' => $pt->category, 'category' => $pt->category,
'dimension' => $pt->dimension, 'dimension' => $pt->dimension,
'bet_mode' => $pt->bet_mode, 'bet_mode' => $pt->bet_mode,
'display_name_zh' => $pt->display_name_zh, 'display_name' => $pt->display_name,
'display_name_en' => $pt->display_name_en,
'display_name_ne' => $pt->display_name_ne,
'is_enabled' => (bool) $pt->is_enabled, 'is_enabled' => (bool) $pt->is_enabled,
'min_bet_amount' => 100, 'min_bet_amount' => 100,
'max_bet_amount' => 500_000_000, 'max_bet_amount' => 500_000_000,

View File

@@ -64,9 +64,7 @@ final class PlayOperationalAlignmentSeeder extends Seeder
'category' => $pt->category, 'category' => $pt->category,
'dimension' => $pt->dimension, 'dimension' => $pt->dimension,
'bet_mode' => $pt->bet_mode, 'bet_mode' => $pt->bet_mode,
'display_name_zh' => $pt->display_name_zh, 'display_name' => $pt->display_name,
'display_name_en' => $pt->display_name_en,
'display_name_ne' => $pt->display_name_ne,
'is_enabled' => (bool) $pt->is_enabled, 'is_enabled' => (bool) $pt->is_enabled,
'min_bet_amount' => self::MIN_BET, 'min_bet_amount' => self::MIN_BET,
'max_bet_amount' => self::MAX_BET, 'max_bet_amount' => self::MAX_BET,

View File

@@ -79,9 +79,7 @@ final class PlayTypeSeeder extends Seeder
'category' => $category, 'category' => $category,
'dimension' => $dimension, 'dimension' => $dimension,
'bet_mode' => $betMode, 'bet_mode' => $betMode,
'display_name_zh' => $name, 'display_name' => $name,
'display_name_en' => $name,
'display_name_ne' => $name,
'is_enabled' => $isEnabled, 'is_enabled' => $isEnabled,
'sort_order' => $sortOrder, 'sort_order' => $sortOrder,
'supports_multi_number' => $supportsMultiNumber, 'supports_multi_number' => $supportsMultiNumber,

View File

@@ -6,4 +6,5 @@ return [
'not_found' => 'The requested resource was not found.', 'not_found' => 'The requested resource was not found.',
'too_many_requests' => 'Too many requests. Please try again later.', 'too_many_requests' => 'Too many requests. Please try again later.',
'server_error' => 'Something went wrong. Please try again later.', 'server_error' => 'Something went wrong. Please try again later.',
'jackpot_manual_burst_failed' => 'Manual jackpot burst failed: :reason',
]; ];

View File

@@ -6,4 +6,5 @@ return [
'not_found' => 'अनुरोध गरिएको स्रोत फेला परेन।', 'not_found' => 'अनुरोध गरिएको स्रोत फेला परेन।',
'too_many_requests' => 'धेरै अनुरोधहरू। कृपया पछि प्रयास गर्नुहोस्।', 'too_many_requests' => 'धेरै अनुरोधहरू। कृपया पछि प्रयास गर्नुहोस्।',
'server_error' => 'केही गडबड भयो। कृपया पछि प्रयास गर्नुहोस्।', 'server_error' => 'केही गडबड भयो। कृपया पछि प्रयास गर्नुहोस्।',
'jackpot_manual_burst_failed' => 'म्यानुअल ज्याकपोट बर्स्ट असफल: :reason',
]; ];

View File

@@ -6,4 +6,5 @@ return [
'not_found' => '请求的资源不存在。', 'not_found' => '请求的资源不存在。',
'too_many_requests' => '请求过于频繁,请稍后再试。', 'too_many_requests' => '请求过于频繁,请稍后再试。',
'server_error' => '服务暂时不可用,请稍后再试。', 'server_error' => '服务暂时不可用,请稍后再试。',
'jackpot_manual_burst_failed' => '手动爆池失败::reason',
]; ];

View File

@@ -21,7 +21,7 @@ Route::prefix('v1')->group(function (): void {
require __DIR__.'/api/v1/admin/auth.php'; require __DIR__.'/api/v1/admin/auth.php';
// 以下需 auth:sanctum + lottery.admin // 以下需 auth:sanctum + lottery.admin
Route::middleware(['auth:sanctum', 'lottery.admin']) Route::middleware(['auth:sanctum', 'lottery.admin', 'admin.audit'])
->group(function (): void { ->group(function (): void {
require __DIR__.'/api/v1/admin/core.php'; require __DIR__.'/api/v1/admin/core.php';
require __DIR__.'/api/v1/admin/wallet.php'; require __DIR__.'/api/v1/admin/wallet.php';

View File

@@ -3,6 +3,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\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\AdminDashboardController; use App\Http\Controllers\Api\V1\Admin\Dashboard\AdminDashboardController;
use App\Http\Controllers\Api\V1\Admin\PingController as AdminPingController; use App\Http\Controllers\Api\V1\Admin\PingController as AdminPingController;
@@ -20,6 +21,10 @@ Route::get('dashboard', AdminDashboardController::class)
->middleware('admin.api-resource') ->middleware('admin.api-resource')
->name('api.v1.admin.dashboard'); ->name('api.v1.admin.dashboard');
Route::get('dashboard/analytics', AdminDashboardAnalyticsController::class)
->middleware('admin.api-resource')
->name('api.v1.admin.dashboard.analytics');
// 当前管理员摘要 // 当前管理员摘要
Route::get('auth/me', MeController::class) Route::get('auth/me', MeController::class)
->middleware('admin.api-resource') ->middleware('admin.api-resource')

26
scripts/perf/README.md Normal file
View File

@@ -0,0 +1,26 @@
# Performance scripts (PRD §17.2)
Requires [k6](https://grafana.com/docs/k6/latest/set-up/install-k6/).
## Environment variables
| Variable | Required | Description |
| :--- | :--- | :--- |
| `BASE_URL` | yes | API origin, e.g. `http://127.0.0.1:8000` |
| `PLAYER_ID` | bet-qps, seal | `players.id` for `Bearer dev:{id}` |
| `DRAW_NO` | yes | Open draw `draw_no` (`YYYYMMDD-NNN`) |
| `PLAYER_IDS` | oversell | Comma-separated player ids |
| `HOT_NUMBER` | oversell | Shared number, default `8888` |
| `LINE_AMOUNT` | oversell | Per-request line amount, default `100` |
Staging must use `LOTTERY_RISK_POOL_USE_REDIS_LUA=true` and sufficient wallet balance.
## Commands
```bash
k6 run bet-qps.js
k6 run oversell-race.js
k6 run seal-after-close.js
```
Thresholds are defined in each script (`thresholds` block). Non-zero exit means failed acceptance.

75
scripts/perf/bet-qps.js Normal file
View File

@@ -0,0 +1,75 @@
import http from 'k6/http';
import { check, sleep } from 'k6';
import { Counter, Trend } from 'k6/metrics';
const okLatency = new Trend('place_ok_latency', true);
const okCount = new Counter('place_ok_total');
const baseUrl = __ENV.BASE_URL;
const playerId = __ENV.PLAYER_ID;
const drawNo = __ENV.DRAW_NO;
if (!baseUrl || !playerId || !drawNo) {
throw new Error('Set BASE_URL, PLAYER_ID, DRAW_NO');
}
export const options = {
scenarios: {
bet_ramp: {
executor: 'ramping-vus',
startVUs: 0,
stages: [
{ duration: '30s', target: 200 },
{ duration: '60s', target: 1000 },
{ duration: '30s', target: 1000 },
{ duration: '15s', target: 0 },
],
gracefulRampDown: '10s',
},
},
thresholds: {
http_req_failed: ['rate<0.05'],
http_req_duration: ['p(99)<200'],
place_ok_latency: ['p(99)<200'],
place_ok_total: ['count>30000'],
},
};
function payload(vu, iter) {
const n = String(1000 + ((vu + iter) % 9000)).padStart(4, '0');
return JSON.stringify({
draw_id: drawNo,
currency_code: 'NPR',
client_trace_id: `k6-${__VU}-${__ITER}-${Date.now()}`,
lines: [{ number: n, play_code: 'big', amount: 10 }],
});
}
export default function () {
const res = http.post(`${baseUrl}/api/v1/ticket/place`, payload(__VU, __ITER), {
headers: {
Authorization: `Bearer dev:${playerId}`,
'Content-Type': 'application/json',
Accept: 'application/json',
},
tags: { name: 'ticket_place' },
});
const ok = check(res, {
'status 200': (r) => r.status === 200,
'code success': (r) => {
try {
return r.json('code') === 0;
} catch {
return false;
}
},
});
if (ok) {
okLatency.add(res.timings.duration);
okCount.add(1);
}
sleep(0.01);
}

View File

@@ -0,0 +1,72 @@
import http from 'k6/http';
import { check } from 'k6';
import { Counter } from 'k6/metrics';
const baseUrl = __ENV.BASE_URL;
const drawNo = __ENV.DRAW_NO;
const hotNumber = __ENV.HOT_NUMBER || '8888';
const lineAmount = Number(__ENV.LINE_AMOUNT || '100');
const playerIds = (__ENV.PLAYER_IDS || '')
.split(',')
.map((s) => s.trim())
.filter(Boolean);
if (!baseUrl || !drawNo || playerIds.length === 0) {
throw new Error('Set BASE_URL, DRAW_NO, PLAYER_IDS (comma-separated)');
}
export const options = {
scenarios: {
oversell: {
executor: 'constant-vus',
vus: Math.min(playerIds.length, 200),
duration: '45s',
},
},
thresholds: {
http_req_failed: ['rate<0.15'],
oversell_seen: ['count>0'],
success_seen: ['count>0'],
},
};
const oversellSeen = new Counter('oversell_seen');
const successSeen = new Counter('success_seen');
function body(playerId, iter) {
return JSON.stringify({
draw_id: drawNo,
currency_code: 'NPR',
client_trace_id: `race-${playerId}-${iter}-${Date.now()}`,
lines: [{ number: hotNumber, play_code: 'big', amount: lineAmount }],
});
}
export default function () {
const playerId = playerIds[(__VU - 1) % playerIds.length];
const res = http.post(`${baseUrl}/api/v1/ticket/place`, body(playerId, __ITER), {
headers: {
Authorization: `Bearer dev:${playerId}`,
'Content-Type': 'application/json',
Accept: 'application/json',
},
});
let code = -1;
try {
code = res.json('code');
} catch {
// ignore
}
if (code === 0) {
successSeen.add(1);
}
if (code === 4001) {
oversellSeen.add(1);
}
check(res, {
'structured response': (r) => r.status === 200 || r.status === 400,
});
}

View File

@@ -0,0 +1,57 @@
import http from 'k6/http';
import { check } from 'k6';
import { Counter } from 'k6/metrics';
const baseUrl = __ENV.BASE_URL;
const playerId = __ENV.PLAYER_ID;
const drawNo = __ENV.DRAW_NO;
if (!baseUrl || !playerId || !drawNo) {
throw new Error('Set BASE_URL, PLAYER_ID, DRAW_NO (must already be sealed / past close_time)');
}
const sealedReject = new Counter('sealed_reject_2001');
export const options = {
vus: 50,
duration: '20s',
thresholds: {
sealed_reject_2001: ['count>400'],
http_req_failed: ['rate<0.01'],
},
};
export default function () {
const res = http.post(
`${baseUrl}/api/v1/ticket/place`,
JSON.stringify({
draw_id: drawNo,
currency_code: 'NPR',
client_trace_id: `seal-${__VU}-${__ITER}-${Date.now()}`,
lines: [{ number: '1234', play_code: 'big', amount: 10 }],
}),
{
headers: {
Authorization: `Bearer dev:${playerId}`,
'Content-Type': 'application/json',
Accept: 'application/json',
},
},
);
let code = null;
try {
code = res.json('code');
} catch {
// ignore
}
const rejected = check(res, {
'http 400': (r) => r.status === 400,
'code 2001': () => code === 2001,
});
if (rejected) {
sealedReject.add(1);
}
}

View File

@@ -0,0 +1,45 @@
<?php
use App\Models\AuditLog;
use App\Models\AdminUser;
use App\Models\Draw;
use App\Lottery\DrawStatus;
use Illuminate\Support\Facades\Hash;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
test('admin api audit middleware records draw reopen', function (): void {
$admin = AdminUser::query()->create([
'username' => 'audit_reopen_admin',
'name' => 'Audit Reopen',
'email' => null,
'password' => Hash::make('secret-strong'),
'status' => 0,
]);
grantSuperAdminRole($admin);
$token = $admin->createToken('test', ['*'], now()->addDay())->plainTextToken;
$draw = Draw::query()->create([
'draw_no' => '20260525-099',
'business_date' => '2026-05-25',
'sequence_no' => 99,
'status' => DrawStatus::Cooldown->value,
'cooling_end_time' => now()->addMinutes(10),
'settle_version' => 0,
]);
$before = AuditLog::query()->count();
$this->withHeader('Authorization', 'Bearer '.$token)
->postJson("/api/v1/admin/draws/{$draw->id}/reopen", ['reason' => 'audit test'])
->assertOk();
expect(AuditLog::query()->count())->toBe($before + 1);
/** @var AuditLog $row */
$row = AuditLog::query()->latest('id')->first();
expect($row->module_code)->toBe('draw')
->and($row->action_code)->toBe('reopen')
->and($row->operator_id)->toBe($admin->id);
});

View File

@@ -90,9 +90,7 @@ test('enabling currency as bettable bootstraps odds items and jackpot pool', fun
'category' => '4d', 'category' => '4d',
'dimension' => 4, 'dimension' => 4,
'bet_mode' => 'single', 'bet_mode' => 'single',
'display_name_zh' => '直选', 'display_name' => '直选',
'display_name_en' => 'Straight',
'display_name_ne' => 'सिधा',
'is_enabled' => true, 'is_enabled' => true,
'sort_order' => 10, 'sort_order' => 10,
'supports_multi_number' => false, 'supports_multi_number' => false,

View File

@@ -0,0 +1,105 @@
<?php
use App\Models\Draw;
use App\Models\Player;
use App\Models\AdminUser;
use App\Models\TicketItem;
use App\Models\TicketOrder;
use Illuminate\Support\Facades\Hash;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
test('dashboard analytics returns summary trend and play breakdown for period', function (): void {
$this->artisan('lottery:admin-auth-sync')->assertExitCode(0);
$player = Player::query()->create([
'site_code' => 'main',
'site_player_id' => 'da-p1',
'username' => 'da_u1',
'nickname' => null,
'default_currency' => 'NPR',
'status' => 0,
]);
$draw = Draw::query()->create([
'draw_no' => '20260510-001',
'business_date' => '2026-05-10',
'sequence_no' => 1,
'status' => 'settled',
'start_time' => now()->subDay(),
'close_time' => now()->subDay(),
'draw_time' => now()->subDay(),
'cooling_end_time' => null,
'result_source' => null,
'current_result_version' => 1,
'settle_version' => 1,
'is_reopened' => false,
]);
$order = TicketOrder::query()->create([
'order_no' => 'ORD-DA-1',
'player_id' => $player->id,
'draw_id' => $draw->id,
'currency_code' => 'NPR',
'total_bet_amount' => 8_000,
'total_rebate_amount' => 0,
'total_actual_deduct' => 8_000,
'total_estimated_payout' => 0,
'status' => 'settled',
'submit_source' => 'h5',
'client_trace_id' => null,
]);
TicketItem::query()->create([
'ticket_no' => 'TK-DA-1',
'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' => null,
'unit_bet_amount' => 8_000,
'total_bet_amount' => 8_000,
'rebate_rate_snapshot' => 0,
'commission_rate_snapshot' => 0,
'actual_deduct_amount' => 8_000,
'odds_snapshot_json' => null,
'rule_snapshot_json' => null,
'combination_count' => 1,
'estimated_max_payout' => 0,
'risk_locked_amount' => 0,
'status' => 'won',
'fail_reason_code' => null,
'fail_reason_text' => null,
'win_amount' => 2_000,
'jackpot_win_amount' => 0,
'settled_at' => now(),
]);
$admin = AdminUser::query()->create([
'username' => 'dash_analytics_admin',
'name' => 'Dash Analytics QA',
'email' => null,
'password' => Hash::make('secret-strong'),
'status' => 0,
]);
grantSuperAdminRole($admin);
$token = $admin->createToken('test', ['*'], now()->addDay())->plainTextToken;
$this->withHeader('Authorization', 'Bearer '.$token)
->getJson('/api/v1/admin/dashboard/analytics?period=last_30_days&metric=overview')
->assertOk()
->assertJsonPath('data.summary.total_bet_minor', 8_000)
->assertJsonPath('data.summary.total_payout_minor', 2_000)
->assertJsonPath('data.summary.approx_house_gross_minor', 6_000)
->assertJsonStructure([
'data' => [
'daily_series',
'play_breakdown',
'chart_meta' => ['truncated', 'span_days'],
],
]);
});

View File

@@ -0,0 +1,103 @@
<?php
use App\Models\Draw;
use App\Models\Player;
use App\Models\AdminUser;
use App\Models\TicketOrder;
use App\Services\Admin\AdminReportQueryService;
use Illuminate\Support\Facades\Hash;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
test('resolve dashboard period lifetime returns associative bounds without error', function (): void {
$service = app(AdminReportQueryService::class);
$empty = $service->resolveDashboardPeriod('lifetime', null, null);
expect($empty)->toHaveKeys(['date_from', 'date_to'])
->and($empty['date_from'])->toBeString()
->and($empty['date_to'])->toBeString();
$player = Player::query()->create([
'site_code' => 'main',
'site_player_id' => 'lt-p1',
'username' => 'lt_u1',
'nickname' => null,
'default_currency' => 'NPR',
'status' => 0,
]);
Draw::query()->create([
'draw_no' => '20260101-001',
'business_date' => '2026-01-01',
'sequence_no' => 1,
'status' => 'settled',
'start_time' => now()->subMonths(2),
'close_time' => now()->subMonths(2),
'draw_time' => now()->subMonths(2),
'cooling_end_time' => null,
'result_source' => null,
'current_result_version' => 1,
'settle_version' => 1,
'is_reopened' => false,
]);
$drawLate = Draw::query()->create([
'draw_no' => '20260520-001',
'business_date' => '2026-05-20',
'sequence_no' => 1,
'status' => 'settled',
'start_time' => now()->subDay(),
'close_time' => now()->subDay(),
'draw_time' => now()->subDay(),
'cooling_end_time' => null,
'result_source' => null,
'current_result_version' => 1,
'settle_version' => 1,
'is_reopened' => false,
]);
TicketOrder::query()->create([
'order_no' => 'ORD-LT-1',
'player_id' => $player->id,
'draw_id' => $drawLate->id,
'currency_code' => 'NPR',
'total_bet_amount' => 1_000,
'total_rebate_amount' => 0,
'total_actual_deduct' => 1_000,
'total_estimated_payout' => 0,
'status' => 'settled',
'submit_source' => 'h5',
'client_trace_id' => null,
]);
$range = $service->resolveDashboardPeriod('lifetime', null, null);
expect($range['date_from'])->toBe('2026-05-20')
->and($range['date_to'])->toBe('2026-05-20');
});
test('dashboard analytics lifetime period returns ok via http', function (): void {
$this->artisan('lottery:admin-auth-sync')->assertExitCode(0);
$admin = AdminUser::query()->create([
'username' => 'lt_dash_admin',
'name' => 'LT Dash',
'email' => null,
'password' => Hash::make('secret-strong'),
'status' => 0,
]);
grantSuperAdminRole($admin);
$token = $admin->createToken('test', ['*'], now()->addDay())->plainTextToken;
$this->withHeader('Authorization', 'Bearer '.$token)
->getJson('/api/v1/admin/dashboard/analytics?period=lifetime&metric=overview')
->assertOk()
->assertJsonStructure([
'data' => [
'date_from',
'date_to',
'summary',
'daily_series',
],
]);
});

View File

@@ -61,6 +61,8 @@ test('admin dashboard aggregates hall finance and risk for super admin', functio
->assertJsonPath('data.resolved_draw.id', $draw->id) ->assertJsonPath('data.resolved_draw.id', $draw->id)
->assertJsonPath('data.capabilities.draw_finance_risk', true) ->assertJsonPath('data.capabilities.draw_finance_risk', true)
->assertJsonPath('data.capabilities.wallet_transfer_view', true) ->assertJsonPath('data.capabilities.wallet_transfer_view', true)
->assertJsonPath('data.today_finance.business_date', now()->toDateString())
->assertJsonPath('data.today_finance.total_bet_minor', 0)
->assertJsonPath('data.finance.draw_id', $draw->id) ->assertJsonPath('data.finance.draw_id', $draw->id)
->assertJsonPath('data.draw.result_batch_counts.total', 0) ->assertJsonPath('data.draw.result_batch_counts.total', 0)
->assertJsonPath('data.risk.locked_amount', 200_100) ->assertJsonPath('data.risk.locked_amount', 200_100)

View File

@@ -0,0 +1,75 @@
<?php
use App\Models\AdminRole;
use App\Models\AdminUser;
use App\Support\AdminAuthorizationRegistry;
use Database\Seeders\AdminRbacAndUserSeeder;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
test('config mutate resources require domain manage slugs only', function (): void {
$this->seed(AdminRbacAndUserSeeder::class);
$this->artisan('lottery:admin-auth-sync')->assertExitCode(0);
$playStore = resourceLegacySlugs('admin.config.play-versions.store');
expect($playStore)->toBe(['prd.play_switch.manage']);
$oddsStore = resourceLegacySlugs('admin.config.odds-versions.store');
expect($oddsStore)->toContain('prd.odds.manage')
->and($oddsStore)->not->toContain('prd.play_switch.manage');
$riskCapStore = resourceLegacySlugs('admin.config.risk-cap-versions.store');
expect($riskCapStore)->toBe(['prd.risk_cap.manage']);
});
test('user with report view only cannot create report export job', function (): void {
$this->seed(AdminRbacAndUserSeeder::class);
$this->artisan('lottery:admin-auth-sync')->assertExitCode(0);
$admin = AdminUser::query()->create([
'username' => 'report_view_only',
'name' => 'Tester',
'email' => null,
'password' => Hash::make('secret-strong'),
'status' => 0,
]);
$role = AdminRole::query()->create(['slug' => 'report_view_only', 'name' => 'Report view only']);
$role->syncLegacyPermissionSlugs(['prd.report.view', 'prd.dashboard.view']);
$siteId = AdminUser::defaultAdminSiteId();
$admin->roles()->sync([
(int) $role->id => ['site_id' => $siteId, 'granted_at' => now()],
]);
$token = $admin->createToken('test', ['*'], now()->addDay())->plainTextToken;
$this->withHeader('Authorization', 'Bearer '.$token)
->getJson('/api/v1/admin/reports/daily-profit')
->assertOk();
$this->withHeader('Authorization', 'Bearer '.$token)
->postJson('/api/v1/admin/report-jobs', [
'report_type' => 'daily_profit_summary',
'export_format' => 'csv',
'parameters' => [
'date_from' => '2026-05-01',
'date_to' => '2026-05-07',
],
])
->assertForbidden();
});
/** @return list<string> */
function resourceLegacySlugs(string $code): array
{
$resource = collect(AdminAuthorizationRegistry::resourceDefinitions())
->firstWhere('code', $code);
expect($resource)->not->toBeNull();
return $resource['legacy_permission_slugs'] ?? [];
}

View File

@@ -52,7 +52,7 @@ test('finance role with report legacy can access report jobs after rbac seed', f
->assertOk(); ->assertOk();
}); });
test('report api resources only bind service.report.view', function (): void { test('report read api resources bind service.report.view only', function (): void {
$this->seed(AdminRbacAndUserSeeder::class); $this->seed(AdminRbacAndUserSeeder::class);
$this->artisan('lottery:admin-auth-sync')->assertExitCode(0); $this->artisan('lottery:admin-auth-sync')->assertExitCode(0);
@@ -63,13 +63,28 @@ test('report api resources only bind service.report.view', function (): void {
]; ];
foreach ($codes as $code) { foreach ($codes as $code) {
$bindings = DB::table('admin_api_resources as ar') $bindings = bindingsForResource($code);
->join('admin_api_resource_bindings as arb', 'arb.api_resource_id', '=', 'ar.id')
->join('admin_menu_actions as ma', 'ma.id', '=', 'arb.menu_action_id')
->where('ar.code', $code)
->pluck('ma.permission_code')
->all();
expect($bindings)->toBe(['service.report.view']); expect($bindings)->toBe(['service.report.view']);
} }
}); });
test('report export api resources bind service.report.export', function (): void {
$this->seed(AdminRbacAndUserSeeder::class);
$this->artisan('lottery:admin-auth-sync')->assertExitCode(0);
expect(bindingsForResource('admin.report-jobs.download'))->toBe(['service.report.export']);
expect(bindingsForResource('admin.report-jobs.store'))->toBe(['service.report.export']);
});
/** @return list<string> */
function bindingsForResource(string $code): array
{
return DB::table('admin_api_resources as ar')
->join('admin_api_resource_bindings as arb', 'arb.api_resource_id', '=', 'ar.id')
->join('admin_menu_actions as ma', 'ma.id', '=', 'arb.menu_action_id')
->where('ar.code', $code)
->orderBy('ma.permission_code')
->pluck('ma.permission_code')
->all();
}

View File

@@ -0,0 +1,119 @@
<?php
use App\Models\Draw;
use App\Models\Player;
use App\Models\TicketItem;
use App\Models\TicketOrder;
use App\Services\Admin\AdminReportQueryService;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
test('platform lifetime totals aggregate all draws with daily profit口径', function (): void {
$player = Player::query()->create([
'site_code' => 'main',
'site_player_id' => 'plt-p1',
'username' => 'plt_u1',
'nickname' => null,
'default_currency' => 'NPR',
'status' => 0,
]);
$drawA = Draw::query()->create([
'draw_no' => '20260501-001',
'business_date' => '2026-05-01',
'sequence_no' => 1,
'status' => 'settled',
'start_time' => now()->subDays(2),
'close_time' => now()->subDays(2)->addHour(),
'draw_time' => now()->subDays(2)->addHours(2),
'cooling_end_time' => null,
'result_source' => null,
'current_result_version' => 1,
'settle_version' => 1,
'is_reopened' => false,
]);
$drawB = Draw::query()->create([
'draw_no' => '20260502-001',
'business_date' => '2026-05-02',
'sequence_no' => 1,
'status' => 'settled',
'start_time' => now()->subDay(),
'close_time' => now()->subDay()->addHour(),
'draw_time' => now()->subDay()->addHours(2),
'cooling_end_time' => null,
'result_source' => null,
'current_result_version' => 1,
'settle_version' => 1,
'is_reopened' => false,
]);
$orderA = TicketOrder::query()->create([
'order_no' => 'ORD-A',
'player_id' => $player->id,
'draw_id' => $drawA->id,
'currency_code' => 'NPR',
'total_bet_amount' => 10_000,
'total_rebate_amount' => 0,
'total_actual_deduct' => 10_000,
'total_estimated_payout' => 0,
'status' => 'settled',
'submit_source' => 'h5',
'client_trace_id' => null,
]);
TicketOrder::query()->create([
'order_no' => 'ORD-B',
'player_id' => $player->id,
'draw_id' => $drawB->id,
'currency_code' => 'NPR',
'total_bet_amount' => 5_000,
'total_rebate_amount' => 0,
'total_actual_deduct' => 5_000,
'total_estimated_payout' => 0,
'status' => 'settled',
'submit_source' => 'h5',
'client_trace_id' => null,
]);
TicketItem::query()->create([
'ticket_no' => 'TK-PLT-1',
'order_id' => $orderA->id,
'player_id' => $player->id,
'draw_id' => $drawA->id,
'original_number' => '1234',
'normalized_number' => '1234',
'play_code' => 'big',
'dimension' => 4,
'digit_slot' => null,
'bet_mode' => null,
'unit_bet_amount' => 10_000,
'total_bet_amount' => 10_000,
'rebate_rate_snapshot' => 0,
'commission_rate_snapshot' => 0,
'actual_deduct_amount' => 10_000,
'odds_snapshot_json' => null,
'rule_snapshot_json' => null,
'combination_count' => 1,
'estimated_max_payout' => 0,
'risk_locked_amount' => 0,
'status' => 'won',
'fail_reason_code' => null,
'fail_reason_text' => null,
'win_amount' => 3_000,
'jackpot_win_amount' => 0,
'settled_at' => now(),
]);
$totals = app(AdminReportQueryService::class)->platformLifetimeTotals();
expect($totals['total_bet_minor'])->toBe(15_000)
->and($totals['total_payout_minor'])->toBe(3_000)
->and($totals['approx_house_gross_minor'])->toBe(12_000)
->and($totals['draw_count'])->toBe(2)
->and($totals['business_day_count'])->toBe(2)
->and($totals['date_from'])->toBe('2026-05-01')
->and($totals['date_to'])->toBe('2026-05-02')
->and($totals['currency_code'])->toBe('NPR');
});

View File

@@ -3,12 +3,39 @@
use App\Models\AdminUser; use App\Models\AdminUser;
use App\Models\Draw; use App\Models\Draw;
use App\Lottery\DrawStatus; use App\Lottery\DrawStatus;
use App\Models\DrawResultBatch;
use App\Models\DrawResultItem;
use App\Models\JackpotPool; use App\Models\JackpotPool;
use App\Models\JackpotPayoutLog;
use App\Models\Player;
use App\Models\PlayerWallet;
use App\Models\SettlementBatch;
use App\Models\TicketItem;
use App\Models\WalletTxn;
use App\Lottery\DrawResultBatchStatus;
use App\Services\Draw\DrawPrizeLayout;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
use App\Events\JackpotBurstBroadcast;
use App\Events\DrawStatusChangeBroadcast;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Database\Seeders\CurrencySeeder;
use Database\Seeders\PlayTypeSeeder;
use Database\Seeders\LotterySettingsSeeder;
use Database\Seeders\OperationalConfigV1Seeder;
use App\Services\Settlement\SettlementOrchestrator;
use App\Services\Settlement\SettlementBatchWorkflowService;
use Illuminate\Support\Facades\DB;
uses(RefreshDatabase::class); uses(RefreshDatabase::class);
beforeEach(function (): void {
$this->seed(CurrencySeeder::class);
$this->seed(PlayTypeSeeder::class);
$this->seed(OperationalConfigV1Seeder::class);
$this->seed(LotterySettingsSeeder::class);
});
function mintSettlementAdminToken(): string function mintSettlementAdminToken(): string
{ {
$admin = AdminUser::query()->create([ $admin = AdminUser::query()->create([
@@ -23,6 +50,44 @@ function mintSettlementAdminToken(): string
return $admin->createToken('test', ['*'], now()->addDay())->plainTextToken; return $admin->createToken('test', ['*'], now()->addDay())->plainTextToken;
} }
function mintRiskOperatorToken(): string
{
$now = now();
DB::table('admin_roles')->updateOrInsert(
['slug' => 'risk_operator'],
['name' => 'Risk', 'code' => 'risk_operator', 'created_at' => $now, 'updated_at' => $now],
);
$roleId = (int) DB::table('admin_roles')->where('slug', 'risk_operator')->value('id');
$admin = AdminUser::query()->create([
'username' => 'risk_jp_admin',
'name' => 'Risk JP',
'email' => null,
'password' => Hash::make('secret-strong'),
'status' => 0,
]);
$siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id');
DB::table('admin_user_site_roles')->insert([
'admin_user_id' => $admin->id,
'site_id' => $siteId,
'role_id' => $roleId,
'granted_at' => $now,
]);
$manageMenuActionId = (int) DB::table('admin_menu_actions')
->where('permission_code', 'config.jackpot.manage')
->value('id');
if ($manageMenuActionId > 0) {
DB::table('admin_role_menu_actions')->updateOrInsert(
['role_id' => $roleId, 'menu_action_id' => $manageMenuActionId],
[],
);
}
return $admin->createToken('test', ['*'], now()->addDay())->plainTextToken;
}
test('admin settlement batches index is authenticated', function (): void { test('admin settlement batches index is authenticated', function (): void {
$this->getJson('/api/v1/admin/settlement-batches')->assertUnauthorized(); $this->getJson('/api/v1/admin/settlement-batches')->assertUnauthorized();
}); });
@@ -42,22 +107,27 @@ test('admin jackpot pools index returns rows', function (): void {
->assertJsonPath('data.items.0.combo_trigger_play_codes', []); ->assertJsonPath('data.items.0.combo_trigger_play_codes', []);
}); });
test('admin can update jackpot combo trigger and manually burst pool', function (): void { test('admin can update jackpot combo trigger', function (): void {
$pool = JackpotPool::query()->where('currency_code', 'NPR')->firstOrFail(); $pool = JackpotPool::query()->where('currency_code', 'NPR')->firstOrFail();
$pool->forceFill([ $token = mintSettlementAdminToken();
'current_amount' => 1000,
'contribution_rate' => '0.01', $this->withHeader('Authorization', 'Bearer '.$token)
'trigger_threshold' => 1000, ->putJson('/api/v1/admin/jackpot/pools/'.$pool->id, [
'payout_rate' => '0.5', 'combo_trigger_play_codes' => ['straight', 'ibox'],
'force_trigger_draw_gap' => 10, ])
'min_bet_amount' => 0, ->assertOk()
'status' => 1, ->assertJsonPath('data.combo_trigger_play_codes.0', 'straight')
'last_trigger_draw_id' => null, ->assertJsonPath('data.combo_trigger_play_codes.1', 'ibox');
])->save(); });
test('risk operator cannot manually burst jackpot', function (): void {
$pool = JackpotPool::query()->where('currency_code', 'NPR')->firstOrFail();
$pool->forceFill(['current_amount' => 1000, 'status' => 1])->save();
$draw = Draw::query()->create([ $draw = Draw::query()->create([
'draw_no' => '20260518-001', 'draw_no' => '20260518-099',
'business_date' => '2026-05-18', 'business_date' => '2026-05-18',
'sequence_no' => 1, 'sequence_no' => 99,
'status' => DrawStatus::Settled->value, 'status' => DrawStatus::Settled->value,
'start_time' => now()->subHours(2), 'start_time' => now()->subHours(2),
'close_time' => now()->subHour(), 'close_time' => now()->subHour(),
@@ -69,22 +139,263 @@ test('admin can update jackpot combo trigger and manually burst pool', function
'is_reopened' => false, 'is_reopened' => false,
]); ]);
$token = mintSettlementAdminToken(); $token = mintRiskOperatorToken();
$this->withHeader('Authorization', 'Bearer '.$token) $this->withHeader('Authorization', 'Bearer '.$token)
->putJson('/api/v1/admin/jackpot/pools/'.$pool->id, [ ->postJson('/api/v1/admin/jackpot/pools/'.$pool->id.'/manual-burst', [
'combo_trigger_play_codes' => ['straight', 'ibox'], 'draw_id' => $draw->id,
]) ])
->assertOk() ->assertForbidden();
->assertJsonPath('data.combo_trigger_play_codes.0', 'straight') });
->assertJsonPath('data.combo_trigger_play_codes.1', 'ibox');
test('super admin manual burst allocates jackpot to first prize winners after settlement', function (): void {
$pool = JackpotPool::query()->where('currency_code', 'NPR')->firstOrFail();
$pool->forceFill([
'current_amount' => 10_000,
'contribution_rate' => '0',
'trigger_threshold' => 999_999_999,
'payout_rate' => '0.5000',
'force_trigger_draw_gap' => 0,
'min_bet_amount' => 0,
'status' => 1,
'last_trigger_draw_id' => null,
])->save();
$player = Player::query()->create([
'site_code' => 'test',
'site_player_id' => 'manual-burst-p1',
'username' => 'manual_burst_p1',
'nickname' => null,
'default_currency' => 'NPR',
'status' => 0,
]);
PlayerWallet::query()->create([
'player_id' => $player->id,
'wallet_type' => 'lottery',
'currency_code' => 'NPR',
'balance' => 5_000_000,
'frozen_balance' => 0,
'status' => 0,
'version' => 0,
]);
$draw = Draw::query()->create([
'draw_no' => '20260518-010',
'business_date' => '2026-05-18',
'sequence_no' => 10,
'status' => DrawStatus::Open->value,
'start_time' => now()->subMinutes(5),
'close_time' => now()->addMinutes(5),
'draw_time' => now()->addMinutes(6),
'cooling_end_time' => null,
'result_source' => null,
'current_result_version' => 0,
'settle_version' => 0,
'is_reopened' => false,
]);
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
->postJson('/api/v1/ticket/place', [
'draw_id' => (string) $draw->draw_no,
'currency_code' => 'NPR',
'client_trace_id' => 'manual-burst-bet-1',
'lines' => [['number' => '1234', 'play_code' => 'straight', 'amount' => 10_000]],
])
->assertOk();
$batch = DrawResultBatch::query()->create([
'draw_id' => $draw->id,
'result_version' => 1,
'source_type' => 'rng',
'raw_seed_encrypted' => null,
'status' => DrawResultBatchStatus::Published->value,
'created_by' => null,
'confirmed_by' => null,
'confirmed_at' => now(),
]);
foreach (DrawPrizeLayout::slots() as $slot) {
$num = $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' => $num,
'suffix_3d' => substr($num, -3),
'suffix_2d' => substr($num, -2),
'head_digit' => (int) substr($num, 0, 1),
'tail_digit' => (int) substr($num, 3, 1),
]);
}
$draw->forceFill([
'status' => DrawStatus::Settling->value,
'current_result_version' => 1,
])->save();
expect(app(SettlementOrchestrator::class)->trySettleDraw($draw->fresh()))->toBeTrue();
$item = TicketItem::query()->where('draw_id', $draw->id)->firstOrFail();
expect((int) $item->jackpot_win_amount)->toBe(0);
$admin = AdminUser::query()->where('username', 'settlement_admin')->first();
if ($admin === null) {
mintSettlementAdminToken();
$admin = AdminUser::query()->where('username', 'settlement_admin')->firstOrFail();
} else {
grantSuperAdminRole($admin);
}
$settlementBatch = SettlementBatch::query()->where('draw_id', $draw->id)->firstOrFail();
app(SettlementBatchWorkflowService::class)->approve($settlementBatch, $admin);
app(SettlementBatchWorkflowService::class)->payout($settlementBatch->fresh());
Event::fake([JackpotBurstBroadcast::class, DrawStatusChangeBroadcast::class]);
config([
'broadcasting.default' => 'reverb',
'broadcasting.connections.reverb.driver' => 'reverb',
]);
$token = $admin->createToken('burst', ['*'], now()->addDay())->plainTextToken;
$this->withHeader('Authorization', 'Bearer '.$token) $this->withHeader('Authorization', 'Bearer '.$token)
->postJson('/api/v1/admin/jackpot/pools/'.$pool->id.'/manual-burst', [ ->postJson('/api/v1/admin/jackpot/pools/'.$pool->id.'/manual-burst', [
'draw_id' => $draw->id, 'draw_id' => $draw->id,
'amount' => 400,
]) ])
->assertOk() ->assertOk()
->assertJsonPath('data.burst_amount', 400) ->assertJsonPath('data.burst_amount', 5000)
->assertJsonPath('data.current_amount', 600); ->assertJsonPath('data.current_amount', 5000)
->assertJsonPath('data.winner_count', 1);
$item->refresh();
expect((int) $item->jackpot_win_amount)->toBe(5000);
$log = JackpotPayoutLog::query()->where('draw_id', $draw->id)->firstOrFail();
expect($log->trigger_type)->toBe('manual')
->and($log->winner_count)->toBe(1)
->and((int) $log->total_payout_amount)->toBe(5000);
expect(WalletTxn::query()->where('biz_type', 'jackpot_manual_payout')->count())->toBe(1);
Event::assertDispatched(
JackpotBurstBroadcast::class,
fn (JackpotBurstBroadcast $event): bool => $event->drawId === (int) $draw->id
&& $event->triggerType === 'manual'
&& $event->totalPayoutAmount === 5000
&& $event->winnerCount === 1
&& $event->firstPrizeNumber === '1234',
);
});
test('manual burst broadcast includes published first prize number', function (): void {
$pool = JackpotPool::query()->where('currency_code', 'NPR')->firstOrFail();
$pool->forceFill(['current_amount' => 500, 'status' => 1, 'payout_rate' => '1'])->save();
$player = Player::query()->create([
'site_code' => 'test',
'site_player_id' => 'manual-burst-p2',
'username' => 'manual_burst_p2',
'nickname' => null,
'default_currency' => 'NPR',
'status' => 0,
]);
PlayerWallet::query()->create([
'player_id' => $player->id,
'wallet_type' => 'lottery',
'currency_code' => 'NPR',
'balance' => 1_000_000,
'frozen_balance' => 0,
'status' => 0,
'version' => 0,
]);
$draw = Draw::query()->create([
'draw_no' => '20260518-002',
'business_date' => '2026-05-18',
'sequence_no' => 2,
'status' => DrawStatus::Open->value,
'start_time' => now()->subMinutes(5),
'close_time' => now()->addMinutes(5),
'draw_time' => now()->addMinutes(6),
'cooling_end_time' => null,
'result_source' => null,
'current_result_version' => 0,
'settle_version' => 0,
'is_reopened' => false,
]);
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
->postJson('/api/v1/ticket/place', [
'draw_id' => (string) $draw->draw_no,
'currency_code' => 'NPR',
'client_trace_id' => 'manual-burst-bet-2',
'lines' => [['number' => '1234', 'play_code' => 'straight', 'amount' => 10_000]],
])
->assertOk();
$resultBatch = DrawResultBatch::query()->create([
'draw_id' => $draw->id,
'result_version' => 1,
'source_type' => 'rng',
'raw_seed_encrypted' => null,
'status' => DrawResultBatchStatus::Published->value,
'created_by' => null,
'confirmed_by' => null,
'confirmed_at' => now(),
]);
foreach (DrawPrizeLayout::slots() as $slot) {
$num = $slot['prize_type'] === 'first' ? '1234' : '5678';
DrawResultItem::query()->create([
'draw_id' => $draw->id,
'result_batch_id' => $resultBatch->id,
'prize_type' => $slot['prize_type'],
'prize_index' => $slot['prize_index'],
'number_4d' => $num,
'suffix_3d' => substr($num, -3),
'suffix_2d' => substr($num, -2),
'head_digit' => (int) substr($num, 0, 1),
'tail_digit' => (int) substr($num, 3, 1),
]);
}
$draw->forceFill([
'status' => DrawStatus::Settling->value,
'current_result_version' => 1,
'result_source' => 'rng',
])->save();
expect(app(SettlementOrchestrator::class)->trySettleDraw($draw->fresh()))->toBeTrue();
$admin = AdminUser::query()->create([
'username' => 'manual_burst_admin2',
'name' => 'Burst Admin',
'email' => null,
'password' => Hash::make('secret-strong'),
'status' => 0,
]);
grantSuperAdminRole($admin);
$settlementBatch = SettlementBatch::query()->where('draw_id', $draw->id)->firstOrFail();
app(SettlementBatchWorkflowService::class)->approve($settlementBatch, $admin);
app(SettlementBatchWorkflowService::class)->payout($settlementBatch->fresh());
Event::fake([JackpotBurstBroadcast::class, DrawStatusChangeBroadcast::class]);
config([
'broadcasting.default' => 'reverb',
'broadcasting.connections.reverb.driver' => 'reverb',
]);
$token = $admin->createToken('test', ['*'], now()->addDay())->plainTextToken;
$this->withHeader('Authorization', 'Bearer '.$token)
->postJson('/api/v1/admin/jackpot/pools/'.$pool->id.'/manual-burst', [
'draw_id' => $draw->id,
])
->assertOk();
Event::assertDispatched(
JackpotBurstBroadcast::class,
fn (JackpotBurstBroadcast $event): bool => $event->firstPrizeNumber === '1234',
);
}); });

View File

@@ -16,6 +16,7 @@ use App\Lottery\DrawResultBatchStatus;
use App\Services\Draw\DrawTickService; use App\Services\Draw\DrawTickService;
use App\Events\DrawStatusChangeBroadcast; use App\Events\DrawStatusChangeBroadcast;
use App\Services\Draw\DrawPlannerService; use App\Services\Draw\DrawPlannerService;
use App\Services\Draw\DrawHallSnapshotBuilder;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class); uses(RefreshDatabase::class);
@@ -747,9 +748,70 @@ test('lottery hall-countdown dispatches draw.countdown when using reverb connect
'broadcasting.connections.reverb.driver' => 'reverb', 'broadcasting.connections.reverb.driver' => 'reverb',
]); ]);
Draw::query()->create([
'draw_no' => '20260509-001',
'business_date' => '2026-05-09',
'sequence_no' => 1,
'status' => DrawStatus::Open->value,
'start_time' => now()->subMinutes(5),
'close_time' => now()->addMinutes(20),
'draw_time' => now()->addMinutes(25),
'cooling_end_time' => null,
'result_source' => null,
'current_result_version' => 0,
'settle_version' => 0,
'is_reopened' => false,
]);
$this->artisan('lottery:hall-countdown')->assertSuccessful(); $this->artisan('lottery:hall-countdown')->assertSuccessful();
Event::assertDispatched(DrawCountdownBroadcast::class); Event::assertDispatched(
DrawCountdownBroadcast::class,
fn (DrawCountdownBroadcast $event): bool => is_array($event->data) && isset($event->data['draw_no']),
);
});
test('hall snapshot switches to next bettable draw when cooldown ended', function (): void {
Carbon::setTestNow(Carbon::parse('2026-05-10 12:00:00', 'UTC'));
Draw::query()->create([
'draw_no' => '20260510-001',
'business_date' => '2026-05-10',
'sequence_no' => 1,
'status' => DrawStatus::Cooldown->value,
'start_time' => now()->subHours(3),
'close_time' => now()->subHours(2),
'draw_time' => now()->subHours(2),
'cooling_end_time' => now()->subMinutes(5),
'result_source' => 'rng',
'current_result_version' => 1,
'settle_version' => 0,
'is_reopened' => false,
]);
$next = Draw::query()->create([
'draw_no' => '20260510-002',
'business_date' => '2026-05-10',
'sequence_no' => 2,
'status' => DrawStatus::Pending->value,
'start_time' => now()->subMinutes(1),
'close_time' => now()->addMinutes(20),
'draw_time' => now()->addMinutes(25),
'cooling_end_time' => null,
'result_source' => null,
'current_result_version' => 0,
'settle_version' => 0,
'is_reopened' => false,
]);
$target = app(DrawHallSnapshotBuilder::class)->resolveHallTarget(now()->utc());
expect($target)->not->toBeNull()
->and($target->draw_no)->toBe('20260510-002');
$payload = app(DrawHallSnapshotBuilder::class)->build(now()->utc());
expect($payload['status'] ?? null)->toBe('open');
Carbon::setTestNow();
}); });
test('draw tick dispatches draw.status_change when hall draw_no or status changes', function (): void { test('draw tick dispatches draw.status_change when hall draw_no or status changes', function (): void {

View File

@@ -79,6 +79,17 @@ function jackpotOpenDraw(string $drawNo): Draw
]); ]);
} }
/** 迁移已 seed 默认 NPR 池,测试内用 upsert 避免 UNIQUE(currency_code) 冲突 */
function jackpotUpsertPool(array $attrs): JackpotPool
{
$currencyCode = (string) ($attrs['currency_code'] ?? 'NPR');
return JackpotPool::query()->updateOrCreate(
['currency_code' => $currencyCode],
$attrs,
);
}
function jackpotPublishResults(Draw $draw, string $firstNumber = '1234'): void function jackpotPublishResults(Draw $draw, string $firstNumber = '1234'): void
{ {
$batch = DrawResultBatch::query()->create([ $batch = DrawResultBatch::query()->create([
@@ -115,7 +126,7 @@ function jackpotPublishResults(Draw $draw, string $firstNumber = '1234'): void
} }
test('jackpot contributes on place and bursts on settle for first-prize straight', function (): void { test('jackpot contributes on place and bursts on settle for first-prize straight', function (): void {
JackpotPool::query()->create([ jackpotUpsertPool([
'currency_code' => 'NPR', 'currency_code' => 'NPR',
'current_amount' => 0, 'current_amount' => 0,
'contribution_rate' => '0.1000', 'contribution_rate' => '0.1000',
@@ -238,7 +249,7 @@ test('jackpot contributes on place and bursts on settle for first-prize straight
}); });
test('jackpot contribution respects switch and minimum bet threshold', function (): void { test('jackpot contribution respects switch and minimum bet threshold', function (): void {
JackpotPool::query()->create([ jackpotUpsertPool([
'currency_code' => 'NPR', 'currency_code' => 'NPR',
'current_amount' => 0, 'current_amount' => 0,
'contribution_rate' => '0.1000', 'contribution_rate' => '0.1000',
@@ -288,7 +299,7 @@ test('jackpot bursts by configured play combination trigger before threshold', f
'broadcasting.connections.reverb.driver' => 'reverb', 'broadcasting.connections.reverb.driver' => 'reverb',
]); ]);
JackpotPool::query()->create([ jackpotUpsertPool([
'currency_code' => 'NPR', 'currency_code' => 'NPR',
'current_amount' => 50_000, 'current_amount' => 50_000,
'contribution_rate' => '0.0000', 'contribution_rate' => '0.0000',
@@ -336,7 +347,7 @@ test('jackpot bursts by configured play combination trigger before threshold', f
}); });
test('jackpot splits burst payout between multiple winners by bet amount', function (): void { test('jackpot splits burst payout between multiple winners by bet amount', function (): void {
JackpotPool::query()->create([ jackpotUpsertPool([
'currency_code' => 'NPR', 'currency_code' => 'NPR',
'current_amount' => 90_000, 'current_amount' => 90_000,
'contribution_rate' => '0.0000', 'contribution_rate' => '0.0000',
@@ -413,7 +424,7 @@ test('jackpot summary and result payload expose pool amount and draw gap', funct
'is_reopened' => false, 'is_reopened' => false,
]); ]);
JackpotPool::query()->create([ jackpotUpsertPool([
'currency_code' => 'NPR', 'currency_code' => 'NPR',
'current_amount' => 123_456, 'current_amount' => 123_456,
'contribution_rate' => '0.0100', 'contribution_rate' => '0.0100',

View File

@@ -80,9 +80,7 @@ test('§12.6 published play limits are visible on public effective catalog witho
'category' => $t->category, 'category' => $t->category,
'dimension' => $t->dimension, 'dimension' => $t->dimension,
'bet_mode' => $t->bet_mode, 'bet_mode' => $t->bet_mode,
'display_name_zh' => $t->display_name_zh ?? $t->play_code, 'display_name' => $t->display_name ?? $t->play_code,
'display_name_en' => $t->display_name_en,
'display_name_ne' => $t->display_name_ne,
'is_enabled' => true, 'is_enabled' => true,
'min_bet_amount' => 777, 'min_bet_amount' => 777,
'max_bet_amount' => 400_000_000, 'max_bet_amount' => 400_000_000,
@@ -271,9 +269,7 @@ test('§12.6 published play config controls master_enabled on public catalog wit
'category' => $r['category'], 'category' => $r['category'],
'dimension' => $r['dimension'], 'dimension' => $r['dimension'],
'bet_mode' => $r['bet_mode'], 'bet_mode' => $r['bet_mode'],
'display_name_zh' => $r['display_name_zh'], 'display_name' => $r['display_name'],
'display_name_en' => $r['display_name_en'],
'display_name_ne' => $r['display_name_ne'],
'is_enabled' => $r['is_enabled'], 'is_enabled' => $r['is_enabled'],
'min_bet_amount' => (int) $r['min_bet_amount'], 'min_bet_amount' => (int) $r['min_bet_amount'],
'max_bet_amount' => (int) $r['max_bet_amount'], 'max_bet_amount' => (int) $r['max_bet_amount'],
@@ -381,9 +377,7 @@ test('§5 play_config publish is audited', function (): void {
'category' => $t->category, 'category' => $t->category,
'dimension' => $t->dimension, 'dimension' => $t->dimension,
'bet_mode' => $t->bet_mode, 'bet_mode' => $t->bet_mode,
'display_name_zh' => $t->display_name_zh ?? $t->play_code, 'display_name' => $t->display_name ?? $t->play_code,
'display_name_en' => $t->display_name_en,
'display_name_ne' => $t->display_name_ne,
'is_enabled' => true, 'is_enabled' => true,
'min_bet_amount' => 100, 'min_bet_amount' => 100,
'max_bet_amount' => 500_000_000, 'max_bet_amount' => 500_000_000,
@@ -404,6 +398,35 @@ test('§5 play_config publish is audited', function (): void {
)->toBeTrue(); )->toBeTrue();
}); });
test('play type patch toggles active config and broadcasts instantly', function (): void {
Event::fake([PlayToggleBroadcast::class]);
config([
'broadcasting.default' => 'reverb',
'broadcasting.connections.reverb.driver' => 'reverb',
]);
$token = acceptanceMintAdminToken();
$auth = ['Authorization' => 'Bearer '.$token];
$this->patchJson('/api/v1/admin/play-types/big', ['is_enabled' => false], $auth)
->assertOk()
->assertJsonPath('data.play_code', 'big')
->assertJsonPath('data.is_enabled', false);
Event::assertDispatched(
PlayToggleBroadcast::class,
fn (PlayToggleBroadcast $event): bool => $event->playCode === 'big' && $event->enabled === false,
);
expect(
AuditLog::query()
->where('module_code', 'play_config')
->where('action_code', 'toggle_active')
->where('target_id', 'big')
->exists(),
)->toBeTrue();
});
test('§9 play_config publish broadcasts changed play toggles', function (): void { test('§9 play_config publish broadcasts changed play toggles', function (): void {
Event::fake([PlayToggleBroadcast::class]); Event::fake([PlayToggleBroadcast::class]);
config([ config([

View File

@@ -78,9 +78,7 @@ test('admin play config draft publish flow', function (): void {
'category' => $t->category, 'category' => $t->category,
'dimension' => $t->dimension, 'dimension' => $t->dimension,
'bet_mode' => $t->bet_mode, 'bet_mode' => $t->bet_mode,
'display_name_zh' => $t->display_name_zh ?? $t->play_code, 'display_name' => $t->display_name ?? $t->play_code,
'display_name_en' => $t->display_name_en,
'display_name_ne' => $t->display_name_ne,
'is_enabled' => true, 'is_enabled' => true,
'min_bet_amount' => 200, 'min_bet_amount' => 200,
'max_bet_amount' => 400_000_000, 'max_bet_amount' => 400_000_000,

View File

@@ -0,0 +1,133 @@
<?php
/**
* PRD §17.2 可自动化子集(不含 k6 与万级结算压测)。
*/
use App\Models\Draw;
use App\Models\Player;
use App\Lottery\ErrorCode;
use App\Lottery\DrawStatus;
use App\Models\PlayerWallet;
use Carbon\Carbon;
use Database\Seeders\CurrencySeeder;
use Database\Seeders\PlayTypeSeeder;
use Database\Seeders\LotterySettingsSeeder;
use Database\Seeders\OperationalConfigV1Seeder;
use App\Services\Draw\DrawPlannerService;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function (): void {
$this->seed(CurrencySeeder::class);
$this->seed(PlayTypeSeeder::class);
$this->seed(OperationalConfigV1Seeder::class);
$this->seed(LotterySettingsSeeder::class);
});
test('draw planner schedules five minute draw_time gaps', function (): void {
config([
'lottery.draw.timezone' => 'UTC',
'lottery.draw.interval_minutes' => 5,
'lottery.draw.buffer_draws_ahead' => 12,
]);
$fixed = Carbon::parse('2026-05-25 00:00:00', 'UTC');
app(DrawPlannerService::class)->ensureBuffer($fixed);
$times = Draw::query()
->whereNotNull('draw_time')
->orderBy('draw_time')
->limit(13)
->pluck('draw_time')
->map(fn ($t) => Carbon::parse($t)->utc())
->all();
expect(count($times))->toBeGreaterThanOrEqual(2);
for ($i = 1; $i < count($times); $i++) {
$delta = (int) $times[$i]->diffInSeconds($times[$i - 1], absolute: true);
expect($delta)->toBe(300);
}
});
test('ticket place rejects bet when draw is closing', function (): void {
$player = perfPlayer();
Draw::query()->create([
'draw_no' => '20260525-001',
'business_date' => '2026-05-25',
'sequence_no' => 1,
'status' => DrawStatus::Closing->value,
'start_time' => now()->subMinutes(10),
'close_time' => now()->subMinute(),
'draw_time' => now()->addMinute(),
'cooling_end_time' => null,
'result_source' => null,
'current_result_version' => 0,
'settle_version' => 0,
'is_reopened' => false,
]);
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
->postJson('/api/v1/ticket/place', [
'draw_id' => '20260525-001',
'currency_code' => 'NPR',
'client_trace_id' => 'perf-sealed',
'lines' => [['number' => '1234', 'play_code' => 'big', 'amount' => 10]],
])
->assertStatus(400)
->assertJsonPath('code', ErrorCode::DrawClosed->value);
});
test('ticket place rejects bet when close_time passed but status still open', function (): void {
$player = perfPlayer();
Draw::query()->create([
'draw_no' => '20260525-002',
'business_date' => '2026-05-25',
'sequence_no' => 2,
'status' => DrawStatus::Open->value,
'start_time' => now()->subMinutes(10),
'close_time' => now()->subSecond(),
'draw_time' => now()->addMinute(),
'cooling_end_time' => null,
'result_source' => null,
'current_result_version' => 0,
'settle_version' => 0,
'is_reopened' => false,
]);
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
->postJson('/api/v1/ticket/place', [
'draw_id' => '20260525-002',
'currency_code' => 'NPR',
'client_trace_id' => 'perf-close-time',
'lines' => [['number' => '5678', 'play_code' => 'big', 'amount' => 10]],
])
->assertStatus(400)
->assertJsonPath('code', ErrorCode::DrawClosed->value);
});
function perfPlayer(): Player
{
$uniq = bin2hex(random_bytes(4));
$player = Player::query()->create([
'site_code' => 'test',
'site_player_id' => 'perf-'.$uniq,
'username' => 'perf_'.$uniq,
'nickname' => null,
'default_currency' => 'NPR',
'status' => 0,
]);
PlayerWallet::query()->create([
'player_id' => $player->id,
'wallet_type' => 'lottery',
'currency_code' => 'NPR',
'balance' => 1_000_000,
'frozen_balance' => 0,
'status' => 0,
'version' => 0,
]);
return $player;
}

View File

@@ -70,10 +70,12 @@ test('player me works with main site jwt when dev bypass is off', function () {
'status' => 0, 'status' => 0,
]); ]);
$now = time();
$jwt = JWT::encode([ $jwt = JWT::encode([
'site_code' => 'main', 'site_code' => 'main',
'site_player_id' => 'jwt-user-1', 'site_player_id' => 'jwt-user-1',
'exp' => time() + 3600, 'iat' => $now,
'exp' => $now + 300,
], 'jwt-test-secret', 'HS256'); ], 'jwt-test-secret', 'HS256');
$this->withHeader('Authorization', 'Bearer '.$jwt) $this->withHeader('Authorization', 'Bearer '.$jwt)
@@ -88,10 +90,12 @@ test('jwt first successful login auto-registers player mapping', function () {
expect(Player::query()->count())->toBe(0); expect(Player::query()->count())->toBe(0);
$now = time();
$jwt = JWT::encode([ $jwt = JWT::encode([
'site_code' => 'main', 'site_code' => 'main',
'site_player_id' => 'brand-new-sso-1', 'site_player_id' => 'brand-new-sso-1',
'exp' => time() + 3600, 'iat' => $now,
'exp' => $now + 300,
], 'jwt-test-secret', 'HS256'); ], 'jwt-test-secret', 'HS256');
$this->withHeader('Authorization', 'Bearer '.$jwt) $this->withHeader('Authorization', 'Bearer '.$jwt)

View File

@@ -0,0 +1,76 @@
<?php
use App\Models\Draw;
use App\Lottery\DrawStatus;
use App\Models\AdminUser;
use App\Models\DrawResultBatch;
use Illuminate\Support\Facades\Hash;
use App\Services\Draw\DrawRngSeedDerivation;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
test('rng seed derivation is deterministic for fixed seed and draw', function (): void {
$seedHex = str_repeat('ab', 32);
$drawId = 42;
$a = DrawRngSeedDerivation::deriveNumber4d($seedHex, $drawId, 0);
$b = DrawRngSeedDerivation::deriveNumber4d($seedHex, $drawId, 0);
$c = DrawRngSeedDerivation::deriveNumber4d($seedHex, $drawId, 1);
expect($a)->toBe($b)
->and(strlen($a))->toBe(4)
->and($a)->not->toBe($c);
});
test('rng seed encrypt decrypt roundtrip preserves hex', function (): void {
$seedHex = DrawRngSeedDerivation::generateSeedHex();
$encrypted = DrawRngSeedDerivation::encryptSeedHex($seedHex);
expect(DrawRngSeedDerivation::decryptSeedHex($encrypted))->toBe($seedHex)
->and(DrawRngSeedDerivation::hashSeedHex($seedHex))->toBe(hash('sha256', $seedHex));
});
test('admin rng run stores encrypted seed and passes batch audit verification', function (): void {
config(['lottery.draw.require_manual_review' => true]);
$draw = Draw::query()->create([
'draw_no' => '20260525-rng-audit',
'business_date' => '2026-05-25',
'sequence_no' => 901,
'status' => DrawStatus::Closed->value,
'start_time' => now()->subMinutes(20),
'close_time' => now()->subMinutes(5),
'draw_time' => now()->subMinute(),
'cooling_end_time' => null,
'result_source' => null,
'current_result_version' => 0,
'settle_version' => 0,
'is_reopened' => false,
]);
$admin = AdminUser::query()->create([
'username' => 'rng_audit_admin',
'name' => 'RNG Audit',
'email' => null,
'password' => Hash::make('secret-strong'),
'status' => 0,
]);
grantSuperAdminRole($admin);
$token = $admin->createToken('test', ['*'], now()->addDay())->plainTextToken;
$this->withHeader('Authorization', 'Bearer '.$token)
->postJson("/api/v1/admin/draws/{$draw->id}/rng")
->assertOk();
$batch = DrawResultBatch::query()->where('draw_id', $draw->id)->firstOrFail();
expect($batch->source_type)->toBe('rng')
->and($batch->rng_seed_hash)->not->toBeEmpty()
->and($batch->raw_seed_encrypted)->not->toBeEmpty()
->and($batch->items()->count())->toBe(23);
$seedHex = DrawRngSeedDerivation::decryptSeedHex((string) $batch->raw_seed_encrypted);
expect(DrawRngSeedDerivation::hashSeedHex($seedHex))->toBe($batch->rng_seed_hash)
->and(DrawRngSeedDerivation::verifyBatchAudit($batch->fresh(['items']), $draw->fresh()))->toBeTrue();
});

View File

@@ -14,6 +14,7 @@ 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;
@@ -413,18 +414,238 @@ 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 {
$cases = [
[
'play' => 'pos_3abc',
'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);
$drawNo = p145_next_draw_no();
$draw = p145_draw($drawNo, random_int(1, 99_999));
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
->postJson('/api/v1/ticket/place', [
'draw_id' => $drawNo,
'currency_code' => 'NPR',
'client_trace_id' => 'module6-multi-tier-'.$case['play'].'-'.$case['expected_tier'].'-'.uniqid('', true),
'lines' => [
['number' => $case['number'], 'play_code' => $case['play'], 'amount' => 10_000],
],
])
->assertOk();
$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']);
}
});
test('module 6 ibox sums payout across combinations hitting different prize tiers', function (): void {
$player = p145_player(80_000_000);
$drawNo = p145_next_draw_no();
$draw = p145_draw($drawNo, random_int(1, 99_999));
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
->postJson('/api/v1/ticket/place', [
'draw_id' => $drawNo,
'currency_code' => 'NPR',
'client_trace_id' => 'module6-ibox-multi-tier',
'lines' => [
['number' => '1122', 'play_code' => 'ibox', 'amount' => 100],
],
])
->assertOk();
$item = TicketItem::query()->where('draw_id', $draw->id)->firstOrFail();
$deduct = (int) $item->actual_deduct_amount;
expect($deduct)->toBe(600)
->and((int) $item->combination_count)->toBe(6);
$unitWinFirst = (int) floor(100 * OddsStandardScopes::PRESET_ODDS_BY_SCOPE['first'] / 10_000);
$unitWinStarter = (int) floor(100 * OddsStandardScopes::PRESET_ODDS_BY_SCOPE['starter'] / 10_000);
$expectedWin = $unitWinFirst + $unitWinStarter;
p145_publish_board($draw, function (string $t, int $i): string {
return match ($t) {
'first' => '1212',
'starter' => $i === 0 ? '2121' : sprintf('71%02d', $i),
default => p145_board_without_8888($t, $i),
};
});
$draw->forceFill([
'status' => DrawStatus::Settling->value,
'current_result_version' => 1,
])->save();
expect(app(SettlementOrchestrator::class)->trySettleDraw($draw->fresh()))->toBeTrue();
p145_approve_and_payout($draw);
$item->refresh();
$detail = TicketSettlementDetail::query()->where('ticket_item_id', $item->id)->firstOrFail();
$matchLines = is_array($detail->match_detail_json)
? ($detail->match_detail_json['lines'] ?? [])
: [];
expect($item->status)->toBe('settled_win')
->and((int) $item->win_amount)->toBe($expectedWin)
->and($detail->matched_prize_tier)->toBe('first')
->and(count($matchLines))->toBe(2);
$wallet = PlayerWallet::query()->where('player_id', $player->id)->firstOrFail();
expect((int) $wallet->balance)->toBe(80_000_000 - $deduct + $expectedWin);
});
test('module 6 mbox remainder deducts floored total and settles win on per-combination unit amount', function (): void {
$player = p145_player(80_000_000);
$drawNo = p145_next_draw_no();
$draw = p145_draw($drawNo, random_int(1, 99_999));
$rawAmount = 10_001;
$comboCount = 24;
$unitBet = intdiv($rawAmount, $comboCount);
$expectedDeduct = $unitBet * $comboCount;
$expectedRemainder = $rawAmount - $expectedDeduct;
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
->postJson('/api/v1/ticket/place', [
'draw_id' => $drawNo,
'currency_code' => 'NPR',
'client_trace_id' => 'module6-mbox-remainder-win',
'lines' => [
['number' => '1234', 'play_code' => 'mbox', 'amount' => $rawAmount],
],
])
->assertOk();
$item = TicketItem::query()->where('draw_id', $draw->id)->firstOrFail();
$ruleSnapshot = is_array($item->rule_snapshot_json) ? $item->rule_snapshot_json : [];
expect((int) $item->combination_count)->toBe($comboCount)
->and((int) $item->unit_bet_amount)->toBe($unitBet)
->and((int) $item->total_bet_amount)->toBe($expectedDeduct)
->and((int) $item->actual_deduct_amount)->toBe($expectedDeduct)
->and($ruleSnapshot['rounding_refund_amount'] ?? null)->toBe($expectedRemainder);
$expectedWin = (int) floor($unitBet * OddsStandardScopes::PRESET_ODDS_BY_SCOPE['first'] / 10_000);
p145_publish_board($draw, fn (string $t, int $i): string => $t === 'first' ? '1234' : p145_board_without_8888($t, $i));
$draw->forceFill([
'status' => DrawStatus::Settling->value,
'current_result_version' => 1,
])->save();
expect(app(SettlementOrchestrator::class)->trySettleDraw($draw->fresh()))->toBeTrue();
p145_approve_and_payout($draw);
$item->refresh();
expect($item->status)->toBe('settled_win')
->and((int) $item->win_amount)->toBe($expectedWin);
$wallet = PlayerWallet::query()->where('player_id', $player->id)->firstOrFail();
expect((int) $wallet->balance)->toBe(80_000_000 - $expectedDeduct + $expectedWin);
});
test('module 6 mbox remainder is not refunded on losing settlement', function (): void {
$player = p145_player(80_000_000);
$drawNo = p145_next_draw_no();
$draw = p145_draw($drawNo, random_int(1, 99_999));
$rawAmount = 10_001;
$expectedDeduct = intdiv($rawAmount, 24) * 24;
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
->postJson('/api/v1/ticket/place', [
'draw_id' => $drawNo,
'currency_code' => 'NPR',
'client_trace_id' => 'module6-mbox-remainder-lose',
'lines' => [
['number' => '1234', 'play_code' => 'mbox', 'amount' => $rawAmount],
],
])
->assertOk();
$item = TicketItem::query()->where('draw_id', $draw->id)->firstOrFail();
expect((int) $item->actual_deduct_amount)->toBe($expectedDeduct);
p145_publish_board($draw, fn (string $t, int $i): string => p145_board_without_8888($t, $i));
$draw->forceFill([
'status' => DrawStatus::Settling->value,
'current_result_version' => 1,
])->save();
expect(app(SettlementOrchestrator::class)->trySettleDraw($draw->fresh()))->toBeTrue();
p145_approve_and_payout($draw);
$item->refresh();
expect($item->status)->toBe('settled_lose')
->and((int) $item->win_amount)->toBe(0);
$wallet = PlayerWallet::query()->where('player_id', $player->id)->firstOrFail();
expect((int) $wallet->balance)->toBe(80_000_000 - $expectedDeduct);
expect(WalletTxn::query()->where('biz_type', 'settle_payout')->count())->toBe(0);
});
test('§14.5 jackpot contributes on place and stays in pool when no first-prize burst', function (): void { test('§14.5 jackpot contributes on place and stays in pool when no first-prize burst', function (): void {
JackpotPool::query()->create([ JackpotPool::query()->updateOrCreate(
'currency_code' => 'NPR', ['currency_code' => 'NPR'],
'current_amount' => 0, [
'contribution_rate' => '0.1000', 'current_amount' => 0,
'trigger_threshold' => 1, 'contribution_rate' => '0.1000',
'payout_rate' => '1.0000', 'trigger_threshold' => 1,
'force_trigger_draw_gap' => 0, 'payout_rate' => '1.0000',
'min_bet_amount' => 0, 'force_trigger_draw_gap' => 0,
'status' => 1, 'min_bet_amount' => 0,
'last_trigger_draw_id' => null, 'status' => 1,
]); 'last_trigger_draw_id' => null,
],
);
$player = p145_player(); $player = p145_player();
$drawNo = p145_next_draw_no(); $drawNo = p145_next_draw_no();
@@ -515,6 +736,117 @@ test('§14.5 placement partial failure only deducts successful lines when mid-or
expect((int) $pool->locked_amount)->toBe(3000); expect((int) $pool->locked_amount)->toBe(3000);
}); });
test('§14.5 settlement uses odds snapshot even if odds config changes after placement', function (): void {
$player = p145_player(80_000_000);
$drawNo = p145_next_draw_no();
$draw = p145_draw($drawNo, random_int(1, 99_999));
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
->postJson('/api/v1/ticket/place', [
'draw_id' => $drawNo,
'currency_code' => 'NPR',
'client_trace_id' => 'p145-odds-snapshot-1',
'lines' => [
['number' => '1234', 'play_code' => 'big', 'amount' => 10_000],
],
])
->assertOk();
$item = TicketItem::query()->where('draw_id', $draw->id)->firstOrFail();
$snapshotOdds = collect(is_array($item->odds_snapshot_json) ? $item->odds_snapshot_json : [])
->firstWhere('prize_scope', 'first');
expect($snapshotOdds)->not->toBeNull();
// 修改当前赔率配置:如果结算错误使用“实时配置”,这里会导致派奖金额变化。
OddsItem::query()
->where('play_code', 'big')
->where('prize_scope', 'first')
->where('currency_code', 'NPR')
->update(['odds_value' => 10_000]);
p145_publish_board($draw, fn (string $t, int $i): string => $t === 'first' ? '1234' : p145_board_without_8888($t, $i));
$draw->forceFill([
'status' => DrawStatus::Settling->value,
'current_result_version' => 1,
])->save();
expect(app(SettlementOrchestrator::class)->trySettleDraw($draw->fresh()))->toBeTrue();
p145_approve_and_payout($draw);
$item->refresh();
$expectedWinBySnapshot = (int) floor(10_000 * ((int) $snapshotOdds['odds_value'] / 10_000));
expect($item->status)->toBe('settled_win')
->and((int) $item->win_amount)->toBe($expectedWinBySnapshot);
});
test('§14.5 settlement releases risk pool locks after payout (win and lose)', function (): void {
$player = p145_player(80_000_000);
$drawNo = p145_next_draw_no();
$draw = p145_draw($drawNo, random_int(1, 99_999));
// 下注一单,确保产生风险池占用。
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
->postJson('/api/v1/ticket/place', [
'draw_id' => $drawNo,
'currency_code' => 'NPR',
'client_trace_id' => 'p145-risk-release-1',
'lines' => [
['number' => '8888', 'play_code' => 'big', 'amount' => 120],
],
])
->assertOk();
$pool = RiskPool::query()->where('draw_id', $draw->id)->where('normalized_number', '8888')->firstOrFail();
$cap = (int) $pool->total_cap_amount;
expect((int) $pool->locked_amount)->toBeGreaterThan(0)
->and((int) $pool->remaining_amount)->toBeLessThan($cap);
// 先走未中奖结算,验证释放。
p145_publish_board($draw, fn (string $t, int $i): string => p145_board_without_8888($t, $i));
$draw->forceFill([
'status' => DrawStatus::Settling->value,
'current_result_version' => 1,
])->save();
expect(app(SettlementOrchestrator::class)->trySettleDraw($draw->fresh()))->toBeTrue();
p145_approve_and_payout($draw);
$poolAfterLose = $pool->fresh();
expect((int) $poolAfterLose->locked_amount)->toBe(0)
->and((int) $poolAfterLose->remaining_amount)->toBe($cap);
// 再开一盘中奖结算,验证同样释放。
$drawNo2 = p145_next_draw_no();
$draw2 = p145_draw($drawNo2, random_int(1, 99_999));
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
->postJson('/api/v1/ticket/place', [
'draw_id' => $drawNo2,
'currency_code' => 'NPR',
'client_trace_id' => 'p145-risk-release-2',
'lines' => [
['number' => '1234', 'play_code' => 'big', 'amount' => 120],
],
])
->assertOk();
$pool2 = RiskPool::query()->where('draw_id', $draw2->id)->where('normalized_number', '1234')->firstOrFail();
$cap2 = (int) $pool2->total_cap_amount;
expect((int) $pool2->locked_amount)->toBeGreaterThan(0);
p145_publish_board($draw2, fn (string $t, int $i): string => $t === 'first' ? '1234' : p145_board_without_8888($t, $i));
$draw2->forceFill([
'status' => DrawStatus::Settling->value,
'current_result_version' => 1,
])->save();
expect(app(SettlementOrchestrator::class)->trySettleDraw($draw2->fresh()))->toBeTrue();
p145_approve_and_payout($draw2);
$poolAfterWin = $pool2->fresh();
expect((int) $poolAfterWin->locked_amount)->toBe(0)
->and((int) $poolAfterWin->remaining_amount)->toBe($cap2);
});
/** /**
* 覆盖 {@see PlayTypeSeeder} 中已启用且注册匹配器的玩法(不含 `half_box`:种子为禁用)。 * 覆盖 {@see PlayTypeSeeder} 中已启用且注册匹配器的玩法(不含 `half_box`:种子为禁用)。
* `odd` / `even`:头奖取该注项首条展开组合号码,避免与 `expandOddEven` 枚举顺序硬编码耦合。 * `odd` / `even`:头奖取该注项首条展开组合号码,避免与 `expandOddEven` 枚举顺序硬编码耦合。

View File

@@ -150,6 +150,84 @@ test('module 6 box family expands combinations and computes amount semantics', f
->and($lines[7]['rule_snapshot_json']['rounding_refund_amount'] ?? null)->toBe(17); ->and($lines[7]['rule_snapshot_json']['rounding_refund_amount'] ?? null)->toBe(17);
}); });
test('module 6 mbox remainder splits amount evenly across preview and place', function (): void {
$player = ticketPlayerWithWallet(500_000);
ticketOpenDraw();
$cases = [
[
'number' => '1234',
'amount' => 10_001,
'combination_count' => 24,
'unit_bet_amount' => 416,
'total_bet_amount' => 9984,
'rounding_refund_amount' => 17,
],
[
'number' => '1122',
'amount' => 601,
'combination_count' => 6,
'unit_bet_amount' => 100,
'total_bet_amount' => 600,
'rounding_refund_amount' => 1,
],
];
foreach ($cases as $index => $case) {
$resp = $this->withHeader('Authorization', 'Bearer dev:'.$player->id)
->postJson('/api/v1/ticket/preview', [
'draw_id' => '20260511-001',
'currency_code' => 'NPR',
'client_trace_id' => 'trace-module6-mbox-remainder-'.$index,
'lines' => [
['number' => $case['number'], 'play_code' => 'mbox', 'amount' => $case['amount']],
],
])
->assertOk();
$line = $resp->json('data.lines.0');
expect($line['combination_count'])->toBe($case['combination_count'])
->and($line['total_bet_amount'])->toBe($case['total_bet_amount'])
->and($line['actual_deduct_amount'])->toBe($case['total_bet_amount'])
->and($line['rule_snapshot_json']['rounding_refund_amount'] ?? null)->toBe($case['rounding_refund_amount'])
->and(intdiv($case['amount'], $case['combination_count']))->toBe($case['unit_bet_amount']);
}
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
->postJson('/api/v1/ticket/place', [
'draw_id' => '20260511-001',
'currency_code' => 'NPR',
'client_trace_id' => 'trace-module6-mbox-place-remainder',
'lines' => [
['number' => '1234', 'play_code' => 'mbox', 'amount' => 10_001],
],
])
->assertOk()
->assertJsonPath('data.summary.total_bet_amount', 9984)
->assertJsonPath('data.summary.total_actual_deduct', 9984);
$item = TicketItem::query()->where('play_code', 'mbox')->firstOrFail();
$ruleSnapshot = is_array($item->rule_snapshot_json) ? $item->rule_snapshot_json : [];
expect((int) $item->unit_bet_amount)->toBe(416)
->and((int) $item->total_bet_amount)->toBe(9984)
->and((int) $item->actual_deduct_amount)->toBe(9984)
->and($ruleSnapshot['rounding_refund_amount'] ?? null)->toBe(17);
$comboAmounts = TicketCombination::query()
->where('ticket_item_id', $item->id)
->pluck('bet_amount')
->map(fn ($amount) => (int) $amount)
->unique()
->values()
->all();
expect($comboAmounts)->toBe([416]);
$wallet = PlayerWallet::query()->where('player_id', $player->id)->firstOrFail();
expect((int) $wallet->balance)->toBe(500_000 - 9984);
});
test('module 6 roll expands each R position and charges per expanded combination', function (): void { test('module 6 roll expands each R position and charges per expanded combination', function (): void {
$player = ticketPlayerWithWallet(500_000); $player = ticketPlayerWithWallet(500_000);
ticketOpenDraw(); ticketOpenDraw();
@@ -537,6 +615,104 @@ test('ticket place rejects bet amount below configured minimum', function (): vo
expect(TicketOrder::query()->count())->toBe(0); expect(TicketOrder::query()->count())->toBe(0);
}); });
test('ticket preview reports high risk warning without deducting wallet or creating order', function (): void {
$player = ticketPlayerWithWallet();
$draw = ticketOpenDraw();
RiskPool::query()->create([
'draw_id' => $draw->id,
'normalized_number' => '1234',
'total_cap_amount' => 4000,
'locked_amount' => 0,
'remaining_amount' => 4000,
'sold_out_status' => 0,
'version' => 0,
]);
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
->postJson('/api/v1/ticket/preview', [
'draw_id' => '20260511-001',
'currency_code' => 'NPR',
'client_trace_id' => 'trace-preview-risk-warning',
'lines' => [
['number' => '1234', 'play_code' => 'big', 'amount' => 160],
],
])
->assertOk()
->assertJsonPath('data.lines.0.risk_status', 'ok')
->assertJsonPath('data.warnings.0.number_4d', '1234');
$wallet = PlayerWallet::query()->where('player_id', $player->id)->firstOrFail();
$pool = RiskPool::query()->where('draw_id', $draw->id)->where('normalized_number', '1234')->firstOrFail();
expect((int) $wallet->balance)->toBe(200_000)
->and((int) $pool->locked_amount)->toBe(0)
->and((int) $pool->remaining_amount)->toBe(4000)
->and(TicketOrder::query()->count())->toBe(0)
->and(WalletTxn::query()->where('biz_type', 'bet_deduct')->count())->toBe(0);
});
test('ticket preview validates digit size dimension and slot rules', function (): void {
$player = ticketPlayerWithWallet();
ticketOpenDraw();
$base = [
'draw_id' => '20260511-001',
'currency_code' => 'NPR',
'client_trace_id' => 'trace-digit-validation',
];
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
->postJson('/api/v1/ticket/preview', $base + [
'lines' => [
['number' => '9', 'play_code' => 'digit_big', 'amount' => 100, 'digit_slot' => 3],
],
])
->assertStatus(400)
->assertJsonPath('code', ErrorCode::BetInvalidPlayInput->value);
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
->postJson('/api/v1/ticket/preview', $base + [
'client_trace_id' => 'trace-digit-invalid-slot',
'lines' => [
['number' => '9', 'play_code' => 'digit_big', 'amount' => 100, 'dimension' => 'D2', 'digit_slot' => 1],
],
])
->assertStatus(400)
->assertJsonPath('code', ErrorCode::BetInvalidPlayInput->value);
expect(TicketOrder::query()->count())->toBe(0);
});
test('ticket place persists valid 2d digit size slot snapshot', function (): void {
$player = ticketPlayerWithWallet(500_000);
ticketOpenDraw();
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
->postJson('/api/v1/ticket/place', [
'draw_id' => '20260511-001',
'currency_code' => 'NPR',
'client_trace_id' => 'trace-digit-d2-slot',
'lines' => [
['number' => '9', 'play_code' => 'digit_big', 'amount' => 100, 'dimension' => 'D2', 'digit_slot' => 3],
],
])
->assertOk()
->assertJsonPath('data.summary.success_count', 1)
->assertJsonPath('data.items.0.status', 'pending_draw');
$item = TicketItem::query()->where('play_code', 'digit_big')->firstOrFail();
$ruleSnapshot = is_array($item->rule_snapshot_json) ? $item->rule_snapshot_json : [];
expect((int) $item->dimension)->toBe(2)
->and((int) $item->digit_slot)->toBe(3)
->and((int) $item->combination_count)->toBe(5000)
->and($ruleSnapshot['dimension'] ?? null)->toBe('D2')
->and($ruleSnapshot['digit_slot'] ?? null)->toBe(3)
->and(TicketCombination::query()->where('ticket_item_id', $item->id)->where('number_4d', '0009')->exists())->toBeTrue()
->and(TicketCombination::query()->where('ticket_item_id', $item->id)->where('number_4d', '0004')->exists())->toBeFalse();
});
test('ticket preview rejects invalid line amount per validation rules', function (): void { test('ticket preview rejects invalid line amount per validation rules', function (): void {
$player = ticketPlayerWithWallet(); $player = ticketPlayerWithWallet();
ticketOpenDraw(); ticketOpenDraw();
@@ -833,16 +1009,18 @@ test('ticket place reverses wallet and releases risk when post deduction confirm
$player = ticketPlayerWithWallet(20_000); $player = ticketPlayerWithWallet(20_000);
$draw = ticketOpenDraw(); $draw = ticketOpenDraw();
JackpotPool::query()->create([ JackpotPool::query()->updateOrCreate(
'currency_code' => 'NPR', ['currency_code' => 'NPR'],
'current_amount' => 0, [
'contribution_rate' => 1, 'current_amount' => 0,
'trigger_threshold' => 0, 'contribution_rate' => 1,
'payout_rate' => 0, 'trigger_threshold' => 0,
'force_trigger_draw_gap' => 0, 'payout_rate' => 0,
'min_bet_amount' => 0, 'force_trigger_draw_gap' => 0,
'status' => 1, 'min_bet_amount' => 0,
]); 'status' => 1,
],
);
DB::statement("CREATE TRIGGER fail_jackpot_contribution_insert BEFORE INSERT ON jackpot_contributions BEGIN SELECT RAISE(ABORT, 'forced_confirmation_failure'); END"); DB::statement("CREATE TRIGGER fail_jackpot_contribution_insert BEFORE INSERT ON jackpot_contributions BEGIN SELECT RAISE(ABORT, 'forced_confirmation_failure'); END");
$this->withHeader('Authorization', 'Bearer dev:'.$player->id) $this->withHeader('Authorization', 'Bearer dev:'.$player->id)