Стек: Spring Boot 3.5 / PostgreSQL / JPA на бэкенде, Next.js 16 / React 19 / TypeScript на фронтенде. Объём: проанализировано 25+ ключевых файлов backend и frontend, все контроллеры, сервисы, фоновые задачи, конфигурация безопасности. Фокус: безопасность, silent skip бизнес-логики, ошибки невидимые пользователю, highload-антипаттерны, нарушения договора и ТЗ. Длительность: 2 рабочих недели.
Контекст
К нам пришёл владелец платформы перепродажи услуг в социальных сетях (лайки, подписчики, охваты) — сервис уже был разработан на фрилансе сторонним подрядчиком, эксплуатируется в проде, есть платёжная система, баланс пользователей, интеграция с внешним провайдером услуг. Заказчик хотел независимое ревью до расширения функционала: понять, что куплено, какие риски накоплены, и какие из них требуют исправления подрядчиком (по договору) перед новыми работами.
Аудит проведён по принципу «как если бы мы выходили этот код в продакшен сами» — без скидок на «фриланс-уровень». Каждое замечание привязано к файлу и строке, бизнес-последствие выражено в рублях или юридическом риске.
Эта публикация — обезличенная версия отчёта (имя клиента, домен и юр. лицо скрыты по NDA), но техническая фактура сохранена полностью — это рабочий чек-лист того, что мы реально находим в backend-системах после внешней разработки.
Резюме для бизнеса
| Приоритет | Кол-во | Главный бизнес-риск |
|---|---|---|
| CRITICAL | 7 | Потеря денег владельца/клиентов, юридические штрафы, brute force |
| HIGH | 12 | Зависшие платежи, несоответствие оферте, 152-ФЗ пробелы |
| MEDIUM | 12 | Инфраструктура, UX-проблемы, неинформативные ошибки |
| LOW | 3 | Осознанные компромиссы, задокументированные в ADR |
Главные риски финансовых потерь для владельца — то, что инвестор/собственник должен знать в первую очередь:
- Провайдер завышает remains → чрезмерный рефанд. Система слепо доверяет данным внешнего провайдера, не валидирует, что
remains ≤ quantity. При сбое на стороне провайдера автоматический рефанд может вернуть клиенту ВСЕ деньги за уже выполненную часть заказа. - Потеря денег клиента при сбое создания заказа. Между списанием баланса и подтверждением заказа есть несколько save-операций без единой транзакции — при сбое БД клиент уходит без услуги, но с минусом на балансе.
- Кража admin-токена = полный доступ к финансам. Один скомпрометированный JWT-токен администратора позволяет начислить баланс на любой аккаунт без 2FA, без IP-whitelist, без подтверждения паролем.
- Зависшие PENDING-платежи без авто-реконсиляции. Если callback Robokassa не дошёл (сетевой сбой) — платёж навсегда в PENDING до ручного действия админа. Через 14 дней клиент идёт за чарджбэком в банк — штраф от платёжной системы.
Юридические риски:
- Нет верификации возраста — несовершеннолетний может оспорить все транзакции через родителя.
- Оферта обещает 5% бонус за задержку рефанда — в коде не реализовано (нарушение договора публичной оферты по ГК РФ и ЗоЗПП).
- 152-ФЗ — нет аудита согласия на обработку ПД (когда и какую версию политики принял пользователь), soft-delete сохраняет user_id во всех связных таблицах вместо обезличивания.
Дополнительно в Части II — сквозной список из 23 пунктов нарушений договора и ТЗ с привязкой к конкретным пунктам контракта — основа для формальной коммуникации с подрядчиком.
Методология
Аудит был построен в три прохода:
Проход 1 — Map. Карта компонентов: какие сервисы есть на бэкенде, какие фоновые задачи (@Scheduled), какие entity, какие контроллеры, что в SecurityConfig. Цель — понять архитектуру за 2-3 часа и определить «горячие» точки (money path, auth, провайдер-интеграция).
Проход 2 — Money path и Security first. Чтение кода в порядке риска:
- Всё, что трогает
BalanceServiceиPayment(списание, рефанд, top-up) - Конфигурация безопасности (
SecurityConfig,RateLimitFilter, JWT, CORS) - Транзакционные границы (
@Transactionalvs@Async) - Идемпотентность операций
- Интеграция с внешним провайдером (где блокирующие вызовы, как обрабатываются ошибки)
Проход 3 — Highload и Legal. После понимания money path:
- Все
@Scheduledджобы — что они читают, как пишут (loop save / batch / pagination) - JPA-настройки (
@DynamicUpdate,@Version, optimistic vs pessimistic locking) - Сценарии lost update / read-modify-write
- Юридический слой (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-статус |
|---|---|---|
DataIntegrityViolationException | Duplicate key, constraint violation | 409 Conflict |
HttpMessageNotReadableException | Malformed JSON в теле запроса | 400 Bad Request |
MethodArgumentTypeMismatchException | Неверный тип параметра | 400 Bad Request |
OptimisticLockingFailureException | Concurrent update конфликт | 409 Conflict |
TransactionSystemException | Hibernate flush error | 500 (с понятным сообщением) |
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 version | bumpTokenVersion() при logout — инвалидирует все JWT |
| IP rate limiting | Bucket4j на auth endpoints |
| Proxy security | Frontend proxy — path traversal, header smuggling, body size |
| Error sanitization | PII-маскирование в error reports |
| Scale-ready indexes | Миграция с partial indexes и covering indexes для 177M+ строк |
| ETag caching | GET /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 анализом |
Рекомендации:
- Документировать бизнес-логику отдельно от кода (создание заказа, оплата, refund) — чтобы новый разработчик мог быстро войти.
- Снизить bus-factor — привлечь второго разработчика для code review.
- Разделить крупные сервисы (
OrderServiceсвыше 1000 строк) наOrderCreationService,OrderQueryService,OrderCancellationService. - Извлечь 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, заражённый компьютер админа), он может:
- Пополнить баланс любому пользователю на любую сумму
- Списать баланс любому
- Пометить платёж «завершённым» без реальной оплаты
- Отменить заказы и инициировать рефанды
Текущая защита: 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% за задержку | Обещан в оферте | Не реализован | 🔴 Юридический риск |
| Реконсиляция платежей | Автоматическая | Только ручная через admin | PENDING зависают |
| Уведомление о приостановке auto-boost | Ожидается клиентом | Только в кабинете (без email) | UX-проблема |
| Обезличивание данных | Обещано в email | Soft-delete с сохранением user_id | 🔴 152-ФЗ несоответствие |
| Верификация возраста | Требуется по ГК РФ | Не реализована | 🔴 Юридический риск |
| Согласие на обработку ПД | Требуется по 152-ФЗ | Галочка есть, аудита нет | Нет timestamp/version |
| Защита от спама заказов | Ожидается | Только для account-level | Post-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 HTTP | 200 | 200 |
| TaskScheduler (cron) | 5 | 5 |
| providerExecutor (async) | 10 | 10 |
| Итого | 215 | 215 |
| Доступно (дефолт) | 10 |
При 50 concurrent HTTP + 8 async + 1 cron = 59 потоков на 10 соединений → 49 ждут до 30 сек → таймауты.
13.4 Синхронные вызовы провайдера блокируют request-потоки
| Endpoint | Sync-вызов | Timeout | Блокирует |
|---|---|---|---|
| POST /orders | checkLivePrice() (при cold cache) | 5с | HTTP-поток |
| POST /orders/{id}/cancel | cancelOrderOrThrow() | 5с | HTTP-поток |
| POST /orders/{id}/refill | refillOrder() | 5с | 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=?
Сценарий перезатирания:
- OrderStatusSyncJob читает заказ (completed=50, status=ACTIVE)
- Администратор через админку читает тот же заказ
- OrderStatusSyncJob обновляет completed=100, status=PARTIAL, сохраняет
- Администратор сохраняет свои изменения → completed откатывается к 50, status к ACTIVE
- Клиент потерял 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 перезатирает правки администратора
- Администратор помечает сервис как
reviewed=true - Через минуту CatalogSyncJob синхронизирует тот же сервис от провайдера
- Провайдер не знает про
reviewed→ CatalogSyncJob сохраняетreviewed=false - Правка администратора потеряна
Решение: в CatalogSyncJob не трогать поля, управляемые администратором (reviewed, active, pricePerUnit override). Обновлять только поля провайдера.
14.4 Последовательные ID: перебор и утечка информации
Все entity используют GenerationType.IDENTITY → 1, 2, 3, 4… Для Payment — InvId в 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 pool | 10 | ⚠️ Исчерпан | 🔴 Каскадный отказ | 🔴 Полный отказ |
| OrderStatusSync query | OK | OK | ⚠️ OOM без пагинации | 🔴 OOM |
| CatalogSync saves | 5K/час | 5K/час | 5K/час | 5K/час (стабильно) |
| DataRetention DELETE | OK | OK | ⚠️ Секунды блокировки | 🔴 Минуты |
| RateLimit memory | ~1 МБ | ~5 МБ | ~20 МБ | ~50 МБ (DDoS) |
| JVM heap (512 МБ) | 342 МБ | ~400 МБ | ⚠️ ~480 МБ | 🔴 OOM |
| Provider sync calls | OK | ⚠️ Блокировка потоков | 🔴 Каскадный отказ | 🔴 Полный отказ |
Приоритет исправлений
| # | Приоритет | Что исправить | Трудозатраты | Риск без исправления |
|---|---|---|---|---|
| 1 | 🔴 P0 | HikariCP pool size → 30+ | 5 мин (конфиг) | Каскадный отказ при >50 пользователях |
| 2 | 🔴 P0 | JVM heap → 1-2 ГБ | 5 мин (Dockerfile) | OOM при росте |
| 3 | 🔴 P0 | @DynamicUpdate на Order, User, ServiceCatalog | 30 мин | Молчаливая потеря данных |
| 4 | 🔴 P1 | Пагинация в OrderStatusSyncJob | 2 часа | OOM при 10K+ заказов |
| 5 | 🔴 P1 | Батчевый DELETE в DataRetentionJob | 2 часа | Блокировка таблиц |
| 6 | 🟠 P2 | saveAll() вместо save() в цикле | 3 часа | Нагрузка на БД |
| 7 | 🟠 P2 | Pessimistic lock в OrderStatusSyncJob | 2 часа | Перезатирание полей |
| 8 | 🟠 P2 | Async вместо sync вызовов провайдера | 1 неделя | Каскадный отказ при slow provider |
| 9 | 🟠 P2 | @Version на критичные entity | 1 час | Lost updates |
| 10 | 🟡 P3 | Очистка RateLimit map чаще | 30 мин | Memory leak при DDoS |
| 11 | 🟡 P3 | Email после транзакции в sync job | 1 час | DB connection hold |
| 12 | 🟡 P3 | UUID для Payment external ID | 3 часа | 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 pool | Micrometer + 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 |
|---|---|---|---|
| 100 | 144K | 5-10 | 1-2 |
| 1 000 | 1.44M | 50-100 | 3-5 |
| 10 000 | 14.4M | 500-1000 | 10+ (исчерпание пула) |
| 100 000 | 144M | 5000-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 — гарантированная доставка.
Приоритеты внедрения очередей
| Фаза | Очередь | Что решает | Трудозатраты |
|---|---|---|---|
| 1 | order.submit (Work Queue) | Потеря задач при рестарте, retry | 3-5 дней |
| 1 | email.send (Work Queue) | Потеря email, блокировка login | 2-3 дня |
| 2 | order.status (Delayed Queue) | Polling 14.4M scans → event-driven | 1 неделя |
| 2 | payment.process (Priority Queue) | Race condition на callback | 2-3 дня |
| 3 | autoboost.execute (Delayed Queue) | 60-мин гранулярность → секунды | 2-3 дня |
| 3 | Transactional Outbox | Атомарность создания заказа | 3-5 дней |
| 4 | catalog.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 detection | 60с | Проверка зависания | Ссылка на поддержку через 3 мин |
| Self-heal | 6с | Retry при ошибке | Восстановление 6-12с |
| Admin health | 60с | Метрики здоровья | Данные стареют без 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 мгновенно. Нулевой трафик когда ничего не меняется.
Приоритеты внедрения
| Фаза | Что | Трудозатраты | Эффект |
|---|---|---|---|
| 1 | WebSocket инфраструктура (backend + frontend) | 2-3 дня | Базовый фреймворк |
| 1 | Order detail real-time progress | 2 дня | Устраняет 10с лаг, снижает polling трафик на ~80% |
| 2 | Balance update push | 1 день | Устраняет race condition после оплаты |
| 2 | Cabinet home order list push | 1-2 дня | Устраняет 15с лаг статусов |
| 3 | User notifications (SSE) | 2-3 дня | Toast при завершении заказа, приостановке auto-boost |
| 3 | Admin health SSE | 1-2 дня | Оператор видит инциденты мгновенно |
Суммарно: ~2 недели разработки. Фаза 1 даёт основной эффект.
Часть II. Нарушения договора и ТЗ
Сквозной список — 23 пункта с явной привязкой к нарушенным пунктам договора и ТЗ. Этот раздел использовался клиентом в формальной коммуникации с подрядчиком для устранения замечаний по гарантии.
Блок А. Заказы и инфраструктура (8 пунктов)
| # | Что | Связано с |
|---|---|---|
| А.1 | createOrder — нет реконсиляции между sync charge и async send → заказ в QUEUED без providerOrderId, баланс списан | п.1.5, 1.6 |
| А.2 | handleProviderFailure — refund в той же async-задаче. При падении до refund деньги клиенту не вернутся | п.1.5 |
| А.3 | applyBackoff — парковка без автовозврата. После 60 попыток → next_check_at = NULL. Сигнал — только событие в БД, ручная обработка оператором | п.2.1 |
| А.4 | Swagger UI / OpenAPI публичные в production → карта админ-эндпоинтов наружу | ✅ закрыто в hotfix |
| А.5 | ClientIpResolver — доверяет X-Forwarded-For без проверки доверенного прокси → rate-limit и login brute-force обходятся подменой заголовка | ✅ закрыто в hotfix |
| А.6 | ObservabilityController — принимает 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 пунктов)
| # | Что | Нарушает |
|---|---|---|
| Б.1 | Robokassa stub-режим оставлен включаемым по умолчанию (enabled=false fallback). Если не подгрузится .env, в stub-режиме initPayment сразу вызывает completePayment и зачисляет деньги на баланс без оплаты. Любой посетитель кабинета пополняет баланс воздухом | п.4.1 договора, ТЗ п.3.2 |
| Б.2 | adjustBalance — нет проверки id != currentUser.id. SUPERADMIN через PATCH /admin/users/{мой_id}/balance начисляет себе любую сумму | п.4.1 договора |
| Б.3 | deleteUser — не возвращает остаток баланса. Деньги зависают в БД, пользователь не может войти и ими воспользоваться | ТЗ п.3.2, ст.1102 ГК РФ |
| Б.4 | topup не принимает Idempotency-Key header вообще. Двойной клик → две Payment-сессии, двойное зачисление | ТЗ п.3.5 |
| Б.5 | Robokassa callback не сверяет OutSum с stored payment.amount. При битом callback (компрометация Password2, баг у Robokassa, MITM) на баланс зачисляется сумма из callback, а не из БД | ТЗ п.3.2 |
| Б.6 | AuthService.login — неудачные попытки не логируются. Медленный brute-force (1 попытка/мин в обход rate-limit) проходит бесследно | ТЗ п.3.2 |
| Б.7 | requestPasswordReset — ни в лог, ни в БД. При компрометации аккаунта нечем доказать, когда и с какого IP пришёл запрос | ТЗ п.3.2, 152-ФЗ |
| Б.8 | verifyEmail (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 пунктов)
| # | Что | Нарушает |
|---|---|---|
| В.1 | topup Idempotency-Key объявлен как required = false. Server-to-server вызов без header → создаётся Payment без idempotency_key → двойной клик → две Payment | ТЗ п.3.5 |
| В.2 | BalanceService.charge/topup/refund — без проверки signum() > 0. Метод refund напрямую делает user.setBalance(balance.add(amount)) и пишет в transactions с operationType=REFUND. При отрицательном amount баланс уменьшается, операция отображается как «возврат» | ТЗ п.3.2 |
| В.3 | UpdateServicePricingRequest — customMarkupPercent без @DecimalMin(0). В сочетании с В.2 даёт прямой сценарий: SUPERADMIN ставит отрицательную наценку → user покупает услугу → charge(negativeAmount) → balance.subtract(negativeAmount) = баланс увеличивается → user выводит. Реальная дыра в кассу, выводимая за минуты | п.4.1 договора, ТЗ п.3.4 |
| В.4 | Robokassa OutSum mismatch — после новой проверки expectedSum.equals(outSum) при mismatch только log.warn + return false. Никаких других действий — payment.setStatus() не вызывается, аудит не пишется, email админу не отправляется → payment висит в PENDING до ручного mark-completed | ТЗ п.3.2 |
| В.5 | SystemSettingsService.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-системы, написанные на стороне:
-
Money path всегда требует особого внимания. Идемпотентность, транзакционные границы, async failure — это места, где техническая ошибка превращается в финансовые потери. Стороннему подрядчику легко «не заметить» сценарий с потерей денег, потому что он не виден в счастливом пути.
-
«Тихие» ошибки UX опаснее громких 500-ок. Когда пользователь видит ошибку — он жалуется. Когда заказ молча зависает, или email уведомления не доходят — он копит фрустрацию и уходит к конкуренту. Эти места разработчик с улицы пропускает, потому что они не падают.
-
Highload — это не «как много RPS вытянет». Это о том, как код деградирует с ростом. Loop saves, отсутствие пагинации в кронах, in-memory rate limit, 512 МБ heap — каждое из этих решений работает на 100 пользователях и ломается на 10 000.
-
Юридический слой требует отдельной проверки. Разработчик не обязан знать ГК РФ ст.26, 152-ФЗ или ЗоЗПП. Аудит здесь служит «глазами юриста» — обещания оферты vs реальный код, аудит ПД-согласий, обезличивание при удалении.
-
Сильный отчёт = 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 или запланируйте брифинг прямо сейчас.