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
This commit is contained in:
parent
8d6410b37b
commit
ccb1082535
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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) {}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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],
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
52
docs/features/backend-architecture-improvements/plan.md
Normal file
52
docs/features/backend-architecture-improvements/plan.md
Normal file
@ -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<T>
|
||||
↓
|
||||
TransformInterceptor → ApiResponse<T>
|
||||
```
|
||||
|
||||
После итерации 1 все модули следуют этому потоку единообразно.
|
||||
33
docs/features/backend-architecture-improvements/spec.md
Normal file
33
docs/features/backend-architecture-improvements/spec.md
Normal file
@ -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` успешен
|
||||
53
docs/features/backend-architecture-improvements/tasks.md
Normal file
53
docs/features/backend-architecture-improvements/tasks.md
Normal file
@ -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 Обновление всех потребителей
|
||||
49
docs/research/2026-06-25-backend-audit.md
Normal file
49
docs/research/2026-06-25-backend-audit.md
Normal file
@ -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<T>` в `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()` декоратор.
|
||||
Loading…
x
Reference in New Issue
Block a user