Repository navigation
Doc Architecture
Сайт состоит из фронтенда (SPA на React), API Gateway и пяти сервисов на .NET 10. У каждого сервиса своя база данных. Снаружи виден только веб-сервер nginx, всё остальное во внутренней сети Docker.
Пользователь открывает страницу тура.
- Браузер загружает SPA с nginx и делает запрос
GET /api/v1/catalog/tours/{id}. - nginx передаёт всё, что начинается с
/api/, в Gateway. - Gateway находит маршрут
catalog-public-route(GET без входа), убирает префикс/api/v1и проксирует запрос в Catalog какGET /catalog/tours/{id}. - Catalog читает тур из своей базы и возвращает JSON. Вместо адресов картинок в ответе только id файлов.
- Браузер строит адреса картинок вида
/api/v1/media/files/{id}/w800, и эти запросы тем же путём идут в Media.
flowchart LR
B[Браузер] -->|HTTPS| NG[nginx + SPA]
NG -->|/api/*| GW[Gateway]
GW --> ID[Identity]
GW --> CAT[Catalog]
GW --> BKG[Booking]
GW --> CH[Chat]
GW --> MED[Media]
BKG -- gRPC --> CAT
BKG -- gRPC --> ID
CH -- gRPC --> BKG
CAT -- gRPC --> MED
ID --> PG[(PostgreSQL)]
CAT --> PG
BKG --> PG
CH --> PG
MED --> PG
GW -.-> R[(Redis)]
ID -.-> R
CH -.-> R
MED --> S3[(MinIO)]
Сплошные стрелки к хранилищам: обязательная зависимость. Пунктир: Redis используется, если задана строка подключения, иначе сервис работает без него.
- Сервис на предметную область. Пользователи, каталог, заявки, чат и файлы живут в разных сервисах.
-
База на сервис. Один экземпляр PostgreSQL, пять баз:
travel_identity,travel_catalog,travel_booking,travel_chat,travel_media(создаются скриптомdocker/init-db.sql). Сервис не читает чужую базу, а ссылается на чужие сущности только по id. - Gateway без бизнес-логики. В нём маршрутизация, проверка JWT и ролей, лимиты запросов, CORS и перевод cookie в заголовок авторизации.
-
Внешний API через один префикс
/api/v1. Внутренние адреса сервисов наружу не публикуются. - Синхронные вызовы между сервисами по gRPC. Брокера сообщений нет.
| Сервис | Отвечает за | REST (внутри) | gRPC-сервер |
|---|---|---|---|
| Gateway | Единая точка входа |
/api/v1/*, /health/*
|
нет |
| Identity | Регистрация, вход, токены, профиль, роли | /identity/* |
GetUserSummary |
| Catalog | Туры, направления, конструктор, публикация | /catalog/* |
GetTourSnapshot, GetTourSnapshotForExistingBooking
|
| Booking | Заявки, предложения, статусы, избранное |
/bookings/*, /favorites/*
|
ValidateBookingAccess |
| Chat | Сообщения по заявке, realtime через SignalR |
/chat/*, хаб /hubs/chat
|
нет |
| Media | Загрузка файлов, превью, отдача картинок | /media/* |
GetMediaFiles, MarkMediaFilesPublic
|
| Фронтенд | SPA, раздаётся nginx | нет | нет |
| Кто вызывает | Кого | Зачем |
|---|---|---|
| Gateway | все сервисы, HTTP | проксирование внешних запросов |
| Booking | Catalog, gRPC | снимок тура при создании заявки, избранного и предложения |
| Booking | Identity, gRPC | имя, email и роль пользователя (карточка заявки для менеджера, проверка доступа к чату) |
| Chat | Booking, gRPC | есть ли у пользователя доступ к заявке |
| Catalog | Media, gRPC | проверка владельца и ширины фото, пометка фото публичными при публикации тура |
Внутри каждого контейнера два порта: 8080 для REST и 8081 для gRPC (HTTP/2). Вызовы gRPC сопровождаются заголовком x-internal-auth с общим сервисным токеном; без него сервер отвечает отказом. Подробно: Межсервисные контракты.
| Хранилище | Кто использует | Для чего |
|---|---|---|
| PostgreSQL | все сервисы, кроме Gateway | основные данные; миграции EF Core применяются при старте (ASPNETCORE_RUN_MIGRATIONS=true) |
| Redis | Gateway | общий счётчик лимитов запросов (если Redis недоступен, запросы пропускаются) |
| Redis | Identity | счётчик неудачных входов и блокировка (lockout) |
| Redis | Chat | только проверка готовности (/health/ready) |
| MinIO (S3) | Media | файлы и превью; bucket закрыт, наружу файлы отдаёт только Media |
Каждый сервис разбит на слои:
src/Services/<Имя>/
├── TravelAgency.<Имя>.Domain/ сущности и правила
├── TravelAgency.<Имя>.Application/ команды и запросы (MediatR), DTO, валидация (FluentValidation)
├── TravelAgency.<Имя>.Infrastructure/ EF Core, репозитории, gRPC-клиенты и серверы, хранилище
├── TravelAgency.<Имя>.API/ контроллеры, middleware, Program.cs
└── тесты (UnitTests, IntegrationTests)
Общий код лежит в src/Shared/:
| Проект | Что внутри |
|---|---|
TravelAgency.Shared.Contracts |
роли (Client, Manager, Admin), имена политик доступа, общие абстракции |
TravelAgency.Shared.Infrastructure |
проверка JWT, correlation id, глобальная обработка ошибок в ProblemDetails, логирование, gRPC-перехватчик сервисного токена |
TravelAgency.Contracts.Grpc |
файлы .proto и сгенерированный код |
-
Ошибки возвращаются в формате ProblemDetails (RFC 7807):
type,title,status,detail. Как сбой запроса пишется в лог: Логирование ошибок. -
Correlation id: заголовок
X-Correlation-Idчитается или создаётся, попадает в логи и в ответ. - Логи: Serilog, структурированные, в консоль контейнера. Уровни для ошибок запросов: Логирование ошибок.
-
Трассировка: OpenTelemetry во всех сервисах. Экспорт по OTLP настроен только у Gateway (
Otel:Endpoint, по умолчаниюhttp://otel-collector:4317), но сервисаotel-collectorв compose нет; остальные сервисы экспортёра не имеют. Куда трейсы попадают на dev, требует проверки, задача #166. -
Здоровье: у каждого сервиса
/health/live(жив ли процесс) и/health/ready(доступны ли зависимости).
Простой пример. Клиент вводит неверный пароль: Identity отвечает 401, в лог уходит одна строка Warning без стектрейса: POST /identity/login 401 UnauthorizedException: Invalid credentials. traceId=.... Непредвиденное исключение даёт ответ 500 и одну строку Error вместе с исключением и стектрейсом.
Правило общее для всех сервисов. GlobalExceptionHandlerMiddleware (TravelAgency.Shared.Infrastructure) подключён в Identity, Catalog, Booking, Chat, Media и Gateway. Он логирует сбой запроса один раз, уже после того как исключение переведено в код ответа:
| Код ответа | Уровень | Что в записи |
|---|---|---|
| 4xx: 401, 403, 404, 400 при ошибке валидации и остальные коды от 400 до 499 | Warning | метод, путь, код, тип и текст исключения, traceId; объект исключения не передаётся, стектрейса нет |
| 5xx и исключения без перевода в код | Error | то же и объект исключения со стектрейсом |
- Свои исключения сервис переводит в код через
IExceptionMapper. Без маппера обработчик сам отдаёт 400 наValidationException(FluentValidation) иArgumentException, 401 наUnauthorizedAccessException, на всё остальное 500. - У Gateway своего маппера нет: необработанное исключение, кроме трёх перечисленных типов, даёт 500 и Error.
- Если ответ уже начал отправляться, код не меняется, исключение пишется как Error.
-
LoggingBehaviorиValidationBehavior(MediatR) исключение не логируют, только пробрасывают, поэтому второй записи о том же сбое нет.
Строка access-лога Serilog (UseTravelAgencyRequestLogging, уровень выбирает RequestLoggingExtensions.GetLevel):
- 5xx: Error.
- 4xx: Information, даже если к запросу прикреплено исключение.
- Прикреплённое исключение при коде ниже 400: Error.
-
/healthи/healthzс кодом ниже 500: Debug, остальное Information.
Во всех сервисах UseTravelAgencyRequestLogging подключён снаружи GlobalExceptionHandlerMiddleware. Обработчик перехватывает исключение и дальше его не пробрасывает, поэтому строка access-лога получает уже готовый код ответа без исключения: для 401 это Information, для 500 Error. Подробности исключения есть только в записи обработчика.
См. также: Где и как работает · Публичный API · Стек и версии
Система
API
Сервисы
Как реализовано
Справочное
- Видение продукта
- Дорожная карта
- Этап 1: что осталось
- Этап 2: расширение
- Этап 3: масштабирование
- Концепция сайта и интерфейса
- Шаблон ТЗ