Мини-необанк на микросервисной архитектуре.
gateway/— единая точка входа (API Gateway)services/— микросервисы:auth-svc,accounts-svc,ledger-svc,transfers-svc,fraud-svc,notifications-svcproto/— общие protobuf-контракты между сервисамиfrontend/— SPA (Vite + React + TypeScript), см. «Фронтенд» ниже.github/workflows/— CI-пайплайны
Postgres, Redis и Kafka подняты в docker-compose.yml. auth-svc использует все три (Postgres и Redis — с первого спринта, Kafka — как продюсер событий, см. ниже); остальные сервисы пока не подключены.
Креды Postgres в docker-compose.yml — только для локальной разработки, не для продакшена.
auth-svc публикует событие UserActivated в топик user.events сразу после успешного POST /verify-email (в момент, когда users.status переходит в active). Контракт — proto/events/v1/user_events.proto (events.v1.UserActivated: user_id, email, occurred_at, event_id), сериализация бинарным protobuf. Ключ сообщения — user_id: это гарантирует, что все события одного пользователя попадают в одну партицию и обрабатываются по порядку. event_id — случайный UUIDv4, генерируется в auth-svc на каждую публикацию (generateEventID в services/auth-svc/kafka.go) и используется accounts-svc для дедупликации при повторной доставке (см. «Идемпотентность» ниже).
accounts-svc — consumer этого топика (consumer group accounts-svc): на UserActivated создаёт строку в accounts со сгенерированным номером счёта и status = 'active', а сразу после этого — вызывает ledger-svc CreateLedgerAccount(account_id) по gRPC, чтобы у нового счёта появился ledger-аккаунт (адрес ledger — env LEDGER_GRPC_ADDR, дефолт ledger-svc:8083). Порядок фиксации важен: если вызов ledger упал, offset события не коммитится — Kafka передоставит сообщение, а идемпотентность (consumer'а и самого CreateLedgerAccount) делает повтор безопасным. Это ровно тот случай, ради которого строились at-least-once + идемпотентность.
Топик создаётся автоматически брокером при первой публикации (KAFKA_CFG_AUTO_CREATE_TOPICS_ENABLE: "true" задано явно в docker-compose.yml, хотя это и так поведение Kafka по умолчанию) — отдельного шага инициализации топика нет. auth-svc не блокирует старт на доступности Kafka: продюсер (segmentio/kafka-go) подключается лениво при первой записи и переподключается сам, как и клиенты Postgres/Redis.
Публикация в Kafka не входит в ту же транзакцию, что и обновление статуса в Postgres — это известное и осознанное ограничение MVP (см. TODO в services/auth-svc/kafka.go), по-настоящему решается паттерном outbox в будущем.
docker compose exec kafka kafka-topics.sh --bootstrap-server localhost:9092 --list
docker compose exec kafka kafka-console-consumer.sh \
--bootstrap-server localhost:9092 \
--topic user.events \
--from-beginning \
--property print.key=true \
--timeout-ms 10000key выводится читаемым текстом (это user_id), value — бинарный protobuf и в консоли будет нечитаемым — это ожидаемо, не баг.
accounts-svc — at-least-once consumer (сначала пишет в БД, потом коммитит оффсет; если упасть между этими двумя шагами, Kafka передоставит то же сообщение после рестарта). Повторная доставка UserActivated обрабатывается на двух независимых, дополняющих друг друга уровнях (handleUserActivated в services/accounts-svc/kafka.go):
accounts.user_id UNIQUE— INSERT используетON CONFLICT (user_id) DO NOTHING. Если строка для этогоuser_idуже есть, повторная доставка не создаёт вторую и не падает — логируется («already exists... not recreating») и оффсет коммитится как обычно. Это единственный уровень, который обязателен: он один гарантирует отсутствие дублей в любом случае, даже если ниже что-то пойдёт не так.processed_events(миграция000002,event_id UUID PRIMARY KEY, processed_at TIMESTAMPTZ) — быстрый путь для уже обработанных событий: перед обработкой consumer проверяет, есть лиevent_idв таблице, и если да — пропускает работу целиком, даже не трогаяaccounts. Запись вprocessed_eventsделается последним шагом, строго после того, как строка вaccountsподтверждённо существует (создана только что или уже была). Это осознанно: если бы событие помечалось обработанным до реальной обработки, а обработка затем упала бы по-настоящему (не из-за дубля, а по другой причине), оффсет не закоммитился бы, Kafka передоставила бы сообщение — ноprocessed_eventsуже говорила бы «готово», и повтор был бы ложно пропущен, а пользователь остался бы без счёта навсегда. Запись последним шагом закрывает эту дыру: любой сбой до неё оставляетprocessed_eventsпустой, и повтор всегда по-настоящему переобрабатывается.
Оба INSERT'а (accounts, затем processed_events) сознательно не обёрнуты в одну транзакцию: consumer однопоточный и последовательный (FetchMessage вызывается строго по одному сообщению за раз, без конкурентной обработки внутри процесса), гонок между сообщениями нет — а уровень 1 сам по себе делает пересоздание строки безопасным, даже если запись в processed_events не успела произойти или потерялась.
Между созданием счёта и записью в processed_events вклинивается ещё один шаг — вызов ledger-svc CreateLedgerAccount(account_id) (см. выше). processed_events по-прежнему пишется последним, строго после того, как и строка accounts, и ledger-аккаунт подтверждённо существуют. Если ledger-вызов падает (сервис недоступен, сетевой сбой), обработчик возвращает ошибку, offset не коммитится, Kafka передоставляет — а идемпотентность самого CreateLedgerAccount (ON CONFLICT (account_id) → возвращает существующий) делает повтор безопасным. Кросс-сервисный RPC в одну SQL-транзакцию с локальными записями обернуть нельзя в принципе — за корректность повтора отвечает именно идемпотентность на каждом уровне, а не общая транзакция.
Самый практичный способ воспроизвести повторную доставку без ручной сборки protobuf-сообщений — сбросить закоммиченный оффсет consumer-группы accounts-svc назад, заставив её перечитать уже обработанное сообщение:
# 1. Остановить accounts-svc — сброс оффсета требует неактивной группы
# (Kafka считает группу активной ещё некоторое время после остановки
# контейнера, из-за session timeout; проверить состояние можно через
# --describe, дождавшись "has no active members"):
docker compose stop accounts-svc
docker compose exec kafka kafka-consumer-groups.sh \
--bootstrap-server localhost:9092 --describe --group accounts-svc
# 2. Сдвинуть оффсет топика user.events на 1 сообщение назад
# (к последнему обработанному UserActivated):
docker compose exec kafka kafka-consumer-groups.sh \
--bootstrap-server localhost:9092 \
--group accounts-svc --topic user.events \
--reset-offsets --shift-by -1 --execute
# 3. Запустить accounts-svc заново — она перечитает то же сообщение:
docker compose start accounts-svc
docker compose logs -f accounts-svcПроверено вручную на этом стеке (bitnamilegacy/kafka:3.7.1): после шага 3 в логах появляется accounts-svc: event <event_id> already processed, skipping (redelivery), а SELECT count(*) FROM accounts WHERE user_id = '<user_id>' остаётся 1. Дополнительно проверен и уровень 1 отдельно: если вручную удалить строку из processed_events (DELETE FROM processed_events WHERE event_id = '<event_id>') и повторить шаги 1–3, лог показывает уже другую ветку — account for user <user_id> already exists (redelivery of event <event_id>), not recreating — то есть дедупликация срабатывает и без processed_events, только на ON CONFLICT (user_id); при этом строка в processed_events восстанавливается (самолечение), а счёт по-прежнему один. Оффсет консьюмера в обоих случаях в итоге закоммичен (kafka-consumer-groups.sh --describe показывает LAG 0), т.е. дубль не оставляет группу «застрявшей».
ledger-svc считает и хранит балансы (account_balances — кэш поверх лога entries, всегда пересчитываемый из него), исполняет атомарные переводы между двумя счетами и отдаёт историю проводок. У него нет публичного HTTP API и нет маршрута в gateway — это осознанно: единственный клиент ledger-svc — transfers-svc (со спринта 5), который сам отвечает за аутентификацию и авторизацию перевода до вызова ledger. Здесь нет ни X-User-Id, ни какой-либо другой клиентской идентичности — это внутренний, service-to-service контракт.
Протокол — gRPC, а не HTTP: это вызов между сервисами внутри кластера, а не браузерный запрос, и buf.gen.yaml в репозитории уже настроен на генерацию grpc-стабов (protoc-gen-go-grpc), так что добавить контракт стоило дёшево.
Контракт — proto/ledger/v1/ledger.proto (ledger.v1.LedgerService):
GetBalance(account_id) → balance— O(1) чтение изaccount_balances.ExecuteTransfer(from_account_id, to_account_id, amount) → transaction_id— атомарный перевод; бизнес-ошибки («недостаточно средств», «аккаунт не найден» — отдельно дляfrom/to, «невалидная сумма») возвращаются как grpc-статусы (FailedPrecondition,NotFound,InvalidArgument), а не как поле в успешном ответе — это gRPC-идиоматичный эквивалент HTTP-кода + JSON{"error": ...}в остальных сервисах репозитория.GetHistory(account_id, limit, offset) → entries[]— постранично, новые сверху (ORDER BY created_at DESC, id DESC;id— tie-breaker, потому что обе проводки одного перевода получают одинаковыйcreated_at:now()внутри одной транзакции Postgres фиксирован на её начало).
Генерация Go-кода из .proto: buf generate из корня репозитория (нужны локально buf, protoc-gen-go, protoc-gen-go-grpc).
Сервер дополнительно регистрирует стандартный grpc health-check (grpc.health.v1.Health) вместо HTTP /healthz и grpc reflection — для internal-only сервиса без внешних потребителей компромисс «reflection раскрывает контракт» не действует, а reflection избавляет от необходимости раздавать .proto-файлы, чтобы дёргать сервис через grpcurl.
grpcurl -plaintext localhost:8083 list
grpcurl -plaintext -d '{"account_id": "<uuid>"}' \
localhost:8083 ledger.v1.LedgerService/GetBalance
grpcurl -plaintext -d '{"from_account_id": "<uuid>", "to_account_id": "<uuid>", "amount": 1000}' \
localhost:8083 ledger.v1.LedgerService/ExecuteTransfer
grpcurl -plaintext -d '{"account_id": "<uuid>", "limit": 10, "offset": 0}' \
localhost:8083 ledger.v1.LedgerService/GetHistoryexecuteTransfer — единственный писатель в entries/account_balances, и он обязан отклонять перевод, если баланса не хватает. Опасность — классическая read-then-write гонка: два одновременных перевода с одного счёта оба читают один и тот же (ещё не списанный) баланс, оба видят «средств достаточно» и оба проходят — счёт уходит в минус, хотя каждая проверка по отдельности была «корректной».
Выбран SELECT ... FOR UPDATE, а не SERIALIZABLE. Обе стороны перевода (ledger_accounts строки from и to) блокируются FOR UPDATE внутри одной транзакции, в детерминированном порядке — по возрастанию account_id, а не в порядке from→to. Без этого два встречных перевода (A→B и B→A одновременно) могли бы захватить блокировки в противоположном порядке и словить дедлок; сортировка по account_id гарантирует, что обе транзакции всегда пытаются заблокировать один и тот же счёт первым — вторая просто ждёт, дедлок невозможен. SERIALIZABLE тоже решил бы гонку, но потребовал бы retry-цикла на 40001 serialization_failure — такого паттерна в репозитории нигде больше нет, и вносить его ради одной функции означало бы новый, ничем не подкреплённый класс ошибок. FOR UPDATE вместо этого просто блокирует вторую транзакцию до коммита первой — тот же приём, что уже используется в accounts-svc (updateAccountStatus) и auth-svc, только тут блокируются два счёта, а не один.
Тест, который это доказывает — TestExecuteTransfer_ConcurrentOverdraftPrevention (services/ledger-svc/ledger_test.go): счёт с балансом 10000, 20 горутин одновременно пытаются списать по 1000 (суммарно 20000 — вдвое больше, чем есть). Ожидаемо: ровно 10 успехов, 10 insufficient funds, итоговый баланс ровно 0 (никогда отрицательный), и SUM(entries) по всем счетам, задействованным в тесте, равен 0.
Это не тест логики «по одной проверке за раз» — он реально запускает 20 горутин параллельно, так что гонка (если она есть) успевает проявиться. Проверено вручную: если временно убрать FOR UPDATE из lockLedgerAccount, тест падает стабильно (10 из 10 прогонов) — все 20 переводов проходят, баланс уходит на −10000. С FOR UPDATE тест стабильно зелёный (прогонялся -count=15 подряд). Запустить самостоятельно:
DATABASE_URL="postgres://neobank:neobank_dev_password@localhost:5432/neobank?sslmode=disable" \
go test ./... -run TestExecuteTransfer_ConcurrentOverdraftPrevention -count=20 -v(-race здесь не годится — он ловит гонки по памяти Go, а не гонки по блокировкам строк в Postgres, которые как раз и проверяются; сама горутина в тесте не имеет разделяемого мутируемого состояния — каждая пишет только в свой индекс среза.)
GET /accounts/me (через Gateway, с валидным токеном) возвращает счёт пользователя вместе с балансом. Баланс — авторитетный, живёт в ledger-svc; accounts-svc получает его вызовом GetBalance(account_id) по gRPC (account_id = accounts.id = ledger_accounts.account_id).
Формат: balance — целое число в минимальных единицах (центах), плюс отдельное поле currency (сейчас всегда "EUR" — в ledger нет измерения валюты, а форматирование "123.45 €" — работа фронта, не API):
{ "id": "...", "user_id": "...", "account_number": "NB...", "status": "active",
"created_at": "...", "updated_at": "...", "balance": 50000, "currency": "EUR" }У нового пользователя ledger-аккаунт уже создан (через Kafka-обработчик выше), проводок нет → balance: 0.
Если ledger-svc временно недоступен (Unavailable/DeadlineExceeded), эндпоинт возвращает 503, а не 200 с нулевым балансом: показать фейковый ноль вместо настоящего баланса в банке хуже, чем честно сказать «сервис недоступен».
Только для локальной разработки. Не путь для прода.
-
services/ledger-svc/cmd/seed— наполняет локальную БД примерными ledger-данными (genesis + два счёта, см. заголовок файла). -
services/ledger-svc/cmd/devtopup— пополнение счёта пользователя до появления Stripe (спринт 9). Переводит--amountцентов с genesis-аккаунта на--account-id(этоaccounts.id) через обычныйExecuteTransferledger-svc — тот же реальный путь (локи, проверка баланса, обновление кэша), что и у продового перевода. Единственное, что не может пройти черезExecuteTransfer— эмиссия денег (источник ушёл бы в минус, аExecuteTransferэто запрещает): поэтому, когда у genesis не хватает средств, инструмент чеканит деньги в genesis прямой сбалансированной вставкой в БД (external → genesis, чтобыSUM(entries)=0сохранялся), и только потом делает настоящий перевод. Именно эта прямая эмиссия — причина, почему это dev-инструмент, а не HTTP-эндпоинт.# из services/ledger-svc, ledger-svc должен быть запущен (docker compose up ledger-svc) DATABASE_URL="postgres://neobank:neobank_dev_password@localhost:5432/neobank?sslmode=disable" \ LEDGER_GRPC_ADDR="localhost:8083" \ go run ./cmd/devtopup --account-id <accounts.id> --amount 50000
После этого
GET /accounts/meдля того же пользователя показывает"balance": 50000.
frontend/ — SPA на Vite + React + TypeScript, обращается к бэкенду через Gateway (http://localhost:8080). Роутинг, структура проекта и типизированный API-слой уже на месте; настоящих форм и экранов пока нет — это следующие шаги.
cd frontend
npm install
npm run devПоднимает dev-сервер на http://localhost:5173 (порт по умолчанию у Vite). Бэкенд (в первую очередь Gateway) поднимается отдельно, docker compose up.
frontend/src/
├── app/ — роутинг (react-router), провайдеры (react-query), layout-shell
├── features/
│ ├── auth/ — components/ (LoginPage, RegisterPage), api.ts (register/login/logout/...); hooks/ появятся вместе с реальными формами
│ └── accounts/ — components/ (DashboardPage), api.ts (getMe); hooks/ появятся вместе с реальными запросами в UI
└── shared/
├── ui/ — переиспользуемые примитивы: Button, Input, Card, tokens.css
└── api-client/ — HTTP-слой: fetch-обёртка, токены, single-flight refresh, сгенерированные типы (см. «API-клиент» ниже)
Принцип: фича несёт свои компоненты, хуки и вызовы API рядом, а не разложена по components/, hooks/, api/ на верхнем уровне репозитория. shared/api-client/ — только инфраструктура (fetch, токены, retry-логика), а не место для конкретных вызовов конкретных эндпоинтов: те типизированы через сгенерированные типы, но живут в api.ts своей фичи.
Стили — CSS Modules (*.module.css), без отдельной библиотеки: работают у Vite из коробки, и классы уже естественно скопированы по компонентам — то же самое разбиение, что и у feature-based структуры. Общие токены (цвета, отступы, radius, шрифт) — shared/ui/tokens.css, CSS custom properties с поддержкой prefers-color-scheme: dark.
У Gateway нет префикса /api — маршруты у него /auth/*, /accounts/* и т.д. напрямую (gateway/proxy.go). Фронт обращается к /api/*; dev-сервер Vite (frontend/vite.config.ts) перехватывает /api/*, снимает префикс /api и проксирует остаток на http://localhost:8080. Например, GET /api/accounts/me с фронта уходит на Gateway как GET /accounts/me.
Это полностью убирает проблему CORS в разработке: браузер видит только один origin (dev-сервер Vite), запрос к Gateway идёт со стороны самого dev-сервера, а не напрямую из браузера. В продакшене так же работать не будет — там нужно либо отдавать собранный статик (npm run build → frontend/dist/) через сам Gateway (тогда фронт и API снова на одном origin), либо явно выставить CORS-заголовки на Gateway, если фронт и бэкенд остаются на разных origin. Этот выбор — не часть текущего шага.
Выбран вариант с OpenAPI-спекой, а не ручными TS-типами. Контракт Gateway (8 auth-эндпоинтов + GET /accounts/me) описан в gateway/openapi.yaml; frontend/src/shared/api-client/schema.ts генерируется из него командой npm run gen:api (обёртка над openapi-typescript, см. frontend/package.json) и руками не редактируется. В спеку осознанно не включены GET /accounts/{id} и PATCH /accounts/{id}/status — Gateway их проксирует, но фронт их не вызывает и не будет: это внутренняя/оперская поверхность accounts-svc, не часть контракта с браузером.
Причина выбора: спека — это ещё и единственное живое, проверяемое описание того, что Gateway на самом деле принимает и отдаёт (тело запроса, все коды ответа, какие пути требуют bearer-токен — это тоже в спеке, security по каждому эндпоинту списан прямо с gateway/middleware.go). Ручные типы работали бы не хуже день в день, но расходятся с бэкендом молча: ничто не заставляет вспомнить о них при следующем изменении хендлера. Цена — лишний шаг генерации при каждом изменении контракта; при таком маленьком числе эндпоинтов (9) она того стоит.
Сами типизированные HTTP-методы (register, login, getMe, ...) не генерируются — это обычные функции в features/*/api.ts, использующие типы paths[...] из сгенерированной схемы под каждый параметр и ответ. Осознанно не взят openapi-fetch (типизированный клиент поверх той же генерации): он берёт на себя разбор ответа и заворачивает результат в {data, error}, что плохо сочетается с тем, что должен делать shared/api-client/client.ts сам — единообразно бросать ApiError (со статусом и телом) и перехватывать 401 для refresh-and-retry. Взято от openapi-typescript только то, что действительно нужно — типы, — а вся управляющая логика написана руками.
# перегенерировать типы после любого изменения gateway/openapi.yaml
cd frontend
npm run gen:apinpm audit на этом шаге показывает 2 high (ReDoS в js-yaml, транзитивная зависимость openapi-typescript → @redocly/openapi-core). Это dev-only инструмент, парсящий только наш собственный gateway/openapi.yaml, а не недоверенный ввод — реальной экспозиции нет; npm audit fix пока недоступен из-за конфликта peer-зависимости openapi-typescript на TypeScript (заявлен ^5.x, в репозитории уже ~6.0.2 — сам пакет от этого не ломается, конфликтует только резолвер).
- Access-токен (JWT, TTL 15 минут) — только в памяти, модульная переменная в
shared/api-client/tokenStore.ts. Не переживает перезагрузку страницы. - Refresh-токен (opaque, TTL 7 дней, одноразовый — ротируется на каждый
/auth/refresh) — вlocalStorage, чтобы сессия переживала перезагрузку.
Это компромисс, не забывчивость. localStorage уязвим к XSS: любой инжектнутый в страницу JS может прочитать localStorage и увести refresh-токен, а с ним — возможность бесконечно перевыпускать сессию. По-настоящему правильное решение — httpOnly-cookie для refresh-токена: тогда JS (в том числе инжектнутый) физически не может его прочитать, только браузер молча прикладывает cookie к запросам на /auth/refresh. Это осознанно не сделано на этом шаге, потому что требует правки бэкенда (auth-svc должен отвечать на /login//refresh через Set-Cookie, а не JSON-полем refresh_token, плюс SameSite/Secure-политика, плюс сам Gateway должен научиться читать cookie, а не только Authorization-заголовок) — то есть контракт TokenPair в gateway/openapi.yaml пришлось бы менять вместе с этим. Текущий вариант (localStorage) — сознательно принятый краткосрочный компромисс, а не то, как это должно остаться.
Держать access-токен вне localStorage (только в памяти) — это половина смягчения: даже успешный XSS не достаёт долгоживущий JWT напрямую, только 15-минутный, и то лишь пока вкладка открыта. Полностью проблему это не снимает (тот же XSS всё ещё может дёрнуть /accounts/me от имени пользователя, пока вкладка жива, и достать refresh-токен из localStorage), но сужает окно и цену компрометации.
shared/api-client/client.ts: любой запрос, получивший 401, автоматически вызывает /auth/refresh и повторяет исходный запрос с новым access-токеном; если сам refresh не проходит (отклонён бэкендом, а не просто сетевой сбой) — токены чистятся и client.ts делает window.location.href = '/login'. Это единственная функция, которая триггерит refresh: см. флаг skipAuthRetry в RequestOptions — им помечены все auth-эндпоинты, у которых собственный 401 (например, /auth/login с неверным паролем) значит совсем не «токен протух», а /auth/logout (единственный auth-путь, реально требующий сессии — см. publicPaths в gateway/middleware.go) от общей логики не освобождён.
Критично — single-flight: refreshPromise в client.ts — общий promise на модуль. Первый вызов, поймавший 401, создаёт его и реально бьёт по /auth/refresh; все остальные конкурентные вызовы видят уже созданный promise и ждут его вместо того, чтобы стрелять своим запросом. Это не оптимизация, а необходимость: refresh-токен одноразовый (ротируется при каждом вызове, спринт 1) — без single-flight пять параллельных запросов на /auth/refresh означали бы, что только первый пройдёт, а остальные четыре попытаются погасить уже использованный токен и получат отказ, разлогинив пользователя на ровном месте. После завершения (успех или неудача) refreshPromise сбрасывается в null через .finally() — следующий, независимый протухший токен (например, 15 минут спустя) запускает новый цикл, а не переиспользует уже разрешившийся promise.
Как проверено. Ручной сценарий из постановки (открыть dashboard с несколькими параллельными запросами, посмотреть Network) пока недоступен буквально — экраны и data-fetching в UI появятся в следующих промптах, DashboardPage сейчас статичная заглушка. Вместо этого поведение проверено скриптом, гоняющим настоящий client.ts/tokenStore.ts под Node (tsx) с подменёнными fetch/localStorage: 5 параллельных запросов, каждый ловит 401, и — ровно один вызов /auth/refresh, все 5 успешно повторились с новым токеном. Отдельно проверено, что после первого цикла refreshPromise не залипает: второй, независимый протухший токен запускает новый (второй) вызов /auth/refresh, а не переиспользует уже разрешившийся promise. Скрипт был временным (не закоммичен) — при появлении реального dashboard с несколькими запросами стоит повторить проверку буквально, через Network-вкладку.
/register, /login, /dashboard — сейчас пустые страницы-заглушки (заголовок внутри Card), нужны только чтобы проверить, что роутинг работает. / редиректит на /login.
На этом шаге описана только структура репозитория и docker-compose.yml.
Следующие шаги добавят Go-код сервисов, интеграцию с инфраструктурой (Postgres/Redis/Kafka) и CI.