fix: restrict and expose tbank broker sync
This commit is contained in:
parent
b10a2cfb0d
commit
ea916dfec9
@ -1,5 +1,6 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
import { BrokerAccountResponseDto } from './broker-account-response.dto';
|
import { BrokerAccountResponseDto } from './broker-account-response.dto';
|
||||||
|
import { BrokerOperationSyncResponseDto } from './broker-operation-sync-query.dto';
|
||||||
import { BrokerOperationsPageResponseDto } from './broker-operation-response.dto';
|
import { BrokerOperationsPageResponseDto } from './broker-operation-response.dto';
|
||||||
import { BrokerPortfolioResponseDto } from './broker-portfolio-response.dto';
|
import { BrokerPortfolioResponseDto } from './broker-portfolio-response.dto';
|
||||||
|
|
||||||
@ -34,3 +35,11 @@ export class BrokerOperationsEnvelopeDto {
|
|||||||
@ApiProperty({ type: BrokerResponseMetaDto })
|
@ApiProperty({ type: BrokerResponseMetaDto })
|
||||||
meta!: BrokerResponseMetaDto;
|
meta!: BrokerResponseMetaDto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class BrokerOperationSyncEnvelopeDto {
|
||||||
|
@ApiProperty({ type: BrokerOperationSyncResponseDto })
|
||||||
|
data!: BrokerOperationSyncResponseDto;
|
||||||
|
|
||||||
|
@ApiProperty({ type: BrokerResponseMetaDto })
|
||||||
|
meta!: BrokerResponseMetaDto;
|
||||||
|
}
|
||||||
|
|||||||
@ -0,0 +1,17 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { IsISO8601 } from 'class-validator';
|
||||||
|
|
||||||
|
export class BrokerOperationSyncQueryDto {
|
||||||
|
@ApiProperty({ example: '2026-06-01T00:00:00.000Z' })
|
||||||
|
@IsISO8601()
|
||||||
|
from!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: '2026-06-17T00:00:00.000Z' })
|
||||||
|
@IsISO8601()
|
||||||
|
to!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BrokerOperationSyncResponseDto {
|
||||||
|
@ApiProperty({ example: 42 })
|
||||||
|
upserted!: number;
|
||||||
|
}
|
||||||
61
apps/backend/src/modules/tbank/tbank.controller.spec.ts
Normal file
61
apps/backend/src/modules/tbank/tbank.controller.spec.ts
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
import { ROLES_KEY } from '../auth/decorators/roles.decorator';
|
||||||
|
import { ApiResponse } from '../../common/dto/api-response.dto';
|
||||||
|
import { TBankController } from './tbank.controller';
|
||||||
|
import { BrokerAccountsService } from './services/broker-accounts.service';
|
||||||
|
import { BrokerOperationSyncService } from './services/broker-operation-sync.service';
|
||||||
|
import { BrokerOperationsService } from './services/broker-operations.service';
|
||||||
|
import { BrokerPortfolioService } from './services/broker-portfolio.service';
|
||||||
|
|
||||||
|
describe('TBankController', () => {
|
||||||
|
const accounts = { findAll: vi.fn() } as unknown as BrokerAccountsService;
|
||||||
|
const portfolio = { getPortfolio: vi.fn() } as unknown as BrokerPortfolioService;
|
||||||
|
const operations = { getOperations: vi.fn() } as unknown as BrokerOperationsService;
|
||||||
|
const sync = { syncAccount: vi.fn() } as unknown as BrokerOperationSyncService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('limits broker endpoints to admins because they use a server-side T-Bank token', () => {
|
||||||
|
expect(Reflect.getMetadata(ROLES_KEY, TBankController)).toEqual(['admin']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns accounts in a single API envelope', async () => {
|
||||||
|
vi.mocked(accounts.findAll).mockResolvedValueOnce({
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
id: 'acc-1',
|
||||||
|
type: 'brokerage',
|
||||||
|
name: 'Broker',
|
||||||
|
status: 'ACCOUNT_STATUS_OPEN',
|
||||||
|
openedAt: null,
|
||||||
|
accessLevel: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
meta: { fromCache: true, cachedAt: '2026-06-17T00:00:00.000Z' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const controller = new TBankController(accounts, portfolio, operations, sync);
|
||||||
|
const response = await controller.getAccounts();
|
||||||
|
|
||||||
|
expect(response).toBeInstanceOf(ApiResponse);
|
||||||
|
expect(response.data).toHaveLength(1);
|
||||||
|
expect(response.meta).toEqual({ fromCache: true, cachedAt: '2026-06-17T00:00:00.000Z' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exposes an admin sync trigger for durable operation history', async () => {
|
||||||
|
vi.mocked(sync.syncAccount).mockResolvedValueOnce({ upserted: 2 });
|
||||||
|
|
||||||
|
const controller = new TBankController(accounts, portfolio, operations, sync);
|
||||||
|
const response = await controller.syncOperations('acc-1', {
|
||||||
|
from: '2026-06-01T00:00:00.000Z',
|
||||||
|
to: '2026-06-17T00:00:00.000Z',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(sync.syncAccount).toHaveBeenCalledWith('acc-1', {
|
||||||
|
from: '2026-06-01T00:00:00.000Z',
|
||||||
|
to: '2026-06-17T00:00:00.000Z',
|
||||||
|
});
|
||||||
|
expect(response.data).toEqual({ upserted: 2 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -1,37 +1,46 @@
|
|||||||
import { Controller, Get, Param, Query } from '@nestjs/common';
|
import { Controller, Get, Param, Post, Query } from '@nestjs/common';
|
||||||
import { ApiBearerAuth, ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
|
import { ApiResponse } from '../../common/dto/api-response.dto';
|
||||||
|
import { Roles } from '../auth/decorators/roles.decorator';
|
||||||
import {
|
import {
|
||||||
BrokerAccountsEnvelopeDto,
|
BrokerAccountsEnvelopeDto,
|
||||||
|
BrokerOperationSyncEnvelopeDto,
|
||||||
BrokerOperationsEnvelopeDto,
|
BrokerOperationsEnvelopeDto,
|
||||||
BrokerPortfolioEnvelopeDto,
|
BrokerPortfolioEnvelopeDto,
|
||||||
} from './dto/broker-envelope.dto';
|
} from './dto/broker-envelope.dto';
|
||||||
import { BrokerOperationQueryDto } from './dto/broker-operation-query.dto';
|
import { BrokerOperationQueryDto } from './dto/broker-operation-query.dto';
|
||||||
|
import { BrokerOperationSyncQueryDto } from './dto/broker-operation-sync-query.dto';
|
||||||
import { BrokerAccountsService } from './services/broker-accounts.service';
|
import { BrokerAccountsService } from './services/broker-accounts.service';
|
||||||
|
import { BrokerOperationSyncService } from './services/broker-operation-sync.service';
|
||||||
import { BrokerOperationsService } from './services/broker-operations.service';
|
import { BrokerOperationsService } from './services/broker-operations.service';
|
||||||
import { BrokerPortfolioService } from './services/broker-portfolio.service';
|
import { BrokerPortfolioService } from './services/broker-portfolio.service';
|
||||||
|
|
||||||
@ApiTags('Broker')
|
@ApiTags('Broker')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
|
@Roles('admin')
|
||||||
@Controller('broker')
|
@Controller('broker')
|
||||||
export class TBankController {
|
export class TBankController {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly brokerAccountsService: BrokerAccountsService,
|
private readonly brokerAccountsService: BrokerAccountsService,
|
||||||
private readonly brokerPortfolioService: BrokerPortfolioService,
|
private readonly brokerPortfolioService: BrokerPortfolioService,
|
||||||
private readonly brokerOperationsService: BrokerOperationsService,
|
private readonly brokerOperationsService: BrokerOperationsService,
|
||||||
|
private readonly brokerOperationSyncService: BrokerOperationSyncService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Get('accounts')
|
@Get('accounts')
|
||||||
@ApiOperation({ summary: 'Get open T-Bank brokerage and IIS accounts' })
|
@ApiOperation({ summary: 'Get open T-Bank brokerage and IIS accounts' })
|
||||||
@ApiOkResponse({ type: BrokerAccountsEnvelopeDto })
|
@ApiOkResponse({ type: BrokerAccountsEnvelopeDto })
|
||||||
async getAccounts() {
|
async getAccounts() {
|
||||||
return this.brokerAccountsService.findAll();
|
const result = await this.brokerAccountsService.findAll();
|
||||||
|
return new ApiResponse(result.data, result.meta.fromCache, result.meta.cachedAt);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('accounts/:accountId/portfolio')
|
@Get('accounts/:accountId/portfolio')
|
||||||
@ApiOperation({ summary: 'Get T-Bank broker account portfolio with cash and positions' })
|
@ApiOperation({ summary: 'Get T-Bank broker account portfolio with cash and positions' })
|
||||||
@ApiOkResponse({ type: BrokerPortfolioEnvelopeDto })
|
@ApiOkResponse({ type: BrokerPortfolioEnvelopeDto })
|
||||||
async getPortfolio(@Param('accountId') accountId: string) {
|
async getPortfolio(@Param('accountId') accountId: string) {
|
||||||
return this.brokerPortfolioService.getPortfolio(accountId);
|
const result = await this.brokerPortfolioService.getPortfolio(accountId);
|
||||||
|
return new ApiResponse(result.data, result.meta.fromCache, result.meta.cachedAt);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('accounts/:accountId/operations')
|
@Get('accounts/:accountId/operations')
|
||||||
@ -41,6 +50,18 @@ export class TBankController {
|
|||||||
@Param('accountId') accountId: string,
|
@Param('accountId') accountId: string,
|
||||||
@Query() query: BrokerOperationQueryDto,
|
@Query() query: BrokerOperationQueryDto,
|
||||||
) {
|
) {
|
||||||
return this.brokerOperationsService.getOperations(accountId, query);
|
const result = await this.brokerOperationsService.getOperations(accountId, query);
|
||||||
|
return new ApiResponse(result.data, result.meta.fromCache, result.meta.cachedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('accounts/:accountId/operations/sync')
|
||||||
|
@ApiOperation({ summary: 'Synchronize T-Bank broker account operations into local history' })
|
||||||
|
@ApiOkResponse({ type: BrokerOperationSyncEnvelopeDto })
|
||||||
|
async syncOperations(
|
||||||
|
@Param('accountId') accountId: string,
|
||||||
|
@Query() query: BrokerOperationSyncQueryDto,
|
||||||
|
) {
|
||||||
|
const result = await this.brokerOperationSyncService.syncAccount(accountId, query);
|
||||||
|
return new ApiResponse(result);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -149,5 +149,7 @@ Read-only интеграция с T-Bank Invest для брокерских сч
|
|||||||
- `BrokerPortfolioService` объединяет портфель, позиции, cash и метаданные инструментов
|
- `BrokerPortfolioService` объединяет портфель, позиции, cash и метаданные инструментов
|
||||||
- `BrokerOperationsService` отдаёт cursor-paginated историю операций
|
- `BrokerOperationsService` отдаёт cursor-paginated историю операций
|
||||||
- `BrokerOperationSyncService` сохраняет историю операций в отдельные Prisma-таблицы
|
- `BrokerOperationSyncService` сохраняет историю операций в отдельные Prisma-таблицы
|
||||||
|
- Broker endpoints требуют роль `admin`, потому что текущая версия использует один server-side
|
||||||
|
`T_BANK_TOKEN`
|
||||||
- Direct-read endpoints используют `CacheService` с T-Bank TTL и не записывают данные в ручной
|
- Direct-read endpoints используют `CacheService` с T-Bank TTL и не записывают данные в ручной
|
||||||
`PortfolioModule`
|
`PortfolioModule`
|
||||||
|
|||||||
@ -29,13 +29,14 @@ x-app-name: ksv741.moex-vibe
|
|||||||
|
|
||||||
## Backend endpoints
|
## Backend endpoints
|
||||||
|
|
||||||
Все endpoints защищены JWT и возвращают стандартную оболочку `{ data, meta }`.
|
Все endpoints защищены JWT, требуют роль `admin` и возвращают стандартную оболочку `{ data, meta }`.
|
||||||
|
|
||||||
| Endpoint | Описание |
|
| Endpoint | Описание |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `GET /api/v1/broker/accounts` | Открытые брокерские счета и ИИС |
|
| `GET /api/v1/broker/accounts` | Открытые брокерские счета и ИИС |
|
||||||
| `GET /api/v1/broker/accounts/:accountId/portfolio` | Итоги портфеля, позиции, деньги и заблокированные деньги |
|
| `GET /api/v1/broker/accounts/:accountId/portfolio` | Итоги портфеля, позиции, деньги и заблокированные деньги |
|
||||||
| `GET /api/v1/broker/accounts/:accountId/operations` | История операций с cursor pagination |
|
| `GET /api/v1/broker/accounts/:accountId/operations` | История операций с cursor pagination |
|
||||||
|
| `POST /api/v1/broker/accounts/:accountId/operations/sync` | Синхронизация истории операций в локальные Prisma-таблицы |
|
||||||
|
|
||||||
## Методы T-Bank
|
## Методы T-Bank
|
||||||
|
|
||||||
@ -62,10 +63,14 @@ Direct-read endpoints используют короткий in-memory cache, ч
|
|||||||
- `BrokerOperation` — нормализованная операция и сырой JSON payload;
|
- `BrokerOperation` — нормализованная операция и сырой JSON payload;
|
||||||
- `BrokerOperationSyncState` — состояние последней синхронизации по брокерскому счёту.
|
- `BrokerOperationSyncState` — состояние последней синхронизации по брокерскому счёту.
|
||||||
|
|
||||||
|
Синхронизация запускается явно через admin-only endpoint `POST .../operations/sync` с query
|
||||||
|
параметрами `from` и `to` в ISO-8601 формате.
|
||||||
|
|
||||||
Эти таблицы не связаны с ручными портфелями `Portfolio` и `Position`.
|
Эти таблицы не связаны с ручными портфелями `Portfolio` и `Position`.
|
||||||
|
|
||||||
## Безопасность
|
## Безопасность
|
||||||
|
|
||||||
Текущая версия рассчитана на single-user/admin сценарий: используется один server-side
|
Текущая версия рассчитана на single-user/admin сценарий: используется один server-side
|
||||||
`T_BANK_TOKEN`. Перед multi-user режимом нужно добавить зашифрованное хранение пользовательских
|
`T_BANK_TOKEN`, поэтому broker endpoints доступны только пользователям с ролью `admin`. Перед
|
||||||
T-Bank токенов и привязку каждого брокерского счёта к владельцу.
|
multi-user режимом нужно добавить зашифрованное хранение пользовательских T-Bank токенов и привязку
|
||||||
|
каждого брокерского счёта к владельцу.
|
||||||
|
|||||||
@ -17,6 +17,24 @@ describe('request', () => {
|
|||||||
expect(result.data.status).toBe('ok');
|
expect(result.data.status).toBe('ok');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('supports the single API envelope shape documented by Swagger', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get(`${API}/test-single-envelope`, () =>
|
||||||
|
HttpResponse.json({
|
||||||
|
data: { ok: true },
|
||||||
|
meta: { fromCache: true, cachedAt: '2026-06-17T00:00:00.000Z' },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await request<{ ok: boolean }>('/api/v1/test-single-envelope');
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
data: { ok: true },
|
||||||
|
meta: { fromCache: true, cachedAt: '2026-06-17T00:00:00.000Z' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('includes Authorization header when token is set', async () => {
|
it('includes Authorization header when token is set', async () => {
|
||||||
setAccessToken('test-token');
|
setAccessToken('test-token');
|
||||||
let capturedAuth: string | null = null;
|
let capturedAuth: string | null = null;
|
||||||
|
|||||||
@ -40,14 +40,31 @@ async function refreshTokens(): Promise<boolean> {
|
|||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
});
|
});
|
||||||
if (!res.ok) return false;
|
if (!res.ok) return false;
|
||||||
const json: ApiEnvelope<{ data: AuthResponse; meta: ApiResponseMeta }> = await res.json();
|
const json = await res.json();
|
||||||
accessToken = json.data.data.accessToken;
|
accessToken = normalizeEnvelope<AuthResponse>(json).data.accessToken;
|
||||||
return true;
|
return true;
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeEnvelope<T>(json: unknown): { data: T; meta: ApiResponseMeta } {
|
||||||
|
const envelope = json as ApiEnvelope<T | { data: T; meta: ApiResponseMeta }>;
|
||||||
|
if (
|
||||||
|
envelope.data &&
|
||||||
|
typeof envelope.data === 'object' &&
|
||||||
|
'data' in envelope.data &&
|
||||||
|
'meta' in envelope.data
|
||||||
|
) {
|
||||||
|
return envelope.data as { data: T; meta: ApiResponseMeta };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: envelope.data as T,
|
||||||
|
meta: envelope.meta,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async function handleUnauthorized(): Promise<boolean> {
|
async function handleUnauthorized(): Promise<boolean> {
|
||||||
if (isRefreshing && refreshPromise) {
|
if (isRefreshing && refreshPromise) {
|
||||||
return refreshPromise;
|
return refreshPromise;
|
||||||
@ -114,8 +131,8 @@ export async function request<T>(
|
|||||||
throw new Error(`Ошибка API: ${res.status} ${res.statusText}${text ? ` - ${text}` : ''}`);
|
throw new Error(`Ошибка API: ${res.status} ${res.statusText}${text ? ` - ${text}` : ''}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const json: ApiEnvelope<{ data: T; meta: ApiResponseMeta }> = await res.json();
|
const json = await res.json();
|
||||||
return json.data;
|
return normalizeEnvelope<T>(json);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getHealth(): Promise<{ data: HealthResponse; meta: ApiResponseMeta }> {
|
export function getHealth(): Promise<{ data: HealthResponse; meta: ApiResponseMeta }> {
|
||||||
|
|||||||
@ -434,6 +434,23 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
'/api/v1/broker/accounts/{accountId}/operations/sync': {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
/** Synchronize T-Bank broker account operations into local history */
|
||||||
|
post: operations['TBankController_syncOperations'];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
export type webhooks = Record<string, never>;
|
export type webhooks = Record<string, never>;
|
||||||
export interface components {
|
export interface components {
|
||||||
@ -843,6 +860,14 @@ export interface components {
|
|||||||
data: components['schemas']['BrokerOperationsPageResponseDto'];
|
data: components['schemas']['BrokerOperationsPageResponseDto'];
|
||||||
meta: components['schemas']['BrokerResponseMetaDto'];
|
meta: components['schemas']['BrokerResponseMetaDto'];
|
||||||
};
|
};
|
||||||
|
BrokerOperationSyncResponseDto: {
|
||||||
|
/** @example 42 */
|
||||||
|
upserted: number;
|
||||||
|
};
|
||||||
|
BrokerOperationSyncEnvelopeDto: {
|
||||||
|
data: components['schemas']['BrokerOperationSyncResponseDto'];
|
||||||
|
meta: components['schemas']['BrokerResponseMetaDto'];
|
||||||
|
};
|
||||||
};
|
};
|
||||||
responses: never;
|
responses: never;
|
||||||
parameters: never;
|
parameters: never;
|
||||||
@ -1523,4 +1548,28 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
TBankController_syncOperations: {
|
||||||
|
parameters: {
|
||||||
|
query: {
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
};
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
accountId: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
'application/json': components['schemas']['BrokerOperationSyncEnvelopeDto'];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user