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.*.dimension' => ['nullable', 'integer', 'min:0', 'max:255'],
'items.*.bet_mode' => ['nullable', 'string', 'max:32'],
'items.*.display_name_zh' => ['required', 'string', 'max:64'],
'items.*.display_name_en' => ['nullable', 'string', 'max:64'],
'items.*.display_name_ne' => ['nullable', 'string', 'max:64'],
'items.*.display_name' => ['required', 'string', 'max:64'],
'items.*.is_enabled' => ['sometimes', 'boolean'],
'items.*.min_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;
use App\Models\AdminUser;
use App\Models\JackpotPool;
use App\Support\ApiResponse;
use App\Lottery\ErrorCode;
use Illuminate\Http\Request;
use App\Models\JackpotPayoutLog;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\DB;
use App\Http\Controllers\Controller;
use App\Services\Jackpot\JackpotManualBurstService;
final class AdminJackpotPoolManualBurstController extends Controller
{
public function __construct(
private readonly JackpotManualBurstService $service,
) {}
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([
'draw_id' => 'required|integer|exists:draws,id',
'amount' => 'nullable|integer|min:1',
]);
$payload = DB::transaction(function () use ($pool, $data): array {
/** @var JackpotPool $locked */
$locked = JackpotPool::query()->whereKey($pool->id)->lockForUpdate()->firstOrFail();
$poolBefore = (int) $locked->current_amount;
$amount = isset($data['amount']) ? min((int) $data['amount'], $poolBefore) : $poolBefore;
if ($amount <= 0) {
return [
'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,
];
});
try {
$payload = $this->service->execute($pool, (int) $data['draw_id']);
} catch (\RuntimeException $e) {
return ApiResponse::error(
trans('api.jackpot_manual_burst_failed', ['reason' => $e->getMessage()], $request->lotteryLocale()),
ErrorCode::ClientHttpError->value,
['reason' => $e->getMessage()],
409,
);
}
return ApiResponse::success($payload);
}

View File

@@ -3,30 +3,46 @@
namespace App\Http\Controllers\Api\V1\Admin;
use App\Models\PlayType;
use App\Models\AdminUser;
use App\Support\ApiResponse;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
use App\Http\Controllers\Controller;
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
{
public function __construct(
private readonly PlayConfigStreamService $playConfig,
) {}
public function __invoke(Request $request, string $play_code): JsonResponse
{
/** @var AdminUser $admin */
$admin = $request->lotteryAdmin();
/** @var PlayType $type */
$type = PlayType::query()->where('play_code', $play_code)->firstOrFail();
$data = $request->validate([
'is_enabled' => ['sometimes', 'boolean'],
'sort_order' => ['sometimes', 'integer'],
'display_name_zh' => ['sometimes', 'nullable', 'string', 'max:64'],
'display_name_en' => ['sometimes', 'nullable', 'string', 'max:64'],
'display_name_ne' => ['sometimes', 'nullable', 'string', 'max:64'],
'display_name' => ['sometimes', 'nullable', 'string', 'max:64'],
'supports_multi_number' => ['sometimes', 'boolean'],
'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 !== []) {
$type->fill($data);
$type->save();

View File

@@ -2,6 +2,7 @@
namespace App\Http\Controllers\Api\V1\Admin\Wallet;
use App\Models\AdminUser;
use App\Support\ApiResponse;
use App\Models\TransferOrder;
use App\Support\PaginationTrait;
@@ -80,8 +81,11 @@ final class TransferOrderListController extends Controller
}
}
$admin = $request->lotteryAdmin();
$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([
'items' => $items,
@@ -94,10 +98,14 @@ final class TransferOrderListController extends Controller
/**
* @return array<string, mixed>
*/
private function formatRow(TransferOrder $o): array
private function formatRow(TransferOrder $o, ?AdminUser $admin): array
{
$p = $o->player;
$amount = (int) $o->amount;
$canWriteWallet = $admin !== null && (
$admin->hasAdminPermission('prd.wallet_adjust.manage')
|| $admin->hasAdminPermission('prd.wallet_reconcile.manage')
);
return [
'id' => $o->id,
@@ -113,8 +121,8 @@ final class TransferOrderListController extends Controller
'amount_formatted' => CurrencyFormatter::fromMinor($amount),
'idempotent_key' => $o->idempotent_key,
'status' => $o->status,
'can_reverse' => $o->status === 'pending_reconcile',
'can_manually_process' => in_array($o->status, ['processing', 'failed', 'pending_reconcile'], true),
'can_reverse' => $canWriteWallet && $o->status === 'pending_reconcile',
'can_manually_process' => $canWriteWallet && in_array($o->status, ['processing', 'failed', 'pending_reconcile'], true),
'external_ref_no' => $o->external_ref_no,
'external_request_payload' => $o->external_request_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',
'dimension',
'bet_mode',
'display_name_zh',
'display_name_en',
'display_name_ne',
'display_name',
'is_enabled',
'min_bet_amount',
'max_bet_amount',

View File

@@ -12,9 +12,7 @@ final class PlayType extends Model
'category',
'dimension',
'bet_mode',
'display_name_zh',
'display_name_en',
'display_name_ne',
'display_name',
'is_enabled',
'sort_order',
'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(
private readonly DrawHallSnapshotBuilder $hallSnapshot,
private readonly AdminReportQueryService $reportQuery,
) {}
/** @return array<string, mixed> */
@@ -34,6 +35,8 @@ final class AdminDashboardSnapshotBuilder
$out = [
'hall' => $hall,
'resolved_draw' => null,
'today_finance' => null,
'lifetime_finance' => null,
'finance' => null,
'draw' => null,
'risk' => null,
@@ -67,6 +70,8 @@ final class AdminDashboardSnapshotBuilder
];
if ($canDraw) {
$out['today_finance'] = $this->todayFinanceSummary();
$out['lifetime_finance'] = $this->reportQuery->platformLifetimeTotals();
$out['finance'] = $this->financeSummary($draw);
$out['draw'] = $this->drawPanel($draw);
$out['risk'] = $this->riskPanel($draw);
@@ -81,8 +86,11 @@ final class AdminDashboardSnapshotBuilder
private function canDrawFinanceAndRisk(AdminUser $admin): bool
{
return $admin->hasAdminPermission('prd.draw_result.manage')
|| $admin->hasAdminPermission('prd.draw_result.view');
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');
}
private function canWalletReconcile(AdminUser $admin): bool
@@ -99,6 +107,36 @@ final class AdminDashboardSnapshotBuilder
->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> */
private function financeSummary(Draw $draw): array
{

View File

@@ -28,6 +28,180 @@ final class AdminReportQueryService
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
{
$rows = $this->dailyProfitRows($dateFrom, $dateTo);
@@ -80,6 +254,57 @@ final class AdminReportQueryService
->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(
?int $playerId,
string $dateFrom,
@@ -340,4 +565,26 @@ final class AdminReportQueryService
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,
'dimension' => $c->dimension === null ? null : (int) $c->dimension,
'bet_mode' => $c->bet_mode,
'display_name_zh' => $c->display_name_zh,
'display_name_en' => $c->display_name_en,
'display_name_ne' => $c->display_name_ne,
'display_name' => $c->display_name,
'sort_order' => (int) $c->display_order,
'supports_multi_number' => (bool) $c->supports_multi_number,
'master_enabled' => (bool) $c->is_enabled,
@@ -148,9 +146,7 @@ final class EffectivePlayCatalogService
'category' => $r->category,
'dimension' => $r->dimension === null ? null : (int) $r->dimension,
'bet_mode' => $r->bet_mode,
'display_name_zh' => $r->display_name_zh,
'display_name_en' => $r->display_name_en,
'display_name_ne' => $r->display_name_ne,
'display_name' => $r->display_name,
'is_enabled' => (bool) $r->is_enabled,
'min_bet_amount' => (int) $r->min_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\Lottery\ConfigVersionStatus;
use App\Services\Draw\LotteryHallRealtimeBroadcaster;
use App\Http\Middleware\RecordAdminApiAudit;
use Illuminate\Validation\ValidationException;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
@@ -162,6 +163,7 @@ final class OddsStreamService
beforeJson: $before,
afterJson: $after,
);
$request?->attributes->set(RecordAdminApiAudit::ATTRIBUTE_AUDIT_RECORDED, true);
}
public function deleteVersion(OddsVersion $version, AdminUser $admin, ?Request $request = null): void
@@ -182,6 +184,7 @@ final class OddsStreamService
beforeJson: $before,
afterJson: null,
);
$request?->attributes->set(RecordAdminApiAudit::ATTRIBUTE_AUDIT_RECORDED, true);
}
/** @return array<string, mixed> */

View File

@@ -11,6 +11,7 @@ use App\Models\PlayConfigVersion;
use Illuminate\Support\Facades\DB;
use App\Lottery\ConfigVersionStatus;
use App\Services\Draw\LotteryHallRealtimeBroadcaster;
use App\Http\Middleware\RecordAdminApiAudit;
use Illuminate\Validation\ValidationException;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
@@ -62,9 +63,7 @@ final class PlayConfigStreamService
'category' => $row->category,
'dimension' => $row->dimension,
'bet_mode' => $row->bet_mode,
'display_name_zh' => $row->display_name_zh,
'display_name_en' => $row->display_name_en,
'display_name_ne' => $row->display_name_ne,
'display_name' => $row->display_name,
'is_enabled' => $row->is_enabled,
'min_bet_amount' => $row->min_bet_amount,
'max_bet_amount' => $row->max_bet_amount,
@@ -85,9 +84,7 @@ final class PlayConfigStreamService
'category' => $pt->category,
'dimension' => $pt->dimension,
'bet_mode' => $pt->bet_mode,
'display_name_zh' => $pt->display_name_zh,
'display_name_en' => $pt->display_name_en,
'display_name_ne' => $pt->display_name_ne,
'display_name' => $pt->display_name,
'is_enabled' => (bool) $pt->is_enabled,
'min_bet_amount' => 100,
'max_bet_amount' => 500_000_000,
@@ -109,6 +106,53 @@ final class PlayConfigStreamService
/**
* @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
{
DB::transaction(function () use ($draft, $items, $admin): void {
@@ -121,9 +165,7 @@ final class PlayConfigStreamService
'category' => $row['category'] ?? null,
'dimension' => $row['dimension'] ?? null,
'bet_mode' => $row['bet_mode'] ?? null,
'display_name_zh' => $row['display_name_zh'] ?? null,
'display_name_en' => $row['display_name_en'] ?? null,
'display_name_ne' => $row['display_name_ne'] ?? null,
'display_name' => $row['display_name'] ?? null,
'is_enabled' => (bool) ($row['is_enabled'] ?? true),
'min_bet_amount' => (int) ($row['min_bet_amount'] ?? 0),
'max_bet_amount' => (int) ($row['max_bet_amount'] ?? 0),
@@ -183,6 +225,7 @@ final class PlayConfigStreamService
beforeJson: $before,
afterJson: $after,
);
$request?->attributes->set(RecordAdminApiAudit::ATTRIBUTE_AUDIT_RECORDED, true);
}
/**
@@ -223,6 +266,7 @@ final class PlayConfigStreamService
beforeJson: $before,
afterJson: null,
);
$request?->attributes->set(RecordAdminApiAudit::ATTRIBUTE_AUDIT_RECORDED, true);
}
/** @return array<string, mixed> */
@@ -278,8 +322,8 @@ final class PlayConfigStreamService
$errors["items.$index.max_bet_amount"][] = '最大下注额不能小于最小下注额';
}
if ($row->display_name_zh === null || $row->display_name_zh === '') {
$errors["items.$index.display_name_zh"][] = '显示名称不能为空';
if ($row->display_name === null || $row->display_name === '') {
$errors["items.$index.display_name"][] = '显示名称不能为空';
}
if ($row->display_order === null) {

View File

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

View File

@@ -30,6 +30,19 @@ final class DrawHallSnapshotBuilder
public function effectiveHallDisplayStatus(Draw $target, Carbon $nowUtc): string
{
$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) {
return $db;
}
@@ -62,7 +75,14 @@ final class DrawHallSnapshotBuilder
$nowUtc = ($nowUtc ?? Carbon::now())->utc();
$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 {
$q->whereNull('close_time')
->orWhere('close_time', '>', $nowUtc);
@@ -70,6 +90,10 @@ final class DrawHallSnapshotBuilder
->orderBy('draw_time')
->first();
if ($bettingOpen !== null) {
return $bettingOpen;
}
$chronological = Draw::query()
->whereNotIn('status', [
DrawStatus::Settled->value,
@@ -78,7 +102,29 @@ final class DrawHallSnapshotBuilder
->orderBy('draw_time')
->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>
* }
*/
/** 已发布批次的头奖 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
{
$byType = [

View File

@@ -32,8 +32,10 @@ final class DrawRngRunner
'draw.require_manual_review',
(bool) config('lottery.draw.require_manual_review', false),
);
$seedMaterial = bin2hex(random_bytes(32));
$rngSeedHash = hash('sha256', $seedMaterial);
$seedHex = DrawRngSeedDerivation::generateSeedHex();
$rngSeedHash = DrawRngSeedDerivation::hashSeedHex($seedHex);
$rawSeedEncrypted = DrawRngSeedDerivation::encryptSeedHex($seedHex);
$derivedRows = DrawRngSeedDerivation::deriveAllSlotRows($seedHex, (int) $draw->id);
$nextVersion = max(1, (int) $draw->current_result_version + 1);
@@ -42,28 +44,24 @@ final class DrawRngRunner
'result_version' => $nextVersion,
'source_type' => DrawResultSourceType::Rng->value,
'rng_seed_hash' => $rngSeedHash,
'raw_seed_encrypted' => null,
'raw_seed_encrypted' => $rawSeedEncrypted,
'status' => $manualReview ? DrawResultBatchStatus::PendingReview->value : DrawResultBatchStatus::Published->value,
'created_by' => null,
'confirmed_by' => null,
'confirmed_at' => $manualReview ? null : now(),
]);
foreach (DrawPrizeLayout::slots() as $slot) {
$num = str_pad((string) random_int(0, 9999), 4, '0', STR_PAD_LEFT);
$suffix3 = substr($num, -3);
$suffix2 = substr($num, -2);
foreach ($derivedRows as $row) {
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' => $suffix3,
'suffix_2d' => $suffix2,
'head_digit' => $num !== '' ? (int) substr($num, 0, 1) : null,
'tail_digit' => $num !== '' ? (int) substr($num, 3, 1) : null,
'prize_type' => $row['prize_type'],
'prize_index' => $row['prize_index'],
'number_4d' => $row['number_4d'],
'suffix_3d' => $row['suffix_3d'],
'suffix_2d' => $row['suffix_2d'],
'head_digit' => $row['head_digit'],
'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\Services\LotterySettings;
use App\Services\Settlement\SettlementOrchestrator;
use App\Services\Settlement\SettlementTickFinalizer;
/**
* 每分钟调度:期号状态推进 RNG若到期号 冷静期结束时进入结算态 补齐未来缓冲。
@@ -21,11 +22,14 @@ final class DrawTickService
private readonly DrawHallSnapshotBuilder $hallSnapshot,
private readonly LotteryHallRealtimeBroadcaster $hallRealtime,
private readonly SettlementOrchestrator $settlementOrchestrator,
private readonly SettlementTickFinalizer $settlementFinalizer,
) {}
/**
* @return array{
* status_updates: array<string, int>,
* settling_settled: int,
* settlement_finalized: array{approved: int, paid: int},
* rng_rung: int,
* rng_errors: array<int, string>,
* planned: array<string, int>
@@ -45,6 +49,7 @@ final class DrawTickService
];
$settlingSettled = $this->settleSettlingDraws();
$settlementFinalized = $this->settlementFinalizer->finalizePendingBatches();
$rngOutcome = $this->rng->runDue($nowUtc);
$planned = $this->planner->ensureBuffer($nowUtc);
@@ -52,6 +57,7 @@ final class DrawTickService
$report = [
'status_updates' => $statusUpdates,
'settling_settled' => $settlingSettled,
'settlement_finalized' => $settlementFinalized,
'rng_rung' => $rngOutcome['rung'],
'rng_errors' => $rngOutcome['errors'],
'planned' => $planned,

View File

@@ -22,7 +22,7 @@ final class LotteryHallRealtimeBroadcaster
private readonly DrawHallSnapshotBuilder $snapshot,
) {}
/** 每秒调度:`draw.countdown` 仅发送轻量心跳,不重查全量大厅快照。 */
/** 每秒调度:`draw.countdown` 推送大厅快照(与 GET draw/current 一致),避免仅本地倒计时无法切期。 */
public function countdownPulse(): void
{
if (! $this->driverSupportsRealtime()) {
@@ -31,7 +31,7 @@ final class LotteryHallRealtimeBroadcaster
$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;
/**
* 产品文档 §5.11.25.11.3:中头奖且满足阈值或连续未爆期数 按比例释放奖池,按注项 `total_bet_amount` 比例分配。
* 产品文档 §5.11.25.11.3:中头奖且满足阈值或连续未爆期数 按比例/全额释放奖池,按注项 `total_bet_amount` 比例分配。
*/
final class JackpotBurstAllocator
{
@@ -36,36 +36,72 @@ final class JackpotBurstAllocator
}
$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;
$poolPayout = (int) floor($poolBefore * (float) $pool->payout_rate);
$poolPayout = $releaseFullPool
? $poolBefore
: (int) floor($poolBefore * (float) $pool->payout_rate);
if ($poolPayout <= 0) {
return ['allocations' => [], 'pool_payout' => 0, 'trigger' => null];
return ['allocations' => [], 'pool_payout' => 0, 'trigger' => $trigger, 'log_id' => 0];
}
$list = $winners->values()->all();
$weightTotal = 0;
foreach ($list as $r) {
$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;
$allocations = $this->distributeByBetWeight($winnerItems, $poolPayout);
if ($allocations === []) {
return ['allocations' => [], 'pool_payout' => 0, 'trigger' => $trigger, 'log_id' => 0];
}
$pool->forceFill([
@@ -73,23 +109,59 @@ final class JackpotBurstAllocator
'last_trigger_draw_id' => $draw->id,
])->save();
JackpotPayoutLog::query()->create([
$log = JackpotPayoutLog::query()->create([
'draw_id' => $draw->id,
'jackpot_pool_id' => $pool->id,
'trigger_type' => $trigger,
'total_payout_amount' => $poolPayout,
'winner_count' => count($allocations),
'trigger_snapshot_json' => [
'threshold_ok' => $thresholdOk,
'gap_ok' => $gapOk,
'combo_ok' => $comboOk,
'combo_trigger_play_codes' => $this->comboTriggerPlayCodes($pool),
'trigger_snapshot_json' => array_merge($snapshotExtra, [
'pool_amount_before' => $poolBefore,
'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

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
{
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 */
$locked = SettlementBatch::query()->whereKey($batch->id)->lockForUpdate()->firstOrFail();
if ($locked->status !== SettlementBatchStatus::PendingReview->value) {
@@ -31,7 +41,7 @@ final class SettlementBatchWorkflowService
$locked->forceFill([
'status' => SettlementBatchStatus::Approved->value,
'review_status' => 'approved',
'reviewed_by' => $admin->id,
'reviewed_by' => $reviewedBy,
'reviewed_at' => now(),
'review_remark' => $remark,
])->save();

View File

@@ -13,6 +13,7 @@ use Illuminate\Support\Facades\DB;
use App\Lottery\DrawResultBatchStatus;
use App\Lottery\SettlementBatchStatus;
use App\Models\TicketSettlementDetail;
use App\Services\Draw\DrawHallSnapshotBuilder;
use App\Services\Draw\LotteryHallRealtimeBroadcaster;
use App\Services\Ticket\RiskPoolService;
use App\Services\Jackpot\JackpotBurstAllocator;
@@ -30,6 +31,7 @@ final class SettlementOrchestrator
private readonly JackpotBurstAllocator $jackpotBurst,
private readonly RiskPoolService $riskPool,
private readonly LotteryHallRealtimeBroadcaster $hallRealtime,
private readonly DrawHallSnapshotBuilder $hallSnapshot,
) {}
/**
@@ -214,6 +216,7 @@ final class SettlementOrchestrator
$jackpotTrigger,
(int) $jackpotPoolAfter,
);
$this->hallRealtime->notifyStatusChange($this->hallSnapshot->build());
}
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
{
if ($amountMinor <= 0) {

View File

@@ -20,32 +20,40 @@ final class AdminAuthorizationRegistry
public static function permissionDefinitions(): array
{
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_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.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.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_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.view', 'name' => '开奖结果·查看', 'nav_segment' => 'draws', 'permission_codes' => ['draw.results.view', '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']],
['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.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.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.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.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.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.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 [
// 总览
['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' => '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' => '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' => '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']],
@@ -129,7 +138,7 @@ final class AdminAuthorizationRegistry
// 权限与系统
['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' => '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' => '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
{
$explicit = [
'dashboard' => ['prd.dashboard.view'],
'admin_users' => ['prd.admin_user.manage'],
'admin_roles' => ['prd.admin_role.manage'],
'players' => ['prd.users.manage', 'prd.users.view_finance', 'prd.users.view_cs', 'prd.player_freeze.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'],
'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'],
'jackpot' => ['prd.jackpot.manage', 'prd.jackpot.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'],
'reconcile' => ['prd.wallet_reconcile.manage', 'prd.wallet_reconcile.view', 'prd.wallet_reconcile.view_cs'],
'reports' => ['prd.report.view'],
'tickets' => ['prd.users.view_cs', 'prd.users.manage'],
'reports' => ['prd.report.view', 'prd.report.export'],
'tickets' => ['prd.tickets.view'],
'audit' => ['prd.audit.view'],
'settings' => [],
'settings' => ['prd.wallet_reconcile.manage', 'prd.currency.manage'],
];
if (isset($explicit[$segment])) {
@@ -331,7 +341,8 @@ final class AdminAuthorizationRegistry
{
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.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.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.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.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.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.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.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.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.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.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.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.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.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.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.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.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.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.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.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.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.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.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.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']],
['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', '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']],
['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']],
['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, '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, '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.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.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.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.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, '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, '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.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.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.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.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.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']],
@@ -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.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.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-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.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-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', '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', '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.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.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.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.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.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', '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.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']],
@@ -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.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.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.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.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.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.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.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.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_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.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']],
@@ -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.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.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.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.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.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', '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.export']],
];
}

View File

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