Новаком
АУДИТ · SMM-ПЛАТФОРМА

Аудит backend-системы SMM-платформы после фриланс-разработки

За 2 недели прошли по 25+ файлам Spring Boot 3.5 + Next.js 16. Нашли 7 CRITICAL (включая 3 пути потери денег владельца), 12 HIGH, 12 MEDIUM + 23-пунктовую сводку нарушений договора и ТЗ. Отчёт стал основой для гарантийной коммуникации клиента с подрядчиком.

ЗаказчикSMM-платформа (под NDA)
Срок2 недели
Команда1 Senior + tech-lead на ревью
Год2026
7
CRITICAL — путей потери денег владельца
23
нарушения договора и ТЗ
25+
проанализированных файлов
2 нед
длительность аудита

Стек: Spring Boot 3.5 / PostgreSQL / JPA на бэкенде, Next.js 16 / React 19 / TypeScript на фронтенде. Объём: проанализировано 25+ ключевых файлов backend и frontend, все контроллеры, сервисы, фоновые задачи, конфигурация безопасности. Фокус: безопасность, silent skip бизнес-логики, ошибки невидимые пользователю, highload-антипаттерны, нарушения договора и ТЗ. Длительность: 2 рабочих недели.


Контекст

К нам пришёл владелец платформы перепродажи услуг в социальных сетях (лайки, подписчики, охваты) — сервис уже был разработан на фрилансе сторонним подрядчиком, эксплуатируется в проде, есть платёжная система, баланс пользователей, интеграция с внешним провайдером услуг. Заказчик хотел независимое ревью до расширения функционала: понять, что куплено, какие риски накоплены, и какие из них требуют исправления подрядчиком (по договору) перед новыми работами.

Аудит проведён по принципу «как если бы мы выходили этот код в продакшен сами» — без скидок на «фриланс-уровень». Каждое замечание привязано к файлу и строке, бизнес-последствие выражено в рублях или юридическом риске.

Эта публикация — обезличенная версия отчёта (имя клиента, домен и юр. лицо скрыты по NDA), но техническая фактура сохранена полностью — это рабочий чек-лист того, что мы реально находим в backend-системах после внешней разработки.


Резюме для бизнеса

ПриоритетКол-воГлавный бизнес-риск
CRITICAL7Потеря денег владельца/клиентов, юридические штрафы, brute force
HIGH12Зависшие платежи, несоответствие оферте, 152-ФЗ пробелы
MEDIUM12Инфраструктура, UX-проблемы, неинформативные ошибки
LOW3Осознанные компромиссы, задокументированные в ADR

Главные риски финансовых потерь для владельца — то, что инвестор/собственник должен знать в первую очередь:

  1. Провайдер завышает remains → чрезмерный рефанд. Система слепо доверяет данным внешнего провайдера, не валидирует, что remains ≤ quantity. При сбое на стороне провайдера автоматический рефанд может вернуть клиенту ВСЕ деньги за уже выполненную часть заказа.
  2. Потеря денег клиента при сбое создания заказа. Между списанием баланса и подтверждением заказа есть несколько save-операций без единой транзакции — при сбое БД клиент уходит без услуги, но с минусом на балансе.
  3. Кража admin-токена = полный доступ к финансам. Один скомпрометированный JWT-токен администратора позволяет начислить баланс на любой аккаунт без 2FA, без IP-whitelist, без подтверждения паролем.
  4. Зависшие PENDING-платежи без авто-реконсиляции. Если callback Robokassa не дошёл (сетевой сбой) — платёж навсегда в PENDING до ручного действия админа. Через 14 дней клиент идёт за чарджбэком в банк — штраф от платёжной системы.

Юридические риски:

  1. Нет верификации возраста — несовершеннолетний может оспорить все транзакции через родителя.
  2. Оферта обещает 5% бонус за задержку рефанда — в коде не реализовано (нарушение договора публичной оферты по ГК РФ и ЗоЗПП).
  3. 152-ФЗ — нет аудита согласия на обработку ПД (когда и какую версию политики принял пользователь), soft-delete сохраняет user_id во всех связных таблицах вместо обезличивания.

Дополнительно в Части II — сквозной список из 23 пунктов нарушений договора и ТЗ с привязкой к конкретным пунктам контракта — основа для формальной коммуникации с подрядчиком.


Методология

Аудит был построен в три прохода:

Проход 1 — Map. Карта компонентов: какие сервисы есть на бэкенде, какие фоновые задачи (@Scheduled), какие entity, какие контроллеры, что в SecurityConfig. Цель — понять архитектуру за 2-3 часа и определить «горячие» точки (money path, auth, провайдер-интеграция).

Проход 2 — Money path и Security first. Чтение кода в порядке риска:

  1. Всё, что трогает BalanceService и Payment (списание, рефанд, top-up)
  2. Конфигурация безопасности (SecurityConfig, RateLimitFilter, JWT, CORS)
  3. Транзакционные границы (@Transactional vs @Async)
  4. Идемпотентность операций
  5. Интеграция с внешним провайдером (где блокирующие вызовы, как обрабатываются ошибки)

Проход 3 — Highload и Legal. После понимания money path:

  1. Все @Scheduled джобы — что они читают, как пишут (loop save / batch / pagination)
  2. JPA-настройки (@DynamicUpdate, @Version, optimistic vs pessimistic locking)
  3. Сценарии lost update / read-modify-write
  4. Юридический слой (152-ФЗ, 54-ФЗ, ЗоЗПП, ГК РФ — возраст, согласие, обезличивание)

Каждая находка фиксировалась в едином документе с шаблоном: Файл / Строка → Проблема → Бизнес-последствие → Решение. После 2 недель — финальный документ передан клиенту, подрядчику дано право устранить часть пунктов в рамках гарантийных обязательств (что и было сделано — см. в конце).


1. CRITICAL — Безопасность

1.1 Race condition при проверке идемпотентности баланса

BalanceService.charge() — проверка existsByIdempotencyKey() до захвата PESSIMISTIC_WRITE блокировки на пользователе.

@Transactional
public void charge(Long userId, BigDecimal amount, String idempotencyKey, ...) {
    if (transactionRepository.existsByIdempotencyKey(idempotencyKey)) {
        return; // ⚠️ два concurrent запроса оба пройдут эту проверку
    }
    User user = userRepository.findByIdForUpdate(userId)  // блокировка ПОСЛЕ
            .orElseThrow(...);
    // ... списание ...
}

Два параллельных запроса с одинаковым idempotencyKey могут одновременно пройти проверку, прежде чем один из них захватит блокировку. Окно гонки — двойное списание.

