Files
portal/app/bootstrap/app.php
T
Дмитрий c0487ddaa5 fix(billing,ux): портал видит отмену платежа и перестаёт молчать в формах
Разбор живого клиента (стоматология, Красноярск, 14.07): 2 часа настраивал 26
проектов, получил 14 отказов в трёх формах и ушёл, не заплатив.

Деньги:
- отменённый шлюзом платёж больше не висит «ожидает» вечно: закрываем как failed
  с причиной (PaymentSettlementService — общий путь для webhook и крона);
- billing:reconcile-payments каждые 5 минут сам спрашивает шлюз про зависшие
  pending. Побочно страхует от ПОТЕРИ ДЕНЕГ: если webhook не дойдёт, оплаченный
  платёж всё равно зачислится;
- кабинет говорит правду: «Оплата не завершена» + «Оплатить снова» вместо
  «баланс обновится автоматически» (GET /api/billing/last-payment).

🔴 RLS-мина (поймана валидатором ДО выката): UPDATE при отмене шёл без
tenant-контекста → на проде тронул бы 0 строк, а портал рапортовал бы «отменено».
Тесты слепы (тестовая БД под postgres). Регресс-тест проверяет ПОРЯДОК:
SET LOCAL tenant ДО UPDATE. Тот же класс, что инциденты 07.07 и 12.07.

Формы (клиент бился и уходил):
- удаление проекта со сделками: причина показывается на месте + кнопка
  «Поставить на паузу» (раньше 422 улетал в никуда — 4 попытки впустую);
- создание проекта: ошибка по дням недели больше не молчит (у поля не было
  места для показа — 2 немых отказа);
- автоподбор «Добавить вручную»: показываем причину от сервера (был голый
  catch {}), длинные ссылки 2ГИС/Яндекс.Карт принимаются — трекинг-хвост срезаем
  сами. Воспроизведено тестом: именно длинная ссылка давала 3 отказа подряд.

Наблюдаемость: причины отказов пишутся в журнал (маршрут, tenant, ИМЕНА полей;
значений нет — 152-ФЗ). Уровень warning: на проде LOG_LEVEL=warning, info в
журнал не попадает вовсе. Робот-сверщик добавлен в реестр пульса.

Тесты: Pest 2475/2475, Vitest 1215/1215.
Выкачено на боевой 14.07.2026 ~13:00 МСК; сверка сразу закрыла 3 мёртвых платежа
(10 000 ₽, 5 000 ₽, 1 000 ₽).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 13:14:49 +03:00

