Files
lotteryLaravel/app/Services/Draw/DrawRngRunner.php
kang d4779660c8
Some checks failed
lotterLaravel CI / test (push) Has been cancelled
lotterLaravel E2E / e2e-api (push) Has been cancelled
feat(draw): 支持多玩法provider及本地化结算时区功能
- Draw相关控制器添加provider_code和provider_name字段支持
- 允许手动录入开奖批次时指定provider_code
- RNG开奖批次生成支持多provider,分别生成对应批次数据
- DrawPublishService和DrawManualResultService中支持基于provider_code管理开奖版本和发布流程
- DrawResultViewService调整,支持按provider汇总及筛选开奖结果
- Settlement处理逻辑调整,按provider区分待结算票据及批次,分开结算
- 结算期间管理相关服务支持使用站点本地结算时区计算开账建议和日期处理
- 增加AdminSite的settlement_timezone字段及相关请求验证和数据存取支持
- 优化AdminSettlementPeriod相关代码,防止存在未结清票据时关账
- Ticket明细返回接口添加结算时区信息,提升用户时间体验
- 多处查询排序和版本号处理改进,保证多provider数据正确顺序与一致性
2026-07-09 15:48:21 +08:00

174 lines
6.2 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
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;
/**
* 按配置执行 RNG写入 {@see DrawResultBatch} / {@see DrawResultItem}。
*/
final class DrawRngRunner
{
public function __construct(
private readonly DrawPublishService $publisher,
) {}
/** 已对单期加锁外层调用时使用 */
public function executeLocked(Draw $draw): DrawResultBatch
{
$draw->forceFill([
'status' => DrawStatus::Drawing->value,
])->save();
$manualReview = LotterySettings::drawRequireManualReview();
$providers = $this->enabledProvidersForRng();
$batches = collect();
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,
'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 {
foreach ($batches as $batch) {
$this->publisher->markPublishedInTransaction($draw->fresh(), $batch->fresh());
}
}
return $primaryBatch->fresh();
}
/**
* @return array{rung: int, errors: array<int, string>}
*/
public function runDue(?Carbon $now = null): array
{
$nowUtc = ($now ?? Carbon::now())->utc();
$rung = 0;
$errors = [];
$ids = Draw::query()
->where('status', DrawStatus::Closed->value)
->whereNotNull('draw_time')
->where('draw_time', '<=', $nowUtc)
->where('settle_version', 0)
->where(function ($q): void {
$q->where('is_reopened', true)
->orWhereDoesntHave('resultBatches');
})
->orderBy('draw_time')
->limit((int) config('lottery.draw_tick_rng_limit', 10))
->pluck('id');
foreach ($ids as $drawId) {
try {
DB::transaction(function () use ($drawId, &$rung): void {
/** @var Draw|null $locked */
$locked = Draw::query()->whereKey($drawId)->lockForUpdate()->first();
if ($locked === null || $locked->status !== DrawStatus::Closed->value) {
return;
}
if ((int) $locked->settle_version > 0) {
return;
}
if (! (bool) $locked->is_reopened && $locked->resultBatches()->exists()) {
return;
}
$this->executeLocked($locked);
$rung++;
});
} catch (\Throwable $e) {
$errors[] = (string) $drawId.': '.$e->getMessage();
}
}
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',
]]);
}
}