Решение: переместить findByIdForUpdate() перед проверкой идемпотентности, либо использовать UNIQUE constraint на idempotency_key (atomic fail на уровне БД).

1.2 Rate limiting — только в памяти, не распределённый

RateLimitFilter использует ConcurrentHashMap для bucket-ов. Каждый экземпляр backend держит свои счётчики:

  • Лимит: 5 req/min на login → при 2 инстансах фактический лимит 10 req/min, при 5 — 25 req/min.
  • Через прокси-сеть атакующий обходит лимит, распределяя запросы между инстансами.

Решение: перенести rate limit в Redis (Bucket4j из коробки поддерживает Redis-backed storage). При одном инстансе пока не критично, но при масштабировании станет уязвимостью.

1.3 CORS — wildcard headers + credentials

CorsConfiguration config = new CorsConfiguration();
config.setAllowedHeaders(List.of("*"));    // ⚠️ Wildcard
config.setAllowCredentials(true);           // ⚠️ + Credentials = запрещено W3C spec

W3C спецификация запрещает комбинацию Allow-Headers: * с Allow-Credentials: true. Браузеры могут отклонить preflight.

Решение: явно перечислить заголовки: Content-Type, Authorization, Idempotency-Key, If-None-Match.

1.4 Нет принудительного HTTPS в CORS origins

@Value("${cors.allowed-origins:http://localhost:3000}")
private String allowedOrigins;

Нет валидации, что в production все origins начинаются с https://. Опечатка оператора в ENV → credentials (JWT) передаются по незашифрованному каналу.

Решение: @PostConstruct проверка профиля: в prod все origins должны быть https://.

1.5 Async failure handling — потеря данных при ошибке рефанда

@Async("providerExecutor")
public void sendToProvider(Order order, ...) {
    try {
        long providerOrderId = providerClient.addOrder(...);
    } catch (Exception e) {
        log.error("Failed to send order {}", order.getId(), e);
        handleProviderFailure(order, "Ошибка отправки к провайдеру");
    }
}

private void handleProviderFailure(Order order, String note) {
    txTemplate.executeWithoutResult(status -> {
        order.setStatus(OrderStatus.FAILED);
        balanceService.refund(...); // ⚠️ если рефанд упадёт — баланс потерян
    });
}

В @Async-методах исключения проглатываются executor-ом без AsyncUncaughtExceptionHandler. Если refund() бросит — транзакция откатится, статус остаётся QUEUED, баланс списан. Пользователь видит «в очереди» бесконечно. Решение: dead-letter-queue + retry для failed рефандов.

1.6 Создание заказа — 3 save без единой транзакции

public CreateOrderResponse createOrder(Long userId, CreateOrderRequest request) {
    Order order = new Order();
    order = orderRepository.save(order);   // Save #1 — PENDING
    balanceService.charge(userId, ...);    // Списание баланса
    // ⚠️ Если следующий save упадёт — баланс списан, заказ PENDING
    order.setStatus(OrderStatus.QUEUED);
    orderRepository.save(order);           // Save #3 — QUEUED
    providerSubmitter.sendToProvider(order, ...); // @Async — fire and forget
    return new CreateOrderResponse(...);
}

При любом сбое БД между save #1 и save #3 — баланс списан, заказ застрял в PENDING без отправки провайдеру. Решение: обернуть всю последовательность в TransactionTemplate (как сделано в cancelOrder).


2. HIGH — Тихий пропуск бизнес-логики

2.1 Заказ «паркуется» после 60 неудачных проверок — пользователь не знает

private void applyBackoff(Order order) {
    if (order.getCheckAttempts() >= STATUS_ATTEMPT_CAP) {
        order.setNextCheckAt(null);  // парковка навсегда
        eventRecorder.recordProviderStatusCheck(order.getId(), null, false, "stuck:exceeded_attempt_cap");
        return;  // ⚠️ пользователь никогда не узнает
    }
}

Заказ висит в ACTIVE/QUEUED, статус не обновляется. Нет email, нет in-app уведомления. Пользователь ждёт выполнения, которое не наступит. Нагрузка на саппорт растёт, репутация падает.

Решение: email пользователю + пометка в UI «проверка приостановлена — обратитесь в поддержку».

2.2 Refill polling паркуется — пользователь видит код ошибки

order.setRefillLastError("exceeded_poll_cap"); // ⚠️ сухой код вместо объяснения

Если фронт не переводит технический код в человеческое сообщение — пользователь видит exceeded_poll_cap в карточке заказа. Решение: локализованное сообщение + email о неудаче refill.

2.3 Email-уведомления о низком балансе — нет retry при провале SMTP

Цикл по пользователям ниже порога:

for (User user : belowThreshold) {
    try {
        emailService.sendLowBalanceAlert(...);
        user.setLastLowBalanceNotifiedAt(LocalDateTime.now());
        userRepository.save(user);
    } catch (Exception e) {
        log.warn("Low-balance email failed for user {}: {}", user.getId(), e.getMessage());
        // ⚠️ retry отсутствует — следующая попытка через 1 час
    }
}

Timestamp ставится после успешной отправки — это корректно. Но при сбое SMTP пользователь пропускает важное уведомление на 1 час (до следующего цикла). Решение: retry с backoff + dead-letter list.

2.4 Email подтверждения удаления аккаунта — silent failure

Аккаунт мягко удаляется → email отправляется → если SMTP падает, пользователь не узнаёт о факте удаления (но юридически уже удалён). Порядок операций правильный, но отсутствие retry создаёт UX-проблему.

2.5 Глобальное отключение email (mail.enabled=false) — без фидбека пользователю

Когда app.mail.enabled=false:

  • OTP-коды для логина не отправляются → пользователь не может войти, но не знает почему.
  • Password reset не работает → «что-то сломалось».
  • Low balance alerts пропадают молча.

Решение: возвращать явную ошибку на endpoints, зависящих от email (login, password-reset), а не тихий skip.


3. HIGH — Ошибки видимые только в логах

3.1 Event recording — best-effort, не откатывает основную операцию

Запись eventRecorder.record*() идёт вне транзакционной границы. Если запись аудит-события упадёт — заказ создан/отменён корректно, но аудит-трейл неполный. При разборе спорных ситуаций с пользователем — неполные данные.

3.2 Async @Async исключения — проглатываются executor'ом

@Bean("providerExecutor")
public Executor providerExecutor() {
    ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
    executor.setCorePoolSize(3);
    executor.setMaxPoolSize(10);
    executor.setQueueCapacity(100);
    // ⚠️ Нет AsyncUncaughtExceptionHandler
    // ⚠️ Нет RejectedExecutionHandler
    executor.initialize();
    return executor;
}

