Files
Deal/docs/technical/Техническая-документация-Дейл.md
T
stepan ea4ed73327
ci / build-test (pull_request) Successful in 2m58s
Обновить документацию под глобальную конфигурацию ИИ
ТЗ, api-map, техническая документация, инструкция пользователя и контракт операторских настроек: настройки ИИ-провайдера перенесены в консоль оператора, пользовательский POST /api/ai/check удалён.
2026-09-15 21:59:43 +03:00

138 KiB
Raw Blame History

Дейл (Deal) — Техническая документация

Версия: 2.0 (этапы 0–12: единая карточка, оператор-консоль и аналитика, i18n, метрики/устойчивость) Дата: 2026-09-10 Содержание: полный стек, структура, конфигурация, развёртывание, эксплуатация.


1. Обзор стека

Слой Технология
Язык C# (современный, актуальная LTS .NET)
Бэкенд-ядро Модульный монолит core (ASP.NET Core: Web API, gRPC, SSE)
База данных PostgreSQL (одна БД, схема на тенанта)
ORM/доступ EF Core (основной) + Dapper (тяжёлые запросы, где нужно)
Миграции Механизм миграций на все схемы тенантов
Очередь/шина Outbox-паттерн в Postgres; порт IEventBus; Kafka — позже
ML-сервис .NET + ONNX Runtime, пул моделей per-tenant
AI-сервис .NET, фасад LLM-провайдеров (OpenAI-совместимые), учёт токенов
Telegram .NET (WTelegramClient/аналог), ферма сессий, анти-бан
Файлы MinIO (S3-совместимое хранилище)
Фронтенд Vue 3 + Vite + Tailwind
Межсервисно gRPC + Protobuf (mTLS — за флагом DEAL_MTLS_*, §10/§13.8)
Наблюдаемость Serilog (JSON: консоль + rolling-файл) → Promtail → Loki → Grafana; метрики OTel → Prometheus; трейсы OTel → Collector → Tempo; ресурсы cAdvisor/node-exporter → Prometheus; единый UI — Grafana
Прокси/edge Caddy (TLS, security-заголовки); Cloudflare/k8s — вне этапа (§10/§11)
Контейнеры Docker / docker compose (VPS); k8s — позже
Бэкапы Ежедневные: pg_dump + MinIO + сессии
CI Сборка, тесты, SAST, сканирование зависимостей и образов

2. Структура репозитория

src/
  core/                    # модульный монолит (один sln, один процесс)
    Deal.sln
    Deal.Api/              # host: /api-контракт, gRPC-сервер, SSE, DI-композиция
    Deal.Modules.Cards/    # модель единой карточки + каталог контейнеров (этап 9)
    Deal.Modules.Pipeline/
    Deal.Modules.Kanban/   # таблицы Cards/Containers, правила, комментарии, архив/корзина + сервисы «Выбранных»
    Deal.Modules.Telegram/ # каталог диалогов/каналов и превью сообщений (этап 6)
    Deal.Modules.Discovery/
    Deal.Modules.Settings/
    Deal.Modules.Tenants/
    Deal.SharedKernel/
    Deal.Infrastructure/
    Deal.Contracts/
    tests/
  ml-service/              # Deal.Ml.sln
  ai-service/              # Deal.Ai.sln
  telegram-service/        # Deal.Telegram.sln
  contracts/               # общие .proto
  deploy/                  # compose.dev.yml / compose.prod.yml (развёртывание)
  frontend/                # Vue 3 + Vite

Принципы:

  • один процесс = один sln;
  • core — единственное место с бизнес-логикой и БД;
  • сервисы stateless по отношению к данным тенантов (ML получает текст — отдаёт решение);
  • .proto — общий язык между процессами (в contracts/, подключается shared-файлами).

3. Модули core

Модуль Проект Владеет
Карточки (ядро) Deal.Modules.Cards модель единой карточки (ICard/ISource/IContainer/ICardMover), каталог контейнеров по умолчанию, единые префиксы id
Пайплайн Deal.Modules.Pipeline очередь, отсев, dedup
Канбан (дашборд) Deal.Modules.Kanban таблицы Cards и Containers, правила колонок, комментарии, CardMoves, MlOutbox
«Выбранные» Deal.Modules.Kanban (CardsService.Selected) сервисы стадий/напоминаний/файлов/ссылок над теми же строками Cards
Discovery Deal.Modules.Discovery задачи поиска, кандидаты, чёрный список
Настройки Deal.Modules.Settings настройки тенанта, промпты, валюты
Тенанты Deal.Modules.Tenants реестр тенантов, пользователи, инвайты, лимиты (в public)
Каналы/Telegram Deal.Modules.Telegram каталог диалогов, превью сообщений, мониторинг источников

После этапа 9 (единая карточка): модель/контейнеры — в Deal.Modules.Cards; Kanban владеет таблицами Cards/Containers и сервисами пространства «Выбранные» (CardsService.Selected) над теми же строками (отдельных модуля Deal.Modules.Projects и таблицы ProjectCards больше нет).

Зависимости между модулями — только через публичные интерфейсы модуля-владельца. Доменные события — через IEventBus (outbox). Правила:

  • внутри модуля таблицы — его собственность;
  • чужие таблицы не читаем/не пишем SQL напрямую;
  • общие справочники живут в модуле-владельце.

4. Мультитенантность и БД

Схемы

  • public: тенанты, пользователи, инвайты, ключи приложения, глобальные настройки.
  • tenant_<id>.*: все данные тенанта (карточки, колонки, настройки, обучение и т.д.).

Доступ

  • tenantId — из сессии/JWT (core) или из gRPC-метаданных (сервисы).
  • DAL формирует search_path = tenant_<id>; пул соединений на схему.
  • Изоляция проверяется: принадлежность объекта тенанту до любого действия (IDOR-защита).

Миграции

  • Миграции пишутся один раз (как для одной схемы) и применяются механизмом «ко всем схемам тенантов»: список схем из public.tenants, применение по очереди, версия миграции хранится на схему. Детали — в плане реализации этапа 0.

Ключевые таблицы public

tenants(Id, Name, Status, CreatedAt)
users(Id, Login, TenantId, PasswordHash, Status, CreatedAt)   -- Login = email пользователя
invites(Code PK, Email, TenantId, Status, ExpiresAt, ActivatedAt, CreatedById, CreatedAt)
global_settings(Key, Value, UpdatedAt)
-- этап 7: оператор/SaaS
token_usage_events(id, tenant_id, at, provider, model, kind, prompt_tokens, completion_tokens, total_tokens, detail_json)
-- этап 12: глобальные настройки сервиса (секреты шифруются)
global_settings(key, value, updated_at)

token_usage_events — история расхода токенов (этап 10, T2; подробнее — §13.10). global_settings — глобальные настройки уровня сервиса; сейчас хранит ключи приложения Telegram (telegramKeys: api_id/api_hash, hash — в enc:) и конфигурацию ИИ-провайдера (aiConfig: providerId/baseUrl/model/apiKey — в enc:), которые задаёт оператор глобально (ручки GET/PUT /api/operator/settings/telegram-keys и /ai-config, проверка связи — POST /api/operator/settings/ai-config/check); тенант эти настройки не видит и не задаёт. Операторские таблицы этапа 7 (operators, operator_sessions, tenant_limits, audit_log) и их контур описаны в §13.8. С этапа 12 счётчики распределённого rate-limit и попыток входа — public.rate_limit_counters (см. §10).

Ключевые таблицы схемы тенанта (пример)

QueueItems, RejectedItems, DedupEntries,
Cards, Containers, CardMoves, LeadComments, MlOutbox,
DiscTasks, DiscCandidates, DiscBlacklist, DiscLog,
Dialogs, TgMessages, settings

Фактическая схема на конец этапа 1 (2026-09-05)

Реализованный фундамент (см. раздел 13 «Быстрый старт»). Списки выше — целевой вид будущих этапов; ниже — то, что реально создано миграциями этапа 1.

  • public (системный контекст, миграция InitialSystem):
tenants(Id uuid PK, Name varchar(200), Status text, CreatedAt timestamptz)   -- реестр тенантов
users(Id uuid PK, Login varchar(200) UNIQUE, TenantId uuid → tenants,        -- учётные записи
      PasswordHash text, Status text default 'active', CreatedAt timestamptz)
sessions(TokenHash varchar(64) PK, UserId uuid → users ON DELETE CASCADE,    -- сессии: кука deal_session,
         Login varchar(200), ExpiresAt timestamptz, CreatedAt timestamptz)   -- срок 30 дней
  • Схема тенанта tenant_<id> (миграция InitialTenant применяется на схему):
settings(Key varchar(200) PK, ValueJson text, UpdatedAt timestamptz)          -- настройки тенанта
  • История миграций: public.__EFMigrationsHistory и __TenantMigrationsHistory в схеме тенанта.
  • Имена таблиц/колонок — по конвенции EF Core (PascalCase). Схемы тенантов создаются и мигрируются автоматически (TenantProvisioningService); при старте API создаётся дефолтный тенант и admin (TenantBootstrapService).

5. Сервисы и контракты

