where('status', SettlementBatchStatus::PendingReview->value) ->orderBy('id') ->limit($finalizeLimit) ->get() : collect(); // 除本轮待审核批次外,也恢复处理上轮已批准但尚未派彩的批次。 // 这样进程即使在 approve 提交后、payout 开始前退出,下一 tick 仍能续跑。 $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') ->from('settlement_batches as sibling') ->whereColumn('sibling.draw_id', 'approved.draw_id') ->whereIn('sibling.status', [ SettlementBatchStatus::Running->value, SettlementBatchStatus::PendingReview->value, ]); }) ->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)') ->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) ->unique() ->values(); 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)) { return ['approved' => $approved, 'paid' => 0, 'payout_failed' => 0]; } foreach ($candidateDrawIds as $drawId) { $hasUnapprovedBatch = SettlementBatch::query() ->where('draw_id', $drawId) ->whereIn('status', [ SettlementBatchStatus::Running->value, SettlementBatchStatus::PendingReview->value, ]) ->exists(); if ($hasUnapprovedBatch) { continue; } $approvedBatches = SettlementBatch::query() ->where('draw_id', $drawId) ->where('status', SettlementBatchStatus::Approved->value) ->orderBy('id') ->get(); foreach ($approvedBatches as $batch) { try { $this->workflow->payout($batch); $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); $this->recordAutoPayoutFailure($batch, $e); $payoutFailed++; } } } return ['approved' => $approved, 'paid' => $paid, 'payout_failed' => $payoutFailed]; } private function recordAutoPayoutFailure(SettlementBatch $batch, \Throwable $e): void { $message = mb_substr($e->getMessage(), 0, 200); DB::transaction(function () use ($batch, $message): void { $locked = SettlementBatch::query()->whereKey($batch->id)->lockForUpdate()->first(); if ($locked === null || $locked->status !== SettlementBatchStatus::Approved->value) { return; } $locked->forceFill([ 'auto_payout_attempts' => (int) $locked->auto_payout_attempts + 1, 'review_remark' => 'auto_payout_failed: '.$message, ])->save(); AuditLogger::recordForSystem( moduleCode: 'settlement', actionCode: 'auto_payout_failed', targetType: 'settlement_batch', targetId: (string) $locked->id, afterJson: [ 'draw_id' => (int) $locked->draw_id, 'error' => $message, ], ); }); } 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), ); } }