При переполнении очереди (>100 задач) → RejectedExecutionException с дефолтной политикой AbortPolicy → потеря задачи без retry. Никакого dead-letter-queue для неудавшихся отправок к провайдеру.

3.3 CatalogSyncJob — полный сбой sync логируется, но сервис-каталог устаревает

@Scheduled(cron = "0 0 * * * *")
public void syncCatalog() {
    try { /* sync logic */ }
    catch (Exception e) {
        log.error("Catalog sync failed", e);
        // ⚠️ Каталог устаревает, цены не обновляются
        // ⚠️ Нет алерта оператору
    }
}

Если sync падает N часов подряд (провайдер недоступен) — каталог услуг устаревает, пользователи видят старые цены. Нет алерта.


4. HIGH — Запись в БД по 1 записи в цикле (highload anti-pattern)

4.1 CatalogSyncJob — save внутри цикла по всем сервисам провайдера

Закрыто подрядчиком после аудита — см. секцию «Что было сделано после аудита» в конце документа. Решение получилось лучше нашей исходной рекомендации (pre-flight bulk fetch + fingerprint diff + Hibernate batching + integration test).

for (JsonNode raw : rawServices) {
    // ... 90+ строк обработки ...
    catalogRepository.save(service); // ⚠️ 1 INSERT/UPDATE per итерацию
}

При 5000 сервисов провайдера → 5000 отдельных SQL-запросов → 5000 network round-trip к PostgreSQL. Занимает connection pool на ~10-30 секунд, увеличивает WAL write amplification, блокирует другие запросы. Во время синхронизации (каждый час) сайт «подтормаживает» для всех.

Рекомендовали: собрать в List<> и saveAll() после цикла — 1 batch INSERT/UPDATE.

4.2 LowBalanceNotificationJob — save по одному пользователю

В том же файле на другой строке уже используется userRepository.saveAll(recovered) — паттерн batch известен автору. Здесь пропущен из-за try/catch на каждого пользователя (email может упасть). Решение: собирать успешных в список, после цикла — saveAll().

4.3 AccountDeletionJob — save по одному удалённому пользователю

Аналогично — saveAll() после цикла.


5. MEDIUM — Производительность и инфраструктура

5.1 Нет конфигурации HikariCP

spring.datasource.url=...
# ⚠️ Нет hikari.* настроек

Используются дефолты: maximum-pool-size=10, connection-timeout=30000ms. При loop-saves + async executor + scheduled jobs возможно исчерпание пула — запросы ждут 30 секунд до таймаута.

Решение:

spring.datasource.hikari.minimum-idle=5
spring.datasource.hikari.maximum-pool-size=20
spring.datasource.hikari.connection-timeout=5000
spring.datasource.hikari.idle-timeout=300000
spring.datasource.hikari.max-lifetime=1800000
spring.datasource.hikari.leak-detection-threshold=60000

5.2 JVM heap — 512 МБ для production

JAVA_OPTS="-XX:+UseG1GC -Xms256m -Xmx512m ..."

512 МБ при highload + in-memory rate limit + кэш цен + async pool → частые GC и потенциальный OOM. Решение: -Xmx1g минимум для production.

5.3 Async executor — нет обработки rejection

При 100+ одновременных заказах → RejectedExecutionException → заказ потерян. Решение: setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()) — вызывающий поток выполнит задачу сам.

5.4 Отсутствует индекс на ServiceCatalog.externalServiceId

Частый lookup findByExternalServiceId() используется в CatalogSyncJob и OrderService. Без индекса — full table scan при каждом поиске.


6. MEDIUM — Необработанные исключения

Следующие исключения не обработаны в @RestControllerAdvice и возвращают generic 500:

ИсключениеКогда возникаетПравильный HTTP-статус
DataIntegrityViolationExceptionDuplicate key, constraint violation409 Conflict
HttpMessageNotReadableExceptionMalformed JSON в теле запроса400 Bad Request
MethodArgumentTypeMismatchExceptionНеверный тип параметра400 Bad Request
OptimisticLockingFailureExceptionConcurrent update конфликт409 Conflict
TransactionSystemExceptionHibernate flush error500 (с понятным сообщением)
IllegalStateExceptionБизнес-ошибки400/500

6.1 Нет лимита размера запроса (backend)

Поле comments в CreateOrderRequest не ограничено. Атакующий может отправить мегабайтный JSON. Frontend proxy ограничивает body до 1 МБ, но прямые запросы к backend (если открыт наружу) — нет.

server.tomcat.max-http-post-size=1048576

Плюс @Size(max=2000) на comments и @Size(max=500) на link.


7. LOW — Frontend замечания

7.1 JWT в localStorage вместо HttpOnly cookies

Осознанное архитектурное решение (задокументировано в ADR). XSS-риск минимизирован благодаря React auto-escaping, отсутствию dangerouslySetInnerHTML с пользовательским вводом, санитизации href (блокирует javascript:, data:). Рекомендация — перейти на HttpOnly cookies при следующей итерации session-cookie API.

7.2 CSP 'unsafe-inline' для стилей и скриптов

Необходимо для Next.js hydration. Не уязвимость при текущей архитектуре, но ограничивает CSP как defence-in-depth.

7.3 Frontend — остальное в хорошем состоянии

✅ Proxy layer — path traversal defence, header whitelist, body size caps ✅ Error reporting — PII-маскирование (email, JWT, IP, токены в URL) ✅ Error boundaries — глобальный + per-route, auto-retry ✅ Payment forgery prevention — session-scoped marker, hostname allowlist, CSP form-action ✅ Admin authorization — frontend gates + backend enforcement ✅ Form validation — client-side + server-side authority ✅ Environment secrets — не утекают в клиентский бандл


8. Сильные стороны проекта

Несмотря на найденные риски, проект в целом production-quality. Что сделано хорошо:

АспектРеализация
ИдемпотентностьРеализована для заказов и балансовых операций (с оговоркой п.1.1)
Изоляция пользователейВсе запросы фильтруются по userId из SecurityContext
Soft-delete@SQLRestriction("deleted = false") — предотвращает orphaning
PESSIMISTIC_WRITEНа балансе пользователя — race conditions
ShedLockРаспределённая блокировка scheduled jobs — предотвращает дублирование
Backoff-стратегияЭкспоненциальный backoff при polling провайдера
Аудит-трейлТаблица order_events — полная история статусов
Token versionbumpTokenVersion() при logout — инвалидирует все JWT
IP rate limitingBucket4j на auth endpoints
Proxy securityFrontend proxy — path traversal, header smuggling, body size
Error sanitizationPII-маскирование в error reports
Scale-ready indexesМиграция с partial indexes и covering indexes для 177M+ строк
ETag cachingGET /orders — 304 Not Modified, снижает нагрузку polling

