feat(draw): 支持多玩法provider及本地化结算时区功能
Some checks failed
lotterLaravel CI / test (push) Has been cancelled
lotterLaravel E2E / e2e-api (push) Has been cancelled

- Draw相关控制器添加provider_code和provider_name字段支持
- 允许手动录入开奖批次时指定provider_code
- RNG开奖批次生成支持多provider,分别生成对应批次数据
- DrawPublishService和DrawManualResultService中支持基于provider_code管理开奖版本和发布流程
- DrawResultViewService调整,支持按provider汇总及筛选开奖结果
- Settlement处理逻辑调整,按provider区分待结算票据及批次,分开结算
- 结算期间管理相关服务支持使用站点本地结算时区计算开账建议和日期处理
- 增加AdminSite的settlement_timezone字段及相关请求验证和数据存取支持
- 优化AdminSettlementPeriod相关代码,防止存在未结清票据时关账
- Ticket明细返回接口添加结算时区信息,提升用户时间体验
- 多处查询排序和版本号处理改进,保证多provider数据正确顺序与一致性
This commit is contained in:
2026-07-09 15:48:21 +08:00
parent c6c10f1397
commit d4779660c8
38 changed files with 1121 additions and 271 deletions

View File

@@ -4,6 +4,7 @@ namespace App\Services\Draw;
use App\Models\Draw;
use App\Models\AdminUser;
use App\Models\BetProvider;
use App\Lottery\DrawStatus;
use App\Models\DrawResultItem;
use App\Models\DrawResultBatch;
@@ -16,9 +17,9 @@ final class DrawManualResultService
/**
* @param list<array{prize_type: string, prize_index: int, number_4d: string}> $items
*/
public function createPendingBatch(Draw $draw, AdminUser $admin, array $items): DrawResultBatch
public function createPendingBatch(Draw $draw, AdminUser $admin, array $items, ?string $providerCode = null): DrawResultBatch
{
return DB::transaction(function () use ($draw, $admin, $items): DrawResultBatch {
return DB::transaction(function () use ($draw, $admin, $items, $providerCode): DrawResultBatch {
/** @var Draw $locked */
$locked = Draw::query()->whereKey($draw->id)->lockForUpdate()->firstOrFail();
if (! in_array($locked->status, [DrawStatus::Closed->value, DrawStatus::Review->value], true)) {
@@ -28,16 +29,34 @@ final class DrawManualResultService
throw new \RuntimeException('draw_already_settled');
}
$providerCode = strtoupper(trim((string) ($providerCode ?: BetProvider::DEFAULT_CODE)));
/** @var BetProvider|null $provider */
$provider = BetProvider::query()
->where('code', $providerCode)
->where('is_enabled', true)
->first();
if ($provider === null && $providerCode !== BetProvider::DEFAULT_CODE) {
throw new \RuntimeException('bet_provider_not_found');
}
$providerName = $provider?->name ?? 'Singapore';
if (DrawResultBatch::query()
->where('draw_id', $locked->id)
->where('provider_code', $providerCode)
->where('status', DrawResultBatchStatus::PendingReview->value)
->exists()) {
throw new \RuntimeException('draw_pending_result_batch_exists');
}
$nextVersion = max(1, (int) $locked->current_result_version + 1);
$latestVersion = (int) DrawResultBatch::query()
->where('draw_id', $locked->id)
->where('provider_code', $providerCode)
->max('result_version');
$nextVersion = max(1, $latestVersion + 1);
$batch = DrawResultBatch::query()->create([
'draw_id' => $locked->id,
'provider_code' => $providerCode,
'provider_name' => $providerName,
'result_version' => $nextVersion,
'source_type' => DrawResultSourceType::Manual->value,
'rng_seed_hash' => null,

View File

@@ -38,6 +38,7 @@ final class DrawPublishService
DrawResultBatch::query()
->where('draw_id', $draw->id)
->where('provider_code', $lockedBatch->provider_code)
->where('id', '!=', $lockedBatch->id)
->where('status', DrawResultBatchStatus::Published->value)
->update(['status' => DrawResultBatchStatus::Rejected->value]);
@@ -80,17 +81,18 @@ final class DrawPublishService
private function applyPublishedToDraw(Draw $draw, DrawResultBatch $batch): Draw
{
$cooldownMinutes = LotterySettings::drawCooldownMinutes();
$currentResultVersion = max((int) $draw->current_result_version, (int) $batch->result_version);
if ($cooldownMinutes > 0) {
$draw->forceFill([
'status' => DrawStatus::Cooldown->value,
'current_result_version' => (int) $batch->result_version,
'current_result_version' => $currentResultVersion,
'result_source' => $batch->source_type,
'cooling_end_time' => now()->addMinutes($cooldownMinutes),
])->save();
} else {
$draw->forceFill([
'status' => DrawStatus::Settling->value,
'current_result_version' => (int) $batch->result_version,
'current_result_version' => $currentResultVersion,
'result_source' => $batch->source_type,
'cooling_end_time' => null,
])->save();
@@ -105,7 +107,12 @@ final class DrawPublishService
throw new \RuntimeException('draw_not_ready_to_publish');
}
if ((int) $batch->result_version < (int) $draw->current_result_version) {
$latestProviderVersion = (int) DrawResultBatch::query()
->where('draw_id', $draw->id)
->where('provider_code', $batch->provider_code)
->where('status', DrawResultBatchStatus::Published->value)
->max('result_version');
if ((int) $batch->result_version < $latestProviderVersion) {
throw new \RuntimeException('batch_result_version_stale');
}

View File

@@ -2,6 +2,7 @@
namespace App\Services\Draw;
use App\Models\BetProvider;
use App\Models\Draw;
use App\Lottery\DrawStatus;
use App\Models\DrawResultItem;
@@ -76,7 +77,7 @@ final class DrawResultViewService
*
* @return array<string, mixed>|null
*/
public function summarizeDraw(Draw $draw, ?string $currencyCode = null): ?array
public function summarizeDraw(Draw $draw, ?string $currencyCode = null, ?string $providerCode = null): ?array
{
$currencyCode = $this->normalizeCurrencyCode($currencyCode);
$version = (int) $draw->current_result_version;
@@ -84,30 +85,38 @@ final class DrawResultViewService
return null;
}
$batch = DrawResultBatch::query()
$batches = DrawResultBatch::query()
->where('draw_id', $draw->id)
->where('result_version', $version)
->where('status', DrawResultBatchStatus::Published->value)
->first();
->orderByRaw('case when provider_code = ? then 0 else 1 end', [BetProvider::DEFAULT_CODE])
->orderBy('provider_code')
->orderByDesc('id')
->get();
if ($batch === null) {
if ($batches->isEmpty()) {
return null;
}
$items = DrawResultItem::query()
->where('result_batch_id', $batch->id)
->orderBy('prize_type')
->orderBy('prize_index')
->get([
'prize_type', 'prize_index', 'number_4d',
'suffix_3d', 'suffix_2d', 'head_digit', 'tail_digit',
]);
$providerResults = $batches
->map(fn (DrawResultBatch $batch): ?array => $this->summarizeBatch($batch))
->filter()
->values();
if ($items->isEmpty()) {
if ($providerResults->isEmpty()) {
return null;
}
$numbers = $this->numbersFromItems($items);
$normalizedProviderCode = strtoupper(trim((string) ($providerCode ?? '')));
$selected = $normalizedProviderCode === ''
? null
: $providerResults->first(
fn (array $row): bool => strtoupper((string) ($row['provider_code'] ?? '')) === $normalizedProviderCode,
);
$primary = $selected ?? $providerResults->first();
if ($primary === null) {
return null;
}
return [
'draw_id' => $draw->draw_no,
@@ -119,16 +128,11 @@ final class DrawResultViewService
'result_source' => $draw->result_source,
'jackpot_currency_code' => $currencyCode,
'jackpot' => $this->jackpotSummary->summary($currencyCode),
'results' => $numbers,
'result_items' => $items->map(fn (DrawResultItem $r) => [
'prize_type' => $r->prize_type,
'prize_index' => (int) $r->prize_index,
'number_4d' => $r->number_4d,
'suffix_3d' => $r->suffix_3d,
'suffix_2d' => $r->suffix_2d,
'head_digit' => $r->head_digit !== null ? (int) $r->head_digit : null,
'tail_digit' => $r->tail_digit !== null ? (int) $r->tail_digit : null,
])->values()->all(),
'provider_code' => $primary['provider_code'],
'provider_name' => $primary['provider_name'],
'results' => $primary['results'],
'result_items' => $primary['result_items'],
'provider_results' => $providerResults->all(),
];
}
@@ -197,4 +201,36 @@ final class DrawResultViewService
return LotterySettings::defaultCurrency();
}
private function summarizeBatch(DrawResultBatch $batch): ?array
{
$items = DrawResultItem::query()
->where('result_batch_id', $batch->id)
->orderBy('prize_type')
->orderBy('prize_index')
->get([
'prize_type', 'prize_index', 'number_4d',
'suffix_3d', 'suffix_2d', 'head_digit', 'tail_digit',
]);
if ($items->isEmpty()) {
return null;
}
return [
'provider_code' => $batch->provider_code,
'provider_name' => $batch->provider_name,
'result_version' => (int) $batch->result_version,
'results' => $this->numbersFromItems($items),
'result_items' => $items->map(fn (DrawResultItem $r) => [
'prize_type' => $r->prize_type,
'prize_index' => (int) $r->prize_index,
'number_4d' => $r->number_4d,
'suffix_3d' => $r->suffix_3d,
'suffix_2d' => $r->suffix_2d,
'head_digit' => $r->head_digit !== null ? (int) $r->head_digit : null,
'tail_digit' => $r->tail_digit !== null ? (int) $r->tail_digit : null,
])->values()->all(),
];
}
}

View File

@@ -4,10 +4,12 @@ namespace App\Services\Draw;
use Carbon\Carbon;
use App\Models\Draw;
use App\Models\BetProvider;
use App\Lottery\DrawStatus;
use App\Models\DrawResultItem;
use App\Models\DrawResultBatch;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Collection;
use App\Services\LotterySettings;
use App\Lottery\DrawResultSourceType;
use App\Lottery\DrawResultBatchStatus;
@@ -29,49 +31,71 @@ final class DrawRngRunner
])->save();
$manualReview = LotterySettings::drawRequireManualReview();
$seedHex = DrawRngSeedDerivation::generateSeedHex();
$rngSeedHash = DrawRngSeedDerivation::hashSeedHex($seedHex);
$rawSeedEncrypted = DrawRngSeedDerivation::encryptSeedHex($seedHex);
$derivedRows = DrawRngSeedDerivation::deriveAllSlotRows($seedHex, (int) $draw->id);
$providers = $this->enabledProvidersForRng();
$batches = collect();
$nextVersion = max(1, (int) $draw->current_result_version + 1);
foreach ($providers as $provider) {
$seedHex = DrawRngSeedDerivation::generateSeedHex();
$rngSeedHash = DrawRngSeedDerivation::hashSeedHex($seedHex);
$rawSeedEncrypted = DrawRngSeedDerivation::encryptSeedHex($seedHex);
$derivedRows = DrawRngSeedDerivation::deriveAllSlotRows($seedHex, (int) $draw->id);
$versionQuery = DrawResultBatch::query()->where('draw_id', $draw->id);
if ($provider['code'] === BetProvider::DEFAULT_CODE) {
$versionQuery->where(function ($q) use ($provider): void {
$q->where('provider_code', $provider['code'])
->orWhereNull('provider_code');
});
} else {
$versionQuery->where('provider_code', $provider['code']);
}
$nextVersion = max(1, (int) $versionQuery->max('result_version') + 1);
$batch = DrawResultBatch::query()->create([
'draw_id' => $draw->id,
'result_version' => $nextVersion,
'source_type' => DrawResultSourceType::Rng->value,
'rng_seed_hash' => $rngSeedHash,
'raw_seed_encrypted' => $rawSeedEncrypted,
'status' => $manualReview ? DrawResultBatchStatus::PendingReview->value : DrawResultBatchStatus::Published->value,
'created_by' => null,
'confirmed_by' => null,
'confirmed_at' => $manualReview ? null : now(),
]);
foreach ($derivedRows as $row) {
DrawResultItem::query()->create([
$batch = DrawResultBatch::query()->create([
'draw_id' => $draw->id,
'result_batch_id' => $batch->id,
'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'],
'provider_code' => $provider['code'],
'provider_name' => $provider['name'],
'result_version' => $nextVersion,
'source_type' => DrawResultSourceType::Rng->value,
'rng_seed_hash' => $rngSeedHash,
'raw_seed_encrypted' => $rawSeedEncrypted,
'status' => $manualReview ? DrawResultBatchStatus::PendingReview->value : DrawResultBatchStatus::Published->value,
'created_by' => null,
'confirmed_by' => null,
'confirmed_at' => $manualReview ? null : now(),
]);
foreach ($derivedRows as $row) {
DrawResultItem::query()->create([
'draw_id' => $draw->id,
'result_batch_id' => $batch->id,
'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'],
]);
}
$batches->push($batch);
}
/** @var DrawResultBatch $primaryBatch */
$primaryBatch = $batches->firstWhere('provider_code', BetProvider::DEFAULT_CODE) ?? $batches->first();
if ($manualReview) {
$draw->forceFill([
'status' => DrawStatus::Review->value,
'result_source' => DrawResultSourceType::Rng->value,
])->save();
} else {
$this->publisher->markPublishedInTransaction($draw->fresh(), $batch->fresh());
foreach ($batches as $batch) {
$this->publisher->markPublishedInTransaction($draw->fresh(), $batch->fresh());
}
}
return $batch->fresh();
return $primaryBatch->fresh();
}
/**
@@ -93,7 +117,7 @@ final class DrawRngRunner
->orWhereDoesntHave('resultBatches');
})
->orderBy('draw_time')
->limit((int) config('lottery.draw_tick_rng_limit', 3))
->limit((int) config('lottery.draw_tick_rng_limit', 10))
->pluck('id');
foreach ($ids as $drawId) {
@@ -120,4 +144,30 @@ final class DrawRngRunner
return ['rung' => $rung, 'errors' => $errors];
}
/**
* @return Collection<int, array{code: string, name: string}>
*/
private function enabledProvidersForRng(): Collection
{
$providers = BetProvider::query()
->where('is_enabled', true)
->orderByRaw('case when code = ? then 0 else 1 end', [BetProvider::DEFAULT_CODE])
->orderBy('sort_order')
->orderBy('id')
->get(['code', 'name'])
->map(fn (BetProvider $provider): array => [
'code' => (string) $provider->code,
'name' => (string) $provider->name,
]);
if ($providers->isNotEmpty()) {
return $providers->values();
}
return collect([[
'code' => BetProvider::DEFAULT_CODE,
'name' => 'Singapore',
]]);
}
}