feat: 更新玩法配置管理,简化字段并增强功能
- 将玩法相关的显示名称字段统一为 `display_name`,移除多语言字段。 - 在 `PlayTypePatchController` 中新增即时切换玩法开关的功能,并推送大厅更新。 - 优化多个控制器和服务中的权限检查与数据处理逻辑,提升代码可读性与维护性。
This commit is contained in:
96
app/Console/Commands/LotteryPerfDrawScheduleAuditCommand.php
Normal file
96
app/Console/Commands/LotteryPerfDrawScheduleAuditCommand.php
Normal 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;
|
||||
}
|
||||
}
|
||||
243
app/Console/Commands/LotteryPerfSettlementBenchmarkCommand.php
Normal file
243
app/Console/Commands/LotteryPerfSettlementBenchmarkCommand.php
Normal 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];
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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'],
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
143
app/Http/Middleware/RecordAdminApiAudit.php
Normal file
143
app/Http/Middleware/RecordAdminApiAudit.php
Normal 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;
|
||||
}
|
||||
}
|
||||
45
app/Http/Requests/Admin/DashboardAnalyticsRequest.php
Normal file
45
app/Http/Requests/Admin/DashboardAnalyticsRequest.php
Normal 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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
82
app/Services/Admin/AdminDashboardAnalyticsBuilder.php
Normal file
82
app/Services/Admin/AdminDashboardAnalyticsBuilder.php
Normal 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');
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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> */
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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> */
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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'],
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
126
app/Services/Draw/DrawRngSeedDerivation.php
Normal file
126
app/Services/Draw/DrawRngSeedDerivation.php
Normal 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 位号码(0000–9999)。
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -10,7 +10,7 @@ use App\Models\JackpotPayoutLog;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
/**
|
||||
* 产品文档 §5.11.2–5.11.3:中头奖且满足阈值或连续未爆期数 → 按比例释放奖池,按注项 `total_bet_amount` 比例分配。
|
||||
* 产品文档 §5.11.2–5.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
|
||||
|
||||
264
app/Services/Jackpot/JackpotManualBurstService.php
Normal file
264
app/Services/Jackpot/JackpotManualBurstService.php
Normal 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 !== [];
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
65
app/Services/Settlement/SettlementTickFinalizer.php
Normal file
65
app/Services/Settlement/SettlementTickFinalizer.php
Normal 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];
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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']],
|
||||
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user