Инициализировать репозиторий «Дейл»
Первый коммит: модульный монолит ядра (.NET 10) и gRPC-сервисы ai/ml/telegram, фронтенд Vue 3/Vite/Tailwind, документация (ТЗ, инструкция пользователя, техдокументация, код-стайл), бэклог, скрипты развёртывания и архив прототипа LeadRadar.
This commit is contained in:
@@ -0,0 +1,272 @@
|
||||
# Дейл (Deal) — архитектурный дизайн-док
|
||||
|
||||
> Исторический документ (архитектурный дизайн-черновик, 2026-09-05). Актуальное состояние — `docs/superpowers/STATUS.md` и `docs/technical/Техническая-документация-Дейл.md`.
|
||||
|
||||
> Версия: 0.1 (черновик для согласования)
|
||||
> Дата: 2026-09-05
|
||||
> Статус: фиксирует согласованные решения по переписыванию LeadRadar в новый продукт «Дейл»
|
||||
|
||||
---
|
||||
|
||||
## 1. Контекст и цели
|
||||
|
||||
**LeadRadar** — рабочий прототип (Python/FastAPI/DuckDB/Vue), проверенный на тестовых данных.
|
||||
**«Дейл»** — новая реализация: SaaS-продукт, который мониторит Telegram-каналы и группы клиентов,
|
||||
отсеивает рекламу/скам/дубликаты и показывает **реальные заказы и клиентов**, совпадающих с
|
||||
профилем пользователя (сфера, стек, бюджет). Клиенты подключают свои Telegram-аккаунты.
|
||||
|
||||
### Цели переписывания
|
||||
1. Код, который владелец продукта может поддерживать сам (типизированный .NET вместо Python).
|
||||
2. Стабильность и строгость типов, интерфейсов, слоёв — «как сеньор-архитектор».
|
||||
3. Мультитенантный SaaS (схема на тенанта) — фундамент для роста до сотен/тысяч клиентов.
|
||||
4. Безопасность «с первого дня» (публичный продукт).
|
||||
5. Полная документация: ТЗ, инструкция пользователя, техдок — параллельно с кодом.
|
||||
|
||||
### Не-цели (сейчас)
|
||||
- Переписывание фронтенда (Vue остаётся как есть).
|
||||
- Kafka/кубер (отложены до реального масштаба; архитектура готова к ним).
|
||||
- Биллинг-провайдер (лимиты в ядре, биллинг — позже).
|
||||
- Саморегистрация тенантов (только инвайты).
|
||||
|
||||
---
|
||||
|
||||
## 2. Решения верхнего уровня (зафиксированы)
|
||||
|
||||
| # | Решение | Выбор |
|
||||
|---|---|---|
|
||||
| 1 | Стратегия | Big Bang: пишем новый бэкенд целиком; старые данные не мигрируем (тестовые) |
|
||||
| 2 | Фронтенд | Vue не трогаем; HTTP-контракт `/api/...` — замороженная спецификация миграции |
|
||||
| 3 | Архитектура | Модульный монолит в `core` (один процесс, одно sln); сервисы — отдельные процессы/sln |
|
||||
| 4 | Стек | .NET (актуальная LTS), C# современный, Postgres |
|
||||
| 5 | Мультитенантность | Одна Postgres-БД, **схема на тенанта** (`tenant_<id>.*`), системное в `public` |
|
||||
| 6 | Владение данными | Каждый модуль владеет своими таблицами; межмодульно — интерфейсы/доменные события |
|
||||
| 7 | Межпроцессно | gRPC + mTLS; шина событий за портом `IEventBus` (outbox → Kafka позже) |
|
||||
| 8 | Telegram | Отдельный `telegram-service`: ферма сессий, 1 аккаунт/тенант, анти-бан; исполняет команды ядра, ничего не знает о бизнес-логике |
|
||||
| 9 | ML | Отдельный `ml-service`: .NET + ONNX, пул моделей per-tenant, обучение на действиях |
|
||||
| 10 | AI (LLM) | Отдельный `ai-service`: фасад провайдеров, промпты, учёт токенов |
|
||||
| 11 | Клиенты SaaS | Подключают свои Telegram-аккаунты и настраивают обработку под свою сферу |
|
||||
| 12 | Доступ тенантов | Инвайты: тенанта создаёт оператор, клиент по ссылке задаёт пароль |
|
||||
| 13 | Аутентификация | Логин = email + пароль (email уникален глобально); `tenantId` в сессии/JWT |
|
||||
| 14 | Название | «Дейл» (бренд), namespace `Deal` |
|
||||
| 15 | Код-стайл | Документ пользователя + 5 адаптаций; 1 тип = 1 файл; `.editorconfig` + анализаторы |
|
||||
| 16 | Наблюдаемость | Serilog + OpenTelemetry → Grafana + Loki + Promtail |
|
||||
| 17 | Админка | Операторская (A): тенанты, лимиты, health, impersonation, аудит |
|
||||
| 18 | Бэкапы | Ежедневные: Postgres + minio + сессии |
|
||||
| 19 | Лимиты | Бюджет токенов на тенанта (LLM); fallback на ML/локальную обработку |
|
||||
| 20 | Деплой | docker compose на своём VPS; Cloudflare перед origin; k8s позже |
|
||||
|
||||
---
|
||||
|
||||
## 3. Структура репозитория
|
||||
|
||||
```
|
||||
src/
|
||||
core/ # МОДУЛЬНЫЙ МОНОЛИТ — один процесс, один sln
|
||||
Deal.sln
|
||||
Deal.Api/ # host: Web API (/api-контракт), gRPC-сервер, SSE, DI
|
||||
Deal.Modules.Pipeline/ # очередь → стоп-лист → дедуп → ML/ИИ → карточка
|
||||
Deal.Modules.Kanban/ # карточки, колонки, правила, архив/корзина
|
||||
Deal.Modules.Projects/ # «Выбранные» (проектный канбан)
|
||||
Deal.Modules.Discovery/ # поиск каналов, вступление, чёрный список
|
||||
Deal.Modules.Settings/ # настройки тенанта, промпты, валюты
|
||||
Deal.Modules.Tenants/ # тенанты, инвайты, лимиты, админка
|
||||
Deal.SharedKernel/ # Result, доменные события, время, tenant-контекст
|
||||
Deal.Infrastructure/ # Postgres, миграции, outbox, IEventBus, файлы (MinIO)
|
||||
Deal.Contracts/ # DTO для /api + gRPC-контракты наружу
|
||||
tests/ # Deal.Tests.* (unit/integration модулей)
|
||||
|
||||
ml-service/ # Deal.Ml.sln — .NET + ONNX, обучение/предсказание per-tenant
|
||||
ai-service/ # Deal.Ai.sln — LLM-фасад, промпты, учёт токенов
|
||||
telegram-service/ # Deal.Telegram.sln — ферма сессий, анти-бан
|
||||
|
||||
contracts/ # общие .proto (gRPC): ml.proto, ai.proto, telegram.proto
|
||||
frontend/ # Vue — переезжает как есть
|
||||
docker-compose.yml # dev-подъём всех процессов
|
||||
```
|
||||
|
||||
Правила:
|
||||
- `core` — единственное место с бизнес-логикой и БД.
|
||||
- Каждый сервис самодостаточен: свой sln, свой контейнер.
|
||||
- `.proto` — единственный общий «язык» между процессами, лежит в `contracts/`.
|
||||
|
||||
---
|
||||
|
||||
## 4. Мультитенантность
|
||||
|
||||
### Модель БД
|
||||
- Одна Postgres-БД, **схема на тенанта**: `tenant_<id>.*`.
|
||||
- Системные таблицы (реестр тенантов, пользователи, инвайты, глобальные настройки,
|
||||
ключи приложения Telegram) — в схеме `public`.
|
||||
- DAL получает схему из tenant-контекста (claim в JWT / gRPC-метаданные);
|
||||
пул соединений переключает `search_path`.
|
||||
- Миграции применяются ко всем схемам тенантов (специальный механизм, см. §10).
|
||||
- «Золотым» клиентам позже — выделенный инстанс: стратегия выбора схемы/БД в одном месте.
|
||||
|
||||
### Изоляция (критично)
|
||||
- `tenantId` **только из сессии/JWT**, никогда из тела запроса.
|
||||
- Каждый SQL-запрос исполняется в контексте схемы тенанта; модуль проверяет
|
||||
принадлежность объекта тенанту (IDOR-защита).
|
||||
- Интеграционные тесты на перекрёстный доступ тенантов — обязательны.
|
||||
|
||||
### Обработка per-tenant
|
||||
- Настройки обработки (стоп-фразы, промпты, колонки/правила, ключи) — per-tenant.
|
||||
- **ML-модель — per-tenant** (модель дизайнера не учится на действиях кровельщика):
|
||||
`ml-service` держит пул моделей, core передаёт `tenantId` в каждом вызове.
|
||||
|
||||
---
|
||||
|
||||
## 5. Модули core и их границы
|
||||
|
||||
Модули заводятся сразу как отдельные проекты; **внутренние интерфейсы между ними
|
||||
не выдумываются заранее** — появляются в момент реальной зависимости.
|
||||
|
||||
| Модуль | Ответственность | Владеет таблицами (в схеме тенанта) |
|
||||
|---|---|---|
|
||||
| Pipeline | очередь входящих → стоп-лист → дедуп → ML/ИИ → карточка; отсев; обработка | очередь, отсев, dedup |
|
||||
| Kanban | карточки, колонки, правила, архив/корзина, комментарии, файлы | карточки, колонки |
|
||||
| Projects | «Выбранные»: свой канбан, стадии, история, напоминания | проекты |
|
||||
| Discovery | задачи поиска каналов, кандидаты, чёрный список, квоты | discovery-таблицы |
|
||||
| Settings | настройки тенанта, промпты, валюты | настройки |
|
||||
| Tenants | тенанты, пользователи, инвайты, лимиты, аудит, операторская админка | tenant-реестр (в `public`) |
|
||||
|
||||
Общие справочники (например, «колонки» нужны и Pipeline при создании карточки, и Kanban
|
||||
при отрисовке) живут в модуле-владельце (Kanban); доступ — через его публичный интерфейс.
|
||||
|
||||
---
|
||||
|
||||
## 6. Контракты
|
||||
|
||||
### 6.1 `/api` — замороженный контракт миграции
|
||||
- Фронтенд Vue продолжает ходить в `/api/...` без изменений.
|
||||
- Снимаем точную карту с работающего LeadRadar (эндпоинты + формы ответов, которые
|
||||
реально потребляет фронт) → фиксируем как OpenAPI-спецификацию.
|
||||
- Новый `Deal.Api` обязан воспроизводить её 1:1.
|
||||
- Ведём реестр «кривых мест»: если правка фронта на 1 строку убирает слой костылей —
|
||||
выносим на решение владельца по одному (не молча).
|
||||
|
||||
### 6.2 gRPC-контракты (`contracts/`)
|
||||
- `telegram.proto`: команды ядра (подключить аккаунт, слушать канал, перечитать,
|
||||
вступить/выйти) + поток сырых сообщений → ядро.
|
||||
- `ml.proto`: predict (текст → решение), train (действие → обучение), health.
|
||||
- `ai.proto`: classify/filter/generate (текст → структура), учёт токенов.
|
||||
- Каждый вызов несёт `tenantId`; сервисы проверяют принадлежность по своей модели
|
||||
(сессии/модели), не доверяя полю на слово.
|
||||
|
||||
### 6.3 Шина событий
|
||||
- Порт `IEventBus` в SharedKernel.
|
||||
- Реализация сейчас: outbox в Postgres (транзакционно событие + эффект, фоновый диспетчер).
|
||||
- Kafka — позже, сменой реализации без правки бизнес-логики.
|
||||
|
||||
---
|
||||
|
||||
## 7. Сервисы
|
||||
|
||||
### 7.1 telegram-service
|
||||
- Отдельный процесс, свой sln. Ничего не знает о данных и бизнес-логике.
|
||||
- **Сессии привязаны к тенанту** (`tenantId → session`, 1:1): команды исполняются только
|
||||
на сессии своего тенанта; нет сессии для tenantId → отказ.
|
||||
- Проверка принадлежности диалога: read/subscribe только для диалогов аккаунта тенанта.
|
||||
- Join — только от имени тенанта, под его квотами и анти-баном.
|
||||
- Исходящий поток сообщений помечен `tenantId` (источник определён на входе, в сервисе).
|
||||
- Сервисная аутентификация (mTLS) + аудит команд `(tenantId, действие, диалог, результат)`.
|
||||
- Один аккаунт на тенанта на старте (связь тенант→аккаунты уже таблицей — расширение позже).
|
||||
|
||||
### 7.2 ml-service
|
||||
- .NET + ONNX (не ML.NET для онлайн-обучения): пул моделей по тенантам, обучение на
|
||||
реальных действиях пользователя и результатах ИИ.
|
||||
- Ничего не знает о домене: получает текст, отдаёт решение; обучение — по контракту.
|
||||
- Экспорт/импорт моделей — по контракту (для переноса между инстансами).
|
||||
|
||||
### 7.3 ai-service
|
||||
- Фасад LLM-провайдеров (DeepSeek и др., включая локальные OpenAI-совместимые),
|
||||
библиотека промптов, классификация, генерация.
|
||||
- **Учёт токенов**: каждый вызов оценивается в токенах и списывается с бюджета тенанта.
|
||||
- При исчерпании бюджета — fallback на ML/локальную обработку + уведомление
|
||||
(приём сообщений не блокируется).
|
||||
|
||||
---
|
||||
|
||||
## 8. Безопасность
|
||||
|
||||
### Слой приложения (core)
|
||||
- SQL-инъекции: запрет конкатенации SQL; только параметризация (EF Core/Dapper);
|
||||
анализаторы; Postgres-роль без DDL.
|
||||
- Tenant-изоляция (IDOR): tenantId из сессии; проверка принадлежности; тесты.
|
||||
- Аутентификация: Argon2id, лимит попыток, одноразовые инвайты с expiry.
|
||||
- Сессии: httpOnly cookie + CSRF (не localStorage).
|
||||
- XSS: экранирование на фронте (renderSourceMessage), CSP, запрет v-html без санитайзера.
|
||||
- SSRF: ai/telegram не тянут произвольные URL от имени тенанта (allowlist).
|
||||
- Валидация входа: DTO + FluentValidation, лимиты размеров.
|
||||
- Аудит: входы, инвайты, impersonation, действия оператора — неизменяемый поток.
|
||||
|
||||
### Транспорт/сервисы
|
||||
- TLS везде; mTLS между сервисами; service-token второй фактор.
|
||||
|
||||
### Инфраструктура
|
||||
- Cloudflare (DDoS/WAF) → reverse proxy (TLS, rate limit по IP, security-заголовки).
|
||||
- Rate limiting в приложении по тенанту (защита от «шумного соседа»).
|
||||
- Docker: сервисы в изолированной сети, наружу — только прокси; non-root, read-only FS.
|
||||
- Секреты: env/secret-хранилище; шифрование (enc); ничего в коде/репозитории.
|
||||
|
||||
### Процессы
|
||||
- CI: сканирование зависимостей (NuGet/npm), SAST, trivy-скан образов.
|
||||
- Обновления и алерты на CVE.
|
||||
- Postgres: бэкапы ежедневные, тест восстановления.
|
||||
|
||||
---
|
||||
|
||||
## 9. Наблюдаемость, админка, бэкапы
|
||||
|
||||
### Observability
|
||||
- Serilog (структурированные логи) + OpenTelemetry (метрики/трейсы) → Promtail → **Grafana + Loki**.
|
||||
- Дашборды: health сервисов, pipeline, ML-качество, расход токенов по тенантам.
|
||||
- За абстракцией экспорта — смена стека без правки кода.
|
||||
|
||||
### Операторская админка (только оператору)
|
||||
- Создание тенантов и инвайтов, лимиты, health, impersonation (с полным аудитом),
|
||||
подозрительная активность. Отдельный защищённый вход (оператор ≠ тенант).
|
||||
|
||||
### Бэкапы
|
||||
- Ежедневно: Postgres (pg_dump), файлы MinIO, сессии telegram.
|
||||
- Retention и внешняя выгрузка — уточнить на этапе деплоя.
|
||||
|
||||
---
|
||||
|
||||
## 10. Деплой
|
||||
|
||||
- docker compose на одном VPS: core, ml-service, ai-service, telegram-service,
|
||||
postgres, minio, grafana/loki/promtail, reverse proxy.
|
||||
- Сервисы compose = будущие k8s-деплойменты (никаких завязок на compose в коде).
|
||||
- Миграции схем тенантов: механизм «миграция ко всем схемам» (список схем в `public`,
|
||||
применение по очереди, версия миграции на схему) — детализировать в плане реализации.
|
||||
|
||||
---
|
||||
|
||||
## 11. Стандарты кода
|
||||
|
||||
- Код-стайл: `C:\telbase\Стиль_кода.docx` + согласованные адаптации
|
||||
(без snake_case-хелперов и регионов, public-поля → свойства, XML-doc для public-контрактов,
|
||||
настройки через `IOptions<T>`, комментарии на русском).
|
||||
- 1 тип = 1 файл (класс/record/struct/enum/interface — отдельный файл).
|
||||
- `.editorconfig` + Roslyn-анализаторы с ошибками на нарушения.
|
||||
- Второй слой правил: скилы `agent-rules-books` (Clean Code, DDD, DDIA).
|
||||
- .NET-эталоны: скил `dotnet-clean-architecture-skills` (адаптировать под проект).
|
||||
|
||||
---
|
||||
|
||||
## 12. Открытые вопросы / следующие шаги
|
||||
|
||||
1. **Карта `/api`**: снять точную спецификацию с работающего LeadRadar (отдельная задача).
|
||||
2. **Детали лимитов**: механика «бюджет токенов» (период, пороги, уведомления) — спроектировать.
|
||||
3. **Бэкапы**: точная схема retention/внешнего хранилища.
|
||||
4. **Миграции на 1000 схем**: детальный механизм.
|
||||
5. Порядок реализации: этап 0 (каркас) → Pipeline+Kanban → ai/ml/telegram → Projects/Discovery.
|
||||
|
||||
---
|
||||
|
||||
## Приложение: глоссарий
|
||||
|
||||
- **Тенант** — клиент SaaS (одна организация/пользователь), владеет схемой БД и настройками.
|
||||
- **Канал/источник** — Telegram-канал/группа, который слушает аккаунт тенанта.
|
||||
- **Карточка** — структурированная заявка (заказ/вакансия), созданная пайплайном.
|
||||
- **Outbox** — паттерн надёжной доставки событий через таблицу в той же транзакции.
|
||||
@@ -0,0 +1,92 @@
|
||||
# Дейл — единая модель карточки (unified card)
|
||||
|
||||
> Исторический документ (дизайн этапа 9, 2026-09-09). Актуальное состояние — `docs/superpowers/STATUS.md` и `docs/technical/Техническая-документация-Дейл.md`.
|
||||
|
||||
> Дата: 2026-09-09
|
||||
> Статус: дизайн согласован с владельцем продукта (в чате), начало реализации.
|
||||
> Связанные документы: `docs/spec/ТЗ-дейл-новая-архитектура.md`, `docs/architecture/2026-09-05-deal-architecture-design.md`.
|
||||
|
||||
## Проблема
|
||||
|
||||
Сейчас в системе **два «домена» карточек**, хотя по смыслу это одна сущность:
|
||||
|
||||
| | Канбан (`/api/leads`) | «Выбранные» (`/api/projects`) |
|
||||
|---|---|---|
|
||||
| Таблица | `Cards` | `ProjectCards` |
|
||||
| Контейнер | колонка `inbox/board/archive/trash/taken` | стадия `planned…finished/rejected` |
|
||||
| «Взять в работу» | `col=taken` + **копия полей** в `ProjectCards` | создание второй записи |
|
||||
| Драйвер/карточка | `CardDto` | `ProjectCardDto` |
|
||||
|
||||
Переход «лид → проектная карточка» — это **клонирование в другую сущность**: у карточки меняется id, теряется связность истории, третий вид карточки/дашборда потребует третьей таблицы и третьего конвейера.
|
||||
|
||||
**Решение (согласовано):** карточка — **один агрегат** во всех дашбордах. Понятие «лид» упраздняется: сообщение из канала — это *входные данные*, из которых создаётся карточка. «Взял в работу» — это **переход карточки в другой контейнер** той же доски пространства «Выбранные», а не создание новой записи.
|
||||
|
||||
## Модель (C#)
|
||||
|
||||
### Ядро
|
||||
|
||||
```csharp
|
||||
/// Единственное, что есть у любой карточки.
|
||||
public interface ICard
|
||||
{
|
||||
string Id { get; }
|
||||
string Title { get; }
|
||||
ISource Source { get; } // откуда пришла (см. ниже)
|
||||
}
|
||||
|
||||
/// Типизированная проекция для сценариев, которым нужен конкретный источник.
|
||||
public interface ICard<TSource> : ICard where TSource : ISource
|
||||
{
|
||||
new TSource Source { get; }
|
||||
}
|
||||
```
|
||||
|
||||
### Источники (ISource) — иерархия, а не enum-свойство
|
||||
|
||||
- `ISource` — общее: `DisplayName`, `OriginRef`, `RawPayload`, `ReceivedAt`.
|
||||
- Простые: `ILocalSource`, `IWebSource`, `IFileSource`.
|
||||
- Сложные: `ITelegramSource` (dialogId/messageId/peer/topic), `IRowSource` (импорт колонки/строки), `IApiSource`, `IAiSource` (провайдер+модель+агент), `ICompositeSource { Origin, Pipeline[] }`.
|
||||
|
||||
### Модули-роли карточки (опциональные части одного агрегата)
|
||||
|
||||
`IContentCard` (блок «О заявке»), `IBudgetedCard`, `IContactCard`, `IAttributedCard` (стек/грейд/локация — настраиваемые атрибуты тенанта), `ICommentableCard`, `ILinkCard`, `IFileCard`, `ITzCard`, `ITraceableCard` (история), `IRemindableCard`, `ILocatedCard` (контейнер + prev + isNew).
|
||||
|
||||
Вид карточки = композиция модулей, **не класс-наследник**. Новый дашборд/вид — новая композиция + при необходимости новый модуль.
|
||||
|
||||
### Контейнеры (общая база колонок/стадий/зон)
|
||||
|
||||
```csharp
|
||||
public interface IContainer
|
||||
{
|
||||
string Id { get; }
|
||||
string Name { get; }
|
||||
string Color { get; }
|
||||
int Order { get; }
|
||||
IContainerRules? Rules { get; } // фильтры попадания (пользовательские колонки)
|
||||
IContainerPolicy Policy { get; } // поведение (роль, не enum)
|
||||
}
|
||||
```
|
||||
|
||||
Политики: возврат/очистка (корзина 7д, архив 90д), терминальность («Отклонено/Выполнено» — только ручная очистка), «выбранные не попадают в архив дашборда». Отсев пайплайна — **не карточка**, вне этой модели.
|
||||
|
||||
### Переходы
|
||||
|
||||
Один `ICardMover.MoveAsync(card, toContainerId, ctx)`; правила — в политиках контейнеров и «воротах» между пространствами; побочные эффекты карточка делает через свои модули (`ITraceableCard` пишет историю, `IRemindableCard` сбрасывает напоминание, `ILocatedCard.IsNew=false`).
|
||||
|
||||
## Терминология
|
||||
|
||||
- ~~лид, lead~~ → **карточка (card)**; входное сообщение → **сообщение-источник**.
|
||||
- ~~проектная карточка~~ → карточка в контейнерах пространства «Выбранные».
|
||||
- «Взять в работу» → переход в контейнер `planned`.
|
||||
|
||||
## Что меняется
|
||||
|
||||
- **БД**: таблицы `Cards` + `ProjectCards` → одна `Cards` (+ модульные данные); доски и стадии — единый реестр контейнеров; удаляется `ProjectCards`, перенос `LeadComments` в модуль карточки.
|
||||
- **Бэк**: модули Kanban и Projects объединяются в один модуль карточки/контейнеров; порты/сервисы/адаптеры/DTO — единые.
|
||||
- **Pipeline**: создаёт карточку (не «лид»), кладёт в контейнер по правилам.
|
||||
- **API**: единый контракт `/api/cards` + `/api/containers`; `/api/leads`, `/api/projects` упраздняются (фронт переписывается).
|
||||
- **Фронт**: один state-слайс карточек, один рендер карточки/драйвера, один канбан-компонент.
|
||||
|
||||
## Границы этапа
|
||||
|
||||
Данные тестовые — схема пересоздаётся, миграции данных нет. Вне рамок: Kafka, «третьи» дашборды (архитектура готова), разовые миграции.
|
||||
@@ -0,0 +1,252 @@
|
||||
# Дейл — контракт операторской аналитики и аудита действий (этап 10, T1–T3)
|
||||
|
||||
> Дата: 2026-09-10
|
||||
> Статус: контракт для фронта (оператор-консоль, T4). Источник истины для `src/frontend`.
|
||||
> Связанные документы: `docs/superpowers/plans/2026-09-10-deal-stage10-operator-analytics.md`,
|
||||
> `docs/architecture/2026-09-10-unified-api-contract.md`.
|
||||
|
||||
## Общие правила
|
||||
|
||||
- **Только операторская сессия.** Все ручки `/api/operator/*` (включая аналитику) требуют разрешённой
|
||||
операторской сессии (кука `deal_operator_session`). Без неё — `401 { "detail": "Требуется вход оператора" }`.
|
||||
- Все ответы — JSON **camelCase**.
|
||||
- **Время на wire в этих ручках — ISO-8601** (`DateTimeOffset`, UTC, напр. `2026-09-10T15:22:46.123Z`).
|
||||
Query-параметры `from`/`to` и поля `at`/`from`/`to` используют ISO-8601 (как уже принято в
|
||||
`GET /api/operator/audit`). Ключи агрегатов `groupBy=day` — строки `ГГГГ-ММ-ДД`.
|
||||
- Диапазоны `from`/`to` — **включительно**; не заданы — без границы.
|
||||
- Ошибки — `{ "detail": "текст" }`. Коды: `400` (некорректный ввод, напр. неизвестный `groupBy`),
|
||||
`401` (нет операторской сессии).
|
||||
- Все ручки аналитики — **read-only** (ничего не меняют).
|
||||
|
||||
## Каталог событий аудита (для фильтров `eventType` / ленты действий)
|
||||
|
||||
К SaaS-событиям этапа 7 добавлены (этап 10, T1; актор `tenant` — действия пользователя тенанта):
|
||||
|
||||
| `eventType` | Когда |
|
||||
|---|---|
|
||||
| `tenant_logout` | выход пользователя тенанта (`POST /api/auth/logout`) |
|
||||
| `operator_logout` | выход оператора (`POST /api/operator/auth/logout`) |
|
||||
| `invite_joined` | активация инвайта (`POST /api/join`) |
|
||||
| `card_created` | создание карточки |
|
||||
| `card_moved` | перенос карточки между контейнерами |
|
||||
| `card_trashed` | карточка отправлена в корзину |
|
||||
| `card_restored` | карточка возвращена из корзины/архива |
|
||||
| `card_deleted` | карточка удалена навсегда |
|
||||
| `card_comment_added` | добавлен комментарий к карточке |
|
||||
| `container_created` | создан контейнер/колонка |
|
||||
| `container_updated` | изменён контейнер/колонка |
|
||||
| `container_deleted` | удалён контейнер/колонка |
|
||||
| `settings_updated` | сохранены настройки тенанта |
|
||||
| `channel_enabled` | включён мониторинг канала Telegram |
|
||||
| `channel_created` | канал добавлен в каталог (резерв каталога) |
|
||||
| `telegram_linked` | аккаунт Telegram привязан (фаза `ready`) |
|
||||
| `telegram_keys_changed` | оператор изменил глобальные ключи Telegram (`PUT /api/operator/settings/telegram-keys`) |
|
||||
|
||||
Типы акторов (`actorType`): `tenant`, `operator`, `system`. Секреты (пароли, токены, api-ключи) в
|
||||
`detailJson` **не пишутся**.
|
||||
|
||||
---
|
||||
|
||||
## GET /api/operator/analytics/overview
|
||||
|
||||
Сводка за период: тенанты, расход токенов, события, входы/выходы/неудачные входы.
|
||||
|
||||
**Query**
|
||||
|
||||
| Параметр | Тип | Обяз. | Описание |
|
||||
|---|---|---|---|
|
||||
| `from` | ISO-8601 | нет | начало периода (включительно) |
|
||||
| `to` | ISO-8601 | нет | конец периода (включительно) |
|
||||
|
||||
**200**
|
||||
|
||||
```json
|
||||
{
|
||||
"tenantsTotal": 12,
|
||||
"tenantsActive": 10,
|
||||
"promptTokens": 1250000,
|
||||
"completionTokens": 320000,
|
||||
"totalTokens": 1570000,
|
||||
"tokenEvents": 842,
|
||||
"events": 5012,
|
||||
"logins": 320,
|
||||
"logouts": 288,
|
||||
"failedLogins": 17,
|
||||
"from": "2026-09-01T00:00:00Z",
|
||||
"to": "2026-10-01T00:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
- `tokens*`/`tokenEvents` — сумма по событиям `public.token_usage_events` за период.
|
||||
- `events` — число записей аудита за период.
|
||||
- `logins` = `tenant_login_ok` + `operator_login_ok`; `logouts` = `tenant_logout` + `operator_logout`;
|
||||
`failedLogins` = `tenant_login_failed` + `operator_login_failed`.
|
||||
|
||||
**Коды**: `200`, `401`.
|
||||
|
||||
---
|
||||
|
||||
## GET /api/operator/analytics/tokens
|
||||
|
||||
Серия/агрегаты расхода токенов.
|
||||
|
||||
**Query**
|
||||
|
||||
| Параметр | Тип | Обяз. | Описание |
|
||||
|---|---|---|---|
|
||||
| `groupBy` | enum | нет | `day` (дефолт) \| `tenant` \| `provider` \| `model` |
|
||||
| `tenantId` | uuid | нет | фильтр по тенанту |
|
||||
| `from` | ISO-8601 | нет | начало периода (включительно) |
|
||||
| `to` | ISO-8601 | нет | конец периода (включительно) |
|
||||
|
||||
**200**
|
||||
|
||||
```json
|
||||
{
|
||||
"groupBy": "day",
|
||||
"from": "2026-09-01T00:00:00Z",
|
||||
"to": "2026-10-01T00:00:00Z",
|
||||
"items": [
|
||||
{ "key": "2026-09-10", "promptTokens": 1200, "completionTokens": 300, "totalTokens": 1500, "eventCount": 42 }
|
||||
],
|
||||
"total": { "key": "total", "promptTokens": 1250000, "completionTokens": 320000, "totalTokens": 1570000, "eventCount": 842 }
|
||||
}
|
||||
```
|
||||
|
||||
- `key` группы: `day` — `ГГГГ-ММ-ДД` (сутки UTC); `tenant` — Guid `D`; `provider` — id провайдера
|
||||
(`deepseek`/`openai`/…, для ML — `local`); `model` — модель (`ml` для локальной ML-модели).
|
||||
- Порядок `items`: `day` — по возрастанию даты; `tenant`/`provider`/`model` — по убыванию `totalTokens`.
|
||||
- `total` — итог по всем строкам.
|
||||
|
||||
**Коды**: `200`; `400 { "detail": "Неизвестная группировка (day|tenant|provider|model)" }`; `401`.
|
||||
|
||||
---
|
||||
|
||||
## GET /api/operator/analytics/activity
|
||||
|
||||
Лента действий (аудит) с фильтрами и пагинацией.
|
||||
|
||||
**Query**
|
||||
|
||||
| Параметр | Тип | Обяз. | Описание |
|
||||
|---|---|---|---|
|
||||
| `eventType` | string | нет | тип события (см. каталог) |
|
||||
| `actorType` | enum | нет | `tenant` \| `operator` \| `system` |
|
||||
| `actorId` | uuid | нет | идентификатор актора |
|
||||
| `tenantId` | uuid | нет | тенант |
|
||||
| `from` | ISO-8601 | нет | нижняя граница `at` (включительно) |
|
||||
| `to` | ISO-8601 | нет | верхняя граница `at` (включительно) |
|
||||
| `limit` | int | нет | размер страницы (дефолт 100, кламп 1..500) |
|
||||
| `offset` | int | нет | смещение (≥0) |
|
||||
|
||||
**200**
|
||||
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"eventType": "card_moved",
|
||||
"actorType": "tenant",
|
||||
"actorId": "1f2e3d4c-5b6a-7980-1234-56789abcdef0",
|
||||
"tenantId": "aabbccdd-eeff-0011-2233-445566778899",
|
||||
"ip": "203.0.113.7",
|
||||
"detailJson": "{\"cardId\":\"c_1a2b3c4d5e6f\",\"to\":\"planned\"}",
|
||||
"at": "2026-09-10T15:22:46.123Z",
|
||||
"id": 1042
|
||||
}
|
||||
],
|
||||
"total": 5012,
|
||||
"limit": 100,
|
||||
"offset": 0
|
||||
}
|
||||
```
|
||||
|
||||
- `items` — новые сверху (`at` DESC). `total` — полное число по фильтру (без `limit`/`offset`).
|
||||
- `detailJson` — **строка** JSON деталей события (без секретов), может быть `null`.
|
||||
|
||||
**Коды**: `200`, `401`.
|
||||
|
||||
---
|
||||
|
||||
## Расширение GET /api/operator/audit
|
||||
|
||||
К прежним фильтрам (`eventType`, `actorType`, `tenantId`, `from`, `to`, `limit`) добавлены:
|
||||
|
||||
| Параметр | Тип | Обяз. | Описание |
|
||||
|---|---|---|---|
|
||||
| `actorId` | uuid | нет | фильтр по идентификатору актора |
|
||||
| `offset` | int | нет | смещение страницы (≥0, дефолт 0) |
|
||||
|
||||
Ответ — прежний `{ "items": [...], "total": n }` (поля `items`/`total` без изменений; форма записи —
|
||||
как в ленте действий выше). **Коды**: `200`, `401`.
|
||||
|
||||
---
|
||||
|
||||
## Операторские настройки: глобальные ключи Telegram
|
||||
|
||||
Ключи приложения Telegram (`api_id`/`api_hash`) задаются оператором **глобально** (ТЗ §4.1/§8.1),
|
||||
едины для всех тенантов. Тенант их не видит и не задаёт (ключ `tgKeys` удалён из `GET/PATCH /api/settings`).
|
||||
Хранилище — системная таблица `public.global_settings` (ключ `telegramKeys`), `apiHash` хранится
|
||||
зашифрованным и наружу не отдаётся.
|
||||
|
||||
### GET /api/operator/settings/telegram-keys
|
||||
|
||||
Маскированный снимок глобальных ключей.
|
||||
|
||||
**200**
|
||||
|
||||
```json
|
||||
{
|
||||
"apiId": "1234567",
|
||||
"apiHash": "abcd…mnop",
|
||||
"keysSet": true
|
||||
}
|
||||
```
|
||||
|
||||
- `apiId` — открыт (не секрет; пусто — ключи не заданы оператором).
|
||||
- `apiHash` — **маска** (пусто / `x…` / `1234…5678`); открытый секрет не возвращается никогда.
|
||||
- `keysSet` — `true`, если заданы оба ключа; в `GET /api/tg/status` это же значение в поле `keysSet`.
|
||||
|
||||
**Коды**: `200`, `401`.
|
||||
|
||||
### PUT /api/operator/settings/telegram-keys
|
||||
|
||||
Сохранение/смена глобальных ключей. Поля можно передавать **по отдельности** (частичное обновление):
|
||||
непереданное поле (`null` или отсутствие в JSON) сохраняет текущее значение. Если ключей ещё нет,
|
||||
оба поля обязательны.
|
||||
|
||||
**Тело**
|
||||
|
||||
```json
|
||||
{ "apiId": "1234567", "apiHash": "abcdefghijklmnop" } // полное обновление
|
||||
```
|
||||
|
||||
```json
|
||||
{ "apiId": "7654321" } // только apiId — apiHash сохраняется
|
||||
```
|
||||
|
||||
```json
|
||||
{ "apiHash": "newsecrethash12" } // только apiHash — apiId сохраняется
|
||||
```
|
||||
|
||||
- `apiId` — если передан, строго 5–9 цифр; если не передан, берётся текущий (`null` = «не менялось»).
|
||||
- `apiHash` — если передан, непустой секрет (не маска и без префикса `enc:`), шифруется перед сохранением;
|
||||
если не передан, берётся текущий зашифрованный секрет.
|
||||
- Явное пустое значение (`""`) считается невалидным, а не «не менялось».
|
||||
|
||||
**200** — маскированный снимок (форма как у GET).
|
||||
|
||||
**Ошибки**
|
||||
|
||||
- `400 { "detail": "Укажите api_id и api_hash" }` — не передано ни одного поля.
|
||||
- `400 { "detail": "Ключи ещё не заданы — укажите и api_id, и api_hash" }` — частичное обновление,
|
||||
но ключей ещё нет (нельзя дополнить отсутствующее значение).
|
||||
- `400 { "detail": "api_id должен состоять из 5–9 цифр" }`
|
||||
- `400 { "detail": "Укажите непустой api_hash" }`
|
||||
- `401 { "detail": "Требуется вход оператора" }`
|
||||
|
||||
**Аудит**: событие `telegram_keys_changed` (актор `operator`, `tenantId: null`, детали `{apiId, apiHashSet}` — без секрета).
|
||||
|
||||
> Примечание для вкладки Telegram у тенанта: `GET /api/tg/status` остаётся (подключение аккаунта),
|
||||
> поле `keysSet` отражает глобальные ключи; команды `start-phone`/`start-qr` без ключей отвечают
|
||||
> `400 { "detail": "Ключи Telegram не заданы оператором" }`.
|
||||
@@ -0,0 +1,433 @@
|
||||
# Дейл — единый API-контракт этапа 9 (cards + containers)
|
||||
|
||||
> Дата: 2026-09-10
|
||||
> Статус: контракт для портирования фронта (T6). Источник истины для `src/frontend`.
|
||||
> Связанные документы: `docs/architecture/2026-09-09-unified-card.md`,
|
||||
> `docs/superpowers/plans/2026-09-09-deal-stage9-unified-card.md` (T4–T6, R5).
|
||||
|
||||
## Общие правила
|
||||
|
||||
- **Только два домена API**: `/api/cards` (карточки) и `/api/containers` (колонки/стадии/зоны).
|
||||
Старые ручки `/api/leads`, `/api/projects`, `/api/boards` **удалены**.
|
||||
- Все ответы и тела запросов — JSON **camelCase**.
|
||||
- Время на wire — **epoch-ms** (`int64`, UTC). Внутри — `DateTimeOffset` (UTC).
|
||||
- Ошибки — объект `{ "detail": "текст" }`. Коды: `400` (некорректный ввод), `401` (нет сессии),
|
||||
`404` (объект не найден), `422` (тело не разобрано).
|
||||
- Аутентификация — сессионная кука (как раньше). Без сессии — `401 {detail}`.
|
||||
- Контейнер — единый реестр колонок/стадий/зон. Карточка ссылается на контейнер полем
|
||||
`containerId` (алиас прежнего `col`). Пространства: `dashboard` (дашборд) и `selected`
|
||||
(«Выбранные»). Карточка живёт в одном пространстве: её `containerId` однозначно определяет,
|
||||
где она показана.
|
||||
- Виды контейнеров (`kind`): `board` (пользовательская колонка-фильтр), `stage` (стадия
|
||||
«Выбранных»), `service` (inbox/archive/trash), `terminal` (finished/rejected).
|
||||
|
||||
## SSE (`GET /api/events`)
|
||||
|
||||
Поток `text/event-stream`, канал тенанта сессии. Типы событий:
|
||||
|
||||
| `event` | `data` | Когда |
|
||||
|---|---|---|
|
||||
| `new_card` | объект **Card** (см. ниже) | создана карточка (пайплайн, демо, тик) |
|
||||
| `reminder_due` | `{ "id", "title", "containerId" }` | наступило напоминание |
|
||||
| `toast` | `{ "text", "icon" }` | статистика тика / служебное уведомление |
|
||||
| `cards_reclassified` | `{ "reclassified", "moved" }` | завершена переклассификация карточки/«Неразобранного» |
|
||||
|
||||
`new_lead` больше не публикуется (переименован в `new_card`).
|
||||
|
||||
---
|
||||
|
||||
## Card (карточка)
|
||||
|
||||
Единая сущность во всех дашбордах. Модульные поля (контакты/ссылки/файлы/ТЗ/история/напоминание)
|
||||
присутствуют всегда, но могут быть пустыми.
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "c_1a2b3c4d5e6f",
|
||||
"containerId": "inbox",
|
||||
"col": "inbox",
|
||||
"isNew": true,
|
||||
"local": false,
|
||||
"title": "Разработка интернет-магазина",
|
||||
"summary": "Компания: ...\nЗадача: ...",
|
||||
"source": {
|
||||
"kind": "telegram",
|
||||
"displayName": "Канал заказов",
|
||||
"originRef": "123456789",
|
||||
"receivedAt": 1726000000000
|
||||
},
|
||||
"sourceMsg": "Ищу разработчика...",
|
||||
"sourceDialogId": "123456789",
|
||||
"sourceMsgId": 4242,
|
||||
"stack": ["vue", "dotnet"],
|
||||
"budget": { "from": 100000, "to": 200000, "cur": "RUB" },
|
||||
"converted": { "from": 100000, "to": 200000, "cur": "RUB" },
|
||||
"contact": "@client",
|
||||
"contacts": [{ "type": "tg", "value": "@client" }],
|
||||
"channel": { "name": "Канал заказов", "handle": "@orders", "hue": "#8b8ff8" },
|
||||
"matchHits": [{ "label": "Стек", "term": "vue", "word": null }],
|
||||
"comments": [{ "id": "cm_...", "by": "Вы", "text": "Позвонил", "time": "5 мин" }],
|
||||
"links": [{ "id": "pl_...", "name": "Бриф", "url": "https://example.com" }],
|
||||
"files": [
|
||||
{ "id": "pf_...", "name": "brief.pdf", "size": 10240, "kind": "document",
|
||||
"label": "Документ", "objectKey": "cards/c_.../pf_..." }
|
||||
],
|
||||
"history": [
|
||||
{ "id": "h_...", "at": 1726000000000, "type": "created", "stage": null },
|
||||
{ "id": "h_...", "at": 1726003600000, "type": null, "stage": "planned" }
|
||||
],
|
||||
"tzText": "Сделать каталог и корзину",
|
||||
"reminder": { "at": 1727000000000 },
|
||||
"prevCol": "inbox",
|
||||
"isVacancy": false,
|
||||
"isVacancyKnown": false,
|
||||
"time": "5 мин",
|
||||
"receivedAt": 1726000000000,
|
||||
"createdAt": 1726000000000,
|
||||
"updatedAt": 1726000000000
|
||||
}
|
||||
```
|
||||
|
||||
Поля:
|
||||
|
||||
| Поле | Тип | Описание |
|
||||
|---|---|---|
|
||||
| `id` | string | короткий id карточки, префикс `c_` |
|
||||
| `containerId` | string | контейнер карточки (`inbox`/`archive`/`trash`/стадия/`b_...`) |
|
||||
| `col` | string | **алиас** `containerId` (совместимость со старым фронтом) |
|
||||
| `isNew` | bool | точка «новое» (снимается просмотром/переносом) |
|
||||
| `local` | bool | карточка создана локально (без внешнего источника) |
|
||||
| `title` / `summary` | string | заголовок / блок «О заявке» |
|
||||
| `source` | object | происхождение: `kind` (`local`/`telegram`/`web`/`file`/`row`/`api`/`ai`/`composite`/`other`), `displayName`, `originRef`, `receivedAt` |
|
||||
| `sourceMsg` / `sourceDialogId` / `sourceMsgId` | string / string / int64? | исходное сообщение (текст, диалог, id) |
|
||||
| `stack` | string[] | стек/направления |
|
||||
| `budget` | object? | `{from,to,cur}` по исходному сообщению |
|
||||
| `converted` | object? | `{from,to,cur}` бюджет в целевой валюте |
|
||||
| `contact` | string | «быстрый» контакт |
|
||||
| `contacts` | object[] | `{type,value}` |
|
||||
| `channel` | object | `{name,handle,hue}` (прежний `ch`) |
|
||||
| `matchHits` | object[] | `{label,term,word?}` — почему карточка в контейнере |
|
||||
| `comments` | object[] | `{id,by,text,time}` |
|
||||
| `links` | object[] | `{id,name,url}` |
|
||||
| `files` | object[] | `{id,name,size,kind,label,objectKey}` |
|
||||
| `history` | object[] | `{id,at,type\|stage}` — ровно один из `type`/`stage` |
|
||||
| `tzText` | string | техническое задание |
|
||||
| `reminder` | object? | `{at}` (epoch-ms) |
|
||||
| `prevCol` | string | предыдущий контейнер (возврат из archive/trash) |
|
||||
| `isVacancy` / `isVacancyKnown` | bool | маркер/подтверждение «найм» |
|
||||
| `time` | string | human-метка от `receivedAt` |
|
||||
| `receivedAt` / `createdAt` / `updatedAt` | int64 | epoch-ms |
|
||||
|
||||
### `GET /api/cards?containerId=`
|
||||
|
||||
Список карточек. `containerId` — фильтр по контейнеру; алиас `col` принят для совместимости; без
|
||||
параметра — все карточки дашборда (кроме стадий «Выбранных»).
|
||||
|
||||
```json
|
||||
{ "items": [ /* Card... */ ] }
|
||||
```
|
||||
|
||||
`400 {detail:"Неизвестный контейнер"}` — если контейнер не существует.
|
||||
|
||||
### `GET /api/cards/counts`
|
||||
|
||||
Плоские счётчики (совместимо с прежним `/api/leads/counts`).
|
||||
|
||||
```json
|
||||
{ "new": 3, "inbox": { "count": 5, "new": 2 }, "learning": 12, "ml": 0, "ai": 0 }
|
||||
```
|
||||
|
||||
### `GET /api/cards/{cardId}`
|
||||
|
||||
Карточка. `404 {detail:"Карточка не найдена"}`.
|
||||
|
||||
### `POST /api/cards`
|
||||
|
||||
Создание локальной карточки. Тело:
|
||||
|
||||
```json
|
||||
{ "title": "Новый заказ", "summary": "", "containerId": "planned",
|
||||
"stack": [], "budget": null, "contact": "", "tzText": "" }
|
||||
```
|
||||
|
||||
Алиас `containerId` — `stage`. Ответ — созданная **Card**.
|
||||
|
||||
### `PATCH /api/cards/{cardId}`
|
||||
|
||||
Частичная правка. Null-поле = «не менять». Тело:
|
||||
|
||||
```json
|
||||
{ "title": "...", "summary": "...", "contact": "...", "tzText": "...",
|
||||
"stack": ["..."], "budget": { "from": 1, "to": 2, "cur": "RUB" } }
|
||||
```
|
||||
|
||||
Ответ — обновлённая **Card**.
|
||||
|
||||
### `POST /api/cards/{cardId}/move` `{ "to": "<containerId>" }`
|
||||
|
||||
Перенос карточки. Ответ — обновлённая **Card**.
|
||||
`400 {"Переносить можно только в существующий контейнер или в «Неразобранное»"}` (несуществующий
|
||||
контейнер/служебный источник), `404`.
|
||||
|
||||
### `POST /api/cards/{cardId}/trash` → `{ "ok": true }`
|
||||
### `POST /api/cards/{cardId}/restore` → `{ "ok": true, "col": "inbox" }`
|
||||
### `DELETE /api/cards/{cardId}` → `{ "ok": true }`
|
||||
### `POST /api/cards/clear-col` `{ "col": "trash"|"archive" }` → `{ "ok": true, "cleared": 4 }`
|
||||
### `POST /api/cards/clear-rejected` → `{ "ok": true, "cleared": 0 }`
|
||||
### `POST /api/cards/mark-all-seen` → `{ "ok": true }`
|
||||
### `POST /api/cards/mark-col-seen` `{ "col": "<containerId>" }` → `{ "ok": true }`
|
||||
### `POST /api/cards/take` `{ "cardId": "<cardId>" }`
|
||||
|
||||
«Взять в работу»: карточка (не клон) переносится в контейнер `planned` пространства
|
||||
`selected`. Ответ — обновлённая **Card**. Алиас поля — `leadId`. `404` — карточки нет.
|
||||
|
||||
### Комментарии
|
||||
|
||||
`POST /api/cards/{cardId}/comments` `{ "text": "..." }` → `{ "comments": [ /* ... */ ] }`
|
||||
`400 {detail:"Пустой комментарий"}`, `404`.
|
||||
|
||||
### Ссылки
|
||||
|
||||
- `POST /api/cards/{cardId}/links` `{ "url": "...", "name": "..." }` → обновлённая **Card**
|
||||
- `DELETE /api/cards/{cardId}/links/{linkId}` → обновлённая **Card**
|
||||
|
||||
### Файлы
|
||||
|
||||
- `POST /api/cards/{cardId}/files` — `multipart/form-data`, поле `files` (одно или несколько)
|
||||
→ обновлённая **Card**
|
||||
- `GET /api/cards/{cardId}/files/{fileId}/download` → бинарный поток
|
||||
- `DELETE /api/cards/{cardId}/files/{fileId}` → обновлённая **Card**
|
||||
|
||||
### Напоминания
|
||||
|
||||
- `POST /api/cards/{cardId}/reminder` `{ "at": 1727000000000 }` → обновлённая **Card**
|
||||
`400 {detail:"Поле at (epoch-ms) обязательно"}`
|
||||
- `DELETE /api/cards/{cardId}/reminder` → обновлённая **Card**
|
||||
- `POST /api/cards/{cardId}/reminder/snooze` → обновлённая **Card**
|
||||
|
||||
### `POST /api/cards/{cardId}/reclassify` и `POST /api/cards/reclassify`
|
||||
|
||||
Переклассификация карточки/«Неразобранного»: повторный прогон через тот же конвейер, что и пайплайн
|
||||
(ИИ-фильтр → классификация → сборка контента → правила колонок; без создания новой карточки).
|
||||
|
||||
- Single: `{cardId}` — любая карточка с исходным текстом; `404 {detail:"Карточка не найдена"}`.
|
||||
- Batch: тело `{ "ids": ["c_..."] }` опционально; без `ids` — все карточки `inbox`.
|
||||
- При включённом ИИ используется порт `IAiClassifier`; при выключенном (`aiEnabled=false`) или недоступности
|
||||
сервиса — детерминированный локальный разбор (без кредов сервис не падает). `usedAi` показывает путь.
|
||||
- Одна переклассификация за раз (single-flight): при занятом проходе `{ "started": false, "busy": true }`.
|
||||
- Аудит — событие `card_reclassified` (только при `reclassified > 0`).
|
||||
- После успешного прохода (`started = true` и `reclassified > 0`) в канал тенанта публикуется SSE
|
||||
`cards_reclassified` с минимальной нагрузкой `{ "reclassified", "moved" }`; фронт перечитывает доску.
|
||||
Пустой inbox/всё пропущено не меняют доску — событие не шлётся. Без подписчиков — no-op.
|
||||
|
||||
```json
|
||||
{
|
||||
"started": true,
|
||||
"busy": false,
|
||||
"attempted": 3,
|
||||
"reclassified": 3,
|
||||
"moved": 1,
|
||||
"kept": 1,
|
||||
"trashed": 1,
|
||||
"skipped": 0,
|
||||
"usedAi": false,
|
||||
"reason": null
|
||||
}
|
||||
```
|
||||
|
||||
| Поле | Тип | Описание |
|
||||
|---|---|---|
|
||||
| `started` | bool | Проход выполнен (target непуст); `false` — пусто/занято |
|
||||
| `busy` | bool | Проход уже выполняется другим запросом |
|
||||
| `attempted` | int | Сколько карточек отобрано (batch — inbox, либо `ids ∩ inbox`) |
|
||||
| `reclassified` | int | Успешно обработано (`moved + kept + trashed`) |
|
||||
| `moved` | int | Ушло в смысловую колонку |
|
||||
| `kept` | int | Осталось в «Неразобранном» |
|
||||
| `trashed` | int | Отправлено в корзину (спам/не прошло ИИ-фильтр) |
|
||||
| `skipped` | int | Пропущено (нет исходного текста) |
|
||||
| `usedAi` | bool | True — разбор хотя бы одной карточки через порт ИИ; false — локальный разбор |
|
||||
| `reason` | string? | Причина, если проход не выполнен/пусто; иначе `null` |
|
||||
|
||||
### `GET /api/search?q=`
|
||||
|
||||
```json
|
||||
{ "cards": [ /* Card... */ ], "messages": [] }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Container (колонка/стадия/зона)
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "b_1a2b3c4d5e6f",
|
||||
"name": "WPF",
|
||||
"description": "Заказы по WPF",
|
||||
"color": "#818cf8",
|
||||
"order": 0,
|
||||
"space": "dashboard",
|
||||
"kind": "board",
|
||||
"collapsed": false,
|
||||
"suggested": false,
|
||||
"note": "",
|
||||
"rules": {
|
||||
"mode": "any",
|
||||
"direction": [],
|
||||
"keywords": ["wpf"],
|
||||
"stack": [],
|
||||
"grade": [],
|
||||
"exclude": [],
|
||||
"budget": { "from": 0, "to": 0, "cur": "RUB" }
|
||||
},
|
||||
"policy": { "canRestore": true, "isTerminal": false, "retentionDays": null },
|
||||
"counts": { "total": 4, "new": 1 }
|
||||
}
|
||||
```
|
||||
|
||||
| Поле | Тип | Описание |
|
||||
|---|---|---|
|
||||
| `id` | string | `b_...` (board), `planned…rejected` (stage/terminal), `inbox`/`archive`/`trash` (service) |
|
||||
| `name` | string | имя для отображения |
|
||||
| `description` | string | описание (подсказка ИИ/ML) |
|
||||
| `color` | string | hex |
|
||||
| `order` | int | позиция в пространстве |
|
||||
| `space` | string | `dashboard` / `selected` |
|
||||
| `kind` | string | `board` / `stage` / `service` / `terminal` |
|
||||
| `collapsed` | bool | свёрнутость колонки на дашборде |
|
||||
| `suggested` | bool | ИИ-предложение, ждёт решения пользователя |
|
||||
| `note` | string | заметка/обоснование ИИ |
|
||||
| `rules` | object? | правила попадания (null — фильтра нет) |
|
||||
| `policy` | object | `{canRestore,isTerminal,retentionDays}` |
|
||||
| `counts` | object | `{total,new}` — счётчики карточек контейнера |
|
||||
|
||||
`rules` (объект фильтров колонки): `mode` (`all`/`any`), `direction`, `keywords`, `stack`, `grade`,
|
||||
`exclude`, `budget` (`{from,to,cur}`) и добавленные этапом 12 группы `levels` (уровень), `locations`
|
||||
(локация/язык), `types` (`vacancy`/`freelance`/`announcement`), `prices` (`{from,to,cur}`). Все группы
|
||||
опциональны; старый сохранённый `rules` без новых групп разбирается как прежде (обратная совместимость).
|
||||
|
||||
---
|
||||
|
||||
### `GET /api/containers?space=`
|
||||
|
||||
```json
|
||||
{ "items": [ /* Container... */ ] }
|
||||
```
|
||||
|
||||
`space` (`dashboard`/`selected`) — опциональный фильтр.
|
||||
|
||||
### `POST /api/containers`
|
||||
|
||||
```json
|
||||
{ "name": "WPF", "description": "", "color": null,
|
||||
"space": "dashboard", "kind": "board", "suggested": false, "note": "",
|
||||
"rules": { "mode": "any", "keywords": ["wpf"] } }
|
||||
```
|
||||
|
||||
`400 {detail:"Укажите название колонки"}` при отсутствующем/null `name`.
|
||||
Ответ — `{ "id": "b_..." }`.
|
||||
|
||||
### `PATCH /api/containers/{containerId}`
|
||||
|
||||
Null-поле = «не менять». Тело: `name`, `description`, `color`, `collapsed`, `suggested`,
|
||||
`note`, `rules`, `policy`. Ответ — `{ "id": "..." }`, `404 {detail:"Контейнер не найден"}`.
|
||||
|
||||
### `POST /api/containers/{containerId}/accept`
|
||||
|
||||
Принять ИИ-предложение (`suggested=false`), ответ — обновлённый **Container**.
|
||||
|
||||
### `DELETE /api/containers/{containerId}`
|
||||
|
||||
Удаление контейнера; его карточки переносятся в `inbox` новыми.
|
||||
Ответ — `{ "ok": true, "movedToInbox": 4 }`.
|
||||
|
||||
### `POST /api/containers/reorder`
|
||||
|
||||
```json
|
||||
{ "space": "dashboard", "order": ["b_...", "b_...", "inbox"] }
|
||||
```
|
||||
|
||||
Ответ — `{ "ok": true }`.
|
||||
|
||||
### Состояние колонок (UI)
|
||||
|
||||
- `GET /api/containers/state` → `{ "<containerId>": { "collapsed": true, "width": "md" } }`
|
||||
- `PATCH /api/containers/{containerId}/state` `{ "collapsed": true }` → `{ "collapsed": true }`
|
||||
(только не-null поля после merge).
|
||||
|
||||
---
|
||||
|
||||
## ML (проверка на сообщении/канале, §8)
|
||||
|
||||
Все ручки — под сессией тенанта (`401 {detail:"Требуется авторизация"}`).
|
||||
|
||||
### `POST /api/ml/candidates`
|
||||
|
||||
Тело: `{ "dialogId": "d_...", "limit": 10 }` — `limit` клампится `1..60` (дефолт 10);
|
||||
пустой `dialogId` — выборка по всем источникам тенанта (очередь/отсев/карточки).
|
||||
|
||||
```json
|
||||
{ "items": [
|
||||
{ "id": 12345, "dialogId": "d_...", "text": "исходный текст (до 600 симв.)",
|
||||
"time": 1757500000000, "lead": true, "verdict": "card", "col": "b_...",
|
||||
"stage": null, "reason": null,
|
||||
"pred": { "take": true, "label": "b_...", "scores": { "b_...": 0.83 } } }
|
||||
] }
|
||||
```
|
||||
|
||||
| Поле | Тип | Описание |
|
||||
|---|---|---|
|
||||
| `id` | int | id исходного сообщения (`msgId`) — его принимает `/apply` |
|
||||
| `dialogId` | string | id диалога-источника |
|
||||
| `text` | string | исходный текст (до 600 символов) |
|
||||
| `time` | int? | время сообщения, epoch-ms (null — неизвестно) |
|
||||
| `lead` | bool | по сообщению уже есть карточка |
|
||||
| `verdict` | string | `card` / `rejected` / `queued` — текущее состояние |
|
||||
| `col` | string? | колонка карточки (для `verdict=card`) |
|
||||
| `stage` | string? | этап отсева / статус очереди |
|
||||
| `reason` | string? | причина отсева (для `verdict=rejected`) |
|
||||
| `pred` | object? | мнение ML `{take,label,scores}` (null — не ответил/не готов) |
|
||||
|
||||
### `POST /api/ml/apply`
|
||||
|
||||
Тело: `{ "dialogId": "d_...", "msgId": 12345, "action": "spam" }` —
|
||||
`action`: `skip` | `spam` | `board:<containerId>`.
|
||||
|
||||
```json
|
||||
{ "ok": true, "learned": true, "moved": "trash", "leadId": "c_..." }
|
||||
```
|
||||
|
||||
- `skip` — ничего не меняет (`learned:false`, `moved:null`);
|
||||
- `spam` — учит ML; карточку → в корзину (`moved:"trash"`), сообщение из очереди → в отсев;
|
||||
- `board:<id>` — учит ML; карточку переносит в колонку (`moved:"<id>"`), уже в колонке — только учит.
|
||||
|
||||
Ошибки: `404 {detail:"Исходное сообщение не найдено"}` — сообщение не найдено ни в карточках, ни в
|
||||
отсеве, ни в очереди; `400 {detail:"Неизвестная доска"}` (нет такого контейнера);
|
||||
`400 {detail:"Неизвестное действие"}`.
|
||||
|
||||
---
|
||||
|
||||
## Операторский health (глубины очередей, §10.2)
|
||||
|
||||
`GET /api/operator/health` дополнен числовыми полями:
|
||||
|
||||
```json
|
||||
{ "ok": true, "core": { "db": "ok" },
|
||||
"services": [ /* ... */ ],
|
||||
"queues": { "pipeline": 12, "mlOutbox": 3 },
|
||||
"sessions": { "active": 5 } }
|
||||
```
|
||||
|
||||
`queues.pipeline` — суммарная глубина очереди обработки (new+filtered), `queues.mlOutbox` — очередь
|
||||
обучения ML по всем тенантам; `sessions.active` — активные непросроченные сессии.
|
||||
|
||||
---
|
||||
|
||||
## Удалённые ручки
|
||||
|
||||
| Было | Стало |
|
||||
|---|---|
|
||||
| `GET/POST /api/leads`, `/api/leads/{id}`, `/counts`, `/move`, `/trash`, `/restore`, `/comments`, `/mark-*-seen`, `/clear-col`, `/reclassify` | `/api/cards...` |
|
||||
| `GET/POST /api/projects`, `/api/projects/{id}`, `/take`, `/move`, `/comments`, `/links`, `/files`, `/reminder`, `/clear-rejected` | `/api/cards...` |
|
||||
| `GET/POST/PATCH/DELETE /api/boards`, `/reorder` | `/api/containers...` |
|
||||
| `GET /api/columns/state`, `PATCH /api/columns/{id}/state` | `/api/containers/state`, `/api/containers/{id}/state` |
|
||||
| SSE `new_lead` | SSE `new_card` |
|
||||
Reference in New Issue
Block a user