gRPC-контракты (src/contracts/*.proto, общий проект Deal.Proto)

  • telegram.proto (пакет deal.telegram.v1) — два сервиса: TelegramService — команды ядра к telegram-service (GetStatus, StartPhone, StartQr, SendCode, SendPassword, Logout, RefreshDialogs, SetMonitor, SetMonitorAll, Backfill, ReadRecent, Search, GetInfo, ReadForEval, Join, Leave); SourceIngressService.PushSource (generic-контракт источников, sources.proto) и IngressService (SyncDialogs, ReportStatus) — исходящий поток telegram-service → core; сервер — gRPC-ингресс core :5082).
  • ml.proto (пакет deal.ml.v1) — MlService: Predict (text → {take,label,scores,margin,type}), Status, Reset, TrainBatch; всё с metadata tenant-id.
  • ai.proto (пакет deal.ai.v1) — AiService: Filter, Classify, GenerateKeywords, EvaluateFit; ответ + расход токенов (usage → TokenUsageRecorder, §6).
  • Полный состав RPC/полей и семантика ошибок — §13.7 и шапки .proto.

Безопасность сервисов

  • Каждый RPC несёт metadata tenant-id + service-token; интерцепторы всех процессов fail-closed сверяют токен с env DEAL_SERVICE_TOKEN (gRPC-health освобождён). Принадлежность (сессия/модель тенанта) проверяется сервисом по своей модели — полю в теле не доверяем.
  • Dev — gRPC plaintext + общий service-token (compose.dev); mTLS — за флагом DEAL_MTLS_* (взаимные сертификаты, цепочка → CA; меняется только транспорт, контракты — нет, Ruling 6 этапа 7).
  • telegram-service: сессии привязаны к тенанту (файлы AES-256-GCM, ключ DEAL_TELEGRAM_SESSION_KEY); команда исполняется только на сессии своего tenantId; проверка принадлежности диалога; join под квотами тенанта; исходящие сообщения помечены tenantId на входе.

6. Ключевые сквозные механизмы

Outbox / IEventBus

  • Событие и бизнес-эффект пишутся в одной транзакции; фоновый диспетчер доставляет события подписчикам (в процессе) и/или в сервисы (gRPC).
  • Реализация сменная (outbox → Kafka) без правки бизнес-логики.

Лимиты токенов (ИИ-бюджет; фактически — этап 7, §13.8)

  • ai-service возвращает usage; TokenUsageRecorder инкрементит public.tenant_limits (период месяц/день, ленивый reset), пишет историю в public.token_usage_events (этап 10, T2) и инкрементит метрики deal.ai.*/deal.ml.* (§7); кроме того ведётся lifetime-KV aiTokenUsage.
  • Коллекции public.token_usage_events подчищает фоновый DataRetentionScheduler (§10); накопительные поля прошедших периодов tenant_limits сбрасываются там же.
  • Гейт-декораторы BudgetedAiClassifier/BudgetedAiTools (только при Services:Ai:UseLocal=false): исчерпание/приостановка → Local-фолбэк (приём не блокируется); SSE-тосты на 80/100% бюджета.

Файлы

  • MinIO (S3): бакет на продукт (deal-files); ключ объекта строит CardsServiceprojects/<card_id>/<file_id>_<unixMs>_<safeName> (tenant_<id>/…-префикса нет).
  • Тип файла определяется автоматически (MIME + расширение).
  • Доступ к файлу — только через core с проверкой tenantId.

Напоминания

  • Фоновый планировщик (в core): проверка due-напоминаний отложенных карточек; при срабатывании — уведомление (SSE/тост).
  • Если напоминания отключены — запланированные не срабатывают и очищаются.

SSE

  • События фронту (4 типа): new_card (полная карточка), toast ({text,icon}), reminder_due ({id,title,containerId}), system_status (объект tg.status()). new_lead переименован в new_card; pipeline_stats/boards_changed/leads_reclassified прототипа не реализованы.
  • Поток GET /api/events — per-tenant (SseBroker), ping-комментарий каждые 15 с. Публикуют только эндпоинты/планировщики Api-слоя (модули — чистые).

7. Наблюдаемость (фактический стек — этапы 7/10, Ruling 7)

  • Serilog.AspNetCore во всех 4 процессах (core + telegram/ai/ml): консоль в формате JSON (CompactJsonFormatter; в Development — текст) + rolling-файл data/logs/deal-<процесс>.json (30 дней; env DEAL_LOG_LEVEL/DEAL_LOGS_DIR). Секреты/пароли/ключи не логируются; gRPC-health не логируется.
  • Access-логи: HTTP — HttpAccessLogMiddleware (первый в конвейере после ForwardedHeaders — длительность и статус всего пути; с BL-LOG-ACTOR — actor и tenant из сессии); gRPC-ингресс — RpcCallLoggingInterceptor (health освобождён).
  • PROD-стек логов: docker-логи контейнеров → promtailloki (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/statusPOST /api/cards → trash → флашер MlOutbox → /api/ml/status; trap → down). Host-режим (core с хоста, Local-заглушки) — §13.113.6.

Prod-стек (deploy/compose.prod.yml)

  • Одна внутренняя сеть; наружу — только caddy (80/443): TLS (шапка deploy/caddy/Caddyfiletls 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-filesmc mirror; (3) файловые данные — tar каталогов/томов (attachments, telegram-сессии AES-GCM, ml-модели); (4) retention 14 копий. Планировщик — вне контейнера: cron «0 2 * * *»/systemd-примеры — §13.9. Запуск — bash scripts/backup.sh (из корня).
  • Восстановлениеscripts/restore.sh (pg → minio → data; pg-шаг пересоздаёт БД целиком, minio/data — overlay): остановить сервисы → bash scripts/restore.sh [TS|pg|minio|data] → поднять. Порядок и требования — §13.9.
  • Рекомендация Ruling 8: раз в месяц — тест восстановления на отдельном инстансе/томах.
  • Потеря данных при ежедневном бэкапе допустима ≤ 24 ч (SLA тестового этапа).
  • Реальный прогон backup.sh и restore-тест — ⚠ Manual (нужен docker-стек; здесь — sh -n, error-path-проверки, offline-проверка retention).

10. Безопасность (эксплуатационная сводка — фактическая, этап 7)

  • Rate limiting (Ruling 5): секция RateLimit (Enabled=false — код-дефолт/dev/тесты, true в PROD). Политики: auth — 10/мин на IP для /api/auth/login и /api/operator/auth/login; api — 600/мин на тенанта/IP; интерцептор gRPC-ингресса :5082 — 600/мин/тенанта (health освобождён); ответ 429 {detail}. С этапа 12 лимитер store-backed: состояние счётчиков — в public.rate_limit_counters (атомарный upsert), т.е. общее для всех инстансов core. Попытки входаLoginAttemptGuard на том же хранилище (окно ip|login: 5 неудач за 15 мин → 429 «Слишком много попыток входа…»; успех сбрасывает счётчик; Enabled=false — no-op).
  • Origin-проверка мутацийOriginGuardMiddleware: не-GET/HEAD/OPTIONS /api с заголовком Origin обязаны иметь Origin = «свой» origin (схема + Host) либо из Security:AllowedOrigins; несовпадение → 403. CORS — явный allowlist; SameSite=Lax httpOnly-кук — первый рубеж CSRF.
  • Прокси-заголовкиUseForwardedHeaders (X-Forwarded-For/X-Forwarded-Proto, один доверенный hop) только за Caddy: ForwardedHeaders:Enabled=true + KnownProxies/KnownNetworks (пустые списки не допускаются — loopback-фолбэк, fail-fast на невалидных значениях). Без этого за Caddy audit-IP (Ruling 4) и rate-limit-по-IP схлопываются в бакет прокси.
  • Security-заголовки: core — SecurityHeadersMiddleware (X-Content-Type-Options: nosniff, X-Frame-Options: DENY, Referrer-Policy: no-referrer); CSP/HSTS — на Caddy (фронт-статика; CSP для Vue требует настройки nonce — документируется в шапке Caddyfile). В PROD куки Secure=true.
  • mTLS — за флагом DEAL_MTLS_* только для внутреннего gRPC (серверный сертификат + обязательный клиентский, цепочка → CA из DEAL_MTLS_CA_PEM); основной HTTP :5080 остаётся http — TLS терминирует Caddy. Живое рукопожатие — ⚠ Manual.
  • Приостановка тенанта (Ruling 10(5)): вход — 403 «Учётная запись приостановлена…» (не 401: неверные учётные данные не раскрывают статус); ИИ-расход заморожен бюджетным гейтом. С этапа 12 активные сессии приостановленного тенанта разлогиниваются сразу: AuthService.ResolveSessionAsync проверяет статус тенанта (включая impersonation) и отказывает в сессии. Impersonation оператором suspended-тенанта разрешена (полностью аудируется; ИИ всё равно заморожен).
  • Аудит — append-only public.audit_log: пишет только AuditService (без Update/Delete), секреты не попадают; чтение — только оператор (GET /api/operator/audit). С этапа 12 retention 180 дней обеспечивает фоновый DataRetentionScheduler (раз в сутки; секция DataRetention), там же — сброс накопительных полей tenant_limits прошедших периодов и уборка окон счётчиков rate_limit_counters.
  • Криптография/код: пароли Argon2id; секреты настроек AES-256-GCM (enc:, ключ DEAL_ENCRYPTION_KEY); сессии Telegram AES-256-GCM (DEAL_TELEGRAM_SESSION_KEY); SQL параметризуется; секреты в логи/аудит не пишутся.
  • Hardening контейнеров (BL-IMG-HARDEN, 2026-09-11): прикладные образы (core/telegram/ai/ml/storage) работают non-root (пользователь deal, UID 10001) с HOME=/tmp; в compose заданы read_only: true, tmpfs: /tmp, security_opt: no-new-privileges, cap_drop: ALL и лимиты mem_limit/cpus (якорь x-service-hardening). Данные — в именованных volume (/app/data core, /data/sessions telegram, /data/ml ml); логи stateless-сервисов — DEAL_LOGS_DIR=/tmp/logs (tmpfs), у core — volume /app/data/logs. Проверено docker compose config (dev и prod, включая профиль observability); живой подъём с этими ограничениями — ⚠ Manual.
  • Вне этапа (не настроено; заделы §11/roadmap): Cloudflare (конфигурация вне кода — шапка Caddyfile), k8s, биллинг, UI админок, саморегистрация.

11. Известные ограничения и TODO

Выполнено на этапе 1 (2026-09-05):

  • доступ и сессии: POST /api/auth/login, POST /api/auth/logout, GET /api/auth/me, POST /api/auth/change-password; httpOnly-кука deal_session (30 дней);
  • мультитенантность и миграции: системный контекст (public: tenants/users/sessions, миграция InitialSystem), схемы tenant_<id> с настройками тенанта (settings, миграция InitialTenant на схему), автоматический провижининг схем и bootstrap дефолтного тенанта + admin при старте API.

Выполнено на этапе 2 (2026-09-06) — модуль Settings (экран «Настройки» обслуживается бэкендом):

  • дерево настроек тенанта 1:1 с прототипом: GET/PATCH /api/settings (дефолты модуля, перекрытые переопределениями в таблице settings тенанта; PATCH мягкий — невалидные поля пропускаются, ответ — полный снимок; секреты наружу только масками keyMasked/apiId; внутренние ключи ratesCache/mlDecisions/aiDecisions не публикуются);
  • шифрование секретов AI/Telegram: AES-256-GCM, в БД — enc: + Base64 (ключ — env/file, см. §13.4a);
  • проверка подключения ИИ: POST /api/operator/settings/ai-config/check (операторская; локальный провайдер / HTTP-проверка облачного);
  • курсы валют: GET /api/rates, POST /api/rates/refresh (кэш ratesCache в settings; mock/ЦБ);
  • ML-панель на детерминированной заглушке: GET /api/ml/status, POST /api/ml/reset|predict (candidates → {items:[]}, apply → 404 — нет telegram-данных до этапа 6);
  • тестер фильтров: POST /api/admin/check-message (этап-1 правила из настроек: длина/стоп-фразы/ резюме/тип; ИИ-фильтр тестера на этапе 2 всегда skipped);
  • 175 unit-тестов PASS; интеграционная приёмка — curl-сценарий на :5080 + psql.

Выполнено на этапе 3 (2026-09-06) — модуль Kanban (дашборд/канбан), см. §13.4c:

  • миграция TenantKanban — таблицы схемы тенанта Boards, Cards, LeadComments, CardMoves, MlOutbox (PascalCase-конвенция; колонки карточки по ТЗ §5: Title/Summary/StackJson/ BudgetFrom/BudgetTo/BudgetCur/ConvFrom/ConvTo/ConvCur/ContactsJson/ChannelName/…/ ReceivedAt/SourceMsg/SourceDialogId/PrevCol/MatchHitsJson/ArchivedAt);
  • модуль Deal.Modules.Kanban: BoardsService/CardsService (доски, переносы, архив/корзина, комментарии, counts, поиск), правила колонок ColumnRules (matchHits — «почему карточка в колонке»), StorageTickService + фоновый StorageTickScheduler (цикл 30 с, автоархив по archiveAfterDays), пересчёт конверсий ConversionRecomputer (listener на смену курсов/targetCurrency), демо-фабрика, эвристика ИИ-предложений SuggestHeuristics;
  • эндпоинты: /api/boards (+reorder/PATCH/DELETE), /api/columns/state, /api/leads (+counts/ {id}/move/trash/restore/DELETE/clear-col/mark-col-seen/mark-all-seen/comments/reclassify-заглушка), GET /api/search?q=, GET /api/events (SSE), /api/admin/tick + /api/admin/fts/rebuild (заглушка {ok,ready}), демо POST /api/demo/simulate-lead|age-lead (флаг DEAL_DEMO), POST /api/ai/suggest-columns|keywords, boot-заглушки GET /api/projects и GET /api/tg/status;
  • SSE-события: new_lead (карточка) и toast (текст+иконка) — публикуют только эндпоинты; потоки per-tenant (SseBroker), ping каждые 15 с;
  • демо-режим: DEAL_DEMO=1 (Development включает и без env) — simulate из демо-пула 1:1 с прототипом, age-lead состаривает карточку досок и тикает автоархив; без флага — 404 «Демо-режим отключён»;
  • ML-контракт IMlClient.PushAsync + локальная детерминированная реализация LocalMlClient (MlOutbox/learning; реальный сервис — этап 6);
  • 410 unit-тестов PASS; сквозная приёмка этапа — curl-сценарий на :5080 + psql (Task 15: PASS=94 FAIL=0: boot-группы, демо-карточки ×14 + SSE new_lead/toast, доски/правила/matchHits, move/trash/restore/комментарий, mark-col-seen, поиск, suggest-columns/keywords, age-lead + автоархив фоновым циклом, admin/tick, пересчёт конверсий 9250 RUB / 100 USD / 92.59 EUR).

Выполнено на этапе 4 (2026-09-06) — модуль Pipeline (вкладка «Обработка»), см. §13.4d:

  • миграция TenantPipeline — таблицы схемы тенанта QueueItems (очередь p_, статус new/filtered), RejectedItems (отсев r_<dialog>_<msgId>, аудит возврата returned/returnReason) и DedupEntries (нормализованный SHA1-хэш, мягкая ссылка LeadId на карточку, чистится при жёстком удалении); в той же миграции — FTS-колонки Cards.SearchTsv/RejectedItems.SearchTsv (russian tsvector STORED + GIN);
  • модуль Deal.Modules.Pipeline (чистый, без EF/HTTP): ядро разбора MessageTextCleaner/ MessageListNormalizer/ContactsQualifier/DedupHasher/SummaryComposer/LocalFieldsParser (1:1 pipeline.py), приём PipelineIngestService (гвард dialog+msgId), обработка/возврат/очистки PipelineProcessingService, pump PipelineWorkerService.PumpOnceAsync 1:1 с _pump_unlocked (устарело → правила → дедуп → ML → ИИ → карточка; счётчики wire 1:1), CardComposer + PipelineCardWriter (карточка через публичный IKanjStore.AddCardAsync + связь дедупа);
  • порт IAiClassifier + детерминированный LocalAiClassifier (до реального ai-service этапа 6), ML-слой — существующий IMlClient (локальная модель не готова — все сообщения к ИИ-ветке);
  • эндпоинты: GET /api/pipeline/stats|queue|rejected (+q FTS LIKE), POST /rejected/clear, DELETE /rejected/{id}, POST /rejected/{id}/return (400 dup/повтор/нет текста; снятие веса «спама» у ML), демо POST /api/demo/ingest (флаг DEAL_DEMO), реальные POST /api/admin/tick (storage+purgedRejected+pipeline+queue+SSE new_lead/тосты) и POST /api/admin/fts/rebuild;
  • фоновые циклы: PipelineWorkerScheduler (pump 2 с, общий гейт с ручным тиком) и автоочистка отсева (3 суток) в StorageTickScheduler (30 с) + SSE-тост «Отсев очищен: N записей (3 дн.)»;
  • FTS-поиск карточек GET /api/search?q= (tsvector + LIKE, ts_rank, лимит 12, messages:[]);
  • 535 unit-тестов PASS; build 0 warnings / 0 errors; сквозная приёмка этапа — curl-сценарии на :5080 + psql (Task 13 — финал: PASS=74 FAIL=0: нули при старте, ingest → карточка с полями §4.1, отсевы правил/dup/нет суммы/устарело на реальных записях, очередь, stats, psql-строки и dedup-связь, поиск отсева FTS (морфология «работой») и LIKE (имя канала), return dup → 400, return → очередь → карточка, повторный return → 400, DELETE, clear, поиск карточек, fts/rebuild, удаление карточки чистит DedupEntries, purge 3 дн. → SSE-тост, logout → 401).

Выполнено на этапе 5 (2026-09-07) — модуль Projects («Выбранные»), см. §13.4e:

Историческое состояние: с этапа 9 модуль Deal.Modules.Projects и таблица ProjectCards упразднены (сервисы «Выбранных» перешли в Deal.Modules.Kanban/CardsService.Selected, данные — в Cards); ниже — как было на этапе 5.

  • миграция TenantProjects — таблица схемы тенанта ProjectCards (PascalCase; partial UNIQUE IX_ProjectCards_LeadId по LeadId — «лид можно взять в работу один раз»); владелец — чистый модуль Deal.Modules.Projects (без EF/HTTP; зависимости — Contracts/Settings/Kanban-порты, реверса нет);
  • стадии ProjectStages 1:1 с PIPELINE_STAGES (planned → reply → work → hold → ready, терминальные finished/rejected), DTO карточки §4.3; история движения — в HistoryJson (создание created/ createdLocal + каждая смена стадии, Ruling 7), комментарии/ссылки/файлы — JSON-поля карточки;
  • сервисы: ProjectsService (список/чтение/ручное создание/take/патч presence-aware (budget:null)/ move+история/clear-rejected/комментарии/ссылки), ProjectFilesService (детект типа FileKindDetector MIME+расширение → порт IFileStorage → мета в карточку), ProjectReminderService (set/clear/snooze/ фоновая проверка due); порт IFileStorage + адаптеры LocalFileStorage (дефолт: data/attachments под ContentRoot) и MinioFileStorage (секция Storage:Minio/env DEAL_MINIO_*, compose-сервис deal-minio :9000/:9001, бакет deal-files лениво);
  • эндпоинты: 16 шт. /api/projects* — список/создание/take/clear-rejected/GET/PATCH/move/comments/links (add/remove)/files (upload/download/delete)/reminder (set/clear/snooze); boot-заглушка GET /api/projects снята (остался /api/tg/status — этап 6); GET /api/projects/reminders и DELETE /api/projects/{id} сознательно не реализованы (Ruling 9);
  • напоминания: настройка remindersEnabled (дефолт true; выключено → set 400); фоновый StorageTickScheduler (30 с) и ручной POST /api/admin/tick (reminders:[{id,title,stage}]) помечают due-строки hold ReminderFired=true и публикуют SSE reminder_due {id,title,stage} (публикации — только Api, Ruling 8); move с hold снимает напоминание; snooze = +24 ч;
  • 620 unit-тестов PASS; build 0 warnings / 0 errors; сквозная приёмка этапа — curl-сценарии на :5080 + psql (Task 13 — финал: PASS=75 FAIL=0: take-семантика (col=taken/is_new=false, исчезновение из /leads и /api/search, идемпотентность, partial-UNIQUE дубля), PATCH полей и budget:null, move по стадиям с историей, комментарии/ссылки, напоминание hold → SSE reminder_due фоновым циклом БЕЗ ручного tick + psql ReminderFired, файлы upload/download(байты)/delete + объекты на диске, clear-rejected, сортировка UpdatedAt DESC, logout → 401).

Выполнено на этапе 6 (2026-09-07) — сервисы telegram/ai/ml + Discovery + каналы, см. §13.7:

  • контракты src/contracts/*.proto (общий проект Deal.Proto, Grpc.Tools); каждый RPC — metadata tenant-id+service-token, интерцепторы fail-closed (health освобождён); dev-безопасность — общий service-token без mTLS (Ruling 2); mTLS и prod-compose — этап 7;
  • три автономных процесса в src/{telegram,ml,ai}-service (свои sln, net10.0): telegram-service (:5101, ферма сессий 1 акк/тенант, AES-256-GCM-файлы /data/sessions, фазы idle/code/password/qr/ready, диалоги/мониторинг/backfill с анти-бан-паузами, канал в core SERVICES__CORE__INGRESS), ai-service (:5102, LLM-фасад OpenAI-совместимых+Anthropic без БД: Filter/Classify/GenerateKeywords/EvaluateFit, usage-токенов), ml-service (:5103, инкрементальный наивный Байес 1:1 mlservice/model.py, SQLite на тенанта /data/ml/<tenant>.sqlite, пул per-tenant);
  • core: gRPC-ингресс telegram :5082 (PushMessage→очередь/превью, SyncDialogs, ReportStatus→SSE), модуль Telegram (Dialogs/TgMessages, ITelegramGateway+GrpcTelegramClient за флагом), эндпоинты /api/tg (14 шт., реальный статус вместо boot-заглушки, QR-SVG), GrpcAiClassifier/GrpcAiTools (контекст/промпты/ маппер/usage), GrpcMlClient + MlOutboxFlushScheduler (10 с, TrainBatch 10/≤100), модуль Discovery (DiscTasks/Candidates/Blacklist/Log, план-бюджет, воркер 5 с с каскадом оценки и авто-join под бан-гардом, эндпоинты /api/discovery 13 шт., generate-keywords);
  • флаги Services:{Ml,Ai,Telegram}:UseLocal — код-дефолт Local (true), compose.dev.yml задаёт false (полный стек «по-настоящему»); сервисы ходят в core-ингресс через SERVICES__CORE__INGRESS;
  • 830 unit-тестов PASS (Deal.Tests.Unit), build 0 warnings / 0 errors всех четырёх sln; приёмки: curl Task 14 (/api/tg) PASS=20 FAIL=0, Task 19 (/api/discovery) PASS=37 FAIL=0, in-proc gRPC-тесты (PushMessage→карточка, флашер, ai-фильтр/классификация/инструменты).

Выполнено на этапе 7 (2026-09-08) — SaaS-контур, Tasks 116 (финал), см. §13.8/§13.9:

  • оператор/сессии (public.Operators/OperatorSessions, кука deal_operator_session, bootstrap env DEAL_OPERATOR_; dev-only дефолт operator/operator) + ручки /api/operator/* (auth/tenants/invites/ limits/audit/health — API-only); инвайты и активация POST /api/join; лимиты ИИ-бюджета (tenant_limits, декораторы-гейт, SSE-тосты 80/100%) с дефолт-бюджетом; append-only аудит-поток; rate limiting (приложение + интерцептор gRPC-ингресса, LoginAttemptGuard); Origin-проверка мутаций и security-заголовки; mTLS за флагом DEAL_MTLS_ (сертификаты scripts/mtls-certs.sh); Serilog JSON во всех 4 процессах (консоль + rolling-файл data/logs, access-логи HTTP/gRPC); compose.prod (caddy, mTLS-env, профиль observability: promtail/loki 7 сут./grafana 127.0.0.1:3001) + .env.prod.example.
  • Бэкапы (Ruling 8, Task 15)scripts/backup.sh (pg_dump -Fc БД deal: docker exec deal-postgres или прямой pg_dump при DEAL_PG_HOST; mc mirror бакета MinIO deal-files — хостовый mc или разовый контейнер minio/mc; tar файловых данных DEAL_TAR_DIRS: attachments/telegram_sessions/ml — либо docker-volume'ы через DEAL_TAR_VOLUMES; retention 14 дней по дате в имени; лог + trap-очистка) и scripts/restore.sh (dropdb+createdb → pg_restore, обратный mc mirror, распаковка архивов). Команды/порядок/cron-пример «0 2 * * *» — §13.9. Реальный прогон и restore-тест — ⚠ Manual (нужен docker-стек).
  • Финальный прогон (Task 16): 1123 unit-теста PASS в core (Deal.Tests.Unit), telegram 114/114, ai 50/50, ml 36/36 PASS; build 0 warnings / 0 errors всех четырёх sln; docker compose -f deploy/compose.prod.yml config rc=0 (+ профиль observability); sh -n dev-smoke/backup/restore/ mtls-certs rc=0. Живые приёмки (curl-сценарий SaaS, подъём стека, бэкап/restore, mTLS, реальные сервисы) — ⚠ Manual, чек-лист в task-16-report.md.

Выполнено на этапе 9 (2026-09-10) — «единая карточка» (см. docs/architecture/2026-09-09-unified-card.md, docs/architecture/2026-09-10-unified-api-contract.md):

  • Модель: карточка — один агрегат во всех дашбордах. Ядро (ICard: id/title/source) + опциональные модули-роли (IContentCard/IBudgetedCard/IContactCard/IAttributedCard/ICommentableCard/ ILinkCard/IFileCard/ITzCard/ITraceableCard/IRemindableCard/ILocatedCard); источник — иерархия ISource (ITelegramSource/IRowSource/IApiSource/IAiSource/ICompositeSource и простые); единый переход ICardMover. Вид карточки — композиция модулей, а не класс-наследник (Deal.Modules.Cards).
  • БД: одна таблица CardsProjectCards упразднена; единый реестр 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 (plannedfinished/rejected); служебные зоны — inbox/archive/trash. Карточка живёт в одном пространстве; «взять в работу» — перенос карточки в planned, а не клон.
  • API: единый контракт /api/cards + /api/containers; ручки /api/leads, /api/projects, /api/boards, /api/columns удалены; SSE new_card вместо new_lead. Единый префикс id — c_. Полная карта — docs/api/api-map.md.
  • Фронт: один слайс карточек (src/frontend/src/store/cards.js) и единый канбан для дашборда и «Выбранных» (пространство определяется контейнером карточки).

Остаётся TODO (после этапа 7, Tasks 116):

  • Живые проверки (⚠ Manual, нужен docker/креды): применение system-миграции SystemSaaS и сквозная SaaS-curl-приёмка (оператор → тенант → инвайт → /api/join → лимиты/гейт → аудит → suspend → resume → IDOR-негативы); подъём compose.prod.yml и dev-smoke sh scripts/dev-smoke.sh; mTLS-рукопожатие контейнеров; реальные Telegram/LLM-вызовы (с кредами); прогон scripts/backup.sh и restore-тест (scripts/restore.sh).
  • Заделы (сознательно вне этапа 7; часть закрыта этапами 8–12): UI операторской админки и страницы активации инвайта (сейчас API-only); OTel-метрики/Prometheus и дашборды метрик (закрыто этапом 12, пакет A — §7); multi-instance rate-limit и бэкенд попыток входа (закрыто этапом 12 — public.rate_limit_counters); мгновенный разлогин suspended-сессий (закрыто этапом 12); реклассификация «Неразобранного» на реальном ИИ (закрыто этапом 12 — reclassify с локальным фолбэком); purge-автоматика audit_log и auto-purge истории tenant_limits (закрыто этапом 12 — DataRetentionScheduler); экспорт/импорт ML-моделей; мультиаккаунтность Telegram на тенанта; саморегистрация/биллинг-провайдер/планы; k8s/Cloudflare-конфигурация.
  • Карта /apidocs/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

# хранилища для 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:

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:

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

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)

Секреты хранятся шифротекстом enc: + Base64(nonce‖ct‖tag), AES-256-GCM (nonce 12 Б, tag 16 Б). Ключ шифрования — env DEAL_ENCRYPTION_KEY (32 байта в urlsafe-Base64); при отсутствии в dev берётся/создаётся файл <ContentRoot>/data/encryption.key (путь переопределяется env DEAL_ENCRYPTION_KEY_FILE) — при генерации лог-warning. Невалидный env-ключ — ошибка при старте. Наружу секреты не отдаются: только маски 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).

С 2026-09-14 там же живёт и конфигурация ИИ-провайдера (ключ aiConfig в public.global_settings, GET/PUT /api/operator/settings/ai-config): провайдер, модель, baseUrl и API-ключ задаёт оператор, все ИИ-вызовы всех тенантов идут на эту конфигурацию; в настройках тенанта ключей aiProvider/ aiConfigs больше нет.

4b. Эндпоинты этапа 2 (настройки тенанта; сессия deal_session обязательна, иначе 401)

  • GET /api/settings — публичный снимок дерева настроек: дефолты модуля, перекрытые переопределениями из settings тенанта; включает myPrompts и колонки, но не настройки ИИ-провайдера (они операторские). PATCH /api/settings — частичное обновление (невалидное поле мягко пропускается, ответ — полный снимок). Побочные эффекты: при rateSource — фоновый refresh курсов. Внутренние ключи (ratesCache, mlDecisions, aiDecisions) в GET/PATCH не участвуют.

  • GET /api/rates / POST /api/rates/refresh — курсы к RUB (base = RUB); источник по rateSource (mock — константа, cbr — ЦБ РФ, ≤4 запроса/сутки, интервал 6 ч; USDT=USD); кэш — внутренняя настройка ratesCache {rates, source, updatedAtMs}.

  • GET /api/ml/status, POST /api/ml/reset|predict — ML-панель на детерминированной заглушке LocalMlClient (этап 6 заменит на gRPC без правки эндпоинтов): ready:false, predict неготовой модели — «не уверен», candidates{items:[]}, apply → 404 (telegram-данных нет до этапа 6).

  • POST /api/admin/check-message — тестер фильтров входящих: {stage1:{pass,reason}, stage2:{pass, reason, skipped}, passed}; этап-1 правила из настроек (длина/стоп-фразы/резюме/тип); ИИ-фильтр тестера на этапе 2 всегда skipped:true.

    Актуально с 2026-09-11: тестер стал сухим прогоном по всему конвейеру (PipelineWorkerService.DryRunAsync): {passed, wouldCreateCard, targetContainer, matchHits, parsed, stages[]} — стоп-правила → глобальные исключения → ML (спам/тип) → ИИ-фильтр/классификация → «без суммы»; без записи в систему. UI — вкладка настроек «Стоп-слова».

4c. Эндпоинты этапа 3 (канбан/дашборд; сессия deal_session обязательна, иначе 401)

На этапе 4 POST /api/admin/tick стал реальным (pipeline/pump/purge-отсева) и POST /api/admin/fts/rebuild — реальным {ok, ready} (см. §4d); описание ниже — состояние этапа 3.

На этапе 5 GET /api/projects — реальный список «Выбранных» (см. §4e); boot-заглушка /projects снята, из boot-заглушек остался только GET /api/tg/status (telegram — этап 6).

  • Доски: GET/POST /api/boards (голый массив / создание), PATCH /api/boards/{id} (name/width/ collapsed/keywords/rules/suggested…), POST /api/boards/reorder, DELETE /api/boards/{id} (карточки → inbox). Правила колонки — {mode: all|any, direction[], keywords[], stack[], grade[], exclude[], budget}; совпавшие термины попадают в matchHits карточки (1:1 с rules.py).
  • Карточки: GET /api/leads?col=inbox|<boardId>|archive|trash (свежие сверху, полный §4.1), GET /api/leads/counts (плоская форма {new, learning, ml, ai} + per-column {count, new}), GET /api/leads/{id}, POST /leads/{id}/move|trash|restore, DELETE /api/leads/{id}, POST /api/leads/clear-col (trash|archive), mark-col-seen|mark-all-seen, POST /leads/{id}/comments. Поиск: GET /api/search?q= (lower-LIKE по title/summary/contact/source_msg → {leads, messages:[]}).
  • Служебные: POST /api/admin/tick{storage:{archived,purgedArchive,purgedTrash,purgedRejected}, reminders:[], pipeline:{}, queue:0} (+SSE-тосты статистики); POST /api/admin/fts/rebuild — заглушка {ok:true, ready:true} (FTS-индекс — этап 4).
  • Boot-заглушки фронта: GET /api/tg/status → idle-форма §4.9 (telegram — этап 6; заглушка GET /api/projects{items:[]} снята на этапе 5 — реальный список см. §4e).
  • SSE: GET /api/events — text/event-stream канала тенанта; события new_lead (полная карточка, после simulate) и toast {text, icon} (демо-лид sparkles, автоархив/тик clock, ИИ-предложения sparkles); ping : ping каждые 15 с. Публикуют только эндпоинты Api (модуль чист).
  • Демо-режим (флаг DEAL_DEMO=1; Development включает и без env): POST /api/demo/simulate-lead (карточка из демо-пула 1:1 с прототипом → inbox + SSE new_lead/toast), POST /api/demo/age-lead (состаривание самой старой карточки досок + тик автоархива + SSE-toast). Без флага — 404 «Демо-режим отключён».
  • ИИ-предложения (эвристика этапа 3, реальный ИИ — этап 6): POST /api/ai/suggest-columns (накопите ≥6 карточек в «Неразобранном» → доски suggested:true с note «Эвристика (этап 3):…» и раскладкой карточек; повторный вызов — cooldown 20 мин lastSuggestAt), POST /api/ai/suggest-keywords (частотные маркеры по текстам).
  • Конверсии (Ruling 7): курсы — POST /api/rates/refresh (mock/ЦБ), кэш ratesCache в settings; пересчёт ConvFrom/ConvTo/ConvCur активных карточек (не archive/trash/taken) выполняется синхронно по listener'ам: после refresh курсов и при PATCH /api/settings {targetCurrency,…}.

4d. Эндпоинты этапа 4 (pipeline/вкладка «Обработка»; сессия обязательна, иначе 401)

Таблицы этапа — миграция TenantPipeline в схеме тенанта (владелец — модуль Deal.Modules.Pipeline): QueueItems (очередь, id p_…, статус new|filtered), RejectedItems (отсев, детерминированный id r_<dialog>_<msgId> либо r_+hex; колонка SearchTsvto_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/tickreminders:[{id,title,stage}]) вызывает ProjectReminderService.CheckDueAsync — due-строки stage='hold' помечаются ReminderFired=true и публикуются SSE-событием reminder_due {id,title,stage} в канал тенанта (баннер фронта; публикации — только из Api, Ruling 8). Любой move с hold снимает напоминание (ReminderAt/ReminderFired очищаются).
  • Файлы: POST /{cardId}/files (multipart, поле files, 1N) → {items:[{id pf_, name, size, kind, label, objectKey}]}; тип — FileKindDetector по MIME+расширению (document/«Документ», image/«Изображение» и т.д.); объект кладётся через порт IFileStorage (Contracts/Integrations): LocalFileStorage (дефолт, корень data/attachments, key → путь projects/<cardId>/<ms>_<name>) или MinioFileStorage (включается секцией Storage:Minio/env DEAL_MINIO_*; бакет deal-files создаётся лениво; compose - сервис deal-minio :9000/:9001). GET /{cardId}/files/{fileId}/downloadattachment (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)

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; в publictenants, 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); RejectedItemsStage/Reason/Kw/Source/Returned/SearchTsv; DedupEntriesHash (PK)/LeadId. Правила контейнеров — в Containers.RulesJson, состояние колонок — в settings (ключ colState).

6. Тесты и сборка (из корня репозитория)

sh scripts/build.sh   # сборка всех 5 решений (0 warnings / 0 errors)
sh scripts/test.sh    # тесты всех сервисов + lint:i18n (core 1315, telegram 130, ai 52, ml 38, storage 9)
sh scripts/ci.sh      # полный CI-прогон: build + test + скан уязвимостей + сборка фронта

Сквозные приёмки этапов — curl-сценарии на :5080 в .superpowers/sdd/deal-stage{4,5}-projects/ (task-N-curl-acceptance.sh/.log): этап 4 — финальный Task 13 PASS=74 FAIL=0; этап 5 — финальный Task 13 PASS=75 FAIL=0 (карточки ProjectCards, лид col=taken, файлы на диске data/attachments, SSE reminder_due фоновым циклом БЕЗ ручного tick).

Каждая из четырёх sln собирается 0 warnings / 0 errors (TreatWarningsAsErrors): src/core/Deal.sln, src/telegram-service/Deal.Telegram.sln, src/ml-service/Deal.Ml.sln, src/ai-service/Deal.Ai.sln. Приёмки этапа 6 (.superpowers/sdd/deal-stage6-services/): in-proc gRPC-тесты (ингресс PushMessage→ карточка, флашер MlOutbox, ai-фильтр/классификация/инструменты) + curl-сценарии Task 14 (/api/tg: PASS=20 FAIL=0) и Task 19 (/api/discovery: PASS=37 FAIL=0).

Финальный прогон этапа 7 (Task 16, docker выключен): core 1123/1123 PASS, telegram 114/114, ai 50/50, ml 36/36 PASS; build 0/0 всех четырёх sln; docker compose -f deploy/compose.prod.yml config rc=0; sh -n scripts/dev-smoke.sh/backup.sh/restore.sh/mtls-certs.sh rc=0. Живые приёмки (SaaS-curl-сценарий этапа 7, подъём compose.dev/prod, бэкап/restore, mTLS, реальные сервисы) — ⚠ Manual, чек-лист — .superpowers/sdd/deal-stage7-saas/task-16-report.md.

7. Этап 6 — автономные сервисы telegram/ai/ml + Discovery + каналы (полный dev-стек)

Реализация — src/telegram-service, src/ml-service, src/ai-service (отдельные sln/процессы, .NET 10, общий код — только .proto через src/contracts/Deal.Proto.csproj, Task 1); core остаётся единственным владельцем БД и бизнес-логики (сервисы не знают домен и не ходят в tenant-БД). Контракты, сервисы и интеграция — план этапа 6 (.superpowers/sdd/deal-stage6-services/), Rulings 113.

Порты и процессы (deploy/compose.dev.yml)

Контейнер Порт Назначение
deal-postgres 5433 БД (host-порт; внутри — 5432)
deal-minio 9000/9001 S3-API / консоль (файлы вложений; в Local-режиме необязателен)
deal-core (Deal.Api) HTTP 5080, gRPC-ингресс 5082 портал /api + приём PushSource/SyncDialogs/ReportStatus
deal-telegram-service 5101 Telegram: сессии/QR/диалоги/мониторинг/backfill/discovery-операции
deal-ai-service 5102 LLM-фасад: Filter/Classify/GenerateKeywords/EvaluateFit
deal-ml-service 5103 инкрементальная модель per-tenant: predict/train/status/reset

Порт каждого сервиса — env GRPC_PORT (контейнерный 5101/5102/5103), порт ингресса core — env GRPC_INGRESS_PORT (5082). Health-проверки контейнеров — встроенный gRPC-health (grpc_health_probe в образе, /bin/grpc_health_probe), Deal-RPC health не трогают.

gRPC-контракты и безопасность (Rulings 1/2/13)

  • src/contracts/{telegram,ai,ml}.proto — пакеты deal.telegram.v1/deal.ai.v1/deal.ml.v1 (csharp_namespace Deal.Grpc.Telegram/Ai/Ml); общий проект кодогенерации Deal.Proto (Grpc.Tools, client+server в одном проходе; каждый процесс собирает свою sln вместе с ним).
  • Каждый RPC несёт metadata tenant-id + service-token; серверный интерцептор каждого процесса fail-closed сверяет токен с env DEAL_SERVICE_TOKEN (единый для всех процессов в compose; отказ — UNAUTHENTICATED; grpc.health.v1.Health освобождён). Принадлежность (сессия/модель тенанта) проверяется сервисом по своей модели — полю не доверяется. Ошибки домена — INVALID_ARGUMENT/ NOT_FOUND/UNAVAILABLE/RESOURCE_EXHAUSTED (flood) с текстом 1:1.
  • Dev — gRPC plaintext без mTLS (Ruling 2); mTLS-сертификаты, их генерация и prod-compose — этап 7.

Флаги интеграций core (Ruling 6)

  • Код-дефолт — Services:{Ml,Ai,Telegram}:UseLocal=true (appsettings.json): Local-адаптеры (LocalMlClient, LocalAiClassifier/LocalAiTools, LocalTelegramGateway) — core работает без сервисов (host-путь §13.113.6, этапы 25).
  • deploy/compose.dev.yml задаёт для core Services__{Ml,Ai,Telegram}__UseLocal: "false" + эндпоинты http://ml-service:5103 / http://ai-service:5102 / http://telegram-service:5101 — полный стек «по-настоящему». Выбор реализации — на старте (рантайм-переключения нет); фолбэки: недоступность ml/ai — локальные пути воркеров (ml predict — «не уверен», ai — локальный разбор), telegram — idle-форма эндпоинтов.

Env (compose.dev.yml; dev-дефолты ${VAR:-…}, перекрываются .env/экспортом)

  • Общие: DEAL_SERVICE_TOKEN (единый service-token core+сервисов), DEAL_ENCRYPTION_KEY (32 байта base64, AES-GCM секретов настроек core; fail-closed), creds БД/минио ниже.
  • core: ConnectionStrings__DealPostgres (host postgres, порт 5432 внутри compose), Storage__Minio__* (или env-алиасы DEAL_MINIO_*), Services__*__{UseLocal,Endpoint}, GRPC_INGRESS_PORT=5082, ASPNETCORE_URLS=http://0.0.0.0:5080.
  • telegram-service: GRPC_PORT, DEAL_SERVICE_TOKEN, DEAL_TELEGRAM_SESSION_KEY (32 байта base64, обязателен — fail-closed: сессии только шифрованные AES-256-GCM, файлы /data/sessions на volume deal_tg_sessions), DEAL_TELEGRAM_SESSION_DIR=/data/sessions, SERVICES__CORE__INGRESS (http://core:5082 в compose; для core с хоста — DEAL_CORE_INGRESS=http://host.docker.internal:5082).
  • ml-service: GRPC_PORT, DEAL_ML_DATA_DIR=/data/ml (volume deal_ml_data; SQLite-файлы моделей /data/ml/<tenantId>.sqlite).
  • ai-service: GRPC_PORT (stateless — промпты/конфиг провайдера приходят в теле запроса, volume не нужен).

Полный стек и smoke-проверка

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=dashboardPOST /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): PushMessagePipelineIngestService.EnqueueAsync (тот же контракт, что demo-ingest) + превью в TgMessages; SyncDialogs → синхронизация каталога/мониторинга; ReportStatus → KV tgStatus/tgAccount + SSE system_status/тосты переходов.

    Актуально с 2026-09-11: приём записей вынесен из TelegramIngressService в generic Deal.Api/Sources/SourceIngressGrpcService (sources.protoPushSource, proto → домен через SourceProtoMapper, тенант — IngressTenantResolver, превью — TelegramSourceIngestObserver). TelegramIngressService обслуживает только SyncDialogs/ReportStatus.

  • Эндпоинты 1:1 api-map §3.3 (14 шт.): статус (§4.9 — live-поля фазы, account из KV, monitored из count(Dialogs WHERE Monitor), keysSet), start-phone/start-qr/send-code/send-password/logout, QR-image (SVG, Net.Codecrete.QrCodeGenerator; 404 «QR не активен — начните вход по QR»), dialogs/refresh/ monitor-all/backfill-all/{id}/monitor/{id}/backfill (сервер-only)/preview. Boot-заглушка GET /api/tg/status снята (Task 14). telegram-service реализует команды (сессии по тенантам 1:1, фазы idle|code|password|qr| ready, auto_resume+heartbeat, backfill с паузами 1.5–3 с/сообщение, discovery-операции).

Discovery (Rulings 911)

  • Таблицы схемы тенанта (миграция TenantDiscovery): DiscTasks/DiscCandidates/DiscBlacklist/ DiscLog (+json-колонки marks/topics/keywords); владелец — чистый модуль Deal.Modules.Discovery (сервисы задач/кандидатов/чёрного списка/лога, DiscoveryPlanGuard — план ≤ discJoinLimit, бюджет активных задач).
  • Фоновый DiscoveryWorkerScheduler (5 с, per-tenant, одно действие за тик): план достигнут → done; поиск следующего ключа через gateway (Search); оценка new-кандидата каскадом (info → выборка → язык/число сообщений → ML-спам (если mlEnabled) → ИИ EvaluateFit (если aiEnabled) → эвристика по ключам; форумы — по темам); авто-вступление review при autoJoin с паузами и квотами (50–70 с, лимит авто-вступлений/сутки по DiscLog, стоп-кран discFloodDay/discPaused), join_failures ≥3 → удаление задачи. Внешний анти-бан — владение core; внутренние паузы сервиса — telegram-service.
  • Эндпоинты 1:1 api-map §3.8 (13 шт.): tasks CRUD+start/pause+generate-keywords (мягкая ошибка {keywords:[],error} HTTP 200), candidates по статусам, join/reject (ручные, вне квот), blacklist, log.

ML-модель ml-service (Ruling 4)

  • Порт python mlservice/model.py 1:1: инкрементальный наивный Байес по терминам (OnlineNaiveBayes, tokenize/upsert/predict/adaptive margin/самооценка eval), НЕ ONNX/ML.NET. Пороги: MIN_TOTAL 20, MIN_WINNER 6, MIN_WINNER_SPAM 4, MIN_HITS 2, MARGIN 0.9; адаптивный отрыв 0.35/0.5/0.7 после 400/150/60 примеров; классы t:hire/t:order (MIN_TYPE_WINNER 4).
  • Хранилище — SQLite на тенанта (/data/ml/<tenantId>.sqlite, таблицы classes/terms/eval_log, запись транзакциями); пул ConcurrentDictionary<tenantId, TenantModel> с lazy-load и lock на модель. Веса сигналов обучения 1.0 (пользователь) / 0.4 (ИИ) / 0.6 (правила).
  • Core: PushAsync ВСЕГДА пишет в MlOutbox; фоновый MlOutboxFlushScheduler (10 с, только при UseLocal=false) выгружает батчами по 10 (≤100/цикл) через RPC TrainBatch, строки удаляются после успеха; недоступность сервиса — строки остаются. Reset = Reset RPC + ClearOutboxAsync. Кэш статуса 15 с → reachable в /api/ml/status.

AI-фасад ai-service (Ruling 5)

  • Без БД: core передаёт в теле запроса заполненные промпты (подстановка {domain}/{keywords}), конфиг провайдера (id/base/model/apiKey/api_style — расшифрованный из aiConfigs) и текст. Методы: Filter → {pass,reason}; Classify → {ok,json} (json-строку маппит core в AiParsedLeadDto, строгий маппинг); GenerateKeywords → {keywords}; EvaluateFit → {fit,reason} (Discovery).
  • Транспорт: OpenAI-совместимые POST {base}/chat/completions (Bearer) и Anthropic POST {base}/v1/messages (x-api-key); temperature 0.2, таймауты 90/60 с, retry max_retries=2 (паузы 0.8/2 с), извлечение JSON из markdown. Ошибки провайдера наружу — UNAVAILABLE («ИИ (имя) не ответил корректно — повторите попытку через несколько секунд»); учёт токенов usage (оценка ≈chars/4 при отсутствии) → KV aiTokenUsage (лимиты/бюджеты — этап 7). Без ключа LLM сервис недоступен — воркер ядра падает в локальные пути (фолбэк по замыслу).

Ручные проверки этапа 6 (нужны креды)

  • Telegram-вход: ключи приложения (api_id/api_hash) задаёт оператор глобально (PUT /api/operator/settings/telegram-keys, hash шифруется) → POST /api/tg/start-qr → QR-скан → фаза ready («Telegram подключён, сессия сохранена»), затем реальные диалоги/мониторинг/«Перечитать»/ discovery-поиск и вступления. В настройках тенанта ключей нет (решение владельца, вариант A).
  • LLM: оператор задаёт провайдера и модель в консоли (PUT /api/operator/settings/ai-config, напр. DeepSeek или локальный OpenAI-совместимый) → POST /api/operator/settings/ai-config/check; реальная классификация/фильтр/генерация ключей при Services__Ai__UseLocal=false.
  • Сквозной smoke стека — scripts/dev-smoke.sh (одна команда; Docker Desktop должен быть поднят).

8. Этап 7 — SaaS-контур (Tasks 114; бэкапы — §13.9; финальные доки — Task 16): оператор/инвайты/лимиты/аудит/rate-limit/mTLS/логи/compose-prod

Кратко (детали — планы docs/superpowers/plans/2026-09-05-deal-stage7-saas.md Rulings 111 и отчёты .superpowers/sdd/deal-stage7-saas/task-*-report.md; api-map — раздел «Реализовано в Deal» (Task 16); живые проверки — ⚠ Manual, чек-лист task-16-report.md):

  • Оператор (public.operators/operator_sessions, кука deal_operator_session, срок 12 ч): bootstrap из env DEAL_OPERATOR_LOGIN/DEAL_OPERATOR_PASSWORD (Development без env — operator/operator; Production без env — warning и пропуск). Ручки — /api/operator/auth/* (login/logout/me); отдельный OperatorSessionMiddleware — тенантные ручки операторских сессий не видят и наоборот (401/403).
  • Инвайты/регистрация: оператор создаёт инвайт (код 16 симв., срок 72 ч, email-unique; список/отзыв — /api/operator/invites), пользователь активирует публичной ручкой POST /api/join {code, email, name?, password} — создание пользователя (Argon2id) и, для инвайта «на новый тенант», тенанта с провижинингом схемы.
  • Лимиты ИИ-бюджета (public.tenant_limits; период месяц/день, ленивый reset): списание — TokenUsageRecorder (успешные RPC ai-service), гейт-декораторы BudgetedAiClassifier/BudgetedAiTools (исчерпание/suspended → Local-фолбэк), SSE-тосты 80/100% (BudgetAlertScheduler, 60 с). Дефолт-бюджет нового тенанта — env DEAL_DEFAULT_AI_BUDGET (константа 10 000 000 токенов/месяц).
  • Операторские ручки /api/operator/*: тенанты (список/создание/статус/impersonation), лимиты (просмотр/смена бюджета + usage), аудит (append-only public.audit_log), health (core/БД/ml/ai/telegram). С этапа 10 у них есть UI — оператор-консоль и страница активации инвайта (см. §13.10).
  • Rate limiting (Ruling 5): секция RateLimit, Enabled=false в dev/тестах; PROD включает env из compose.prod: политики api/auth (600/10 в минуту на тенанта/IP), интерцептор gRPC-ингресса :5082 (600/мин/тенанта, health освобождён), LoginAttemptGuard (5 неудач/15 мин → 429). Ответ 429 — {detail}.
  • mTLS (Ruling 6): env DEAL_MTLS_* (Enabled=false default) — Kestrel внутренних gRPC-эндпоинтов (+ ингресс core) и исходящие каналы core/telegram-service. Сертификаты — scripts/mtls-certs.shdeploy/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-сервисов — как §13 (core с Postgres :5433; операторские ручки/лимиты/аудит живут в том же процессе, AI — Local-режим).

9. Бэкапы и восстановление (Task 15, Ruling 8) — scripts/backup.sh / restore.sh

Реализация ежедневных бэкапов и восстановления — scripts/backup.sh + scripts/restore.sh (общие env-дефолты/хелперы — scripts/deal-backup-lib.sh). Планировщик — вне контейнера (cron/systemd, примеры ниже): скрипты ничего не ставят. Реальный прогон и restore-тест — ⚠ Manual (нужен поднятый docker-стек; здесь — синтаксис sh -n и error-path-проверки).

Что входит в бэкап (4 источника данных Ruling 8):

  1. Postgres — БД deal целиком (схемы public + tenant_*): pg_dump -Fc (custom, сжатие) → $BACKUP_DIR/pg/backup-<TS>.dump. По умолчанию — docker exec deal-postgres (локальный socket, пароль не нужен); при заданном DEAL_PG_HOST — прямое pg_dump с хоста.
  2. MinIO — бакет deal-files (вложения карточек): mc mirror$BACKUP_DIR/minio/backup-<TS>/ (бэкап = выгрузка ИЗ MinIO в BACKUP_DIR). mc берётся с хоста, если есть в PATH; иначе — разовый контейнер minio/mc в docker-сети контейнера MinIO. Секреты передаются env-алиасом MC_HOST_deal (в конфиг mc не пишутся). Endpoint: docker-режим — http://minio:9000 (алиас compose-сервиса, работает и в compose.dev, и в compose.prod; в prod-сети DNS deal-minio НЕ существует — там нет container_name); host-режим (dev, порт 9000 опубликован) — http://localhost:9000. Нестандартная схема — env DEAL_MINIO_ENDPOINT. compose.prod порты MinIO не публикует — для prod не ставьте хостовый mc (он не достанет MinIO), docker-режим работает из коробки. При Local-хранилище (MinIO не поднят) — DEAL_MINIO_SKIP=1.
  3. Файловые данные — tar каталогов DEAL_TAR_DIRS внутри DEAL_DATA_DIR$BACKUP_DIR/data/backup-<TS>.tar.gz. Дефолт: attachments (вложения Local-фолбэка), telegram_sessions (сессии telegram-service; контейнерный путь — /data/sessions, шифрованы AES-GCM — архив без доп. шифрования, доступ только root), ml (SQLite-модели ml-service). Для docker-томов задайте DEAL_TAR_VOLUMES (список имён, напр. deploy_deal_api_data deploy_deal_tg_sessions deploy_deal_ml_data; docker volume ls | grep deal_) — тар выполнит busybox-контейнер.
  4. Retention — удаление снапшотов старше RETENTION_DAYS (дата YYYYMMDD из имени файла/каталога, дефолт 14). При ежедневном запуске хранится ~15 копий (эквивалент find -mtime +14).

НЕ входит: сам BACKUP_DIR (не кладите его внутрь тарируемых каталогов), docker-образы и compose-конфиги, логи (data/logs — собственная rolling-ротация 30 дней), БД LeadRadar/прочие.

Структура и запуск (из корня репозитория):

# ежедневный бэкап: консоль + $BACKUP_DIR/logs/backup-YYYYMM.log; rc=0 при успехе
bash scripts/backup.sh
# восстановление (сначала остановите сервисы, см. ниже): всё из последнего снапшота /
# из снапшота с конкретной меткой / только шаг:
bash scripts/restore.sh                 # all — pg + minio + data из последнего pg-снапшота
bash scripts/restore.sh 20260908-021500 # TS вида YYYYMMDD-HHMMSS (из имени файла backup-<TS>…)
bash scripts/restore.sh pg|minio|data [TS]
# структура: $BACKUP_DIR/{pg,minio,data}/backup-YYYYMMDD-HHMMSS{,.dump,/,…}, logs/

Скрипты используют bash-специфику (set -o pipefail) — запускать именно bash … (или исполняемый файл ./scripts/backup.sh), НЕ sh … (на системах с dash/sh=bash-не-гарантированно).

BACKUP_DIR по умолчанию — <репозиторий>/data/backups (env BACKUP_DIR/DEAL_BACKUP_DIR). Dev-дефолты env соответствуют deploy/compose.dev.yml (БД deal/user deal; MinIO deal_minio/deal_minio_secret, бакет deal-files); прод-имена секретов читаются как fallback (DEAL_MINIO_ACCESS_KEYMINIO_ROOT_USER, DEAL_MINIO_SECRET_KEYMINIO_ROOT_PASSWORD; DEAL_PG_PASSWORD совпадает с compose.prod). Полная таблица env — шапка scripts/deal-backup-lib.sh.

Планировщик (вне контейнера; запуск от пользователя с доступом к docker):

# 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):

# 1) остановить core и сервисы (БД/тома не должны быть заняты):
docker compose -f deploy/compose.dev.yml stop core telegram-service ml-service          # dev
# prod: docker compose --env-file deploy/.env.prod -f deploy/compose.prod.yml stop
# 2) восстановить данные (шаг 1 → 3: pg → minio → data при restore all):
bash scripts/restore.sh
# 3) поднять сервисы обратно:
docker compose -f deploy/compose.dev.yml start core telegram-service ml-service

Восстановление — overlay: pg-шаг пересоздаёт БД целиком (dropdb+createdb, pg_restore --exit-on-error — rc=1 при любой ошибке), а minio/data дописывают ПОВЕРХ текущих данных: файл/объект, которого нет в снапшоте, останется. Строгий снимок бакета 1:1 — DEAL_MINIO_MIRROR_REMOVE=1 (mc mirror --remove); для data-каталогов/томов при необходимости очистите целевой каталог/том вручную перед распаковкой.

Рекомендация Ruling 8: раз в месяц — тест восстановления на отдельном инстансе/томах (поднять копию стека, restore.sh, curl-приёмка /api). Потеря данных при ежедневном бэкапе допустима ≤ 24 ч (SLA тестового этапа). Требования: bash + GNU date (coreutils), docker; секреты скрипты не логируют; параллельный запуск backup.sh не поддерживается.

10. Этап 10 — оператор-консоль, аналитика и аудит действий (Tasks 1–7)

Кратко (детали — план docs/superpowers/plans/2026-09-10-deal-stage10-operator-analytics.md, отчёты .superpowers/sdd/deal-stage10-operator-analytics/task-*-report.md, контракт docs/architecture/2026-09-10-operator-analytics-contract.md; api-map — §6; наблюдаемость — §7):

  • Фронт: hash-роутер без зависимостей (src/frontend/src/router.js; vue-router не добавлялся). Три верхнеуровневых экрана: #/ — основное приложение (как раньше), #/operator — консоль оператора (подразделы #/operator/<section>), #/join?code=… — активация инвайта. Разбор hash синхронный (первый рендер сразу на нужном экране). Операторская ссылка на активацию формируется функцией joinLink(code)origin+pathname#/join?code=<код>.
  • Оператор-консоль (src/frontend/src/views/operator/*): вход оператора (отдельная ручка и кука, экран выводит подсказку dev-дефолта operator / operator); разделы «Тенанты» (список/создание/suspend/resume/ impersonate), «Приглашения» (создание/отзыв/копирование ссылки), «Лимиты ИИ» (сводка/правка), «Аудит» (фильтры/пагинация), «Аналитика» (обзор/токены/действия), «Состояние системы». Внешних chart-библиотек нет — визуализации на Tailwind-компонентах.
  • Impersonation: POST /api/operator/tenants/{id}/impersonate выпускает tenant-сессию целевого пользователя (обычный механизм AuthService) и ставит httpOnly-куку deal_session в том же ответе (Deal.Api/Http/SessionCookieWriter.cs) — оператор сразу попадает в тенант; завершение — обычный POST /api/auth/logout (аудит impersonation_stopped).
  • Страница активации инвайта (src/frontend/src/views/JoinView.vue, #/join?code=…): форма email, имя пространства (необязательно), пароль (минимум 8 символов) → POST /api/join; кука не ставится — после успеха пользователь входит обычным POST /api/auth/login. Тексты причин отказа берутся с сервера как есть (код не найден/истёк/использован/отозван, email не совпал/занят, тенант не найден/приостановлен).
  • История расхода токенов (public.token_usage_events, миграция 20260910152246_AddTokenUsageEvents): Id (bigint identity), TenantId (uuid → public.tenants, Restrict), At (timestamptz), Provider, Model, Kind (ai|ml, text), PromptTokens/CompletionTokens/TotalTokens (bigint), DetailJson (text). Индексы (TenantId, At) и (At). Запись — единая точка TokenUsageRecorder в момент списания (успешный RPC ai-service — провайдер/модель из конфигурации; локальный ML-вызов — kind=ml, оценка токенов ≈ chars/4). Агрегат public.tenant_limits остаётся для гейта; история — для аналитики.
  • Операторская аналитика (read-only; src/core/Deal.Api/Endpoints/OperatorAnalyticsEndpoints.cs): GET /api/operator/analytics/overview, /tokens (groupBy=day|tenant|provider|model, неизвестное — 400), /activity (лента аудита с фильтрами и пагинацией). Все ответы — camelCase, время ISO-8601 (from/to включительно), без операторской сессии — 401 «Требуется вход оператора». GET /api/operator/audit расширен фильтром actorId и offset (ответ {items, total} без изменений). Полная форма запросов/ответов — в контракте (ссылка выше).
  • Аудит действий (этап 10, T1): единая точка AuditService/AuditAppender (append-only public.audit_log; актор tenant/operator/system; секреты не пишутся). К SaaS-событиям этапа 7 добавлены: tenant_logout, operator_logout, invite_joined, действия карточек (card_created, card_moved, card_trashed, card_restored, card_deleted, card_comment_added), контейнеры (container_created, container_updated, container_deleted), settings_updated, channel_enabled, telegram_linked (таблица — в контракте; channel_created зарезервирован, но не эмитится).
  • Наблюдаемость (Grafana provisioning + promtail-лейблы, дашборды Deal-Auth/Errors/Rps/Logs; с этапа 12 — метрики OTel → Prometheus и дашборд Deal-Metrics-Overview; трейсы OTel → Collector → Tempo (Deal-Traces) и ресурсы cAdvisor/node-exporter (Deal-Resources), см. §7).
  • Как открыть (dev): docker compose -f deploy/compose.dev.yml up -d --build (или core на :5080 с Postgres :5433, AI в Local-режиме) → фронт cd src/frontend && npm run dev (:5173, прокси /api) → оператор: http://localhost:5173/#/operator, вход operator/operator (dev-дефолт; в Production — env DEAL_OPERATOR_*); активация: http://localhost:5173/#/join?code=<код>; основное приложение — http://localhost:5173/#/. Prod-сценарий — §13.8.
  • Ограничения: реальные Telegram/LLM-креды — ⚠ Manual (по решению владельца); access-лог с BL-LOG-ACTOR содержит actor/tenant (IP в строке нет) — полный аудит действий в public.audit_log.

14. Локализация интерфейса (i18n, этап 11)

  • Назначение. Все пользовательские строки фронтенда вынесены из компонентов и логики в словари-ресурсы (единый источник текстов). Язык один — русский; переключатель языка и второй язык — в бэклоге (делаем, когда возникнет потребность).
  • Модуль src/frontend/src/i18n/:
    • index.js — ядро: t(key, params) с подстановкой {name}, реактивный locale (по умолчанию ru), setLocale(code), registerLocale(code, dict), availableLocales(), useI18n(). Фолбэк: активный язык → ru → сам ключ. Плагин Vue даёт шаблонам $t(...); в <script setup> t импортируется.
    • locales/ru.js — словарь сообщений (единственный источник текстов), сгруппирован по областям: common/ nav/ search/ cards/ drawer/ columns/ settings/ channels/ processing/ auth/ operator/ join/ errors/.
    • locales/ru.data.js — языковой контент-каталог (валюты, AI-провайдеры, дефолтные промпты, шаблоны); реэкспортируется из src/data.js, поэтому потребители не меняются.
    • errors.jslocalizeError(err, fallbackKey): известные HTTP-статусы и сетевые сбои → ключи errors/*; осмысленный {detail} бэка и всё неизвестное показываются как есть. Точка применения — errMsg в src/frontend/src/store/core.js.
  • Правила. Технические id/ключи/логи и бренд «Дейл» не локализуются; для исключений — директива i18n-ignore в строке. Значение ключа равно отображаемой строке (1:1).
  • Проверка. npm run lint:i18n (скрипт src/frontend/scripts/i18n-lint.mjs) падает, если вне src/i18n/locales/** осталась кириллица в пользовательских строках (комментарии и i18n-ignore игнорируются). Сборка — npm run build.
  • Механизм (если язык когда-нибудь понадобится):
    1. Создать словарь src/frontend/src/i18n/locales/<code>.js с теми же ключами (и, при необходимости, контент-каталог <code>.data.js).
    2. Зарегистрировать словарь через registerLocale('<code>', dict) при старте приложения.
    3. Вызвать setLocale('<code>'). Отсутствующие ключи берутся из ru; компоненты менять не нужно.
    4. Перевод форматирования дат/чисел (Intl) и плюрализации — вместе с языком.
  • Ограничение: константы, вычисленные один раз при загрузке модуля (контент-каталог, карты подсказок), держат текст стартового языка; «горячее» переключение потребовало бы обернуть их в computed.

15. Темы оформления (внешний вид, §8.12 ТЗ)

  • Назначение. В настройках появился раздел «Внешний вид»: выбор темы — «Тёмная» (по умолчанию), «Светлая» и «Системная». Переключение мгновенное, без перезагрузки; выбор запоминается на устройстве.
  • Устройство палитры. Все цвета — токены Tailwind v4 в src/frontend/src/style.css (блок @theme: --color-ink/panel/raise/hover/edge/hi/mid/low/brand/brand-2/online/danger/warn). Утилиты (bg-ink, text-hi, border-edge, …) ссылаются на эти переменные, поэтому тема меняется переопределением значений токенов, без правок компонентов.
  • Тёмная тема — дефолт (значения в @theme, вид 1:1, не менялся). Светлая включается атрибутом data-theme="light" на <html>: блок :root[data-theme="light"] переопределяет те же токены, а также тени (--shadow-card/--shadow-pop), цвет скроллбара, линии фоновой сетки на экранах входа и подложки встроенного Markdown (.tgmd). Здесь же выставляется color-scheme: light для нативных контролов (select/date/time), в тёмной — color-scheme: dark на html.
  • Полупрозрачные слои. Подсветки, разделители и hover свёрстаны как bg-white/N и border-white/N. На светлом фоне белое «поверх» невидимо, поэтому в светлой теме токен --color-white намеренно указывает на тёмный оттенок (#0b1220) — получаются мягкие серые подложки и границы. Литеральные text-white/bg-white (текст на бренд-градиенте, фон QR-кода, ползунок тумблера) остаются белыми — для них добавлены отдельные правила. Для текста поверх сплошной заливки введены токены --color-on-brand и --color-on-warn (тёмный на светлой заливке в тёмной теме и наоборот).
  • Где хранится выбор. src/frontend/src/composables/theme.js: значения dark | light | system, ключ localStorageleadradar_theme; setTheme() меняет атрибут data-theme и сохраняет выбор, режим system слушает prefers-color-scheme. Инлайн-скрипт в src/frontend/index.html применяет сохранённую тему к <html> до первого рендера — без «мигания» тёмной темы.
  • UI. Раздел «Настройки → Внешний вид» — вкладка appearance (src/frontend/src/components/settings/AppearanceTab.vue), добавлена в список вкладок SettingsView.vue рядом с «Уведомлениями». Все строки — в словаре (settings.vneshnij-vid, settings.tema-* в locales/ru.js).
  • Проверка. npm run build и npm run lint:i18n — зелёные; новых зависимостей нет.

16. Добивка по ТЗ (этап 12) — что добавлено

По итогам аудита docs/superpowers/reviews/2026-09-10-tz-compliance-audit.md закрыты частично/незакрытые пункты:

  • ML-проверка на канале/сообщении (§8, E11). POST /api/ml/candidates и POST /api/ml/apply — не заглушки: отдают реальные сообщения (очередь/отсев/карточки) с мнением ML и применяют ручную разметку (skip|spam|board:<id>) через существующие сервисы (обучение ML без дублей). Ядро — MlReviewService.
  • Глобальные исключения до ML/ИИ (§5.14). Настройки excludeKeywords/Locations/Types, excludeBudgetFrom/To; применяются на стоп-этапе, отсев пишет причину (exclude_kw/location/type/budget).
  • Группы фильтров колонки (§6.3). В rules добавлены levels/locations/types/prices; движок правил и matchHits дополнены метками «Уровень/Локация/Тип/Цена».
  • /api/operator/health (§10.2). Добавлены queues:{pipeline,mlOutbox} и sessions:{active} (общий RuntimeDepthsCollector, без дублей SQL).
  • Подозрительная активность (§10.5). SuspiciousActivityService + GET /api/operator/analytics/suspicious (всплеск неудачных входов по IP/логину, входы актора с множества IP, серии по тенанту, перебор разных логинов с одного IP distinct_logins_per_ip; пороги — константы). Плюс real-time учёт SuspiciousActivityReporter: метрика deal.security.suspicious{kind} и предупреждающий лог на 429 rate limiter (rate_limit) и блокировке входа (login_blocked).
  • «Открыть исходник» на карточке (§6.6) и темы оформления (§8.12, §15) — во фронтенде.

Итог: core-тесты 1275/1275; фронт npm run build + lint:i18n зелёные. Контракт API — docs/architecture/2026-09-10-unified-api-contract.md.