phase1(webhook): failed() callback + FailedWebhookJob модель — упавшие jobs после 3 ретраев
После исчерпания всех 3 ретраев Laravel вызывает failed(\Throwable $e) —
упавший job сохраняется в failed_webhook_jobs для ручного разбора и
повторного запуска через админку.
Реализация:
- app/app/Models/FailedWebhookJob.php — Eloquent для failed_webhook_jobs
- ProcessWebhookJob::failed() через DB::table->insert (не Eloquent::create)
чтобы обойти RLS: failed-callback запускается вне транзакции воркера,
SET LOCAL app.current_tenant_id не выставлен, политика бы отвергла INSERT.
Запись должна попасть в БД даже в катастрофическом сценарии.
- payload через json_encode(JSON_UNESCAPED_UNICODE) — UTF-8 кириллица
сохраняется
Sentry::captureException оставлен как TODO для production (на dev-стеке
нет DSN).
3 новых Pest-теста:
- failed() пишет упавший job с webhookLogId (через DB::table('webhook_log')
для FK satisfaction)
- failed() работает БЕЗ webhookLogId (NULL ok — soft FK)
- failed() записывает payload с UTF-8 кириллицей корректно
Pest 48/48 зелёные за 4.7 сек. Pint + Larastan чисто.
Webhook-flow покрыт полностью на dev-стеке (за исключением Sentry и
SendNewLeadNotificationJob — Биз-20 Telegram, ждёт настоящего бота).
CLAUDE.md v1.15 → v1.16. Реестр Открытые_вопросы v1.24 → v1.25.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -377,3 +377,56 @@ test('Биз-19: ActivityLog для дубля содержит context.duplicat
|
||||
expect($masterLog->context)->toBe(['source' => 'webhook']);
|
||||
expect($dupLog->context)->toBe(['source' => 'webhook', 'duplicate_of' => $master->id]);
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// failed() callback — финальная обработка после исчерпания ретраев
|
||||
// =============================================================================
|
||||
|
||||
test('failed() пишет упавший job в failed_webhook_jobs', function () {
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 10]);
|
||||
$webhookLogId = (int) DB::table('webhook_log')->insertGetId([
|
||||
'tenant_id' => $tenant->id,
|
||||
'raw_payload' => json_encode(['vid' => 1001]),
|
||||
'received_at' => now(),
|
||||
]);
|
||||
$payload = makePayload(vid: 1001);
|
||||
|
||||
$job = new ProcessWebhookJob($tenant->id, $payload, webhookLogId: $webhookLogId);
|
||||
$job->failed(new RuntimeException('boom: db down'));
|
||||
|
||||
$row = DB::table('failed_webhook_jobs')
|
||||
->where('tenant_id', $tenant->id)
|
||||
->first();
|
||||
|
||||
expect($row)->not->toBeNull();
|
||||
expect($row->webhook_log_id)->toBe($webhookLogId);
|
||||
expect($row->exception)->toBe('boom: db down');
|
||||
expect($row->retry_count)->toBe(3);
|
||||
expect($row->resolved_at)->toBeNull();
|
||||
expect(json_decode($row->raw_payload, true)['vid'])->toBe(1001);
|
||||
});
|
||||
|
||||
test('failed() работает БЕЗ webhookLogId (NULL ok)', function () {
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 10]);
|
||||
|
||||
$job = new ProcessWebhookJob($tenant->id, makePayload(vid: 1002));
|
||||
$job->failed(new RuntimeException('no webhook log id'));
|
||||
|
||||
$row = DB::table('failed_webhook_jobs')->where('tenant_id', $tenant->id)->first();
|
||||
expect($row)->not->toBeNull();
|
||||
expect($row->webhook_log_id)->toBeNull();
|
||||
});
|
||||
|
||||
test('failed() записывает payload с UTF-8 кириллицей корректно', function () {
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 10]);
|
||||
$payload = makePayload(vid: 1003);
|
||||
$payload['contact_name'] = 'Дмитрий Петров';
|
||||
|
||||
$job = new ProcessWebhookJob($tenant->id, $payload);
|
||||
$job->failed(new RuntimeException('utf-8 test'));
|
||||
|
||||
$row = DB::table('failed_webhook_jobs')->where('tenant_id', $tenant->id)->first();
|
||||
$decoded = json_decode($row->raw_payload, true);
|
||||
|
||||
expect($decoded['contact_name'])->toBe('Дмитрий Петров');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user