refactor: organize backend by business responsibility

This commit is contained in:
2026-09-13 13:52:57 +03:00
parent 7fde3ece6e
commit 8ba005346d
145 changed files with 3027 additions and 2346 deletions
@@ -0,0 +1,118 @@
import { Injectable, ServiceUnavailableException } from '@nestjs/common';
import { LRUCache } from 'lru-cache';
import { createHash } from 'node:crypto';
import { StructuredLogger } from '../../common/logging/structured-logger.service';
import { TbankClient, type TbankInstrument } from './tbank.client';
const metadataForMs = 86_400_000;
const maxCachedInstruments = 500;
const missingInstrument = Symbol('missingInstrument');
@Injectable()
export class InstrumentMetadataService {
private readonly metadata = new LRUCache<
string,
TbankInstrument | typeof missingInstrument
>({
max: maxCachedInstruments,
ttl: metadataForMs,
noDeleteOnStaleGet: true,
});
private readonly pending = new Map<string, Promise<TbankInstrument | null>>();
constructor(
private readonly tbank: TbankClient,
private readonly logger: StructuredLogger,
) {}
async resolve(
token: string,
instrumentUid: string | null | undefined,
figi: string | null | undefined,
signal?: AbortSignal,
): Promise<TbankInstrument | null> {
const uid = instrumentUid?.trim();
let byUid: TbankInstrument | null = null;
if (uid) {
byUid = await this.lookup(
token,
`uid:${uid}`,
() => this.tbank.getInstrumentByUid(token, uid),
signal,
);
if (byUid?.name?.trim() || byUid?.ticker?.trim()) return byUid;
}
const instrumentFigi = figi?.trim();
const byFigi = instrumentFigi
? await this.lookup(
token,
`figi:${instrumentFigi}`,
() => this.tbank.getInstrumentByFigi(token, instrumentFigi),
signal,
)
: null;
return byFigi ?? byUid;
}
private async lookup(
token: string,
key: string,
fetch: () => Promise<TbankInstrument | null>,
signal?: AbortSignal,
): Promise<TbankInstrument | null> {
const cached = this.metadata.get(key);
if (cached !== undefined)
return cached === missingInstrument ? null : cached;
if (signal?.aborted) return this.lastKnown(key);
const owner = createHash('sha256').update(token).digest('hex');
const requestKey = `${owner}:${key}`;
let pending = this.pending.get(requestKey);
if (!pending) {
pending = this.fetch(key, fetch, this.lastKnown(key)).finally(() => {
this.pending.delete(requestKey);
});
this.pending.set(requestKey, pending);
}
if (!signal) return pending;
return new Promise((resolve, reject) => {
const onAbort = () => resolve(this.lastKnown(key));
signal.addEventListener('abort', onAbort, { once: true });
void pending.then(resolve, reject).finally(() => {
signal.removeEventListener('abort', onAbort);
});
});
}
private lastKnown(key: string): TbankInstrument | null {
const value = this.metadata.peek(key, { allowStale: true });
return value === missingInstrument ? null : (value ?? null);
}
private async fetch(
key: string,
request: () => Promise<TbankInstrument | null>,
previous: TbankInstrument | null,
): Promise<TbankInstrument | null> {
try {
const value = await request();
const found = Boolean(value?.name?.trim() || value?.ticker?.trim());
if (value === null || found)
this.metadata.set(key, value ?? missingInstrument);
this.logger.event('debug', 'instrument-metadata.resolved', {
found,
});
return found || value === null ? value : (previous ?? value);
} catch (error) {
this.logger.event('warn', 'instrument-metadata.failed', {
code:
error instanceof Error && 'response' in error
? ((error as { response?: { code?: string } }).response?.code ??
'UNKNOWN')
: 'UNKNOWN',
});
if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN')
throw error;
return error instanceof ServiceUnavailableException ? previous : null;
}
}
}