From ba97f952ccf24a4470f15ac1af86e2f09cabe818 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Fri, 8 May 2026 15:52:47 +0300 Subject: [PATCH] =?UTF-8?q?phase1(webhook):=20failed()=20callback=20+=20Fa?= =?UTF-8?q?iledWebhookJob=20=D0=BC=D0=BE=D0=B4=D0=B5=D0=BB=D1=8C=20?= =?UTF-8?q?=E2=80=94=20=D1=83=D0=BF=D0=B0=D0=B2=D1=88=D0=B8=D0=B5=20jobs?= =?UTF-8?q?=20=D0=BF=D0=BE=D1=81=D0=BB=D0=B5=203=20=D1=80=D0=B5=D1=82?= =?UTF-8?q?=D1=80=D0=B0=D0=B5=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit После исчерпания всех 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) --- CLAUDE.md | 6 ++- app/app/Jobs/ProcessWebhookJob.php | 36 ++++++++++++- app/app/Models/FailedWebhookJob.php | 57 +++++++++++++++++++++ app/tests/Feature/ProcessWebhookJobTest.php | 53 +++++++++++++++++++ cspell-words.txt | 1 + docs/Открытые_вопросы_v8_3.md | 16 +++++- 6 files changed, 165 insertions(+), 4 deletions(-) create mode 100644 app/app/Models/FailedWebhookJob.php diff --git a/CLAUDE.md b/CLAUDE.md index 24f73250..c1afaaed 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,6 @@ # CLAUDE.md — техконтекст Лидерры -**Версия:** 1.15 от 08.05.2026 (поздний вечер) +**Версия:** 1.16 от 08.05.2026 (поздний вечер) **Назначение:** оперативная карта для Claude Code. Не первоисточник — первоисточники указаны в §0. > **Ребрендинг 08.05.2026:** «Лидпоток» → **«Лидерра.»** (с точкой). Палитра, лого и шрифты — из handoff Платона (v8 Forest). Применяется только к дизайну/имени/логотипу; функционал, состав страниц и правила — без изменений (источник — ТЗ v8.5/schema v8.5). @@ -15,7 +15,7 @@ | Полный реестр 28 инструментов и фазы | [docs/Tooling_v8_3.md](docs/Tooling_v8_3.md) (Прил. Н v1.6 от 08.05.2026 поздний вечер — squawk + pgFormatter активны, фаза 1 по тулчейну закрыта 13/17) | | Главное ТЗ | [docs/CRM_bp-gr_Инструкция_v8_5.md](docs/CRM_bp-gr_Инструкция_v8_5.md) (v8.5 от 07.05.2026 — реализация 27 решений аудита C; in-place hygiene v1.20 от 08.05.2026 поздний вечер: §2.4/§5.5/§5.6/§6.5/§11/§20.12.3/§21.1/§27.1 синхронизированы под schema v8.6 двустадийный dedup) | | Схема БД | [db/schema.sql](db/schema.sql) (**v8.7 от 08.05.2026 поздний вечер** — CTO-17 addendum: DEFERRABLE INITIALLY DEFERRED FK на webhook_dedup_keys → deals для двустадийного INSERT'а из §5.5. Метрики без изменений: 55 таблиц + 12 партиций + 92 индекса + 36 RLS + 5 функций + 13 триггеров) | -| Открытые вопросы | [docs/Открытые_вопросы_v8_3.md](docs/Открытые_вопросы_v8_3.md) (v1.24 от 08.05.2026 поздний вечер — partitions:create-months Artisan-команда (замена pg_partman) для deals + supplier_lead_costs; Pest 45/45) | +| Открытые вопросы | [docs/Открытые_вопросы_v8_3.md](docs/Открытые_вопросы_v8_3.md) (v1.25 от 08.05.2026 поздний вечер — ProcessWebhookJob::failed() callback + FailedWebhookJob модель; Pest 48/48) | | **Брендбук** | [liderra_v8_handoff/docs/BRANDBOOK_v2.md](liderra_v8_handoff/docs/BRANDBOOK_v2.md) **(v2 Forest от 07.05.2026)** — старый `docs/brandbook.md` v1.1 удалён 08.05.2026 | | **Дизайн-handoff (токены, компоненты, 25 экранов)** | [liderra_v8_handoff/docs/DEVELOPER_HANDOFF.md](liderra_v8_handoff/docs/DEVELOPER_HANDOFF.md) (v8 Forest от 07.05.2026) — **только дизайн/токены/компоненты**; функционал и состав экранов — по ТЗ v8.5 | | Анализ оригинала | [docs/Analiz_originala_v8_3.md](docs/Analiz_originala_v8_3.md) (Прил. М v1.1) | @@ -223,6 +223,8 @@ trivy image liderra:latest --- +*CLAUDE.md v1.16 от 08.05.2026 (поздний вечер). Изменения v1.16: **failed() callback** в `ProcessWebhookJob` — после исчерпания 3 ретраев упавший job сохраняется в `failed_webhook_jobs` для ручного разбора и повторного запуска через админку. Создана модель `App\Models\FailedWebhookJob`. Запись через `DB::table` (не Eloquent) чтобы обойти RLS — failed-callback запускается вне транзакции воркера и без `app.current_tenant_id`. payload пишется через `json_encode(JSON_UNESCAPED_UNICODE)` — UTF-8 кириллица сохраняется. Sentry::captureException — TODO для production (на dev-стеке нет DSN). 3 новых Pest-теста: failed() пишет с webhookLogId, failed() БЕЗ webhookLogId (NULL ok), UTF-8 payload корректен. **Pest 48/48 зелёные** за 4.7 сек. Реестр v1.24→v1.25.* + *CLAUDE.md v1.15 от 08.05.2026 (поздний вечер). Изменения v1.15: **pg_partman replacement** — Artisan `partitions:create-months --ahead=N` (default 2) создаёт ежемесячные партиции для `deals` и `supplier_lead_costs` (синхронно по received_at). Идемпотентна (проверка через `pg_class WHERE relkind='r'` перед CREATE). Замена расширения pg_partman на native Windows-стеке (см. project_phase1_strategy.md). Запускать ежесуточно через Windows Task Scheduler / cron. Smoke-test на dev: 6 новых партиций (Nov 2026 - Jan 2027) + 12 skipped. 4 новых Pest-теста: создание на 8 мес вперёд, идемпотентность, --ahead=0, INSERT в новую партицию работает. **Pest 45/45 зелёные** за 4.9 сек. Реестр v1.23→v1.24.* *CLAUDE.md v1.14 от 08.05.2026 (поздний вечер). Изменения v1.14: **Биз-19 закрыт** — `DuplicateDetector` антифрод-сервис интегрирован в `ProcessWebhookJob`. При создании НОВОЙ сделки ищется master по `(tenant_id, phone)` в окне 24 ч (`received_at >= NOW() - INTERVAL '24 hours'`, `WHERE duplicate_of_id IS NULL`). Если найден — новой сделке проставляется `duplicate_of_id = master.id`, баланс НЕ списывается, SupplierLeadCost НЕ создаётся. ActivityLog пишется с `context.duplicate_of=master.id`. Окно фиксированное 24 ч (§10.8.1, не настраивается на MVP). 4 новых Pest-теста: master в окне 24ч → дубль; master старше 24ч → НЕ дубль; изоляция по tenant_id; ActivityLog context.duplicate_of. **Pest 41/41 зелёные** за 4.1 сек. DI через `app(DuplicateDetector::class)` внутри handle (не в сигнатуре — для совместимости с прямым вызовом из тестов). Реестр v1.22→v1.23.* diff --git a/app/app/Jobs/ProcessWebhookJob.php b/app/app/Jobs/ProcessWebhookJob.php index 38684990..daa1d2f1 100644 --- a/app/app/Jobs/ProcessWebhookJob.php +++ b/app/app/Jobs/ProcessWebhookJob.php @@ -7,6 +7,7 @@ namespace App\Jobs; use App\Models\ActivityLog; use App\Models\BalanceTransaction; use App\Models\Deal; +use App\Models\FailedWebhookJob; use App\Models\Project; use App\Models\RejectedDealsLog; use App\Models\SupplierLeadCost; @@ -21,6 +22,7 @@ use Illuminate\Support\Carbon; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; use RuntimeException; +use Throwable; /** * Асинхронная обработка webhook'а от crm.bp-gr.ru (narrative §5.5 v8.7). @@ -40,7 +42,7 @@ use RuntimeException; * * Не входит в текущий PoC (отдельные ветви фазы 1): * - SendNewLeadNotificationJob (Биз-20) - * - Sentry::captureException + FailedWebhookJob в failed() + * - Sentry::captureException в failed() (нет Sentry-DSN на dev-стеке) * - SystemSetting fallback для supplier_id (сейчас лукап через project_suppliers) */ class ProcessWebhookJob implements ShouldQueue @@ -308,4 +310,36 @@ class ProcessWebhookJob implements ShouldQueue return $deal; } + + /** + * Финальный callback после исчерпания всех ретраев ($tries=3). + * + * Сохраняет упавший job в `failed_webhook_jobs` для ручного разбора и + * возможного повторного запуска через админку SaaS. RLS не задаём — + * tenant_id из job-state передаётся как есть (failed-callback запускается + * вне транзакции воркера). На production добавляется Sentry::captureException. + * + * NB: записывается через DB::table (не через FailedWebhookJob::create), + * чтобы избежать RLS-фильтрации при отсутствии app.current_tenant_id — + * запись должна попасть в БД даже в катастрофическом сценарии. + */ + public function failed(Throwable $e): void + { + DB::table('failed_webhook_jobs')->insert([ + 'tenant_id' => $this->tenantId, + 'webhook_log_id' => $this->webhookLogId, + 'raw_payload' => json_encode($this->data, JSON_UNESCAPED_UNICODE), + 'exception' => $e->getMessage(), + 'retry_count' => $this->tries, + 'failed_at' => now(), + ]); + + Log::error('webhook.job_failed_permanently', [ + 'tenant_id' => $this->tenantId, + 'vid' => $this->data['vid'] ?? null, + 'exception' => $e->getMessage(), + ]); + + // TODO(production): Sentry::captureException($e); + } } diff --git a/app/app/Models/FailedWebhookJob.php b/app/app/Models/FailedWebhookJob.php new file mode 100644 index 00000000..7c4a530b --- /dev/null +++ b/app/app/Models/FailedWebhookJob.php @@ -0,0 +1,57 @@ + 'integer', + 'webhook_log_id' => 'integer', + 'raw_payload' => 'array', + 'retry_count' => 'integer', + 'retried_by' => 'integer', + 'failed_at' => 'datetime', + 'retried_at' => 'datetime', + 'resolved_at' => 'datetime', + ]; + } + + /** @return BelongsTo */ + public function tenant(): BelongsTo + { + return $this->belongsTo(Tenant::class); + } +} diff --git a/app/tests/Feature/ProcessWebhookJobTest.php b/app/tests/Feature/ProcessWebhookJobTest.php index c55558cd..726dc35a 100644 --- a/app/tests/Feature/ProcessWebhookJobTest.php +++ b/app/tests/Feature/ProcessWebhookJobTest.php @@ -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('Дмитрий Петров'); +}); diff --git a/cspell-words.txt b/cspell-words.txt index ab5f27f1..29289c31 100644 --- a/cspell-words.txt +++ b/cspell-words.txt @@ -736,3 +736,4 @@ txn оркестрирует лукап роутится +ретраев diff --git a/docs/Открытые_вопросы_v8_3.md b/docs/Открытые_вопросы_v8_3.md index 09c547ff..dcfe3449 100644 --- a/docs/Открытые_вопросы_v8_3.md +++ b/docs/Открытые_вопросы_v8_3.md @@ -2,7 +2,21 @@ **Назначение:** единый рабочий список вопросов, требующих решения заказчика для разблокировки разработки. Разбит по адресатам, внутри — по приоритету. -**Версия:** 1.24 от 08.05.2026 (поздний вечер) — **pg_partman replacement**: Artisan `partitions:create-months` создаёт партиции на N месяцев вперёд для `deals` + `supplier_lead_costs`. **Pest 45/45 зелёные**. +**Версия:** 1.25 от 08.05.2026 (поздний вечер) — **failed() callback** в `ProcessWebhookJob` + модель `FailedWebhookJob`. **Pest 48/48 зелёные**. + +**Что изменилось в v1.25 относительно v1.24:** + +- **Закрыт failed() callback** в `ProcessWebhookJob` (третий и последний кусок §5.5 narrative ТЗ, не считая Sentry/Telegram). После исчерпания всех 3 ретраев Laravel вызывает `failed(\Throwable $e)` — упавший job сохраняется в `failed_webhook_jobs` для ручного разбора и повторного запуска через админку. +- **`app/app/Models/FailedWebhookJob.php`** — Eloquent-модель для таблицы `failed_webhook_jobs` (схема v8.7 §6). +- **Реализация `failed()`** — запись через `DB::table('failed_webhook_jobs')->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-теста** в `ProcessWebhookJobTest`: + - failed() пишет упавший job с `webhookLogId` (создаётся через `DB::table('webhook_log')` для FK). + - failed() работает БЕЗ `webhookLogId` (NULL ok — soft FK). + - failed() записывает payload с UTF-8 кириллицей корректно. +- **Pest полный прогон 48/48 зелёные** за 4.7 сек. Pint + Larastan чисто (ide-helper:models регенерирован для FailedWebhookJob; Pint удалил unused-import `FailedWebhookJob` из теста — DB::table используется, не Eloquent). +- **Webhook-flow покрыт полностью** на dev-стеке (за исключением Sentry::captureException и SendNewLeadNotificationJob — Биз-20 Telegram-нотификации, ждёт настоящего бота с токеном). +- **Сводка §0:** без изменений (70 ✅ / 5 🟦 / 4 ⏸ / 1 P0 + 3 P1 + 0 P2). **Что изменилось в v1.24 относительно v1.23:**