feat(core): harden sessions settlement and credit activity
This commit is contained in:
57
app/Services/Settlement/DrawSettlementStartService.php
Normal file
57
app/Services/Settlement/DrawSettlementStartService.php
Normal file
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Settlement;
|
||||
|
||||
use App\Models\Draw;
|
||||
use App\Lottery\DrawStatus;
|
||||
use App\Models\DrawResultBatch;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use App\Lottery\DrawResultBatchStatus;
|
||||
|
||||
/**
|
||||
* 将期号推进到可结算状态。
|
||||
*
|
||||
* 冷静期内的人工操作只跳过当前期剩余的核错窗口,不修改全局冷静期配置。
|
||||
*/
|
||||
final class DrawSettlementStartService
|
||||
{
|
||||
/**
|
||||
* @return array{draw: Draw, cooldown_skipped: bool}
|
||||
*/
|
||||
public function start(Draw $draw): array
|
||||
{
|
||||
return DB::transaction(function () use ($draw): array {
|
||||
/** @var Draw $locked */
|
||||
$locked = Draw::query()->whereKey($draw->id)->lockForUpdate()->firstOrFail();
|
||||
|
||||
if (! in_array($locked->status, [
|
||||
DrawStatus::Cooldown->value,
|
||||
DrawStatus::Settling->value,
|
||||
], true)) {
|
||||
throw new \RuntimeException('draw_not_ready_for_settlement');
|
||||
}
|
||||
|
||||
$hasPublishedResult = DrawResultBatch::query()
|
||||
->where('draw_id', $locked->id)
|
||||
->where('status', DrawResultBatchStatus::Published->value)
|
||||
->exists();
|
||||
|
||||
if (! $hasPublishedResult) {
|
||||
throw new \RuntimeException('draw_result_not_published');
|
||||
}
|
||||
|
||||
$cooldownSkipped = $locked->status === DrawStatus::Cooldown->value;
|
||||
if ($cooldownSkipped) {
|
||||
$locked->forceFill([
|
||||
'status' => DrawStatus::Settling->value,
|
||||
'cooling_end_time' => now(),
|
||||
])->save();
|
||||
}
|
||||
|
||||
return [
|
||||
'draw' => $locked->fresh(),
|
||||
'cooldown_skipped' => $cooldownSkipped,
|
||||
];
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Services\Settlement;
|
||||
|
||||
use App\Services\AuditLogger;
|
||||
use Illuminate\Support\Carbon;
|
||||
use App\Models\SettlementBatch;
|
||||
use App\Services\LotterySettings;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
@@ -23,19 +24,21 @@ final class SettlementTickFinalizer
|
||||
$approved = 0;
|
||||
$paid = 0;
|
||||
$payoutFailed = 0;
|
||||
$finalizeLimit = (int) config('lottery.draw_tick_finalize_limit', 5);
|
||||
|
||||
$autoApprove = (bool) LotterySettings::get('settlement.auto_approve_on_tick', true);
|
||||
$pending = $autoApprove
|
||||
? SettlementBatch::query()
|
||||
->where('status', SettlementBatchStatus::PendingReview->value)
|
||||
->orderBy('id')
|
||||
->limit((int) config('lottery.draw_tick_finalize_limit', 5))
|
||||
->limit($finalizeLimit)
|
||||
->get()
|
||||
: collect();
|
||||
|
||||
// 除本轮待审核批次外,也恢复处理上轮已批准但尚未派彩的批次。
|
||||
// 这样进程即使在 approve 提交后、payout 开始前退出,下一 tick 仍能续跑。
|
||||
$approvedDrawIds = DB::table('settlement_batches as approved')
|
||||
$retryBaseSeconds = (int) config('lottery.auto_payout_retry_base_seconds', 60);
|
||||
$approvedDrawCandidates = DB::table('settlement_batches as approved')
|
||||
->where('approved.status', SettlementBatchStatus::Approved->value)
|
||||
->whereNotExists(function ($query): void {
|
||||
$query->selectRaw('1')
|
||||
@@ -47,10 +50,21 @@ final class SettlementTickFinalizer
|
||||
]);
|
||||
})
|
||||
->groupBy('approved.draw_id')
|
||||
->selectRaw('approved.draw_id, MAX(approved.auto_payout_attempts) AS max_auto_payout_attempts, MAX(approved.updated_at) AS last_attempt_at')
|
||||
->havingRaw(
|
||||
'(MAX(approved.auto_payout_attempts) = 0 OR MAX(approved.updated_at) <= ?)',
|
||||
[now()->subSeconds($retryBaseSeconds)],
|
||||
)
|
||||
->orderByRaw('MAX(approved.auto_payout_attempts)')
|
||||
->orderByRaw('MIN(approved.id)')
|
||||
->limit((int) config('lottery.draw_tick_finalize_limit', 5))
|
||||
->pluck('approved.draw_id');
|
||||
->cursor();
|
||||
$approvedDrawIds = $approvedDrawCandidates
|
||||
->filter(fn (object $candidate): bool => $this->autoPayoutRetryIsDue(
|
||||
(int) $candidate->max_auto_payout_attempts,
|
||||
$candidate->last_attempt_at !== null ? (string) $candidate->last_attempt_at : null,
|
||||
))
|
||||
->take($finalizeLimit)
|
||||
->pluck('draw_id');
|
||||
$candidateDrawIds = $pending->pluck('draw_id')
|
||||
->merge($approvedDrawIds)
|
||||
->map(fn ($id): int => (int) $id)
|
||||
@@ -139,4 +153,20 @@ final class SettlementTickFinalizer
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
private function autoPayoutRetryIsDue(int $attempts, ?string $lastAttemptAt): bool
|
||||
{
|
||||
if ($attempts <= 0 || $lastAttemptAt === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$baseSeconds = (int) config('lottery.auto_payout_retry_base_seconds', 60);
|
||||
$maxSeconds = max($baseSeconds, (int) config('lottery.auto_payout_retry_max_seconds', 3600));
|
||||
$exponent = min($attempts - 1, 20);
|
||||
$delaySeconds = min($maxSeconds, $baseSeconds * (2 ** $exponent));
|
||||
|
||||
return now()->greaterThanOrEqualTo(
|
||||
Carbon::parse($lastAttemptAt)->addSeconds($delaySeconds),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user