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';
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { ApiResponseMeta } from '../../../common/dto/api-response.dto';
|
||||||
class AuthResponseMetaDto {
|
|
||||||
@ApiProperty({ type: String, nullable: true })
|
|
||||||
cachedAt!: string | null;
|
|
||||||
|
|
||||||
@ApiProperty()
|
|
||||||
fromCache!: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
class AuthUserDto {
|
class AuthUserDto {
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
@ -39,22 +32,22 @@ export class AuthTokenResponseDto {
|
|||||||
@ApiProperty({ type: AuthTokenDataDto })
|
@ApiProperty({ type: AuthTokenDataDto })
|
||||||
data!: AuthTokenDataDto;
|
data!: AuthTokenDataDto;
|
||||||
|
|
||||||
@ApiProperty({ type: AuthResponseMetaDto })
|
@ApiProperty({ type: ApiResponseMeta })
|
||||||
meta!: AuthResponseMetaDto;
|
meta!: ApiResponseMeta;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class AuthProfileResponseDto {
|
export class AuthProfileResponseDto {
|
||||||
@ApiProperty({ type: AuthUserDto })
|
@ApiProperty({ type: AuthUserDto })
|
||||||
data!: AuthUserDto;
|
data!: AuthUserDto;
|
||||||
|
|
||||||
@ApiProperty({ type: AuthResponseMetaDto })
|
@ApiProperty({ type: ApiResponseMeta })
|
||||||
meta!: AuthResponseMetaDto;
|
meta!: ApiResponseMeta;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class AuthLogoutResponseDto {
|
export class AuthLogoutResponseDto {
|
||||||
@ApiProperty({ type: LogoutDataDto })
|
@ApiProperty({ type: LogoutDataDto })
|
||||||
data!: LogoutDataDto;
|
data!: LogoutDataDto;
|
||||||
|
|
||||||
@ApiProperty({ type: AuthResponseMetaDto })
|
@ApiProperty({ type: ApiResponseMeta })
|
||||||
meta!: AuthResponseMetaDto;
|
meta!: ApiResponseMeta;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,53 +1,46 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { ApiResponseMeta } from '../../../common/dto/api-response.dto';
|
||||||
import { AnalyticsResponseDto } from './analytics-response.dto';
|
import { AnalyticsResponseDto } from './analytics-response.dto';
|
||||||
import { PortfolioListResponseDto } from './portfolio-list-response.dto';
|
import { PortfolioListResponseDto } from './portfolio-list-response.dto';
|
||||||
import { PortfolioDetailResponseDto, PortfolioResponseDto } from './portfolio-response.dto';
|
import { PortfolioDetailResponseDto, PortfolioResponseDto } from './portfolio-response.dto';
|
||||||
import { PositionResponseDto } from './position-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 {
|
export class PortfolioListEnvelopeDto {
|
||||||
@ApiProperty({ type: [PortfolioListResponseDto] })
|
@ApiProperty({ type: [PortfolioListResponseDto] })
|
||||||
data!: PortfolioListResponseDto[];
|
data!: PortfolioListResponseDto[];
|
||||||
|
|
||||||
@ApiProperty({ type: PortfolioResponseMetaDto })
|
@ApiProperty({ type: ApiResponseMeta })
|
||||||
meta!: PortfolioResponseMetaDto;
|
meta!: ApiResponseMeta;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class PortfolioEnvelopeDto {
|
export class PortfolioEnvelopeDto {
|
||||||
@ApiProperty({ type: PortfolioResponseDto })
|
@ApiProperty({ type: PortfolioResponseDto })
|
||||||
data!: PortfolioResponseDto;
|
data!: PortfolioResponseDto;
|
||||||
|
|
||||||
@ApiProperty({ type: PortfolioResponseMetaDto })
|
@ApiProperty({ type: ApiResponseMeta })
|
||||||
meta!: PortfolioResponseMetaDto;
|
meta!: ApiResponseMeta;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class PortfolioDetailEnvelopeDto {
|
export class PortfolioDetailEnvelopeDto {
|
||||||
@ApiProperty({ type: PortfolioDetailResponseDto })
|
@ApiProperty({ type: PortfolioDetailResponseDto })
|
||||||
data!: PortfolioDetailResponseDto;
|
data!: PortfolioDetailResponseDto;
|
||||||
|
|
||||||
@ApiProperty({ type: PortfolioResponseMetaDto })
|
@ApiProperty({ type: ApiResponseMeta })
|
||||||
meta!: PortfolioResponseMetaDto;
|
meta!: ApiResponseMeta;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class PositionEnvelopeDto {
|
export class PositionEnvelopeDto {
|
||||||
@ApiProperty({ type: PositionResponseDto })
|
@ApiProperty({ type: PositionResponseDto })
|
||||||
data!: PositionResponseDto;
|
data!: PositionResponseDto;
|
||||||
|
|
||||||
@ApiProperty({ type: PortfolioResponseMetaDto })
|
@ApiProperty({ type: ApiResponseMeta })
|
||||||
meta!: PortfolioResponseMetaDto;
|
meta!: ApiResponseMeta;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class AnalyticsEnvelopeDto {
|
export class AnalyticsEnvelopeDto {
|
||||||
@ApiProperty({ type: AnalyticsResponseDto })
|
@ApiProperty({ type: AnalyticsResponseDto })
|
||||||
data!: AnalyticsResponseDto;
|
data!: AnalyticsResponseDto;
|
||||||
|
|
||||||
@ApiProperty({ type: PortfolioResponseMetaDto })
|
@ApiProperty({ type: ApiResponseMeta })
|
||||||
meta!: PortfolioResponseMetaDto;
|
meta!: ApiResponseMeta;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -13,13 +13,13 @@ import { CreatePortfolioDto } from './dto/create-portfolio.dto';
|
|||||||
import { UpdatePortfolioDto } from './dto/update-portfolio.dto';
|
import { UpdatePortfolioDto } from './dto/update-portfolio.dto';
|
||||||
import { AddPositionDto } from './dto/add-position.dto';
|
import { AddPositionDto } from './dto/add-position.dto';
|
||||||
import { UpdatePositionDto } from './dto/update-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 { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||||
import {
|
import {
|
||||||
AnalyticsEnvelopeDto,
|
AnalyticsEnvelopeDto,
|
||||||
PortfolioDetailEnvelopeDto,
|
PortfolioDetailEnvelopeDto,
|
||||||
PortfolioEnvelopeDto,
|
PortfolioEnvelopeDto,
|
||||||
PortfolioListEnvelopeDto,
|
PortfolioListEnvelopeDto,
|
||||||
PortfolioResponseMetaDto,
|
|
||||||
PositionEnvelopeDto,
|
PositionEnvelopeDto,
|
||||||
} from './dto/portfolio-envelope.dto';
|
} from './dto/portfolio-envelope.dto';
|
||||||
|
|
||||||
@ -27,14 +27,14 @@ const nullDataEnvelopeSchema = {
|
|||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
data: { type: 'null' },
|
data: { type: 'null' },
|
||||||
meta: { $ref: getSchemaPath(PortfolioResponseMetaDto) },
|
meta: { $ref: getSchemaPath(ApiResponseMeta) },
|
||||||
},
|
},
|
||||||
required: ['data', 'meta'],
|
required: ['data', 'meta'],
|
||||||
};
|
};
|
||||||
|
|
||||||
@ApiTags('Portfolios')
|
@ApiTags('Portfolios')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@ApiExtraModels(PortfolioResponseMetaDto)
|
@ApiExtraModels(ApiResponseMeta)
|
||||||
@Controller('portfolios')
|
@Controller('portfolios')
|
||||||
export class PortfolioController {
|
export class PortfolioController {
|
||||||
constructor(private readonly portfolioService: PortfolioService) {}
|
constructor(private readonly portfolioService: PortfolioService) {}
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { ApiResponseMeta } from '../../../common/dto/api-response.dto';
|
||||||
|
|
||||||
export class ScreenerItemDto {
|
export class ScreenerItemDto {
|
||||||
@ApiProperty({ example: 'SBER' })
|
@ApiProperty({ example: 'SBER' })
|
||||||
@ -70,18 +71,10 @@ export class ScreenerResultDto {
|
|||||||
totalPages!: number;
|
totalPages!: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
class ScreenerResponseMetaDto {
|
|
||||||
@ApiProperty({ type: String, nullable: true })
|
|
||||||
cachedAt!: string | null;
|
|
||||||
|
|
||||||
@ApiProperty()
|
|
||||||
fromCache!: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class ScreenerResponseDto {
|
export class ScreenerResponseDto {
|
||||||
@ApiProperty({ type: ScreenerResultDto })
|
@ApiProperty({ type: ScreenerResultDto })
|
||||||
data!: ScreenerResultDto;
|
data!: ScreenerResultDto;
|
||||||
|
|
||||||
@ApiProperty({ type: ScreenerResponseMetaDto })
|
@ApiProperty({ type: ApiResponseMeta })
|
||||||
meta!: ScreenerResponseMetaDto;
|
meta!: ApiResponseMeta;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,11 +1,9 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { CacheModule } from '../cache/cache.module';
|
|
||||||
import { SecuritiesController } from './securities.controller';
|
import { SecuritiesController } from './securities.controller';
|
||||||
import { SecuritiesService } from './securities.service';
|
import { SecuritiesService } from './securities.service';
|
||||||
import { ScreenerService } from './screener.service';
|
import { ScreenerService } from './screener.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [CacheModule],
|
|
||||||
controllers: [SecuritiesController],
|
controllers: [SecuritiesController],
|
||||||
providers: [SecuritiesService, ScreenerService],
|
providers: [SecuritiesService, ScreenerService],
|
||||||
exports: [SecuritiesService],
|
exports: [SecuritiesService],
|
||||||
|
|||||||
@ -83,7 +83,7 @@ describe('SharesService', () => {
|
|||||||
'marketDataTtl',
|
'marketDataTtl',
|
||||||
);
|
);
|
||||||
expect(moexClient.getShareMarketData).toHaveBeenCalledWith('SBER');
|
expect(moexClient.getShareMarketData).toHaveBeenCalledWith('SBER');
|
||||||
expect(result).toMatchObject({
|
expect(result.data).toMatchObject({
|
||||||
secid: 'SBER',
|
secid: 'SBER',
|
||||||
isin: 'RU0009029540',
|
isin: 'RU0009029540',
|
||||||
name: 'Сбербанк России ПАО ао',
|
name: 'Сбербанк России ПАО ао',
|
||||||
@ -106,7 +106,7 @@ describe('SharesService', () => {
|
|||||||
issueCapitalization: 6900000000000,
|
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 () => {
|
it('throws NotFoundException for non-share security', async () => {
|
||||||
|
|||||||
@ -23,7 +23,11 @@ export class SharesService {
|
|||||||
throw new NotFoundException(`Share ${secid} not found`);
|
throw new NotFoundException(`Share ${secid} not found`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { data: marketData } = await this.cache.getOrFetch(
|
const {
|
||||||
|
data: marketData,
|
||||||
|
fromCache,
|
||||||
|
cachedAt,
|
||||||
|
} = await this.cache.getOrFetch(
|
||||||
'marketdata',
|
'marketdata',
|
||||||
['shares', secid],
|
['shares', secid],
|
||||||
() => this.moexClient.getShareMarketData(secid),
|
() => this.moexClient.getShareMarketData(secid),
|
||||||
@ -34,32 +38,36 @@ export class SharesService {
|
|||||||
const change = marketData?.lastChange ?? 0;
|
const change = marketData?.lastChange ?? 0;
|
||||||
const changePercent = marketData?.lastChangePrcnt ?? 0;
|
const changePercent = marketData?.lastChangePrcnt ?? 0;
|
||||||
|
|
||||||
return {
|
return new ApiEnvelopePayload(
|
||||||
secid: desc.secid,
|
{
|
||||||
isin: desc.isin,
|
secid: desc.secid,
|
||||||
name: desc.name,
|
isin: desc.isin,
|
||||||
shortName: desc.shortName,
|
name: desc.name,
|
||||||
latName: desc.latName,
|
shortName: desc.shortName,
|
||||||
listLevel: desc.listLevel,
|
latName: desc.latName,
|
||||||
issueSize: desc.issueSize,
|
listLevel: desc.listLevel,
|
||||||
faceValue: desc.faceValue,
|
issueSize: desc.issueSize,
|
||||||
faceUnit: desc.faceUnit === 'SUR' ? 'RUB' : desc.faceUnit,
|
faceValue: desc.faceValue,
|
||||||
type: desc.type,
|
faceUnit: desc.faceUnit === 'SUR' ? 'RUB' : desc.faceUnit,
|
||||||
marketData: {
|
type: desc.type,
|
||||||
price: price ?? 0,
|
marketData: {
|
||||||
change,
|
price: price ?? 0,
|
||||||
changePercent,
|
change,
|
||||||
open: marketData?.open ?? 0,
|
changePercent,
|
||||||
high: marketData?.high ?? null,
|
open: marketData?.open ?? 0,
|
||||||
low: marketData?.low ?? null,
|
high: marketData?.high ?? null,
|
||||||
volume: marketData?.volume ?? 0,
|
low: marketData?.low ?? null,
|
||||||
value: marketData?.value ?? 0,
|
volume: marketData?.volume ?? 0,
|
||||||
issueCapitalization: marketData?.issueCapitalization ?? null,
|
value: marketData?.value ?? 0,
|
||||||
updatedAt: marketData?.updateTime
|
issueCapitalization: marketData?.issueCapitalization ?? null,
|
||||||
? new Date().toISOString().split('T')[0] + 'T' + marketData.updateTime
|
updatedAt: marketData?.updateTime
|
||||||
: new Date().toISOString(),
|
? new Date().toISOString().split('T')[0] + 'T' + marketData.updateTime
|
||||||
|
: new Date().toISOString(),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
fromCache,
|
||||||
|
cachedAt,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getMarketData(secid: string) {
|
async getMarketData(secid: string) {
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { ApiResponseMeta } from '../../../common/dto/api-response.dto';
|
||||||
import { BrokerAccountResponseDto } from './broker-account-response.dto';
|
import { BrokerAccountResponseDto } from './broker-account-response.dto';
|
||||||
import { BrokerEventsDataDto } from './broker-events-response.dto';
|
import { BrokerEventsDataDto } from './broker-events-response.dto';
|
||||||
import { BrokerOperationSyncResponseDto } from './broker-operation-sync-query.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 { BrokerPortfolioResponseDto } from './broker-portfolio-response.dto';
|
||||||
import { BrokerAnalyticsDto } from './broker-analytics-response.dto';
|
import { BrokerAnalyticsDto } from './broker-analytics-response.dto';
|
||||||
|
|
||||||
export class BrokerResponseMetaDto {
|
|
||||||
@ApiProperty({ nullable: true })
|
|
||||||
cachedAt!: string | null;
|
|
||||||
|
|
||||||
@ApiProperty()
|
|
||||||
fromCache!: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class BrokerAccountsEnvelopeDto {
|
export class BrokerAccountsEnvelopeDto {
|
||||||
@ApiProperty({ type: [BrokerAccountResponseDto] })
|
@ApiProperty({ type: [BrokerAccountResponseDto] })
|
||||||
data!: BrokerAccountResponseDto[];
|
data!: BrokerAccountResponseDto[];
|
||||||
|
|
||||||
@ApiProperty({ type: BrokerResponseMetaDto })
|
@ApiProperty({ type: ApiResponseMeta })
|
||||||
meta!: BrokerResponseMetaDto;
|
meta!: ApiResponseMeta;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class BrokerPortfolioEnvelopeDto {
|
export class BrokerPortfolioEnvelopeDto {
|
||||||
@ApiProperty({ type: BrokerPortfolioResponseDto })
|
@ApiProperty({ type: BrokerPortfolioResponseDto })
|
||||||
data!: BrokerPortfolioResponseDto;
|
data!: BrokerPortfolioResponseDto;
|
||||||
|
|
||||||
@ApiProperty({ type: BrokerResponseMetaDto })
|
@ApiProperty({ type: ApiResponseMeta })
|
||||||
meta!: BrokerResponseMetaDto;
|
meta!: ApiResponseMeta;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class BrokerOperationsEnvelopeDto {
|
export class BrokerOperationsEnvelopeDto {
|
||||||
@ApiProperty({ type: BrokerOperationsPageResponseDto })
|
@ApiProperty({ type: BrokerOperationsPageResponseDto })
|
||||||
data!: BrokerOperationsPageResponseDto;
|
data!: BrokerOperationsPageResponseDto;
|
||||||
|
|
||||||
@ApiProperty({ type: BrokerResponseMetaDto })
|
@ApiProperty({ type: ApiResponseMeta })
|
||||||
meta!: BrokerResponseMetaDto;
|
meta!: ApiResponseMeta;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class BrokerPositionsEnvelopeDto {
|
export class BrokerPositionsEnvelopeDto {
|
||||||
@ApiProperty({ type: BrokerPositionsPageResponseDto })
|
@ApiProperty({ type: BrokerPositionsPageResponseDto })
|
||||||
data!: BrokerPositionsPageResponseDto;
|
data!: BrokerPositionsPageResponseDto;
|
||||||
|
|
||||||
@ApiProperty({ type: BrokerResponseMetaDto })
|
@ApiProperty({ type: ApiResponseMeta })
|
||||||
meta!: BrokerResponseMetaDto;
|
meta!: ApiResponseMeta;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class BrokerOperationSyncEnvelopeDto {
|
export class BrokerOperationSyncEnvelopeDto {
|
||||||
@ApiProperty({ type: BrokerOperationSyncResponseDto })
|
@ApiProperty({ type: BrokerOperationSyncResponseDto })
|
||||||
data!: BrokerOperationSyncResponseDto;
|
data!: BrokerOperationSyncResponseDto;
|
||||||
|
|
||||||
@ApiProperty({ type: BrokerResponseMetaDto })
|
@ApiProperty({ type: ApiResponseMeta })
|
||||||
meta!: BrokerResponseMetaDto;
|
meta!: ApiResponseMeta;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class BrokerAnalyticsEnvelopeDto {
|
export class BrokerAnalyticsEnvelopeDto {
|
||||||
@ApiProperty({ type: BrokerAnalyticsDto })
|
@ApiProperty({ type: BrokerAnalyticsDto })
|
||||||
data!: BrokerAnalyticsDto;
|
data!: BrokerAnalyticsDto;
|
||||||
|
|
||||||
@ApiProperty({ type: BrokerResponseMetaDto })
|
@ApiProperty({ type: ApiResponseMeta })
|
||||||
meta!: BrokerResponseMetaDto;
|
meta!: ApiResponseMeta;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class BrokerEventsEnvelopeDto {
|
export class BrokerEventsEnvelopeDto {
|
||||||
@ApiProperty({ type: BrokerEventsDataDto })
|
@ApiProperty({ type: BrokerEventsDataDto })
|
||||||
data!: BrokerEventsDataDto;
|
data!: BrokerEventsDataDto;
|
||||||
|
|
||||||
@ApiProperty({ type: BrokerResponseMetaDto })
|
@ApiProperty({ type: ApiResponseMeta })
|
||||||
meta!: BrokerResponseMetaDto;
|
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