У каждой подписки — собственная одноразовая виртуальная карта с месячным лимитом. Мерчант не спишет ни копейки сверх разрешённого, а заморозка подписки в один шаг замораживает и её карту. На этом принципе стоит весь сервис.
Go-микросервис читает поток транзакций и сам разбирается, что перед ним подписка: группирует платежи по мерчанту, меряет медианный интервал между ними, требует стабильности сумм (±5%) и совпадения валюты, сверяется с «подписочными» MCC. Трёх списаний достаточно для вердикта — подписочным категориям хватает двух. Найденное получает виртуальную карту и живёт дальше по понятным правилам: триал → активная → заморожена → завершена, а мерчант, который перестал списывать, отправляется в зомби.
Гексагональная: домен и use-case слои не зависят от адаптеров.
cmd/firewall— сборка, конфигурация из env, graceful shutdown.internal/domain— сущности и правила жизненного цикла (подписка, виртуальный токен, транзакция) и валидация входа.internal/ports— интерфейсы репозиториев и эмиттера карт.internal/lifecycle— синхронизация жизненного цикла подписки и её карты, отложенные синхронизации.internal/adapter/httpapi— REST API, middleware (auth, recovery, logging, metrics).internal/adapter/memory— in-memory репозитории (для тестов и dev).internal/adapter/mysql— MySQL-репозитории (включаются черезSUBSCRIPTION_FIREWALL_DB_DSN).internal/adapter/issuer— эмитент виртуальных карт: заглушка плюс обёртка с timeout/retry/circuit breaker.internal/detector— детектор регулярных платежей (медианный интервал, стабильность сумм, MCC).internal/pipeline— асинхронная очередь детекции по пользователям.internal/worker— общий цикл «взять пачку → обработать → подтвердить» с retry и остановкой.internal/subscription,internal/token— use-case сервисы.internal/obs— метрики Prometheus и санитизация логов.pkg/masking— маскирование PAN и идентификаторов.
POST /v1/transactionsсохраняет транзакцию (без PAN) и ставит пользователя в очередь детекции.- Воркеры ищут регулярные списания; найденные подписки получают виртуальную карту и запись о подписке.
- Списания по виртуальной карте авторизуются через
POST /v1/tokens/{id}/authorizeс проверкой месячного лимита. - Фоновый sweep-джоб переводит просроченные подписки в
Zombie.
Freeze/reactivate/terminate подписки и её виртуальной карты синхронизированы: любой переход проходит через internal/lifecycle, поэтому новая точка входа не может «забыть» про связку. Заморозка подписки замораживает карту, и наоборот. Переход подписки в Zombie на фоновом sweep тоже замораживает карту, чтобы зомби не мог авторизовывать списания.
# локально (без auth — только для разработки)
make run
# через docker compose (поднимает сервис + MySQL)
SUBSCRIPTION_FIREWALL_API_KEYS=local-dev-key make docker-runСпецификация API: api/openapi.yaml. Лицензия — Apache-2.0. Руководство для контрибьюторов: docs/CONTRIBUTING.md.
Без настроенных ключей API сервис не стартует — нужно либо задать SUBSCRIPTION_FIREWALL_API_KEYS, либо явно разрешить анонимный доступ (SUBSCRIPTION_FIREWALL_ALLOW_NO_AUTH=true). Проверка живёт в httpapi.NewServer и возвращает ошибку, а не предупреждение в лог: сервер с незащищёнными эндпоинтами не поднимается.
Все эндпоинты, кроме GET /healthz и GET /metrics, требуют API-ключ в заголовке X-API-Key или Authorization: Bearer <key>.
Формат ключей: ключ:скоуп1,скоуп2; ключ без скоупов имеет полный доступ. Скоупы:
ingest— инжест транзакций (POST /v1/transactions);read— все GET-эндпоинты;manage— freeze/reactivate/terminate и авторизация списаний;full— всё сразу.
Запрос с валидным ключом без нужного скоупа получает 403. Сравнение ключей выполняется за константное время.
curl -H "X-API-Key: local-dev-key" http://localhost:8080/v1/users/user-1/subscriptionsИнжест транзакции и постановка задачи детекции пишутся в одной SQL-транзакции (таблица detection_outbox). Воркеры разбирают очередь через SELECT ... FOR UPDATE SKIP LOCKED, так что несколько реплик не обрабатывают одну задачу дважды; упавшая задача переигрывается (детекция идемпотентна), после 8 попыток уходит в статус failed. Задачи зависших воркеров возвращает maintenance-цикл. freezes/reactivates/terminates пишутся в audit_log с маскированным ключом-актором — по записи на каждую из двух сущностей.
Сервис спроектирован для нескольких реплик за общей MySQL:
- Списание лимита атомарно.
virtual_tokens.ChargeвыполняетSELECT … FOR UPDATE, применяет доменное правило и сохраняет результат в одной транзакции. Сумма одобренных списаний не может превысить месячный лимит, сколько бы реплик ни обслуживало карту. - Выпуск карты идемпотентен. Перед вызовом провайдера резервируется строка в состоянии
Issuingс ключомissue:<user>:<merchant>; эмитент вызывается только победителем брони. Зависшая бронь старшеSUBSCRIPTION_FIREWALL_ISSUANCE_TIMEOUTсчитается брошенной и забирается следующей попыткой, так что упавшая реплика не блокирует выпуск навсегда. - Подписки защищены оптимистичной блокировкой. Колонка
versionиUPDATE … WHERE id = ? AND version = ?не дают двум репликам затереть переход друг друга; конфликт приводит к повторному чтению, а не к потере обновления. Одновременное создание одной и той же пары (user, merchant) сходится к одной строке через уникальный ключ. - Карты защищены так же.
Freeze,ReactivateиTerminateидут черезUpdateVersion, поэтому переход, прочитанный до списания, не может записать обратно устаревшийspent_in_period. Списание, попавшее в это окно, приводит к повторному чтению, а не к потере бюджета месяца. - Sweep запускает одна реплика под advisory-блокировкой MySQL
GET_LOCK, которую она берёт и отпускает на одном и том же соединении пула. Блокировка освобождается даже при отменённом контексте, поэтому не «залипает» до пересоздания соединения. - Sweep читает только то, что может изменить. Запрос выбирает активные и триальные подписки, уже перешагнувшие своё окно, по индексу
idx_subscriptions_sweep. Уже зомби и замороженные не попадают в выборку, поэтому проход sweep всегда завершается, а его стоимость пропорциональна бэклогу, а не общему числу подписок. - Детектор не тащит историю целиком. Сначала база агрегирует платежи по мерчанту (
GROUP BY … HAVING COUNT(*) >= 2), и в память попадают только кандидаты; точное правило по числу платежей применяется после.
internal/lifecycle применяет переход к обеим сущностям. Если вторая часть не удалась:
freezeиreactivateобратимы — первый переход откатывается, вызывающий получает ошибку, состояние пары остаётся прежним;terminateнеобратим — действие для второй сущности пишется вlifecycle_sync_outboxи доигрывается фоновым синкером (8 попыток, затемfailed), так что карта не остаётся активной навсегда.
Прогресс виден в метриках subscription_firewall_lifecycle_sync_total{entity,action,result}, subscription_firewall_linked_action_failures_total и subscription_firewall_lifecycle_queue_depth.
| Переменная | По умолчанию | Описание |
|---|---|---|
SUBSCRIPTION_FIREWALL_ADDRESS |
:8080 |
Адрес HTTP-сервера |
SUBSCRIPTION_FIREWALL_API_KEYS |
— | API-ключи через запятую |
SUBSCRIPTION_FIREWALL_ALLOW_NO_AUTH |
false |
Разрешить запуск без ключей (только dev) |
SUBSCRIPTION_FIREWALL_STORAGE |
auto |
auto — mysql при заданном DSN, иначе memory; memory/mysql фиксируют выбор |
SUBSCRIPTION_FIREWALL_ISSUER |
simulated |
Эмитент виртуальных карт. Сейчас доступен только simulated — заглушка, непригодная для продакшена; при старте пишет предупреждение |
SUBSCRIPTION_FIREWALL_WORKERS |
4 |
Воркеры детекции |
SUBSCRIPTION_FIREWALL_QUEUE_CAPACITY |
1024 |
Ёмкость очереди детекции |
SUBSCRIPTION_FIREWALL_SWEEP_INTERVAL |
1m |
Период sweep-джа зомби |
SUBSCRIPTION_FIREWALL_ISSUER_MONTHLY_LIMIT |
100000 |
Месячный лимит карты (minor units) |
SUBSCRIPTION_FIREWALL_DB_DSN |
— | DSN MySQL; без переменной — in-memory хранилище |
SUBSCRIPTION_FIREWALL_DB_MAX_OPEN_CONNS |
16 |
Максимум открытых соединений с БД |
SUBSCRIPTION_FIREWALL_DB_MAX_IDLE_CONNS |
16 |
Максимум простаивающих соединений |
SUBSCRIPTION_FIREWALL_DB_CONN_MAX_LIFETIME |
5m |
Время жизни соединения |
SUBSCRIPTION_FIREWALL_LOG_LEVEL |
info |
Уровень логов: debug, info, warn, error |
SUBSCRIPTION_FIREWALL_READ_TIMEOUT |
10s |
HTTP read timeout |
SUBSCRIPTION_FIREWALL_WRITE_TIMEOUT |
10s |
HTTP write timeout |
SUBSCRIPTION_FIREWALL_IDLE_TIMEOUT |
10s |
Простой keep-alive соединения; пусто или 0 — берётся READ_TIMEOUT |
SUBSCRIPTION_FIREWALL_SHUTDOWN_TIMEOUT |
10s |
Таймаут graceful shutdown |
SUBSCRIPTION_FIREWALL_RATE_LIMIT |
100 |
Запросов в секунду на клиентский IP (0 — выключить) |
SUBSCRIPTION_FIREWALL_RATE_BURST |
200 |
Допустимый burst сверх лимита (in-memory limiter) |
SUBSCRIPTION_FIREWALL_REDIS_ADDR |
— | Адрес Redis; при заданном rate limit общий для всех реплик |
SUBSCRIPTION_FIREWALL_REDIS_PASSWORD |
— | Пароль Redis (если требуется) |
SUBSCRIPTION_FIREWALL_OTLP_ENDPOINT |
— | OTLP-эндпоинт трейсинга (host:port); без переменной трейсинг выключен |
SUBSCRIPTION_FIREWALL_OTLP_INSECURE |
true |
Экспорт трейсов по http (без TLS) |
SUBSCRIPTION_FIREWALL_ISSUER_TIMEOUT |
5s |
Таймаут одного вызова эмитента карт |
SUBSCRIPTION_FIREWALL_ISSUER_RETRIES |
2 |
Повторы после первой неудачи |
SUBSCRIPTION_FIREWALL_ISSUER_BREAKER_THRESHOLD |
5 |
Подряд неудач, открывающих circuit breaker |
SUBSCRIPTION_FIREWALL_ISSUER_BREAKER_COOLDOWN |
30s |
Пауза breaker-а до пробного вызова |
SUBSCRIPTION_FIREWALL_ISSUANCE_TIMEOUT |
5m |
Через сколько брошенная бронь выдачи карты считается протухшей |
Пример DSN: firewall:пароль@tcp(127.0.0.1:3306)/subscription_firewall?parseTime=true&loc=UTC. Схема применяется версионированными миграциями goose (SQL-файлы в internal/adapter/mysql/migrations) при старте — повторный запуск безопасен. Интеграционные тесты адаптера запускаются при заданном SUBSCRIPTION_FIREWALL_TEST_DSN.
| Метод и путь | Описание |
|---|---|
GET /healthz |
Liveness (без auth) |
GET /readyz |
Readiness: 503, если БД недоступна (без auth) |
GET /version |
Версия, commit и дата сборки (без auth) |
GET /metrics |
Prometheus (без auth) |
POST /v1/transactions |
Инжест транзакции, идемпотентен по id (409 на повтор) |
GET /v1/users/{user_id}/subscriptions?limit&offset |
Подписки пользователя; limit и offset применяются в SQL |
GET /v1/subscriptions/{id} |
Подписка по id |
POST /v1/subscriptions/{id}/freeze / reactivate / terminate |
Жизненный цикл подписки (синхронно с картой) |
GET /v1/users/{user_id}/tokens?limit&offset |
Виртуальные карты пользователя; limit и offset применяются в SQL |
GET /v1/tokens/{id} |
Токен по id |
POST /v1/tokens/{id}/freeze / reactivate / terminate |
Жизненный цикл карты (синхронно с подпиской) |
POST /v1/tokens/{id}/authorize |
Авторизация списания по карте |
Коды ошибок: 400 — валидация, 401 — нет ключа, 403 — не хватает скоупа или карта не активна, 402 — превышен лимит, 404 — не найдено, 409 — дубликат/невалидный переход, 429 — rate limit, 503 — недоступен общий rate limit при операции списания.
Ответы на limit&offset остаются массивами: метаданных пагинации (всего строк, ссылок на соседние страницы) API не возвращает.
Логическое значение API-ключа сравнивается по SHA-256 в постоянном времени: так ни длина настроенного ключа, ни позиция совпадения не видны по времени ответа.
POST /v1/tokens/{id}/authorize отклоняет amount_minor <= 0 и невалидную валюту с 400. Проверка продублирована в домене (ErrInvalidAmount), поэтому защита не зависит от вызывающего кода.
Валидация инжеста отражает ограничения схемы: id, user_id, merchant_id — до 64 байт, merchant_name — до 255, authorized_at — не дальше суток в будущее (допуск расхождения часов) и не старше двух лет. Всё это приходит как 400, а не как 500 от СУБД. Тело запроса разбирается строго: лишний JSON после первого объекта тоже даёт 400.
subscription_firewall_* в /metrics: инжест, детекции, найденные подписки по состояниям (subscriptions_detected_total), переходы sweep-а (subscriptions_swept_total{state}), авторизации по результатам, глубина очереди детекции, дропы при переполнении, HTTP-запросы и латентность, синхронизация жизненного цикла (lifecycle_sync_total, linked_action_failures_total, lifecycle_queue_depth), build_info (версия/commit/дата сборки).
- Request-id: каждый ответ содержит
X-Request-Id; входящий заголовок пробрасывается, иначе генерируется.request_idпишется в логи HTTP-запросов и инжеста — используйте его для сквозной корреляции. - Контейнерный healthcheck: образ собирается на distroless (без шелла), поэтому
HEALTHCHECKвызывает подкоманду/firewall healthcheck, которая опрашивает/readyzтого же процесса. - Трейсинг: при заданном
SUBSCRIPTION_FIREWALL_OTLP_ENDPOINTHTTP-запросы и детекция оборачиваются в OpenTelemetry-спаны (экспорт OTLP/HTTP, W3C trace context). Локально удобно поднять Jaeger:docker compose --profile tracing up -d jaeger(UI на:16686).
.github/workflows/ci.yml— vet,go test -raceс покрытием и линтер; интеграционные тесты MySQL-адаптера гоняются против сервисаmysql:8.4в workflow..github/workflows/release.yml— сборка и публикация образа в GHCR при пуше вmainи тегахv*; версия/commit/дата прошиваются через ldflags..github/dependabot.yml— еженедельные обновления gomod, GitHub Actions и Dockerfile.
- In-memory хранилище рассчитано ровно на одну реплику. Данные не переживают рестарт, а блокировки репозиториев живут только в процессе: вторая реплика получит свой мир и разъедется с первой без единой ошибки. При старте без DSN сервис пишет об этом предупреждение. Для нескольких реплик нужен MySQL — он и есть единственный режим с реальными гарантиями.
- Эмитент карт — заглушка
SimulatedCardIssuer, выбирается черезSUBSCRIPTION_FIREWALL_ISSUER. Для продакшена нужен реальный адаптер: портports.VirtualCardIssuer, подключение вbuildCardIssuer. Сервис выдачи гарантирует один вызов провайдера на пару (user, merchant) и передаёт стабильныйIdempotencyKey, который провайдер может использовать для дедупликации. - Очередь детекции in-memory без БД теряет задачи при рестарте (dev-режим); с MySQL — outbox с гарантией at-least-once.
- Пагинация выполняется в SQL, но ответ остаётся массивом без метаданных: клиент не узнает общий объём выборки и не может перескочить на последнюю страницу одним запросом.
- Rate limit по IP берётся из
RemoteAddr: за обратным прокси все клиенты делят один адрес. Redis-лимитер общий для реплик; при недоступности Redis запросы чтения и управления проходят без лимита (fail-open), аPOST /v1/tokens/{id}/authorizeполучает503— списание денег без счётчика не проходит. - Трейс-контекст не распространяется из HTTP-запроса в задачу outbox: спаны детекции не связаны со спаном инжеста (корреляция по
user_idи request-id в логах).