From ccb108253593c6db0879a267f12558ec48296033 Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Thu, 25 Jun 2026 20:20:59 +0300 Subject: [PATCH] refactor(backend): unify envelope DTOs and fix shares/bonds inconsistency - Replace 4 duplicate meta DTOs (AuthResponseMetaDto, PortfolioResponseMetaDto, BrokerResponseMetaDto, ScreenerResponseMetaDto) with shared ApiResponseMeta - Wrap shares getShare() in ApiEnvelopePayload (was raw object, unlike bonds) - Remove unnecessary CacheModule import from securities module - Update portfolio controller nullDataEnvelopeSchema to use shared ApiResponseMeta - All 116 tests pass --- .../src/modules/auth/dto/auth-response.dto.ts | 21 +++---- .../portfolio/dto/portfolio-envelope.dto.ts | 29 ++++----- .../modules/portfolio/portfolio.controller.ts | 6 +- .../securities/dto/screener-response.dto.ts | 13 +--- .../modules/securities/securities.module.ts | 2 - .../src/modules/shares/shares.service.spec.ts | 4 +- .../src/modules/shares/shares.service.ts | 60 +++++++++++-------- .../modules/tbank/dto/broker-envelope.dto.ts | 37 +++++------- .../backend-architecture-improvements/plan.md | 52 ++++++++++++++++ .../backend-architecture-improvements/spec.md | 33 ++++++++++ .../tasks.md | 53 ++++++++++++++++ docs/research/2026-06-25-backend-audit.md | 49 +++++++++++++++ 12 files changed, 262 insertions(+), 97 deletions(-) create mode 100644 docs/features/backend-architecture-improvements/plan.md create mode 100644 docs/features/backend-architecture-improvements/spec.md create mode 100644 docs/features/backend-architecture-improvements/tasks.md create mode 100644 docs/research/2026-06-25-backend-audit.md diff --git a/apps/backend/src/modules/auth/dto/auth-response.dto.ts b/apps/backend/src/modules/auth/dto/auth-response.dto.ts index da8885d..2a5d561 100644 --- a/apps/backend/src/modules/auth/dto/auth-response.dto.ts +++ b/apps/backend/src/modules/auth/dto/auth-response.dto.ts @@ -1,12 +1,5 @@ import { ApiProperty } from '@nestjs/swagger'; - -class AuthResponseMetaDto { - @ApiProperty({ type: String, nullable: true }) - cachedAt!: string | null; - - @ApiProperty() - fromCache!: boolean; -} +import { ApiResponseMeta } from '../../../common/dto/api-response.dto'; class AuthUserDto { @ApiProperty() @@ -39,22 +32,22 @@ export class AuthTokenResponseDto { @ApiProperty({ type: AuthTokenDataDto }) data!: AuthTokenDataDto; - @ApiProperty({ type: AuthResponseMetaDto }) - meta!: AuthResponseMetaDto; + @ApiProperty({ type: ApiResponseMeta }) + meta!: ApiResponseMeta; } export class AuthProfileResponseDto { @ApiProperty({ type: AuthUserDto }) data!: AuthUserDto; - @ApiProperty({ type: AuthResponseMetaDto }) - meta!: AuthResponseMetaDto; + @ApiProperty({ type: ApiResponseMeta }) + meta!: ApiResponseMeta; } export class AuthLogoutResponseDto { @ApiProperty({ type: LogoutDataDto }) data!: LogoutDataDto; - @ApiProperty({ type: AuthResponseMetaDto }) - meta!: AuthResponseMetaDto; + @ApiProperty({ type: ApiResponseMeta }) + meta!: ApiResponseMeta; } diff --git a/apps/backend/src/modules/portfolio/dto/portfolio-envelope.dto.ts b/apps/backend/src/modules/portfolio/dto/portfolio-envelope.dto.ts index 37ae555..1ff52e7 100644 --- a/apps/backend/src/modules/portfolio/dto/portfolio-envelope.dto.ts +++ b/apps/backend/src/modules/portfolio/dto/portfolio-envelope.dto.ts @@ -1,53 +1,46 @@ import { ApiProperty } from '@nestjs/swagger'; +import { ApiResponseMeta } from '../../../common/dto/api-response.dto'; import { AnalyticsResponseDto } from './analytics-response.dto'; import { PortfolioListResponseDto } from './portfolio-list-response.dto'; import { PortfolioDetailResponseDto, PortfolioResponseDto } from './portfolio-response.dto'; import { PositionResponseDto } from './position-response.dto'; -export class PortfolioResponseMetaDto { - @ApiProperty({ type: String, nullable: true }) - cachedAt!: string | null; - - @ApiProperty() - fromCache!: boolean; -} - export class PortfolioListEnvelopeDto { @ApiProperty({ type: [PortfolioListResponseDto] }) data!: PortfolioListResponseDto[]; - @ApiProperty({ type: PortfolioResponseMetaDto }) - meta!: PortfolioResponseMetaDto; + @ApiProperty({ type: ApiResponseMeta }) + meta!: ApiResponseMeta; } export class PortfolioEnvelopeDto { @ApiProperty({ type: PortfolioResponseDto }) data!: PortfolioResponseDto; - @ApiProperty({ type: PortfolioResponseMetaDto }) - meta!: PortfolioResponseMetaDto; + @ApiProperty({ type: ApiResponseMeta }) + meta!: ApiResponseMeta; } export class PortfolioDetailEnvelopeDto { @ApiProperty({ type: PortfolioDetailResponseDto }) data!: PortfolioDetailResponseDto; - @ApiProperty({ type: PortfolioResponseMetaDto }) - meta!: PortfolioResponseMetaDto; + @ApiProperty({ type: ApiResponseMeta }) + meta!: ApiResponseMeta; } export class PositionEnvelopeDto { @ApiProperty({ type: PositionResponseDto }) data!: PositionResponseDto; - @ApiProperty({ type: PortfolioResponseMetaDto }) - meta!: PortfolioResponseMetaDto; + @ApiProperty({ type: ApiResponseMeta }) + meta!: ApiResponseMeta; } export class AnalyticsEnvelopeDto { @ApiProperty({ type: AnalyticsResponseDto }) data!: AnalyticsResponseDto; - @ApiProperty({ type: PortfolioResponseMetaDto }) - meta!: PortfolioResponseMetaDto; + @ApiProperty({ type: ApiResponseMeta }) + meta!: ApiResponseMeta; } diff --git a/apps/backend/src/modules/portfolio/portfolio.controller.ts b/apps/backend/src/modules/portfolio/portfolio.controller.ts index c9badd4..b43035a 100644 --- a/apps/backend/src/modules/portfolio/portfolio.controller.ts +++ b/apps/backend/src/modules/portfolio/portfolio.controller.ts @@ -13,13 +13,13 @@ import { CreatePortfolioDto } from './dto/create-portfolio.dto'; import { UpdatePortfolioDto } from './dto/update-portfolio.dto'; import { AddPositionDto } from './dto/add-position.dto'; import { UpdatePositionDto } from './dto/update-position.dto'; +import { ApiResponseMeta } from '../../common/dto/api-response.dto'; import { CurrentUser } from '../auth/decorators/current-user.decorator'; import { AnalyticsEnvelopeDto, PortfolioDetailEnvelopeDto, PortfolioEnvelopeDto, PortfolioListEnvelopeDto, - PortfolioResponseMetaDto, PositionEnvelopeDto, } from './dto/portfolio-envelope.dto'; @@ -27,14 +27,14 @@ const nullDataEnvelopeSchema = { type: 'object', properties: { data: { type: 'null' }, - meta: { $ref: getSchemaPath(PortfolioResponseMetaDto) }, + meta: { $ref: getSchemaPath(ApiResponseMeta) }, }, required: ['data', 'meta'], }; @ApiTags('Portfolios') @ApiBearerAuth() -@ApiExtraModels(PortfolioResponseMetaDto) +@ApiExtraModels(ApiResponseMeta) @Controller('portfolios') export class PortfolioController { constructor(private readonly portfolioService: PortfolioService) {} diff --git a/apps/backend/src/modules/securities/dto/screener-response.dto.ts b/apps/backend/src/modules/securities/dto/screener-response.dto.ts index 3c765d8..1f265a6 100644 --- a/apps/backend/src/modules/securities/dto/screener-response.dto.ts +++ b/apps/backend/src/modules/securities/dto/screener-response.dto.ts @@ -1,4 +1,5 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { ApiResponseMeta } from '../../../common/dto/api-response.dto'; export class ScreenerItemDto { @ApiProperty({ example: 'SBER' }) @@ -70,18 +71,10 @@ export class ScreenerResultDto { totalPages!: number; } -class ScreenerResponseMetaDto { - @ApiProperty({ type: String, nullable: true }) - cachedAt!: string | null; - - @ApiProperty() - fromCache!: boolean; -} - export class ScreenerResponseDto { @ApiProperty({ type: ScreenerResultDto }) data!: ScreenerResultDto; - @ApiProperty({ type: ScreenerResponseMetaDto }) - meta!: ScreenerResponseMetaDto; + @ApiProperty({ type: ApiResponseMeta }) + meta!: ApiResponseMeta; } diff --git a/apps/backend/src/modules/securities/securities.module.ts b/apps/backend/src/modules/securities/securities.module.ts index dc3736d..ce1bba3 100644 --- a/apps/backend/src/modules/securities/securities.module.ts +++ b/apps/backend/src/modules/securities/securities.module.ts @@ -1,11 +1,9 @@ import { Module } from '@nestjs/common'; -import { CacheModule } from '../cache/cache.module'; import { SecuritiesController } from './securities.controller'; import { SecuritiesService } from './securities.service'; import { ScreenerService } from './screener.service'; @Module({ - imports: [CacheModule], controllers: [SecuritiesController], providers: [SecuritiesService, ScreenerService], exports: [SecuritiesService], diff --git a/apps/backend/src/modules/shares/shares.service.spec.ts b/apps/backend/src/modules/shares/shares.service.spec.ts index 5873412..d261af7 100644 --- a/apps/backend/src/modules/shares/shares.service.spec.ts +++ b/apps/backend/src/modules/shares/shares.service.spec.ts @@ -83,7 +83,7 @@ describe('SharesService', () => { 'marketDataTtl', ); expect(moexClient.getShareMarketData).toHaveBeenCalledWith('SBER'); - expect(result).toMatchObject({ + expect(result.data).toMatchObject({ secid: 'SBER', isin: 'RU0009029540', name: 'Сбербанк России ПАО ао', @@ -106,7 +106,7 @@ describe('SharesService', () => { issueCapitalization: 6900000000000, }, }); - expect(result.marketData.updatedAt).toMatch(/T18:45:00$/); + expect(result.data.marketData.updatedAt).toMatch(/T18:45:00$/); }); it('throws NotFoundException for non-share security', async () => { diff --git a/apps/backend/src/modules/shares/shares.service.ts b/apps/backend/src/modules/shares/shares.service.ts index e565d9e..b9032d2 100644 --- a/apps/backend/src/modules/shares/shares.service.ts +++ b/apps/backend/src/modules/shares/shares.service.ts @@ -23,7 +23,11 @@ export class SharesService { throw new NotFoundException(`Share ${secid} not found`); } - const { data: marketData } = await this.cache.getOrFetch( + const { + data: marketData, + fromCache, + cachedAt, + } = await this.cache.getOrFetch( 'marketdata', ['shares', secid], () => this.moexClient.getShareMarketData(secid), @@ -34,32 +38,36 @@ export class SharesService { const change = marketData?.lastChange ?? 0; const changePercent = marketData?.lastChangePrcnt ?? 0; - return { - secid: desc.secid, - isin: desc.isin, - name: desc.name, - shortName: desc.shortName, - latName: desc.latName, - listLevel: desc.listLevel, - issueSize: desc.issueSize, - faceValue: desc.faceValue, - faceUnit: desc.faceUnit === 'SUR' ? 'RUB' : desc.faceUnit, - type: desc.type, - marketData: { - price: price ?? 0, - change, - changePercent, - open: marketData?.open ?? 0, - high: marketData?.high ?? null, - low: marketData?.low ?? null, - volume: marketData?.volume ?? 0, - value: marketData?.value ?? 0, - issueCapitalization: marketData?.issueCapitalization ?? null, - updatedAt: marketData?.updateTime - ? new Date().toISOString().split('T')[0] + 'T' + marketData.updateTime - : new Date().toISOString(), + return new ApiEnvelopePayload( + { + secid: desc.secid, + isin: desc.isin, + name: desc.name, + shortName: desc.shortName, + latName: desc.latName, + listLevel: desc.listLevel, + issueSize: desc.issueSize, + faceValue: desc.faceValue, + faceUnit: desc.faceUnit === 'SUR' ? 'RUB' : desc.faceUnit, + type: desc.type, + marketData: { + price: price ?? 0, + change, + changePercent, + open: marketData?.open ?? 0, + high: marketData?.high ?? null, + low: marketData?.low ?? null, + volume: marketData?.volume ?? 0, + value: marketData?.value ?? 0, + issueCapitalization: marketData?.issueCapitalization ?? null, + updatedAt: marketData?.updateTime + ? new Date().toISOString().split('T')[0] + 'T' + marketData.updateTime + : new Date().toISOString(), + }, }, - }; + fromCache, + cachedAt, + ); } async getMarketData(secid: string) { diff --git a/apps/backend/src/modules/tbank/dto/broker-envelope.dto.ts b/apps/backend/src/modules/tbank/dto/broker-envelope.dto.ts index 50f047a..cadf06f 100644 --- a/apps/backend/src/modules/tbank/dto/broker-envelope.dto.ts +++ b/apps/backend/src/modules/tbank/dto/broker-envelope.dto.ts @@ -1,4 +1,5 @@ import { ApiProperty } from '@nestjs/swagger'; +import { ApiResponseMeta } from '../../../common/dto/api-response.dto'; import { BrokerAccountResponseDto } from './broker-account-response.dto'; import { BrokerEventsDataDto } from './broker-events-response.dto'; import { BrokerOperationSyncResponseDto } from './broker-operation-sync-query.dto'; @@ -7,66 +8,58 @@ import { BrokerPositionsPageResponseDto } from './broker-positions-page-response import { BrokerPortfolioResponseDto } from './broker-portfolio-response.dto'; import { BrokerAnalyticsDto } from './broker-analytics-response.dto'; -export class BrokerResponseMetaDto { - @ApiProperty({ nullable: true }) - cachedAt!: string | null; - - @ApiProperty() - fromCache!: boolean; -} - export class BrokerAccountsEnvelopeDto { @ApiProperty({ type: [BrokerAccountResponseDto] }) data!: BrokerAccountResponseDto[]; - @ApiProperty({ type: BrokerResponseMetaDto }) - meta!: BrokerResponseMetaDto; + @ApiProperty({ type: ApiResponseMeta }) + meta!: ApiResponseMeta; } export class BrokerPortfolioEnvelopeDto { @ApiProperty({ type: BrokerPortfolioResponseDto }) data!: BrokerPortfolioResponseDto; - @ApiProperty({ type: BrokerResponseMetaDto }) - meta!: BrokerResponseMetaDto; + @ApiProperty({ type: ApiResponseMeta }) + meta!: ApiResponseMeta; } export class BrokerOperationsEnvelopeDto { @ApiProperty({ type: BrokerOperationsPageResponseDto }) data!: BrokerOperationsPageResponseDto; - @ApiProperty({ type: BrokerResponseMetaDto }) - meta!: BrokerResponseMetaDto; + @ApiProperty({ type: ApiResponseMeta }) + meta!: ApiResponseMeta; } export class BrokerPositionsEnvelopeDto { @ApiProperty({ type: BrokerPositionsPageResponseDto }) data!: BrokerPositionsPageResponseDto; - @ApiProperty({ type: BrokerResponseMetaDto }) - meta!: BrokerResponseMetaDto; + @ApiProperty({ type: ApiResponseMeta }) + meta!: ApiResponseMeta; } export class BrokerOperationSyncEnvelopeDto { @ApiProperty({ type: BrokerOperationSyncResponseDto }) data!: BrokerOperationSyncResponseDto; - @ApiProperty({ type: BrokerResponseMetaDto }) - meta!: BrokerResponseMetaDto; + @ApiProperty({ type: ApiResponseMeta }) + meta!: ApiResponseMeta; } export class BrokerAnalyticsEnvelopeDto { @ApiProperty({ type: BrokerAnalyticsDto }) data!: BrokerAnalyticsDto; - @ApiProperty({ type: BrokerResponseMetaDto }) - meta!: BrokerResponseMetaDto; + @ApiProperty({ type: ApiResponseMeta }) + meta!: ApiResponseMeta; } export class BrokerEventsEnvelopeDto { @ApiProperty({ type: BrokerEventsDataDto }) data!: BrokerEventsDataDto; - @ApiProperty({ type: BrokerResponseMetaDto }) - meta!: BrokerResponseMetaDto; + @ApiProperty({ type: ApiResponseMeta }) + meta!: ApiResponseMeta; } diff --git a/docs/features/backend-architecture-improvements/plan.md b/docs/features/backend-architecture-improvements/plan.md new file mode 100644 index 0000000..2af94ce --- /dev/null +++ b/docs/features/backend-architecture-improvements/plan.md @@ -0,0 +1,52 @@ +# Backend Architecture Improvements — Plan + +## Подход + +Разбиваем на итерации от самых безопасных (только DTO/косметика) к самым рискованным (сплит сервисов). Каждая итерация — отдельная задача с отдельным commit'ом. + +## Итерации + +### Итерация 1: Shared envelope DTO + fixes + +1. Вынести `ApiResponseMeta` в `common/dto/api-response.dto.ts` как единый класс, убрать дубликаты `*ResponseMetaDto` в модулях. +2. Убрать `AuthResponseMetaDto` → заменить на `ApiResponseMeta`. +3. Поправить `shares/shares.service.ts:getShare()` — обернуть результат в `ApiEnvelopePayload`. +4. Убрать лишний импорт `CacheModule` из `securities/securities.module.ts`. +5. Убрать `PortfolioResponseMetaDto` и `BrokerResponseMetaDto` — заменить на `ApiResponseMeta`. + +### Итерация 2: Screener caching + server-side пагинация + +1. Добавить кеширование полного screener-датасета при пустом фильтре. +2. Ключ кеша: `'screener:full'` с TTL из `app.cache.marketDataTtl`. + +### Итерация 3: Domain exception hierarchy + +1. Создать `common/exceptions/` с базой `DomainException` и конкретными классами. +2. Обновить `HttpExceptionFilter` для map'инга доменных → HTTP исключений. +3. Заменить `NotFoundException` на `EntityNotFoundException` в сервисах. + +### Итерация 4: Health check improvement + +1. Добавить проверки: Prisma ping, MOEX `/health`, T-Bank gRPC connectivity. +2. Обновить `HealthResponseDto` с полем `checks`. + +### Итерация 5: RequestLoggingMiddleware через DI + +1. Перенести middleware в корректный DI-контекст через `configure()` в `AppModule`. + +### Итерация 6 (отдельный эпик): MoexClientService split + +1. Выделить `MoexSecuritiesClient`, `MoexMarketDataClient`, `MoexCandlesClient`. +2. Общий rate limiter + circuit breaker в shared utils. + +## Data Flow + +``` +Controller → Service → [CacheService.getOrFetch] → [MoexClient* или TBank*] → Внешнее API + ↓ + ApiEnvelopePayload + ↓ + TransformInterceptor → ApiResponse +``` + +После итерации 1 все модули следуют этому потоку единообразно. diff --git a/docs/features/backend-architecture-improvements/spec.md b/docs/features/backend-architecture-improvements/spec.md new file mode 100644 index 0000000..6d51f68 --- /dev/null +++ b/docs/features/backend-architecture-improvements/spec.md @@ -0,0 +1,33 @@ +# Backend Architecture Improvements + +## Цель + +Устранить выявленные в ходе аудита архитектурные проблемы бэкенда: консистентность ответов API, качество кода модулей, обработку ошибок, тестируемость. + +## Требования + +1. Унифицировать формат ответов API — единый envelope DTO, используемый всеми модулями. +2. Устранить inconsistency между shares и bonds модулями. +3. Ввести иерархию доменных исключений с корректной обработкой. +4. Улучшить health check (проверка зависимостей). +5. Убрать дублирование и лишние зависимости. +6. Сохранить обратную совместимость API (поля ответов не меняются, только структура). + +## Ограничения + +- Не менять внешний API-контракт (формат `{ data, meta }` остаётся). +- Не рефакторить то, что не указано в требованиях. +- Каждое изменение идёт через TDD-цикл. + +## Критерии приемки (Acceptance Criteria) + +- [ ] Все модули используют единый shared envelope DTO из `common/dto/` +- [ ] `shares/shares.service.ts:getShare()` возвращает `ApiEnvelopePayload` как и `bonds/` +- [ ] Screener кеширует полный набор данных +- [ ] Создана иерархия доменных исключений +- [ ] `HttpExceptionFilter` корректно обрабатывает доменные исключения +- [ ] Health check проверяет Prisma, MOEX, T-Bank +- [ ] `RequestLoggingMiddleware` подключён через DI +- [ ] `securities/` не импортирует `CacheModule` напрямую +- [ ] Все тесты проходят +- [ ] `npm run build` успешен diff --git a/docs/features/backend-architecture-improvements/tasks.md b/docs/features/backend-architecture-improvements/tasks.md new file mode 100644 index 0000000..071e036 --- /dev/null +++ b/docs/features/backend-architecture-improvements/tasks.md @@ -0,0 +1,53 @@ +# Backend Architecture Improvements — Tasks + +## Итерация 1: Shared envelope DTO + consistency fixes ✅ + +- [x] 1.1 Создать документ аудита в `docs/research/` +- [x] 1.2 Вынести `ApiResponseMeta` в `common/dto/api-response.dto.ts` как единый shared класс +- [x] 1.3 Заменить `AuthResponseMetaDto` на `ApiResponseMeta` в `auth/` +- [x] 1.4 Заменить `PortfolioResponseMetaDto` на `ApiResponseMeta` в `portfolio/` +- [x] 1.5 Заменить `BrokerResponseMetaDto` на `ApiResponseMeta` в `tbank/` +- [x] 1.6 Поправить `shares/shares.service.ts:getShare()` — обернуть в `ApiEnvelopePayload` +- [x] 1.7 Убрать лишний импорт `CacheModule` из `securities/securities.module.ts` +- [x] 1.8 Удалить дублирующиеся envelope DTO (`ScreenerResponseMetaDto`, `AuthResponseMetaDto`, `PortfolioResponseMetaDto`, `BrokerResponseMetaDto`) +- [x] 1.9 `npm run build` успешен, 116 тестов проходят + +## Итерация 2: Screener caching + +- [ ] 2.1 Добавить `getFullDataset()` метод в `ScreenerService` с кешированием +- [ ] 2.2 Использовать кеш в `screen()` при пустых фильтрах +- [ ] 2.3 Тесты на кеширование screener'а + +## Итерация 3: Domain exceptions + +- [ ] 3.1 Создать `common/exceptions/domain.exception.ts` +- [ ] 3.2 Создать `common/exceptions/entity-not-found.exception.ts` +- [ ] 3.3 Создать `common/exceptions/moex-api.exception.ts` +- [ ] 3.4 Создать `common/exceptions/tbank-api.exception.ts` +- [ ] 3.5 Создать `common/exceptions/portfolio-access.exception.ts` +- [ ] 3.6 Обновить `HttpExceptionFilter` для доменных исключений +- [ ] 3.7 Заменить generic исключения в сервисах на доменные +- [ ] 3.8 Тесты на фильтр + исключения + +## Итерация 4: Health check + +- [ ] 4.1 Добавить `PrismaHealthIndicator` в `health/` +- [ ] 4.2 Добавить `MoexHealthIndicator` +- [ ] 4.3 Добавить `TBankHealthIndicator` +- [ ] 4.4 Обновить `HealthResponseDto` с `checks` +- [ ] 4.5 Тесты health module + +## Итерация 5: RequestLoggingMiddleware DI + +- [ ] 5.1 Переписать подключение через `configure()` в `AppModule` +- [ ] 5.2 Убрать `app.use()` из `main.ts` + +## Итерация 6: MoexClientService split (отдельный эпик) + +- [ ] 6.1 ADR на разделение MoexClientService +- [ ] 6.2 spec/plan/tasks отдельного эпика +- [ ] 6.3 Выделение rate limiter + circuit breaker в shared utils +- [ ] 6.4 Создание MoexSecuritiesClient +- [ ] 6.5 Создание MoexMarketDataClient +- [ ] 6.6 Создание MoexCandlesClient +- [ ] 6.7 Обновление всех потребителей diff --git a/docs/research/2026-06-25-backend-audit.md b/docs/research/2026-06-25-backend-audit.md new file mode 100644 index 0000000..b83931f --- /dev/null +++ b/docs/research/2026-06-25-backend-audit.md @@ -0,0 +1,49 @@ +# Backend Architecture Audit 2026-06-25 + +## Что хорошо + +- **Чистая модульная структура** — каждый домен в своём каталоге, NestJS-модули, DI +- **Global-модули** (`PrismaModule`, `CacheModule`, `MoexClientModule`) — не дублируются импорты +- **`CacheService.getOrFetch()`** — универсальный примитив кеширования, config-driven TTL +- **MOEX rate limiter + circuit breaker** — p-queue + ручной CB, защищают от внешнего API +- **TBank mappers layer** — proto wire type → domain type → DTO, правильная изоляция +- **Batch-оптимизация в PortfolioService** — `fetchShareBatch/fetchBondBatch/fetchDividendsBatch` собирает всё одним MOEX-запросом +- **Спецификации и тесты** — 25 spec-файлов, есть TDD-подход + +## Ключевые архитектурные проблемы + +| # | Проблема | Где | Описание | +|---|----------|-----|----------| +| 1 | **Дублирование Envelope DTO** | Все модули | Каждый модуль переопределяет свой `*ResponseMetaDto / *EnvelopeDto`. 6+ копий одной структуры | +| 2 | **Inconsistent caching** | `shares/shares.service.ts:getShare()` (raw) vs `bonds/bonds.service.ts:getBond()` (envelope) | Разное поведение одинаковых по смыслу методов | +| 3 | **Screener — in-memory filtering** | `securities/screener.service.ts` | При пустом массиве `getShareMarketDataBatch([])` тянет **все** бумаги с MOEX и фильтрует в памяти. Не масштабируется | +| 4 | **MoexClientService — God Service** | 11 публичных методов | Один сервис делает всё: search, shares, bonds, candles, history, dividends. Нарушает SRP | +| 5 | **Отсутствие доменных исключений** | Весь код | Нет иерархии исключений (`SecurityNotFoundException`, `PortfolioAccessDeniedException`, `MoexApiException`) — только generic `NotFoundException` / `ForbiddenException` | +| 6 | **Health check — заглушка** | `health/` | Не проверяет БД, MOEX, T-Bank. Только `{ status: 'ok', timestamp, uptime }` | +| 7 | **Prisma JSON как String** | `targets`, `tags`, `payment`, `price` | JSON хранится как `String` без валидации на уровне БД. Нет типизированных JSON-полей | +| 8 | **tbank/ — перегруженный модуль** | 22 файла, 8 сервисов | Один модуль содержит gRPC клиент, мапперы, CRUD, аналитику, синхронизацию. Можно разбить | +| 9 | **Auth глобальные гарды** | `JwtAuthGuard` + `RolesGuard` как `APP_GUARD` | Неявная защита всех эндпоинтов. Приходится использовать `@Public()` для открытых | +| 10 | **RequestLoggingMiddleware** | Подключён через `.use()`, а не через `configure()` | Работает, но не идёт через DI и не является частью модуля | + +## Рекомендации + +### 🔴 Critical + +1. **Shared envelope DTO** — вынести `ApiResponseMeta` и один generic `EnvelopeDto` в `common/dto/`, убрать дублирование. Унифицировать формат ответа screener'а под общий envelope. +2. **Разделить MoexClientService** — выделить `MoexSecuritiesClient`, `MoexMarketDataClient`, `MoexCandlesClient` — каждый со своим набором методов. +3. **Убрать inconsistency shares vs bonds** — `getShare()` должен возвращать `ApiEnvelopePayload` как и `getBond()`. + +### 🟡 Medium + +4. **Domain exception hierarchy** — создать `BaseDomainException` → `MoexApiException`, `SecurityNotFoundException`, `PortfolioAccessDeniedException`, `TBankApiException`. Добавить соответствующие фильтры в `HttpExceptionFilter`. +5. **Screener — server-side пагинация** — кешировать полный результат screener'а отдельным TTL. +6. **Health check прокачка** — добавить проверки Prisma (`db.ping()`), MOEX (`/health`), T-Bank gRPC connectivity. +7. **Prisma JSON → typed JSON** — использовать строки с `JSON.parse` в геттерах или перейти на отдельные таблицы. +8. **Отвязать `securities/` от прямого импорта `CacheModule`** — раз он `@Global()`, убрать лишний импорт. + +### 🟢 Low / Nice to have + +9. **RequestLoggingMiddleware** — перевести на `configure()` в `AppModule` для единообразия. +10. **Refactor `tbank/`** — выделить `broker-analytics` и `broker-sync` в отдельные модули, если будут расти. +11. **Swagger schema object для обёртки** — глобально настроить OpenAPI для автоматической обёртки `{ data, meta }`. +12. **Circuit breaker — вынести в декоратор** — обобщить в `@CircuitBreaker()` декоратор.