fix: harden auth and cache concurrency

This commit is contained in:
2026-08-23 22:42:35 +03:00
parent 71d57d60bc
commit c77b3cf782
6 changed files with 289 additions and 38 deletions
+34 -10
View File
@@ -5,6 +5,7 @@ import {
NotFoundException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { LRUCache } from 'lru-cache';
import { RequestContextService } from '../common/logging/request-context.service';
import { Environment } from '../config/env.validation';
import { PrismaService } from '../prisma/prisma.service';
@@ -20,6 +21,7 @@ import { TbankClient } from './tbank.client';
import { TbankCrypto } from './tbank.crypto';
const freshForMs = 30_000;
const maxCachedPages = 100;
const supportedAccounts = new Set([
'ACCOUNT_TYPE_TINKOFF',
'ACCOUNT_TYPE_TINKOFF_IIS',
@@ -30,10 +32,11 @@ const supportedAccounts = new Set([
@Injectable()
export class BrokerageOperationsService {
private readonly crypto: TbankCrypto;
private readonly pages = new Map<
string,
{ page: BrokerageOperationsPageDto; storedAt: number }
>();
private readonly pages = new LRUCache<string, BrokerageOperationsPageDto>({
max: maxCachedPages,
ttl: freshForMs,
ttlAutopurge: true,
});
constructor(
private readonly prisma: PrismaService,
@@ -53,10 +56,6 @@ export class BrokerageOperationsService {
): Promise<BrokerageOperationsPageDto> {
this.requestContext.set({ userId, brokerageAccountId: accountId });
const range = this.range(query);
const key = JSON.stringify({ userId, accountId, ...query, ...range });
const cached = this.pages.get(key);
if (cached && Date.now() - cached.storedAt <= freshForMs)
return cached.page;
try {
const connection = await this.prisma.tbankConnection.findUnique({
@@ -74,9 +73,13 @@ export class BrokerageOperationsService {
)
throw new NotFoundException();
const key = cacheKey(userId, accountId, query, range);
const cached = this.pages.get(key);
if (cached) return cached;
if (range.from > range.to) {
const page = emptyPage(accountId);
this.pages.set(key, { page, storedAt: Date.now() });
this.pages.set(key, page);
return page;
}
@@ -101,7 +104,7 @@ export class BrokerageOperationsService {
hasNext: Boolean(response.hasNext),
observedAt: new Date().toISOString(),
};
this.pages.set(key, { page, storedAt: Date.now() });
this.pages.set(key, page);
return page;
} catch (error) {
if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN')
@@ -121,6 +124,27 @@ export class BrokerageOperationsService {
}
}
function cacheKey(
userId: string,
accountId: string,
query: BrokerageOperationsQueryDto,
range: { from: Date; to: Date },
): string {
const categories = [...new Set(query.categories ?? [])].sort();
const end = query.to
? range.to.toISOString()
: Math.floor(range.to.valueOf() / freshForMs);
return JSON.stringify({
userId,
accountId,
from: range.from.toISOString(),
to: end,
categories,
cursor: query.cursor ?? null,
limit: query.limit ?? 100,
});
}
function dateBoundary(value: string | undefined, end: boolean): Date | null {
if (!value) return null;
if (/^\d{4}-\d{2}-\d{2}$/.test(value))