Files
Deal/docs/technical/Техническая-документация-Дейл.md
T
stepan 1c0c35946d
ci / build-test (pull_request) Successful in 2m52s
Обновить документацию observability
2026-09-13 13:47:31 +03:00

1434 lines
137 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Дейл (Deal) — Техническая документация
> Версия: 2.0 (этапы 0–12: единая карточка, оператор-консоль и аналитика, i18n, метрики/устойчивость)
> Дата: 2026-09-10
> Содержание: полный стек, структура, конфигурация, развёртывание, эксплуатация.
---
## 1. Обзор стека
| Слой | Технология |
|---|---|
| Язык | C# (современный, актуальная LTS .NET) |
| Бэкенд-ядро | Модульный монолит `core` (ASP.NET Core: Web API, gRPC, SSE) |
| База данных | PostgreSQL (одна БД, схема на тенанта) |
| ORM/доступ | EF Core (основной) + Dapper (тяжёлые запросы, где нужно) |
| Миграции | Механизм миграций на все схемы тенантов |
| Очередь/шина | Outbox-паттерн в Postgres; порт `IEventBus`; Kafka — позже |
| ML-сервис | .NET + ONNX Runtime, пул моделей per-tenant |
| AI-сервис | .NET, фасад LLM-провайдеров (OpenAI-совместимые), учёт токенов |
| Telegram | .NET (WTelegramClient/аналог), ферма сессий, анти-бан |
| Файлы | MinIO (S3-совместимое хранилище) |
| Фронтенд | Vue 3 + Vite + Tailwind |
| Межсервисно | gRPC + Protobuf (mTLS — за флагом `DEAL_MTLS_*`, §10/§13.8) |
| Наблюдаемость | Serilog (JSON: консоль + rolling-файл) → Promtail → Loki → Grafana; метрики OTel → Prometheus; трейсы OTel → Collector → Tempo; ресурсы cAdvisor/node-exporter → Prometheus; единый UI — Grafana |
| Прокси/edge | Caddy (TLS, security-заголовки); Cloudflare/k8s — вне этапа (§10/§11) |
| Контейнеры | Docker / docker compose (VPS); k8s — позже |
| Бэкапы | Ежедневные: pg_dump + MinIO + сессии |
| CI | Сборка, тесты, SAST, сканирование зависимостей и образов |
---
## 2. Структура репозитория
```
src/
core/ # модульный монолит (один sln, один процесс)
Deal.sln
Deal.Api/ # host: /api-контракт, gRPC-сервер, SSE, DI-композиция
Deal.Modules.Cards/ # модель единой карточки + каталог контейнеров (этап 9)
Deal.Modules.Pipeline/
Deal.Modules.Kanban/ # таблицы Cards/Containers, правила, комментарии, архив/корзина + сервисы «Выбранных»
Deal.Modules.Telegram/ # каталог диалогов/каналов и превью сообщений (этап 6)
Deal.Modules.Discovery/
Deal.Modules.Settings/
Deal.Modules.Tenants/
Deal.SharedKernel/
Deal.Infrastructure/
Deal.Contracts/
tests/
ml-service/ # Deal.Ml.sln
ai-service/ # Deal.Ai.sln
telegram-service/ # Deal.Telegram.sln
contracts/ # общие .proto
deploy/ # compose.dev.yml / compose.prod.yml (развёртывание)
frontend/ # Vue 3 + Vite
```
Принципы:
- один процесс = один sln;
- `core` — единственное место с бизнес-логикой и БД;
- сервисы stateless по отношению к данным тенантов (ML получает текст — отдаёт решение);
- `.proto` — общий язык между процессами (в `contracts/`, подключается shared-файлами).
---
## 3. Модули core
| Модуль | Проект | Владеет |
|---|---|---|
| Карточки (ядро) | `Deal.Modules.Cards` | модель единой карточки (`ICard`/`ISource`/`IContainer`/`ICardMover`), каталог контейнеров по умолчанию, единые префиксы id |
| Пайплайн | `Deal.Modules.Pipeline` | очередь, отсев, dedup |
| Канбан (дашборд) | `Deal.Modules.Kanban` | таблицы `Cards` и `Containers`, правила колонок, комментарии, `CardMoves`, `MlOutbox` |
| «Выбранные» | `Deal.Modules.Kanban` (`CardsService.Selected`) | сервисы стадий/напоминаний/файлов/ссылок над теми же строками `Cards` |
| Discovery | `Deal.Modules.Discovery` | задачи поиска, кандидаты, чёрный список |
| Настройки | `Deal.Modules.Settings` | настройки тенанта, промпты, валюты |
| Тенанты | `Deal.Modules.Tenants` | реестр тенантов, пользователи, инвайты, лимиты (в `public`) |
| Каналы/Telegram | `Deal.Modules.Telegram` | каталог диалогов, превью сообщений, мониторинг источников |
После этапа 9 (единая карточка): модель/контейнеры — в `Deal.Modules.Cards`; `Kanban` владеет таблицами
`Cards`/`Containers` и сервисами пространства «Выбранные» (`CardsService.Selected`) над теми же строками
(отдельных модуля `Deal.Modules.Projects` и таблицы `ProjectCards` больше нет).
Зависимости между модулями — только через публичные интерфейсы модуля-владельца.
Доменные события — через `IEventBus` (outbox). Правила:
- внутри модуля таблицы — его собственность;
- чужие таблицы не читаем/не пишем SQL напрямую;
- общие справочники живут в модуле-владельце.
---
## 4. Мультитенантность и БД
### Схемы
- `public`: тенанты, пользователи, инвайты, ключи приложения, глобальные настройки.
- `tenant_<id>.*`: все данные тенанта (карточки, колонки, настройки, обучение и т.д.).
### Доступ
- `tenantId` — из сессии/JWT (core) или из gRPC-метаданных (сервисы).
- DAL формирует `search_path` = `tenant_<id>`; пул соединений на схему.
- Изоляция проверяется: принадлежность объекта тенанту до любого действия (IDOR-защита).
### Миграции
- Миграции пишутся один раз (как для одной схемы) и применяются механизмом
«ко всем схемам тенантов»: список схем из `public.tenants`, применение по очереди,
версия миграции хранится на схему. Детали — в плане реализации этапа 0.
### Ключевые таблицы `public`
```
tenants(Id, Name, Status, CreatedAt)
users(Id, Login, TenantId, PasswordHash, Status, CreatedAt) -- Login = email пользователя
invites(Code PK, Email, TenantId, Status, ExpiresAt, ActivatedAt, CreatedById, CreatedAt)
global_settings(Key, Value, UpdatedAt)
-- этап 7: оператор/SaaS
token_usage_events(id, tenant_id, at, provider, model, kind, prompt_tokens, completion_tokens, total_tokens, detail_json)
-- этап 12: глобальные настройки сервиса (секреты шифруются)
global_settings(key, value, updated_at)
```
> `token_usage_events` — история расхода токенов (этап 10, T2; подробнее — §13.10).
> `global_settings` — глобальные настройки уровня сервиса; сейчас хранит ключи приложения Telegram
> (`telegramKeys`: `api_id`/`api_hash`, hash — в `enc:`), которые задаёт **оператор** глобально
> (ручки `GET/PUT /api/operator/settings/telegram-keys`); тенант ключи не видит/не задаёт.
> Операторские таблицы этапа 7 (`operators`, `operator_sessions`, `tenant_limits`, `audit_log`) и их
> контур описаны в §13.8. С этапа 12 счётчики распределённого rate-limit и попыток входа —
> `public.rate_limit_counters` (см. §10).
### Ключевые таблицы схемы тенанта (пример)
```
QueueItems, RejectedItems, DedupEntries,
Cards, Containers, CardMoves, LeadComments, MlOutbox,
DiscTasks, DiscCandidates, DiscBlacklist, DiscLog,
Dialogs, TgMessages, settings
```
### Фактическая схема на конец этапа 1 (2026-09-05)
Реализованный фундамент (см. раздел 13 «Быстрый старт»). Списки выше — целевой вид будущих этапов;
ниже — то, что реально создано миграциями этапа 1.
- `public` (системный контекст, миграция `InitialSystem`):
```
tenants(Id uuid PK, Name varchar(200), Status text, CreatedAt timestamptz) -- реестр тенантов
users(Id uuid PK, Login varchar(200) UNIQUE, TenantId uuid → tenants, -- учётные записи
PasswordHash text, Status text default 'active', CreatedAt timestamptz)
sessions(TokenHash varchar(64) PK, UserId uuid → users ON DELETE CASCADE, -- сессии: кука deal_session,
Login varchar(200), ExpiresAt timestamptz, CreatedAt timestamptz) -- срок 30 дней
```
- Схема тенанта `tenant_<id>` (миграция `InitialTenant` применяется на схему):
```
settings(Key varchar(200) PK, ValueJson text, UpdatedAt timestamptz) -- настройки тенанта
```
- История миграций: `public.__EFMigrationsHistory` и `__TenantMigrationsHistory` в схеме тенанта.
- Имена таблиц/колонок — по конвенции EF Core (PascalCase). Схемы тенантов создаются и мигрируются
автоматически (`TenantProvisioningService`); при старте API создаётся дефолтный тенант и admin
(`TenantBootstrapService`).
---
## 5. Сервисы и контракты
### gRPC-контракты (`src/contracts/*.proto`, общий проект `Deal.Proto`)
- `telegram.proto` (пакет `deal.telegram.v1`) — два сервиса: `TelegramService` — команды ядра к
telegram-service (GetStatus, StartPhone, StartQr, SendCode, SendPassword, Logout, RefreshDialogs,
SetMonitor, SetMonitorAll, Backfill, ReadRecent, Search, GetInfo, ReadForEval, Join, Leave);
`SourceIngressService.PushSource` (generic-контракт источников, `sources.proto`) и `IngressService`
(SyncDialogs, ReportStatus) — исходящий поток telegram-service → core;
сервер — gRPC-ингресс core :5082).
- `ml.proto` (пакет `deal.ml.v1`) — `MlService`: Predict (text → {take,label,scores,margin,type}),
Status, Reset, TrainBatch; всё с metadata `tenant-id`.
- `ai.proto` (пакет `deal.ai.v1`) — `AiService`: Filter, Classify, GenerateKeywords, EvaluateFit;
ответ + расход токенов (usage → `TokenUsageRecorder`, §6).
- Полный состав RPC/полей и семантика ошибок — §13.7 и шапки `.proto`.
### Безопасность сервисов
- Каждый RPC несёт metadata `tenant-id` + `service-token`; интерцепторы всех процессов fail-closed
сверяют токен с env `DEAL_SERVICE_TOKEN` (gRPC-health освобождён). Принадлежность
(сессия/модель тенанта) проверяется сервисом по своей модели — полю в теле не доверяем.
- Dev — gRPC plaintext + общий service-token (compose.dev); mTLS — за флагом `DEAL_MTLS_*`
(взаимные сертификаты, цепочка → CA; меняется только транспорт, контракты — нет, Ruling 6 этапа 7).
- telegram-service: сессии привязаны к тенанту (файлы AES-256-GCM, ключ `DEAL_TELEGRAM_SESSION_KEY`);
команда исполняется только на сессии своего tenantId; проверка принадлежности диалога; join
под квотами тенанта; исходящие сообщения помечены tenantId на входе.
---
## 6. Ключевые сквозные механизмы
### Outbox / IEventBus
- Событие и бизнес-эффект пишутся в одной транзакции; фоновый диспетчер доставляет
события подписчикам (в процессе) и/или в сервисы (gRPC).
- Реализация сменная (outbox → Kafka) без правки бизнес-логики.
### Лимиты токенов (ИИ-бюджет; фактически — этап 7, §13.8)
- ai-service возвращает usage; `TokenUsageRecorder` инкрементит `public.tenant_limits` (период
месяц/день, ленивый reset), пишет историю в `public.token_usage_events` (этап 10, T2) и инкрементит
метрики `deal.ai.*`/`deal.ml.*` (§7); кроме того ведётся lifetime-KV `aiTokenUsage`.
- Коллекции `public.token_usage_events` подчищает фоновый `DataRetentionScheduler` (§10);
накопительные поля прошедших периодов `tenant_limits` сбрасываются там же.
- Гейт-декораторы `BudgetedAiClassifier`/`BudgetedAiTools` (только при `Services:Ai:UseLocal=false`):
исчерпание/приостановка → Local-фолбэк (приём не блокируется); SSE-тосты на 80/100% бюджета.
### Файлы
- MinIO (S3): бакет на продукт (`deal-files`); ключ объекта строит `CardsService`
`projects/<card_id>/<file_id>_<unixMs>_<safeName>` (`tenant_<id>/…`-префикса нет).
- Тип файла определяется автоматически (MIME + расширение).
- Доступ к файлу — только через core с проверкой tenantId.
### Напоминания
- Фоновый планировщик (в core): проверка due-напоминаний отложенных карточек;
при срабатывании — уведомление (SSE/тост).
- Если напоминания отключены — запланированные не срабатывают и очищаются.
### SSE
- События фронту (4 типа): `new_card` (полная карточка), `toast` (`{text,icon}`), `reminder_due`
(`{id,title,containerId}`), `system_status` (объект `tg.status()`). `new_lead` переименован в
`new_card`; `pipeline_stats`/`boards_changed`/`leads_reclassified` прототипа не реализованы.
- Поток `GET /api/events` — per-tenant (SseBroker), ping-комментарий каждые 15 с. Публикуют только
эндпоинты/планировщики Api-слоя (модули — чистые).
---
## 7. Наблюдаемость (фактический стек — этапы 7/10, Ruling 7)
- **Serilog.AspNetCore во всех 4 процессах** (core + telegram/ai/ml): консоль в формате JSON
(`CompactJsonFormatter`; в Development — текст) + rolling-файл `data/logs/deal-<процесс>.json`
(30 дней; env `DEAL_LOG_LEVEL`/`DEAL_LOGS_DIR`). Секреты/пароли/ключи не логируются;
gRPC-health не логируется.
- **Access-логи**: HTTP — `HttpAccessLogMiddleware` (первый в конвейере после ForwardedHeaders —
длительность и статус всего пути; с BL-LOG-ACTOR — `actor` и `tenant` из сессии); gRPC-ингресс —
`RpcCallLoggingInterceptor` (health освобождён).
- **PROD-стек логов**: docker-логи контейнеров → `promtail``loki` (retention 7 суток) → `grafana`
(`127.0.0.1:3001` — только оператору по SSH-туннелю). Поднимается профилем `observability`
файла `deploy/compose.prod.yml` (живой подъём — ⚠ Manual):
`docker compose --env-file deploy/.env.prod -f deploy/compose.prod.yml --profile observability up -d`
(нужен `DEAL_GRAFANA_ADMIN_PASSWORD` в `.env.prod`; порты Grafana/Prometheus — только loopback). Остановка —
`docker compose -f deploy/compose.prod.yml --profile observability down`.
- **Провижининг Grafana — как код** (`deploy/observability/grafana/provisioning`, монтируется в
контейнер): `datasources/datasources.yml` — датасорсы Loki (uid `loki`, default), Prometheus
(uid `prometheus`) и Tempo (uid `tempo`); у Loki — `derivedFields` TraceID → Tempo (клик по traceId
в логе открывает трейс), у Tempo — `tracesToLogsV2` → Loki и `serviceMap`/`nodeGraph` по метрикам;
`dashboards/dashboards.yml` — папка `Дейл` из `/var/lib/grafana/dashboards`. Дашборды — файлы
`deploy/observability/grafana/dashboards/*.json`: правки только в репозитории, UI-изменения не
сохраняются (`allowUiUpdates: false`).
- **Метки Promtail** (`deploy/observability/promtail.yml`): `service` (имя compose-сервиса),
`container` (имя контейнера), `stream`; пайплайн дополнительно поднимает метку `level` из
Serilog-поля `@l` (`Information`/`Warning`/`Error`/`Fatal`) — только для deal-процессов по
`service`-селектору, логи прочих контейнеров хоста не парсятся. Запросы Grafana — LogQL, JSON
разбирается на лету: `{service="core"} | json | StatusCode >= 500`.
- **Дашборды** (папка «Дейл», источник — Loki):
- `Deal-Health` — активность логов и строки Error/Fatal по процессам (доступность сервиса);
- `Deal-Auth` — успешные/неудачные входы и выходы (контур тенант/оператор по пути + HTTP-код из
access-лога core) и активация инвайтов (`/api/join`);
- `Deal-Errors` — HTTP 5xx, необработанные исключения (`@x`), Error/Fatal, ошибки gRPC и общая лента;
- `Deal-Rps` — нагрузка HTTP+gRPC (RPS), top-путей/методов и p50/p95 длительности запроса;
- `Deal-Logs` — обзор логов с фильтрами по сервису и уровню, активность по тенантам (AI/ML/Telegram);
- `Deal-Traces` — поиск трейсов (Tempo, TraceQL), спаны по сервисам, переход к логам по traceId;
- `Deal-Resources` — потребление ресурсов контейнерами (cAdvisor) и хостом (node-exporter): CPU/RAM,
свободное место на дисках.
**Актор в логах:** с BL-LOG-ACTOR access-лог включает `actor` (login пользователя/оператора) и
`tenant`; полная лента действий с деталями — `public.audit_log` (append-only) через
`GET /api/operator/audit` / экран «Аудит» оператор-консоли.
- Алерты Prometheus (этап 12) — правила `deploy/observability/prometheus-rules.yml` (см. подраздел
«Метрики»); исчерпание ИИ-бюджета по-прежнему доставляется SSE-тостом тенанту — отдельной
бюджетной метрики в Prometheus нет (метки метрик низкокардинальные, без tenantId/бюджета).
### Метрики (Prometheus + Grafana — этап 12, пакет A)
- **Экспорт из 4 процессов**: OpenTelemetry → экспортёр Prometheus, общая настройка — `Deal.Grpc.Hosting`
(`DealMetricsHosting`) для сервисов и `Deal.Api/Observability/DealMetricsHosting.cs` для ядра.
Инструментация даёт готовые метрики без ручного кода: входящие запросы `http.server.request.duration`
(RPS/латентность/ошибки по route, включая gRPC-вызовы) и исходящие HTTP-клиенты `http.client.*`.
- **Эндпоинт `/metrics`** — на **отдельном HTTP/1.1 Kestrel-эндпоинте :9464** у всех 4 процессов
(gRPC-порты :5101:5103/:5082 слушают только HTTP/2, обычный GET-scrape по ним невозможен). Порт
переопределяется env `METRICS_PORT`; наружу не публикуется (scrape — внутри compose-сети). Формат — Prometheus.
- **Прикладные метрики** (meter `Deal`, `deal.*`; метки низкокардинальные — без tenantId/userId/cardId):
- `deal_ai_calls_total` / `deal_ai_tokens_total{type=prompt|completion}` — вызовы и токены платного ИИ;
- `deal_ml_calls_total` / `deal_ml_tokens_total` — вызовы и оценка токенов локального ML;
- `deal_audit_events_total{event,actor}` — события аудита по типу/актору;
- `deal_pipeline_queue_depth`, `deal_ml_outbox_depth` — суммарные глубины очередей (пайплайн, MlOutbox)
по всем тенантам; `deal_sessions_active` — активные непросроченные сессии пользователей и операторов;
- `deal_ai_budget_used_ratio{tenant}` — доля израсходованного ИИ-бюджета периода (0..1) по тенантам
(осознанное исключение из низкокардинального правила: бюджеты пер-тенантные, алерт должен знать тенанта).
Gauge-значения собирает фоновый `DealMetricsCollector` ядра (каждые 15 с) через существующие
сервисы/хранилища (`PipelineProcessingService.QueueCountsAsync`, `IMlLearningStore.CountOutboxAsync`,
`public.sessions`/`operator_sessions`); инкремент счётчиков токенов/аудита — там же, где пишутся
`token_usage_events` (`TokenUsageRecorder`) и `audit_log` (`AuditService`).
- **Scrape/Prometheus**: сервис `prometheus` (образ `prom/prometheus:v3.5.0`) в профиле `observability`
compose.prod; конфиг `deploy/observability/prometheus.yml` — job `deal` с таргетами
`core/telegram-service/ai-service/ml-service:9464` (target-метка `service`), retention 15 суток
(volume `deal_prometheus_data`). UI — `127.0.0.1:9090` (оператору по SSH-туннелю). В dev тот же сервис
добавлен в `deploy/compose.dev.yml` (профиль `observability`, UI `localhost:9090`).
- **Grafana-провижининг**: `datasources/datasources.yml` — датасорсы Loki (uid `loki`, default) и
Prometheus (uid `prometheus`, `http://prometheus:9090`); дашборд `Deal-Metrics-Overview` (uid `deal-metrics`)
в папке «Дейл»: RPS/p95/5xx по сервисам, токены и вызовы AI/ML, глубины очередей, активные сессии,
события аудита. Правки — файлами в `deploy/observability/grafana/dashboards/*.json`.
- **Правила алертов Prometheus** (`deploy/observability/prometheus-rules.yml`, подключены через
`rule_files` в `prometheus.yml`): сервис недоступен (`up{job="deal"} == 0`), рост 5xx
(`http_response_status_code=~"5.."`), лаг очереди pipeline/ML-outbox (`deal_pipeline_queue_depth`,
`deal_ml_outbox_depth`), пропажа метрик ядра (`absent(deal_sessions_active)`). Замечание: правила
бюджета токенов нет — метрика бюджета в Prometheus отсутствует (см. §6/§10), поэтому алерт не вводится.
- Как поднять/проверить: `docker compose --env-file deploy/.env.prod -f deploy/compose.prod.yml
--profile observability up -d` → Prometheus `/targets` (все UP) → Grafana → папка «Дейл» →
`Deal-Metrics-Overview`/`Deal-Resources`/`Deal-Traces`. Быстрая проверка экспортёра без Grafana:
`curl http://<процесс>:9464/metrics` изнутри сети.
### Трейсы (OpenTelemetry Collector + Tempo)
- **Экспорт из 5 процессов**: OpenTelemetry SDK → OTLP → **OpenTelemetry Collector** (`otel-collector:
4317`) → **Tempo** (`tempo:4317`, хранилище трейсов, retention 7 суток). Настройка — общая в
`Deal.Grpc.Hosting` (`DealTracingHosting`) для telegram/ai/ml/storage и `Deal.Api/Observability/
DealTracingHosting.cs` для ядра. Инструментируется входящий HTTP/gRPC (AspNetCore), исходящие
HTTP-клиенты и gRPC-клиенты (GrpcNetClient) — трейсы сквозные от входа до БД/внешних сервисов.
- **Включение — опт-ин через env** `OTEL_EXPORTER_OTLP_ENDPOINT` (адрес коллектора, напр.
`http://otel-collector:4317`); без него трейсинг выключен. В compose env задан пустым
(`${DEAL_OTEL_ENDPOINT:-}`) — чтобы включить, задайте `DEAL_OTEL_ENDPOINT` в `.env`. Имя сервиса в
трейсах — `OTEL_SERVICE_NAME` (дефолт по процессу: `core`, `telegram-service`, `ai-service`,
`ml-service`, `storage-service`).
- **Корреляция с логами**: Serilog обогащается `TraceId`/`SpanId` из `Activity.Current`
(`TraceContextEnricher`) — в Loki-логе есть `TraceId`, а датасорс Loki `derivedFields` даёт переход
из лога в трейс Tempo (и обратно — `tracesToLogsV2`).
- Сервисы профиля: `otel-collector` (`otel/opentelemetry-collector-contrib:0.160.0`, конфиг
`deploy/observability/otel-collector.yml`) и `tempo` (`grafana/tempo:2.8.1`, конфиг
`deploy/observability/tempo.yml`, volume `deal_tempo_data`). Наружу порты не публикуются (dev — для отладки).
### Ресурсы (cAdvisor + node-exporter)
- **cAdvisor** (`gcr.io/cadvisor/cadvisor:v0.52.1`) — потребление ресурсов **контейнерами**
(CPU/RAM/сеть/диск); **node-exporter** (`prom/node-exporter:v1.9.1`) — ресурсы **хоста** (CPU/RAM/
диски/сеть). Оба scrape'ит Prometheus (jobs `cadvisor`, `node-exporter` в `prometheus.yml`).
- Дашборд `Deal-Resources` (uid `deal-resources`): CPU/RAM контейнеров, CPU/RAM хоста, свободное место
на дисках. Правила алертов по ресурсам — в отдельном файле `prometheus-resource-rules.yml`,
**отключены по умолчанию** (не входят в `rule_files`); пороги — через env `DEAL_ALERT_*` при включении.
---
## 8. Развёртывание (факт: dev-compose + prod-compose, один VPS)
Два compose-стека: `deploy/compose.dev.yml` (разработка/демо) и `deploy/compose.prod.yml` (прод;
единственный наружу — Caddy). Команды/детали — §13.7 (dev-стек этапа 6), §13.8 (этап 7, быстрый
сценарий оператора), §13.9 (бэкапы).
> Примечание: наследие LeadRadar (DuckDB + MinIO + Python-ml) и его прежний корневой `docker-compose.yml`
> вынесены в `archive/leadradar-legacy/` и к стеку Дейла не относятся; актуальные стеки — только
> `deploy/compose.dev.yml` и `deploy/compose.prod.yml`.
### Dev-стек (`deploy/compose.dev.yml`)
| Контейнер | Порт | Назначение |
|---|---|---|
| `deal-postgres` | 5433 | Postgres 16, БД `deal` (host-порт; внутри 5432) |
| `deal-minio` | 9000/9001 | MinIO (вложения; в Local-режиме необязателен) |
| `deal-core` | 5080 / 5082 | Deal.Api: HTTP `/api` + gRPC-ингресс telegram |
| `deal-telegram-service` / `deal-ai-service` / `deal-ml-service` | 5101/5102/5103 | автономные сервисы этапа 6 |
`docker compose -f deploy/compose.dev.yml up -d --build` — весь стек в сквозном gRPC-режиме
(`Services__*__UseLocal=false`); `sh scripts/dev-smoke.sh` — одна команда (подъём → health → login →
`/api/tg/status` → `POST /api/cards` → trash → флашер MlOutbox → `/api/ml/status`; trap → down). Host-режим
(core с хоста, Local-заглушки) — §13.113.6.
### Prod-стек (`deploy/compose.prod.yml`)
- Одна внутренняя сеть; наружу — только **caddy** (80/443): TLS (шапка `deploy/caddy/Caddyfile` —
`tls internal` для dev/интранет, для реального домена заменить на Cloudflare-origin/сертификаты),
статика `src/frontend/dist`, `reverse_proxy /api → core:5080`, security-заголовки (CSP/HSTS — здесь).
- `core` (:5080 http + :5082 gRPC-ингресс), `telegram/ai/ml/storage-service` (mTLS-env, Ruling 6),
`postgres`/`minio` **без host-портов**; healthcheck'и — `grpc_health_probe` (при mTLS — TLS-проба с
PEM `deal-client.crt/.key`)/`pg_isready`.
- Профиль `observability`: `otel-collector`/`tempo` (трейсы), `loki`/`promtail` (логи),
`prometheus`/`cadvisor`/`node-exporter` (метрики и ресурсы), `grafana` (UI); см. §7.
Секреты — только из `.env.prod`
(шаблон `deploy/.env.prod.example`, без дефолтных паролей; отсутствие → fail-fast `:?`).
Rate limiting включён (`RateLimit__Enabled: true`), CORS — явный `Security__AllowedOrigins`
(`DEAL_ALLOWED_ORIGINS`), куки Secure, `ForwardedHeaders` доверяет Caddy (`KnownNetworks`).
- mTLS внутреннего gRPC — флаг `DEAL_MTLS_ENABLED=1` + сертификаты `deploy/certs/`
(`scripts/mtls-certs.sh`); основной HTTP :5080 остаётся http — TLS терминирует Caddy.
### Порядок первого запуска (prod)
1. Установить docker + docker compose на VPS.
2. Скопировать `deploy/.env.prod.example` → `.env.prod`; заполнить секреты: пароли БД/MinIO,
`DEAL_SERVICE_TOKEN`, `DEAL_ENCRYPTION_KEY`, `DEAL_TELEGRAM_SESSION_KEY`, `DEAL_ALLOWED_ORIGINS`
(origin фронта); опционально креды оператора `DEAL_OPERATOR_LOGIN/PASSWORD`, `DEAL_DEFAULT_AI_BUDGET`,
`DEAL_MTLS_*`. Полный список — шапка `.env.prod.example`.
3. Применить системные миграции к БД стека (команда §13.2, строка подключения — прод-БД).
4. `docker compose --env-file deploy/.env.prod -f deploy/compose.prod.yml up -d --build`
(наблюдаемость — добавить `--profile observability`). Старт core: провижининг схем всех тенантов,
bootstrap оператора (Production без env — warning и пропуск, Ruling 1).
5. Проверить: оператор `POST /api/operator/auth/login` → создать тенанта → инвайт → `POST /api/join`
(быстрый сценарий — §13.8); health — `/api/health`, `/api/operator/health`.
6. Авто-проверка: `docker compose --env-file deploy/.env.prod -f deploy/compose.prod.yml config` (rc=0).
Живой подъём PROD-стека — ⚠ Manual (нужен docker).
### Переменные окружения (prod; без дефолтных значений)
```
DEAL_PG_PASSWORD=... MINIO_ROOT_USER=... MINIO_ROOT_PASSWORD=...
DEAL_SERVICE_TOKEN=... DEAL_ENCRYPTION_KEY=... (32 байта base64)
DEAL_TELEGRAM_SESSION_KEY=... (32 байта base64, AES-GCM сессий)
DEAL_ALLOWED_ORIGINS=https://deal.example DEAL_OPERATOR_LOGIN=... DEAL_OPERATOR_PASSWORD=...
DEAL_MTLS_ENABLED=0|1 DEAL_MTLS_CERT_PASSWORD=... DEAL_DEFAULT_AI_BUDGET=...
```
Секреты — только через env/secret-хранилище, не в коде и не в репозитории.
### CI/CD
- Единый прогон: `scripts/ci.sh` (BL-CI, 2026-09-11) — сборка всех 5 решений (`scripts/build.sh`),
тесты всех сервисов (`scripts/test.sh`: core/telegram/ai/ml/storage + `npm run lint:i18n`),
скан уязвимых NuGet-зависимостей (`dotnet list package --vulnerable --include-transitive`),
сборка фронта (`npm ci && npm run build`). Сборка — 0 warnings/0 errors (`TreatWarningsAsErrors`).
- Готовый workflow: `.github/workflows/ci.yml` (setup-dotnet 10 + setup-node 20 → `sh scripts/ci.sh`);
первый прогон в удалённом CI — при публикации репозитория.
- Нагрузочный прогон: `scripts/loadtest/` (bash+curl и k6-вариант; логин admin/admin → контейнеры/карточки;
RPS/avg/p95; см. README рядом).
- Доставка на VPS: сборка образов → `docker compose ... up -d --build`.
- Результат локального прогона `scripts/ci.sh` (2026-09-11): core 1315, telegram 130, ai 52, ml 38,
storage 9 — всё PASS; фронт `lint:i18n`/`build` зелёные. k8s — вне этапа (задел).
---
## 9. Бэкапы и восстановление (факт — scripts/backup.sh, Ruling 8; детали §13.9)
- **Ежедневный бэкап** — `scripts/backup.sh`: (1) Postgres — `pg_dump -Fc` всех схем (public + tenant_*);
(2) MinIO-бакет `deal-files` — `mc mirror`; (3) файловые данные — tar каталогов/томов
(attachments, telegram-сессии AES-GCM, ml-модели); (4) retention 14 копий. Планировщик — вне
контейнера: cron «0 2 * * *»/systemd-примеры — §13.9. Запуск — `bash scripts/backup.sh` (из корня).
- **Восстановление** — `scripts/restore.sh` (pg → minio → data; pg-шаг пересоздаёт БД целиком,
minio/data — overlay): остановить сервисы → `bash scripts/restore.sh [TS|pg|minio|data]` → поднять.
Порядок и требования — §13.9.
- Рекомендация Ruling 8: раз в месяц — тест восстановления на отдельном инстансе/томах.
- Потеря данных при ежедневном бэкапе допустима ≤ 24 ч (SLA тестового этапа).
- Реальный прогон `backup.sh` и restore-тест — ⚠ Manual (нужен docker-стек; здесь — `sh -n`,
error-path-проверки, offline-проверка retention).
---
## 10. Безопасность (эксплуатационная сводка — фактическая, этап 7)
- **Rate limiting** (Ruling 5): секция `RateLimit` (`Enabled=false` — код-дефолт/dev/тесты, `true`
в PROD). Политики: `auth` — 10/мин на IP для `/api/auth/login` и `/api/operator/auth/login`;
`api` — 600/мин на тенанта/IP; интерцептор gRPC-ингресса :5082 — 600/мин/тенанта (health
освобождён); ответ 429 `{detail}`. С этапа 12 лимитер **store-backed**: состояние счётчиков — в
`public.rate_limit_counters` (атомарный upsert), т.е. общее для всех инстансов core. **Попытки
входа** — `LoginAttemptGuard` на том же хранилище (окно ip|login: 5 неудач за 15 мин → 429 «Слишком
много попыток входа…»; успех сбрасывает счётчик; `Enabled=false` — no-op).
- **Origin-проверка мутаций** — `OriginGuardMiddleware`: не-GET/HEAD/OPTIONS `/api` с заголовком
Origin обязаны иметь Origin = «свой» origin (схема + Host) либо из `Security:AllowedOrigins`;
несовпадение → 403. CORS — явный allowlist; SameSite=Lax httpOnly-кук — первый рубеж CSRF.
- **Прокси-заголовки** — `UseForwardedHeaders` (X-Forwarded-For/X-Forwarded-Proto, один доверенный
hop) только за Caddy: `ForwardedHeaders:Enabled=true` + KnownProxies/KnownNetworks (пустые списки
не допускаются — loopback-фолбэк, fail-fast на невалидных значениях). Без этого за Caddy audit-IP
(Ruling 4) и rate-limit-по-IP схлопываются в бакет прокси.
- **Security-заголовки**: core — `SecurityHeadersMiddleware` (X-Content-Type-Options: nosniff,
X-Frame-Options: DENY, Referrer-Policy: no-referrer); CSP/HSTS — на Caddy (фронт-статика; CSP для
Vue требует настройки nonce — документируется в шапке Caddyfile). В PROD куки Secure=true.
- **mTLS** — за флагом `DEAL_MTLS_*` только для внутреннего gRPC (серверный сертификат + обязательный
клиентский, цепочка → CA из `DEAL_MTLS_CA_PEM`); основной HTTP :5080 остаётся http — TLS
терминирует Caddy. Живое рукопожатие — ⚠ Manual.
- **Приостановка тенанта** (Ruling 10(5)): вход — **403** «Учётная запись приостановлена…» (не 401:
неверные учётные данные не раскрывают статус); ИИ-расход заморожен бюджетным гейтом. С этапа 12
активные сессии приостановленного тенанта **разлогиниваются сразу**: `AuthService.ResolveSessionAsync`
проверяет статус тенанта (включая impersonation) и отказывает в сессии. Impersonation оператором
suspended-тенанта разрешена (полностью аудируется; ИИ всё равно заморожен).
- **Аудит** — append-only `public.audit_log`: пишет только `AuditService` (без Update/Delete),
секреты не попадают; чтение — только оператор (`GET /api/operator/audit`). С этапа 12 retention
180 дней обеспечивает фоновый `DataRetentionScheduler` (раз в сутки; секция `DataRetention`),
там же — сброс накопительных полей `tenant_limits` прошедших периодов и уборка окон счётчиков
`rate_limit_counters`.
- Криптография/код: пароли Argon2id; секреты настроек AES-256-GCM (`enc:`, ключ
`DEAL_ENCRYPTION_KEY`); сессии Telegram AES-256-GCM (`DEAL_TELEGRAM_SESSION_KEY`); SQL
параметризуется; секреты в логи/аудит не пишутся.
- **Hardening контейнеров (BL-IMG-HARDEN, 2026-09-11):** прикладные образы (core/telegram/ai/ml/storage)
работают non-root (пользователь `deal`, UID 10001) с `HOME=/tmp`; в compose заданы `read_only: true`,
`tmpfs: /tmp`, `security_opt: no-new-privileges`, `cap_drop: ALL` и лимиты `mem_limit`/`cpus` (якорь
`x-service-hardening`). Данные — в именованных volume (`/app/data` core, `/data/sessions` telegram,
`/data/ml` ml); логи stateless-сервисов — `DEAL_LOGS_DIR=/tmp/logs` (tmpfs), у core — volume
`/app/data/logs`. Проверено `docker compose config` (dev и prod, включая профиль observability);
живой подъём с этими ограничениями — ⚠ Manual.
- **Вне этапа (не настроено; заделы §11/roadmap):** Cloudflare (конфигурация вне кода — шапка
Caddyfile), k8s, биллинг, UI админок, саморегистрация.
---
## 11. Известные ограничения и TODO
**Выполнено на этапе 1 (2026-09-05):**
- доступ и сессии: `POST /api/auth/login`, `POST /api/auth/logout`, `GET /api/auth/me`,
`POST /api/auth/change-password`; httpOnly-кука `deal_session` (30 дней);
- мультитенантность и миграции: системный контекст (`public`: `tenants`/`users`/`sessions`, миграция
`InitialSystem`), схемы `tenant_<id>` с настройками тенанта (`settings`, миграция `InitialTenant`
на схему), автоматический провижининг схем и bootstrap дефолтного тенанта + admin при старте API.
**Выполнено на этапе 2 (2026-09-06) — модуль Settings (экран «Настройки» обслуживается бэкендом):**
- дерево настроек тенанта 1:1 с прототипом: `GET/PATCH /api/settings` (дефолты модуля, перекрытые
переопределениями в таблице `settings` тенанта; PATCH мягкий — невалидные поля пропускаются,
ответ — полный снимок; секреты наружу только масками `keyMasked`/`apiId`; внутренние ключи
`ratesCache`/`mlDecisions`/`aiDecisions` не публикуются);
- шифрование секретов AI/Telegram: AES-256-GCM, в БД — `enc:` + Base64 (ключ — env/file, см. §13.4a);
- проверка подключения ИИ: `POST /api/ai/check` (локальный провайдер / HTTP-проверка облачного);
- курсы валют: `GET /api/rates`, `POST /api/rates/refresh` (кэш `ratesCache` в settings; `mock`/ЦБ);
- ML-панель на детерминированной заглушке: `GET /api/ml/status`, `POST /api/ml/reset|predict`
(candidates → `{items:[]}`, apply → 404 — нет telegram-данных до этапа 6);
- тестер фильтров: `POST /api/admin/check-message` (этап-1 правила из настроек: длина/стоп-фразы/
резюме/тип; ИИ-фильтр тестера на этапе 2 всегда skipped);
- 175 unit-тестов PASS; интеграционная приёмка — curl-сценарий на :5080 + psql.
**Выполнено на этапе 3 (2026-09-06) — модуль Kanban (дашборд/канбан), см. §13.4c:**
- миграция `TenantKanban` — таблицы схемы тенанта `Boards`, `Cards`, `LeadComments`, `CardMoves`,
`MlOutbox` (PascalCase-конвенция; колонки карточки по ТЗ §5: `Title`/`Summary`/`StackJson`/
`BudgetFrom`/`BudgetTo`/`BudgetCur`/`ConvFrom`/`ConvTo`/`ConvCur`/`ContactsJson`/`ChannelName`/…/
`ReceivedAt`/`SourceMsg`/`SourceDialogId`/`PrevCol`/`MatchHitsJson`/`ArchivedAt`);
- модуль `Deal.Modules.Kanban`: `BoardsService`/`CardsService` (доски, переносы, архив/корзина,
комментарии, counts, поиск), правила колонок `ColumnRules` (matchHits — «почему карточка в колонке»),
`StorageTickService` + фоновый `StorageTickScheduler` (цикл 30 с, автоархив по `archiveAfterDays`),
пересчёт конверсий `ConversionRecomputer` (listener на смену курсов/`targetCurrency`), демо-фабрика,
эвристика ИИ-предложений `SuggestHeuristics`;
- эндпоинты: `/api/boards` (+reorder/PATCH/DELETE), `/api/columns/state`, `/api/leads` (+counts/
{id}/move/trash/restore/DELETE/clear-col/mark-col-seen/mark-all-seen/comments/reclassify-заглушка),
`GET /api/search?q=`, `GET /api/events` (SSE), `/api/admin/tick` + `/api/admin/fts/rebuild`
(заглушка {ok,ready}), демо `POST /api/demo/simulate-lead|age-lead` (флаг `DEAL_DEMO`),
`POST /api/ai/suggest-columns|keywords`, boot-заглушки `GET /api/projects` и `GET /api/tg/status`;
- SSE-события: `new_lead` (карточка) и `toast` (текст+иконка) — публикуют только эндпоинты;
потоки per-tenant (SseBroker), ping каждые 15 с;
- демо-режим: `DEAL_DEMO=1` (Development включает и без env) — simulate из демо-пула 1:1 с прототипом,
age-lead состаривает карточку досок и тикает автоархив; без флага — 404 «Демо-режим отключён»;
- ML-контракт `IMlClient.PushAsync` + локальная детерминированная реализация `LocalMlClient`
(MlOutbox/learning; реальный сервис — этап 6);
- **410 unit-тестов PASS**; сквозная приёмка этапа — curl-сценарий на :5080 + psql (Task 15:
PASS=94 FAIL=0: boot-группы, демо-карточки ×14 + SSE new_lead/toast, доски/правила/matchHits,
move/trash/restore/комментарий, mark-col-seen, поиск, suggest-columns/keywords, age-lead + автоархив
фоновым циклом, admin/tick, пересчёт конверсий 9250 RUB / 100 USD / 92.59 EUR).
**Выполнено на этапе 4 (2026-09-06) — модуль Pipeline (вкладка «Обработка»), см. §13.4d:**
- миграция `TenantPipeline` — таблицы схемы тенанта `QueueItems` (очередь `p_`, статус new/filtered),
`RejectedItems` (отсев `r_<dialog>_<msgId>`, аудит возврата returned/returnReason) и `DedupEntries`
(нормализованный SHA1-хэш, мягкая ссылка `LeadId` на карточку, чистится при жёстком удалении);
в той же миграции — FTS-колонки `Cards.SearchTsv`/`RejectedItems.SearchTsv` (russian tsvector STORED + GIN);
- модуль `Deal.Modules.Pipeline` (чистый, без EF/HTTP): ядро разбора `MessageTextCleaner`/
`MessageListNormalizer`/`ContactsQualifier`/`DedupHasher`/`SummaryComposer`/`LocalFieldsParser`
(1:1 pipeline.py), приём `PipelineIngestService` (гвард dialog+msgId), обработка/возврат/очистки
`PipelineProcessingService`, pump `PipelineWorkerService.PumpOnceAsync` 1:1 с `_pump_unlocked`
(устарело → правила → дедуп → ML → ИИ → карточка; счётчики wire 1:1), `CardComposer` +
`PipelineCardWriter` (карточка через публичный `IKanjStore.AddCardAsync` + связь дедупа);
- порт `IAiClassifier` + детерминированный `LocalAiClassifier` (до реального ai-service этапа 6),
ML-слой — существующий `IMlClient` (локальная модель не готова — все сообщения к ИИ-ветке);
- эндпоинты: `GET /api/pipeline/stats|queue|rejected` (+`q` FTS LIKE), `POST /rejected/clear`,
`DELETE /rejected/{id}`, `POST /rejected/{id}/return` (400 dup/повтор/нет текста; снятие веса
«спама» у ML), демо `POST /api/demo/ingest` (флаг `DEAL_DEMO`), реальные `POST /api/admin/tick`
(storage+purgedRejected+pipeline+queue+SSE new_lead/тосты) и `POST /api/admin/fts/rebuild`;
- фоновые циклы: `PipelineWorkerScheduler` (pump 2 с, общий гейт с ручным тиком) и автоочистка отсева
(3 суток) в `StorageTickScheduler` (30 с) + SSE-тост «Отсев очищен: N записей (3 дн.)»;
- FTS-поиск карточек `GET /api/search?q=` (tsvector + LIKE, ts_rank, лимит 12, `messages:[]`);
- **535 unit-тестов PASS**; build 0 warnings / 0 errors; сквозная приёмка этапа — curl-сценарии на
:5080 + psql (Task 13 — финал: PASS=74 FAIL=0: нули при старте, ingest → карточка с полями §4.1,
отсевы правил/dup/нет суммы/устарело на реальных записях, очередь, stats, psql-строки и dedup-связь,
поиск отсева FTS (морфология «работой») и LIKE (имя канала), return dup → 400, return → очередь →
карточка, повторный return → 400, DELETE, clear, поиск карточек, fts/rebuild, удаление карточки
чистит DedupEntries, purge 3 дн. → SSE-тост, logout → 401).
**Выполнено на этапе 5 (2026-09-07) — модуль Projects («Выбранные»), см. §13.4e:**
> Историческое состояние: с этапа 9 модуль `Deal.Modules.Projects` и таблица `ProjectCards` упразднены
> (сервисы «Выбранных» перешли в `Deal.Modules.Kanban`/`CardsService.Selected`, данные — в `Cards`);
> ниже — как было на этапе 5.
- миграция `TenantProjects` — таблица схемы тенанта `ProjectCards` (PascalCase; partial UNIQUE
`IX_ProjectCards_LeadId` по `LeadId` — «лид можно взять в работу один раз»); владелец — чистый модуль
`Deal.Modules.Projects` (без EF/HTTP; зависимости — Contracts/Settings/Kanban-порты, реверса нет);
- стадии `ProjectStages` 1:1 с PIPELINE_STAGES (planned → reply → work → hold → ready, терминальные
finished/rejected), DTO карточки §4.3; история движения — в `HistoryJson` (создание `created`/
`createdLocal` + каждая смена стадии, Ruling 7), комментарии/ссылки/файлы — JSON-поля карточки;
- сервисы: `ProjectsService` (список/чтение/ручное создание/take/патч presence-aware (`budget:null`)/
move+история/clear-rejected/комментарии/ссылки), `ProjectFilesService` (детект типа `FileKindDetector`
MIME+расширение → порт `IFileStorage` → мета в карточку), `ProjectReminderService` (set/clear/snooze/
фоновая проверка due); порт `IFileStorage` + адаптеры `LocalFileStorage` (дефолт: `data/attachments` под
ContentRoot) и `MinioFileStorage` (секция `Storage:Minio`/env `DEAL_MINIO_*`, compose-сервис
`deal-minio` :9000/:9001, бакет `deal-files` лениво);
- эндпоинты: 16 шт. `/api/projects*` — список/создание/take/clear-rejected/GET/PATCH/move/comments/links
(add/remove)/files (upload/download/delete)/reminder (set/clear/snooze); boot-заглушка `GET /api/projects`
**снята** (остался `/api/tg/status` — этап 6); `GET /api/projects/reminders` и `DELETE /api/projects/{id}`
сознательно не реализованы (Ruling 9);
- напоминания: настройка `remindersEnabled` (дефолт true; выключено → set 400); фоновый
`StorageTickScheduler` (30 с) и ручной `POST /api/admin/tick` (`reminders:[{id,title,stage}]`) помечают
due-строки hold `ReminderFired=true` и публикуют SSE `reminder_due {id,title,stage}` (публикации — только
Api, Ruling 8); move с hold снимает напоминание; snooze = +24 ч;
- **620 unit-тестов PASS**; build 0 warnings / 0 errors; сквозная приёмка этапа — curl-сценарии на :5080 +
psql (Task 13 — финал: PASS=75 FAIL=0: take-семантика (col=taken/is_new=false, исчезновение из /leads и
/api/search, идемпотентность, partial-UNIQUE дубля), PATCH полей и `budget:null`, move по стадиям с
историей, комментарии/ссылки, напоминание hold → SSE `reminder_due` фоновым циклом БЕЗ ручного tick +
psql ReminderFired, файлы upload/download(байты)/delete + объекты на диске, clear-rejected, сортировка
UpdatedAt DESC, logout → 401).
**Выполнено на этапе 6 (2026-09-07) — сервисы telegram/ai/ml + Discovery + каналы, см. §13.7:**
- контракты `src/contracts/*.proto` (общий проект `Deal.Proto`, Grpc.Tools); каждый RPC — metadata
`tenant-id`+`service-token`, интерцепторы fail-closed (health освобождён); dev-безопасность — общий
service-token без mTLS (Ruling 2); mTLS и prod-compose — этап 7;
- три автономных процесса в `src/{telegram,ml,ai}-service` (свои sln, net10.0): telegram-service (:5101,
ферма сессий 1 акк/тенант, AES-256-GCM-файлы `/data/sessions`, фазы idle/code/password/qr/ready,
диалоги/мониторинг/backfill с анти-бан-паузами, канал в core `SERVICES__CORE__INGRESS`), ai-service
(:5102, LLM-фасад OpenAI-совместимых+Anthropic без БД: Filter/Classify/GenerateKeywords/EvaluateFit,
usage-токенов), ml-service (:5103, инкрементальный наивный Байес 1:1 `mlservice/model.py`, SQLite на
тенанта `/data/ml/<tenant>.sqlite`, пул per-tenant);
- core: gRPC-ингресс telegram :5082 (`PushMessage`→очередь/превью, `SyncDialogs`, `ReportStatus`→SSE),
модуль Telegram (Dialogs/TgMessages, `ITelegramGateway`+GrpcTelegramClient за флагом), эндпоинты /api/tg
(14 шт., реальный статус вместо boot-заглушки, QR-SVG), GrpcAiClassifier/GrpcAiTools (контекст/промпты/
маппер/usage), GrpcMlClient + MlOutboxFlushScheduler (10 с, TrainBatch 10/≤100), модуль Discovery
(DiscTasks/Candidates/Blacklist/Log, план-бюджет, воркер 5 с с каскадом оценки и авто-join под бан-гардом,
эндпоинты /api/discovery 13 шт., generate-keywords);
- флаги `Services:{Ml,Ai,Telegram}:UseLocal` — код-дефолт Local (true), compose.dev.yml задаёт false
(полный стек «по-настоящему»); сервисы ходят в core-ингресс через `SERVICES__CORE__INGRESS`;
- **830 unit-тестов PASS** (Deal.Tests.Unit), build 0 warnings / 0 errors всех четырёх sln; приёмки:
curl Task 14 (/api/tg) PASS=20 FAIL=0, Task 19 (/api/discovery) PASS=37 FAIL=0, in-proc gRPC-тесты
(PushMessage→карточка, флашер, ai-фильтр/классификация/инструменты).
**Выполнено на этапе 7 (2026-09-08) — SaaS-контур, Tasks 116 (финал), см. §13.8/§13.9:**
- оператор/сессии (`public.Operators/OperatorSessions`, кука `deal_operator_session`, bootstrap env
DEAL_OPERATOR_*; dev-only дефолт operator/operator) + ручки `/api/operator/*` (auth/tenants/invites/
limits/audit/health — API-only); инвайты и активация `POST /api/join`; лимиты ИИ-бюджета
(`tenant_limits`, декораторы-гейт, SSE-тосты 80/100%) с дефолт-бюджетом; append-only аудит-поток;
rate limiting (приложение + интерцептор gRPC-ингресса, `LoginAttemptGuard`); Origin-проверка мутаций и
security-заголовки; mTLS за флагом DEAL_MTLS_* (сертификаты scripts/mtls-certs.sh); Serilog JSON во всех
4 процессах (консоль + rolling-файл data/logs, access-логи HTTP/gRPC); compose.prod (caddy, mTLS-env,
профиль observability: promtail/loki 7 сут./grafana 127.0.0.1:3001) + .env.prod.example.
- **Бэкапы (Ruling 8, Task 15)** — `scripts/backup.sh` (pg_dump -Fc БД deal: docker exec deal-postgres
или прямой pg_dump при DEAL_PG_HOST; mc mirror бакета MinIO `deal-files` — хостовый mc или разовый
контейнер minio/mc; tar файловых данных DEAL_TAR_DIRS: attachments/telegram_sessions/ml — либо
docker-volume'ы через DEAL_TAR_VOLUMES; retention 14 дней по дате в имени; лог + trap-очистка) и
`scripts/restore.sh` (dropdb+createdb → pg_restore, обратный mc mirror, распаковка архивов).
Команды/порядок/cron-пример «0 2 * * *» — §13.9. Реальный прогон и restore-тест — ⚠ Manual (нужен docker-стек).
- **Финальный прогон (Task 16)**: 1123 unit-теста PASS в core (Deal.Tests.Unit), telegram 114/114,
ai 50/50, ml 36/36 PASS; build 0 warnings / 0 errors всех четырёх sln; `docker compose
-f deploy/compose.prod.yml config` rc=0 (+ профиль observability); `sh -n` dev-smoke/backup/restore/
mtls-certs rc=0. Живые приёмки (curl-сценарий SaaS, подъём стека, бэкап/restore, mTLS, реальные
сервисы) — ⚠ Manual, чек-лист в task-16-report.md.
**Выполнено на этапе 9 (2026-09-10) — «единая карточка» (см. `docs/architecture/2026-09-09-unified-card.md`, `docs/architecture/2026-09-10-unified-api-contract.md`):**
- **Модель**: карточка — один агрегат во всех дашбордах. Ядро (`ICard`: id/title/source) + опциональные
модули-роли (`IContentCard`/`IBudgetedCard`/`IContactCard`/`IAttributedCard`/`ICommentableCard`/
`ILinkCard`/`IFileCard`/`ITzCard`/`ITraceableCard`/`IRemindableCard`/`ILocatedCard`); источник —
иерархия `ISource` (`ITelegramSource`/`IRowSource`/`IApiSource`/`IAiSource`/`ICompositeSource` и простые);
единый переход `ICardMover`. Вид карточки — композиция модулей, а не класс-наследник (`Deal.Modules.Cards`).
- **БД**: одна таблица `Cards` — `ProjectCards` упразднена; единый реестр `Containers` вместо таблицы
`Boards` и колонок-строк. Модульные данные — колонки той же строки (`StackJson`/`LinksJson`/`FilesJson`/
`HistoryJson`/`TzText`/`ReminderAt`), комментарии — `LeadComments`; `CardMoves`, `MlOutbox`,
`DedupEntries`, `QueueItems`, `RejectedItems` — без изменений. Полнотекстовые `SearchTsv` — у `Cards` и `Containers`.
- **Контейнеры**: поля `space` (`dashboard`/`selected`), `kind` (`board`/`stage`/`service`/`terminal`),
`rules`, `policy`, `counts`. Стадии «Выбранных» — контейнеры `kind=stage/terminal` каталога
`CardsDefaultContainers` (`planned`…`finished`/`rejected`); служебные зоны — `inbox`/`archive`/`trash`.
Карточка живёт в одном пространстве; «взять в работу» — перенос карточки в `planned`, а не клон.
- **API**: единый контракт `/api/cards` + `/api/containers`; ручки `/api/leads`, `/api/projects`,
`/api/boards`, `/api/columns` удалены; SSE `new_card` вместо `new_lead`. Единый префикс id — `c_`.
Полная карта — `docs/api/api-map.md`.
- **Фронт**: один слайс карточек (`src/frontend/src/store/cards.js`) и единый канбан для дашборда и
«Выбранных» (пространство определяется контейнером карточки).
Остаётся TODO (после этапа 7, Tasks 116):
- Живые проверки (⚠ Manual, нужен docker/креды): применение system-миграции `SystemSaaS` и сквозная
SaaS-curl-приёмка (оператор → тенант → инвайт → /api/join → лимиты/гейт → аудит → suspend → resume →
IDOR-негативы); подъём compose.prod.yml и dev-smoke `sh scripts/dev-smoke.sh`; mTLS-рукопожатие
контейнеров; реальные Telegram/LLM-вызовы (с кредами); прогон `scripts/backup.sh` и restore-тест
(`scripts/restore.sh`).
- Заделы (сознательно вне этапа 7; часть закрыта этапами 8–12): UI операторской админки и страницы активации
инвайта (сейчас API-only); OTel-метрики/Prometheus и дашборды метрик (закрыто этапом 12, пакет A — §7);
multi-instance rate-limit и бэкенд попыток входа (закрыто этапом 12 — `public.rate_limit_counters`);
мгновенный разлогин suspended-сессий (закрыто этапом 12); реклассификация «Неразобранного» на реальном
ИИ (закрыто этапом 12 — reclassify с локальным фолбэком); purge-автоматика audit_log и auto-purge
истории tenant_limits (закрыто этапом 12 — `DataRetentionScheduler`); экспорт/импорт ML-моделей;
мультиаккаунтность Telegram на тенанта; саморегистрация/биллинг-провайдер/планы; k8s/Cloudflare-конфигурация.
- Карта `/api` — `docs/api/api-map.md` + контракты `docs/architecture/2026-09-10-unified-api-contract.md`
и `docs/architecture/2026-09-10-operator-analytics-contract.md` (актуальны на этап 12).
- Пакетная миграция схем тенантов (сотни/тысячи) — реализована на этапе 12 (`POST
/api/operator/maintenance/tenants/migrate`, §13.10/§16; см. также §4/§7); с BL-SCALE-1000 (2026-09-11)
обход шардирован страницами (`ITenantRepository.ListPageAsync`, `DefaultPageSize=200`) с параллелизмом
внутри страницы и изоляцией сбоев.
- Kafka — отложена.
---
## 12. Глоссарий
См. дизайн-док (§Приложение). Дополнительно:
- **search_path** — механизм Postgres выбора текущей схемы.
- **outbox** — таблица событий в той же транзакции, что и бизнес-изменение.
- **карточка (card)** — единая сущность всех дашбордов (ядро + модули); id с префиксом `c_`.
- **контейнер (container)** — колонка/стадия/зона единого реестра; `space` + `kind` + `rules`/`policy`.
- **пространство (space)** — `dashboard` или `selected`; карточка живёт ровно в одном.
---
## 13. Быстрый старт (dev; актуально для этапов 0–12 — финальное состояние)
> Для этапов 0–7 ниже приведены исторические списки эндпоинтов (в т.ч. `/api/leads`, `/api/projects`,
> `/api/boards`). С этапа 9 (2026-09-10) актуальны единые `/api/cards` и `/api/containers` — см.
> `docs/api/api-map.md` и `docs/architecture/2026-09-10-unified-api-contract.md`.
Проверенный путь (2026-09-07, Windows + sh, .NET 10, Postgres 16 в Docker): системный контекст
(`public`), контекст тенанта (схема с `settings` + таблицами канбана, пайплайна и «Выбранных»), auth `/api/auth`, настройки
тенанта (Settings-модуль этапа 2), канбан этапа 3 (`/api/boards`, `/api/leads`, `/api/events` SSE,
демо `/api/demo/*`), пайплайн этапа 4 (вкладка «Обработка» `/api/pipeline/*`, демо-ingest, воркер 2 с,
FTS `/api/search` + `/api/pipeline/rejected?q=`, реальные `/api/admin/tick` и `/api/admin/fts/rebuild`),
«Выбранные» этапа 5 (вкладка Projects: `/api/projects` — стадии/напоминания/файлы/ссылки/история, файлы
через порт `IFileStorage` — Local `data/attachments` по умолчанию или MinIO `deal-minio` при конфигурации,
SSE `reminder_due` фоновым 30-с циклом),
провижининг схем и bootstrap дефолтного тенанта с admin при старте API. Логин/пароль по умолчанию —
`admin`/`admin` (env `DEAL_BOOTSTRAP_LOGIN`/`DEAL_BOOTSTRAP_PASSWORD`).
Разделы 1–6 ниже — «классический» host-путь этапов 1–5: core запускается с хоста на Local-заглушках
(код-дефолт `Services:*:UseLocal=true`), сервисы этапа 6 не нужны. Полный dev-стек этапа 6 (три сервиса +
core в docker, сквозной gRPC-режим) — §13.7.
### 1. Postgres
```sh
# хранилища для host-режима (core с хоста); весь стек (сервисы этапа 6 + core) — §13.7
docker compose -f deploy/compose.dev.yml up -d postgres minio
```
Полный стек поднимается той же командой без аргументов (`... up -d --build`): postgres + minio +
telegram/ai/ml-сервисы + core в сквозном gRPC-режиме (`Services__*__UseLocal=false` заданы в compose), см. §13.7.
Контейнер `deal-postgres`: наружный порт **5433**, БД `deal`, пользователь `deal`
(пароль `deal_dev_password`). Тот же compose-файл поднимает **`deal-minio`** (MinIO для вложений этапа 5):
порты **9000** (S3 API) / **9001** (консоль), бакет `deal-files` создаётся лениво при первом upload.
Dev-режим по умолчанию работает БЕЗ MinIO — `LocalFileStorage` (каталог `data/attachments` под ContentRoot
Deal.Api); MinIO-режим включается секцией `Storage:Minio` или env-алиасами `DEAL_MINIO_ENDPOINT`/
`DEAL_MINIO_ACCESS_KEY`/`DEAL_MINIO_SECRET_KEY`/`DEAL_MINIO_BUCKET`/`DEAL_MINIO_SECURE` (см. §4e).
### 2. Системные миграции (`public`)
Из `src/core`:
```sh
dotnet ef database update --project Deal.Infrastructure --startup-project Deal.Api --context DealDbContext
```
Применяет `InitialSystem` — публичные таблицы `tenants`, `users`, `sessions`
(история — `public.__EFMigrationsHistory`). Строка подключения — `ConnectionStrings:DealPostgres`
(`Deal.Api/appsettings.Development.json`; перекрывается env `ConnectionStrings__DealPostgres`).
### 3. Запуск API
Из `src/core`:
```sh
dotnet run --project Deal.Api --urls http://localhost:5080
```
При старте `TenantBootstrapService` (идемпотентно) создаёт дефолтного тенанта
`00000000-0000-0000-0000-000000000001` (имя `Default`) с его схемой
`tenant_00000000000000000000000000000001` и таблицей `settings` (миграция `InitialTenant`), а также
пользователя `admin` — логин/пароль из env `DEAL_BOOTSTRAP_LOGIN` / `DEAL_BOOTSTRAP_PASSWORD`,
по умолчанию `admin` / `admin`. Схемы провижинируются для всех тенантов реестра; повторные старты
дублей не создают.
### 4. Проверка auth
```sh
curl -i -X POST http://localhost:5080/api/auth/login \
-H "Content-Type: application/json" \
-d '{"login":"admin","password":"admin"}'
```
→ `{"ok":true,"login":"admin"}` (HTTP 200) и httpOnly-кука `deal_session` (SameSite=Lax, **30 дней**;
срок — константа `AuthService.SessionLifetimeDays`, перекрывается `Cookies__Days`).
Прочие эндпоинты: `GET /api/auth/me`, `POST /api/auth/logout`, `POST /api/auth/change-password`;
health — `GET /api/health` → `{"ok":true,"service":"deal"}`.
### 4a. Шифрование секретов настроек (ключи AI/Telegram)
Секреты (`aiConfigs[].apiKey`) хранятся в `settings.ValueJson` шифротекстом:
`enc:` + Base64(nonce‖ct‖tag), AES-256-GCM (nonce 12 Б, tag 16 Б). Ключ шифрования — env
`DEAL_ENCRYPTION_KEY` (32 байта в urlsafe-Base64); при отсутствии в dev берётся/создаётся файл
`<ContentRoot>/data/encryption.key` (путь переопределяется env `DEAL_ENCRYPTION_KEY_FILE`) —
при генерации лог-warning. Невалидный env-ключ — ошибка при старте. Наружу секреты не отдаются:
в GET/PATCH `/api/settings` только маски `keyMasked` (первые 4 + «…» + последние 4, len≤8 — как есть)
и `keySet`.
> Исторический раздел (этап 2). С этапа 12 ключей Telegram (`tgKeys`/`apiId`/`apiHash`) в настройках
> тенанта нет — они задаются **оператором** глобально (таблица `public.global_settings`,
> `GET/PUT /api/operator/settings/telegram-keys`; hash шифруется тем же AES-256-GCM).
### 4b. Эндпоинты этапа 2 (настройки тенанта; сессия `deal_session` обязательна, иначе 401)
- `GET /api/settings` — публичный снимок дерева настроек: дефолты модуля, перекрытые
переопределениями из `settings` тенанта; включает списки `providers`/`aiConfigs`/`tgKeys`/`myPrompts`.
`PATCH /api/settings` — частичное обновление (невалидное поле мягко пропускается, ответ — полный
снимок). Побочные эффекты: при `rateSource` — фоновый refresh курсов. Внутренние ключи
(`ratesCache`, `mlDecisions`, `aiDecisions`) в GET/PATCH не участвуют.
- `POST /api/ai/check` — проверка подключения активного провайдера (`aiProvider` + `aiConfigs`, ключ
расшифровывается): локальный провайдер → `ok:true` «Локальный сервер…»; облачный — HTTP `GET
{base}/models`; без ключа → «Не задан API-ключ».
- `GET /api/rates` / `POST /api/rates/refresh` — курсы к RUB (`base` = `RUB`); источник по `rateSource`
(`mock` — константа, `cbr` — ЦБ РФ, ≤4 запроса/сутки, интервал 6 ч; `USDT`=`USD`); кэш — внутренняя
настройка `ratesCache` `{rates, source, updatedAtMs}`.
- `GET /api/ml/status`, `POST /api/ml/reset|predict` — ML-панель на детерминированной заглушке
`LocalMlClient` (этап 6 заменит на gRPC без правки эндпоинтов): `ready:false`, predict неготовой
модели — «не уверен», `candidates` → `{items:[]}`, `apply` → 404 (telegram-данных нет до этапа 6).
- `POST /api/admin/check-message` — тестер фильтров входящих: `{stage1:{pass,reason}, stage2:{pass,
reason, skipped}, passed}`; этап-1 правила из настроек (длина/стоп-фразы/резюме/тип); ИИ-фильтр
тестера на этапе 2 всегда `skipped:true`.
> **Актуально с 2026-09-11:** тестер стал сухим прогоном по всему конвейеру
> (`PipelineWorkerService.DryRunAsync`): `{passed, wouldCreateCard, targetContainer, matchHits, parsed,
> stages[]}` — стоп-правила → глобальные исключения → ML (спам/тип) → ИИ-фильтр/классификация → «без
> суммы»; без записи в систему. UI — вкладка настроек «Стоп-слова».
### 4c. Эндпоинты этапа 3 (канбан/дашборд; сессия `deal_session` обязательна, иначе 401)
> На этапе 4 `POST /api/admin/tick` стал реальным (pipeline/pump/purge-отсева) и
> `POST /api/admin/fts/rebuild` — реальным `{ok, ready}` (см. §4d); описание ниже — состояние этапа 3.
>
> На этапе 5 `GET /api/projects` — реальный список «Выбранных» (см. §4e); boot-заглушка `/projects`
> снята, из boot-заглушек остался только `GET /api/tg/status` (telegram — этап 6).
- Доски: `GET/POST /api/boards` (голый массив / создание), `PATCH /api/boards/{id}` (name/width/
collapsed/keywords/rules/suggested…), `POST /api/boards/reorder`, `DELETE /api/boards/{id}`
(карточки → inbox). Правила колонки — `{mode: all|any, direction[], keywords[], stack[], grade[],
exclude[], budget}`; совпавшие термины попадают в `matchHits` карточки (1:1 с rules.py).
- Карточки: `GET /api/leads?col=inbox|<boardId>|archive|trash` (свежие сверху, полный §4.1),
`GET /api/leads/counts` (плоская форма `{new, learning, ml, ai}` + per-column `{count, new}`),
`GET /api/leads/{id}`, `POST /leads/{id}/move|trash|restore`, `DELETE /api/leads/{id}`,
`POST /api/leads/clear-col` (trash|archive), `mark-col-seen|mark-all-seen`, `POST /leads/{id}/comments`.
Поиск: `GET /api/search?q=` (lower-LIKE по title/summary/contact/source_msg → `{leads, messages:[]}`).
- Служебные: `POST /api/admin/tick` → `{storage:{archived,purgedArchive,purgedTrash,purgedRejected},
reminders:[], pipeline:{}, queue:0}` (+SSE-тосты статистики); `POST /api/admin/fts/rebuild` —
заглушка `{ok:true, ready:true}` (FTS-индекс — этап 4).
- Boot-заглушки фронта: `GET /api/tg/status` → idle-форма §4.9 (telegram — этап 6; заглушка
`GET /api/projects` → `{items:[]}` снята на этапе 5 — реальный список см. §4e).
- SSE: `GET /api/events` — text/event-stream канала тенанта; события `new_lead` (полная карточка,
после simulate) и `toast` `{text, icon}` (демо-лид sparkles, автоархив/тик clock, ИИ-предложения
sparkles); ping `: ping` каждые 15 с. Публикуют только эндпоинты Api (модуль чист).
- Демо-режим (флаг `DEAL_DEMO=1`; Development включает и без env): `POST /api/demo/simulate-lead`
(карточка из демо-пула 1:1 с прототипом → inbox + SSE new_lead/toast), `POST /api/demo/age-lead`
(состаривание самой старой карточки досок + тик автоархива + SSE-toast). Без флага — 404
«Демо-режим отключён».
- ИИ-предложения (эвристика этапа 3, реальный ИИ — этап 6): `POST /api/ai/suggest-columns`
(накопите ≥6 карточек в «Неразобранном» → доски `suggested:true` с note «Эвристика (этап 3):…» и
раскладкой карточек; повторный вызов — cooldown 20 мин `lastSuggestAt`), `POST /api/ai/suggest-keywords`
(частотные маркеры по текстам).
- Конверсии (Ruling 7): курсы — `POST /api/rates/refresh` (mock/ЦБ), кэш `ratesCache` в settings;
пересчёт `ConvFrom/ConvTo/ConvCur` активных карточек (не archive/trash/taken) выполняется
синхронно по listener'ам: после refresh курсов и при `PATCH /api/settings {targetCurrency,…}`.
### 4d. Эндпоинты этапа 4 (pipeline/вкладка «Обработка»; сессия обязательна, иначе 401)
Таблицы этапа — миграция `TenantPipeline` в схеме тенанта (владелец — модуль `Deal.Modules.Pipeline`):
`QueueItems` (очередь, id `p_…`, статус `new`|`filtered`), `RejectedItems` (отсев, детерминированный id
`r_<dialog>_<msgId>` либо `r_`+hex; колонка `SearchTsv` — `to_tsvector('russian', text)` STORED + GIN) и
`DedupEntries` (SHA1-хэш нормализованного текста, PK; `LeadId` — мягкая ссылка на `Cards`, чистится при
жёстком удалении карточки). В той же миграции — FTS-колонка `Cards.SearchTsv` (Title+Summary+SourceMsg+
Contact) + GIN-индекс; tsvector-колонки авто-актуальны (перестроение не требуется).
- Приём сообщений (этап 4 — только демо; этап 6 — gRPC telegram-service): `POST /api/demo/ingest`
`{text, dialogId?, channelName?, channelHandle?, channelHue?, msgId?, msgAt?}` (флаг `DEAL_DEMO=1`, иначе
404 «Демо-режим отключён») → `{ok, id:p_…, queue:{new,ai,total}}`; пустой текст — 400 «Текст сообщения
пуст»; повтор `dialogId+msgId` уже в очереди — `id:null` (гвард Telethon-дублей); нет dialogId — no-op.
- Разбор очереди: фоновый `PipelineWorkerScheduler` каждые **2 с** (per-tenant pump, общий гейт с ручным
тиком) и `POST /api/admin/tick`. Конвейер 1:1 с прототипом: «устарело» (msgAt старше `archiveAfterDays`
при `autoArchive`) → правила этапа-1 (`IncomingRules`: длина/стоп-фразы/резюме/тип) → дедуп по тексту →
ML-слот (`IMlClient`, локальная модель не готова — «не уверен») → ИИ-слот (`LocalAiClassifier` до этапа 6;
`aiEnabled=false` — локальный разбор) → карточка/отсев. Счётчики решений pump — в ответе тика и KV
`mlDecisions`/`aiDecisions`.
- Карточка из сообщения (CardComposer, через публичный `IKanjStore.AddCardAsync`): title, блок «О заявке»
(summary), stack ≤12, бюджет (нормализованный + конверсия в целевую валюту при поступлении), контакты
(квалификация, ≤6, primary), поля канала `ch`, `sourceMsg`/`sourceDialogId`/`sourceMsgId`; колонка —
inbox либо доска по `BoardAccepts`+правилам; после успешной классификации карточка получает
`isVacancy`/`isVacancyKnown`; создание карточки связывает хэш в `DedupEntries` с её id.
> **Актуально с 2026-09-11: единый контракт источника.** Карточка, очередь и отсев работают с generic-типом
> `SourceItem` (`SourceRef` + `SourceContent`). В таблицах `Cards`/`QueueItems`/`RejectedItems` вместо
> Telegram-колонок (`ChannelName`/`SourceMsg`/`SourceMsgId`…) хранятся `SourceKind`/`SourceExternalId`/
> `SourceOriginRef`/`SourceJson`/`ContentJson` (карточка — ещё `SourceText` и FTS по нему; очередь/отсев —
> `SourceKey` для дедупа). Вложение — `DataRef` (ссылка на общий Storage-сервис); контакты/ссылки — поля
> `SourceContent`. Telegram-поля остались только в тонком адаптере приёма telegram-сервиса. Tenant-миграции
> пересозданы с нуля (данных нет). Детали — `docs/superpowers/specs/2026-09-11-source-contract-design.md`.
- Отсев — источник решения `stop|ml|ai|stale|dup` (подписи «правила/ML/ИИ/система») и этап
`length|stop|resume|type|budget|stale|spam_ml|spam_ai|filter_ai|dup` (подписи UI: «короткое сообщение»,
«стоп-фраза», «резюме соискателя», «нет суммы», «устарело», «повтор»…), причина ≤500, kw — сработавшая
фраза. Возврат (`force`) повторно проводит сообщение мимо правил/устарелости/ИИ-фильтра и снимает у ML
вес «спама» для spam-отсева.
- Вкладка «Обработка» (фронт на поллинге; SSE `pipeline_stats` не публикуем — Ruling 9):
`GET /api/pipeline/stats` → `{queue:{new,ai,total}, rejected}`; `GET /api/pipeline/queue?limit=`
(≤500, дефолт 100) → `{items, counts:{new,ai,total}, rejected}`; `GET /api/pipeline/rejected?q=&offset=&limit=`
→ `{items,total,offset,limit}`; `q` — FTS-кандидаты (`SearchTsv @@ plainto_tsquery('russian')`, ts_rank)
LIKE-дополнение по `lower(text)/reason/kw/ch_name` (страница из объединения, offset/limit ≤500).
- Возврат и очистки: `POST /api/pipeline/rejected/{rejId}/return {reason}` → `{id, returned:true, returnedAt}`;
404 «Запись не найдена»; 400 «Сообщение уже возвращено в обработку» / «Повтор: карточка с таким текстом
уже есть в системе — возвращать нечего» (источник dup) / «В записи нет текста сообщения». `DELETE
/api/pipeline/rejected/{rejId}` → `{ok:true}` (404 не шлём); `POST /api/pipeline/rejected/clear` →
`{ok, cleared}`. Автоочистка отсева — **3 суток** (`RejectedAt`), выполняется в тике правил хранения
(ручной tick и фоновый `StorageTickScheduler` 30 с), при ненулевой очистке — SSE-тост
«Отсев очищен: N записей (3 дн.)».
- `POST /api/admin/tick` (этап 4): ответ `{storage:{archived, purgedArchive, purgedTrash, purgedRejected},
reminders:[], pipeline:{staged, rulesStored, mlStored, mlDrop, typeDrop, aiStored, aiDrop, aiFail,
noBudget}, queue}`; после pump — SSE `new_lead` по созданным карточкам и тосты статистики. `POST
/api/admin/fts/rebuild` → `{ok:true, ready:true}` (идемпотентно: `CREATE INDEX IF NOT EXISTS` + `ANALYZE`
`Cards`/`RejectedItems`).
- Поиск карточек `GET /api/search?q=` (q ≥ 2): один SQL — `SearchTsv @@ plainto_tsquery('russian', q)` OR
`lower(title/summary/source_msg/contact) LIKE '%q%'`, порядок `ts_rank DESC, ReceivedAt DESC`, лимит 12;
ответ `{leads, messages:[]}` — русская морфология (например, q=работа находит «работой»).
### 4e. Эндпоинты этапа 5 (Projects/«Выбранные»; сессия `deal_session` обязательна, иначе 401)
> Исторический раздел (как было на этапе 5). С этапа 9 все перечисленные операции живут под
> `/api/cards*`, модуль `Deal.Modules.Projects` и таблица `ProjectCards` упразднены — актуальный контракт
> см. §3.5/§5 и `docs/api/api-map.md`.
Таблица этапа — миграция `TenantProjects` в схеме тенанта (владелец — чистый модуль
`Deal.Modules.Projects`): `ProjectCards` — 1:1 с таблицей `projects` прототипа. Колонки (PascalCase):
`Id` (`pr_…`), `Stage` (каталог `ProjectStages` 1:1 с PIPELINE_STAGES: planned → reply → work → hold →
ready, терминальные finished/rejected), `Local`, `LeadId` (**partial UNIQUE** `IX_ProjectCards_LeadId` —
лид может быть взят в работу ровно один раз), `Title`, `Summary`, `StackJson`, `BudgetFrom`/`BudgetTo`/
`BudgetCur`, `Contact`, `TzText`, JSON-поля `CommentsJson`/`LinksJson`/`FilesJson`/`HistoryJson`
(история — только создание `created`/`createdLocal` и смены стадии, Ruling 7) и напоминание
`ReminderAt` (timestamptz)/`ReminderFired`; времена наружу — epoch-ms, список — `UpdatedAt DESC`.
- Взять в работу: `POST /api/projects/take {leadId}` → проектная карточка `local=false`, `stage=planned`,
`leadId`+`title/summary/stack/budget/contact` скопированы из лида, комментарий «Взял в работу из лида.»,
история `created`. Лид помечается `col='taken', is_new=false` через публичный порт Kanban
(`IKanjStore.MarkTakenAsync`) — исчезает из `/api/leads` и `/api/search`, не попадает в архив/корзину
тика; повторный `take` идемпотентен (возвращает ту же карточку), partial-UNIQUE `LeadId` страхует гонки.
- Карточки: `GET /api/projects` (`?stage=` фильтр) → `{items:[…]}` (UpdatedAt DESC), `POST /api/projects`
(ручное создание `{title,…,stage?}`; стадия — каталог, по умолчанию planned; `local=true`,
`createdLocal`), `GET/PATCH /api/projects/{cardId}` (PATCH presence-aware: `budget:null`/`stack:null`
очищают поле; 404 «Карточка не найдена»), `POST /api/projects/{cardId}/move {stage}` (валидация
каталогом: 400 «Неизвестная стадия»; смена стадии дописывает историю `{id h_, at, stage}` и сбрасывает
напоминание), `POST /api/projects/clear-rejected` → `{ok, cleared}` (единственный hard-delete — стадия
«Отклонено»).
- Комментарии и ссылки: `POST /{cardId}/comments {text}` (400 «Пустой комментарий»; ответ `{comments}`),
`POST /{cardId}/links {url,name?}` (схема добавляется: example.com → https://example.com; name = url по
умолчанию), `DELETE /{cardId}/links/{linkId}`. Значки-счётчики — из массивов карточки §4.3.
- Напоминания «Отложено»: `POST/DELETE /{cardId}/reminder {at}` (прошлое допустимо — «выстрелит» на
ближайшей проверке; включённость — настройка `remindersEnabled`, дефолт true; при выключенной set → 400
«Напоминания об отложенных выключены в настройках») и `POST /{cardId}/reminder/snooze` (now + 24 ч).
Срабатывание: фоновый `StorageTickScheduler` каждые **30 с** (и ручной `POST /api/admin/tick` →
`reminders:[{id,title,stage}]`) вызывает `ProjectReminderService.CheckDueAsync` — due-строки
`stage='hold'` помечаются `ReminderFired=true` и публикуются SSE-событием `reminder_due {id,title,stage}`
в канал тенанта (баннер фронта; публикации — только из Api, Ruling 8). Любой move с hold снимает
напоминание (`ReminderAt`/`ReminderFired` очищаются).
- Файлы: `POST /{cardId}/files` (multipart, поле `files`, 1N) → `{items:[{id pf_, name, size, kind, label,
objectKey}]}`; тип — `FileKindDetector` по MIME+расширению (`document`/«Документ», `image`/«Изображение»
и т.д.); объект кладётся через порт `IFileStorage` (Contracts/Integrations): `LocalFileStorage`
(дефолт, корень `data/attachments`, key → путь `projects/<cardId>/<ms>_<name>`) или `MinioFileStorage`
(включается секцией `Storage:Minio`/env `DEAL_MINIO_*`; бакет `deal-files` создаётся лениво; compose -
сервис `deal-minio` :9000/:9001). `GET /{cardId}/files/{fileId}/download` — `attachment`
(Content-Length/Type из дескриптора; локально MIME пуст → `application/octet-stream`, 1:1 прототип),
`DELETE /{cardId}/files/{fileId}` → `{ok:true}` (мета + объект).
- Сознательно НЕ реализованы (Ruling 9, api-map п.9/п.6): `GET /api/projects/reminders` (список
активных напоминаний — у фронта UI нет) и `DELETE /api/projects/{id}` (удаление проектной карточки
отключено; hard-delete — только clear-rejected). Всего 16 эндпоинтов `/api/projects*`.
- Демо: `DEAL_DEMO=1` включает демо-эндпоинты (simulate/ingest) этапов 3–4; сам контур «Выбранных»
работает без флага (сессии + `admin/admin`).
### 5. Проверка схем (psql)
```sh
docker exec deal-postgres psql -U deal -d deal -c '\dn'
docker exec deal-postgres psql -U deal -d deal -c '\dt public.*'
docker exec deal-postgres psql -U deal -d deal -c '\dt tenant_*.*'
```
Ожидается: схемы `public` и `tenant_00000000000000000000000000000001`; в `public` — `tenants`, `users`,
`sessions`, `invites`, `operators`, `operator_sessions`, `tenant_limits`, `audit_log`,
`token_usage_events`, `global_settings`, `rate_limit_counters`, `__EFMigrationsHistory`; в схеме тенанта —
`settings`, `Cards`, `Containers`, `LeadComments`, `CardMoves`, `MlOutbox`, `QueueItems`, `RejectedItems`,
`DedupEntries`, `Dialogs`, `TgMessages`, `DiscTasks`/`DiscCandidates`/`DiscBlacklist`/`DiscLog`
и `__TenantMigrationsHistory`.
Ключевые колонки `Cards` (PascalCase): `Id`, `Col`, `IsNew`, `Title`, `Summary`, `StackJson`, `BudgetCur`,
`ConvCur`, `ReceivedAt`, `PrevCol`, `MatchHitsJson`, `ArchivedAt`, `SearchTsv` (tsvector STORED);
`RejectedItems` — `Stage`/`Reason`/`Kw`/`Source`/`Returned`/`SearchTsv`; `DedupEntries` — `Hash`
(PK)/`LeadId`. Правила контейнеров — в `Containers.RulesJson`, состояние
колонок — в `settings` (ключ `colState`).
### 6. Тесты и сборка (из корня репозитория)
```sh
sh scripts/build.sh # сборка всех 5 решений (0 warnings / 0 errors)
sh scripts/test.sh # тесты всех сервисов + lint:i18n (core 1315, telegram 130, ai 52, ml 38, storage 9)
sh scripts/ci.sh # полный CI-прогон: build + test + скан уязвимостей + сборка фронта
```
Сквозные приёмки этапов — curl-сценарии на :5080 в `.superpowers/sdd/deal-stage{4,5}-projects/`
(task-N-curl-acceptance.sh/.log): этап 4 — финальный Task 13 PASS=74 FAIL=0; этап 5 — финальный Task 13
PASS=75 FAIL=0 (карточки `ProjectCards`, лид `col=taken`, файлы на диске `data/attachments`,
SSE `reminder_due` фоновым циклом БЕЗ ручного tick).
Каждая из четырёх sln собирается 0 warnings / 0 errors (`TreatWarningsAsErrors`): `src/core/Deal.sln`,
`src/telegram-service/Deal.Telegram.sln`, `src/ml-service/Deal.Ml.sln`, `src/ai-service/Deal.Ai.sln`.
Приёмки этапа 6 (`.superpowers/sdd/deal-stage6-services/`): in-proc gRPC-тесты (ингресс PushMessage→
карточка, флашер MlOutbox, ai-фильтр/классификация/инструменты) + curl-сценарии Task 14 (/api/tg:
PASS=20 FAIL=0) и Task 19 (/api/discovery: PASS=37 FAIL=0).
Финальный прогон этапа 7 (Task 16, docker выключен): core 1123/1123 PASS, telegram 114/114, ai 50/50,
ml 36/36 PASS; build 0/0 всех четырёх sln; `docker compose -f deploy/compose.prod.yml config` rc=0;
`sh -n` scripts/dev-smoke.sh/backup.sh/restore.sh/mtls-certs.sh rc=0. Живые приёмки (SaaS-curl-сценарий
этапа 7, подъём compose.dev/prod, бэкап/restore, mTLS, реальные сервисы) — ⚠ Manual, чек-лист —
`.superpowers/sdd/deal-stage7-saas/task-16-report.md`.
### 7. Этап 6 — автономные сервисы telegram/ai/ml + Discovery + каналы (полный dev-стек)
Реализация — `src/telegram-service`, `src/ml-service`, `src/ai-service` (отдельные sln/процессы, .NET 10,
общий код — только `.proto` через `src/contracts/Deal.Proto.csproj`, Task 1); core остаётся единственным
владельцем БД и бизнес-логики (сервисы не знают домен и не ходят в tenant-БД). Контракты, сервисы и
интеграция — план этапа 6 (`.superpowers/sdd/deal-stage6-services/`), Rulings 113.
#### Порты и процессы (`deploy/compose.dev.yml`)
| Контейнер | Порт | Назначение |
|---|---|---|
| `deal-postgres` | **5433** | БД (host-порт; внутри — 5432) |
| `deal-minio` | **9000/9001** | S3-API / консоль (файлы вложений; в Local-режиме необязателен) |
| `deal-core` (Deal.Api) | HTTP **5080**, gRPC-ингресс **5082** | портал `/api` + приём PushSource/SyncDialogs/ReportStatus |
| `deal-telegram-service` | **5101** | Telegram: сессии/QR/диалоги/мониторинг/backfill/discovery-операции |
| `deal-ai-service` | **5102** | LLM-фасад: Filter/Classify/GenerateKeywords/EvaluateFit |
| `deal-ml-service` | **5103** | инкрементальная модель per-tenant: predict/train/status/reset |
Порт каждого сервиса — env `GRPC_PORT` (контейнерный 5101/5102/5103), порт ингресса core — env
`GRPC_INGRESS_PORT` (5082). Health-проверки контейнеров — встроенный gRPC-health (`grpc_health_probe`
в образе, `/bin/grpc_health_probe`), Deal-RPC health не трогают.
#### gRPC-контракты и безопасность (Rulings 1/2/13)
- `src/contracts/{telegram,ai,ml}.proto` — пакеты `deal.telegram.v1`/`deal.ai.v1`/`deal.ml.v1`
(csharp_namespace `Deal.Grpc.Telegram/Ai/Ml`); общий проект кодогенерации `Deal.Proto`
(`Grpc.Tools`, client+server в одном проходе; каждый процесс собирает свою sln вместе с ним).
- Каждый RPC несёт metadata `tenant-id` + `service-token`; серверный интерцептор каждого процесса
fail-closed сверяет токен с env `DEAL_SERVICE_TOKEN` (единый для всех процессов в compose; отказ —
`UNAUTHENTICATED`; `grpc.health.v1.Health` освобождён). Принадлежность (сессия/модель тенанта)
проверяется сервисом по своей модели — полю не доверяется. Ошибки домена — `INVALID_ARGUMENT`/
`NOT_FOUND`/`UNAVAILABLE`/`RESOURCE_EXHAUSTED` (flood) с текстом 1:1.
- Dev — gRPC plaintext без mTLS (Ruling 2); mTLS-сертификаты, их генерация и prod-compose — этап 7.
#### Флаги интеграций core (Ruling 6)
- Код-дефолт — `Services:{Ml,Ai,Telegram}:UseLocal=true` (`appsettings.json`): Local-адаптеры
(`LocalMlClient`, `LocalAiClassifier`/`LocalAiTools`, `LocalTelegramGateway`) — core работает без сервисов
(host-путь §13.113.6, этапы 25).
- `deploy/compose.dev.yml` задаёт для core `Services__{Ml,Ai,Telegram}__UseLocal: "false"` + эндпоинты
`http://ml-service:5103` / `http://ai-service:5102` / `http://telegram-service:5101` — полный стек
«по-настоящему». Выбор реализации — на старте (рантайм-переключения нет); фолбэки: недоступность
ml/ai — локальные пути воркеров (ml predict — «не уверен», ai — локальный разбор), telegram — idle-форма
эндпоинтов.
#### Env (compose.dev.yml; dev-дефолты `${VAR:-…}`, перекрываются `.env`/экспортом)
- Общие: `DEAL_SERVICE_TOKEN` (единый service-token core+сервисов), `DEAL_ENCRYPTION_KEY` (32 байта base64,
AES-GCM секретов настроек core; fail-closed), creds БД/минио ниже.
- core: `ConnectionStrings__DealPostgres` (host `postgres`, порт 5432 внутри compose), `Storage__Minio__*`
(или env-алиасы `DEAL_MINIO_*`), `Services__*__{UseLocal,Endpoint}`, `GRPC_INGRESS_PORT=5082`,
`ASPNETCORE_URLS=http://0.0.0.0:5080`.
- telegram-service: `GRPC_PORT`, `DEAL_SERVICE_TOKEN`, `DEAL_TELEGRAM_SESSION_KEY` (32 байта base64,
**обязателен** — fail-closed: сессии только шифрованные AES-256-GCM, файлы `/data/sessions` на volume
`deal_tg_sessions`), `DEAL_TELEGRAM_SESSION_DIR=/data/sessions`, `SERVICES__CORE__INGRESS`
(`http://core:5082` в compose; для core с хоста — `DEAL_CORE_INGRESS=http://host.docker.internal:5082`).
- ml-service: `GRPC_PORT`, `DEAL_ML_DATA_DIR=/data/ml` (volume `deal_ml_data`; SQLite-файлы моделей
`/data/ml/<tenantId>.sqlite`).
- ai-service: `GRPC_PORT` (stateless — промпты/конфиг провайдера приходят в теле запроса, volume не нужен).
#### Полный стек и smoke-проверка
```sh
cd /c/telbase
docker compose -f deploy/compose.dev.yml up -d --build # весь стек (первый прогон собирает 4 образа)
sh scripts/dev-smoke.sh # сквозной smoke и авто-очистка (trap → down)
docker compose -f deploy/compose.dev.yml down # погасить стек вручную (volumes сохраняются)
```
`scripts/dev-smoke.sh`: подъём стека → health всех контейнеров → login admin/admin → `GET /api/tg/status`
(idle-форма через GrpcTelegramClient) → `GET /api/containers?space=dashboard` → `POST /api/cards`
(локальная карточка в `planned`) → `POST /api/cards/{id}/trash`
(сигнал spam → строка MlOutbox) → ожидание флашера `MlOutboxFlushScheduler` (TrainBatch в ml-service) →
`GET /api/ml/status`: `reachable:true`, `stats.outbox:0`, класс `spam` в модели. Скрипт ничего не оставляет
в фоне (trap EXIT → `docker compose down`, временные файлы удаляются).
#### Каналы-вкладка `/api/tg` (модуль Telegram; Rulings 3/7/8)
- Таблицы схемы тенанта (миграция `TenantTelegram`): `Dialogs` (каталог каналов: Name/Handle/Kind/Hue,
Monitor, LastText/LastAt, Backfilled) и `TgMessages` (превью сообщений, `LeadId` nullable) — владелец
чистый модуль `Deal.Modules.Telegram` (`ITelegramStore` + `DialogsService`, порт-гейт
`ITelegramGateway` 16 команд).
- gRPC-ингресс core (`Deal.Api/Telegram/TelegramIngressService`, :5082): `PushMessage` →
`PipelineIngestService.EnqueueAsync` (тот же контракт, что demo-ingest) + превью в `TgMessages`;
`SyncDialogs` → синхронизация каталога/мониторинга; `ReportStatus` → KV `tgStatus`/`tgAccount` + SSE
`system_status`/тосты переходов.
> **Актуально с 2026-09-11:** приём записей вынесен из `TelegramIngressService` в generic
> `Deal.Api/Sources/SourceIngressGrpcService` (`sources.proto` → `PushSource`, proto → домен через
> `SourceProtoMapper`, тенант — `IngressTenantResolver`, превью — `TelegramSourceIngestObserver`).
> `TelegramIngressService` обслуживает только `SyncDialogs`/`ReportStatus`.
- Эндпоинты 1:1 api-map §3.3 (14 шт.): статус (§4.9 — live-поля фазы, `account` из KV, `monitored` из
`count(Dialogs WHERE Monitor)`, `keysSet`), start-phone/start-qr/send-code/send-password/logout,
QR-image (SVG, Net.Codecrete.QrCodeGenerator; 404 «QR не активен — начните вход по QR»), dialogs/refresh/
monitor-all/backfill-all/{id}/monitor/{id}/backfill (сервер-only)/preview. Boot-заглушка `GET /api/tg/status`
снята (Task 14). telegram-service реализует команды (сессии по тенантам 1:1, фазы idle|code|password|qr|
ready, auto_resume+heartbeat, backfill с паузами 1.5–3 с/сообщение, discovery-операции).
#### Discovery (Rulings 911)
- Таблицы схемы тенанта (миграция `TenantDiscovery`): `DiscTasks`/`DiscCandidates`/`DiscBlacklist`/
`DiscLog` (+json-колонки marks/topics/keywords); владелец — чистый модуль `Deal.Modules.Discovery`
(сервисы задач/кандидатов/чёрного списка/лога, `DiscoveryPlanGuard` — план ≤ `discJoinLimit`, бюджет
активных задач).
- Фоновый `DiscoveryWorkerScheduler` (5 с, per-tenant, одно действие за тик): план достигнут → done;
поиск следующего ключа через gateway (`Search`); оценка `new`-кандидата каскадом (info → выборка →
язык/число сообщений → ML-спам (если mlEnabled) → ИИ `EvaluateFit` (если aiEnabled) → эвристика по
ключам; форумы — по темам); авто-вступление `review` при autoJoin с паузами и квотами (50–70 с,
лимит авто-вступлений/сутки по `DiscLog`, стоп-кран `discFloodDay`/`discPaused`), join_failures ≥3 →
удаление задачи. Внешний анти-бан — владение core; внутренние паузы сервиса — telegram-service.
- Эндпоинты 1:1 api-map §3.8 (13 шт.): tasks CRUD+start/pause+generate-keywords (мягкая ошибка
`{keywords:[],error}` HTTP 200), candidates по статусам, join/reject (ручные, вне квот), blacklist, log.
#### ML-модель ml-service (Ruling 4)
- Порт python `mlservice/model.py` 1:1: инкрементальный наивный Байес по терминам (`OnlineNaiveBayes`,
tokenize/upsert/predict/adaptive margin/самооценка eval), НЕ ONNX/ML.NET. Пороги: `MIN_TOTAL 20`,
`MIN_WINNER 6`, `MIN_WINNER_SPAM 4`, `MIN_HITS 2`, `MARGIN 0.9`; адаптивный отрыв 0.35/0.5/0.7 после
400/150/60 примеров; классы `t:hire`/`t:order` (`MIN_TYPE_WINNER 4`).
- Хранилище — SQLite на тенанта (`/data/ml/<tenantId>.sqlite`, таблицы classes/terms/eval_log, запись
транзакциями); пул `ConcurrentDictionary<tenantId, TenantModel>` с lazy-load и lock на модель. Веса
сигналов обучения 1.0 (пользователь) / 0.4 (ИИ) / 0.6 (правила).
- Core: `PushAsync` ВСЕГДА пишет в `MlOutbox`; фоновый `MlOutboxFlushScheduler` (10 с, только при
`UseLocal=false`) выгружает батчами по 10 (≤100/цикл) через RPC `TrainBatch`, строки удаляются после
успеха; недоступность сервиса — строки остаются. `Reset` = Reset RPC + `ClearOutboxAsync`. Кэш статуса
15 с → `reachable` в `/api/ml/status`.
#### AI-фасад ai-service (Ruling 5)
- Без БД: core передаёт в теле запроса заполненные промпты (подстановка `{domain}`/`{keywords}`), конфиг
провайдера (id/base/model/apiKey/api_style — расшифрованный из `aiConfigs`) и текст. Методы: `Filter`
→ {pass,reason}; `Classify` → {ok,json} (json-строку маппит core в `AiParsedLeadDto`, строгий маппинг);
`GenerateKeywords` → {keywords}; `EvaluateFit` → {fit,reason} (Discovery).
- Транспорт: OpenAI-совместимые `POST {base}/chat/completions` (Bearer) и Anthropic
`POST {base}/v1/messages` (x-api-key); temperature 0.2, таймауты 90/60 с, retry max_retries=2 (паузы
0.8/2 с), извлечение JSON из markdown. Ошибки провайдера наружу — `UNAVAILABLE` («ИИ (имя) не ответил
корректно — повторите попытку через несколько секунд»); учёт токенов `usage` (оценка ≈chars/4 при
отсутствии) → KV `aiTokenUsage` (лимиты/бюджеты — этап 7). Без ключа LLM сервис недоступен — воркер
ядра падает в локальные пути (фолбэк по замыслу).
#### Ручные проверки этапа 6 (нужны креды)
- Telegram-вход: ключи приложения (`api_id`/`api_hash`) задаёт **оператор** глобально
(`PUT /api/operator/settings/telegram-keys`, hash шифруется) → `POST /api/tg/start-qr` → QR-скан →
фаза `ready` («Telegram подключён, сессия сохранена»), затем реальные диалоги/мониторинг/«Перечитать»/
discovery-поиск и вступления. В настройках тенанта ключей нет (решение владельца, вариант A).
- LLM: `PATCH /api/settings` `aiConfigs`/`aiProvider` (напр. DeepSeek или локальный OpenAI-совместимый) →
`POST /api/ai/check`; реальная классификация/фильтр/генерация ключей при `Services__Ai__UseLocal=false`.
- Сквозной smoke стека — `scripts/dev-smoke.sh` (одна команда; Docker Desktop должен быть поднят).
### 8. Этап 7 — SaaS-контур (Tasks 114; бэкапы — §13.9; финальные доки — Task 16): оператор/инвайты/лимиты/аудит/rate-limit/mTLS/логи/compose-prod
Кратко (детали — планы `docs/superpowers/plans/2026-09-05-deal-stage7-saas.md` Rulings 111 и отчёты
`.superpowers/sdd/deal-stage7-saas/task-*-report.md`; api-map — раздел «Реализовано в Deal» (Task 16);
живые проверки — ⚠ Manual, чек-лист task-16-report.md):
- **Оператор** (`public.operators`/`operator_sessions`, кука `deal_operator_session`, срок 12 ч): bootstrap из env
`DEAL_OPERATOR_LOGIN`/`DEAL_OPERATOR_PASSWORD` (Development без env — `operator`/`operator`; Production без env —
warning и пропуск). Ручки — `/api/operator/auth/*` (login/logout/me); отдельный `OperatorSessionMiddleware` —
тенантные ручки операторских сессий не видят и наоборот (401/403).
- **Инвайты/регистрация**: оператор создаёт инвайт (код 16 симв., срок 72 ч, email-unique; список/отзыв —
`/api/operator/invites`), пользователь активирует публичной ручкой **`POST /api/join`** `{code, email, name?, password}`
— создание пользователя (Argon2id) и, для инвайта «на новый тенант», тенанта с провижинингом схемы.
- **Лимиты ИИ-бюджета** (`public.tenant_limits`; период месяц/день, ленивый reset): списание — `TokenUsageRecorder`
(успешные RPC ai-service), гейт-декораторы `BudgetedAiClassifier`/`BudgetedAiTools` (исчерпание/`suspended` →
Local-фолбэк), SSE-тосты 80/100% (`BudgetAlertScheduler`, 60 с). Дефолт-бюджет нового тенанта — env
`DEAL_DEFAULT_AI_BUDGET` (константа 10 000 000 токенов/месяц).
- **Операторские ручки** `/api/operator/*`: тенанты (список/создание/статус/impersonation), лимиты (просмотр/смена
бюджета + usage), аудит (append-only `public.audit_log`), health (core/БД/ml/ai/telegram). С этапа 10 у них есть
UI — оператор-консоль и страница активации инвайта (см. §13.10).
- **Rate limiting** (Ruling 5): секция `RateLimit`, `Enabled=false` в dev/тестах; PROD включает env из compose.prod:
политики api/auth (600/10 в минуту на тенанта/IP), интерцептор gRPC-ингресса :5082 (600/мин/тенанта, health
освобождён), `LoginAttemptGuard` (5 неудач/15 мин → 429). Ответ 429 — `{detail}`.
- **mTLS** (Ruling 6): env `DEAL_MTLS_*` (`Enabled=false` default) — Kestrel внутренних gRPC-эндпоинтов
(+ ингресс core) и исходящие каналы core/telegram-service. Сертификаты — `scripts/mtls-certs.sh` →
`deploy/certs/` (PFX процессов, общий `deal-client.pfx` + PEM `deal-client.crt/.key` для grpc_health_probe).
Живое рукопожатие — ⚠ Manual.
- **Логи/наблюдаемость** (Ruling 7; метрики — этап 12, пакет A): Serilog.AspNetCore во **всех 4 процессах** — консоль JSON
(CompactJsonFormatter; в Development — текст) + rolling-файл `data/logs/deal-<процесс>.json` (30 дней; env
`DEAL_LOG_LEVEL`/`DEAL_LOGS_DIR`). Access-логи: HTTP (HttpAccessLogMiddleware) и gRPC
(RpcCallLoggingInterceptor; gRPC-health не логируется). **Метрики** — OTel → Prometheus: `/metrics`
(HTTP/1.1 :9464) + прикладные `deal.*` (токены/вызовы AI/ML, аудит, глубины очередей, сессии) — см. §7.
PROD-стек: docker-логи → Promtail → Loki (retention 7 сут.) → Grafana (`127.0.0.1:3001`, SSH-туннель),
метрики → Prometheus (`127.0.0.1:9090`) → Grafana, трейсы OTel → otel-collector → Tempo, ресурсы
cAdvisor/node-exporter → Prometheus; профиль `observability` compose.prod.
- **compose.prod** (Ruling 9): `deploy/compose.prod.yml` — postgres/minio (без host-портов), core + telegram/ai/ml
(mTLS env; healthcheck — `grpc_health_probe`, при mTLS — TLS-проба с PEM), `caddy` (80/443: статика
`src/frontend/dist` + `reverse_proxy /api → core:5080`, security-заголовки; домен/TLS/Cloudflare — шапка
`deploy/caddy/Caddyfile`), профиль `observability` (otel-collector/tempo/loki/promtail/prometheus/cadvisor/node-exporter/grafana). Секреты — только из `.env.prod`
(шаблон `deploy/.env.prod.example`, без дефолтных паролей, fail-fast `:?`). Запуск:
`docker compose --env-file deploy/.env.prod -f deploy/compose.prod.yml up -d --build` (+ `--profile observability`);
авто-проверка — `... config` rc=0.
- **Быстрый сценарий оператора** (после подъёма): login оператора → создать тенанта → инвайт → `POST /api/join`
(или инвайт «на существующего тенанта») → вход тенанта и работа `/api` → оператор: лимиты/usage/health/аудит,
приостановка тенанта (вход 403, ИИ-гейт заморожен). Dev-прогон без docker-сервисов — как §1–3 (core с
Postgres :5433; операторские ручки/лимиты/аудит живут в том же процессе, AI — Local-режим).
### 9. Бэкапы и восстановление (Task 15, Ruling 8) — scripts/backup.sh / restore.sh
Реализация ежедневных бэкапов и восстановления — `scripts/backup.sh` + `scripts/restore.sh`
(общие env-дефолты/хелперы — `scripts/deal-backup-lib.sh`). Планировщик — **вне контейнера**
(cron/systemd, примеры ниже): скрипты ничего не ставят. Реальный прогон и restore-тест —
⚠ Manual (нужен поднятый docker-стек; здесь — синтаксис `sh -n` и error-path-проверки).
**Что входит в бэкап (4 источника данных Ruling 8):**
1. **Postgres** — БД `deal` целиком (схемы `public` + `tenant_*`): `pg_dump -Fc` (custom, сжатие) →
`$BACKUP_DIR/pg/backup-<TS>.dump`. По умолчанию — `docker exec deal-postgres` (локальный socket,
пароль не нужен); при заданном `DEAL_PG_HOST` — прямое `pg_dump` с хоста.
2. **MinIO** — бакет `deal-files` (вложения карточек): `mc mirror` → `$BACKUP_DIR/minio/backup-<TS>/`
(бэкап = выгрузка ИЗ MinIO в BACKUP_DIR). mc берётся с хоста, если есть в PATH; иначе — разовый
контейнер `minio/mc` в docker-сети контейнера MinIO. Секреты передаются env-алиасом `MC_HOST_deal`
(в конфиг mc не пишутся). Endpoint: docker-режим — `http://minio:9000` (алиас compose-сервиса,
работает и в compose.dev, и в compose.prod; в prod-сети DNS `deal-minio` НЕ существует — там нет
container_name); host-режим (dev, порт 9000 опубликован) — `http://localhost:9000`. Нестандартная
схема — env `DEAL_MINIO_ENDPOINT`. compose.prod порты MinIO не публикует — для prod не ставьте
хостовый mc (он не достанет MinIO), docker-режим работает из коробки. При Local-хранилище
(MinIO не поднят) — `DEAL_MINIO_SKIP=1`.
3. **Файловые данные** — tar каталогов `DEAL_TAR_DIRS` внутри `DEAL_DATA_DIR` →
`$BACKUP_DIR/data/backup-<TS>.tar.gz`. Дефолт: `attachments` (вложения Local-фолбэка), `telegram_sessions`
(сессии telegram-service; контейнерный путь — `/data/sessions`, шифрованы AES-GCM — архив без доп.
шифрования, доступ только root), `ml` (SQLite-модели ml-service). Для docker-томов задайте
`DEAL_TAR_VOLUMES` (список имён, напр. `deploy_deal_api_data deploy_deal_tg_sessions deploy_deal_ml_data`;
`docker volume ls | grep deal_`) — тар выполнит busybox-контейнер.
4. **Retention** — удаление снапшотов старше `RETENTION_DAYS` (дата `YYYYMMDD` из имени файла/каталога,
дефолт 14). При ежедневном запуске хранится ~15 копий (эквивалент `find -mtime +14`).
**НЕ входит:** сам `BACKUP_DIR` (не кладите его внутрь тарируемых каталогов), docker-образы и
compose-конфиги, логи (`data/logs` — собственная rolling-ротация 30 дней), БД LeadRadar/прочие.
**Структура и запуск (из корня репозитория):**
```sh
# ежедневный бэкап: консоль + $BACKUP_DIR/logs/backup-YYYYMM.log; rc=0 при успехе
bash scripts/backup.sh
# восстановление (сначала остановите сервисы, см. ниже): всё из последнего снапшота /
# из снапшота с конкретной меткой / только шаг:
bash scripts/restore.sh # all — pg + minio + data из последнего pg-снапшота
bash scripts/restore.sh 20260908-021500 # TS вида YYYYMMDD-HHMMSS (из имени файла backup-<TS>…)
bash scripts/restore.sh pg|minio|data [TS]
# структура: $BACKUP_DIR/{pg,minio,data}/backup-YYYYMMDD-HHMMSS{,.dump,/,…}, logs/
```
> Скрипты используют bash-специфику (`set -o pipefail`) — запускать именно `bash …` (или исполняемый
> файл `./scripts/backup.sh`), НЕ `sh …` (на системах с dash/sh=bash-не-гарантированно).
`BACKUP_DIR` по умолчанию — `<репозиторий>/data/backups` (env `BACKUP_DIR`/`DEAL_BACKUP_DIR`).
Dev-дефолты env соответствуют `deploy/compose.dev.yml` (БД `deal`/user `deal`; MinIO
`deal_minio`/`deal_minio_secret`, бакет `deal-files`); прод-имена секретов читаются как fallback
(`DEAL_MINIO_ACCESS_KEY` ← `MINIO_ROOT_USER`, `DEAL_MINIO_SECRET_KEY` ← `MINIO_ROOT_PASSWORD`;
`DEAL_PG_PASSWORD` совпадает с compose.prod). Полная таблица env — шапка `scripts/deal-backup-lib.sh`.
**Планировщик (вне контейнера; запуск от пользователя с доступом к docker):**
```sh
# cron — ежедневно в 02:00 («0 2 * * *»):
0 2 * * * /opt/deal/scripts/backup.sh >> /opt/deal/data/backups/cron.log 2>&1
# prod-вариант: секреты из deploy/.env.prod читаются сами (MINIO_ROOT_*, DEAL_PG_PASSWORD),
# BACKUP_DIR вынести из data/. prod НЕ публикует порты MinIO → mc в docker-режиме, endpoint по
# умолчанию http://minio:9000 (алиас сервиса); DEAL_MINIO_ENDPOINT задавать не нужно:
# 0 2 * * * cd /opt/deal && BACKUP_DIR=/var/backups/deal \
# bash scripts/backup.sh >> /var/backups/deal/cron.log 2>&1
# dev: хостовый mc + опубликованный порт 9000 → http://localhost:9000; docker-режим — http://minio:9000.
#
# systemd: /etc/systemd/system/deal-backup.{service,timer}
# [Unit] Description=Deal daily backup
# [Service] Type=oneshot; ExecStart=/opt/deal/scripts/backup.sh
# [Timer] OnCalendar=*-*-* 02:00:00; Persistent=true
# [Install] WantedBy=timers.target → systemctl enable --now deal-backup.timer
```
**Восстановление — порядок** (сводка — техдок §9):
```sh
# 1) остановить core и сервисы (БД/тома не должны быть заняты):
docker compose -f deploy/compose.dev.yml stop core telegram-service ml-service # dev
# prod: docker compose --env-file deploy/.env.prod -f deploy/compose.prod.yml stop
# 2) восстановить данные (шаг 1 → 3: pg → minio → data при restore all):
bash scripts/restore.sh
# 3) поднять сервисы обратно:
docker compose -f deploy/compose.dev.yml start core telegram-service ml-service
```
Восстановление — **overlay**: pg-шаг пересоздаёт БД целиком (dropdb+createdb, `pg_restore
--exit-on-error` — rc=1 при любой ошибке), а minio/data дописывают ПОВЕРХ текущих данных:
файл/объект, которого нет в снапшоте, останется. Строгий снимок бакета 1:1 — `DEAL_MINIO_MIRROR_REMOVE=1`
(`mc mirror --remove`); для data-каталогов/томов при необходимости очистите целевой каталог/том вручную
перед распаковкой.
Рекомендация Ruling 8: **раз в месяц** — тест восстановления на отдельном инстансе/томах
(поднять копию стека, `restore.sh`, curl-приёмка `/api`). Потеря данных при ежедневном бэкапе
допустима ≤ 24 ч (SLA тестового этапа). Требования: bash + GNU date (coreutils), docker;
секреты скрипты не логируют; параллельный запуск `backup.sh` не поддерживается.
### 10. Этап 10 — оператор-консоль, аналитика и аудит действий (Tasks 1–7)
Кратко (детали — план `docs/superpowers/plans/2026-09-10-deal-stage10-operator-analytics.md`, отчёты
`.superpowers/sdd/deal-stage10-operator-analytics/task-*-report.md`, контракт
`docs/architecture/2026-09-10-operator-analytics-contract.md`; api-map — §6; наблюдаемость — §7):
- **Фронт: hash-роутер без зависимостей** (`src/frontend/src/router.js`; `vue-router` не добавлялся).
Три верхнеуровневых экрана: **`#/`** — основное приложение (как раньше), **`#/operator`** — консоль
оператора (подразделы `#/operator/<section>`), **`#/join?code=…`** — активация инвайта. Разбор hash
синхронный (первый рендер сразу на нужном экране). Операторская ссылка на активацию формируется
функцией `joinLink(code)` — `origin+pathname#/join?code=<код>`.
- **Оператор-консоль** (`src/frontend/src/views/operator/*`): вход оператора (отдельная ручка и кука, экран
выводит подсказку dev-дефолта `operator / operator`); разделы «Тенанты» (список/создание/suspend/resume/
impersonate), «Приглашения» (создание/отзыв/копирование ссылки), «Лимиты ИИ» (сводка/правка),
«Аудит» (фильтры/пагинация), «Аналитика» (обзор/токены/действия), «Состояние системы». Внешних
chart-библиотек нет — визуализации на Tailwind-компонентах.
- **Impersonation**: `POST /api/operator/tenants/{id}/impersonate` выпускает tenant-сессию целевого
пользователя (обычный механизм `AuthService`) и **ставит httpOnly-куку `deal_session` в том же ответе**
(`Deal.Api/Http/SessionCookieWriter.cs`) — оператор сразу попадает в тенант; завершение — обычный
`POST /api/auth/logout` (аудит `impersonation_stopped`).
- **Страница активации инвайта** (`src/frontend/src/views/JoinView.vue`, `#/join?code=…`): форма `email`,
имя пространства (необязательно), пароль (**минимум 8 символов**) → `POST /api/join`; кука не ставится —
после успеха пользователь входит обычным `POST /api/auth/login`. Тексты причин отказа берутся с сервера
как есть (код не найден/истёк/использован/отозван, email не совпал/занят, тенант не найден/приостановлен).
- **История расхода токенов** (`public.token_usage_events`, миграция `20260910152246_AddTokenUsageEvents`):
`Id` (bigint identity), `TenantId` (uuid → `public.tenants`, Restrict), `At` (timestamptz), `Provider`,
`Model`, `Kind` (`ai|ml`, text), `PromptTokens`/`CompletionTokens`/`TotalTokens` (bigint), `DetailJson`
(text). Индексы `(TenantId, At)` и `(At)`. Запись — единая точка `TokenUsageRecorder` в момент списания
(успешный RPC ai-service — провайдер/модель из конфигурации; локальный ML-вызов — `kind=ml`,
оценка токенов ≈ chars/4). Агрегат `public.tenant_limits` остаётся для гейта; история — для аналитики.
- **Операторская аналитика** (read-only; `src/core/Deal.Api/Endpoints/OperatorAnalyticsEndpoints.cs`):
`GET /api/operator/analytics/overview`, `/tokens` (`groupBy=day|tenant|provider|model`, неизвестное — 400),
`/activity` (лента аудита с фильтрами и пагинацией). Все ответы — camelCase, время ISO-8601 (`from`/`to`
включительно), без операторской сессии — 401 «Требуется вход оператора». `GET /api/operator/audit`
расширен фильтром `actorId` и `offset` (ответ `{items, total}` без изменений). Полная форма запросов/ответов —
в контракте (ссылка выше).
- **Аудит действий** (этап 10, T1): единая точка `AuditService`/`AuditAppender` (append-only
`public.audit_log`; актор `tenant`/`operator`/`system`; секреты не пишутся). К SaaS-событиям этапа 7
добавлены: `tenant_logout`, `operator_logout`, `invite_joined`, действия карточек (`card_created`,
`card_moved`, `card_trashed`, `card_restored`, `card_deleted`, `card_comment_added`), контейнеры
(`container_created`, `container_updated`, `container_deleted`), `settings_updated`, `channel_enabled`,
`telegram_linked` (таблица — в контракте; `channel_created` зарезервирован, но не эмитится).
- **Наблюдаемость** (Grafana provisioning + promtail-лейблы, дашборды `Deal-Auth/Errors/Rps/Logs`; с этапа 12 —
метрики OTel → Prometheus и дашборд `Deal-Metrics-Overview`; трейсы OTel → Collector → Tempo (`Deal-Traces`)
и ресурсы cAdvisor/node-exporter (`Deal-Resources`), см. §7).
- **Как открыть (dev):** `docker compose -f deploy/compose.dev.yml up -d --build` (или core на `:5080`
с Postgres `:5433`, AI в Local-режиме) → фронт `cd src/frontend && npm run dev` (`:5173`, прокси `/api`)
→ **оператор:** `http://localhost:5173/#/operator`, вход `operator`/`operator` (dev-дефолт; в Production —
env `DEAL_OPERATOR_*`); **активация:** `http://localhost:5173/#/join?code=<код>`; основное приложение —
`http://localhost:5173/#/`. Prod-сценарий — §13.8.
- **Ограничения:** реальные Telegram/LLM-креды — ⚠ Manual (по решению владельца); access-лог с
BL-LOG-ACTOR содержит `actor`/`tenant` (IP в строке нет) — полный аудит действий в `public.audit_log`.
## 14. Локализация интерфейса (i18n, этап 11)
- **Назначение.** Все пользовательские строки фронтенда вынесены из компонентов и логики в словари-ресурсы
(единый источник текстов). Язык один — русский; переключатель языка и второй язык — **в бэклоге**
(делаем, когда возникнет потребность).
- **Модуль** `src/frontend/src/i18n/`:
- `index.js` — ядро: `t(key, params)` с подстановкой `{name}`, реактивный `locale` (по умолчанию `ru`),
`setLocale(code)`, `registerLocale(code, dict)`, `availableLocales()`, `useI18n()`. Фолбэк: активный
язык → `ru` → сам ключ. Плагин Vue даёт шаблонам `$t(...)`; в `<script setup>` `t` импортируется.
- `locales/ru.js` — словарь сообщений (единственный источник текстов), сгруппирован по областям:
`common/ nav/ search/ cards/ drawer/ columns/ settings/ channels/ processing/ auth/ operator/ join/ errors/`.
- `locales/ru.data.js` — языковой контент-каталог (валюты, AI-провайдеры, дефолтные промпты, шаблоны);
реэкспортируется из `src/data.js`, поэтому потребители не меняются.
- `errors.js` — `localizeError(err, fallbackKey)`: известные HTTP-статусы и сетевые сбои → ключи `errors/*`;
осмысленный `{detail}` бэка и всё неизвестное показываются как есть. Точка применения — `errMsg` в
`src/frontend/src/store/core.js`.
- **Правила.** Технические id/ключи/логи и бренд «Дейл» не локализуются; для исключений — директива
`i18n-ignore` в строке. Значение ключа равно отображаемой строке (1:1).
- **Проверка.** `npm run lint:i18n` (скрипт `src/frontend/scripts/i18n-lint.mjs`) падает, если вне
`src/i18n/locales/**` осталась кириллица в пользовательских строках (комментарии и `i18n-ignore`
игнорируются). Сборка — `npm run build`.
- **Механизм (если язык когда-нибудь понадобится):**
1. Создать словарь `src/frontend/src/i18n/locales/<code>.js` с теми же ключами (и, при необходимости,
контент-каталог `<code>.data.js`).
2. Зарегистрировать словарь через `registerLocale('<code>', dict)` при старте приложения.
3. Вызвать `setLocale('<code>')`. Отсутствующие ключи берутся из `ru`; компоненты менять не нужно.
4. Перевод форматирования дат/чисел (`Intl`) и плюрализации — вместе с языком.
- **Ограничение:** константы, вычисленные один раз при загрузке модуля (контент-каталог, карты подсказок),
держат текст стартового языка; «горячее» переключение потребовало бы обернуть их в `computed`.
## 15. Темы оформления (внешний вид, §8.12 ТЗ)
- **Назначение.** В настройках появился раздел «Внешний вид»: выбор темы — «Тёмная» (по умолчанию),
«Светлая» и «Системная». Переключение мгновенное, без перезагрузки; выбор запоминается на устройстве.
- **Устройство палитры.** Все цвета — токены Tailwind v4 в `src/frontend/src/style.css` (блок `@theme`:
`--color-ink/panel/raise/hover/edge/hi/mid/low/brand/brand-2/online/danger/warn`). Утилиты
(`bg-ink`, `text-hi`, `border-edge`, …) ссылаются на эти переменные, поэтому тема меняется
переопределением значений токенов, без правок компонентов.
- **Тёмная тема — дефолт** (значения в `@theme`, вид 1:1, не менялся). **Светлая** включается атрибутом
`data-theme="light"` на `<html>`: блок `:root[data-theme="light"]` переопределяет те же токены, а также
тени (`--shadow-card/--shadow-pop`), цвет скроллбара, линии фоновой сетки на экранах входа и подложки
встроенного Markdown (`.tgmd`). Здесь же выставляется `color-scheme: light` для нативных контролов
(`select`/`date`/`time`), в тёмной — `color-scheme: dark` на `html`.
- **Полупрозрачные слои.** Подсветки, разделители и hover свёрстаны как `bg-white/N` и `border-white/N`.
На светлом фоне белое «поверх» невидимо, поэтому в светлой теме токен `--color-white` намеренно
указывает на тёмный оттенок (`#0b1220`) — получаются мягкие серые подложки и границы. Литеральные
`text-white`/`bg-white` (текст на бренд-градиенте, фон QR-кода, ползунок тумблера) остаются белыми —
для них добавлены отдельные правила. Для текста поверх сплошной заливки введены токены
`--color-on-brand` и `--color-on-warn` (тёмный на светлой заливке в тёмной теме и наоборот).
- **Где хранится выбор.** `src/frontend/src/composables/theme.js`: значения `dark | light | system`,
ключ `localStorage` — `leadradar_theme`; `setTheme()` меняет атрибут `data-theme` и сохраняет выбор,
режим `system` слушает `prefers-color-scheme`. Инлайн-скрипт в `src/frontend/index.html` применяет
сохранённую тему к `<html>` **до первого рендера** — без «мигания» тёмной темы.
- **UI.** Раздел «Настройки → Внешний вид» — вкладка `appearance`
(`src/frontend/src/components/settings/AppearanceTab.vue`), добавлена в список вкладок
`SettingsView.vue` рядом с «Уведомлениями». Все строки — в словаре (`settings.vneshnij-vid`,
`settings.tema-*` в `locales/ru.js`).
- **Проверка.** `npm run build` и `npm run lint:i18n` — зелёные; новых зависимостей нет.
## 16. Добивка по ТЗ (этап 12) — что добавлено
По итогам аудита `docs/superpowers/reviews/2026-09-10-tz-compliance-audit.md` закрыты частично/незакрытые пункты:
- **ML-проверка на канале/сообщении (§8, E11).** `POST /api/ml/candidates` и `POST /api/ml/apply` — не
заглушки: отдают реальные сообщения (очередь/отсев/карточки) с мнением ML и применяют ручную разметку
(`skip|spam|board:<id>`) через существующие сервисы (обучение ML без дублей). Ядро — `MlReviewService`.
- **Глобальные исключения до ML/ИИ (§5.14).** Настройки `excludeKeywords/Locations/Types`,
`excludeBudgetFrom/To`; применяются на стоп-этапе, отсев пишет причину (`exclude_kw/location/type/budget`).
- **Группы фильтров колонки (§6.3).** В `rules` добавлены `levels/locations/types/prices`; движок правил
и `matchHits` дополнены метками «Уровень/Локация/Тип/Цена».
- **`/api/operator/health` (§10.2).** Добавлены `queues:{pipeline,mlOutbox}` и `sessions:{active}`
(общий `RuntimeDepthsCollector`, без дублей SQL).
- **Подозрительная активность (§10.5).** `SuspiciousActivityService` + `GET /api/operator/analytics/suspicious`
(всплеск неудачных входов по IP/логину, входы актора с множества IP, серии по тенанту, перебор разных
логинов с одного IP `distinct_logins_per_ip`; пороги — константы). Плюс real-time учёт
`SuspiciousActivityReporter`: метрика `deal.security.suspicious{kind}` и предупреждающий лог на 429
rate limiter (`rate_limit`) и блокировке входа (`login_blocked`).
- **«Открыть исходник» на карточке (§6.6)** и **темы оформления (§8.12, §15)** — во фронтенде.
Итог: core-тесты **1275/1275**; фронт `npm run build` + `lint:i18n` зелёные. Контракт API —
`docs/architecture/2026-09-10-unified-api-contract.md`.