01 Технологическое ядро
| Категория | Выбор |
|---|---|
| Фреймворк | NestJS 10 как gRPC-микросервис (Transport.GRPC) |
| Транспорт | gRPC (content.proto, 22 сервиса, 78 RPC), лимит сообщений 50 МБ |
| ORM | Prisma 6, multi-schema, typedSql, raw-SQL для векторов |
| БД | PostgreSQL 17 + pgvector, pg_trgm, btree_gin, uuid-ossp |
| Поиск | Elasticsearch 8.19 (@nestjs/elasticsearch) |
| Кэш/очереди | Redis (ioredis), Bull (@nestjs/bull), cron (@nestjs/schedule) |
| Auth | Passport JWT, nestjs-rbac, bcrypt |
| ML inference | @tensorflow/tfjs-node (с фолбэком на чистый JS) |
| Внешние | Kinopoisk API, TMDB, Timeweb LLM, Telegram (nestjs-telegram) |
Точка входа поднимает чистый микросервис без HTTP-обвязки:
// main.ts const app = await NestFactory.createMicroservice<MicroserviceOptions>(AppModule, { transport: Transport.GRPC, options: { url: '0.0.0.0:50051', package: 'content', protoPath, maxSendMessageLength: 1024 * 1024 * 50, maxReceiveMessageLength: 1024 * 1024 * 50, }, });
protoPath ищется по списку кандидатов (dev / dist / cwd) — устойчивость к разным окружениям сборки.
02 Модульная структура
Контроллеры тонкие (маппинг proto ↔ домен), бизнес-логика — в сервисах. Это канонический NestJS, выдержанный последовательно по всем 18 контроллерам.
03 Согласованность с фронтендом: единый proto-контракт
Это центральный принцип всей системы. content.proto (1497 строк) — единый источник истины для обоих концов:
- На бэкенде NestJS реализует gRPC-сервисы по этому proto.
- На фронте
buf generateпроизводит из того же файла TypeScript-клиент.
Фронтовый content.proto и бэкендовый идентичны (фронт держит копию для кодогенерации). 78 RPC-методов покрывают весь функционал: AuthService, MovieService, CatalogService, SearchService, RecommendationService, AnalyticsService, TelegramMailingService, StudioService, StaffService и т.д.
Эффект: изменение поля сообщения немедленно отражается в типах обеих сторон, а бинарная сериализация gRPC-Web компактнее и быстрее JSON. Браузерный трафик транслируется из gRPC-Web в gRPC через Envoy.
04 Работа с БД: Prisma + multi-schema + pgvector
Схема
schema.prisma (899 строк) описывает богатую доменную модель в двух схемах PostgreSQL:
content— фильмы, пользователи, плейлисты, персоны, студии, жанры, страны, факты, рейтинги, watch-сессии, статистика, Telegram-кампании, ML-реакции.content-balancer— провайдеры и токены балансировщика (изоляция инфраструктуры от домена).
Включены расширения и preview-фичи:
generator client { previewFeatures = ["fullTextSearchPostgres", "multiSchema", "postgresqlExtensions", "typedSql"] } datasource db { extensions = [btree_gin, pg_trgm, uuid_ossp(map: "uuid-ossp"), vector] schemas = ["content", "content-balancer"] }
Векторные колонки
Ключевой нюанс — pgvector-колонки объявлены как Unsupported (Prisma их не умеет читать), поэтому над ними работают через raw SQL:
model watchContent { // ... semanticVector Unsupported("vector(768)")? @map("semantic_vector") // смысл описания itemTowerVector Unsupported("vector(64)")? @map("item_tower_vector") // выход Item Tower }
Качество схемы
Схема демонстрирует продуманное индексирование под конкретные запросы:
- GIN-индексы с
text_opsдля полнотекстового поиска по именам/email/username. - Хэш-индексы на
kinopoiskId,slug,accessToken(точечный lookup). - Композитные индексы под реальные паттерны:
idx_watch_sessions_user_kp_status,idx_movie_content_type_year,idx_playlist_items_list_order. - Каскады и
onDelete-политики проставлены осознанно (Cascade / SetNull / NoAction).
11 ролей пользователей, 8 статусов сессий, аккуратные enum-ы — домен проработан глубоко, а не «на коленке».
05 ★ Гибридный поиск (Elasticsearch + pgvector + LLM)
elastic-search.service.ts (738 строк) — витрина инженерного мастерства. Поиск объединяет три независимых сигнала.
5.1. Два пула кандидатов
- BM25 в Elasticsearch — широкий пул (150 кандидатов) по полям
names^3 / description / roles^2сfuzziness: AUTO. Из_sourceберутся толькоkinopoiskIdи предрассчитанные взвешенные рейтинги (без загрузки карточек). - Семантический kNN в pgvector — запрос превращается в 768-мерный вектор и ищет ближайшие
semantic_vector(60 кандидатов).
5.2. Reciprocal Rank Fusion (RRF)
Две несравнимые шкалы скоров сливаются через RRF — каждый кандидат получает Σ w / (k + rank):
esHits.forEach((h, i) => { rrf.set(h.id, (rrf.get(h.id) ?? 0) + this.SEARCH.wEs / (k + i + 1)); }); semanticIds.forEach((id, i) => rrf.set(id, (rrf.get(id) ?? 0) + this.SEARCH.wSem / (k + i + 1)));
5.3. Байесовское взвешивание популярности (формула IMDb Top-250)
Чтобы среди равно-релевантных побеждал известный тайтл, применяется взвешенный рейтинг WR = v/(v+m)·R + m/(v+m)·C, предрассчитанный и хранимый прямо в ES-индексе:
final = rrf · (1 + α · popularity) // α = 2.0 (сильное влияние популярности)
Константы C и m выведены из данных (≈70-й перцентиль голосов) и переопределяемы через env.
5.4. Семантический «гейт»
Короткие запросы (< 3 слов) — это поиск по названию/имени/персонажу, где эмбеддинги целых документов вносят шум. Поэтому семантика для них отключается:
const useSemantic = wordCount >= this.SEARCH.semMinWords;
5.5. LLM-нормализация запроса
Если включён флаг, сырой запрос прогоняется через Timeweb LLM (deepseek-v4-flash), которая превращает «кино где парень застрял во времени» → «день сурка groundhog day петля времени комедия». System-prompt жёстко требует «только теги через пробел, без знаков препинания и диалога», стоят лимиты по символам/словам и таймаут с фолбэком на исходный запрос.
5.6. Производительность как дисциплина
- Полные карточки из PG грузятся только для финального top-N, а не для всего пула.
- Для семантик-only кандидатов (нет ES-популярности) — ограниченный re-rank-пул.
- Пагинация со 2-й страницы — простой BM25 (re-rank это забота 1-й страницы).
- Инкрементальная синхронизация ES каждую минуту (
@Cron(EVERY_MINUTE)) + батчевый full reindex по 5000 документов.
Это уровень поисковой команды, а не «прикрутил ES».
06 ★ Векторные рекомендации и pgvector
recommendation.service.ts (757 строк). Бэкенд не гоняет фильмы через нейросеть в рантайме — вместо этого:
- Загружает только User Tower при старте (
onModuleInit), с фолбэкомtfjs-node → tfjsи даже ручным IO-handler-ом для весов. - Собирает 814-мерный профиль пользователя из истории/избранного.
- Прогоняет профиль через User Tower → 64-мерный вектор.
- Делает один SQL-запрос по
item_tower_vector(предрассчитанные офлайн).
Двухстадийный HNSW-поиск (с разбором EXPLAIN в комментарии)
// EXPLAIN показал: одностадийный `<=> + random` шёл seq scan по ~291k строк = ~930мс. // (1) HNSW берёт top-K ближайших ЧИСТЫМ `<=>` (индекс работает только на чистом // порядке); (2) ре-ранк этих кандидатов со штрафом + джиттером. const sqlQuery = ` WITH cand AS ( SELECT wc.id, wc.kinopoisk_id, (wc.item_tower_vector <=> $1::vector) AS dist FROM content.movie_content wc WHERE wc.item_tower_vector IS NOT NULL ${candidateWhere} ORDER BY wc.item_tower_vector <=> $1::vector LIMIT ${candidatePool} ) SELECT c.kinopoisk_id, 1.0 - c.dist AS score FROM cand c ORDER BY c.dist + (${demotionSql}) + (RANDOM() * 0.05) LIMIT ${limit}`;
Дополнительно — SET LOCAL hnsw.ef_search внутри транзакции, чтобы не протёк в пул соединений:
await this.prisma.$transaction(async (tx) => { await tx.$executeRawUnsafe(`SET LOCAL hnsw.ef_search = ${candidatePool}`); return tx.$queryRawUnsafe(sqlQuery, ...queryArgs); });
Фильтрация прямо в SQL: исключаются оценённые (ml_user_to_movie), реально просмотренные (хит-сессии), и контент из плейлистов. + RANDOM()*0.05 — лёгкий exploration, чтобы лента не застывала.
Объяснимый «контент-визибилити»-слой
content-visibility.util.ts — единый блэклист (скрытые жанры/страны, мин. рейтинг, мин. число голосов), отдаваемый в трёх формах:
visibilityWhereAnd()— Prisma-фрагменты;visibilitySql(alias)— raw-SQL AND-клаузы (там, где Prisma не годится);visibilityDemotionSql(alias)— мягкий штраф к дистанции (демоут, а не исключение) для ранжирования рекомендаций.
Одна доменная политика, три представления под разные движки запросов — отличный DRY.
07 ★ Полки главной страницы (shelves.service.ts)
774 строки. Главная — не один усреднённый список, а лента объяснимых «полок», где заголовок несёт причину: «Вам понравился „X“ — вот похожее», «Вы недавно смотрели „Y“», «Вы любите фантастику».
Сильные приёмы:
- Runtime k-means прямо по векторам пользователя (детерминированный, без
Math.random) — лекарство от «усреднения вкуса»: разные кластеры → разные полки. - Stale-while-revalidate + single-flight для «Сейчас смотрят»: главная не ждёт дорогой агрегат, пересчёт идёт максимум одним процессом через Redis NX-lock, при недоступности Redis — деградация на live-вычисление.
private async refreshTrendingCache(computeIfNoLock: boolean): Promise<number[]> { const gotLock = await this.redis.safeExecute( () => this.redis.set(this.TRENDING_LOCK_KEY, '1', 'EX', this.TRENDING_LOCK_TTL_S, 'NX'), null); if (!gotLock && !computeIfNoLock) return []; // другой процесс уже считает try { /* ...пересчёт и запись в кэш... */ } finally { if (gotLock) await this.redis.safeExecute(() => this.redis.del(this.TRENDING_LOCK_KEY), null); } }
- Сквозной dedup между полками (фильм не якорит две полки и не повторяется), trimming до 12 карточек, отбрасывание полок короче
MIN_RAIL, и резолв id→карточки одним батч-запросом в конце. - Качественный «пол» для всех выборок (есть жанры, IMDb ≥ 6, год ≥ 2005 + блэклист) с честным комментарием, почему нельзя использовать абсолютный порог косинуса (пространство эмбеддингов слишком сжато).
08 ★ Фасетный каталог (catalog/ модуль)
catalog.service.ts строит фасетный каталог поверх Elasticsearch-агрегаций:
- Pipeline:
normalize(валидация + резолв слагов/персон в id) →searchList(ES) →loadCards(PG, с сохранением порядка) → ответ. - Кэш фасетов отдельно от списка: ключ фасетов намеренно не зависит от paging/sort (фасет-счётчики от них не меняются) — экономия пересчётов.
- Хэш-ключи кэша на sha1 от фильтра, версионирование префикса (
catalog:v3). - Лейблы (жанры/страны) резолвятся из PG/Redis при рендере; в ES хранятся только id — чистое разделение «факты в ES / человекочитаемое в PG».
- Дефолтная сортировка — по байесовскому взвешенному рейтингу с бонусом за свежесть.
09 ★ Балансировщик внешних провайдеров
balancer/core/balancer.facade.ts — отказоустойчивый слой доступа к внешним API (Kinopoisk Unofficial / Dev). Реализует сразу несколько паттернов надёжности:
- До 3 попыток с разными провайдерами/токенами.
- Sticky-провайдер: запоминается последний успешный провайдер на тип операции.
- Ротация токенов с учётом rate-limit (Redis, burst 100 req/min на токен).
- Circuit breaker: критические ошибки (
ECONNREFUSED,timeout,502/503/504,402/quota) блокируют провайдера черезProviderPolicy. - Реестр провайдеров + duck-typing проверка реализации
IOperations.
for (let attempt = 0; attempt < 3; attempt++) { const providerCode = await this.router.selectProvider({ operation, preferredProvider, excludedProviders }); const token = await this.tokenManager.getAvailableToken(providerCode); const withinLimit = await this.rateLimitPolicy.checkRateLimit(providerCode, token, 100); if (!withinLimit) { await this.tokenManager.markTokenFailed(...); continue; } const result = await this.executeOperation(provider, operation, params); if (result.success) { this.stickyProviders.set(operation, providerCode); return ...; } if (this.isCriticalError(result.error)) this.providerPolicy.blockProvider(providerCode); }
Сопутствующие сервисы (content-loader / content-merge / content-saver / movie-cache) загружают, объединяют и сохраняют контент от провайдеров — на уровне, который обычно делает отдельная команда интеграций.
10 Аналитика и фоновые задачи
- Watch-сессии:
AnalyticsServiceлимитирует время 900с, в агрегат шлёт только дельту (timeDelta), помечает хиты (≥30с).StatisticsServiceотдаёт realtime и детальную историю для админки. - Cron: инкрементальная ES-синхронизация каждую минуту.
- Bull-очереди для миграционных/тяжёлых задач (
runnable_tasks,error_handler). - Backfill-скрипты с настройкой concurrency/rps (
backfill:studios,backfill:staff) — продуманный bulk-ingest с rate-control. - Telegram-рассылки: кампании, авто-отписка при
403/400от Telegram API.
11 ★ Откуда берутся данные и как обеспечивается консистентность
Это ключевой вопрос для системы с каталогом ~290k единиц, и он решён продуманно.
Источники данных
| Источник | Что даёт | Как попадает в систему |
|---|---|---|
| Kinopoisk Unofficial / Dev API | фильмы, сериалы, персоны, факты, связи | через балансировщик (§9) → нормализация → PostgreSQL |
| TMDB | студии и сети (компании-производители) | TmdbService + backfill:studios (с HTTP-прокси, т.к. DNS отравлён в РФ) |
| MovieLens 25M | прайор реальных оценок для ML | офлайн-стриминг → movielens-users.jsonl (см. ml.md) |
| Timeweb LLM / Infinity | оптимизация запросов / эмбеддинги | синхронно при поиске |
PostgreSQL — единственный источник истины. Elasticsearch, pgvector-колонки и Redis — это производные представления, которые пересобираются из PG.
Модель консистентности
- Идемпотентный ingest по натуральному ключу. Всё сохранение —
upsertпоkinopoiskId/slug/tmdbId, поэтому повторный прогон провайдера не плодит дубли (content-saver.service.ts). - Асинхронный ingest тяжёлых связей через Bull-очереди. Стафф, факты, связи и спин-оффы грузятся не в синхронном пути запроса, а воркерами-консьюмерами (
services/cache/*.consumer.ts), каждый из которых оборачивает запись в$transaction— атомарность связного графа. - Reconcile с защитой от усечённого ответа провайдера. Самое сильное решение —
MovieStaffCacheConsumer: он не просто перезаписывает каст, а сверяет свежий payload с существующим и отказывается удалять данные, если новый ответ покрывает < 50% уже сохранённых строк (подозрение на частичный/битый ответ API):
// Partial-payload guard: не вычищаем каст на «тонком» ответе провайдера. if (existing.length >= RECONCILE_GUARD_MIN_EXISTING && freshRows.length < existing.length * RECONCILE_MIN_COVERAGE) { this.logger.warn(`Skipping reconcile: suspected partial payload`); return 0; } // + пустой payload вообще не может удалить существующий каст
Это защита данных от деградации внешнего источника — то, о чём забывают даже в проде.
- Кросс-хранилищная синхронизация через
updatedAt. Когда консьюмер меняет связи, он «трогает»watchContent.updatedAt; минутный cron (@Cron(EVERY_MINUTE)) подхватывает изменённые строки и переиндексирует их в Elasticsearch. Один сигнал (updatedAt) синхронизирует PG → ES без отдельной шины.
if (changed) { await this.prisma.watchContent.update({ where: { id: movie.id }, data: { updatedAt: new Date() }, // → minute-cron reindex }); }
- Векторы пересобираются офлайн.
semantic_vectorиitem_tower_vector— производные; обновляются скриптамиgenerate-embeddings/cache-item-tower(идемпотентно, только для новых/пустых строк). - Согласованные тоталы пагинации через
$transaction([findMany, count])— список и счётчик читаются в одной транзакции (playlist,user-playlist,admin,sitemap). - Слияние нескольких провайдеров (
content-merge.service.ts): приоритет non-null значений + дедуп массивов по ключу — консолидация данных из разных источников в одну запись. - Восстановление после гонок на уникальных ключах: ловля
P2002с повторнымfindUnique(две параллельные вставки страны/жанра не валят ingest).
12 Авторизация и безопасность доступа
Полноценная JWT-авторизация (вопреки первому впечатлению — она есть и работает на каждом защищённом методе).
- JWT-стратегия с проверкой ревокации (
jwt.strategy.ts): мало проверить подпись — стратегия дополнительно проверяет, что токен не отозван (таблицаtoken,isRevoked), и что пользователь существует:
async validate(payload: JwtPayload) { const user = await this.authService.getUserById(payload.id); if (!user) throw new UnauthorizedException('User not found'); const tokenRecord = await this.prisma.token.findFirst({ where: { userId: user.id, clientId: payload.clientId, isRevoked: false }, }); if (!tokenRecord) throw new UnauthorizedException('Token revoked'); return { userId: user.id, clientId: payload.clientId, role: user.role, user }; }
- gRPC-интерсептор (
GrpcAuthInterceptor) достаёт токен из metadata (с учётом регистра ключей в HTTP/2), валидирует, прикрепляетuserк контексту и возвращаетUNAUTHENTICATEDпри протухшем токене — это ровно тот код, по которому фронтовый retry-интерсептор запускает refresh (см. frontend.md §6). Обе стороны согласованы по контракту ошибки. @Public()и@CurrentUser()декораторы + явная проверкаuserIdна защищённых методах (77 использований в 11 контроллерах):
@GrpcMethod('RecommendationService', 'GetRecommendations') @UseInterceptors(GrpcAuthInterceptor) async getRecommendations(@Payload() data, @CurrentUser() user: { userId: number }) { if (!user?.userId) throw new RpcException({ code: grpc.status.UNAUTHENTICATED, ... }); // ... }
- Optional-auth паттерн: интерсептор пропускает анонима для методов, которые персонализируются при наличии
userId(полки, фид) и работают как публичные без него. - Роли (
ADMIN/MODERATOR/..., 11 значений) +nestjs-rbac;last_visitedобновляется на каждом авторизованном вызове.
@UseInterceptors + проверка userId), а не глобальным guard'ом с декларативными role-декораторами. Работает и прозрачно читается, но легко забыть проверку на новом методе — глобальный APP_GUARD + @Roles() был бы надёжнее. Плюс секреты в .env (см. ниже).13 N+1 и узкие места производительности
Проверено отдельно — в горячих read-путях N+1 нет:
- Чтения идут одним запросом через Prisma
include(JOIN), либо батчемfindMany({ where: { id: { in: [...] } } }), либо raw-SQL... IN (...)/= ANY($1::int[]). Карточки фильмов в полках/каталоге/поиске резолвятся одним батч-запросом и переставляются в нужный порядок in-memory (fetchCardsInOrder,loadCards,dedupeAndTrim) — классическое лекарство от N+1. - Семантические векторы истории пользователя тянутся одним
IN (...)-запросом, а не по фильму.
Где циклы есть — это write/background-пути, и там это допустимо:
saveMovieGenres/buildFreshRowsделают per-itemupsertв цикле. Это ingest через очередь (не запрос пользователя), объём ограничен размером каста/жанров, и запись атомарна транзакцией. Здесь батч-createManyбыл бы оптимизацией, но не корректностью.
Прочие наблюдения:
RecommendationService.initializeMaps()грузит все страны со связями для подсчёта — тяжело, но выполняется один раз на старте (onModuleInit), не на запрос.ContentSaverService.saveMovie(синхронный путь) — последовательностьawaitбез$transaction: при сбое на середине возможна частичная запись (фильм есть, жанры — нет). Тяжёлый ingest вынесен в транзакционные консьюмеры, но самsaveMovieстоит обернуть в транзакцию. (Зафиксировано как замечание.)
14 Оценка качества бэкенда
Сильные стороны
- Образцовая модульность NestJS, тонкие контроллеры, сервисы с одной зоной ответственности.
- Глубокая, проиндексированная под запросы доменная схема в двух PG-схемах.
- Производительность как явная практика: разбор EXPLAIN в комментариях, двухстадийный HNSW,
SET LOCAL ef_search, предрасчёт WR в ES, батч-загрузка карточек. - Зрелые distributed-паттерны: circuit breaker, sticky-провайдер, ротация токенов, rate-limit, single-flight, stale-while-revalidate.
- Единая доменная политика видимости в трёх представлениях (DRY поверх разных движков).
- Осознанное разделение хранилищ: PG (истина) / ES (текст+фасеты) / pgvector (смысл) / Redis (кэш+локи).
- Зрелая консистентность ingest: идемпотентные upsert'ы, транзакционные Bull-консьюмеры, reconcile с защитой от усечённого ответа провайдера, кросс-store sync через
updatedAt. - Чистые read-пути без N+1 (батч-загрузка карточек,
IN/ANY,include). - Полноценная JWT-авторизация с проверкой ревокации токена и согласованным с фронтом контрактом ошибки
UNAUTHENTICATED.
Замечания
- Несколько сервисов перевалили за 700 строк — кандидаты на декомпозицию.
- Секреты в
.envв репозитории (пароли БД, токены Telegram/TMDB, ключи LLM) — для прод-системы это уязвимость; нужен secret-manager. (Зафиксировано как замечание; правки не вношу.) - Юнит/интеграционных тестов мало для системы такого размера.
- Местами
$queryRawUnsafeс интерполяцией — здесь безопасно (значения внутренние, есть явные комментарии про injection-safety), но требует дисциплины при развитии.
Уровень backend-исполнителя — Senior. Аргументация: системное проектирование data-платформы, профилирование и оптимизация на уровне планировщика БД, набор production-grade distributed-паттернов, end-to-end владение поиском и рекомендациями.