Это не «студенческий код» — это работа взрослого инженера, у которого закрыты базовые архитектурные вопросы. Проблемы накопились на специфических edge-кейсах (money path, async failure, highload-нагрузка) — там, где ошибки стоят дороже всего.


9. Поддерживаемость кода

Общая оценка: хорошая. Комментарии точечные («почему», а не «что»). Нет over-documentation. Нет мёртвого кода (0 TODO на фронтенде, 1 TODO на бэкенде).

АспектОценка
Структура проектаОтлично — Feature-Sliced Design (frontend), слоистая MVC (backend)
ИменованиеИдеально — camelCase/PascalCase/kebab-case без нарушений на 250K+ строк
Размер файловРазумный (есть файлы по 500-1000 строк, но обоснованно)
ДублированиеМинимально — общие паттерны в shared
ТестыОсмысленные — тестируют поведение, а не геттеры
ADRВедутся в docs/decisions.md с trade-off анализом

Рекомендации:

  1. Документировать бизнес-логику отдельно от кода (создание заказа, оплата, refund) — чтобы новый разработчик мог быстро войти.
  2. Снизить bus-factor — привлечь второго разработчика для code review.
  3. Разделить крупные сервисы (OrderService свыше 1000 строк) на OrderCreationService, OrderQueryService, OrderCancellationService.
  4. Извлечь email-шаблоны (EmailService ~700 строк HTML inline) в Thymeleaf/Mustache шаблоны.

10. Глубокий security: денежные потери владельца

10.1 Brute force OTP — нет задержки между попытками

6-значный OTP (100 000 вариантов). После N неудач код удаляется. Между попытками — нет cooldown. Атакующий через прокси-сеть (10 IP) проходит пространство за ~17 часов. При in-memory rate limit (п.1.2) при масштабировании на 2-3 инстанса — время сокращается. Решение: задержка 30s после 2-й неудачной попытки + TOTP вместо random-кодов.

10.2 Провайдер может завысить remains → чрезмерный рефанд

refundAmount = orderAmount × (remains / quantity)

Значение remains берётся от провайдера без валидации: нет проверки remains ≤ quantity, нет проверки на отрицательное, нет проверки, что remains не вырос (провайдер «откатил» прогресс).

Сценарий: провайдер выполнил 500 из 1000, вернул remains=500 → рефанд 50₽. Провайдер «сбрасывается» и возвращает remains=1000 → рефанд 100₽ (все деньги обратно). При этом 500 единиц уже выполнены — владелец потерял 50₽ стоимости услуги.

Решение: remains ≤ quantity валидация + completed_max watermark (не может увеличиться после уменьшения).

10.3 Нет авто-реконсиляции зависших PENDING-платежей

Если callback Robokassa не дошёл (сетевой сбой, timeout на нашей стороне) — платёж остаётся в PENDING навсегда до ручного действия админа. Клиент уже заплатил → деньги списались с карты → баланс не пополнился → клиент идёт за чарджбэком. Штраф от платёжной системы + убытки.

Решение: scheduled job каждые 15 минут проверяет PENDING-платежи старше 30 минут через Robokassa API (OpState), автоматически кредитует подтверждённые.

10.4 Auto-boost молча приостанавливается при нехватке средств

Auto-boost (автоматическое создание заказов по расписанию) при нехватке баланса → правило переходит в SUSPENDED. Без email пользователю. Клиент платит за услугу автоматического продвижения, ожидает что она работает, но она остановлена. Узнаёт только если зайдёт в кабинет.


11. Юридические пробелы (законодательство РФ)

11.1 Нет верификации возраста (18+)

В сущности User отсутствуют поля birthDate/age. Нет чекбокса «18+» на регистрации. Нет проверки при оплате.

Риск: ГК РФ ст. 26 — сделки несовершеннолетних 14-18 лет требуют согласия родителей. ФЗ-54 предполагает дееспособность покупателя. Родитель может оспорить все транзакции → возврат всех средств.

Решение: обязательный чекбокс «Подтверждаю, что мне 18+» с сохранением timestamp в БД.

11.2 Нет аудита согласия на обработку ПД (152-ФЗ)

Пользователь ставит галочку «Принимаю оферту», но нет полей:

  • dataProcessingConsentAt — когда согласился
  • dataProcessingConsentVersion — какую версию политики
  • Возможность отозвать согласие отдельно от удаления аккаунта

При проверке Роскомнадзором оператор не может предъявить «когда конкретный пользователь дал согласие». Штраф до 300 000 ₽ для ИП (КоАП ст. 13.11).

Решение: consent_accepted_at TIMESTAMPTZ + consent_version VARCHAR(20), логировать каждое принятие.

11.3 Email об удалении обещает «обезличивание», но данные сохраняются

Текст email: «История заказов и финансовых операций сохраняется в обезличенном виде в течение 3 лет».

Реальность: при soft-delete (deleted=true) user_id остаётся в orders, transactions, payments. Связь user → orders → transactions = полная восстановимость личности.

Если Роскомнадзор запросит доказательство обезличивания — нарушение 152-ФЗ.

Решение: при удалении заменять email на deleted_{userId}@removed.local, обнулить все PII-поля. Либо честно скорректировать текст email на «данные хранятся для выполнения налоговых обязательств».

11.4 Бонус 5% за задержку рефанда — обещан в оферте, не реализован

Текст оферты: «При задержке возврата свыше 12 часов — бонусная компенсация 5%».

В коде нет ни одного класса/job/метода, который бы отслеживал время рефанда и начислял бонус. Оферта — публичный договор (ГК РФ ст. 437). Клиент может требовать:

  • Начисление обещанных 5%
  • Штраф по ЗоЗПП (50% от суммы, присуждённой судом)
  • Компенсацию морального вреда

Решение: либо реализовать в коде, либо убрать обещание из оферты.

11.5 54-ФЗ: ИП/ООО статус hardcoded

fiscalSno = "usn_income"  // УСН "доходы"
fiscalItemName = "Услуга продвижения в социальных сетях"

При смене системы налогообложения нужно менять код и редеплоить приложение. Неправильный sno = ОФД отклоняет чек = платёж зависает в PENDING. Решение: вынести в ENV.


12. Несостыковки в бизнес-процессах

12.1 Нет защиты от спама заказов (queue overflow)

Проверка дубликатов работает только для LINK_TARGET_ACCOUNT (аккаунт-уровневые сервисы). Для post-level — пользователь может создать неограниченное количество заказов на один контент.

