# Дейл (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_.*`: все данные тенанта (карточки, колонки, настройки, обучение и т.д.). ### Доступ - `tenantId` — из сессии/JWT (core) или из gRPC-метаданных (сервисы). - DAL формирует `search_path` = `tenant_`; пул соединений на схему. - Изоляция проверяется: принадлежность объекта тенанту до любого действия (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_` (миграция `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//__` (`tenant_/…`-префикса нет). - Тип файла определяется автоматически (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.1–13.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_` с настройками тенанта (`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__`, аудит возврата 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/.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 1–16 (финал), см. §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 1–16): - Живые проверки (⚠ 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 берётся/создаётся файл `/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||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__` либо `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`, 1–N) → `{items:[{id pf_, name, size, kind, label, objectKey}]}`; тип — `FileKindDetector` по MIME+расширению (`document`/«Документ», `image`/«Изображение» и т.д.); объект кладётся через порт `IFileStorage` (Contracts/Integrations): `LocalFileStorage` (дефолт, корень `data/attachments`, key → путь `projects//_`) или `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 1–13. #### Порты и процессы (`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.1–13.6, этапы 2–5). - `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/.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 9–11) - Таблицы схемы тенанта (миграция `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/.sqlite`, таблицы classes/terms/eval_log, запись транзакциями); пул `ConcurrentDictionary` с 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 1–14; бэкапы — §13.9; финальные доки — Task 16): оператор/инвайты/лимиты/аудит/rate-limit/mTLS/логи/compose-prod Кратко (детали — планы `docs/superpowers/plans/2026-09-05-deal-stage7-saas.md` Rulings 1–11 и отчёты `.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-.dump`. По умолчанию — `docker exec deal-postgres` (локальный socket, пароль не нужен); при заданном `DEAL_PG_HOST` — прямое `pg_dump` с хоста. 2. **MinIO** — бакет `deal-files` (вложения карточек): `mc mirror` → `$BACKUP_DIR/minio/backup-/` (бэкап = выгрузка ИЗ 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-.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-…) 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/
`), **`#/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(...)`; в `