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