Сценарий: клиент создаёт 100 заказов «лайки на пост» по 10₽ → все уходят к провайдеру одновременно → 100×1000 лайков на один пост → социальная сеть банит пост/аккаунт → клиент требует возврат.

Решение: rate limit на создание заказов per user + предупреждение при множественных заказах на один link.

12.2 Кража admin-токена = полный контроль над финансами

Если злоумышленник получает admin JWT (через XSS, social engineering, заражённый компьютер админа), он может:

  1. Пополнить баланс любому пользователю на любую сумму
  2. Списать баланс любому
  3. Пометить платёж «завершённым» без реальной оплаты
  4. Отменить заказы и инициировать рефанды

Текущая защита: JWT TTL 15 мин, все действия логируются в transactions.actorAdminId.

Отсутствует: 2FA для admin-сессии. IP-whitelist. Подтверждение паролем для критичных операций (пополнение >10 000₽). Лимит на сумму единовременного пополнения.

Решение: обязательная 2FA для admin-входа + max лимит admin balance adjustment (например, 50 000₽) + email-уведомление владельцу при любом admin-пополнении >5 000₽.

12.3 Сводка процессных несостыковок

ПроцессЧто обещаноЧто реализованоРазрыв
Авто-рефанд при отказеВ течение 24 часов (оферта)По факту callback от провайдера (может быть дни)Нет SLA-таймера
Бонус 5% за задержкуОбещан в офертеНе реализован🔴 Юридический риск
Реконсиляция платежейАвтоматическаяТолько ручная через adminPENDING зависают
Уведомление о приостановке auto-boostОжидается клиентомТолько в кабинете (без email)UX-проблема
Обезличивание данныхОбещано в emailSoft-delete с сохранением user_id🔴 152-ФЗ несоответствие
Верификация возрастаТребуется по ГК РФНе реализована🔴 Юридический риск
Согласие на обработку ПДТребуется по 152-ФЗГалочка есть, аудита нетНет timestamp/version
Защита от спама заказовОжидаетсяТолько для account-levelPost-level незащищён

13. Highload: кроны и нагрузка на БД

13.1 OrderStatusSyncJob: нет пагинации, OOM при масштабировании

@Scheduled(fixedDelay = 60_000) // Каждые 60 секунд
public void syncOrderStatuses() {
    List<Order> dueOrders = orderRepository.findOrdersDueForStatusCheck(...);
    // ⚠️ Загружает ВСЕ подходящие заказы в память — нет LIMIT/Pageable
    for (Order order : dueOrders) {
        checkSingleOrderAsync(order.getId());
    }
}

При 10K+ активных заказов запрос вернёт тысячи записей в одном List → OOM при 512 МБ heap. Решение: Pageable с лимитом 500 заказов за тик.

13.2 DataRetentionJob: неограниченный DELETE, блокировка таблиц

idempotencyRepository.deleteOlderThan(7_days_ago);   // Может удалить 100K+ строк
eventRepository.deleteOlderThan(90_days_ago);        // Может удалить 1M+ строк

DELETE FROM table WHERE created_at < cutoff без LIMIT при миллионе строк → exclusive lock на минуты, блокирует INSERT/UPDATE.

Решение: батчевый delete по 10K строк в цикле до 0 удалённых.

13.3 Connection pool: 10 соединений на 215+ потоков

КомпонентMax потоковПотребность DB
Tomcat HTTP200200
TaskScheduler (cron)55
providerExecutor (async)1010
Итого215215
Доступно (дефолт)10

При 50 concurrent HTTP + 8 async + 1 cron = 59 потоков на 10 соединений → 49 ждут до 30 сек → таймауты.

13.4 Синхронные вызовы провайдера блокируют request-потоки

EndpointSync-вызовTimeoutБлокирует
POST /orderscheckLivePrice() (при cold cache)HTTP-поток
POST /orders/{id}/cancelcancelOrderOrThrow()HTTP-поток
POST /orders/{id}/refillrefillOrder()HTTP-поток

Сценарий: провайдер отвечает 5 секунд → 50 пользователей создают заказы → 50 HTTP-потоков заблокированы → каскадный отказ.

Решение: все вызовы провайдера в @Async + возврат 202 Accepted + уведомление через websocket/polling.

13.5 RateLimitFilter: unbounded memory growth

Очистка buckets — раз в 24 часа. При DDoS с 100K уникальных IP → ~50 МБ heap. При 512 МБ — критично. Решение: очистка каждый час + maxSize=100K.

13.6 Email блокирует транзакцию в OrderStatusSyncJob

// Внутри TransactionTemplate:
notifyOrderTerminal(order, newStatus);
// → emailService.sendOrderStatusNotification() → SMTP timeout до 15 секунд

SMTP-вызов внутри транзакции. При таймауте → DB connection заблокирован на 15 секунд. При 10 заказах одновременно — 10 connection pool слотов заняты.

Решение: вынести notifyOrderTerminal() после txTemplate.executeWithoutResult().


14. Highload: потеря данных и перезатирание полей

14.1 Нет @DynamicUpdate: save() перезаписывает ВСЕ поля

Ни одна entity не аннотирована @DynamicUpdate. По умолчанию Hibernate генерирует:

UPDATE orders SET status=?, completed=?, amount=?, link=?, ... (ВСЕ 30+ полей)
WHERE id=?

Сценарий перезатирания:

  1. OrderStatusSyncJob читает заказ (completed=50, status=ACTIVE)
  2. Администратор через админку читает тот же заказ
  3. OrderStatusSyncJob обновляет completed=100, status=PARTIAL, сохраняет
  4. Администратор сохраняет свои изменения → completed откатывается к 50, status к ACTIVE
  5. Клиент потерял 50 единиц + рефанд рассчитан неверно

Решение: @DynamicUpdate на entity + @Version для optimistic locking.

14.2 OrderStatusSyncJob: read-modify-write без блокировки

Order order = orderRepository.findById(orderId).orElse(null); // ⚠️ НЕТ FOR UPDATE
// ... модификация completed, status, nextCheckAt ...
orderRepository.save(order); // ⚠️ Перезаписывает ВСЕ поля

ShedLock защищает только запуск job, не отдельные заказы. При двух инстансах последний save() выигрывает. Решение: findByIdForUpdate(orderId) с @Lock(PESSIMISTIC_WRITE).

14.3 CatalogSyncJob перезатирает правки администратора

  1. Администратор помечает сервис как reviewed=true
  2. Через минуту CatalogSyncJob синхронизирует тот же сервис от провайдера
  3. Провайдер не знает про reviewed → CatalogSyncJob сохраняет reviewed=false
  4. Правка администратора потеряна