171 lines
9.7 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
use App\Http\Middleware\ApiKeyAuth;
use App\Http\Middleware\EnsureSaasAdmin;
use App\Http\Middleware\ImpersonationContext;
use App\Http\Middleware\SetTenantContext;
use App\Http\Middleware\UseAdminConnection;
use Illuminate\Auth\AuthenticationException;
use Illuminate\Database\QueryException;
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Illuminate\Validation\ValidationException;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__.'/../routes/web.php',
commands: __DIR__.'/../routes/console.php',
health: '/up',
)
->withMiddleware(function (Middleware $middleware): void {
// /api/auth/* размещены в web.php (см. routes/web.php) и используют
// session-based Sanctum auth. Token-based mode (через api.php) пока
// не нужен — добавим если понадобится для интеграций.
$middleware->alias([
'tenant' => SetTenantContext::class,
'saas-admin' => EnsureSaasAdmin::class,
'admin-db' => UseAdminConnection::class,
'apikey' => ApiKeyAuth::class,
]);
$middleware->web(append: [
ImpersonationContext::class,
]);
// FN-LOGIN-ROUTE (приёмка 22.06.2026): по умолчанию Laravel при неаутент.
// не-JSON запросе зовёт route('login') для guest-редиректа. У SPA нет
// backend-роута с именем 'login' (логин — клиентский Vue-роут), поэтому
// дефолт бросал «Route [login] not defined» (500) на прямых заходах
// браузера/бота к /api/*. Возвращаем строковый путь SPA-логина — никакого
// route() вызова. Для /api/* финальный ответ — 401 JSON (render ниже во
// withExceptions), сюда не доходит.
$middleware->redirectGuestsTo(fn (): string => '/login');
// Защитные HTTP-заголовки (CSP, X-Frame-Options, X-Content-Type-Options,
// Referrer-Policy, HSTS, Permissions-Policy, COOP/CORP) ставит nginx —
// единый источник: /etc/nginx/sites-available/liderra (add_header ... always).
// App-уровневый middleware SecurityHeaders удалён 18.06.2026: он дублировал
// те же заголовки, и на проде add_header always + PHP-заголовок давали дубль
// в ответе. CSP в nginx — enforcing (был Report-Only в middleware).
// Webhook receive endpoint (POST /api/webhook/{token}) не должен требовать
// CSRF — запросы приходят от внешних CRM-систем без сессии браузера.
// Авторизация — через webhook_token в URL + (на prod) HMAC.
// Маячок учёта посетителей (spec 2026-07-13) — публичный POST без сессии/CSRF-
// токена (шлёт JS лендинга/портала, иногда до инициализации SPA-сессии).
$middleware->validateCsrfTokens(except: [
'api/webhook/*',
'api/track',
]);
})
->withExceptions(function (Exceptions $exceptions): void {
// FN-LOGIN-ROUTE (приёмка 22.06.2026): неаутентифицированный запрос к
// auth:sanctum-роуту, который НЕ просит JSON (прямой заход браузером/ботом
// без Accept: application/json), уводил Laravel в Authenticate::redirectTo()
// → route('login'), которого в SPA нет (логин — клиентский Vue-роут) →
// «Route [login] not defined» (500). Все защищённые маршруты тут — /api/*,
// поэтому для них всегда отдаём 401 JSON вместо редиректа на несуществующий
// именованный роут. Наблюдалось в проде 08.06 и 21.06.
$exceptions->render(function (AuthenticationException $e, Request $request) {
if ($request->is('api/*')) {
return response()->json([
'message' => 'Требуется авторизация.',
], 401);
}
return null; // не-API: поведение по умолчанию
});
// Reduce verbosity of constraint-violation logging (SQLSTATE 23xxx):
// data-validity errors do not need a full stack trace в laravel.log.
// Incident 2026-05-29: 420k повторов B1+SMS check_violation накопили
// 8.7 GB stack traces → disk full → 4h prod downtime.
// Solution: log a warning summary с sqlstate, return false to stop
// default reporting (which would write full stack trace).
// Ref: docs/incidents/2026-05-29-disk-full-pg-recovery.md §5
$exceptions->reportable(function (QueryException $e) {
$sqlState = $e->errorInfo[0] ?? '';
if (is_string($sqlState) && str_starts_with($sqlState, '23')) {
Log::warning('db.constraint_violation', [
'sqlstate' => $sqlState,
'message' => mb_substr($e->getMessage(), 0, 200),
]);
return false; // skip default reporting (no stack trace в laravel.log)
}
return null; // continue default reporting для non-constraint QueryExceptions
});
$exceptions->render(function (QueryException $e, Request $request) {
$sqlState = $e->errorInfo[0] ?? '';
$isConstraintViolation = is_string($sqlState) && str_starts_with($sqlState, '23');
if (! $isConstraintViolation) {
// Default verbose log для non-constraint QueryExceptions (table missing,
// syntax error, etc. — these are bugs needing investigation).
Log::error('db.query_exception', [
'message' => $e->getMessage(),
'sql' => $e->getSql(),
'path' => $request->path(),
]);
}
// Constraint violations уже залогированы в reportable() выше как warning,
// дублировать не нужно.
if ($request->expectsJson()) {
return response()->json([
'message' => 'Не удалось сохранить. Проверьте данные или попробуйте ещё раз.',
], 422);
}
return null; // default render for non-JSON
});
// Supplier webhook always returns JSON, even when client omits Accept header.
// Without this render, Laravel's default ValidationException handler returns
// 302 redirect to /, which strips POST body — losing supplier leads.
// Confirmed 2026-05-25: 76 of 234 webhook hits today got 302 instead of 422.
$exceptions->render(function (ValidationException $e, Request $request) {
// Журнал отказов по проверке полей (14.07.2026). Живой клиент получил 14 отказов
// подряд в трёх формах — и в логах не осталось НИ СЛОВА о причинах: их пришлось
// час восстанавливать по размеру HTTP-ответа в nginx. Пишем маршрут, клиента и
// ИМЕНА полей, на которые ругались.
//
// ЗНАЧЕНИЯ полей НЕ пишем никогда: там ПДн (телефоны, имена, ИНН) — 152-ФЗ.
// api/track — публичный маячок посетителей: боты дают отказы пачками, в журнале не нужны.
if ($request->is('api/*') && ! $request->is('api/webhook/*') && ! $request->is('api/track')) {
$user = $request->user();
// Уровень warning (не info) намеренно: на проде LOG_LEVEL=warning, и info
// в журнал просто не попадает — отказы остались бы невидимыми, ради чего
// всё и затевалось.
Log::warning('validation.rejected', [
'method' => $request->method(),
'path' => $request->path(),
'tenant_id' => $user?->tenant_id,
'user_id' => $user?->id,
'fields' => array_keys($e->errors()), // только ИМЕНА полей
'messages' => array_values(array_map(
static fn (array $messages): string => (string) ($messages[0] ?? ''),
$e->errors(),
)), // тексты НАШИХ сообщений, без данных клиента
]);
}
if ($request->is('api/webhook/supplier/*')) {
return response()->json([
'message' => 'Validation failed',
'errors' => $e->errors(),
], 422);
}
return null; // default render for other routes
});
})->create();