Решение: в CatalogSyncJob не трогать поля, управляемые администратором (reviewed, active, pricePerUnit override). Обновлять только поля провайдера.

14.4 Последовательные ID: перебор и утечка информации

Все entity используют GenerationType.IDENTITY → 1, 2, 3, 4… Для PaymentInvId в Robokassa callback последовательный, виден в URL редиректа. Утечка: зная что последний заказ имеет ID 5000 — конкурент узнаёт объём бизнеса.

Решение для Payment: UUID как внешний идентификатор (InvId для Robokassa). Внутренний sequential — для производительности.

14.5 Нет optimistic locking (@Version) ни на одной entity

Конкурентные save() молча перезаписывают друг друга. Нет исключения при конфликте — данные просто теряются.

@Entity
public class Order {
    @Version
    private Long version;  // JPA автоматически проверит при save()
}

При конфликте — OptimisticLockingFailureException → повторить операцию. Лучше ошибка, чем молчаливая потеря.


15. Highload: матрица масштабирования

МетрикаТекущееПри 10K заказовПри 100KПри 1M
Connection pool10⚠️ Исчерпан🔴 Каскадный отказ🔴 Полный отказ
OrderStatusSync queryOKOK⚠️ OOM без пагинации🔴 OOM
CatalogSync saves5K/час5K/час5K/час5K/час (стабильно)
DataRetention DELETEOKOK⚠️ Секунды блокировки🔴 Минуты
RateLimit memory~1 МБ~5 МБ~20 МБ~50 МБ (DDoS)
JVM heap (512 МБ)342 МБ~400 МБ⚠️ ~480 МБ🔴 OOM
Provider sync callsOK⚠️ Блокировка потоков🔴 Каскадный отказ🔴 Полный отказ

Приоритет исправлений

#ПриоритетЧто исправитьТрудозатратыРиск без исправления
1🔴 P0HikariCP pool size → 30+5 мин (конфиг)Каскадный отказ при >50 пользователях
2🔴 P0JVM heap → 1-2 ГБ5 мин (Dockerfile)OOM при росте
3🔴 P0@DynamicUpdate на Order, User, ServiceCatalog30 минМолчаливая потеря данных
4🔴 P1Пагинация в OrderStatusSyncJob2 часаOOM при 10K+ заказов
5🔴 P1Батчевый DELETE в DataRetentionJob2 часаБлокировка таблиц
6🟠 P2saveAll() вместо save() в цикле3 часаНагрузка на БД
7🟠 P2Pessimistic lock в OrderStatusSyncJob2 часаПерезатирание полей
8🟠 P2Async вместо sync вызовов провайдера1 неделяКаскадный отказ при slow provider
9🟠 P2@Version на критичные entity1 часLost updates
10🟡 P3Очистка RateLimit map чаще30 минMemory leak при DDoS
11🟡 P3Email после транзакции в sync job1 часDB connection hold
12🟡 P3UUID для Payment external ID3 часаInformation disclosure
13🟡 P3Недостающие индексы1 час (миграция)Медленные запросы

16. Что отсутствует в наблюдаемости

Что нетРискРекомендация
Centralized logging (ELK/Loki/CloudWatch)Логи теряются при перезапуске контейнераDocker log driver → Loki или CloudWatch
Alerting (Sentry/PagerDuty)Никто не узнает об ошибке пока не проверит логиSentry (бесплатный тариф до 5K events/мес)
Metrics (Prometheus/Grafana)Нет данных о latency, throughput, connection poolMicrometer + Prometheus endpoint
Log rotation на VPSДиск может переполнитьсяDocker log driver с max-size: 100m, max-file: 5
Web Vitals сборEndpoint-заглушка (no-op)Vercel Analytics или ClickHouse

17. Очереди: где не хватает

Сейчас в проекте нет брокера сообщений (RabbitMQ/Kafka/Redis Streams). Вся асинхронная работа — на @Async thread pool (capacity=100, max 10 потоков) и @Scheduled cron-джобах. Это создаёт целый класс проблем:

17.1 Отправка заказа провайдеру: потеря задач при рестарте

@Async("providerExecutor") — in-memory очередь (capacity=100). При рестарте/крэше все задачи теряются. Что даст persistent Work Queue + DLQ:

  • Задачи переживают рестарт
  • Retry с экспоненциальным backoff
  • Dead-Letter Queue для неисправимых
  • Отдельный consumer pool, не конкурирующий с HTTP-потоками

17.2 Email: потеря уведомлений без retry

Все 6 типов email (OTP, подтверждение, статус заказа, низкий баланс, удаление, password reset) при SMTP-сбое теряются навсегда. Что даст Email Queue + Retry:

  • Каждый email — сообщение в persistent queue
  • Consumer с retry: 3 попытки с интервалом 30с → 5мин → 30мин
  • DLQ для permanently-bounced адресов
  • OTP-код: enqueue + return 202 → не блокирует login endpoint

17.3 Статус заказов: polling 14.4M scans/день вместо событий

Активных заказовСканирований/деньAsync задач/минDB connections
100144K5-101-2
1 0001.44M50-1003-5
10 00014.4M500-100010+ (исчерпание пула)
100 000144M5000-10000🔴 Невозможно

Что даст Delayed Task Queue: при создании заказа publish CheckOrderStatus { orderId, checkAt: now+60s } → брокер доставляет точно в checkAt → нагрузка O(1) per заказ вместо O(N) per тик.

17.4 Robokassa callback: race condition

Если обработка callback занимает >10с (SMTP, медленный DB) → Robokassa повторяет → два callback одновременно → race condition на идемпотентности → возможно двойное зачисление.

Что даст Priority Queue: callback endpoint валидирует подпись → publishes в queue → return OK за <50мс → Robokassa не повторяет → consumer идемпотентный.

17.5 Auto-boost: часовая гранулярность вместо точного расписания

@Scheduled(fixedDelay=3_600_000) — правило, созданное в 14:30 со scheduledAt=15:00, выполнится между 15:00 и 16:00. До 60 мин задержки. С Delayed Queue — точность до секунды.

17.6 Transactional Outbox для создания заказа

Текущая последовательность (Save → charge → Save → @Async sendToProvider) — без атомарности. С Outbox:

1. В ОДНОЙ транзакции:
   - Save order (QUEUED)
   - Charge balance
   - Insert outbox_event { type: "SubmitOrder", orderId }
2. Отдельный outbox poller: читает outbox → publish в queue → mark processed
3. Consumer: sendToProvider

Атомарность: либо ВСЁ, либо НИЧЕГО. При crash outbox poller повторит. Нет fire-and-forget — гарантированная доставка.

Приоритеты внедрения очередей

ФазаОчередьЧто решаетТрудозатраты
1order.submit (Work Queue)Потеря задач при рестарте, retry3-5 дней
1email.send (Work Queue)Потеря email, блокировка login2-3 дня
2order.status (Delayed Queue)Polling 14.4M scans → event-driven1 неделя
2payment.process (Priority Queue)Race condition на callback2-3 дня
3autoboost.execute (Delayed Queue)60-мин гранулярность → секунды2-3 дня
3Transactional OutboxАтомарность создания заказа3-5 дней
4catalog.events (Event Bus)30-мин лаг каталога1-2 дня

Суммарно: ~3-4 недели разработки для полного перехода на event-driven архитектуру.


18. WebSocket/SSE: real-time вместо polling

В проекте нет WebSocket/SSE инфраструктуры. Все real-time обновления — через HTTP polling с setInterval.

ГдеИнтервалЧто опрашиваетсяUX-задержка
Детали заказа10сСтатус + completed countДо 10с лаг прогресса
Список заказов15сВсе заказы пользователяДо 15с лаг статуса
Stuck detection60сПроверка зависанияСсылка на поддержку через 3 мин
Self-healRetry при ошибкеВосстановление 6-12с
Admin health60сМетрики здоровьяДанные стареют без refresh
Баланс после оплаты1x (one-shot)Баланс пользователяRace condition

Главная боль — детали заказа: провайдер доставил 50 единиц за 3 секунды, пользователь видит 0 ещё 7 секунд → думает «сломалось». При 1000 активных пользователей × 10с polling = 6000 req/min к API (даже если ничего не изменилось).

С WebSocket бэкенд отправляет progress_updated сразу при обновлении completed в sync job. Фронтенд обновляет progress bar мгновенно. Нулевой трафик когда ничего не меняется.

Приоритеты внедрения

ФазаЧтоТрудозатратыЭффект
1WebSocket инфраструктура (backend + frontend)2-3 дняБазовый фреймворк
1Order detail real-time progress2 дняУстраняет 10с лаг, снижает polling трафик на ~80%
2Balance update push1 деньУстраняет race condition после оплаты
2Cabinet home order list push1-2 дняУстраняет 15с лаг статусов
3User notifications (SSE)2-3 дняToast при завершении заказа, приостановке auto-boost
3Admin health SSE1-2 дняОператор видит инциденты мгновенно

Суммарно: ~2 недели разработки. Фаза 1 даёт основной эффект.


Часть II. Нарушения договора и ТЗ

Сквозной список — 23 пункта с явной привязкой к нарушенным пунктам договора и ТЗ. Этот раздел использовался клиентом в формальной коммуникации с подрядчиком для устранения замечаний по гарантии.

Блок А. Заказы и инфраструктура (8 пунктов)

#ЧтоСвязано с
А.1createOrder — нет реконсиляции между sync charge и async send → заказ в QUEUED без providerOrderId, баланс списанп.1.5, 1.6
А.2handleProviderFailure — refund в той же async-задаче. При падении до refund деньги клиенту не вернутсяп.1.5
А.3applyBackoff — парковка без автовозврата. После 60 попыток → next_check_at = NULL. Сигнал — только событие в БД, ручная обработка операторомп.2.1
А.4Swagger UI / OpenAPI публичные в production → карта админ-эндпоинтов наружу✅ закрыто в hotfix
А.5ClientIpResolver — доверяет X-Forwarded-For без проверки доверенного прокси → rate-limit и login brute-force обходятся подменой заголовка✅ закрыто в hotfix
А.6ObservabilityController — принимает JsonNode любого размера, пишет в БД. В связке с обходом rate-limit — DoS на диск✅ частично
А.7Таблицы-аккумуляторы без cleanup (order_idempotency, order_events, client_error_logs, contact_submissions)✅ закрыто в hotfix
А.8Миграция timezone частичная — TIMESTAMPTZ только для 3 таблиц, остальные 7 на TIMESTAMP без TZп.4.1 договора

Блок Б. Биллинг и админ-операции (10 пунктов)

#ЧтоНарушает
Б.1Robokassa stub-режим оставлен включаемым по умолчанию (enabled=false fallback). Если не подгрузится .env, в stub-режиме initPayment сразу вызывает completePayment и зачисляет деньги на баланс без оплаты. Любой посетитель кабинета пополняет баланс воздухомп.4.1 договора, ТЗ п.3.2
Б.2adjustBalance — нет проверки id != currentUser.id. SUPERADMIN через PATCH /admin/users/{мой_id}/balance начисляет себе любую суммуп.4.1 договора
Б.3deleteUser — не возвращает остаток баланса. Деньги зависают в БД, пользователь не может войти и ими воспользоватьсяТЗ п.3.2, ст.1102 ГК РФ
Б.4topup не принимает Idempotency-Key header вообще. Двойной клик → две Payment-сессии, двойное зачислениеТЗ п.3.5
Б.5Robokassa callback не сверяет OutSum с stored payment.amount. При битом callback (компрометация Password2, баг у Robokassa, MITM) на баланс зачисляется сумма из callback, а не из БДТЗ п.3.2
Б.6AuthService.login — неудачные попытки не логируются. Медленный brute-force (1 попытка/мин в обход rate-limit) проходит бесследноТЗ п.3.2
Б.7requestPasswordReset — ни в лог, ни в БД. При компрометации аккаунта нечем доказать, когда и с какого IP пришёл запросТЗ п.3.2, 152-ФЗ
Б.8verifyEmail (admin) — без actor_admin_id. SUPERADMIN может верифицировать чужой email и угнать аккаунт без следаТЗ п.3.2, 152-ФЗ
Б.9Сборка production-образа выполняется без тестов (mvn -DskipTests). Тесты в CI/build не запускаются вообщеТЗ п.3.5, 3.6
Б.10Миграция timezone частичная (V50 объявлена complete, по факту 4 таблицы из 11)п.4.1 договора

Блок В. Идемпотентность и валидация (5 пунктов)

#ЧтоНарушает
В.1topup Idempotency-Key объявлен как required = false. Server-to-server вызов без header → создаётся Payment без idempotency_key → двойной клик → две PaymentТЗ п.3.5
В.2BalanceService.charge/topup/refundбез проверки signum() > 0. Метод refund напрямую делает user.setBalance(balance.add(amount)) и пишет в transactions с operationType=REFUND. При отрицательном amount баланс уменьшается, операция отображается как «возврат»ТЗ п.3.2
В.3UpdateServicePricingRequestcustomMarkupPercent без @DecimalMin(0). В сочетании с В.2 даёт прямой сценарий: SUPERADMIN ставит отрицательную наценку → user покупает услугу → charge(negativeAmount)balance.subtract(negativeAmount) = баланс увеличивается → user выводит. Реальная дыра в кассу, выводимая за минутып.4.1 договора, ТЗ п.3.4
В.4Robokassa OutSum mismatch — после новой проверки expectedSum.equals(outSum) при mismatch только log.warn + return false. Никаких других действий — payment.setStatus() не вызывается, аудит не пишется, email админу не отправляется → payment висит в PENDING до ручного mark-completedТЗ п.3.2
В.5SystemSettingsService.normalizeInteger — проверяет только нижнюю границу. SUPERADMIN через PUT /admin/settings может задать auth.code.max-attempts = 999999 (защита от brute-force OTP отключена), auth.code.ttl-seconds = 999999 (код действует ~11 дней), auth.code.resend-cooldown-seconds = 0 (DDoS SMTP). Один скомпрометированный админ-аккаунт через UI обнуляет защиту аутентификации всей системып.4.1 договора

Что было сделано после аудита

Получив отчёт, владелец передал замечания подрядчику в рамках гарантийных обязательств. В первом hotfix-коммите были закрыты 7 пунктов:

  • ✅ Async failure → потеря баланса (частично — добавлен reconciliation job)
  • ✅ Executor без rejection handler — добавлен CallerRunsPolicy
  • ✅ Нет лимита размера payload — ClientErrorService обрезает до 32 КБ
  • ✅ Swagger в prod — OpenAPI отключён в prod-профиле
  • ClientIpResolver — убрано доверие к X-Forwarded-For
  • ✅ Таблицы-аккумуляторы — добавлен DataRetentionJob
  • ✅ Robokassa OutSum mismatch — добавлена sum-проверка (но без аудита mismatch — см. В.4)

В последующих коммитах закрыт CatalogSyncJob batch-issue (п.4.1) — и закрыт лучше, чем рекомендовалось в аудите:

Что рекомендовалиЧто подрядчик сделал
saveAll(toSave) вместо save() в цикле✅ + pre-flight bulk fetch: один findAll().stream().collect(toMap(...)) вместо N findByExternalServiceId() в цикле (O(N) → O(1) read)
Fingerprint-based change detection: считаем fullSyncFingerprint(service) до и после обновления, saveAll() получает только реально изменённые записи
Hibernate batch config в application.properties: hibernate.jdbc.batch_size=50, order_inserts=true, order_updates=true, generate_statistics=true
Integration test на batch-path (173 строки) — фиксирует поведение, чтобы регрессия не вернулась
✅ Cron 0 0 * * * *fixedRate=3600000 (не накладывается при долгой обработке, ShedLock защищает от пересечения инстансов)

Это сильный пример того, как работает независимый аудит как leverage. Мы указали проблему («запись по 1 в цикле — anti-pattern»), подрядчик решил её существенно глубже:

  • read-path оптимизирован отдельно (N запросов → 1)
  • skip unchanged через fingerprint diff — для стабильного каталога БД вообще не пишется
  • JPA batch_size + ordering — реальное использование возможностей Hibernate
  • integration test закрывает регрессию

В исходной рекомендации мы могли указать «сделайте saveAll() — будет batch». Но без hibernate.jdbc.batch_size, без change detection, без order_updates это всё равно осталось бы N INSERT-ов в одной транзакции — лучше, но не намного. Решение подрядчика — production-grade.

Остались открытыми: race condition баланса (1.1), in-memory rate limit (1.2), CORS issues (1.3-1.4), 3 save без транзакции (1.6), silent skip business logic (2.1-2.5), only-in-logs ошибки (3.1-3.3), оставшиеся loop saves (4.2-4.3), HikariCP/JVM heap, и все пункты блоков А.1-А.3, А.8 + весь блок Б и В дополнительного аудита.

Это и есть ценность независимого аудита для владельца — отчёт стал рабочим артефактом для гарантийных переговоров с подрядчиком. Конкретные ссылки на файлы, строки, нарушения пунктов договора — основа для формальной коммуникации, а не «общие пожелания». А качество исправления (как в случае CatalogSync) — критерий того, что подрядчик ещё «в строю» и способен на инженерную работу.


Выводы для бизнеса

Чему этот аудит учит про backend-системы, написанные на стороне:

  1. Money path всегда требует особого внимания. Идемпотентность, транзакционные границы, async failure — это места, где техническая ошибка превращается в финансовые потери. Стороннему подрядчику легко «не заметить» сценарий с потерей денег, потому что он не виден в счастливом пути.

  2. «Тихие» ошибки UX опаснее громких 500-ок. Когда пользователь видит ошибку — он жалуется. Когда заказ молча зависает, или email уведомления не доходят — он копит фрустрацию и уходит к конкуренту. Эти места разработчик с улицы пропускает, потому что они не падают.

  3. Highload — это не «как много RPS вытянет». Это о том, как код деградирует с ростом. Loop saves, отсутствие пагинации в кронах, in-memory rate limit, 512 МБ heap — каждое из этих решений работает на 100 пользователях и ломается на 10 000.

  4. Юридический слой требует отдельной проверки. Разработчик не обязан знать ГК РФ ст.26, 152-ФЗ или ЗоЗПП. Аудит здесь служит «глазами юриста» — обещания оферты vs реальный код, аудит ПД-согласий, обезличивание при удалении.

  5. Сильный отчёт = leverage в переговорах. Когда замечания привязаны к конкретным пунктам договора и ТЗ, заказчику легче «продавать» их подрядчику. Без структурированного отчёта это «пожелания», с отчётом — «нарушения договора».


Что Novacom может сделать для вашего проекта

Если вы получили проект от стороннего подрядчика и хотите независимое ревью до расширения функционала — это входит в наш Backend Health-Check за 3 дня. Senior-инженер с опытом ВТБ/Сбер проходит по 25+ файлам, отдаёт отчёт с топ-10 проблем по severity, 5 quick wins, оцененными в часах, и (при необходимости) шаблоном для гарантийной коммуникации с подрядчиком. Фиксированная цена 80 000 ₽, без созвонов про скоуп.

Если backend сложный (Spring Boot + Kafka + микросервисы + интеграции) — полный аудит уровня этого отчёта занимает 1-2 недели от 300 000 ₽. Скоуп согласуется заранее, отчёт — ваш в любом случае.

Связаться: info@novacom.ru или запланируйте брифинг прямо сейчас.