Восстановить docs/ как зеркало для агентов (ревью МР #11)
Ревью rust: перенос в вики не должен удалять из репозитория то, что нужно агенту для работы (бэклог, статус, планы, код-стайл, спеки). Дублирование вики и репы разрешено и обязательно: вики — актуальные версии для людей, docs/ — зеркало для контекста агентов. README разведён по ролям.
This commit is contained in:
@@ -2,7 +2,10 @@
|
||||
|
||||
SaaS-мониторинг Telegram: реальные заказы и клиенты вместо рекламы и дубликатов.
|
||||
|
||||
## Документация (актуальное) — в вики проекта
|
||||
## Документация
|
||||
|
||||
Актуальные версии для людей — в [вики проекта](https://gitea.khomegeneric.keenetic.pro/rust/Deal/wiki/Home);
|
||||
в `docs/` — зеркала для работы агентов (дублирование разрешено и нужно).
|
||||
|
||||
- **ТЗ**: [https://gitea.khomegeneric.keenetic.pro/rust/Deal/wiki/ТЗ](https://gitea.khomegeneric.keenetic.pro/rust/Deal/wiki/ТЗ)
|
||||
- **Инструкция пользователя**: [https://gitea.khomegeneric.keenetic.pro/rust/Deal/wiki/Инструкция-пользователя](https://gitea.khomegeneric.keenetic.pro/rust/Deal/wiki/Инструкция-пользователя)
|
||||
|
||||
@@ -0,0 +1,463 @@
|
||||
# Дейл (Deal) — карта API (Python/FastAPI → .NET)
|
||||
|
||||
> Этап 9 «единая карточка»: карточки и колонки/стадии сведены в два домена — `/api/cards` и
|
||||
> `/api/containers`; ручки `/api/leads`, `/api/projects`, `/api/boards`, `/api/columns` удалены, SSE
|
||||
> `new_lead` переименован в `new_card`. Точный контракт — `docs/architecture/2026-09-10-unified-api-contract.md`.
|
||||
|
||||
Источники: `src/frontend/src/{api,store,data,utils}.js`, `src/frontend/src/store/*.js`, `src/frontend/src/views|components/*.vue`, `backend/app/main.py`, `backend/app/routers/*.py`, `backend/app/{auth,sse,constants}.py`, сервисы (`pipeline`, `processing`, `discovery`, `telegram`, `ml_client`, `rates`, `files`, `rules`, `suggest`). Фронт — высший авторитет по формам JSON; по карточкам/контейнерам источник истины — единый контракт этапа 9.
|
||||
|
||||
---
|
||||
|
||||
## 1. Общие правила
|
||||
|
||||
| Правило | Значение |
|
||||
|---|---|
|
||||
| Base path | Все API-роуты под префиксом `/api`; домены карточек/колонок — `/api/cards` и `/api/containers` (плюс `/api/auth/...`, `/api/tg/...` и т.д.) |
|
||||
| Контент-типы | Запросы/ответы JSON (`application/json`), сериализация camelCase. Исключения: `POST /api/cards/{id}/files` — `multipart/form-data`, поле **`files`** (несколько файлов); `GET /api/tg/qr-image` — `image/svg+xml`; `GET /api/cards/{id}/files/{fileId}/download` — `application/octet-stream` (attachment); `GET /api/events` — `text/event-stream` |
|
||||
| Сессия | httpOnly-кука **`deal_session`** (в прототипе — `leadradar_session`); `HttpOnly`, `SameSite=Lax`, `max-age` 30 дней, `secure` — по конфигурации (`Cookies__Secure`, в проде true). Запросы идут с `credentials: 'include'`. Токен сессии — случайный, хранится в БД. При logout кука удаляется; смена пароля инвалидирует старые сессии |
|
||||
| Авторизация | Все роуты, кроме `POST /api/auth/login`, требуют валидной куки (`current_login`). Иначе **401** `{"detail": "Требуется авторизация"}`. Фронт на 401 разлогинивается (`setUnauthorizedHandler`) |
|
||||
| Ошибки | Всегда **`{"detail": "<текст>"}`** (без `error`/`message`-обёртки). Коды: `400` (неверное тело/правила), `401` (нет сессии), `403` (тенант приостановлен), `404` (не найдено), `410` (файл не сохранён). Фронт парсит `data.detail \|\| data.message`. ⚠ «мягкие» ошибки отдаются HTTP 200 с полями (`generate-keywords` → `{keywords:[], error}`; `suggest-*` → `{ok:false, reason}`; `reset` ML → `{ok:false, error}`) |
|
||||
| Оборачивание списков | `{"items": [...]}` — везде; исключение — `GET /api/containers/state` (объект `<containerId> → state`). Пагинация отсева: `{items, total, offset, limit}` |
|
||||
| Успех-без-данных | `{"ok": true}` (+ опциональные поля) |
|
||||
| Времена | epoch **миллисекунды** (int) в `receivedAt`, `createdAt`, `updatedAt`, `at`, `msgAt`, `queuedAt`, `rejectedAt`, `returnedAt`, `time` в превью-сообщениях; поле `card.time` — **строка** «только что»/«5 мин»/«3 ч»/«2 дн» |
|
||||
| Статические сегменты против `{id}` | Литералы объявляются до `{id}`: `/cards/counts`, `/cards/clear-col`, `/cards/clear-rejected`, `/cards/reclassify`, `/cards/mark-all-seen`, `/cards/mark-col-seen`, `/cards/take` — до `/cards/{cardId}`; `/containers/state`, `/containers/reorder` — до `/containers/{containerId}`; `/rejected/clear` — до `/rejected/{rejId}`. ASP.NET Core отдаёт приоритет литералам, порядок сохранён для читаемости |
|
||||
| Prefix'ы id | `c_` — карточка (единый для всех дашбордов), `b_` — контейнер-колонка, `p_` — строка очереди, `r_` — запись отсева, `cm_` — комментарий, `h_` — запись истории, `pf_` — файл, `pl_*` — ссылка, `dt_` — discovery-задача, `dl_` — запись лога, `m_<dialog>_<msg>` — сообщение. Контейнеры-стадии/служебные зоны — без префикса (`planned`…`rejected`, `inbox`/`archive`/`trash`) |
|
||||
| Служебные админ | `admin/tick`, `admin/fts/rebuild`, `admin/check-message` — служебные, фронтом не вызываются |
|
||||
| Проверка фильтра/тестера | `POST /api/admin/check-message` — сухой прогон текста по всему конвейеру (стоп-правила → ML → ИИ) без создания карточки |
|
||||
| Фоновые циклы (не API) | storage-тик (30 с): автоархив/очистка + напоминания; pipeline-воркер (2 с); discovery-воркер (5 с); ML outbox (10 с); suggest (180 с); tg sweep (30 с); rates (30 мин) |
|
||||
|
||||
---
|
||||
|
||||
## 2. SSE `GET /api/events`
|
||||
|
||||
Поток `text/event-stream`, заголовки `Cache-Control: no-cache`, `X-Accel-Buffering: no`; каждые 15 с без событий — комментарий-пинг `: ping`. Формат события: `event: <name>\ndata: <json>\n\n` (все данные — JSON).
|
||||
|
||||
**Что публикует бэкенд Дейла (4 именованных типа; прототип публиковал 7 — `pipeline_stats`/`boards_changed`/`leads_reclassified` в Дейле не реализованы):**
|
||||
|
||||
| event | Payload | Кто шлёт / когда |
|
||||
|---|---|---|
|
||||
| `new_card` | **полный объект карточки** (см. §4.1 — тот же объект, что элемент `GET /api/cards`) | pipeline-воркер при создании карточки (ML/ИИ-путь) |
|
||||
| `toast` | `{"text": str, "icon": str}` — icon: `check`/`sparkles`/`clock`/`trash`/`x`/`send`/`logout`/`bell`/`refresh`/`restore` | автоархив/очистки, подключение/отключение Telegram, ИИ-предложения колонок, срабатывание ИИ-бюджета |
|
||||
| `reminder_due` | `{"id": "<c_…>", "title": str, "containerId": "hold"}` | фоновый цикл правил хранения (30 с) — наступившие напоминания стадии `hold` при включённых напоминаниях |
|
||||
| `system_status` | полный объект `tg.status()` (см. §4.9) | telegram-service при изменении подключения |
|
||||
|
||||
`new_lead` больше не публикуется (переименован в `new_card`). Фронтовый `openEvents()` (`api.js`) слушает `new_card`, `toast`, `reminder_due`, `system_status`.
|
||||
|
||||
---
|
||||
|
||||
## 3. Таблицы эндпоинтов
|
||||
|
||||
Сокращения: «→ карточка» = полный объект карточки §4.1; «→ контейнер» = §4.2; «→ settings» = §4.6; «→ задача/кандидат» = §4.8. `(фронт не вызывает)` — эндпоинт есть, UI его не дёргает; `(не используется фронтом)` — поле в ответе есть, UI не читает.
|
||||
|
||||
Счётчики в заголовках разделов — фактические строки таблиц (без строки-шапки); при добавлении/удалении ручки — обновлять.
|
||||
|
||||
### 3.1 Auth (auth_routes.py) — 4 эндпоинта
|
||||
|
||||
| METHOD /api/… | Назначение | Request body | Response |
|
||||
|---|---|---|---|
|
||||
| `POST /auth/login` | Вход; ставит куку | `{login, password}` | `{ok: true, login: "<login>"}`. 401 `{"detail":"Неверный логин или пароль"}` |
|
||||
| `POST /auth/logout` | Удалить сессию и куку | — | `{ok: true}` |
|
||||
| `GET /auth/me` | Проверка живой сессии | — | `{login, ok: true}` |
|
||||
| `POST /auth/change-password` | Смена пароля; перевыпуск куки | `{oldPassword, newPassword}` (min 8) | `{ok: true}`; 400 «Текущий пароль неверен»/«Пароль слишком короткий (минимум 8 символов)» |
|
||||
|
||||
### 3.2 Карточки, контейнеры, поиск, admin, ai (бывший Dashboard)
|
||||
|
||||
**Контейнеры (8; бывшие доски + колонки):**
|
||||
|
||||
| METHOD /api/… | Назначение | Request body | Response |
|
||||
|---|---|---|---|
|
||||
| `GET /containers?space=` | Список контейнеров пространства (`dashboard`/`selected`) — колонки/стадии/зоны со счётчиками | — | `{items: [→ контейнер]}` |
|
||||
| `POST /containers` | Создать контейнер (колонку-фильтр) | `{name, description?, color?, space?, kind?, suggested?, note?, rules?}` | `{id: "<b_…>"}`; 400 «Укажите название колонки» |
|
||||
| `PATCH /containers/{id}` | Правка (`name/description/color/collapsed/suggested/note/rules/policy`; null — «не менять») | `{…}` | `{id}`; 404 «Контейнер не найден» |
|
||||
| `POST /containers/{id}/accept` | Принять ИИ-предложение (`suggested=false`) | — | → контейнер |
|
||||
| `DELETE /containers/{id}` | Удалить; карточки → inbox новыми | — | `{ok: true, movedToInbox: <int>}` |
|
||||
| `POST /containers/reorder` | Порядок контейнеров пространства | `{space, order: ["<b_…>", …]}` | `{ok: true}`; 400 «Не указан порядок колонок» |
|
||||
| `GET /containers/state` | Состояние колонок (свёрнутость/ширина, `colState`) | — | `{ "<id>": {"collapsed": bool, "width": "sm"\|"md"\|"lg"} }` |
|
||||
| `PATCH /containers/{id}/state` | Сменить состояние колонки | `{collapsed?, width?}` | состояние **только этой** колонки |
|
||||
|
||||
**Карточки (13; бывшие лиды + базовые операции Projects):**
|
||||
|
||||
| METHOD /api/… | Назначение | Request body | Response |
|
||||
|---|---|---|---|
|
||||
| `GET /cards?containerId=` | Карточки (`containerId`/алиас `col`; без параметра — весь дашборд), свежие сверху | — | `{items: [→ карточка]}`; 400 «Неизвестный контейнер» |
|
||||
| `GET /cards/counts` | Плоские счётчики + счётчики обучения | — | см. §4.1 «counts» |
|
||||
| `GET /cards/{cardId}` | Одна карточка | — | → карточка; 404 «Карточка не найдена» |
|
||||
| `POST /cards/mark-all-seen` | Снять «новое» со всех | — | `{ok: true}` |
|
||||
| `POST /cards/mark-col-seen` | Снять «новое» с контейнера | `{col}` | `{ok: true}` |
|
||||
| `POST /cards/{cardId}/move` | Перенос карточки в контейнер; учит ML | `{to: "<containerId>"}` | → карточка; 400 «Переносить можно только…» |
|
||||
| `POST /cards/{cardId}/trash` | В корзину; учит ML `spam` | — | `{ok: true}`; 404 |
|
||||
| `POST /cards/{cardId}/restore` | Возврат из архива/корзины | — | `{ok: true, col: "<inbox\|b_…>"}` |
|
||||
| `DELETE /cards/{cardId}` | Удалить навсегда | — | `{ok: true}` |
|
||||
| `POST /cards/clear-col` | Очистить корзину/архив целиком | `{col: "trash"\|"archive"}` | `{ok: true, cleared: <int>}`; 400 |
|
||||
| `POST /cards/{cardId}/comments` | Добавить комментарий | `{text}` | `{comments: [{id, by:"Вы", text, time:"только что"}]}`; 400 «Пустой комментарий» |
|
||||
| `POST /cards/reclassify` | Переклассификация «Неразобранного» (реальный прогон; single-flight) | `{ids?: ["c_…"]}` (тело опционально; без `ids` — все `inbox`) | `{started, busy, attempted, reclassified, moved, kept, trashed, skipped, usedAi, reason}`; при занятом проходе `{started:false, busy:true}` |
|
||||
| `POST /cards/{cardId}/reclassify` | Переклассификация одной карточки | — | тот же объект ответа; 404 «Карточка не найдена» |
|
||||
|
||||
**Поиск (1):**
|
||||
|
||||
| METHOD /api/… | Назначение | Request | Response |
|
||||
|---|---|---|---|
|
||||
| `GET /search?q=` | Полнотекстовый+LIKE поиск, `limit=12` | query `q` (min 2 симв.) | `{cards: [→ карточка], messages: []}` — в Дейле `messages` всегда пуст |
|
||||
|
||||
**Admin (3; в Дейле реализованы `tick`/`fts/rebuild`/`check-message`, остальные строки — только прототип, §6):**
|
||||
|
||||
| METHOD /api/… | Назначение | Response |
|
||||
|---|---|---|
|
||||
| `POST /admin/tick` | Ручной тик: хранение+напоминания+разбор очереди (фронт зовёт раз в 60 с) | `{storage: {archived, purgedArchive, purgedTrash, purgedRejected}, reminders: [{id, title, containerId}] (уже «выстрелившие», после SSE), pipeline: <dict pump_once>, queue: int}` |
|
||||
| `POST /admin/fts/rebuild` | Пересобрать FTS-индекс | `{ok: bool, ready: bool}` |
|
||||
| `POST /admin/check-message` | Сухой прогон текста по конвейеру (стоп-правила → ML → ИИ) | см. §4.10 |
|
||||
| `POST /admin/wipe` | Полный сброс (карточки+ML+счётчики) | `{ok, cardsRemoved, ml: {ok}}` *(прототип)* |
|
||||
| `POST /admin/clear-cards` | Очистить карточки/очереди без сброса ML | `{ok, cardsRemoved}` *(прототип)* |
|
||||
| `POST /admin/pump-gate` | Шлагбаум воркера `{limit?}` | `{ok, limit, done}` *(прототип)* |
|
||||
|
||||
**AI-действия (2):**
|
||||
|
||||
| METHOD /api/… | Назначение | Response |
|
||||
|---|---|---|
|
||||
| `POST /ai/suggest-columns` | ИИ предлагает колонки по inbox (ручной запуск) | `{ok: true, created: int}` или `{ok: false, reason: str, cooldown?}`; при успехе шлёт `toast` |
|
||||
| `POST /ai/suggest-keywords` | ИИ предлагает общие ключи сферы | `{ok: true, keywords: [str]}` (≤60 шт., длина ≤40) или `{ok: false, reason}` |
|
||||
|
||||
### 3.3 Telegram (tg_routes.py) — 14
|
||||
|
||||
| METHOD /api/tg/… | Назначение | Request body | Response |
|
||||
|---|---|---|---|
|
||||
| `GET /status` | Статус аккаунта/фазы входа | — | §4.9 (status) |
|
||||
| `POST /start-phone` | Вход по телефону | `{phone}` | `{phase: "code"}`; 400 с текстом причины |
|
||||
| `POST /start-qr` | Начать QR-вход | — | `{phase: "qr", qrUrl: "https://t.me/…"}` |
|
||||
| `POST /send-code` | Отправить SMS-код | `{code}` | `{phase: "password"\|"done"}`; 400 |
|
||||
| `POST /send-password` | 2FA-пароль | `{password}` | `{phase: "done"}`; 400 |
|
||||
| `POST /logout` | Отключить аккаунт, удалить сессию | — | `{ok: true}` (+toast/`system_status` по SSE) |
|
||||
| `GET /qr-image` | SVG QR-кода (фаза qr) | — | `image/svg+xml`; 404 «QR не активен…». Фронт: `<img src="/api/tg/qr-image?t=N">` |
|
||||
| `GET /dialogs` | Список диалогов из БД | — | `{items: [§4.11 диалог]}` |
|
||||
| `POST /dialogs/refresh` | Синхронизировать диалоги из Telegram | — | `{ok: true, count: int}` или `{ok: false, reason: "not-connected", count: 0}` |
|
||||
| `POST /dialogs/monitor-all` | Мониторинг всех каналов (первое включение → backfill в фоне) | `{enabled: bool}` | `{ok: true, count: int, enabled: bool}` |
|
||||
| `POST /dialogs/backfill-all` | Перечитать последние ~10 сообщений включённых каналов (фон) | — | `{ok: true, count: int}` |
|
||||
| `POST /dialogs/{dialog_id}/monitor` | Вкл/выкл мониторинг канала | `{enabled: bool}` | `{ok: true, enabled: bool}` |
|
||||
| `POST /dialogs/{dialog_id}/backfill` | Догнать сообщения одного диалога | — | `{ok: true, processed: int}` *(фронт не вызывает — только сервер)* |
|
||||
| `POST /dialogs/preview` | Последние сообщения диалога (свежие из TG, старые из БД) | `{dialogId, limit?=24 (clamp 1..50)}` | `{items: [§4.11 сообщение]}` |
|
||||
|
||||
### 3.4 Settings / rates / meta (settings_routes.py) — 6
|
||||
|
||||
| METHOD /api/… | Назначение | Request body | Response |
|
||||
|---|---|---|---|
|
||||
| `GET /settings` | Публичные настройки (секреты замаскированы) | — | §4.6 (полный settings) |
|
||||
| `PATCH /settings` | Частичное обновление (см. §4.6 список ключей). Инварианты: `archiveAfterDays` 1..30, `minLen` 10..500, `discJoinLimit` 1..200, `discJoinDelayMin/Max` 5..600 (min≤max), `discEvalSample` 3..30, `discEvalThreshold` 1..100; `aiConfigs` apiKey ≥8 → шифруется; `myPrompts` ≤100. ⚠ `tgKeys` удалён из настроек тенанта — ключи Telegram задаёт оператор глобально. ⚠ Ответ — **весь** public settings (фронт затирает локальное состояние ответом) | произвольный dict из публичных ключей | §4.6 |
|
||||
| `POST /ai/check` | Проверка подключения AI-провайдера | — | `{ok: bool, message: str}` + поля статуса провайдера |
|
||||
| `GET /rates` | Курсы валют | — | `{base: "RUB", rates: {CODE: num}, source: "cbr"\|"mock", updatedAt: ms\|null}` |
|
||||
| `POST /rates/refresh` | Принудительно обновить курсы (ЦБ/мок) | — | `{ok: bool, rates: {base, rates, source, updatedAt}}` — ⚠ фронт передаёт `r.rates` в `applyRates` |
|
||||
| `GET /meta/constants` | Валюты/стадии/палитра | — | `{currencies: [{code,name,symbol}], stages: [§4.4], palette: ["#…"]}` *(фронт не вызывает — зашиты в data.js)* |
|
||||
|
||||
### 3.5 Детальные операции карточки (бывший Projects, projects_routes.py) — 15
|
||||
|
||||
Все операции — над ресурсом `/api/cards/{cardId}` (см. §4.1); отдельного `/api/projects` больше нет.
|
||||
|
||||
| METHOD /api/cards… | Назначение | Request body | Response |
|
||||
|---|---|---|---|
|
||||
| `POST ""` | Создать локальную карточку | `{title="", summary="", containerId?="planned", stack?, budget?, contact="", tzText=""}` (алиас `stage`) | → карточка |
|
||||
| `POST /take` | «Взять в работу»: карточка (**не клон**) → контейнер `planned` пространства `selected` | `{cardId}` (алиас `leadId`) | → карточка; 404 «Карточка не найдена» |
|
||||
| `POST /clear-rejected` | Очистить стадию «Отклонено» | — | `{ok: true, cleared: int}` |
|
||||
| `PATCH /{cardId}` | Правка полей (null — «не менять») | `{title?, summary?, stack?, budget?{from,to,cur}, contact?, tzText?}` | → карточка |
|
||||
| `POST /{cardId}/move` | Перенос по контейнерам/стадиям (+история; сброс reminder при уходе с hold) | `{to}` | → карточка; 400 «Переносить можно только…» |
|
||||
| `POST /{cardId}/comments` | Комментарий | `{text}` | `{comments: [...]}`; 400 «Пустой комментарий» |
|
||||
| `POST /{cardId}/links` | Добавить ссылку (`url` без схемы → префикс https://) | `{name="", url}` | → карточка; 400 «Пустая ссылка» |
|
||||
| `DELETE /{cardId}/links/{linkId}` | Удалить ссылку | — | → карточка |
|
||||
| `POST /{cardId}/files` | Загрузить файлы (multipart, поле `files`) | FormData `files` | → карточка (с обновлённым `files`) |
|
||||
| `GET /{cardId}/files/{fileId}/download` | Скачать (stream из MinIO/локального store) | — | `application/octet-stream`, `Content-Disposition: attachment`; 410/404 |
|
||||
| `DELETE /{cardId}/files/{fileId}` | Открепить файл | — | → карточка |
|
||||
| `GET /{cardId}/source` | Содержимое источника: провайдер по `source.kind` либо сохранённое в карточке | — | `SourceContent`; 404 «Карточка не найдена» |
|
||||
| `POST /{cardId}/reminder` | Напоминание карточке | `{at: <epoch ms>}` | → карточка; 400 «Поле at (epoch-ms) обязательно» |
|
||||
| `DELETE /{cardId}/reminder` | Снять напоминание | — | → карточка |
|
||||
| `POST /{cardId}/reminder/snooze` | Отложить на +24 ч | — | → карточка |
|
||||
|
||||
### 3.6 Processing — очередь и отсев (processing_routes.py) — 6
|
||||
|
||||
| METHOD /api/pipeline… | Назначение | Request | Response |
|
||||
|---|---|---|---|
|
||||
| `GET /stats` | Сводка для синхронизации | — | `{queue: {new, ai, total}, rejected: int}` |
|
||||
| `GET /queue?limit=` | Сырые сообщения очереди (`limit` ≤500, дефолт 100; фронт шлёт 120) | query `limit` | `{items: [§4.5 очередь], counts: {new, ai, total}, rejected: int}` |
|
||||
| `GET /rejected?q=&offset=&limit=` | Отсев (поиск по q, страницы; лимит ≤500) | query | `{items: [§4.5 отсев], total: int, offset: int, limit: int}` |
|
||||
| `POST /rejected/clear` | Очистить отсев | — | `{ok: true, cleared: int}` |
|
||||
| `DELETE /rejected/{rej_id}` | Удалить запись отсева | — | `{ok: true}` |
|
||||
| `POST /rejected/{rej_id}/return` | Вернуть в обработку (`{reason}` помечается на записи; снимает у ML вес спама; повтор/dup → 400) | `{reason=""}` | `{id, returned: true, returnedAt: ms}`; 404/400 |
|
||||
|
||||
### 3.7 ML (ml_routes.py) — 7
|
||||
|
||||
| METHOD /api/ml… | Назначение | Request body | Response |
|
||||
|---|---|---|---|
|
||||
| `GET /status` | Статус ML-сервиса (форс-refresh) + локальная статистика | — | §4.10 (ml status) |
|
||||
| `POST /reset` | Сброс модели + очистка outbox | — | `{ok: true}` или `{ok: false, error: str}` (⚠ ошибка — HTTP 200) |
|
||||
| `POST /predict` | Проверка ML на тексте | `{text}` | `{text: <первые 200>, take: bool, label: str\|null, scores: {class: num}, hits, ready, margin, terms, type}`; 400 «Введите текст» |
|
||||
| `POST /learn` | Ручная разметка в outbox | `{text, label}` | `{ok: true, outbox: int}` *(фронт не вызывает — использует apply)* |
|
||||
| `POST /flush` | Немедленная отправка обучения | — | `{ok, flushed, outbox, service}` *(фронт не вызывает)* |
|
||||
| `POST /candidates` | Последние сообщения канала + мнение ML | `{dialogId, limit?=10 (clamp 1..60)}` | `{items: [{id, dialogId, text(≤600), time, lead, pred: {take, label, scores}}]}` |
|
||||
| `POST /apply` | Ручное решение: `action` = `spam` \| `board:<id>` \| `skip` | `{dialogId, msgId, action}` | `{ok, learned: bool, moved: "trash"\|"<board>"\|null, leadId: str\|null}`; `skip` → `{ok, learned: false, moved: null}`; 400/404 |
|
||||
|
||||
### 3.8 Discovery (discovery_routes.py) — 13
|
||||
|
||||
| METHOD /api/discovery… | Назначение | Request body | Response |
|
||||
|---|---|---|---|
|
||||
| `GET /tasks` | Список задач (старые первыми) | — | `{items: [→ задача]}` |
|
||||
| `POST /tasks` | Создать (бюджет plan_joins ≤ discJoinLimit) | `{name, description?, keywords?[], minSubscribers?, lang? "ru"\|"any", threshold?, sampleSize?, planJoins?, autoJoin?}` | → задача; 400 (нет имени / бюджет) |
|
||||
| `PATCH /tasks/{task_id}` | Обновить задачу | те же поля, все optional | → задача; 404/400 |
|
||||
| `DELETE /tasks/{task_id}` | Удалить (с кандидатами и логом) | — | `{ok: true}` |
|
||||
| `POST /tasks/{task_id}/start` | Запуск поиска (draft/paused/done/failed → running) | — | → задача; 400 «Нет ключевых слов…» |
|
||||
| `POST /tasks/{task_id}/pause` | Пауза | — | → задача |
|
||||
| `POST /tasks/{task_id}/generate-keywords` | ИИ-генерация ключей по description | — | `{keywords: [str≤30×60]}`, ошибка — `{keywords: [], error: str}` (HTTP 200, ⚠) |
|
||||
| `GET /tasks/{task_id}/candidates?status=` | Кандидаты задачи, фильтр `new\|review\|joined\|rejected` | query `status` | `{items: [→ кандидат]}`; 404 |
|
||||
| `POST /candidates/{dialog_id}/join` | Ручное вступление (+в мониторинг, +backfill, −чёрный список) | — | → кандидат; 400/404 |
|
||||
| `POST /candidates/{dialog_id}/reject` | Отклонить → чёрный список | — | → кандидат; 400 (уже вступили)/404 |
|
||||
| `GET /blacklist` | Чёрный список | — | `{items: [{dialogId, name, reason, createdAt}]}` |
|
||||
| `DELETE /blacklist/{dialog_id}` | Убрать из чёрного списка | — | `{ok: true}` |
|
||||
| `GET /tasks/{task_id}/log` | Лог задачи | — | `{items: [{id, taskId, event, text, createdAt}]}`, event ∈ `search\|skip\|review\|join_auto\|join_manual\|reject\|done\|flood\|error` |
|
||||
|
||||
### 3.9 Прочее (main.py / events_routes.py)
|
||||
|
||||
| METHOD /api/… | Назначение | Response |
|
||||
|---|---|---|
|
||||
| `GET /events` | SSE-поток (см. §2), авторизация обязательна | `text/event-stream` |
|
||||
| `GET /health` | Healthcheck | `{ok: true, service: "deal"}` *(фронт не вызывает)* |
|
||||
|
||||
---
|
||||
|
||||
## 4. Сущности: поля JSON, которые реально читает фронт
|
||||
|
||||
### 4.1 Карточка (card) — `GET /api/cards`, `GET /api/cards/{cardId}`, ответы всех мутаций и payload SSE `new_card`
|
||||
|
||||
Единая сущность всех дашбордов (этап 9). Модульные поля (контакты/ссылки/файлы/ТЗ/история/напоминание)
|
||||
присутствуют всегда, но могут быть пустыми. Точный контракт — `docs/architecture/2026-09-10-unified-api-contract.md`.
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"id": "c_1a2b3c4d5e6f", // string, префикс c_ — единый
|
||||
"containerId": "inbox", // контейнер карточки
|
||||
"col": "inbox", // алиас containerId (совместимость)
|
||||
"isNew": true, // «новое» (точка на карточке)
|
||||
"local": false, // создана локально, без внешнего источника
|
||||
"title": "Разработка интернет-магазина", // string ≤140
|
||||
"summary": "Компания: …\nЗадача: …",// блок «О заявке»
|
||||
"source": { "kind": "telegram", "externalId": "4242", "displayName": "Канал заказов", "originRef": "123456789", "author": "…", "receivedAt": "2026-09-11T10:00:00+00:00", "extra": {"hue": "#8b8ff8"} },
|
||||
"content": { "text": "Ищу разработчика…", "html": null, "author": "…", "subject": null, "data": [], "links": [], "contacts": [] },
|
||||
"stack": ["vue", "dotnet"],
|
||||
"budget": {"from": 100000, "to": 200000, "cur": "RUB"},
|
||||
"converted": {"from": 100000, "to": 200000, "cur": "RUB"},
|
||||
"contact": "@client",
|
||||
"contacts": [{"type": "tg", "value": "@client"}],
|
||||
"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": "projects/c_…/pf_…_1726000000000_brief.pdf"}],
|
||||
"history": [{"id": "h_…", "at": 1726000000000, "type": "created"}, {"id": "h_…", "at": 1726003600000, "stage": "planned"}],
|
||||
"tzText": "Сделать каталог и корзину",
|
||||
"reminder": {"at": 1727000000000},
|
||||
"prevCol": "inbox", // предыдущий контейнер (возврат из archive/trash)
|
||||
"isVacancy": false,
|
||||
"isVacancyKnown": false,
|
||||
"time": "5 мин", // human-метка от receivedAt
|
||||
"receivedAt": 1726000000000, "createdAt": 1726000000000, "updatedAt": 1726000000000
|
||||
}
|
||||
```
|
||||
|
||||
Ключевые поля: `id/containerId/(col)` — принадлежность; `source` (`SourceRef`: вид, внешний id, подпись,
|
||||
ссылка на оригинал, цвет в `extra.hue`) и `content` (`SourceContent`: текст, разметка, ссылки, контакты,
|
||||
вложения `data`) — происхождение; `stack/budget/converted/contact/contacts/matchHits` — данные заявки; `comments/
|
||||
links/files/history/tzText/reminder` — модули; `isNew/prevCol/isVacancy/isVacancyKnown` — маркеры. Ровно
|
||||
один из полей `history[].type`/`history[].stage` задан.
|
||||
|
||||
Строки очереди и отсева «Обработки» отдают тот же generic `source`/`content` (раньше — `ch`); решение
|
||||
отсева — `decidedBy`/`decidedByLabel` (stop|ml|ai|stale|dup).
|
||||
|
||||
**counts** (`GET /api/cards/counts`): `{new: int, "<containerId>": {count: int, new: int}, learning: int, ml: int, ai: int}` — плоская форма (совместима с прежним `/api/leads/counts`).
|
||||
|
||||
### 4.2 Контейнер (container) — `GET /api/containers`, `POST/PATCH` тела
|
||||
|
||||
Единый реестр колонок/стадий/зон (этап 9): пользовательские колонки-фильтры (`kind: board`),
|
||||
стадии «Выбранных» (`stage`), служебные зоны (`service`: inbox/archive/trash), терминальные (`terminal`).
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"id": "b_1a2b3c4d5e6f", // b_... | planned…rejected | inbox/archive/trash
|
||||
"name": "WPF", "description": "Заказы по WPF", "color": "#818cf8",
|
||||
"order": 0,
|
||||
"space": "dashboard", // dashboard | selected
|
||||
"kind": "board", // board | stage | service | terminal
|
||||
"collapsed": false, // свёрнута на дашборде
|
||||
"suggested": false, // ИИ-предложение ждёт решения
|
||||
"note": "", // заметка/обоснование ИИ
|
||||
"rules": { // правила попадания (null — фильтра нет)
|
||||
"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} // счётчики карточек контейнера
|
||||
}
|
||||
```
|
||||
`counts`/`policy` — только в ответе `GET`; `rules` — набор опциональных фильтров колонки (как раньше у доски).
|
||||
|
||||
### 4.3 Модульные поля карточки (бывшая «проектная карточка»)
|
||||
|
||||
Отдельной сущности/таблицы больше нет: модули (`comments`, `links`, `files`, `history`, `tzText`,
|
||||
`reminder`, `budget`) — поля той же карточки §4.1. Формы элементов:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"comments": [{"id":"cm_…","by":"Вы","text":"…","time":"только что"}],
|
||||
"links": [{"id":"pl_…","name":"сайт","url":"https://…"}],
|
||||
"files": [{"id":"pf_…","name":"tz.pdf","size":12345,"kind":"document","label":"Документ","objectKey":"…"}],
|
||||
"history": [{"id":"h_…","at":1757000000000,"type":"created"}, // type: "created"|"createdLocal" ИЛИ
|
||||
{"id":"h_…","at":…,"stage":"work"}], // stage — при переносе
|
||||
"tzText": "", // техническое задание
|
||||
"reminder": {"at": 1757000000000}, // object|null
|
||||
"createdAt": …, "updatedAt": … // int ms
|
||||
}
|
||||
```
|
||||
Загрузка файлов: multipart — ответ — обновлённая **карточка** (фронт берёт `files` из ответа). Скачивание: `GET /api/cards/{cardId}/files/{fileId}/download`.
|
||||
|
||||
### 4.4 Контейнеры по умолчанию (стадии «Выбранных» и зоны)
|
||||
|
||||
Стадии «Выбранных» (`space: selected`, `kind: stage/terminal`): `planned` Запланировано / `reply` Отклик /
|
||||
`agree` Согласование / `work` В работе / `review` Проверка / `ready` Готово / `hold` Отложено (не terminal) /
|
||||
`finished` Выполнено (terminal) / `rejected` Отклонено (terminal). Служебные зоны дашборда:
|
||||
`inbox`, `archive`, `trash` (`space: dashboard`, `kind: service`).
|
||||
|
||||
### 4.5 Очередь и отсев (вкладка «Обработка»)
|
||||
|
||||
Очередь (`GET /pipeline/queue` item): `{id, source: SourceRef, content: SourceContent, text, status: "new"|"filtered", msgAt: ms, queuedAt: ms}` — UI показывает `text`, статус-бейдж и подпись источника (`source.displayName`, цвет `source.extra.hue`).
|
||||
|
||||
Отсев (`GET /pipeline/rejected` item): `{id, source: SourceRef, content: SourceContent, text, stage, stageLabel, reason, kw, decidedBy, decidedByLabel, msgAt, rejectedAt, returned: bool, returnedAt: ms|null, returnReason: string}`.
|
||||
- `stage` ∈ `length|stop|resume|type|budget|stale|spam_ml|spam_ai|filter_ai|dup`; `stageLabel` — подпись («короткое сообщение», «стоп-фраза», «спам (ML)», …).
|
||||
- `decidedBy` ∈ `stop|ml|ai|stale|dup`; `decidedByLabel` ∈ «правила|ML|ИИ|система».
|
||||
- Фронт читает: `id, stageLabel, kw, reason, decidedBy, decidedByLabel, text, source, rejectedAt, returned, returnedAt, returnReason`. «Возврат» неактивен при `decidedBy==='dup'` или `returned`.
|
||||
|
||||
### 4.6 Настройки (settings) — все ключи ответа `GET/PATCH /api/settings` (camelCase; значения по умолчанию из `constants.DEFAULT_SETTINGS`)
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"autoArchive": true, "archiveAfterDays": 14, "archiveClearDays": 90, "trashClearDays": 7,
|
||||
"minLen": 24, "stopPhrases": ["взаимный пиар", "…"],
|
||||
"mlEnabled": true, "aiEnabled": true, "aiFilterEnabled": true,
|
||||
"aiPrompt": "Ты — классификатор…{domain}…{keywords}…", "aiFilterPrompt": "…", "cardPrompt": "…",
|
||||
"wantedType": "both", // "both"|"vacancy"|"freelance"
|
||||
"budgetRequiredHire": false, "budgetRequiredOrder": false,
|
||||
"hireLabel": "вакансия", "orderLabel": "фриланс",
|
||||
"domainDescription": "", "domainKeywords": [], "hireMarkers": [], "levelTerms": [], "resumeMarkers": [],
|
||||
"blockResumes": true, "myPrompts": [{"id":"pp_…","name":"…","description":"…","prompt":"…"}],
|
||||
"remindersEnabled": true,
|
||||
"conversionOn": true, "targetCurrency": "RUB", "rateSource": "cbr", // cbr|mock
|
||||
"autoMonitorNew": true,
|
||||
"discJoinLimit": 50, "discJoinDelayMin": 50, "discJoinDelayMax": 70,
|
||||
"discEvalSample": 10, "discEvalThreshold": 40, // (не используется фронтом)
|
||||
"discPaused": false, "colState": {}, // colState — то же, что GET /columns/state
|
||||
"aiProvider": "deepseek",
|
||||
"aiConfigs": { "deepseek": {"baseUrl": "https://api.deepseek.com", "model": "…", "keySet": true, "keyMasked": "sk-12…3456"} },
|
||||
"providers": [{"id":"deepseek","name":"DeepSeek","base":"…","local":false,"models":[…]}, …]
|
||||
}
|
||||
```
|
||||
Ключи, которые фронт шлёт в PATCH (по одному/группами): `aiProvider`, `aiConfigs{<id>:{baseUrl,model,apiKey?}}`, `aiPrompt`, `cardPrompt`, `aiFilterPrompt`, `stopPhrases`, `domainDescription`, `domainKeywords`, `hireMarkers`, `levelTerms`, `resumeMarkers`, `blockResumes`, `myPrompts`, `autoArchive`, `archiveAfterDays`, `aiEnabled`, `aiFilterEnabled`, `minLen`, `conversionOn`, `targetCurrency`, `rateSource`, `remindersEnabled`, `mlEnabled`, `wantedType`, `budgetRequiredHire`, `budgetRequiredOrder`, `hireLabel`, `orderLabel`, `autoMonitorNew`, `discJoinLimit`, `discJoinDelayMin`, `discJoinDelayMax`, `discPaused`.
|
||||
⚠ Ответ PATCH — **полный** settings: `schedulePersist`/`saveAiSettings`/`saveDiscQuota` применяют его целиком к локальному state (источник истины после клампов).
|
||||
⚠ **Изменение (решение владельца, вариант A):** ключей Telegram (`api_id`/`api_hash`) в настройках тенанта больше нет — они задаются оператором глобально (ТЗ §4.1/§8.1), см. `docs/architecture/2026-09-10-operator-analytics-contract.md` (раздел «Операторские настройки»). Вкладка Telegram у тенанта остаётся (подключение аккаунта, `GET /api/tg/status`).
|
||||
|
||||
### 4.7 Промпты
|
||||
- `aiPrompt`, `aiFilterPrompt`, `cardPrompt` — plain string, редактируются на вкладке ИИ; содержат плейсхолдеры `{domain}`/`{keywords}`.
|
||||
- `myPrompts` — личная библиотека: `[{id, name(≤80), description(≤300), prompt}]`, ≤100; id генерирует и фронт (`pp_…`), и бэк при отсутствии.
|
||||
|
||||
### 4.8 Каналы/discovery
|
||||
|
||||
**Диалог** (`GET /api/tg/dialogs` item): `{id: string, name, handle, type, hue, on: bool, last: {text, time}}` — фронт читает `id/name/handle/type/hue/on` (`last` не читает). ⚠ `type` нестабилен по значению: «канал»/«группа»/«чат» (из `refresh_dialogs`) либо `channel`/`group`/`forum` (после discovery-вступлений `add_dialog_monitored`).
|
||||
|
||||
**Сообщение превью** (`POST /dialogs/preview` item): `{id, text, time: ms, lead: bool}`. ⚠ `id`: из Telegram — int; фолбэк из БД — string `m_<dialog>_<msg>`.
|
||||
|
||||
**Discovery-задача** (`GET/POST/PATCH …/tasks`, ответы start/pause): `{id:"dt_…", name, description, keywords[], minSubscribers: int, lang: "ru"|"any", threshold: int(1..100), sampleSize: int, planJoins: int, autoJoin: bool, status: "draft"|"running"|"paused"|"done"|"failed", searchIdx: int, searchDone: bool, found: int, evaluated: int, joined: int, rejected: int, createdAt: ms, updatedAt: ms}`.
|
||||
|
||||
**Кандидат** (`GET …/candidates` item, ответы join/reject): `{dialogId, taskId, name, username, kind: "channel"|"group"|"forum", hue, participants: int|null, langRu: bool|null, marks: string[], topics: [{topicId, title, fitCount, total, fitRatio, passed}] (форумы), fitRatio: 0..1|null, status: "new"|"review"|"joined"|"rejected", autoJoined: bool, joinFailures: int, createdAt: ms, updatedAt: ms}`.
|
||||
|
||||
### 4.9 Telegram-статус (`GET /api/tg/status`, payload `system_status`)
|
||||
|
||||
`{phase: "idle"|"phone"|"code"|"password"|"qr"|"ready", connected: bool, listener: bool, account: string, monitored: int, keysSet: bool, error: string|null, qrUrl: string|null}`. Фронт: `connected→tgConnected`, `account`, `phase`→`tgState` (ready→done), `qrUrl` при phase='qr', `keysSet`.
|
||||
|
||||
### 4.10 Прочее
|
||||
|
||||
- **`GET /api/ml/status`**: `{enabled: bool, service: {ready, classes: {label: n}, learned: int, eval: {count, correct, accuracy}}, reachable: bool, stats: {ml, ai, learning, ready, classes, learned, reachable, outbox}}`. Фронт читает: `reachable`, `service.ready/classes/learned/eval.{count,correct,accuracy}`, `stats.outbox`.
|
||||
- **`POST /api/admin/check-message`** (сухой прогон конвейера): `{text}` →
|
||||
`{passed, wouldCreateCard, targetContainer, matchHits, parsed, stages:[{stage, pass, skipped, reason, kw, label}]}`.
|
||||
Коды `stage`: `length|stop|resume|type|exclude|ml|ai|spam_ai|budget`; `skipped=true` — этап выключен
|
||||
настройкой. `parsed` — разбор текста (поля карточки) либо null. Запись в систему не производится.
|
||||
- **`POST /api/ai/check`**: `{ok: bool, message: string, local?, keySet?}`.
|
||||
- Комментарии карточки: `{id, by: string, text, time: string}` — `by` всегда «Вы», `time` «только что».
|
||||
|
||||
---
|
||||
|
||||
## 5. Сводка
|
||||
|
||||
**Карточки и контейнеры (единый контракт этапа 9):** `GET/POST /api/cards`, `GET/DELETE
|
||||
/api/cards/{cardId}`, `/move`, `/trash`, `/restore`, `/comments`, `/links`, `/files`, `/reminder`,
|
||||
`/take`, `/clear-col`, `/clear-rejected`, `/mark-all-seen`, `/mark-col-seen`, `/reclassify`,
|
||||
`GET /api/search`; `GET/POST /api/containers`, `PATCH/DELETE /api/containers/{id}`, `/accept`, `/reorder`,
|
||||
`/state` — описаны в §3.2 и §3.5.
|
||||
|
||||
**Прочие домены (этап 9 их не менял):**
|
||||
|
||||
| Модуль (роутер) | Эндпоинты |
|
||||
|---|---:|
|
||||
| Auth `/api/auth` | 4 |
|
||||
| Telegram `/api/tg` | 14 |
|
||||
| Settings/rates/meta (`/api/settings`, `/api/ai/check`, `/api/rates`) | 5 |
|
||||
| Processing `/api/pipeline` (+ `/api/admin/check-message`) | 7 |
|
||||
| ML `/api/ml` | 5 |
|
||||
| Discovery `/api/discovery` | 13 |
|
||||
| Operator `/api/operator` + `/api/join` | 25 |
|
||||
| Events `/api/events` | 1 |
|
||||
| Health `/api/health` | 1 |
|
||||
|
||||
**SSE-события:** `new_card`, `toast`, `reminder_due`, `system_status` — 4 именованных типа (см. §2).
|
||||
|
||||
**Коды ошибок:** всегда `{"detail": "<текст>"}` — `400` (неверный ввод/правила), `401` (нет сессии),
|
||||
`403` (вход приостановленного тенанта), `404` (объект не найден), `410` (файл не сохранён), `422` (тело
|
||||
не разобрано). Исключения — «мягкие» ошибки в HTTP 200 с полями `error`/`reason` (см. п.1 ниже).
|
||||
|
||||
**Замечания (актуальные):**
|
||||
1. Ответы PATCH `/api/settings`, `POST /api/ml/reset` и discovery `generate-keywords` «ошибочные» ветки: мягкие ошибки в HTTP 200 с полями `error`/`reason` вместо `{"detail"}` (см. §6 п.7).
|
||||
2. Тип диалога (`tg/dialogs.type`/`kind`) хранится вперемешку («канал»/«группа»/«чат» после refresh против `channel`/`group`/`forum` после discovery-вступления) — UI показывает как есть.
|
||||
3. Превью-сообщения: `id` — int (из Telegram) либо string `m_<dialog>_<msg>` (фолбэк из БД) — ключи рендера неустойчивы.
|
||||
4. Контейнер: `POST`/`PATCH` отвечают `{id}` (не полный объект); после мутаций фронт перечитывает `GET /api/containers`.
|
||||
|
||||
---
|
||||
|
||||
## 6. Реализовано в Deal — расхождения с картой и SaaS-дополнения (этапы 7, 10)
|
||||
|
||||
Карта выше — контракт фронта Дейла (после этапа 9 — единый: карточки/контейнеры). Расхождения,
|
||||
влияющие на HTTP-семантику, и SaaS-ручки вне карты — ниже (контракт фронта они НЕ ломают).
|
||||
|
||||
**Расхождения/решения этапа 7 (зафиксированы в коде; task-7-report.md):**
|
||||
|
||||
1. Вход приостановленного тенанта — **HTTP 403** `{detail: "Учётная запись приостановлена. Обратитесь к оператору"}`, а не 401: учётка существует, доступ запрещён; 401 остаётся только для неверных учётных данных (статус не раскрывается). В аудит пишется `tenant_login_failed` с tenantId.
|
||||
2. Смена статуса тенанта — **не PATCH {status}**, а явные `POST /api/operator/tenants/{id}/suspend` и `POST …/unsuspend` (аудит `tenant_status_changed`, идемпотентно). Отклонение приёмочного текста плана «PATCH … status» — осознанное.
|
||||
3. `POST /api/operator/tenants` (create) принимает `{name, email?}` **без `budget?`**: бюджет задаётся отдельно (`GET/PATCH …/tenants/{id}/limit`); у нового тенанта — ленивый дефолт-бюджет (константа `TokenBudgetDefaults`/env `DEAL_DEFAULT_AI_BUDGET`). Поле-заглушка «принять и не применить» не вводилась.
|
||||
4. «Отсутствующие» эндпоинты карты не реализованы сознательно (экономия; список — §5): `/cards/{cardId}/seen` (снятие «новое» с одной карточки), `/meta/constants`, `admin/wipe|clear-cards|pump-gate`, `ml/learn|flush` (внутренние RPC/флашер MlOutbox), `/tg/dialogs/{id}/backfill` (сервер-only: backfill включается мониторингом/«Перечитать всё»). Демо-ручки `POST /api/demo/*` (флаг `DEAL_DEMO`) удалены. `POST /api/cards/reclassify` — **реальный проход** (этап 12): переклассификация «Неразобранного» через тот же конвейер, что и пайплайн (ИИ-фильтр → классификация → правила колонок) с локальным фолбэком при выключенном/недоступном ИИ; single-flight (`{started:false, busy:true}` при занятом проходе), есть и одиночная ручка `POST /api/cards/{cardId}/reclassify`.
|
||||
|
||||
**Устойчивость и очистки (этап 12, пакет B).** Rate limiting и `LoginAttemptGuard` — store-backed на Postgres (таблица `public.rate_limit_counters`), т.е. работают при нескольких инстансах core; активные сессии приостановленного тенанта разлогиниваются сразу (проверка статуса в `AuthService.ResolveSessionAsync`, включая impersonation). Фоновый `DataRetentionScheduler` (раз в сутки) чистит `audit_log` по retention (дефолт 180 дней), сбрасывает накопительные поля `tenant_limits` прошедших периодов и удаляет завершившиеся окна счётчиков.
|
||||
|
||||
**SaaS-ручки (этапы 7, 10).** С этапа 10 у операторских ручек есть **UI**: экран оператор-консоли `#/operator` (разделы «Тенанты», «Приглашения», «Лимиты ИИ», «Аудит», «Аналитика», «Состояние системы») и публичная страница активации инвайта `#/join?code=…`; основное приложение — `#/`. Операторская кука — `deal_operator_session` (12 ч, httpOnly, SameSite=Lax; отдельная от `deal_session`); `/api/join` — публичная (без куки). 401 на всех `/operator/*` без операторской сессии — «Требуется вход оператора». Тенантные `/api`-ручки операторских сессий не видят и наоборот (разные middleware). Подробнее — техдок §13.8 (контур) и §13.10 (консоль/аналитика).
|
||||
|
||||
| METHOD /api/… | Назначение | Ответ |
|
||||
|---|---|---|
|
||||
| `POST /operator/auth/login` `{login,password}` | вход оператора (env `DEAL_OPERATOR_*`; dev-дефолт `operator`/`operator`) | `{ok, login}` + кука; 401; 429 (rate limit) |
|
||||
| `POST /operator/auth/logout`; `GET /operator/auth/me` | выход / проверка сессии | `{ok}`; `{login, ok}`; 401 |
|
||||
| `POST /join` `{code, email, name?, password}` | публичная активация инвайта (страница `#/join?code=…`): пользователь (Argon2id) и, при необходимости, тенант с провижинингом | `{ok: true, login}`; 400 `{detail}` |
|
||||
| `GET /operator/tenants` | список тенантов + счётчики пользователей | `{items:[{id,name,status,createdAt,usersCount}]}` |
|
||||
| `POST /operator/tenants` `{name, email?}` | создать тенанта (email → владелец с одноразовым паролем) | `{id,name,status,createdAt}` (+`ownerEmail`,`initialPassword`); 400/401 |
|
||||
| `GET /operator/tenants/{id}` | детали + пользователи | тенант; 404 |
|
||||
| `POST /operator/tenants/{id}/suspend`; `…/unsuspend` | приостановка/возобновление (см. п.2) | `{ok, status}`; 404 |
|
||||
| `POST /operator/tenants/{id}/impersonate` `{login?}` | вход от имени пользователя тенанта; **ставит httpOnly-куку `deal_session` ответом** — оператор сразу в тенанте | `{sessionToken, expiresAt, tenantId, login}`; 404/400 |
|
||||
| `GET /operator/invites`; `POST /operator/invites` `{email, tenantId?, name?}` | список / создание инвайта (код 16 симв., 72 ч) | `{items:[…]}`; `{code,email,tenantId,expiresAt,status}` |
|
||||
| `POST /operator/invites/{code}/revoke` | отзыв инвайта | `{ok:true}` |
|
||||
| `GET /operator/limits` | сводка ИИ-бюджетов по тенантам | `{items:[{tenantId,name,budget,period,used,percent,status}]}` |
|
||||
| `GET/PATCH /operator/tenants/{id}/limit` | детали/смена бюджета `{budget?, period?}` (сброс флагов порогов, аудит) | лимит; 400/404 |
|
||||
| `GET /operator/audit?eventType=&actorType=&actorId=&tenantId=&from=&to=&limit=&offset=` | лента аудита (append-only, At DESC, limit ≤500, пагинация) | `{items, total}` |
|
||||
| `GET /operator/analytics/overview?from=&to=` | сводка за период: тенанты, токены, события, входы/выходы/неудачные входы | `{tenantsTotal,tenantsActive,promptTokens,completionTokens,totalTokens,tokenEvents,events,logins,logouts,failedLogins,from,to}` |
|
||||
| `GET /operator/analytics/tokens?groupBy=&tenantId=&from=&to=` | агрегаты расхода токенов (`groupBy=day\|tenant\|provider\|model`) | `{groupBy,from,to,items:[{key,…}],total}`; 400 (неизвестная группировка) |
|
||||
| `GET /operator/analytics/activity?eventType=&actorType=&actorId=&tenantId=&from=&to=&limit=&offset=` | лента действий (аудит) с фильтрами и пагинацией | `{items,total,limit,offset}` |
|
||||
| `GET /operator/health` | health core/БД + сервисы ml/ai/telegram (UseLocal → `mode:local`); этап 12: глубины очередей и активные сессии | `{ok, core:{db}, services:[…], queues:{pipeline,mlOutbox}, sessions:{active}}` (всегда 200) |
|
||||
| `POST /operator/maintenance/tenants/migrate` | Пакетная миграция схем всех тенантов (идемпотентно, шардированный обход страницами + ограниченный параллелизм; этап 12, пакет C / BL-SCALE-1000) | `{ok,total,migrated,failed,failedSchemas,durationMs}` (`ok=false`, если хотя бы одна схема не мигрирована); 401 без операторской сессии |
|
||||
| `GET /operator/analytics/suspicious?from=&to=` | подозрительная активность по аудиту (всплеск неудачных входов по IP/логину, входы актора с множества IP, серии по тенанту; этап 12) | `{scanned,truncated,items:[…]}` |
|
||||
| `GET /operator/settings/telegram-keys` | глобальные ключи Telegram (задаёт оператор; тенант их не видит) | `{apiId, apiHash (маска), keysSet}` |
|
||||
| `PUT /operator/settings/telegram-keys` `{apiId?, apiHash?}` | задать/обновить ключи (частично: можно одно поле, второе сохраняется); `api_id` 5–9 цифр, `api_hash` непустой; hash шифруется | маска-форма; 400 `{detail}`; 401 |
|
||||
@@ -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,434 @@
|
||||
# Дейл — единый 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` | промежуточный — `{ "progress": true, "done", "total", "moved", "kept", "trashed", "skipped" }`; финал — `{ "progress": false, "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`).
|
||||
- Во время пакетного прохода публикуются промежуточные SSE `cards_reclassified` с `{ "progress": true,
|
||||
"done", "total", "moved", "kept", "trashed", "skipped" }` (каждые 5 карточек и на последней); по завершении —
|
||||
финальное `{ "progress": false, "reclassified", "moved" }`; фронт показывает `done/total` и перечитывает
|
||||
доску только по финальному событию.
|
||||
|
||||
```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` |
|
||||
@@ -0,0 +1,276 @@
|
||||
# Дейл — код-стайл (действующие правила)
|
||||
|
||||
> Единый свод правил стиля кода для всего репозитория (core, telegram/ai/ml-сервисы, тесты).
|
||||
> Составлен на основе исходного `Стиль_кода.docx` (перенесён в `archive/style-guide-original/`),
|
||||
> дополнен действующими правилами проекта и `.editorconfig`. Правила обязательны для нового кода;
|
||||
> приведение существующего — в `docs/superpowers/backlog.md` (`TD-COMMENTS-IFACE`, `TD-PROTO-COMMENTS`).
|
||||
|
||||
Пометки:
|
||||
- **[изм.]** — правило дополнено/уточнено относительно исходного документа.
|
||||
- **[отмена]** — правило исходного документа, которое в этом проекте не применяется.
|
||||
|
||||
---
|
||||
|
||||
## 1. Именование
|
||||
|
||||
Используются стандартные соглашения .NET. Венгерская нотация и префиксы типов в именах не применяются.
|
||||
|
||||
- **Классы** — Pascal: `User`.
|
||||
- **Интерфейсы** — Pascal с префиксом `I`: `IDisposable`, `ICardStore`.
|
||||
- **Generic-параметры** — Pascal с `T`: `T`, `TKey`, `TValue`.
|
||||
- **Публичные функции/методы** — Pascal: `Authenticate`.
|
||||
- **Приватные функции/методы** — тоже Pascal: `Authenticate` (не camel).
|
||||
- **Параметры функций** — camel: `userId`.
|
||||
- **Свойства (public/private)** — Pascal: `FirstName`.
|
||||
- **Public-поля** — Pascal: `FirstName`. **[изм.]** Публичное состояние — свойство (§4); публичное поле допускается
|
||||
только для данных-контейнеров без логики и именуется Pascal.
|
||||
- **Private-поля — обязательный префикс `_` + camelCase: `_firstName`.** **[изм.]** Без `_` запрещено.
|
||||
Исключения — только для константоподобных полей: `const` и `static readonly` именуются PascalCase
|
||||
(`MaxRetryCount`, `DefaultTimeout`).
|
||||
- **Локальные переменные** — camel: `user`.
|
||||
- **Константы** — Pascal: `MaxRetryCount` (приватные `const` и `static readonly` — тоже Pascal, без `_`).
|
||||
- **Enum** — Pascal: `UserStatus`; **значения enum** — Pascal: `Active`.
|
||||
- **Exception** — Pascal с суффиксом `Exception`: `UserAuthenticationException`.
|
||||
- **Event** — Pascal: `StatusChanged`.
|
||||
- **Namespace** — Pascal.
|
||||
|
||||
Не использовать сокращения, кроме общепринятых (`id`, `ui`, `http`, `grpc`, `json`, `api`).
|
||||
|
||||
## 2. Организация кода и файлов
|
||||
|
||||
- Один публичный тип — один файл; имя файла = имя типа. **[изм.]** Правило усилено: смешивать типы в
|
||||
одном файле нельзя (небольшие вспомогательные private-классы — исключение).
|
||||
- **`namespace` строго соответствует пути папки** (для тестов — тоже). Файлы группируются по назначению:
|
||||
`Abstractions` (интерфейсы `I*`), `Services` (сервисы/воркеры/исполнители), `Models` (доменные типы,
|
||||
enum/статусы/константные реестры), `Dtos` (`*Dto`/`*Request`/`*Response`/`*Patch`), `Extensions`
|
||||
(`*Extensions`), `Options` (`*Options`), `Exceptions` (`*Exception`), `Registrars` (`*ModuleRegistrar`),
|
||||
`Configurations` (EF-конфигурации), `Entities`, `Repositories`. Feature-папки допустимы и сохраняются
|
||||
(`Endpoints`, `Middleware`, `Hosting`, `Parsing`, `ColumnRules` и т.п.).
|
||||
- **Тестовые проекты** группируются по областям (`Modules/<X>`, `Api`, `Infrastructure`, `Contracts`,
|
||||
`Grpc`, …), общие фейки/хелперы — в `Support`; `namespace` = `<ПроектТестов>.<Область>`.
|
||||
- В одном файле — один `namespace`. File-scoped namespace допустим.
|
||||
- Все `using` — в начале файла; сначала системные, затем сторонние/project.
|
||||
- `using` внутри `namespace` не используются (внешние `using`).
|
||||
- Порядок членов внутри типа: константы → поля → конструкторы → свойства → методы. Члены группируются
|
||||
по назначению.
|
||||
- **[отмена]** Регионы (`#region`) **не используются** — вместо них осмысленный порядок и декомпозиция.
|
||||
- Если у свойства есть backing-поле, поле объявляется **над** свойством:
|
||||
|
||||
```csharp
|
||||
private User _user;
|
||||
public User User { get; set; }
|
||||
```
|
||||
|
||||
## 3. Форматирование
|
||||
|
||||
- Стандартные настройки форматирования Visual Studio / `.editorconfig`.
|
||||
- Фигурные скобки — всегда на отдельной строке (Allman).
|
||||
- В `if`/`else` фигурные скобки используются **всегда**, даже для одной инструкции.
|
||||
- Отступ — 4 пробела (символ табуляции в историческом документе; в проекте — пробелы).
|
||||
- Длина строки — желательно не более 100 символов; при переносе продолжение сдвигается вправо на один
|
||||
уровень отступа.
|
||||
- Каждая переменная объявляется на отдельной строке.
|
||||
- Если `get`/`set` свойства состоит из одной операции, допускается размещение на одной строке:
|
||||
|
||||
```csharp
|
||||
public User
|
||||
{
|
||||
get { return user; }
|
||||
}
|
||||
```
|
||||
- Модификаторы доступа указываются **всегда**, включая явный `private`.
|
||||
|
||||
## 4. Проектные соглашения .NET
|
||||
|
||||
Машиночитаемая часть правил форматирования/анализа — в `.editorconfig` и `Directory.Build.props`
|
||||
(`Nullable=enable`, `TreatWarningsAsErrors=true`, `EnforceCodeStyleInBuild=true`). Ниже — соглашения
|
||||
уровня кода, которые этими файлами не выражаются.
|
||||
|
||||
- **Публичные члены — только свойства** (`{ get; init; }` / `{ get; set; }`), **не публичные поля**.
|
||||
**[изм.]** Отменяет исходное правило о публичных полях: публичное состояние — свойство.
|
||||
- Приватное/внутреннее состояние без дополнительной логики — поле (см. §6); с логикой — свойство.
|
||||
- Зависимости — через конструктор (DI). Настройки — через `IOptions<T>` / `IOptionsSnapshot<T>`;
|
||||
прямое чтение `IConfiguration` в бизнес-коде не допускается.
|
||||
- `var` не использовать для встроенных типов и когда тип неочевиден — предпочитать явный тип
|
||||
(см. `.editorconfig`, `csharp_style_var_* = false`).
|
||||
- **Время**: `DateTimeOffset` в UTC внутри домена; на wire — epoch-миллисекунды. Локальное время —
|
||||
только на границе представления (UI).
|
||||
- **JSON на wire** — camelCase; ошибки API — объект `{ "detail": ... }`.
|
||||
- **Идентификаторы** — с префиксом сущности/типа (напр. `card_...`, `board_...`), без «сырых» чисел.
|
||||
- `this.` для обращения к членам **запрещён** (`dotnet_style_qualification_* = false:warning`).
|
||||
К приватным полям обращаемся по имени с `_` (`_logger.Info(...)`), к свойствам/методам — без
|
||||
квалификации. Запрет распространяется на поля, свойства, методы и события. **[изм.]**
|
||||
- Асинхронность: суффикс `Async`, `CancellationToken` пробрасывать до конца; `.Result` / `.Wait()`
|
||||
запрещены — только `await`.
|
||||
|
||||
## 5. Комментирование кода
|
||||
|
||||
Все комментарии — на русском языке.
|
||||
|
||||
- **Комментируем то, что видно снаружи.** XML-doc (`///`) — на **public/protected** члены, типы и
|
||||
интерфейсы. **[изм.]** Приватные/внутренние детали реализации комментариями не «обвешиваем» — только
|
||||
там, где неочевидна причина/ограничение (короткий обычный комментарий).
|
||||
- **Кратко.** Комментарий объясняет **зачем и что**, а не пересказывает код. Не дублировать имя и
|
||||
сигнатуру словами.
|
||||
- **`<summary>` — короткое описание (одна фраза).** Это назначение типа/члена, а **не** «как оно
|
||||
работает» и не пояснения/детали реализации. Несколько предложений в summary не пишем.
|
||||
- **`<remarks>` не используем** — подробные пояснения «как устроено» не нужны; rationale — только если
|
||||
поведение действительно неочевидно, коротким обычным комментарием.
|
||||
- **Никаких упоминаний процесса:** в комментариях запрещены ссылки на таски/этапы/рулинги/планы и
|
||||
прототип (`Task N`, `Ruling N`, `этап N`, `python L…`, `main.py`, `прототип`, `LEADRADAR_*` и т.п.).
|
||||
- **Внутренние `//`-комментарии — только для неочевидного поведения** (причина, ограничение, подвох).
|
||||
Пересказ кода, пошаговая навигация и «что делает следующая строка» — удалять.
|
||||
- **`<param>`/`<returns>`** — только если смысл не очевиден из имени/типа; не переписывать сигнатуру.
|
||||
- **`<summary>` — только блочный.** Открывающий `<summary>` и закрывающий `</summary>` — **каждый на
|
||||
своей строке**; запись в одну строку (`/// <summary>текст</summary>`) **не допускается**. **[изм.]**
|
||||
|
||||
Правильно:
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// Краткое описание назначения.
|
||||
/// </summary>
|
||||
public void DoWork() { }
|
||||
```
|
||||
|
||||
Неправильно:
|
||||
```csharp
|
||||
/// <summary>Краткое описание.</summary>
|
||||
public void DoWork() { }
|
||||
```
|
||||
- Прочие теги (`<param>`, `<returns>`, `<remarks>`, `<inheritdoc/>`) — по необходимости; `<param>`/`<returns>`
|
||||
можно однострочно, `<remarks>` — блоком.
|
||||
- Для функций, создающих исключения, возможные исключения указывать в `<exception>`.
|
||||
- Для примеров использования — `<example>`, `<remarks>`, `<code>`.
|
||||
- Для ссылок в документации — `<see cref="..."/>`, `<seeAlso cref="..."/>`.
|
||||
- Спецсимволы XML в тексте комментария — через `CDATA`.
|
||||
- Для сложных/неочевидных алгоритмов — пояснение каждого шага прямо в коде.
|
||||
- **[изм.]** При изменении критичных участков/ядра — комментарий: кто, когда, почему.
|
||||
- Временные заплатки — с `//TODO:` и указанием, что и когда должно быть исправлено.
|
||||
- Неочевидные межкомпонентные зависимости (не ловятся компилятором) — описывать подробно.
|
||||
|
||||
## 6. Переменные и типы
|
||||
|
||||
- Свойство использовать только когда есть смысл. Если при получении/сохранении дополнительной логики
|
||||
нет — использовать поле.
|
||||
- Использовать максимально простой достаточный тип (`int`, а не `long`, когда `int` хватает).
|
||||
- Константы — только для простых типов; для сложных — `static readonly`-поля.
|
||||
- `object` — только когда действительно необходимо; в остальных случаях generic-и. `Hashtable` → `Dictionary<>`,
|
||||
`ArrayList` → `List<>`.
|
||||
- Boxing/unboxing value-типов — только при необходимости.
|
||||
- При задании нецелых значений — минимум одна цифра до и после точки.
|
||||
- Использовать имена типов C# (`int`, `string`), а не CTS (`Int32`, `String`).
|
||||
- Поля и переменные инициализировать при объявлении, когда возможно.
|
||||
- Конструктор по умолчанию, если класс требует параметров инициализации, делать `private`, чтобы клиент
|
||||
не создал неинициализированный объект.
|
||||
- Magic numbers для статусов/состояний запрещены — только константы/enum:
|
||||
|
||||
```csharp
|
||||
// плохо
|
||||
public User GetUserByStatus(int statusId);
|
||||
// хорошо
|
||||
public User GetUserByStatus(UserStatus userStatus);
|
||||
```
|
||||
- Если `get`/`set` содержит сложные вычисления, преобразование, побочный эффект или долго выполняется —
|
||||
заменить свойством на метод.
|
||||
- Свойство не должно менять значение от вызова к вызову при неизменном состоянии объекта.
|
||||
- Внутри `get`/`set` не должно быть обращений к коду, не связанному напрямую с получением/сохранением значения.
|
||||
- Настройки, влияющие на работу приложения, не хардкодить — выносить в конфигурацию. Значения по умолчанию
|
||||
прописывать; если default невозможен и ключ отсутствует — выбрасывать исключение.
|
||||
|
||||
## 7. Функции
|
||||
|
||||
- Функции, возвращающие массив/коллекцию, всегда возвращают массив/коллекцию: если данных нет — пустой
|
||||
экземпляр, но не `null`.
|
||||
- Не более 7 параметров у функции. Больше — объединять в класс/DTO.
|
||||
- **Перенос параметров:** если параметров **больше двух** — каждый на **отдельной строке** (открывающая `(` — в конце первой строки, закрывающая `)` — на отдельной строке с отступом объявления); если **два или меньше** — все параметры **в одну строку**.
|
||||
|
||||
Больше двух:
|
||||
```csharp
|
||||
public async Task<CardMoveResultDto> MoveAsync(
|
||||
string cardId,
|
||||
string toContainerId,
|
||||
TransitionContext ctx,
|
||||
CancellationToken ct)
|
||||
```
|
||||
|
||||
Два или меньше:
|
||||
```csharp
|
||||
public User FindUser(string login, CancellationToken ct) { }
|
||||
```
|
||||
|
||||
## 8. Управление выполнением программы
|
||||
|
||||
- При `foreach` по коллекции саму коллекцию модифицировать нельзя (не добавлять и не удалять элементы).
|
||||
- Если задача решается и рекурсией, и циклом — предпочитать цикл; рекурсия — только когда цикл сложнее.
|
||||
- Тернарный оператор — только для простых проверок; сложные условия — через `if`/`else`.
|
||||
- Сложные составные условия разбивать на простые, сохраняя промежуточные результаты в `bool`-переменные.
|
||||
- Типы, реализующие `IDisposable`, создавать в `using`:
|
||||
|
||||
```csharp
|
||||
using (SqlConnection sqlConnection = new SqlConnection(...)) { }
|
||||
```
|
||||
|
||||
## 9. События, делегаты, потоки
|
||||
|
||||
- Перед вызовом делегата/события — всегда проверка на `null`.
|
||||
- Для простых event-ов использовать `EventHandler`/`EventArgs`.
|
||||
- Для сложных event-ов — наследники `EventArgs`.
|
||||
- Для блокировок использовать `lock`, а не класс `Monitor`.
|
||||
|
||||
## 10. Исключения и их обработка
|
||||
|
||||
- `try-catch` — только для непредвиденных ошибок, не для управления ходом программы.
|
||||
- При пробрасывании выше — `throw;`, а **не** `throw ex;`.
|
||||
- Свои исключения наследовать от `Exception`.
|
||||
- Исключение создавать всегда, когда функция не может быть выполнена (неверные параметры, нет доступа к
|
||||
БД, неизвестные идентификаторы и т.п.).
|
||||
- Все исключения должны быть залогированы или показаны пользователю; пустые `catch` запрещены.
|
||||
- В лог об ошибке, как правило, писать `StackTrace`.
|
||||
|
||||
## 11. Интерфейсы
|
||||
|
||||
- **Не дублировать `<summary>` интерфейса в реализации.** Если член объявлен в интерфейсе с XML-doc,
|
||||
в классе-реализации достаточно `/// <inheritdoc/>` (или вообще ничего, если doc наследуется настройкой).
|
||||
Текст описания пишется **один раз** — у интерфейса.
|
||||
- **Явная реализация интерфейсов — по умолчанию** (`Task ICardStore.GetAsync(...)`). **[изм. 2026-09-11,
|
||||
решение владельца]** Классы напрямую не вызываются — только через интерфейсы; исключения: DTO/модели
|
||||
(напр. `Card` и семейство `I*Card`), хелперы, extension-классы. Весь прод-код уже переведён на явные
|
||||
реализации (codemod `scripts/make_explicit.py`, идемпотентный).
|
||||
- Один публичный тип интерфейса = один файл (как и для классов); имя файла = имя типа.
|
||||
- **Маркерные классы не используются** — если нужен маркер, это маркерный интерфейс
|
||||
(`IKanbanModule`, `ISharedKernel` и т.п.). **[изм. 2026-09-11]**
|
||||
- **Тесты: моки — через NSubstitute** (`Substitute.For<IPasswordHasher>()`), тестовые переменные
|
||||
типизируются интерфейсом. Новые hand-written фейк-классы не заводить; существующие мигрируются
|
||||
поэтапно (план — `backlog.md`, `TD-TESTS-NSUBSTITUTE`). **[изм. 2026-09-11]**
|
||||
|
||||
## 12. Приложение: сводная таблица правил именования
|
||||
|
||||
| Идентификатор | Регистр | Пример |
|
||||
| --- | --- | --- |
|
||||
| Класс | Pascal | `User` |
|
||||
| Локальная переменная | camel | `user` |
|
||||
| Интерфейс | Pascal (`I`) | `IDisposable` |
|
||||
| Generic | Pascal (`T`) | `T`, `TKey`, `TValue` |
|
||||
| Публичная функция | Pascal | `Authenticate` |
|
||||
| Приватная функция | Pascal | `Authenticate` |
|
||||
| Параметр функции | camel | `userId` |
|
||||
| Публичное свойство | Pascal | `FirstName` |
|
||||
| Приватное свойство | Pascal | `FirstName` |
|
||||
| Публичное поле | Pascal | `FirstName` |
|
||||
| Приватное поле | `_` + camel | `_firstName` |
|
||||
| Приватное `const` / `static readonly` | Pascal | `MaxRetryCount` |
|
||||
| Константа | Pascal | `MaxRetryCount` |
|
||||
| Enum | Pascal | `UserStatus` |
|
||||
| Значение enum | Pascal | `Active` |
|
||||
| Exception | Pascal (+`Exception`) | `UserAuthenticationException` |
|
||||
| Event | Pascal | `StatusChanged` |
|
||||
| Namespace | Pascal | `Deal.Core.Cards` |
|
||||
|
||||
## 13. Автоматизация
|
||||
|
||||
- **Служебные скрипты (codemod'ы, скрипты сборки/тестов/бэкапов) в репозиторий не входят** —
|
||||
правило владельца: в репе только код. Актуальные копии живут локально, вне кода.
|
||||
- **Проверка на новом коде**: правила `<summary>`-блока и «комментарии только на public» проверяемы
|
||||
статически; задел — линтер (по аналогии с `scripts/i18n-lint.mjs`) и/или анализаторы Roslyn/StyleCop в
|
||||
`Directory.Build.props`.
|
||||
- Открытые пункты аудита и решения по ним — `docs/spec/Код-стайл-аудит-2026-09-11.md`.
|
||||
@@ -0,0 +1,62 @@
|
||||
# Аудит кода на соответствие код-стайлу «Дейл» (2026-09-11)
|
||||
|
||||
> Отчёт прохода по всему C#-коду (`src/**/*.cs`, 928 файлов, без `bin/obj`).
|
||||
> Правила — `docs/spec/Код-стайл-Дейл.md`. Проверка: сборка 4 решений + все тесты.
|
||||
|
||||
## 1. Исправлено (применено и проверено)
|
||||
|
||||
| Пункт | Правило | Было | Стало | Инструмент |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| Блочный `<summary>` | §5 | 5286 однострочных/инлайн (833 файла) | 0 | `scripts/fix_summary_blocks.py --apply` |
|
||||
| XML-док на private/internal | §5 | 2028 блоков (359 файлов) | 0 (понижены до `//`) | `scripts/fix_private_docs.py --apply` |
|
||||
| Квалификация `this.` | §4 | 124 | **0** | разовый Roslyn-инструмент (семантический) |
|
||||
| Приватные instance-поля | §1 | camelCase (`logger`) | `_camelCase` (`_logger`) | разовый Roslyn-инструмент |
|
||||
| Приватные `static readonly`/`const` | §1 | — | Pascal (`DefaultTimeout`) | разовый Roslyn-инструмент |
|
||||
|
||||
Дополнительно в `.editorconfig` включены машинные правила, теперь ломающие сборку при нарушении
|
||||
(`TreatWarningsAsErrors=true`, `EnforceCodeStyleInBuild=true`):
|
||||
- `dotnet_style_qualification_for_{field,property,method,event} = false:warning` — запрет `this.`;
|
||||
- правила именования `IDE1006`: приватные instance-поля `_camelCase`, `const`/`static readonly` — Pascal.
|
||||
|
||||
Также проверено и **не требует правок**: `#region` нет; trailing whitespace нет; все файлы заканчиваются
|
||||
переводом строки; кодировка UTF-8; настоящих public-полей нет (публичные члены — свойства); явные
|
||||
модификаторы доступа соблюдены.
|
||||
|
||||
### Проверка после правок
|
||||
|
||||
- `dotnet build` — `Deal.sln`, `Deal.Telegram.sln`, `Deal.Ai.sln`, `Deal.Ml.sln`: 0 ошибок / 0 предупреждений.
|
||||
- Тесты: core **1275/1275**, telegram **125/125**, ai **52/52**, ml **38/38** — все пройдены.
|
||||
- Повторный прогон renamer: `this.` — 0, полей к переименованию — 0 (идемпотентно).
|
||||
|
||||
## 2. Остатки — решения (закрыто 2026-09-11, вечер)
|
||||
|
||||
1. **`var` — закрыто.** В `.editorconfig` включён ломающий сборку гейт `csharp_style_var_for_built_in_types = false:warning`
|
||||
(запрет только для встроенных типов — как в §4); режимы «очевидный тип» и «прочие» оставлены `silent`
|
||||
осознанно: правка субъективна и потребовала бы ~1600 механических замен. Остаток встроенных типов
|
||||
выправлен `dotnet format style --diagnostics IDE0008` по всем 5 решениям (51 файл); сборка 5 sln — 0/0.
|
||||
2. **Явная реализация интерфейсов (§11) — выполнено (вечер, решение владельца, вариант A).** 161 член
|
||||
в 30 прод-файлах конвертирован codemod'ом `scripts/make_explicit.py` (частичные классы и многострочные
|
||||
сигнатуры учтены); потребители конкретных типов перетипизированы на интерфейсы (8 мест в проде,
|
||||
17 тест-файлов); `Card`/семейство `I*Card` оставлены implicit — это DTO, их члены и есть публичный API.
|
||||
Правило закреплено в §11 код-стайла: классы напрямую не вызываем (DTO/хелперы/экстеншены — исключения).
|
||||
3. **Дедупликация `<summary>` через `<inheritdoc/>` — закрыто: дублей нет.** Проверено двумя независимыми
|
||||
сканами (сопоставление по тексту и по имени члена интерфейса: 39 интерфейсов, 229 задокументированных
|
||||
членов) — реализаций, дублирующих summary интерфейсного члена, в продакшн-коде нет; случаев
|
||||
«`<param>` + дублирующий summary» не существует.
|
||||
4. **Переводы строк — решено: LF.** Обоснование: инструменты проекта (Python/Node-скрипты, codemod'ы) пишут LF;
|
||||
shell-скрипты с CRLF не работают на Linux CI (`sh scripts/ci.sh` в GitHub Actions); фактическое большинство
|
||||
файлов уже было LF. Применено: `.gitattributes` (`* text=auto eol=lf` + бинарные исключения),
|
||||
`.editorconfig` → `end_of_line = lf`, конвертировано 1029 трекаемых файлов, `git add --renormalize`.
|
||||
Побочный эффект: починены 42 CRLF-.sh (9 в `scripts/` — до этого первый удалённый прогон CI падал бы).
|
||||
|
||||
### Попутно исправлено (2026-09-11, вечер)
|
||||
|
||||
- Повторный прогон `scripts/fix_private_docs.py --apply`: понижено 12 XML-доков на private/internal (extension-файлы).
|
||||
- Добавлены недостающие `<summary>`: `IContainerRules.Keywords`/`Stack`, `ITenantContext.TenantId`/`HasTenant`.
|
||||
- Переведены на русский англоязычные `//`-комментарии (3 шт. из 18 найденных; остальные — имена
|
||||
сущностей/заголовки секций тестов, не англоязычный текст).
|
||||
- Из индекса убраны случайно закоммиченные `archive/**/__pycache__/*.pyc` (2 шт., уже в `.gitignore`).
|
||||
- STATUS.md: удалён устаревший блок «Осталось (в backlog)» в шапке (пункты закрыты generic-контрактом источника).
|
||||
- Дочистка по контрольному скану краткости: удалены 73 очевидных `<param name="ct|cancellationToken">`
|
||||
(«Токен отмены.» — пересказ сигнатуры, §5) в 17 файлах; ужаты 3 summary (2 многосентенционных, 1 длинное).
|
||||
Контроль: `<remarks>` — 0, inline-`<summary>` — 0, многосентенционных summary — 0, TODO — 0.
|
||||
@@ -0,0 +1,257 @@
|
||||
# Дейл (Deal) — Техническое задание на новую архитектуру
|
||||
|
||||
> Версия: 1.0 (отражает этапы 0–12)
|
||||
> Дата: 2026-09-10
|
||||
> Связанные документы: `docs/architecture/2026-09-05-deal-architecture-design.md`,
|
||||
> `docs/architecture/2026-09-10-unified-api-contract.md`,
|
||||
> `docs/architecture/2026-09-10-operator-analytics-contract.md`,
|
||||
> исходное ТЗ прототипа LeadRadar V1.2 — `archive/leadradar-legacy/ТЗ-LeadRadar-v1.2.md`.
|
||||
|
||||
---
|
||||
|
||||
## 1. О продукте
|
||||
|
||||
«Дейл» — SaaS-сервис мониторинга Telegram-каналов и групп. Клиент подключает свой
|
||||
Telegram-аккаунт, выбирает каналы/группы для мониторинга, а система:
|
||||
|
||||
1. получает сообщения из источников в реальном времени;
|
||||
2. отсеивает мусор: рекламу, скам, служебные сообщения, дубликаты, устаревшее;
|
||||
3. структурирует оставшееся в **карточки** (заказ/вакансия/услуга) по профилю клиента
|
||||
(сфера, стек, бюджет, локация);
|
||||
4. раскладывает карточки по **колонкам-фильтрам** клиента;
|
||||
5. обучается на действиях клиента (ML) и всё больше обрабатывает поток сама;
|
||||
6. помогает искать и подключать новые источники (Discovery).
|
||||
|
||||
**Целевая аудитория:** специалисты и мастера в разных сферах (разработчики, дизайнеры,
|
||||
риелторы, строители и т.д.), которые ищут реальные заказы и клиентов в Telegram.
|
||||
|
||||
**Ключевая ценность:** видеть реальные заказы и клиентов, а не кучу дубликатов и рекламы.
|
||||
|
||||
---
|
||||
|
||||
## 2. Термины
|
||||
|
||||
- **Тенант** — клиент SaaS. Владеет схемой БД, настройками обработки, ML-моделью.
|
||||
- **Аккаунт (Telegram)** — личный Telegram-аккаунт тенанта, подключённый к системе.
|
||||
- **Источник** — откуда система получает записи. Сейчас это Telegram-канал/группа/чат (тема форума);
|
||||
контракт источника универсален, поэтому позже сюда добавляются другие сервисы (WhatsApp, сайты,
|
||||
файлы/таблицы) без изменения ядра.
|
||||
- **Сырое сообщение** — оригинальное сообщение из источника до обработки (входные данные пайплайна).
|
||||
- **Карточка** — единая сущность системы: ядро (id, заголовок, источник) + опциональные модули
|
||||
(содержимое, бюджет, контакты, атрибуты, комментарии, ссылки, файлы, ТЗ, история, напоминания,
|
||||
размещение в контейнере). Создаётся из прошедшего фильтры сообщения либо вручную; переезжает между
|
||||
дашбордами/контейнерами без смены сущности. Термин «лид» не используется — это лишь входное сообщение.
|
||||
- **Источник (Source)** — откуда пришла карточка: локально/вручную, ссылка на сайт, файл, Telegram
|
||||
(канал/группа/чат, тема форума), колонка импортированных данных, внешний API, ИИ (провайдер+модель),
|
||||
составной «первоисточник + цепочка обработки».
|
||||
- **Контейнер** — общая база колонок/стадий/зон: пользовательские колонки дашборда (набор фильтров),
|
||||
стадии «Выбранных», «Неразобранное», архив, корзина, терминальные зоны. У каждого контейнера —
|
||||
политика (что можно/нельзя, автоочистка, терминальность).
|
||||
- **Отсев** — сообщения, отклонённые пайплайном (с причиной).
|
||||
|
||||
---
|
||||
|
||||
## 3. Роли и доступ
|
||||
|
||||
| Роль | Возможности |
|
||||
|---|---|
|
||||
| **Оператор (владелец SaaS)** | Создаёт тенантов и инвайты; управляет лимитами; видит health; impersonation с аудитом |
|
||||
| **Тенант (клиент)** | Входит по инвайту, задаёт пароль; подключает свой Telegram-аккаунт; настраивает обработку; работает с дашбордом |
|
||||
|
||||
- Регистрация — **только по инвайту** (ссылка/код от оператора).
|
||||
- Логин: email + пароль; email уникален в масштабе SaaS; `tenantId` — в сессии/JWT.
|
||||
- Вход оператора — отдельный, изолированный от тенантов.
|
||||
|
||||
---
|
||||
|
||||
## 4. Подключение Telegram-аккаунта
|
||||
|
||||
1. Оператор один раз задаёт ключи приложения Telegram (`api_id`/`api_hash`) — глобально.
|
||||
2. Тенант в UI: «Добавить аккаунт» → QR-код (или телефон + код подтверждения).
|
||||
3. Система сохраняет сессию аккаунта (в telegram-service) и показывает статус подключения.
|
||||
4. **1 аккаунт на тенанта** на старте (схема допускает расширение).
|
||||
5. При первом подключении система подтягивает список диалогов аккаунта (каналы/группы/чаты)
|
||||
и обновляет его при каждом входе на экран каналов и в фоне (появление/исчезновение
|
||||
источников отслеживается автоматически).
|
||||
|
||||
### Мониторинг источников
|
||||
- Тенант включает/выключает мониторинг по каждому источнику из списка его диалогов.
|
||||
- Настройка «новый чат → мониторинг автоматически» (вкл/выкл).
|
||||
- Источники, удалённые/покинутые вне системы, исчезают из списка.
|
||||
- Кнопка «Перечитать»: догон последних ~10 сообщений всех включённых источников
|
||||
(с паузами, анти-бан).
|
||||
- Полученные сообщения **сразу помечаются прочитанными** в Telegram.
|
||||
|
||||
### Discovery (поиск и подключение источников)
|
||||
- Тенант создаёт **задачу поиска**: описание цели → ИИ генерирует ключевые слова.
|
||||
- Система ищет каналы/группы/форумы, в которых аккаунт **не состоит** (глобальное правило).
|
||||
- Каскад фильтров: участники → язык → содержание (по темам, порог ≥40%).
|
||||
- Кандидаты показываются «на рассмотрение» с метаданными (тип, участники, fit «X из N»,
|
||||
темы форума, метки: закрытая группа и т.п.).
|
||||
- Действия: «Вступить и мониторить» (вручную) или авто-вступление с квотами
|
||||
(50/сутки общий, паузы 50–70 с), «Отклонить» → чёрный список.
|
||||
- Чёрный список исключает источник во всех задачах; снимается вручную.
|
||||
|
||||
---
|
||||
|
||||
## 5. Обработка входящих (пайплайн)
|
||||
|
||||
Путь сообщения: **источник → очередь → стоп-лист → дедуп → ML → ИИ → карточка**.
|
||||
Всё, что отсеяно, — в «Отсеве» с причиной. Настройки обработки — **per-tenant**.
|
||||
|
||||
### Этап 1 (без ИИ, дёшево)
|
||||
1. Минимальная длина текста.
|
||||
2. **Стоп-фразы** (настраиваемый список).
|
||||
3. Отсев резюме соискателей (настройка).
|
||||
4. Тип заявки (только вакансии / только заказы) по контексту.
|
||||
5. **Дедуп**: одинаковый текст (нормализованный хэш) уже в системе → отсев «повтор».
|
||||
6. Устаревшее сообщение (старше срока архивации) → отсев.
|
||||
|
||||
### ML-слой
|
||||
- Если ML-модель тенанта уверена — решает сама: спам → отсев; колонка → карточка сразу.
|
||||
- Не уверена → сообщение уходит на ИИ.
|
||||
- Возврат из отсева (force) идёт мимо ML к ИИ-классификации.
|
||||
|
||||
### ИИ-слой (если включён)
|
||||
- ИИ-фильтр: сообщение не про заявки/интересы тенанта → отсев.
|
||||
- Классификация: структурированный разбор (компания, формат, о задаче, требования,
|
||||
плюсы, условия, бюджет, стек, контакты, тип заявки).
|
||||
- Назначение колонки с проверкой её правил.
|
||||
|
||||
### Глобальные фильтры
|
||||
- «Не создавать карточку без суммы» — отдельно для вакансий и для заказов.
|
||||
- Исключения по ключевым словам/технологиям/бюджету/локации (стоп на уровне фильтров).
|
||||
|
||||
### Карточка
|
||||
- Единая сущность: ядро (id, заголовок, источник) + опциональные модули. Вид карточки — композиция
|
||||
модулей, не отдельный класс/таблица; третий дашборд работает с той же карточкой.
|
||||
- Реализация (этап 9): карточка — **одна строка одной таблицы `Cards`** во всех дашбордах; таблица
|
||||
`ProjectCards` упразднена. Модули — колонки той же строки (`StackJson`/`LinksJson`/`FilesJson`/
|
||||
`HistoryJson`/`TzText`/напоминание), комментарии — общая таблица `LeadComments`. Колонки/стадии/зоны —
|
||||
единый реестр контейнеров; пространства не пересекаются (карточка не может быть одновременно
|
||||
в дашборде и в «Выбранных»), «взять в работу» — смена контейнера, а не клон.
|
||||
- Модули: содержимое (единая структура «О заявке»: Компания → Формат → О задаче → Требования →
|
||||
Будет плюсом → Условия), бюджет (from/to/валюта), контакты (квалифицированные: tg/phone/email/
|
||||
linkedin/site), атрибуты (стек/грейд/локация/сроки — настраиваются тенантом в UI, не зашиты),
|
||||
комментарии, ссылки, файлы, ТЗ, история движения, напоминание, размещение в контейнере.
|
||||
- Исходное сообщение карточки хранится и доступно: текст структурируется и показывается в карточке,
|
||||
вложения/ссылки/контакты — отдельными блоками; кнопка «Обновить из источника» догружает оригинал
|
||||
у сервиса-владельца источника (для Telegram — по id сообщения), если он доступен.
|
||||
|
||||
---
|
||||
|
||||
## 6. Дашборд (канбан)
|
||||
|
||||
- Колонки: «Неразобранное», пользовательские колонки (набор фильтров), «Архив», «Корзина».
|
||||
- Пользовательские колонки создаёт пользователь; ИИ может **предлагать** колонки с
|
||||
обоснованием (по каким критериям), пользователь принимает/отклоняет/переименовывает.
|
||||
- Колонка = сложный набор опциональных фильтров: ключевые слова/стек/грейд/уровень/цена/
|
||||
бюджет/локация/тип + отрицательные фильтры («чтобы не попадало»).
|
||||
- При помещении карточки в колонку указывается, **по каким критериям** она попала.
|
||||
- Карточки в колонке: свежие сверху. Drag&drop между колонками (с обучением ML).
|
||||
- Быстрые действия на карточке: комментарий, корзина, контакт, «открыть исходник».
|
||||
- Виджеты-счётчики свёрнутых колонок; колонки можно двигать, менять размер/ширину.
|
||||
- **Архив**: карточки старше N дней (настройка 1–30); очистка архива через 90 дней.
|
||||
- **Корзина**: очистка раз в 7 дней; из архива/корзины карточку можно вернуть (на канбан).
|
||||
|
||||
### «Выбранные» (пространство стадий)
|
||||
- То же пространство карточек: **те же карточки** в контейнерах-стадиях
|
||||
(Запланировано → Отклик → Согласование → В работе → Проверка → Готово / Отложено и др.).
|
||||
«Взять в работу» — переход карточки в контейнер, а не создание второй сущности.
|
||||
- У карточки наполняются модули работы: комментарии, изменение суммы/стека/контактов,
|
||||
прикрепление ссылок, ТЗ, **файлов** (медиа/документы; тип определяется автоматически;
|
||||
хранение в S3/MinIO; на карточке значки количества файлов и ссылок).
|
||||
- Отложенные: напоминания (через срок + в заданное время, выбор даты в календаре);
|
||||
настройка в общих настройках; если напоминания выключены — окно не показывается и
|
||||
установленные не срабатывают.
|
||||
- История движения карточки (статус, дата, время) — под спойлером в карточке.
|
||||
- Ручное создание карточки с тем же набором полей (пометка «создано локально»).
|
||||
- В архив/корзину дашборда карточки «Выбранных» не попадают; свои терминальные зоны:
|
||||
«Отклонено», «Выполнено» (политики контейнеров).
|
||||
|
||||
---
|
||||
|
||||
## 7. Вкладка «Обработка»
|
||||
|
||||
- **Очередь**: сырые сообщения, ожидающие обработки (этап 1 / ожидают ИИ), с автопрокруткой.
|
||||
- **Отсев**: отклонённые сообщения с причиной и источником решения
|
||||
(правила / ML / ИИ / система), включая конкретное стоп-слово/фразу.
|
||||
- У записи: метаданные (источник, подпись, вид, время), «показать исходное сообщение» с форматированием,
|
||||
кнопка обновления исходника у сервиса-владельца источника.
|
||||
- Поиск по отсеву — полнотекстовый.
|
||||
- Возврат из отсева в обработку: причины отсева игнорируются, ML/ИИ обучаются на решении;
|
||||
можно указать причину возврата.
|
||||
- Автоочистка отсева: раз в 3 дня; ручная очистка.
|
||||
- Вкладка показывает счётчик обработки (в боковой панели отсев не показывается).
|
||||
|
||||
---
|
||||
|
||||
## 8. Настройки тенанта
|
||||
|
||||
- Telegram: ключи приложения (оператор), подключение аккаунта, авто-мониторинг новых.
|
||||
- ИИ: провайдер (один; включая локальные), модель, ключ (хранится зашифрованно),
|
||||
промпты (базовый + свой), библиотека готовых промптов по сферам + «мои промпты»,
|
||||
вкл/выкл ИИ, вкл/выкл ИИ-фильтр.
|
||||
- ML: вкл/выкл, обучение на действиях, проверка на сообщении/канале, сброс, самооценка
|
||||
(«ML справляется с последними N сообщениями — ИИ можно отключить»).
|
||||
- Обработка: стоп-фразы, длина, резюме, тип заявки, домен/ключи, маркеры найма/заказа.
|
||||
- Проверка текста: сухой прогон по цепочке (стоп-правила → глобальные исключения → ML → ИИ →
|
||||
«без суммы») без создания карточки — показывает этапы, причину отсева и куда попала бы карточка.
|
||||
- Колонки: набор, правила, отрицательные фильтры, исключения.
|
||||
- Валюта: целевая валюта отображения, источник курсов (4 запроса/сутки), конвертация
|
||||
при приходе данных + пересчёт старых карточек (кроме архива/корзины); USDT = USD.
|
||||
- Хранение: срок архивации (1–30 дней), очистка архива/корзины.
|
||||
- Уведомления и напоминания (общие; отложенные — отдельно).
|
||||
- Звук, внешний вид.
|
||||
|
||||
---
|
||||
|
||||
## 9. Лимиты (бюджет токенов)
|
||||
|
||||
- Каждый тенант имеет **бюджет токенов** на LLM-вызовы (период — настраивается).
|
||||
- ai-service оценивает каждый вызов в токенах и списывает с бюджета.
|
||||
- При исчерпании: AI-обработка переключается на fallback (ML/локальный разбор),
|
||||
тенант получает уведомление; приём и базовая обработка сообщений не блокируются.
|
||||
- Оператор видит расход по тенантам в админке и может менять бюджет.
|
||||
|
||||
---
|
||||
|
||||
## 10. Админка оператора
|
||||
|
||||
- Тенанты: создание, инвайты, статус, лимиты/бюджеты, приостановка.
|
||||
- Health всех сервисов и очередей.
|
||||
- Аудит: входы/выходы, инвайты, impersonation, действия оператора и пользователей тенанта
|
||||
(создание/перенос/удаление карточек, комментарии, контейнеры, настройки, каналы).
|
||||
- Аналитика: расход токенов (по дню/тенанту/провайдеру/модели) и лента действий с фильтрами.
|
||||
- Подозрительная активность (по логам безопасности) и метрики сервисов (Prometheus/Grafana).
|
||||
- UI: оператор-консоль (`#/operator`) и страница активации инвайта (`#/join`).
|
||||
|
||||
---
|
||||
|
||||
## 11. Нефункциональные требования
|
||||
|
||||
- **Безопасность**: TLS, mTLS между сервисами, параметризованный SQL, защита от
|
||||
IDOR/XSS/SSRF/CSRF, Argon2id, rate limiting (прокси + приложение; счётчики — распределённые,
|
||||
в БД, работают при нескольких инстансах), Cloudflare.
|
||||
- **Надёжность**: ежедневные бэкапы (Postgres, файлы, сессии), outbox для событий;
|
||||
авто-очистки (retention аудита, лимитов, окон rate-limit); мгновенный разлогин suspended-сессий.
|
||||
- **Наблюдаемость**: структурированные логи → Loki, метрики (OpenTelemetry → Prometheus) → Grafana
|
||||
+ правила алертов; история расхода токенов (`token_usage_events`).
|
||||
- **Масштабируемость**: модульный монолит + отдельные сервисы (ml/ai/telegram);
|
||||
горизонтальное масштабирование сервисов; k8s — позже.
|
||||
- **Производительность**: пайплайн обрабатывает поток без потерь; анти-бан-паузы
|
||||
Telegram не блокируют обработку.
|
||||
- **Локализация (i18n)**: весь интерфейс — на русском; все пользовательские строки вынесены в ресурсы
|
||||
(без хардкода в компонентах), включая тексты ошибок; фолбэк — русский. Переключатель языка и второй
|
||||
язык — **в бэклоге**: делаем, когда возникнет потребность (основа в ресурсах уже готова).
|
||||
Область — основное приложение и оператор-консоль. (Этап 11 roadmap.)
|
||||
|
||||
---
|
||||
|
||||
## 12. Ограничения и допущения
|
||||
|
||||
- Фронтенд (Vue 3 + Vite + Tailwind) переезжает из LeadRadar; с этапа 9 контракт карточек/колонок — единый
|
||||
(`/api/cards` + `/api/containers`, см. `docs/architecture/2026-09-10-unified-api-contract.md`).
|
||||
- Данные текущего LeadRadar тестовые — не мигрируются.
|
||||
- Kafka, k8s, биллинг-провайдер, саморегистрация — вне рамок текущего этапа.
|
||||
- 1 Telegram-аккаунт на тенанта; несколько аккаунтов — позже (схема готова).
|
||||
@@ -0,0 +1,241 @@
|
||||
# Дейл (Deal) — Статус разработки и прогресс
|
||||
|
||||
> Обновляется в конце каждого захода. Проект в git (ветка `main`, коммиты на русском) — борд состояния + git-история.
|
||||
> Дата последнего обновления: 2026-09-11.
|
||||
>
|
||||
> **2026-09-11 — единый контракт источника (generic source).** Ядро (домен Cards, Storage-сервис,
|
||||
> персистентность, конвейер, wire, фронт) переведено с Telegram-полей карточки на generic-тип
|
||||
> `SourceItem` (`SourceRef` + `SourceContent`, вложения — `DataRef` → общий Storage). Дашборды/канбан/конвейер
|
||||
> больше не знают о Telegram; Telegram-специфика — только в тонком адаптере приёма. Tenant-миграции
|
||||
> пересозданы с нуля (init). Добавлены extension-point `ISourceContentProvider`/`SourceContentResolver` и
|
||||
> `GET /api/cards/{id}/source`. Входящий поток источников — generic (`sources.proto`/`PushSource`,
|
||||
> `SourceIngressGrpcService`), `PushMessage` из telegram.proto удалён. Сухой прогон текста по конвейеру
|
||||
> (стоп-правила → ML → ИИ) без записи: `POST /api/admin/check-message` + UI настроек. Remote-просмотр
|
||||
> исходника: `TelegramService.ReadSource` + `TelegramSourceContentProvider` + UI «Обновить из источника».
|
||||
> Метрика алертинга `deal.ai.budget.used.ratio{tenant}`; actor/tenant в access-логе; прогресс переклассификации
|
||||
> через SSE. Hardening контейнеров (non-root/read-only/limits), шардированная пакетная миграция схем,
|
||||
> единый CI (`scripts/ci.sh`). Ядро: build 5 sln 0/0, `Deal.Tests.Unit` **1326/1326 PASS**,
|
||||
> telegram **130/130**, ai **52/52**, ml **38/38**, storage **9/9**, фронт `build` + `lint:i18n` зелёные.
|
||||
> Детали — `docs/superpowers/specs/2026-09-11-source-contract-design.md`.
|
||||
>
|
||||
> **2026-09-11 (вечер) — закрыты остатки код-стайла (TD-COMMENTS-IFACE, TD-STYLE-ANALYZERS).** Дедупликация
|
||||
> `<summary>`: дублей нет (сканы по тексту и по имени члена — 39 интерфейсов/229 членов). `var`: гейт
|
||||
> `csharp_style_var_for_built_in_types = false:warning` (ломает сборку), остаток выправлен `dotnet format`
|
||||
> по 5 sln (51 файл), «очевидный/прочий тип» — silent осознанно. Переводы строк: решено LF — `.gitattributes`
|
||||
> (`* text=auto eol=lf`), `.editorconfig` → lf, нормализовано 1029 файлов; попутно починены 42 CRLF-.sh
|
||||
> (первый прогон удалённого CI падал бы). Понижено 12 новых private XML-доков; добавлены 4 `<summary>`
|
||||
> членам интерфейсов; переведены 3 англоязычных комментария; из индекса убраны 2 `__pycache__/*.pyc`;
|
||||
> STATUS.md — удалён устаревший блок «Осталось (в backlog)» в шапке. Дочистка по контрольному скану
|
||||
> краткости: удалены 73 очевидных `<param name="ct">` (пересказ сигнатуры) в 17 файлах, ужаты 3 summary
|
||||
> (многосентенционные/длинные); контроль: `<remarks>` 0, inline-`<summary>` 0, многосентенционных 0, TODO 0. Явные реализации интерфейсов (§11) —
|
||||
> остались точечным ревью владельца (43 интерфейса с реализациями, массовая правка не автоматизируется).
|
||||
> Сборка 5 sln 0/0; тесты: core **1340/1340**, telegram **130/130**, ai **52/52**, ml **38/38**, storage **9/9** — зелёные.
|
||||
> Детали — `docs/spec/Код-стайл-аудит-2026-09-11.md`, §2.
|
||||
>
|
||||
> **2026-09-11 (ночь) — Gitea-контур.** Репозиторий запушен (`gitea.khomegeneric.keenetic.pro/rust/Deal`),
|
||||
> ~4000 спам-пользователей вычищено, регистрация закрыта владельцем. CI перенесён в
|
||||
> `.gitea/workflows/ci.yml`; первый прогон поймал и закрыл кроссплатформенный баг
|
||||
> (`ModelPool` валидировал tenant-id через `GetInvalidFileNameChars` — на Linux пропускал `\`);
|
||||
> валидация заменена на явный белый список. **`scripts/` исключён из репозитория** (правило владельца:
|
||||
> в репе только код) — скрипты сборки/тестов/бэкапов/кодмоды живут только локально; CI-workflow
|
||||
> переписан inline. Раннер для CI — на сервере рядом с Gitea (пакет `deploy/gitea-runner/`).
|
||||
>
|
||||
> **2026-09-11 (поздний вечер) — явные реализации интерфейсов (вариант A, решение владельца).** Правило
|
||||
> владельца: классы напрямую не вызываем (исключения — DTO, хелперы, экстеншены), тесты — через
|
||||
> интерфейсы, моки — NSubstitute, маркерные классы не используем. Сделано: 161 член в 30 прод-файлах
|
||||
> переведён на явные реализации codemod'ом `scripts/make_explicit.py`; потребители конкретных типов
|
||||
> перетипизированы на интерфейсы (8 мест в проде, 17 тест-файлов; самовызовы — `((ISessionClient)this)`);
|
||||
> 10 маркерных классов заменены маркерными интерфейсами (`IKanbanModule`…`ISharedKernel`); NSubstitute 6.1.0
|
||||
> подключён к 5 тест-проектам, эталон миграции — `FakePasswordHasher` → `TestHashers.New()` (фейк удалён);
|
||||
> правила зафиксированы в §11 код-стайла. Card/`I*Card` — implicit (DTO). Оставшиеся 30 фейков —
|
||||
> поэтапная миграция (`backlog.md`, TD-TESTS-NSUBSTITUTE). Build 5 sln 0/0, тесты зелёные.
|
||||
|
||||
**Все этапы 0–12 выполнены (100%)** — см. roadmap
|
||||
> `docs/superpowers/plans/2026-09-05-deal-roadmap.md`, план этапа 10
|
||||
> `docs/superpowers/plans/2026-09-10-deal-stage10-operator-analytics.md` и ledgers в `.superpowers/sdd/`.
|
||||
> Live-приёмки на Docker Desktop выполнены: dev-smoke 14/14, SaaS-контур 15/15, prod-контур+mTLS+
|
||||
> observability PASS, backup/restore на копии PASS, runtime-приёмка этапа 10 (оператор-консоль,
|
||||
> аналитика, аудит) PASS (см. чек-лист ниже). Осталось Manual: реальные Telegram/LLM-креды (п.5).
|
||||
|
||||
## Общий прогресс по этапам
|
||||
|
||||
| Этап | Статус | Задач | Тесты (unit, накопительно) | Приёмка |
|
||||
|---|---|---|---|---|
|
||||
| 0. Каркас | ✅ готов | 9/9 | 6 | health 200 |
|
||||
| 1. Доступ и мультитенантность | ✅ готов | 6/6 | 25 | auth 1:1 |
|
||||
| 2. Settings (настройки) | ✅ готов | 11/11 | 175 | 60/60 |
|
||||
| 3. Kanban (дашборд) | ✅ готов | 15/15 | 410 | 94/94 |
|
||||
| 4. Pipeline/«Обработка» | ✅ готов | 13/13 | 535 | 74/74 |
|
||||
| 5. Projects («Выбранные») | ✅ готов | 13/13 | 620 | 75/75 |
|
||||
| 6. Сервисы telegram/ml/ai + Discovery | ✅ готов | 20/20 | 830 | 20/20 + 37/37 |
|
||||
| 7. SaaS-контур (оператор/инвайты/лимиты/аудит/безопасность/prod-деплой/бэкапы/доки) | ✅ готов | 16/16 | 1123 | ✅ live SaaS 15/15 (остальное — ⚠ Manual) |
|
||||
| 8. Code-quality rework (ревью 5 зон) | ✅ готов | 5/5 фаз | 1139 | build 4 sln 0/0; фронт build OK |
|
||||
| 9. Единая карточка (слияние Kanban/Projects, `/api/cards`+`/api/containers`) | ✅ готов | 11/11 | 1138 | ✅ live dev-smoke PASS=14 FAIL=0 |
|
||||
| 10. Оператор-консоль, аналитика, аудит действий, Grafana/Loki-дашборды | ✅ готов | 7/7 | 1173 | ✅ live runtime (Docker dev) |
|
||||
| 11. Локализация UI (вынос строк в ресурсы) | ✅ готов | 7/7 | 1173 | build + `lint:i18n` зелёные |
|
||||
| 12. Наблюдаемость/устойчивость/перф + добивка ТЗ | ✅ готов | 4/4 пакетов + добивка | 1275 | build 4 sln 0/0; telegram 125/125 |
|
||||
| **Итого** | **этапы 0–12 = 100%** | **137/137** | **1275 (core)** + 125/52/38 (сервисы) | — |
|
||||
|
||||
Финальный прогон этапа 12 (2026-09-10, автономный заход A–D): build `Deal.sln` 0/0; core **1275/1275 PASS**;
|
||||
telegram **125/125**; фронт `npm run build` зелёный (main-чанк 309 kB, словарь в отдельном чанке i18n),
|
||||
`npm run lint:i18n` зелёный; метрики: `/metrics` (OTel→Prometheus) во всех 4 процессах, Prometheus targets 5/5 UP;
|
||||
пакеты B (rate-limit/LoginAttemptGuard на Postgres, разлогин suspended, purge) и D (reclassify + токены ML)
|
||||
с зелёными тестами. Всё остановлено (правило «без хвостов»). LEDGER: `.superpowers/sdd/deal-stage12-observability-hardening/`.
|
||||
|
||||
Финальный прогон этапа 10 (2026-09-10): build `Deal.sln` 0/0; core **1173/1173 PASS**; фронт
|
||||
`npm run build` зелёный; `GET /api/operator/analytics/{overview,tokens,activity}` и
|
||||
`GET /api/operator/audit` живьём на dev-Postgres (`:5433`, миграция `AddTokenUsageEvents` применена),
|
||||
`deal-core` healthy; страницы `#/operator` и `#/join` отдаются dev-сервером. Детали — ledger
|
||||
`.superpowers/sdd/deal-stage10-operator-analytics/progress.md`.
|
||||
|
||||
Финальный прогон (Task 16, 2026-09-08, docker выключен): build 0 warnings / 0 errors всех четырёх sln
|
||||
(core/telegram/ai/ml); core 1123/1123 PASS, telegram 114/114, ai 50/50, ml 36/36 PASS;
|
||||
`docker compose -f deploy/compose.prod.yml config` rc=0 (+ профиль observability);
|
||||
`sh -n` scripts/dev-smoke.sh/backup.sh/restore.sh/mtls-certs.sh rc=0.
|
||||
> Накопительный счётчик core в таблице — по состоянию на конец этапа: этап 8 — 1139, этап 9 — 1138
|
||||
> (часть тестов удалена вместе с доменом Projects), этап 10 — 1173.
|
||||
|
||||
## Что система умеет СЕЙЧАС (проверяемо)
|
||||
|
||||
- **Ядро (этапы 0–6)**: вход admin/admin (dev-seed, dev-only), сессии, схемы на тенанта (Postgres :5433);
|
||||
дашборд (колонки/карточки/drag&drop/архив/корзина/FTS-поиск), «Обработка»
|
||||
(очередь→стоп-лист→дедуп→ML/ИИ→карточка), «Выбранные» (стадии/напоминания SSE/файлы Local/MinIO),
|
||||
Настройки (ключи AI enc:, промпты, валюты; Telegram-ключи — глобально у оператора), Каналы/Discovery; полный dev-стек этапа 6 —
|
||||
`deploy/compose.dev.yml` (`Services__*__UseLocal=false`, gRPC-режим telegram/ai/ml + ингресс core).
|
||||
- **SaaS-контур (этап 7)**: оператор (`public.operators/operator_sessions`, кука `deal_operator_session`,
|
||||
bootstrap env `DEAL_OPERATOR_*`; dev-дефолт operator/operator) и ручки `/api/operator/*`
|
||||
(auth/tenants/invites/limits/audit/health — с этапа 10 у них есть UI, см. ниже); инвайты (код 16 симв., 72 ч) и активация
|
||||
`POST /api/join` (пользователь + провижининг тенанта); лимиты ИИ-бюджета (`tenant_limits`,
|
||||
`TokenUsageRecorder`, гейт-декораторы → Local-фолбэк, SSE-тосты 80/100%); append-only аудит;
|
||||
rate limiting (auth 10/мин·IP, api 600/мин·тенант, gRPC-ингресс 600/мин·тенант, `LoginAttemptGuard`
|
||||
5/15 мин); Origin-проверка мутаций + security-заголовки + ForwardedHeaders за Caddy;
|
||||
mTLS за флагом `DEAL_MTLS_*` (`scripts/mtls-certs.sh` → `deploy/certs/`); Serilog JSON во всех
|
||||
4 процессах (+ access-логи HTTP/gRPC); prod-деплой `deploy/compose.prod.yml` (caddy 80/443,
|
||||
postgres/minio без host-портов, профиль observability: promtail/loki/grafana, `.env.prod.example`);
|
||||
бэкапы `scripts/backup.sh`/`restore.sh`/`deal-backup-lib.sh` (pg_dump -Fc + MinIO + tar; retention 14).
|
||||
- **Оператор-консоль и аналитика (этап 10)**: hash-роутер фронта (`#/` приложение, `#/operator` консоль,
|
||||
`#/join?code=…` активация инвайта); разделы консоли (вход, тенанты+suspend/resume/impersonate,
|
||||
инвайты, лимиты, аудит с фильтрами/пагинацией, аналитика, health); impersonation ставит httpOnly-куку
|
||||
`deal_session` тем же ответом (оператор сразу в тенанте). Сквозной аудит действий (`public.audit_log`):
|
||||
выходы, `invite_joined`, действия карточек/комментариев, CRUD контейнеров, настройки, каналы, Telegram.
|
||||
История расхода токенов `public.token_usage_events` + операторская аналитика
|
||||
(`/api/operator/analytics/{overview,tokens,activity}`, `groupBy=day|tenant|provider|model`).
|
||||
Grafana provisioning (datasource Loki + дашборды `Deal-Auth/Errors/Rps/Logs`) и promtail-лейблы.
|
||||
- **Что увидеть глазами**: полный dev-стек — `docker compose -f deploy/compose.dev.yml up -d --build`
|
||||
→ фронт `cd src/frontend && npm run dev` → логин `admin/admin` (dev-seed);
|
||||
либо сквозной smoke одной командой: `sh scripts/dev-smoke.sh`. Prod-контур — §13.8 техдока
|
||||
(оператор → тенант → инвайт → `/api/join`). Host-режим (Local-заглушки): postgres/minio +
|
||||
`dotnet run --project src/core/Deal.Api --urls http://localhost:5080`.
|
||||
- Полная карта «что/где» — техдок `docs/technical/Техническая-документация-Дейл.md` (§5, §7–§11,
|
||||
§13.1–§13.10), api-map `docs/api/api-map.md` (раздел «Реализовано в Deal»), инструкция пользователя
|
||||
`docs/user-guide/Инструкция-пользователя-Дейл.md`.
|
||||
|
||||
## Manual-чек-лист (остаток после live-приёмок)
|
||||
|
||||
Выполнено живьём на Docker (автономно от авто-прогонов Task 16, build/test/config/syntax — там же):
|
||||
|
||||
1. ✅ **SaaS-сквозная приёмка (live, 15/15 PASS)** — `run-live-saas.sh` + `live-saas-check.sh` на поднятом
|
||||
dev-Postgres (:5433, миграции SystemSaaS + SessionsImpersonationMark применены): оператор login →
|
||||
создать тенанта → инвайт → `POST /api/join` → вход пользователя → settings/cards (на тот момент —
|
||||
`boards`/демо-карточка) →
|
||||
IDOR-негатив 401 (пользователь к операторским ручкам) → suspend (вход 403) → resume (вход 200) →
|
||||
лимиты (tenant_limits) → аудит-лента. Core погашен, :5080 свободен.
|
||||
2. ✅ **dev-smoke 14/14 PASS** — полный gRPC-стек (`scripts/dev-smoke.sh`): подъём, health, login
|
||||
admin/admin, `/api/tg/status` idle, `POST /api/cards` (карточка `planned`) → trash → обучающий сигнал spam,
|
||||
ML-флашер выгрузил outbox. Стек погашен скриптом (trap).
|
||||
3. ✅ **Prod-контур + mTLS + observability (live)** — сертификаты перегенерированы (`scripts/mtls-certs.sh -f`,)
|
||||
полный набор в deploy/certs; подъём `compose.prod.yml` + `--profile observability` с фиктивными
|
||||
env-секретами (`DEAL_MTLS_ENABLED=1`, endpoint'ы https://): core/telegram/ai/ml **healthy** под mTLS;
|
||||
исходящее mTLS подтверждено живьём — `/api/tg/status` (idle) и `/api/ml/status` (reachable:true) через
|
||||
Caddy; фронт и `/api/health` через Caddy 200; promtail→loki (логи пишутся), Grafana 200. Исправлен
|
||||
дефект `deploy/observability/loki.yml` (Loki 3.x: `delete_request_store`). `.env.prod` тестовый удалён.
|
||||
4. ✅ **backup/restore (live, на копии)** — `backup.sh`: pg (-Fc) + minio (docker-mc) + data (tar docker-томов)
|
||||
+ retention; `restore.sh pg` в копию-БД — 43 таблицы/3 схемы идентичны, данные сошлись (users=2,
|
||||
tenants=2, sessions=30); `restore.sh minio` с реальным объектом (залит→бэкап→удалён→восстановлен);
|
||||
`restore.sh data`. Исправлены дефекты скриптов, проявившиеся живьём: двойная схема в MC_HOST_deal
|
||||
(`deal-backup-lib.sh`), пустой бакет → mv (`backup.sh`), Windows/MSYS docker-пути (`host_docker_path`).
|
||||
5. ❌ Реальный Telegram-вход (api_id/api_hash/QR) и LLM-вызовы — **нужны живые креды**.
|
||||
6. ✅ Прогон `scripts/backup.sh` и restore-тест — см. п.4 (полный цикл на dev-хранилищах и копии-БД).
|
||||
7. ✅ **Этап 10 — runtime-приёмка (Docker dev-стек)** — миграция `AddTokenUsageEvents` применена к
|
||||
dev-Postgres (`:5433`), `deal-core` пересобран/healthy; операторский вход `operator`/`operator` → 200;
|
||||
`GET /api/operator/tenants`, `/audit`, `/analytics/overview`, `/analytics/tokens?groupBy=day|provider`,
|
||||
`/analytics/activity?limit=3` → 200 (реальные лента/агрегаты); фронт `npm run build` зелёный, dev-сервер
|
||||
отдаёт `#/operator` и `#/join`; core-тесты 1173/1173. Детали — ledger этапа 10.
|
||||
|
||||
## Заделы (этап 13+; подробно — техдок §11 и roadmap)
|
||||
|
||||
> **Единый источник отложенного и техдолга — `docs/superpowers/backlog.md`.** Ниже — краткая выжимка.
|
||||
|
||||
- **Этап 11 — Локализация интерфейса (i18n)** — **выполнен** (2026-09-10, урезанный объём): все
|
||||
пользовательские строки фронта в ресурсах (`src/frontend/src/i18n/`, 1039 ключей в 13 областях),
|
||||
линтер `npm run lint:i18n`. Переключатель языка и второй язык — **в бэклоге**: делаем, когда появится
|
||||
потребность (ядро i18n/`registerLocale` к этому готово).
|
||||
- **Этап 12 — Наблюдаемость/устойчивость/перф** — **выполнен** (2026-09-10, автономно, пакеты A–D):
|
||||
метрики Prometheus+Grafana (`/metrics` :9464 во всех процессах); распределённый rate-limit и
|
||||
`LoginAttemptGuard` на Postgres; мгновенный разлогин suspended-сессий; авто-purge `audit_log`/`tenant_limits`;
|
||||
разбиение бандла фронта + прогрессивный рендер колонок; LRU-кэши WTelegram; пакетная миграция схем тенантов;
|
||||
реальный `reclassify` с Local-фолбэком. LEDGER: `.superpowers/sdd/deal-stage12-observability-hardening/`.
|
||||
- **Остатки этапа 12 (закрыто 2026-09-10):** доки под этап 12 (real-reclassify, maintenance-migrate, без
|
||||
демо), Prometheus alert rules (`deploy/observability/prometheus-rules.yml` + провижининг), устранена гонка
|
||||
`FreeTcpPort()` в тест-харнессе (единый `TestPort`), SSE `cards_reclassified` (бэк+фронт), нагрузочные
|
||||
скрипты `scripts/loadtest/`, скан уязвимостей — **чисто** (core: 0 уязвимых пакетов; frontend `npm audit`: 0).
|
||||
- Прочее (требует владельца/кредов): биллинг/провайдер планов и саморегистрация; мультиаккаунтность Telegram;
|
||||
k8s/Cloudflare; Kafka; экспорт/импорт ML; переключатель языка/второй язык (в бэклоге — по потребности);
|
||||
реальный Telegram-вход и живые LLM-вызовы;
|
||||
legacy-прототип `docker-compose.yml` перенесён в `archive/leadradar-legacy/` (2026-09-10).
|
||||
- **Добивка по ТЗ (2026-09-10, автономно)** — закрыты найденные аудитом частично/незакрытые пункты:
|
||||
ML-проверка на канале/сообщении (`/api/ml/candidates`+`/apply` — реальные, не заглушки); глобальные
|
||||
исключения до ML/ИИ (§5.14); новые группы фильтров колонки `levels/locations/types/prices` (§6.3);
|
||||
«открыть исходник» как быстрое действие на карточке (§6.6); глубины очередей/сессии в `/api/operator/health` (§10.2);
|
||||
детектор подозрительной активности `/api/operator/analytics/suspicious` (§10.5); раздел настроек и
|
||||
поддержка тем «Внешний вид» — тёмная (дефолт) / светлая / системная (§8.12). Отчёт аудита:
|
||||
`docs/superpowers/reviews/2026-09-10-tz-compliance-audit.md`. Core-тесты **1275/1275**; фронт build+`lint:i18n` зелёные.
|
||||
- **Telegram-ключи — вариант A (2026-09-10, по решению владельца):** `api_id`/`api_hash` задаёт
|
||||
**оператор глобально** (таблица `public.global_settings`, ручки `GET/PUT /api/operator/settings/telegram-keys`,
|
||||
hash шифруется; раздел «Telegram» в оператор-консоли). У тенанта ключи убраны — только подключение
|
||||
аккаунта; без ключей подключение недоступно (`/api/tg/status → keysSet:false`). Миграция `GlobalSettings`
|
||||
**применена**; `PUT` поддерживает частичное обновление (можно сменить одно поле). Core-тесты **1275/1275**.
|
||||
|
||||
## Code-quality rework (2026-09-08, после ревью 5 зон ~1100 файлов)
|
||||
|
||||
Проведено многоосевое ревью (безопасность/корректность/архитектура/перф) бэкенда и фронта; findings —
|
||||
`docs/superpowers/reviews/2026-09-08-code-quality-review.md`. Все исправления закрыты и проверены:
|
||||
**core 1135/1135, telegram 118/118, ai 52/52, ml 38/38**, фронт build OK, 4 sln 0/0. Главное:
|
||||
|
||||
- **Безопасность**: SSRF-гейт (baseUrl каталоговых провайдеров фиксирован + private-IP-блок в проверке),
|
||||
fail-closed Production (rate limit/CORS/conn-string обязательны), код инвайта в аудите — SHA-256, пароль ≥8,
|
||||
маска ключа не перезаписывает ключ и короткие секреты скрыты, TenantId = 32-hex, mTLS fail-closed,
|
||||
gRPC-лимиты входных данных, join проверяет целевого тенанта, атомарный инкремент токенов (Npgsql).
|
||||
- **Корректность**: атомарные append комментариев/ссылок/файлов (1 SQL), уникальный objectKey, атомарный
|
||||
дедуп-pump, запрет move из trash/archive/taken, логирование «немых» catch, очистка сессий вне hot-path;
|
||||
фронт: смена пароля с текущим паролем, boot не падает от одного 500, автосейв не затирает промпты,
|
||||
гонки поиска закрыты seq-токенами.
|
||||
- **Архитектура**: `Deal.Grpc.Hosting` (общая обвязка 3 сервисов), `TenantSettingsSnapshot` (9 копий чтения
|
||||
настроек → одна), декомпозиция 7 крупных файлов на partial (<350 строк), фронт store.js → слайсы `store/`,
|
||||
вынесены компоненты Settings/Discovery; мёртвый код удалён.
|
||||
- **Реестры констант (C35, 2026-09-09)**: общие `Deal.Contracts.Integrations.MlLearningLabels`
|
||||
(spam/t:hire/t:order) и `SourceDefaults` (DefaultHue) вместо дублей в 5 модулях; единый предикат «активные
|
||||
правила» (Kanban `ColumnRules.HasActiveRules`); реестр id-стадий `ProjectStages` (с этапа 9 — `CardsDefaultContainers`); общий
|
||||
`CardsService.JustNowLabel`; TTL-эвикция в DiscoverySearchErrorCounter (+4 теста, core 1139).
|
||||
- **Заделы** (не рисковали без e2e/не успели): вынос оставшихся вкладок SettingsView, Optional-пункты
|
||||
(пагинация колонок, виртуализация, LRU-кэши WTelegram и др.). Подробности — в отчёте ревью и
|
||||
`.superpowers/sdd/deal-stage8-quality-rework/`.
|
||||
|
||||
## Процесс (обязательства, чтобы не жрать память/хосты)
|
||||
|
||||
- Acceptance-серверы — только через враппер с гарантированным kill (taskkill //T //F по PID-файлу) +
|
||||
проверка освобождения порта; в конце каждой приёмки — шаг очистки.
|
||||
- Контейнеры — по требованию (`docker compose ... up|down`); между заходами ничего не держать;
|
||||
`scripts/cleanup-dev.sh` (kill висящих Deal.*/тест-хостов, `dotnet build-server shutdown`,
|
||||
остановка deal-контейнеров) — в конце захода.
|
||||
- **Хвостов не оставлять (правило владельца, 2026-09-10):** по завершении работы все сервисы и процессы
|
||||
должны быть остановлены — включая Docker-контейнеры и dev-серверы (frontend/Vite, dotnet) — если
|
||||
владелец явно не попросил оставить их запущенными. Проверка в конце: `docker ps` без `deal-*`,
|
||||
свободные порты (5173/5080/5082/5101/5102/5103/5433/9000/9001/9464), `dotnet build-server shutdown`.
|
||||
- **Разовые решения — одним списком в начале (правило владельца, 2026-09-10):** все вопросы, требующие
|
||||
выбора владельца, собираются и задаются **сразу, до начала работы**, а не по ходу/в конце. **Не
|
||||
спрашивать о том, что уже определено ТЗ/принятыми решениями** — это делать без вопросов; вопрос —
|
||||
только при реальном противоречии в требованиях. При неоднозначности без противоречий — выбирать
|
||||
безопасный обратимый дефолт и делать (напр. перенос, а не удаление).
|
||||
- **Легаси-прототип LeadRadar** перенесён из корня в `archive/leadradar-legacy/` (2026-09-10; обратимо,
|
||||
на сборку/запуск не влияет).
|
||||
@@ -0,0 +1,95 @@
|
||||
# Бэклог (техдолг и отложенные задачи) — «Дейл»
|
||||
|
||||
> Назначение: единый источник отложенного/запланированного. Роудмап черпается отсюда.
|
||||
> Статусы: **BACKLOG** (сделаем при потребности), **DEFERRED** (отложено осознанно, вне текущих рамок),
|
||||
> **MANUAL** (нужны внешние условия: креды, хост, прод), **TECHDEBT** (качество/архитектура).
|
||||
> Приоритет: P1 (важно), P2 (полезно), P3 (когда-нибудь).
|
||||
> Обновлять при каждом заходе; выполненные пункты переносить в `docs/superpowers/STATUS.md` и вычёркивать здесь.
|
||||
|
||||
## 1. Продуктовые фичи (по потребности)
|
||||
|
||||
| ID | Пункт | Источник | Приоритет | Статус |
|
||||
|---|---|---|---|---|
|
||||
| BL-I18N | Переключатель языка в UI + второй язык (en) + locale-aware форматирование (`Intl`), плюрализация. Основа (вынос строк в ресурсы, `registerLocale`) готова | ТЗ §11, этап 11 | P3 | BACKLOG |
|
||||
| BL-TG-MULTI | Мультиаккаунтность Telegram (сейчас 1 аккаунт на тенант) | ТЗ §12 | P2 | DEFERRED |
|
||||
| BL-ML-EXP | Экспорт/импорт ML-моделей (перенос «мозгов» между инстансами) | обсуждение этапа 12 | P3 | DEFERRED (решено не делать; вернуться при SaaS-масштабе) |
|
||||
| BL-RECLASS-SSE | **Сделано (2026-09-11):** пакетная переклассификация отдаёт промежуточный прогресс через SSE `cards_reclassified` (`{progress:true,done,total,moved,kept,trashed,skipped}`) и финальное событие (`{progress:false,reclassified,moved}`); `CardReclassifier.ReclassifyInboxAsync` принимает `IProgress<ReclassifyProgressDto>`; в UI — индикатор `done/total` в шапке «Неразобранного» | этап 12, D | P3 | DONE |
|
||||
| TD-CARD-MERGE | Полное слияние внутренних DTO карточки в единый `CardDto`. **Решение (2026-09-11): DEFERRED.** Наружный контракт единый; внутренние DTO (read/write/DB/patch) намеренно разделены по слоям, слияние — риск без пользы | этап 9/11 | P3 | DEFERRED |
|
||||
| TD-PROTO-COMMENTS | **Сделано (2026-09-11):** из комментариев убраны ссылки на процесс/прототип (`Task/Ruling/этап/python L…/main.py/прототип/LEADRADAR_*`), удалены блоки `<remarks>`, `<summary>` сжаты до короткой фразы; `//`-комментарии со ссылками удалены, в `.proto` — тоже. Строк комментариев 27 210 → ~19 100 | запрос владельца 2026-09-11 | P2 | DONE |
|
||||
| TD-COMMENTS-IFACE | Привести код к правилам код-стайла (`docs/spec/Код-стайл-Дейл.md`). **Сделано (2026-09-11):** (1) `<summary>` только блочно — 5286 шт.; (2) приватные XML-доки понижены — 2028+12; (3) дедупликация `<summary>`→`<inheritdoc/>` — дублей нет (сканы); (4) **явные реализации интерфейсов — сделано (2026-09-11, вечер, вариант A)**: 161 член в 30 прод-файлах конвертирован codemod'ом `scripts/make_explicit.py`, потребители перетипизированы на интерфейсы (8 мест в проде, 17 тест-файлов), Card/ICard-семейство оставлено implicit как DTO; попутно маркерные классы заменены маркерными интерфейсами. Детали — `docs/spec/Код-стайл-аудит-2026-09-11.md` | запрос владельца 2026-09-11 | P2 | DONE |
|
||||
| TD-TESTS-NSUBSTITUTE | Миграция тестовых фейков на NSubstitute (решение владельца 2026-09-11: моки — через NSubstitute, новых фейк-классов не заводить). **Сделано (2026-09-11):** NSubstitute 6.1.0 подключён к 5 тест-проектам; эталон миграции — `FakePasswordHasher` → хелпер `TestHashers.New()` (NSubstitute, детерминированная семантика сохранена), фейк удалён. **Осталось (по размеру):** FakeDiscoveryPacer (1 файл), FakeRatesListener (2), FakeTenantProvisioner (5), FakeSecretCipher (8), FakeAiTools (4), FakeRatesSource (1), FakeGlobalSettingsStore (4), FakeTenantRegistry/FakeTenantRepository (3+7), FakeAiClassifier (5), FakeSettingsStore (42), FakeRateLimitCounterStore (5), FakeAuditLogStore (13), FakeTenantStore (10), FakeMlLearningStore (5), FakeMlClient (17), FakeOperatorAuthStore (14), FakeInviteStore (7), FakeFileStorage (9), FakeTokenUsageEventStore (10), FakeAuthStore (15), Recording*/Harness* (gRPC-харнессы — оставить как хелперы), крупные stateful: FakeTelegramGateway (5), FakeDiscoveryGateway (2), FakeTelegramStore (7), FakeTenantLimitStore (15), FakePipelineStore (12), FakeDiscoveryStore (7), FakeKanjStore (21). Для каждого: заменить подставку на `Substitute.For<>()` + `Returns`, семантику состояния воспроизвести в конфигурации, тесты перетипизировать на интерфейс | решение владельца 2026-09-11 | P2 | BACKLOG |
|
||||
| TD-STYLE-ANALYZERS | Остаток мягких правил код-стайла. **Закрыто (2026-09-11):** (1) `var` — включён ломающий сборку гейт только для встроенных типов (`csharp_style_var_for_built_in_types = false:warning`), остаток выправлен `dotnet format style --diagnostics IDE0008` по 5 sln; режимы «очевидный/прочий тип» — silent осознанно (~1600 субъективных замен); (2) дедупликация `<summary>` — дублей нет (см. TD-COMMENTS-IFACE); (3) переводы строк — **решено: LF** (`.gitattributes` `* text=auto eol=lf`, `.editorconfig` → lf, 1029 файлов нормализовано, `git add --renormalize`; попутно починены 42 CRLF-.sh — до этого первый прогон удалённого CI падал бы). `this.` и именование приватных полей уже закрыты в `.editorconfig` | аудит 2026-09-11 | P3 | DONE |
|
||||
|
||||
## 2. Инфраструктура и эксплуатация
|
||||
|
||||
| ID | Пункт | Источник | Приоритет | Статус |
|
||||
|---|---|---|---|---|
|
||||
| BL-K8S | Kubernetes-манифесты (сейчас docker-compose; k8s — при масштабировании) | ТЗ §11/§12, R6 | P3 | DEFERRED |
|
||||
| BL-KAFKA | Kafka как шина данных между сервисами (сейчас gRPC + БД-outbox) | ТЗ §12, обсуждение | P3 | DEFERRED |
|
||||
| BL-CF | Cloudflare / внешний периметр (сейчас Caddy, mTLS; конфиг вне кода) | ТЗ §11, техдок §10 | P2 | MANUAL |
|
||||
| BL-CI | **Сделано (2026-09-11):** `scripts/ci.sh` — сборка всех 5 решений, тесты всех сервисов, скан уязвимых NuGet-зависимостей (`dotnet list package --vulnerable --include-transitive`), сборка+линтер фронта; `build.sh`/`test.sh` расширены на все решения/сервисы; `.github/workflows/ci.yml`. Первый прогон в удалённом CI — при публикации репозитория | техдок §8 | P2 | DONE (remote — MANUAL) |
|
||||
| BL-IMG-HARDEN | **Сделано (2026-09-11):** non-root USER (deal, UID 10001, HOME=/tmp) во всех прикладных образах; в compose — read_only root FS + tmpfs /tmp, no-new-privileges, cap_drop ALL, mem_limit/cpus; логи stateless-сервисов в /tmp/logs. Проверено `compose config` (dev/prod/observability); живой прогон — MANUAL | техдок §10 | P2 | DONE (live — MANUAL) |
|
||||
| BL-BACKUP-CRON | Автоматизация бэкапов (cron/systemd-примеры есть, реальный прогон — MANUAL) | техдок §9 | P2 | MANUAL |
|
||||
|
||||
## 3. SaaS / мультитенантность
|
||||
|
||||
| ID | Пункт | Источник | Приоритет | Статус |
|
||||
|---|---|---|---|---|
|
||||
| BL-BILLING | Биллинг и тарифные планы, провайдер платежей | ТЗ §12 | P3 | DEFERRED |
|
||||
| BL-SIGNUP | Саморегистрация тенантов (сейчас инвайты/оператор) | ТЗ §12 | P3 | DEFERRED |
|
||||
| BL-SCALE-1000 | Механизм миграций/провижининга на 1000+ схем. **Сделано (2026-09-11):** пакетная миграция шардирована — `ITenantRepository.ListPageAsync` + обход страницами в `TenantSchemaMigrationService` (параллелизм внутри страницы, `DefaultPageSize=200`, границы 1..32 / 1..5000), сбои изолированы. Осталось при росте: вынести параллелизм/размер в конфиг и кэш прогресса (при необходимости) | roadmap этап 0, этап 12 | P2 | DONE |
|
||||
|
||||
## 4. Безопасность и наблюдаемость (доработки)
|
||||
|
||||
| ID | Пункт | Источник | Приоритет | Статус |
|
||||
|---|---|---|---|---|
|
||||
| BL-ALERT-BUDGET | **Сделано (2026-09-11):** метрика `deal.ai.budget.used.ratio{tenant}` (доля израсходованного ИИ-бюджета периода, 0..1) в `DealMetrics` + сбор в `RuntimeDepthsCollector`/`DealMetricsCollector`; на её основе оператор настраивает алерт в Prometheus/Grafana | этап 12, A | P2 | DONE |
|
||||
| BL-LOG-ACTOR | **Сделано (2026-09-11):** access-лог HTTP core (`HttpAccessLogMiddleware`) включает `actor` (login пользователя тенанта либо оператора) и `tenant` (id тенанта) — их берут из `HttpContext.Items` (Session/OperatorSession middleware) | этап 12, T6 | P3 | DONE |
|
||||
| BL-GRACEFUL | Дополнительные проверки устойчивости/ретраев (по результатам нагрузочного прогона) | этап 12, C | P2 | BACKLOG |
|
||||
| BL-SUSPICIOUS | **Сделано (2026-09-11):** детектор `SuspiciousActivityService` расширен правилом `distinct_logins_per_ip` (перебор разных логинов с одного IP, порог `DistinctLoginsPerIpThreshold`); плюс real-time `SuspiciousActivityReporter` — метрика `deal.security.suspicious{kind}` + warn-лог на 429 rate limiter (`rate_limit`) и блокировке входа (`login_blocked`) | ТЗ §10.5, этап 12 | P3 | DONE |
|
||||
|
||||
## 5. Технический долг (качество/архитектура)
|
||||
|
||||
| ID | Пункт | Источник | Приоритет | Статус |
|
||||
|---|---|---|---|---|
|
||||
| TD-SETTINGS-UI | Вынос вкладок `SettingsView` в компоненты. **Сделано (2026-09-11):** `SettingsView.vue` — только набор вкладок/QR-опрос, все 10 вкладок — отдельные компоненты (`components/settings/*`) | ревью 2026-09-08 | P3 | DONE |
|
||||
| TD-VIRT | Полная виртуализация длинных колонок. **Решение (2026-09-11): DEFERRED** — прогрессивный рендер «Показать ещё» покрывает текущие объёмы; виртуализация — при росте списков | ревью, этап 12 | P3 | DEFERRED |
|
||||
| TD-SSE-DEAD | **Сделано (2026-09-11):** мёртвые SSE-ветки фронта `boards_changed`/`pipeline_stats` удалены из `store/lifecycle.js` (core их не публикует) | этап 12, E | P3 | DONE |
|
||||
| TD-DBL-CLICK | **Сделано (2026-09-11):** перезагрузка доски при batch-переклассификации коалесцируется `scheduleBoardReload()` (ответ + SSE → один запрос) | этап 12, E | P3 | DONE |
|
||||
| TD-TEST-HARNESS | Историческая гонка `FreeTcpPort` — устранена; следить за новыми хост-хелперами | этап 12, E | P3 | TECHDEBT |
|
||||
| TD-OLD-DOCS | Исторические доки несут старые термины под пометками. **Проверено (2026-09-11):** `docs/superpowers/plans/*` и старые `docs/architecture/2026-09-0*` имеют шапку «Исторический документ»; переписывать не нужно | docs sweep | P3 | DONE |
|
||||
| TD-SOURCE-PROVIDER | Провайдеры содержимого источников. **Сделано (2026-09-11):** `TelegramSourceContentProvider` + `ReadSource` RPC + `GET /api/cards/{id}/source` + UI «Обновить из источника». Осталось: провайдеры прочих источников по мере появления | generic source 2026-09-11 | P2 | TECHDEBT |
|
||||
| TD-STORE-ATTACH | Выгрузка вложений источника в Storage-сервис адаптером. **Решение (2026-09-11): медиа-посты Telegram пропускаем** — извлечение/выгрузка не делаются; вложений у прочих источников пока нет — **DEFERRED** (контракт `DataRef` готов, включается при появлении такого источника) | generic source 2026-09-11 | P3 | DEFERRED |
|
||||
| TD-TG-CORE-SPLIT | Перенос оставшейся Telegram-специфики ядра в telegram-сервис. **Закрыто (2026-09-11): не требуется.** Задача «дашборды/карточки не знают о Telegram» решена generic-контрактом источника; оставшиеся `TelegramStore`/`Dialogs`/`TgMessages`/Discovery — это состояние тенанта (ядро — владелец данных, telegram-service — stateless-шлюз), перенос отдал бы шлюзу доступ к схеме тенанта | generic source 2026-09-11 | — | CLOSED |
|
||||
| TD-SOURCE-CONTACTS | Квалификатор контактов знает форматы профилей (t.me/`@handle`). **Решение (2026-09-11): DEFERRED** — форматы стабильны, расширяемость под источник добавляется при конкретной потребности | generic source 2026-09-11 | P3 | DEFERRED |
|
||||
| TD-APIMAP-COUNT | Ручной подсчёт числа ручек в `api-map`. **Сделано (2026-09-11):** сверил счётчики §3.1–§3.8 с фактическими строками (рассинхрон §3.5 — 14→15 из-за `GET /cards/{id}/source`); в §3 добавлено правило обновлять счётчики | docs sweep | P3 | DONE |
|
||||
|
||||
## 6. Manual-проверки (нужны внешние условия)
|
||||
|
||||
| ID | Пункт | Источник | Приоритет | Статус |
|
||||
|---|---|---|---|---|
|
||||
| MN-E2E-TG | Реальный Telegram-вход (QR) + приём сообщений, backfill, «Перечитать каналы» | ТЗ §4, STATUS | P1 | MANUAL (креды/аккаунт) |
|
||||
| MN-E2E-LLM | Живые LLM-вызовы (классификация/фильтр/reclassify/расход токенов) | ТЗ §8–9 | P1 | MANUAL (LLM-ключ) |
|
||||
| MN-PROD | Прод-развёртывание (хост/домен, Caddy+mTLS, observability-профиль) | техдок §13.8 | P2 | MANUAL (данные хоста) |
|
||||
| MN-GRAFANA | Живая проверка Grafana-дашбордов метрик/алертов и логов | этап 12, A/T6 | P2 | MANUAL |
|
||||
| MN-LOADTEST | Живой нагрузочный прогон (`scripts/loadtest/`) и baseline | этап 12, C | P2 | MANUAL |
|
||||
| MN-BACKUP | Живой прогон `backup.sh`/restore на реальных данных | техдок §9 | P2 | MANUAL |
|
||||
| MN-THEME | Визуальная приёмка светлой темы в браузере | этап 12, «Внешний вид» | P3 | MANUAL |
|
||||
|
||||
## 7. Отложено/решено «не делать» (для истории)
|
||||
|
||||
| ID | Пункт | Решение |
|
||||
|---|---|---|
|
||||
| DEC-DEMO | Демо-эндпоинты (`DEAL_DEMO`, `simulate-lead`) | Удалены (этап 9/12) |
|
||||
| DEC-LEGACY | Легаси-прототип LeadRadar (`backend/`, `mlservice/`, корневой compose) | Перенесён в `archive/leadradar-legacy/` (2026-09-10); 2026-09-11 убран и из репозитория — лежит только локально |
|
||||
| DEC-ML-EXP | Экспорт/импорт ML | Отложено владельцем (перенесено в `BL-ML-EXP`) |
|
||||
|
||||
---
|
||||
|
||||
## Как пользоваться
|
||||
- Для нового захода: выбрать пункты по приоритету/теме, оформить SDD-план в `docs/superpowers/plans/`
|
||||
и ledger `.superpowers/sdd/<этап>/`, после приёмки — перенести факт в `docs/superpowers/STATUS.md`,
|
||||
а пункт здесь пометить выполненным/удалить.
|
||||
- Открытые пункты (BACKLOG/MANUAL) зеркалятся задачами в Gitea (`rust/Deal`, метки P1/P2/P3/manual/techdebt);
|
||||
при закрытии пункта закрывать задачу и наоборот.
|
||||
- Пункты `MANUAL` не блокируют разработку; выполняются, когда владелец даёт креды/хост.
|
||||
@@ -0,0 +1,341 @@
|
||||
# Поиск и подключение каналов (Discovery) — Implementation Plan
|
||||
|
||||
> Исторический документ (план Discovery прототипа LeadRadar, 2026-09-04). Актуальное состояние — `docs/superpowers/STATUS.md` и `docs/technical/Техническая-документация-Дейл.md`.
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Дать пользователю возможность создавать «задачи поиска»: система находит по описанию/ключам Telegram-каналы и группы (в которых мы не состоим), оценивает их (метаданные → язык → контент по темам), показывает «на рассмотрение», а человек вступает сам или включает авто-вступление в рамках суточных квот с анти-бан паузами.
|
||||
|
||||
**Architecture:** Дочерняя система Discovery поверх существующего стека (FastAPI + DuckDB + TelegramManager/Telethon + Vue 3). Отдельный сервис `discovery` (хранилище+оркестрация), новые методы Telegram-действий в `TelegramManager`, общий BanGuard для квот/пауз, отдельный фоновый воркер в `main.py`. Оценка сообщений переиспользует правила/ML/ИИ, но с профилем задачи и БЕЗ создания карточек. UI — подвкладка «Поиск» на экране «Каналы».
|
||||
|
||||
**Tech Stack:** Python 3.12 / FastAPI / DuckDB / Telethon / Vue 3 + Tailwind (Vite). Новых зависимостей нет.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Правило «мы не состоим» — глобальное и безусловное: источники из `dialogs`, чёрного списка или уже в другой задаче исключаются сразу (проверка повторяется и при вступлении).
|
||||
- Спека: `docs/superpowers/specs/2026-09-04-channel-discovery-design.md` (читать при каждом задании).
|
||||
- Проект НЕ git-репозиторий: вместо `git commit` — проверка через `docker compose`/`npm run build`, фиксация результата в тексте шага.
|
||||
- Все тексты UI — по-русски, в стиле существующего интерфейса (без канцелярита, короткие подписи).
|
||||
- Все настраиваемые числа (лимиты, паузы, пороги, размеры выборок) — настройки в БД (`store.get_setting`), НЕ в коде; дефолты в `constants.DEFAULT_SETTINGS`.
|
||||
- Новые таблицы добавлять только через `db.py` (`_SCHEMA`, `CREATE TABLE IF NOT EXISTS`), при необходимости — миграции в `_MIGRATIONS`.
|
||||
- Запуск/проверка: контейнеры `docker compose up -d`, бэкенд на :8000, ML на :8100; пересборка `docker compose build app`.
|
||||
- JSON-поля (keywords/marks/topics) хранить как VARCHAR с `json.dumps(..., ensure_ascii=False)`, читать через `json.loads` — как в остальном коде.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Схема БД и настройки по умолчанию
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/app/db.py` (добавить 4 таблицы в `_SCHEMA`)
|
||||
- Modify: `backend/app/constants.py` (`DEFAULT_SETTINGS`)
|
||||
- Modify: `backend/app/routers/settings_routes.py` (`_PUBLIC_INT`)
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: таблицы `disc_tasks`, `disc_candidates`, `disc_blacklist`, `disc_log`; настройки `discJoinLimit` (50), `discJoinDelayMin` (50), `discJoinDelayMax` (70), `discEvalSample` (10), `discEvalThreshold` (40).
|
||||
|
||||
- [ ] **Step 1: Добавить таблицы в `_SCHEMA`** (перед таблицей `settings`)
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS disc_tasks (
|
||||
id VARCHAR PRIMARY KEY,
|
||||
name VARCHAR NOT NULL,
|
||||
description VARCHAR NOT NULL DEFAULT '',
|
||||
keywords VARCHAR NOT NULL DEFAULT '[]',
|
||||
min_subscribers INTEGER NOT NULL DEFAULT 0,
|
||||
lang VARCHAR NOT NULL DEFAULT 'ru',
|
||||
threshold INTEGER NOT NULL DEFAULT 40,
|
||||
sample_size INTEGER NOT NULL DEFAULT 10,
|
||||
plan_joins INTEGER NOT NULL DEFAULT 1,
|
||||
auto_join BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
status VARCHAR NOT NULL DEFAULT 'draft', -- draft|running|paused|done|failed
|
||||
search_idx INTEGER NOT NULL DEFAULT 0,
|
||||
search_done BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
found INTEGER NOT NULL DEFAULT 0,
|
||||
evaluated INTEGER NOT NULL DEFAULT 0,
|
||||
joined INTEGER NOT NULL DEFAULT 0,
|
||||
rejected INTEGER NOT NULL DEFAULT 0,
|
||||
created_at BIGINT NOT NULL,
|
||||
updated_at BIGINT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS disc_candidates (
|
||||
dialog_id VARCHAR PRIMARY KEY,
|
||||
task_id VARCHAR NOT NULL,
|
||||
name VARCHAR NOT NULL DEFAULT '',
|
||||
username VARCHAR NOT NULL DEFAULT '',
|
||||
kind VARCHAR NOT NULL DEFAULT 'channel', -- channel|group|forum
|
||||
hue VARCHAR NOT NULL DEFAULT '#666',
|
||||
participants INTEGER,
|
||||
lang_ru BOOLEAN,
|
||||
marks VARCHAR NOT NULL DEFAULT '[]',
|
||||
topics VARCHAR NOT NULL DEFAULT '[]',
|
||||
fit_ratio DOUBLE,
|
||||
status VARCHAR NOT NULL DEFAULT 'new', -- new|review|joined|rejected
|
||||
auto_joined BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
created_at BIGINT NOT NULL,
|
||||
updated_at BIGINT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_disc_cand_task ON disc_candidates(task_id, status);
|
||||
CREATE TABLE IF NOT EXISTS disc_blacklist (
|
||||
dialog_id VARCHAR PRIMARY KEY,
|
||||
name VARCHAR NOT NULL DEFAULT '',
|
||||
reason VARCHAR NOT NULL DEFAULT '',
|
||||
created_at BIGINT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS disc_log (
|
||||
id VARCHAR PRIMARY KEY,
|
||||
task_id VARCHAR NOT NULL,
|
||||
event VARCHAR NOT NULL, -- search|found|skip|eval|review|join_auto|join_manual|leave|reject|flood|error|done
|
||||
text VARCHAR NOT NULL DEFAULT '',
|
||||
created_at BIGINT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_disc_log_task ON disc_log(task_id, created_at);
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Добавить настройки в `constants.py` → `DEFAULT_SETTINGS`**
|
||||
|
||||
```python
|
||||
# поиск каналов (Discovery)
|
||||
"discJoinLimit": 50, # суточный лимит авто-вступлений (общий)
|
||||
"discJoinDelayMin": 50, # сек, нижняя граница паузы между авто-вступлениями
|
||||
"discJoinDelayMax": 70, # сек, верхняя граница
|
||||
"discEvalSample": 10, # размер выборки сообщений при оценке
|
||||
"discEvalThreshold": 40, # % подходящих сообщений
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Открыть настройки наружу в `settings_routes.py`**
|
||||
|
||||
В `_PUBLIC_INT` добавить `discJoinLimit, discJoinDelayMin, discJoinDelayMax, discEvalSample, discEvalThreshold`. В `patch_settings` наложить границы: limit 1..200; паузы 5..600; sample 3..30; threshold 1..100.
|
||||
|
||||
- [ ] **Step 4: Проверить**
|
||||
|
||||
```bash
|
||||
docker compose build app && docker compose up -d app
|
||||
```
|
||||
Затем `GET /api/settings` (после логина) — в ответе присутствуют `discJoinLimit: 50` и остальные ключи. `python -m py_compile` всех изменённых файлов — без ошибок.
|
||||
|
||||
---
|
||||
|
||||
### Task 2: BanGuard (квоты, паузы, flood)
|
||||
|
||||
**Files:**
|
||||
- Create: `backend/app/services/ban_guard.py`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `store`, настройки из Task 1.
|
||||
- Produces:
|
||||
- `def joins_today_auto() -> int` — авто-вступления за текущие UTC-сутки (считает `disc_log` event='join_auto', `created_at >= начало суток`).
|
||||
- `def can_auto_join() -> bool` — лимит не исчерпан И нет flood-блокировки на сегодня И нет глобальной паузы.
|
||||
- `async def wait_join_delay() -> None` — `asyncio.sleep(random.uniform(min, max))`.
|
||||
- `def note_flood() -> None` — `store.set_setting("discFloodDay", <start_of_day_ms>)`.
|
||||
- `def flood_today() -> bool`
|
||||
- `def global_paused() -> bool` / `def set_global_pause(v: bool) -> None` (setting `discPaused`)
|
||||
- `def search_pause() -> float` — `random.uniform(2.0, 4.0)`.
|
||||
|
||||
- [ ] **Step 1: Реализовать модуль** (~60 строк; начало суток — UTC: `datetime.now(timezone.utc).replace(hour=0,minute=0,second=0,microsecond=0)` → ms).
|
||||
|
||||
- [ ] **Step 2: Проверить на временной БД в контейнере**
|
||||
|
||||
```bash
|
||||
docker compose run --rm --no-deps -e PYTHONPATH=/srv -e LEADRADAR_DATA=/tmp/lr_bg --entrypoint python app -c "
|
||||
from app.db import store; store.init()
|
||||
from app.services import ban_guard as bg
|
||||
assert bg.can_auto_join() is True
|
||||
assert bg.joins_today_auto() == 0
|
||||
bg.note_flood(); assert bg.flood_today() is True
|
||||
bg.set_global_pause(True); assert bg.can_auto_join() is False
|
||||
print('BANGUARD OK')
|
||||
"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Хранилище Discovery (задачи/кандидаты/чёрный список/лог)
|
||||
|
||||
**Files:**
|
||||
- Create: `backend/app/services/discovery.py`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `store` (таблицы Task 1).
|
||||
- Produces (все синхронные):
|
||||
- `list_tasks() -> list[dict]`, `get_task(id) -> dict | None` (keywords — список)
|
||||
- `create_task(payload: dict) -> dict` — валидация: name непустое; `plan_joins` 1..limit; **правило бюджета**: `sum(plan_joins задач, где status NOT IN ('done','failed')) + plan_joins <= discJoinLimit`, иначе `raise ValueError(...)`.
|
||||
- `patch_task(id, patch: dict) -> dict` (name/description/keywords/min_subscribers/lang/threshold/sample_size/plan_joins/auto_join; увеличение plan_joins — с той же проверкой)
|
||||
- `delete_task(id) -> None` (удалить задачу и её кандидатов)
|
||||
- `start_task(id) -> dict` — требует непустой keywords; status=running; `pause_task(id) -> dict` — paused
|
||||
- `list_candidates(task_id, status: str | None) -> list[dict]` (декод marks/topics)
|
||||
- `add_candidate(task_id, dialog_id, name, username, kind, hue) -> dict | None` — `None`, если: в `dialogs`, в `disc_blacklist`, либо уже есть `disc_candidates` со статусом new/review/joined. Лог `skip` с причиной.
|
||||
- `bump_counter(task_id, field: str, n: int = 1)` — found/evaluated/joined/rejected
|
||||
- `set_candidate(task_id, dialog_id, patch: dict)` — обновление полей кандидата
|
||||
- `set_candidate_status(dialog_id, status)` + лог
|
||||
- `delete_candidate(dialog_id) -> None` — удалить кандидата (skip-ветки)
|
||||
- `advance_search(task_id) -> None` — `search_idx += 1`; когда индекс >= len(keywords) → `search_done=True`
|
||||
- `mark_joined(dialog_id, auto: bool)` — статус joined + `bump_counter('joined')` + лог `join_auto`/`join_manual`
|
||||
- `mark_rejected(dialog_id, reason="")` — статус rejected + `bump_counter('rejected')` + лог `reject` + `add_blacklist`
|
||||
- `add_blacklist(dialog_id, name, reason) / remove_blacklist(dialog_id) / list_blacklist() -> list[dict]`
|
||||
- `add_log(task_id, event, text="")`; `task_log(task_id, limit=100) -> list[dict]`
|
||||
|
||||
- [ ] **Step 1: Реализовать модуль** (json-поля по конвенции проекта; все `store.execute/query` с параметрами).
|
||||
- [ ] **Step 2: Проверить на временной БД** (сценарий): создать задачу plan=25 → ок; создать вторую plan=30 → `ValueError`; кандидат, совпадающий с `dialogs` → `add_candidate` вернул None + лог skip; `mark_rejected` → в чёрном списке; повторный `add_candidate` того же источника → None.
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Telegram-действия поиска (методы TelegramManager)
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/app/services/telegram.py` (класс `TelegramManager`)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `self.client`, `ban_guard`.
|
||||
- Produces (async-методы):
|
||||
- `async def discovery_search(q: str, limit: int = 30) -> list[dict]` — `client(functions.contacts.SearchRequest(q=q, limit=limit))`; вернуть `[{id(str), name, username, kind, hue}]` (kind через `_kind_of`, hue через `dialog_hue`); между вызовами — `await asyncio.sleep(ban_guard.search_pause())`.
|
||||
- `async def discovery_info(dialog_id: str) -> dict` — `{id, name, username, kind, hue, participants: int | None, is_forum: bool}` (participants из `full_chat` где возможно; иначе None).
|
||||
- `async def discovery_read(dialog_id: str, limit: int) -> dict` — последние сообщения: `{"ok": bool, "error": str | None, "messages": [{"id", "text", "date_ms", "topic_id"}]}`; `topic_id` — `getattr(getattr(m,'reply_to',None),'reply_to_top_id',None)`. История недоступна → `{"ok": False, "error": "no_history", "messages": []}`.
|
||||
- `async def discovery_join(username: str) -> None` — `client(functions.channels.JoinChannelRequest(...))`; ПЕРЕД вызовом `await ban_guard.wait_join_delay()`; `FloodWaitError` → `ban_guard.note_flood()` и проброс.
|
||||
- `async def discovery_leave(dialog_id: str) -> None` — `channels.LeaveChannelRequest`.
|
||||
- `def add_dialog_monitored(dialog_id, name, username, kind, hue) -> None` — INSERT/UPDATE `dialogs` с `monitor=TRUE, backfilled=FALSE` (как в `set_monitor`, но без авто-join-логики).
|
||||
|
||||
- [ ] **Step 1: Реализовать методы** (импорт `telethon.tl.functions`, `telethon.errors.rpcerrorlist.FloodWaitError`).
|
||||
- [ ] **Step 2: Проверить компиляцию** `py_compile`. Полная проверка — после Task 6/7 на живом аккаунте (ручные шаги в Task 10).
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Оценка контента (язык, темы, fit по профилю задачи)
|
||||
|
||||
**Files:**
|
||||
- Create: `backend/app/services/discovery_eval.py`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `store`, `ml_client`, `ai_service` (chat_json), `pipeline.clean_short`.
|
||||
- Produces:
|
||||
- `def detect_lang_ru(texts: list[str]) -> bool | None` — доля кириллических букв от всех букв в сумме: `>=0.15 → True`; `<=0.03 → False`; между порогами → `None` (неопределённо).
|
||||
- `def group_by_topic(messages: list[dict]) -> list[dict]` — группировка по `topic_id` (None → "main"); возвращает `[{"topic_id", "title", "messages": [...]}]`, title = сниппет первого текста темы (≤60 симв.), сортировка по количеству сообщений (убыв.).
|
||||
- `async def evaluate_message(task: dict, text: str) -> dict` — `{"fit": bool, "reason": str, "source": "heuristic"|"ml"|"ai"}`:
|
||||
1) текст пустой/длина <10 → fit False «слишком короткое»;
|
||||
2) ML: если `ml_client.is_enabled()` и прогноз `take` и `label=='spam'` → fit False «ML: спам»;
|
||||
3) ИИ: если `aiEnabled` → один JSON-вызов `ai_service.chat_json(промпт, user=text)` с промптом из описания задачи и ключей (`{fit, reason}`); ошибка → шаг 4;
|
||||
4) эвристика: fit = любой ключ входит в `clean_short(text)` casefold; reason «совпал ключ "…"» / «нет совпадений с ключами».
|
||||
- `async def evaluate_sample(task: dict, messages: list[dict]) -> dict` — последовательно по каждому сообщению; вернуть `{"fit_count": int, "total": int, "fit_ratio": float, "per_message": [{"text": …, "fit", "reason", "topic_id"}]}`.
|
||||
- `def passed(ev: dict, task: dict) -> bool` — `ev["total"] >= 3 and ev["fit_ratio"]*100 >= task["threshold"]`.
|
||||
|
||||
- [ ] **Step 1: Реализовать модуль**. Промпт ИИ (внутри модуля, константа):
|
||||
`Оцени, относится ли сообщение к сфере/задаче. Описание: {description}. Ключи: {keywords}. Верни JSON {"fit": 0|1, "reason": "краткая причина"}.`
|
||||
|
||||
- [ ] **Step 2: Проверить на временной БД** (без сети): `detect_lang_ru(["Ищем python разработчика"]) is True`; `detect_lang_ru(["we need a python developer"]) is False`; `group_by_topic` объединяет по topic_id и сортирует; `evaluate_message` на задаче без ИИ/ML возвращает эвристический fit по ключу.
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Воркер Discovery (поиск → оценка → авто-вступление)
|
||||
|
||||
**Files:**
|
||||
- Create: `backend/app/services/discovery_worker.py`
|
||||
- Modify: `backend/app/main.py` (фоновый цикл `_discovery_loop`, каждые 5 c)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `discovery` (Task 3), `tg.discovery_*` (Task 4), `discovery_eval` (Task 5), `ban_guard` (Task 2).
|
||||
- Produces: `async def tick() -> dict` — выполняет ОДНО действие и возвращает `{"action": ..., "taskId": ...}` (или `{"action": "none"}`).
|
||||
|
||||
Логика tick (по одной задаче за вызов, начиная с самой старой running):
|
||||
1. Если задача `search_done=False`: взять ключ `keywords[search_idx]`, вызвать `tg.discovery_search`; для каждого результата `discovery.add_candidate`; `discovery.advance_search(task_id)`; если `search_done` стал True — лог `search` «поиск завершён: N кандидатов». Возврат.
|
||||
2. Иначе взять первого кандидата статуса `new` задачи:
|
||||
- `info = tg.discovery_info`; `participants`, `kind` (forum если `is_forum`); при заданном `min_subscribers` и participants НЕ None и меньше минимума — `set_candidate_status(...)` нет: просто `discovery.delete_candidate` + лог `skip`; если participants None — метка «участники не подтверждены» (идём дальше).
|
||||
- `read = tg.discovery_read(dialog_id, sample_size)`.
|
||||
- Если `read.ok=False` (история недоступна без членства): kind==channel → `review` с меткой «канал: история недоступна»; группа/форум → `review` с меткой «закрытая группа (история скрыта) — вступите сами»; оценка контента не производится, неподтверждённые фильтры помечаются.
|
||||
- Язык: если прочитано и `task.lang=='ru'`: `lang_ru=detect_lang_ru(...)`; False → удалить кандидата, лог `skip` «язык не русский»; None → метка «язык не подтверждён».
|
||||
- Оценка: `evaluate_sample`; `passed` → метки topics/fit → `review` + лог `review`; иначе удалить кандидата, лог `skip` «мало подходящих (X из N)».
|
||||
3. Авто-вступление (отдельный проход tick, приоритет ниже оценки): если у running-задачи `auto_join` и есть кандидат `review` и `ban_guard.can_auto_join()`:
|
||||
- повторная проверка «мы не состоим» (`dialogs`/blacklist) → если вступили уже → `mark_rejected` с логом;
|
||||
- `await ban_guard.wait_join_delay()` (рандом 50–70 с — спейсинг авто-вступлений; ручные join из API паузу не делают);
|
||||
- `tg.discovery_join(username)` → `discovery.mark_joined(dialog_id, auto=True)` → `tg.add_dialog_monitored(...)`; при FloodWaitError → `ban_guard.note_flood()` + лог `flood`.
|
||||
4. Если `task.joined >= task.plan_joins` → статус `done`, лог `done`.
|
||||
|
||||
- [ ] **Step 1: Реализовать** `discovery_worker.py` и цикл в `main.py`.
|
||||
- [ ] **Step 2: Проверить компиляцию** и запуск без падений (воркер с пустыми таблицами делает `none`). Полный прогон — Task 10 вручную.
|
||||
|
||||
---
|
||||
|
||||
### Task 7: API Discovery
|
||||
|
||||
**Files:**
|
||||
- Create: `backend/app/routers/discovery_routes.py`
|
||||
- Modify: `backend/app/main.py` (регистрация роутера)
|
||||
|
||||
**Interfaces:**
|
||||
- Prefix `/api/discovery`, auth `current_login`:
|
||||
- `GET /tasks`, `POST /tasks`, `PATCH /tasks/{id}`, `DELETE /tasks/{id}`, `POST /tasks/{id}/start`, `POST /tasks/{id}/pause`
|
||||
- `POST /tasks/{id}/generate-keywords` — ИИ: промпт по description → JSON `{"keywords": [...]}` (8–16 строк RU+EN); ИИ недоступен/выключен → `{"keywords": [], "error": "..."}`.
|
||||
- `GET /tasks/{id}/candidates?status=`
|
||||
- `POST /candidates/{dialog_id}/join` — ручное вступление (вне квот): `tg.discovery_join` + `add_dialog_monitored` + `mark_joined(auto=False)`; 400 при ошибке.
|
||||
- `POST /candidates/{dialog_id}/reject` — `mark_rejected` (добавляет в чёрный список). Если кандидат уже `joined` — 400.
|
||||
- `GET /blacklist`, `DELETE /blacklist/{dialog_id}`
|
||||
- `GET /tasks/{id}/log`
|
||||
|
||||
Pydantic-модели: `TaskCreate` (name, description, keywords, minSubscribers, lang, threshold, sampleSize, planJoins, autoJoin), `TaskPatch` (все optional), `GenKeywordsBody` не нужен (id в пути).
|
||||
|
||||
- [ ] **Step 1: Реализовать роутер** (ValueError → HTTPException 400; KeyError → 404).
|
||||
- [ ] **Step 2: Зарегистрировать в main.py**.
|
||||
- [ ] **Step 3: Проверить API на живом контейнере**: логин, создание задачи plan=1, list, delete; `generate-keywords` вернёт error-ветку без настроенного ИИ (не падает).
|
||||
|
||||
---
|
||||
|
||||
### Task 8: Фронтенд — store + каркас подвкладки «Поиск»
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/store.js`
|
||||
- Create: `frontend/src/views/DiscoveryView.vue`
|
||||
- Modify: `frontend/src/views/ChannelsView.vue`
|
||||
|
||||
**Interfaces:**
|
||||
- state: `channelsTab: 'list' | 'search'`, `discTasks: []`, `discCandidates: []`, `discBlacklist: []`, `discLog: []`, `discActiveTaskId: null`, `discCandidateStatus: 'review'`, `discBusy: false`.
|
||||
- store-функции: `gotoChannelsTab(tab)`, `loadDiscTasks()`, `saveDiscTask(form, id=null)` (create/patch), `deleteDiscTask(id)`, `startDiscTask(id)`, `pauseDiscTask(id)`, `generateDiscKeywords(taskId)`, `loadDiscCandidates(taskId, status)`, `joinDiscCandidate(c)`, `rejectDiscCandidate(c)`, `loadDiscBlacklist()`, `removeDiscBlacklist(id)`, `loadDiscLog(taskId)`.
|
||||
|
||||
- [ ] **Step 1: store.js** — состояние + функции (паттерны: `api.get/post/patch/delete`, `toast`, `errMsg`).
|
||||
- [ ] **Step 2: ChannelsView.vue** — в шапке сегмент: «Каналы | Поиск» (`state.channelsTab`), содержимое по табу.
|
||||
- [ ] **Step 3: DiscoveryView.vue (каркас)**: левая колонка — список задач (+ «Новая задача»); правая — панель задачи: мастер (name, description, «Сгенерировать ключи ИИ», чипы ключей редактируемые, minSubscribers, lang select ru/any, threshold, sampleSize, planJoins, autoJoin toggle, кнопки «Запустить/Пауза/Удалить»).
|
||||
- [ ] **Step 4: `npm run build`** — без ошибок.
|
||||
|
||||
---
|
||||
|
||||
### Task 9: Фронтенд — кандидаты, действия, чёрный список, история
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/views/DiscoveryView.vue`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Task 8 (store).
|
||||
|
||||
- [ ] **Step 1: Табы панели задачи**: «В обработке» (`new`) / «На рассмотрении» (`review`) / «Вступили» (`joined`) / «Отклонены» (`rejected`) + «История» (лог). Бейджи счётчиков скрыты при 0.
|
||||
- [ ] **Step 2: Карточка кандидата**: название, @username, kind-иконка/метка (канал/группа/форум), метки marks (чипы: закрытая, не прочитан, участники/язык не подтверждены, мало сообщений), участники, «подходит X из N», кнопки «Вступить и мониторить» / «Отклонить» (только для review). Форум → раскрывающийся список topics («тема — подходит X из N»).
|
||||
- [ ] **Step 3: Чёрный список** (под списками или отдельный таб) — снять источник; «Настройки квот» — popover/inline с `discJoinLimit/discJoinDelayMin/discJoinDelayMax` + стоп-кран (PATCH /api/settings).
|
||||
- [ ] **Step 4: История** — лог задачи.
|
||||
- [ ] **Step 5: `npm run build`** — без ошибок; визуальная проверка основных сценариев (Task 10).
|
||||
|
||||
---
|
||||
|
||||
### Task 10: ТЗ, сборка и end-to-end проверка
|
||||
|
||||
**Files:**
|
||||
- Modify: `ТЗ.md` (раздел «Поиск и подключение каналов»)
|
||||
|
||||
- [ ] **Step 1: Дополнить ТЗ** — кратко: задачи поиска, каскад фильтров, глобальное правило «мы не состоим», метки, оценка по темам, review/join/reject + чёрный список, авто-вступление и квоты (50/сутки, 50–70 с), подвкладка «Поиск» на «Каналах».
|
||||
- [ ] **Step 2: Сборка и рестарт**:
|
||||
|
||||
```bash
|
||||
docker compose build app && docker compose up -d app
|
||||
cd frontend && npm run build
|
||||
```
|
||||
|
||||
- [ ] **Step 3: E2E вручную (нужен подключённый Telegram-аккаунт)**:
|
||||
1. «Каналы → Поиск» → создать задачу (описание, план 3, авто-вступление выкл) → «Сгенерировать ключи» → запустить.
|
||||
2. Дождаться кандидатов; проверить, что текущие подписки и отклонённые не появляются.
|
||||
3. Открыть кандидата: метки, участники, fit «X из N», темы форума.
|
||||
4. «Вступить и мониторить» → источник появляется в «Каналах» (monitor on) и начинает давать карточки.
|
||||
5. «Отклонить» → уходит в чёрный список; повторно не находится.
|
||||
6. Включить авто-вступление: проверить паузы (≥50 с) и расход суточного лимита.
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
- **Покрытие спеки:** Task 1 (хранилище+настройки), Task 2 (квоты/анти-бан), Task 3 (задачи/бюджет планов/чёрный список), Task 4 (поиск/инфо/чтение/join), Task 5 (язык/темы/fit), Task 6 (воркер+авто-join), Task 7 (API), Task 8–9 (UI), Task 10 (ТЗ+E2E). Правило «мы не состоим» — Task 3 `add_candidate`, Task 6 шаг 3 (повторная проверка перед join), UI Task 9. Разделы спеки §4–§12 покрыты; «вне рамок» (§13) не реализуются.
|
||||
- **Плейсхолдеры:** нет; у каждого шага есть конкретный код/поведение и способ проверки.
|
||||
- **Согласованность:** единые статусы задач `draft|running|paused|done|failed`, кандидатов `new|review|joined|rejected`; события лога `search|found|skip|eval|review|join_auto|join_manual|leave|reject|flood|error|done`; все имена настроек и функций совпадают между задачами.
|
||||
@@ -0,0 +1,173 @@
|
||||
# Дейл (Deal) — Roadmap этапов (все этапы 0–7 выполнены; 2026-09-08)
|
||||
|
||||
> Исторический документ (roadmap этапов 0–7, 2026-09-08). Актуальное состояние — `docs/superpowers/STATUS.md` и `docs/technical/Техническая-документация-Дейл.md`.
|
||||
|
||||
> Назначение: зафиксировать план продолжения разработки «Дейл» по этапам. Каждый этап исполняется
|
||||
> как отдельный SDD-план (файл в `docs/superpowers/plans/`, ledger в `.superpowers/sdd/<plan>/`),
|
||||
> задача-за-задачей с ревью. Проект НЕ git — фиксация в отчётах и ledgers.
|
||||
|
||||
## Выполнено
|
||||
|
||||
- **Этап 0 — Каркас** (`2026-09-05-deal-scaffold.md`): структура `src/`, фронт переехал в `src/frontend/`,
|
||||
`.editorconfig`+`Directory.Build.props`, `Deal.sln` (11 проектов), тесты, dev-Postgres (:5433),
|
||||
tenant-контекст (`TenantId`/`ITenantContext`/`TenantContext` AsyncLocal/`ConnectionStringProvider` search_path),
|
||||
EF (public), `TenantSchemaMigrator`, CI-скрипты.
|
||||
- **Этап 1 — Доступ и мультитенантность** (`2026-09-05-deal-stage1-tenancy.md`): карта `/api`
|
||||
(`docs/api/api-map.md`, 101 эндпоинт, 87 использует фронт), два DbContext (системный `public` + бессхемный
|
||||
tenant: таблица `settings`), миграции InitialSystem/InitialTenant, модуль Tenants (порты+адаптеры,
|
||||
Argon2id, сессии 30 дней), auth-эндпоинты 1:1 (login/logout/me/change-password), SessionMiddleware +
|
||||
`ITenantContext.Reset`, `TenantProvisioningService` (схема `tenant_<32hex>` + Migrate c
|
||||
`MigrationsHistoryTable("__TenantMigrationsHistory", schema)`), `TenantBootstrapService` (seed: тенант
|
||||
id `000…001` + admin/admin из env; идемпотентно). 25 тестов PASS.
|
||||
- **Этап 2 — Settings (настройки тенанта)** (`2026-09-05-deal-stage2-settings.md`): модуль
|
||||
`Deal.Modules.Settings` (каталог ключей/дефолты 1:1 с прототипом, ISettingsStore, SettingsService
|
||||
снимок+PATCH 1:1, IncomingRules, PromptFiller, RatesService) + адаптеры (SettingsStore на `settings`,
|
||||
AesGcmSecretCipher), эндпоинты GET/PATCH `/api/settings`, POST `/api/ai/check`, GET `/api/rates`,
|
||||
POST `/api/rates/refresh`, `/api/ml/*` (заглушка LocalMlClient), POST `/api/admin/check-message`
|
||||
(тестер фильтров). Секреты AI/Telegram — AES-GCM (`enc:` в БД, ключ env `DEAL_ENCRYPTION_KEY`/файл).
|
||||
Задачи 1–11 приняты: 175 unit-тестов PASS, build 0/0, сквозная curl-приёмка :5080 PASS=60 FAIL=0 +
|
||||
psql (шифрование, внутренние ключи не публикуются). **Ограничение:** Settings-экран обслуживается
|
||||
бэкендом, но Vue-фронт полностью оживает только с этапом 3 (его `boot()` требует `/api/boards`,
|
||||
`/api/leads`, `/api/projects`, `/api/tg/status`, `/api/columns/state`; Telegram-вкладка, кнопки
|
||||
«Проверить правила сейчас»/«Пересобрать индекс», «Предложить ключи» и канбан-фронт — этапы 3–6).
|
||||
|
||||
- **Этап 3 — Kanban (дашборд): колонки, карточки, архив/корзина** (`2026-09-05-deal-stage3-kanban.md`):
|
||||
миграция TenantKanban (Boards/Cards/LeadComments/CardMoves/MlOutbox в схеме тенанта), модуль
|
||||
`Deal.Modules.Kanban` (доски/карточки/правила `ColumnRules` с matchHits, StorageTickService +
|
||||
фоновый StorageTickScheduler 30 с, ConversionRecomputer, демо-фабрика, эвристика ИИ-предложений),
|
||||
эндпоинты boards/columns/leads/search/events(SSE)/admin/demo/ai-suggest, boot-заглушки /projects и
|
||||
/tg/status, LocalMlClient+PushAsync. Задачи 1–15 приняты: 410 unit-тестов PASS, build 0/0, сквозная
|
||||
curl-приёмка :5080 PASS=94 FAIL=0 + psql. **Ограничения:** pipeline/очередь/отсев/FTS — этап 4;
|
||||
projects/файлы/reminder_due — этап 5; реальные ai/telegram/ml и discovery — этап 6; reclassify и
|
||||
/admin/fts/rebuild — контракт-заглушки. Фронт теперь boot'ится полностью и канбан-дашборд работает
|
||||
на демо-данных (реальные данные появятся с pipeline этапа 4).
|
||||
- **Этап 4 — Pipeline + вкладка «Обработка»** (`2026-09-05-deal-stage4-pipeline.md`): миграция
|
||||
TenantPipeline (QueueItems/RejectedItems/DedupEntries + FTS-колонки SearchTsv на Cards/RejectedItems),
|
||||
модуль `Deal.Modules.Pipeline` (ядро разбора 1:1, Ingest/ProcessingService/PipelineWorkerService
|
||||
pump 1:1, CardComposer через `IKanjStore.AddCardAsync`, dedup-связь), порт `IAiClassifier` +
|
||||
детерминированный `LocalAiClassifier`, эндпоинты `/api/pipeline/*` + демо-ingest, реальные
|
||||
admin/tick и admin/fts/rebuild (+SSE new_lead/тост очистки отсева), фоновые PipelineWorkerScheduler
|
||||
(2 с) и purge-отсева 3 дня в StorageTickScheduler, FTS-поиск `/api/search`. Задачи 1–13 приняты:
|
||||
**535 unit-тестов PASS**, build 0/0, сквозная curl-приёмка :5080 (Task 13 — финал) PASS=74 FAIL=0
|
||||
+ psql. **Ограничения:** projects/reminder_due/файлы — этап 5; реальные ai/telegram/ml-сервисы и
|
||||
их gRPC-ингресс, discovery, ИИ-предложения колонок на реальных данных — этап 6; оператор/лимиты/
|
||||
аудит — этап 7. Вкладка «Обработка» и канбан-дашборд работают на реальном конвейере (демо-ingest
|
||||
до telegram-этапа 6).
|
||||
- **Этап 5 — Projects («Выбранные»): стадии, напоминания, файлы/ссылки, история**
|
||||
(`2026-09-05-deal-stage5-projects.md`): миграция TenantProjects (`ProjectCards` с partial UNIQUE
|
||||
LeadId), модуль `Deal.Modules.Projects` (стадии ProjectStages 1:1, ProjectsService: take из дашборда с
|
||||
уходом лида в `col='taken'`/ручное создание/patch presence-aware/move+история/clear-rejected/
|
||||
комментарии/ссылки; ProjectReminderService; ProjectFilesService), порт `IFileStorage` + адаптеры
|
||||
`LocalFileStorage`/`MinioFileStorage` (deal-minio в compose.dev.yml), эндпоинты `/api/projects*`
|
||||
(16 шт., файлы и напоминания включены), boot-заглушка /projects снята, напоминания в admin/tick +
|
||||
фоновый 30-с цикл StorageTickScheduler + SSE `reminder_due`. Задачи 1–13 приняты: **620 unit-тестов
|
||||
PASS**, build 0/0, сквозная curl-приёмка :5080 (Task 13 — финал) PASS=75 FAIL=0 + psql (take-
|
||||
семантика, история, clear-rejected, UNIQUE LeadId, файлы на диске, reminder_due фоновым циклом).
|
||||
**Ограничения:** реальные ai/telegram/ml-сервисы и их gRPC-ингресс, discovery, telegram-вкладка и
|
||||
`/tg/status` — этап 6; оператор/лимиты/админка, мульти-аренда MinIO-бакетов — этап 7.
|
||||
- **Этап 6 — Сервисы telegram/ai/ml (отдельные процессы) + Discovery** (`2026-09-05-deal-stage6-services.md`):
|
||||
gRPC-контракты в `src/contracts/*.proto` (общий `Deal.Proto`); три автономных процесса — telegram-service
|
||||
(:5101: ферма сессий 1 акк/тенант, QR-вход, AES-GCM-сессии `/data/sessions`, диалоги/мониторинг/backfill
|
||||
с анти-бан-паузами), ai-service (:5102: LLM-фасад OpenAI-совместимых+Anthropic, Filter/Classify/
|
||||
GenerateKeywords/EvaluateFit, usage), ml-service (:5103: инкрементальный наивный Байес 1:1 с python
|
||||
`mlservice/model.py`, SQLite на тенанта); core — gRPC-ингресс telegram :5082 (PushMessage→очередь,
|
||||
SyncDialogs, ReportStatus→SSE), модуль Telegram (Dialogs/TgMessages) + эндпоинты /api/tg (14 шт., реальный
|
||||
статус, QR-SVG) вместо boot-заглушки, ai/ml-gRPC-адаптеры за флагами `Services:*:UseLocal` (код-дефолт
|
||||
Local, compose.dev.yml — false), MlOutboxFlushScheduler (10 с), модуль Discovery (воркер 5 с: поиск/
|
||||
каскад оценки/авто-join с квотами и бан-гардом; /api/discovery 13 шт.); полный dev-стек —
|
||||
`deploy/compose.dev.yml` (postgres/minio/3 сервиса/core, secrets, healthcheck), smoke-скрипт
|
||||
`scripts/dev-smoke.sh` (отложенный живой прогон — Docker Desktop был выключен). Задачи 1–20 приняты:
|
||||
**830 unit-тестов PASS**, 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-приёмки. Ledger:
|
||||
`.superpowers/sdd/deal-stage6-services/`. **Ручные проверки (с кредами):** Telegram-вход
|
||||
(api_id/api_hash/QR) и реальные LLM-вызовы; живой smoke `scripts/dev-smoke.sh` — после поднятия Docker.
|
||||
**Ограничения этапа 6 (переходят в этап 7):** mTLS-сертификаты и prod-compose; лимиты/бюджеты токенов
|
||||
(учёт `aiTokenUsage` уже есть); оператор/админка/аудит-поток; rate limiting gRPC; экспорт/импорт
|
||||
ML-моделей; ротация/бэкап ключей сессий; reclassify на реальном ИИ (контракт-заглушка остаётся).
|
||||
|
||||
- **Этап 7 — SaaS-контур (Tasks 1–16, 2026-09-08)** (`2026-09-05-deal-stage7-saas.md`): оператор
|
||||
(`public`-таблицы, кука `deal_operator_session`, bootstrap env, ручки `/api/operator/*` — API-only),
|
||||
инвайты + активация `POST /api/join`, лимиты ИИ-бюджета с fallback-декораторами и SSE-тостами,
|
||||
append-only аудит-поток, rate limiting (приложение + gRPC-ингресс + защита входа), Origin-проверка/
|
||||
security-заголовки/ForwardedHeaders, mTLS за флагом DEAL_MTLS_* + `scripts/mtls-certs.sh`, Serilog JSON
|
||||
во всех 4 процессах, prod-деплой `deploy/compose.prod.yml` (caddy 80/443, mTLS-env, healthcheck'и
|
||||
grpc_health_probe, профиль observability: promtail/loki/grafana) + `deploy/.env.prod.example`,
|
||||
бэкапы `scripts/backup.sh`/`restore.sh` (+ `deal-backup-lib.sh`). **Task 16 (финал)**: актуализированы
|
||||
техдок §5/§7–§11/§13 (фактический стек, Manual-пометки), api-map (раздел «Реализовано в Deal»),
|
||||
user-guide, STATUS.md (этапы 0–7 = 100%). Финальный прогон: core **1123/1123 PASS**, telegram 114/114,
|
||||
ai 50/50, ml 36/36 PASS, build 0/0 всех четырёх sln, `compose.prod.yml config` rc=0, `sh -n`
|
||||
скриптов rc=0. Ledger: `.superpowers/sdd/deal-stage7-saas/`.
|
||||
**Manual (нужен docker/живые креды):** применение system-миграции + сквозная SaaS-curl-приёмка,
|
||||
подъём compose.prod и dev-smoke `scripts/dev-smoke.sh`, mTLS-рукопожатие контейнеров, реальные
|
||||
Telegram/LLM-вызовы, прогон `scripts/backup.sh` и restore-тест — чек-лист в task-16-report.md.
|
||||
|
||||
## Эталонные конвенции (уже в коде — их придерживаться дальше)
|
||||
|
||||
- Модуль = чистый проект (SharedKernel/Contracts): порты (интерфейсы) + record-DTO, без EF.
|
||||
Регистрация: `AddTenantsModule()` (модуль), адаптеры EF — в `Deal.Infrastructure` через
|
||||
`AddDealPersistence()` (scoped). HTTP-эндпоинты — в `Deal.Api/Endpoints/*` (`MapXxxEndpoints`).
|
||||
- Два EF-контекста: системный (public, явная схема) и tenant (бессхемный; новые таблицы модулей —
|
||||
DbSet в `TenantDbContext` + `dotnet ef migrations add X --context TenantDbContext`; применяются
|
||||
провижинером ко всем схемам). Ошибки API — `{detail}`; JSON camelCase; кука `deal_session`.
|
||||
- Константы/настройки: `IOptions<T>`; без магических чисел; 1 тип=1 файл; XML-doc на public.
|
||||
|
||||
## Следующие этапы (после этапов 0–7; порядок из архитектуры §12.5)
|
||||
|
||||
> **Актуальный источник отложенного и техдолга — `backlog.md` в корне** (роудмап черпается оттуда).
|
||||
> Ниже — историческая секция роудмапа.
|
||||
|
||||
> Этапы 0–7 выполнены (см. «Выполнено»). Ниже — следующие инкременты: заделы этапа 7 (сознательно
|
||||
> вынесены, подробно — техдок §11) и пункты архитектуры, не входившие в этапы.
|
||||
|
||||
### Этап 8+ — следующие инкременты (заделы этапа 7, подробно — техдок §11):
|
||||
- **Этапы 8–10 выполнены** (2026-09-10): ревью/качество; единая карточка (unified card);
|
||||
оператор-консоль + активация инвайта (UI) + аудит действий и аналитика расхода токенов + ELK/Loki-дашборды.
|
||||
- Остаются заделы: OTel-метрики/Prometheus и дашборды метрик (сейчас Serilog-логи → Loki);
|
||||
multi-instance rate-limit и бэкенд попыток входа; экспорт/импорт ML-моделей; reclassify на реальном ИИ;
|
||||
мультиаккаунтность Telegram; биллинг/планы; k8s/Cloudflare-конфигурация; purge-автоматика audit_log.
|
||||
|
||||
### Этап 11 — Локализация интерфейса (i18n)
|
||||
|
||||
**Требование владельца (2026-09-10).** Весь интерфейс — на русском; все тексты вынесены в ресурсы,
|
||||
чтобы можно было добавлять новые языки и менять язык **на лету**.
|
||||
|
||||
- **Русский — язык по умолчанию.** Все пользовательские строки UI (экраны, кнопки, подписи, пустые
|
||||
состояния, подсказки, подтверждения, уведомления/тосты, страницы оператора и активации) — на русском.
|
||||
- **Никакого хардкода строк в компонентах.** Все тексты — в словарях ресурсов (ключ → значение),
|
||||
включая сообщения об ошибках, которые сейчас формируются на бэке (`{detail}`), — они должны быть
|
||||
локализуемы (ключ + параметры) или переводимы по коду.
|
||||
- **Переключение языка на лету**, без перезагрузки страницы; выбранный язык сохраняется (localStorage/настройки).
|
||||
- **Расширяемость:** добавление нового языка = новый файл словаря, без правок компонентов.
|
||||
- **Форматирование** дат/времени/чисел/валют — через i18n-форматтеры (не вручную), плюрализация —
|
||||
через правила языка.
|
||||
- Ключи — стабильные, сгруппированные по областям (nav/cards/settings/operator/…); отсутствующий
|
||||
ключ в языке → фолбэк на русский.
|
||||
- Бэк: ответы API остаются с `{detail}`/кодами; фронт отображает локализованный текст по коду/ключу
|
||||
(при необходимости — расширяемый словарь ошибок).
|
||||
|
||||
UI-область, к которой это применяется: основное приложение (дашборд, «Выбранные», настройки, каналы,
|
||||
обработка) и оператор-консоль (этап 10).
|
||||
|
||||
## Открытые точки согласования (накопились к концу этапа 1)
|
||||
|
||||
> Решения владельца (2026-09-06): 1 — бренд меняем (сделано точечно: index.html, LoginView, Sidebar, DiscoveryView, SettingsView); 2 — инвайты/оператор остаются на SaaS-этап, dev-seed admin/admin; 3 — PascalCase — конвенция БД; 4 — кука `deal_session` остаётся; 5 — заглушки сервисов допустимы (порты с детерминированными локальными реализациями до этапов 6+); 6 — идём по roadmap все этапы.
|
||||
|
||||
1. **Бренд во фронте**: Vue-фронт всё ещё показывает «LeadRadar» (LoginView, заголовки). Фронт
|
||||
«не трогаем» — но бренд теперь «Дейл». Менять ли строки бренда во фронте (точечно) или позже?
|
||||
2. **Инвайты/оператор**: ТЗ требует invite-only + отдельный вход оператора; во фронте такого UI нет.
|
||||
Оставляем dev-seed (admin/admin + дефолтный тенант) до этапа 7? Тогда auth остаётся «как прототип».
|
||||
3. **Имена колонок БД**: EF генерирует PascalCase (`UpdatedAt`), ТЗ/доки местами в SQL-нотации
|
||||
(snake_case). Оставляем PascalCase (конвенция кода) — подтвердить.
|
||||
4. **Имя куки** `deal_session` (прототип: `leadradar_session`) — фронту всё равно; ок?
|
||||
5. **Сервисы ml/ai/telegram**: полный ребилд — это самые крупные этапы. Подтвердить, что локально
|
||||
между этапами допустимы порты с детерминированными заглушками (пока сервисы не написаны), чтобы
|
||||
pipeline/kanban разрабатывать и показывать на синтетических входах.
|
||||
6. **Приоритет и темп**: продолжать строго по roadmap (Settings → Kanban → Pipeline → Projects →
|
||||
сервисы+Discovery → SaaS-контур) и в каком темпе/объёме на сессию.
|
||||
|
||||
**Статус на 2026-09-08 (этапы 0–7 завершены):** п.2 **закрыт** — инвайты/оператор реализованы в этапе 7
|
||||
(API-only: `/api/operator/*` + `/api/join`), dev-seed `admin/admin` остаётся dev-only (Ruling 1);
|
||||
UI операторской админки и страницы активации — задел этапа 8+. п.1 — бренд во фронте меняется точечно
|
||||
(как решено); п.3–5 — зафиксированы решениями (PascalCase, `deal_session`, локальные реализации портов)
|
||||
и не пересматривались; п.6 — roadmap исполнен: этапы 0–7 пройдены (STATUS.md — 100%, Manual-чек-лист
|
||||
вынесен отдельно).
|
||||
@@ -0,0 +1,825 @@
|
||||
# Дейл (Deal) — Этап 0: Каркас решения Implementation Plan
|
||||
|
||||
> Исторический документ этапа 0. Актуальное состояние — `docs/superpowers/STATUS.md` и `docs/technical/Техническая-документация-Дейл.md`.
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Создать каркас нового продукта «Дейл»: структуру `src/`, решение `core` (модульный монолит) с пустыми модулями, стандарты кода (.editorconfig + анализаторы), dev-Postgres со схемой на тенанта и tenant-контекст.
|
||||
|
||||
**Architecture:** Модульный монолит в `src/core` (один процесс, одно sln: `Deal.Api` + `Deal.Modules.*` + `Deal.SharedKernel` + `Deal.Infrastructure` + `Deal.Contracts`). Postgres: одна БД, системные таблицы в `public`, данные тенантов в `tenant_<id>.*`. Сервисы ml/ai/telegram — отдельные процессы со своими sln (создаются в этом этапе как пустые каталоги, наполняются позже). Фронтенд Vue переезжает как есть в `src/frontend`.
|
||||
|
||||
**Tech Stack:** .NET 10 (C#), ASP.NET Core (Web API + minimal), EF Core, Npgsql, xUnit, docker compose.
|
||||
|
||||
**Spec:** `docs/architecture/2026-09-05-deal-architecture-design.md` (разделы 2, 3, 4, 10, 11)
|
||||
**ТЗ:** `docs/spec/ТЗ-дейл-новая-архитектура.md` (разделы 3, 11)
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Проект **НЕ git-репозиторий** (рабочее дерево `C:\telbase`, деплой docker compose). Вместо коммитов фиксируем затронутые файлы и результат проверок в отчёте задачи. Рабочая папка плана: `.superpowers/sdd/deal-scaffold/`.
|
||||
- Решение собирается на .NET 10 SDK (установлен: `10.0.400`).
|
||||
- Код-стайл: `C:\telbase\Стиль_кода.docx` + адаптации: 1 тип = 1 файл; комментарии на русском; XML-doc только для public-контрактов; настройки через `IOptions<T>`; без snake_case-хелперов и регионов; public-члены — только свойства; явные модификаторы доступа.
|
||||
- Все имена: namespace `Deal.*`, проекты `Deal.*`, имя решения `Deal.sln`.
|
||||
- Каждый публичный тип — в отдельном файле, имя файла = имя типа.
|
||||
- Анализаторы: `Microsoft.CodeAnalysis.NetAnalyzers` включён; нарушения стиля — ошибки сборки (через `.editorconfig` severity).
|
||||
- Запрещено: секреты в коде/репозитории; конкатенация SQL; magic numbers.
|
||||
- Старый LeadRadar-код (`backend/`, `frontend/` верхнего уровня) не трогаем, кроме переноса `frontend/` → `src/frontend/`.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Структура src/ и перенос фронтенда
|
||||
|
||||
**Files:**
|
||||
- Create: `src/README.md`
|
||||
- Create: `README.md` (корневой, краткий)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: — (старт)
|
||||
- Produces: структура папок `src/{core, ml-service, ai-service, telegram-service, contracts, frontend}`; фронтенд перенесён в `src/frontend/`.
|
||||
|
||||
- [ ] **Step 1: Создать структуру каталогов**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
mkdir -p src/core src/ml-service src/ai-service src/telegram-service src/contracts
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Перенести фронтенд**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
mkdir -p src/frontend
|
||||
cp -r frontend/* src/frontend/ && rm -rf frontend
|
||||
```
|
||||
Expected: `src/frontend/` содержит package.json, src/, index.html и т.д.; старая папка `frontend/` удалена.
|
||||
|
||||
- [ ] **Step 3: Создать `src/README.md`**
|
||||
|
||||
```markdown
|
||||
# Дейл (Deal) — исходники
|
||||
|
||||
- `core/` — модульный монолит .NET (бизнес-логика, API)
|
||||
- `ml-service/` — ML (.NET + ONNX), отдельный процесс
|
||||
- `ai-service/` — LLM-фасад, отдельный процесс
|
||||
- `telegram-service/` — ферма сессий Telegram, отдельный процесс
|
||||
- `contracts/` — общие .proto (gRPC)
|
||||
- `frontend/` — Vue (переехал из LeadRadar как есть)
|
||||
|
||||
Подробности: `docs/architecture/2026-09-05-deal-architecture-design.md`
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Создать корневой `README.md`**
|
||||
|
||||
```markdown
|
||||
# Дейл (Deal)
|
||||
|
||||
SaaS-мониторинг Telegram: реальные заказы и клиенты вместо рекламы и дубликатов.
|
||||
|
||||
- Архитектура: `docs/architecture/2026-09-05-deal-architecture-design.md`
|
||||
- ТЗ: `docs/spec/ТЗ-дейл-новая-архитектура.md`
|
||||
- Техдок: `docs/technical/Техническая-документация-Дейл.md`
|
||||
- Исходники: `src/`
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Проверить**
|
||||
|
||||
Run: `ls src/` — 6 папок; `ls src/frontend/` — файлы Vue-проекта; `test -f README.md && echo ok`.
|
||||
Expected: все проверки успешны.
|
||||
|
||||
- [ ] **Step 6: Зафиксировать в отчёте** `task-1-report.md` (файлы, результат проверок).
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Стандарты кода — .editorconfig, Directory.Build.props
|
||||
|
||||
**Files:**
|
||||
- Create: `.editorconfig`
|
||||
- Create: `src/core/Directory.Build.props`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: единые правила для всех проектов `src/core`; нарушения — ошибки сборки.
|
||||
|
||||
- [ ] **Step 1: Создать корневой `.editorconfig`**
|
||||
|
||||
```editorconfig
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = crlf
|
||||
insert_final_newline = true
|
||||
indent_style = space
|
||||
indent_size = 4
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.{cs,vb}]
|
||||
indent_size = 4
|
||||
|
||||
# Стиль фигурных скобок — Allman (на отдельной строке)
|
||||
csharp_new_line_before_open_brace = all
|
||||
csharp_new_line_before_else = true
|
||||
csharp_new_line_before_catch = true
|
||||
csharp_new_line_before_finally = true
|
||||
|
||||
# using — в начале файла
|
||||
dotnet_sort_system_directives_first = true
|
||||
|
||||
# Модификаторы доступа — всегда явные
|
||||
dotnet_style_require_accessibility_modifiers = always:error
|
||||
|
||||
# this. — не требуется
|
||||
dotnet_style_qualification_for_field = false:silent
|
||||
dotnet_style_qualification_for_property = false:silent
|
||||
dotnet_style_qualification_for_method = false:silent
|
||||
|
||||
# Члены
|
||||
csharp_style_var_for_built_in_types = false:silent
|
||||
csharp_style_var_when_type_is_apparent = false:silent
|
||||
csharp_style_var_elsewhere = false:silent
|
||||
|
||||
[*.cs]
|
||||
# Отключить лишние правила IDE, которые конфликтуют с код-стайлом проекта
|
||||
dotnet_diagnostic.IDE0290.severity = none
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Создать `src/core/Directory.Build.props`**
|
||||
|
||||
```xml
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<AnalysisLevel>latest</AnalysisLevel>
|
||||
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.CodeAnalysis.NetAnalyzers" Version="9.0.0">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Зафиксировать в отчёте** (проверка сборки — после Task 3).
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Решение Deal.sln и пустые проекты core
|
||||
|
||||
**Files:**
|
||||
- Create: `src/core/Deal.sln`
|
||||
- Create: `src/core/Deal.Api/Deal.Api.csproj` + `Program.cs`
|
||||
- Create: `src/core/Deal.Modules.Pipeline/`, `...Kanban/`, `...Projects/`, `...Discovery/`, `...Settings/`, `...Tenants/` (csproj + класс-маркер)
|
||||
- Create: `src/core/Deal.SharedKernel/`, `Deal.Infrastructure/`, `Deal.Contracts/` (csproj + маркер)
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: собираемое решение; проекты-модули, готовые к наполнению в следующих этапах.
|
||||
|
||||
- [ ] **Step 1: Создать решение и проекты командой**
|
||||
|
||||
```bash
|
||||
cd /c/telbase/src/core
|
||||
dotnet new sln -n Deal
|
||||
dotnet new web -n Deal.Api -o Deal.Api --no-https
|
||||
dotnet new classlib -n Deal.Modules.Pipeline -o Deal.Modules.Pipeline
|
||||
dotnet new classlib -n Deal.Modules.Kanban -o Deal.Modules.Kanban
|
||||
dotnet new classlib -n Deal.Modules.Projects -o Deal.Modules.Projects
|
||||
dotnet new classlib -n Deal.Modules.Discovery -o Deal.Modules.Discovery
|
||||
dotnet new classlib -n Deal.Modules.Settings -o Deal.Modules.Settings
|
||||
dotnet new classlib -n Deal.Modules.Tenants -o Deal.Modules.Tenants
|
||||
dotnet new classlib -n Deal.SharedKernel -o Deal.SharedKernel
|
||||
dotnet new classlib -n Deal.Infrastructure -o Deal.Infrastructure
|
||||
dotnet new classlib -n Deal.Contracts -o Deal.Contracts
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Добавить проекты в решение**
|
||||
|
||||
```bash
|
||||
dotnet sln Deal.sln add Deal.Api Deal.Modules.Pipeline Deal.Modules.Kanban Deal.Modules.Projects Deal.Modules.Discovery Deal.Modules.Settings Deal.Modules.Tenants Deal.SharedKernel Deal.Infrastructure Deal.Contracts
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Удалить Class1.cs и добавить маркеры модулей**
|
||||
|
||||
Каждый модуль получает публичный маркер-класс (1 тип = 1 файл), например `Deal.Modules.Pipeline/PipelineModuleMarker.cs`:
|
||||
|
||||
```csharp
|
||||
namespace Deal.Modules.Pipeline;
|
||||
|
||||
/// <summary>Маркер модуля Pipeline: используется для DI-сканирования и тестов.</summary>
|
||||
public sealed class PipelineModuleMarker
|
||||
{
|
||||
}
|
||||
```
|
||||
|
||||
Аналогично для всех модулей и Infrastructure/SharedKernel/Contracts (маркеры: `InfrastructureMarker`, `SharedKernelMarker`, `ContractsMarker`).
|
||||
|
||||
- [ ] **Step 4: Ссылки между проектами (минимальные, по дизайн-доку)**
|
||||
|
||||
```bash
|
||||
dotnet add Deal.Api reference Deal.SharedKernel Deal.Contracts Deal.Infrastructure
|
||||
dotnet add Deal.Modules.Pipeline reference Deal.SharedKernel Deal.Contracts
|
||||
dotnet add Deal.Modules.Kanban reference Deal.SharedKernel Deal.Contracts
|
||||
dotnet add Deal.Modules.Projects reference Deal.SharedKernel Deal.Contracts
|
||||
dotnet add Deal.Modules.Discovery reference Deal.SharedKernel Deal.Contracts
|
||||
dotnet add Deal.Modules.Settings reference Deal.SharedKernel Deal.Contracts
|
||||
dotnet add Deal.Modules.Tenants reference Deal.SharedKernel Deal.Contracts
|
||||
dotnet add Deal.Infrastructure reference Deal.SharedKernel Deal.Contracts
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Минимальный Program.cs в Deal.Api (health)**
|
||||
|
||||
```csharp
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
app.MapGet("/api/health", () => Results.Ok(new { ok = true, service = "deal" }));
|
||||
|
||||
app.Run();
|
||||
|
||||
public partial class Program
|
||||
{
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Собрать решение**
|
||||
|
||||
Run: `dotnet build Deal.sln`
|
||||
Expected: Build succeeded, 0 warnings, 0 errors.
|
||||
|
||||
- [ ] **Step 7: Проверить health локально**
|
||||
|
||||
Run: `dotnet run --project Deal.Api --urls http://localhost:5080` (в фоне), затем `curl http://localhost:5080/api/health`
|
||||
Expected: `{"ok":true,"service":"deal"}` (процесс остановить после проверки).
|
||||
|
||||
- [ ] **Step 8: Зафиксировать в отчёте** `task-3-report.md`.
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Тесты — xUnit-каркас
|
||||
|
||||
**Files:**
|
||||
- Create: `src/core/tests/Deal.Tests.Unit/Deal.Tests.Unit.csproj`
|
||||
- Test: `src/core/tests/Deal.Tests.Unit/MarkerTests.cs`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: маркеры модулей из Task 3.
|
||||
- Produces: тестовый проект, подключённый к решению.
|
||||
|
||||
- [ ] **Step 1: Создать тестовый проект**
|
||||
|
||||
```bash
|
||||
cd /c/telbase/src/core
|
||||
dotnet new xunit -n Deal.Tests.Unit -o tests/Deal.Tests.Unit
|
||||
dotnet sln Deal.sln add tests/Deal.Tests.Unit
|
||||
dotnet add tests/Deal.Tests.Unit reference Deal.SharedKernel Deal.Modules.Pipeline Deal.Modules.Kanban Deal.Modules.Projects Deal.Modules.Discovery Deal.Modules.Settings Deal.Modules.Tenants
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Написать тест на маркеры модулей**
|
||||
|
||||
`tests/Deal.Tests.Unit/MarkerTests.cs`:
|
||||
|
||||
```csharp
|
||||
using Deal.Modules.Pipeline;
|
||||
|
||||
namespace Deal.Tests.Unit;
|
||||
|
||||
public sealed class MarkerTests
|
||||
{
|
||||
[Fact]
|
||||
public void PipelineModuleMarker_IsPublicAndSealed()
|
||||
{
|
||||
Assert.True(typeof(PipelineModuleMarker).IsPublic);
|
||||
Assert.True(typeof(PipelineModuleMarker).IsSealed);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Запустить тесты**
|
||||
|
||||
Run: `dotnet test tests/Deal.Tests.Unit`
|
||||
Expected: 1 тест PASS.
|
||||
|
||||
- [ ] **Step 4: Зафиксировать в отчёте** `task-4-report.md`.
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Dev-Postgres в docker compose (схема на тенанта)
|
||||
|
||||
**Files:**
|
||||
- Create: `deploy/compose.dev.yml`
|
||||
- Create: `deploy/.env.example`
|
||||
- Modify: `README.md` (инструкция запуска dev-БД)
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: dev-контейнер Postgres 16; БД `deal`; схема `public` готова к миграциям.
|
||||
|
||||
- [ ] **Step 1: Создать `deploy/compose.dev.yml`**
|
||||
|
||||
```yaml
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
container_name: deal-postgres
|
||||
environment:
|
||||
POSTGRES_DB: deal
|
||||
POSTGRES_USER: deal
|
||||
POSTGRES_PASSWORD: deal_dev_password
|
||||
ports:
|
||||
- "5433:5432" # 5432 может быть занят LeadRadar-стеком
|
||||
volumes:
|
||||
- deal_pgdata:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U deal -d deal"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 10
|
||||
|
||||
volumes:
|
||||
deal_pgdata:
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Создать `deploy/.env.example`**
|
||||
|
||||
```
|
||||
DEAL_PG_HOST=localhost
|
||||
DEAL_PG_PORT=5433
|
||||
DEAL_PG_DB=deal
|
||||
DEAL_PG_USER=deal
|
||||
DEAL_PG_PASSWORD=deal_dev_password
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Поднять контейнер**
|
||||
|
||||
Run: `docker compose -f deploy/compose.dev.yml up -d`
|
||||
Expected: `deal-postgres` running, healthy.
|
||||
|
||||
- [ ] **Step 4: Проверить подключение**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
docker exec deal-postgres psql -U deal -d deal -c "SELECT current_database(), current_schema();"
|
||||
```
|
||||
Expected: `deal | public`
|
||||
|
||||
- [ ] **Step 5: Дополнить README.md разделом «Запуск dev-окружения»**
|
||||
|
||||
```markdown
|
||||
## Запуск dev-окружения
|
||||
|
||||
Postgres (схема на тенанта): `docker compose -f deploy/compose.dev.yml up -d`
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Зафиксировать в отчёте** `task-5-report.md`.
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Tenant-контекст и подключение к Postgres
|
||||
|
||||
**Files:**
|
||||
- Create: `src/core/Deal.SharedKernel/Tenants/TenantId.cs`
|
||||
- Create: `src/core/Deal.SharedKernel/Tenants/ITenantContext.cs`
|
||||
- Create: `src/core/Deal.Infrastructure/Data/TenantContext.cs`
|
||||
- Create: `src/core/Deal.Infrastructure/Data/ConnectionStringProvider.cs`
|
||||
- Modify: `Deal.Api/Program.cs`
|
||||
- Test: `tests/Deal.Tests.Unit/TenantIdTests.cs`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces:
|
||||
- `TenantId` — readonly record struct, обёртка над строкой.
|
||||
- `ITenantContext` — `TenantId? TenantId { get; }`, `bool HasTenant { get; }`, `string? SchemaName { get; }`.
|
||||
- `TenantContext` — реализация на AsyncLocal.
|
||||
- `ConnectionStringProvider` — строка подключения с `search_path`.
|
||||
|
||||
- [ ] **Step 1: `TenantId.cs` (1 тип = 1 файл)**
|
||||
|
||||
```csharp
|
||||
namespace Deal.SharedKernel.Tenants;
|
||||
|
||||
/// <summary>Идентификатор тенанта. Инвариант: непустой.</summary>
|
||||
public readonly record struct TenantId(string Value)
|
||||
{
|
||||
public string Value { get; } = string.IsNullOrWhiteSpace(Value)
|
||||
? throw new ArgumentException("TenantId не может быть пустым", nameof(Value))
|
||||
: Value;
|
||||
|
||||
/// <summary>Имя схемы Postgres для тенанта.</summary>
|
||||
public string SchemaName => $"tenant_{Value}";
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Тест `TenantIdTests.cs`**
|
||||
|
||||
```csharp
|
||||
using Deal.SharedKernel.Tenants;
|
||||
|
||||
namespace Deal.Tests.Unit;
|
||||
|
||||
public sealed class TenantIdTests
|
||||
{
|
||||
[Fact]
|
||||
public void SchemaName_PrefixesTenant()
|
||||
{
|
||||
var id = new TenantId("abc123");
|
||||
Assert.Equal("tenant_abc123", id.SchemaName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TenantId_Empty_Throws()
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() => new TenantId(""));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Запустить тесты**
|
||||
|
||||
Run: `dotnet test tests/Deal.Tests.Unit`
|
||||
Expected: 3 теста PASS.
|
||||
|
||||
- [ ] **Step 4: `ITenantContext.cs`**
|
||||
|
||||
```csharp
|
||||
namespace Deal.SharedKernel.Tenants;
|
||||
|
||||
/// <summary>Контекст текущего тенанта запроса.</summary>
|
||||
public interface ITenantContext
|
||||
{
|
||||
TenantId? TenantId { get; }
|
||||
|
||||
bool HasTenant { get; }
|
||||
|
||||
/// <summary>Имя схемы текущего тенанта или null для системного контекста (public).</summary>
|
||||
string? SchemaName { get; }
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: `TenantContext.cs` (реализация в Infrastructure)**
|
||||
|
||||
```csharp
|
||||
using Deal.SharedKernel.Tenants;
|
||||
|
||||
namespace Deal.Infrastructure.Data;
|
||||
|
||||
/// <summary>Контекст тенанта на AsyncLocal: пробрасывается через весь запрос.</summary>
|
||||
public sealed class TenantContext : ITenantContext
|
||||
{
|
||||
private static readonly AsyncLocal<TenantId?> Current = new();
|
||||
|
||||
public TenantId? TenantId => Current.Value;
|
||||
|
||||
public bool HasTenant => Current.Value is not null;
|
||||
|
||||
public string? SchemaName => Current.Value?.SchemaName;
|
||||
|
||||
public void SetTenant(TenantId tenantId) => Current.Value = tenantId;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 6: `ConnectionStringProvider.cs`**
|
||||
|
||||
```csharp
|
||||
using Deal.SharedKernel.Tenants;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace Deal.Infrastructure.Data;
|
||||
|
||||
/// <summary>Строит строку подключения к Postgres с учётом схемы тенанта.</summary>
|
||||
public sealed class ConnectionStringProvider
|
||||
{
|
||||
private readonly string _baseConnectionString;
|
||||
|
||||
public ConnectionStringProvider(IConfiguration configuration)
|
||||
{
|
||||
_baseConnectionString = configuration.GetConnectionString("DealPostgres")
|
||||
?? throw new InvalidOperationException("ConnectionStrings:DealPostgres не задан");
|
||||
}
|
||||
|
||||
/// <summary>Строка подключения; при tenantId не null добавляет search_path к схеме тенанта.</summary>
|
||||
public string ForTenant(TenantId? tenantId)
|
||||
{
|
||||
if (tenantId is null)
|
||||
{
|
||||
return _baseConnectionString;
|
||||
}
|
||||
|
||||
return $"{_baseConnectionString};Search Path={tenantId.Value.SchemaName}";
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Подключить в `Program.cs` (DI)**
|
||||
|
||||
```csharp
|
||||
using Deal.Infrastructure.Data;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
builder.Services.AddSingleton<ITenantContext, TenantContext>();
|
||||
builder.Services.AddSingleton<ConnectionStringProvider>();
|
||||
|
||||
var app = builder.Build();
|
||||
```
|
||||
|
||||
(недостающие `using Deal.SharedKernel.Tenants;` добавить по месту)
|
||||
|
||||
- [ ] **Step 8: Собрать и прогнать тесты**
|
||||
|
||||
Run: `dotnet build Deal.sln && dotnet test tests/Deal.Tests.Unit`
|
||||
Expected: build 0 ошибок, тесты PASS.
|
||||
|
||||
- [ ] **Step 9: Зафиксировать в отчёте** `task-6-report.md`.
|
||||
|
||||
---
|
||||
|
||||
### Task 7: EF Core + миграции (public)
|
||||
|
||||
**Files:**
|
||||
- Create: `src/core/Deal.Infrastructure/Persistence/DealDbContext.cs`
|
||||
- Create: `src/core/Deal.Infrastructure/Persistence/Entities/TenantEntity.cs`
|
||||
- Create: `src/core/Deal.Infrastructure/Persistence/DealDbDesignTimeFactory.cs`
|
||||
- Modify: `Deal.Api/Program.cs` (регистрация DbContext)
|
||||
- Test: `tests/Deal.Tests.Unit/TenantEntityTests.cs`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces:
|
||||
- `DealDbContext` — базовый DbContext; системная сущность Tenant в схеме `public`.
|
||||
- Миграция `InitialPublic`, применённая к `public`.
|
||||
|
||||
- [ ] **Step 1: Добавить EF Core пакеты в Infrastructure**
|
||||
|
||||
```bash
|
||||
cd /c/telbase/src/core
|
||||
dotnet add Deal.Infrastructure package Microsoft.EntityFrameworkCore
|
||||
dotnet add Deal.Infrastructure package Npgsql.EntityFrameworkCore.PostgreSQL
|
||||
dotnet add Deal.Infrastructure package Microsoft.EntityFrameworkCore.Design
|
||||
```
|
||||
|
||||
- [ ] **Step 2: `TenantEntity.cs` (в `Deal.Infrastructure/Persistence/Entities/`)**
|
||||
|
||||
```csharp
|
||||
namespace Deal.Infrastructure.Persistence.Entities;
|
||||
|
||||
/// <summary>Тенант в системной схеме public.</summary>
|
||||
public sealed class TenantEntity
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
public string Status { get; set; } = "active";
|
||||
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: `DealDbContext.cs`**
|
||||
|
||||
```csharp
|
||||
using Deal.Infrastructure.Persistence.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Deal.Infrastructure.Persistence;
|
||||
|
||||
/// <summary>Базовый DbContext. Системные сущности — в схеме public.</summary>
|
||||
public sealed class DealDbContext(DbContextOptions<DealDbContext> options) : DbContext(options)
|
||||
{
|
||||
public DbSet<TenantEntity> Tenants => Set<TenantEntity>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.Entity<TenantEntity>(entity =>
|
||||
{
|
||||
entity.ToTable("tenants", "public");
|
||||
entity.HasKey(x => x.Id);
|
||||
entity.Property(x => x.Name).HasMaxLength(200).IsRequired();
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: `DealDbDesignTimeFactory.cs`**
|
||||
|
||||
```csharp
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Design;
|
||||
|
||||
namespace Deal.Infrastructure.Persistence;
|
||||
|
||||
/// <summary>Фабрика для dotnet-ef (миграции). Читает строку подключения из env.</summary>
|
||||
public sealed class DealDbDesignTimeFactory : IDesignTimeDbContextFactory<DealDbContext>
|
||||
{
|
||||
public DealDbContext CreateDbContext(string[] args)
|
||||
{
|
||||
var connectionString = Environment.GetEnvironmentVariable("DEAL_PG_CONNECTION")
|
||||
?? "Host=localhost;Port=5433;Database=deal;Username=deal;Password=deal_dev_password";
|
||||
var options = new DbContextOptionsBuilder<DealDbContext>()
|
||||
.UseNpgsql(connectionString)
|
||||
.Options;
|
||||
return new DealDbContext(options);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Регистрация DbContext в Program.cs**
|
||||
|
||||
```csharp
|
||||
using Deal.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
var connectionString = builder.Configuration.GetConnectionString("DealPostgres")
|
||||
?? "Host=localhost;Port=5433;Database=deal;Username=deal;Password=deal_dev_password";
|
||||
builder.Services.AddDbContext<DealDbContext>(options => options.UseNpgsql(connectionString));
|
||||
```
|
||||
|
||||
(в `appsettings.Development.json` положить `ConnectionStrings:DealPostgres`; в проде — из env)
|
||||
|
||||
- [ ] **Step 6: Создать `appsettings.Development.json` в Deal.Api**
|
||||
|
||||
```json
|
||||
{
|
||||
"ConnectionStrings": {
|
||||
"DealPostgres": "Host=localhost;Port=5433;Database=deal;Username=deal;Password=deal_dev_password"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Установить dotnet-ef tool и создать миграцию**
|
||||
|
||||
```bash
|
||||
dotnet tool install --global dotnet-ef
|
||||
cd /c/telbase/src/core
|
||||
dotnet ef migrations add InitialPublic --project Deal.Infrastructure --startup-project Deal.Api
|
||||
```
|
||||
|
||||
- [ ] **Step 8: Применить миграцию к public**
|
||||
|
||||
```bash
|
||||
dotnet ef database update --project Deal.Infrastructure --startup-project Deal.Api
|
||||
```
|
||||
|
||||
- [ ] **Step 9: Проверить таблицу**
|
||||
|
||||
```bash
|
||||
docker exec deal-postgres psql -U deal -d deal -c "\dt public.*"
|
||||
```
|
||||
Expected: таблицы `tenants`, `__EFMigrationsHistory`.
|
||||
|
||||
- [ ] **Step 10: Тест `TenantEntityTests.cs`**
|
||||
|
||||
```csharp
|
||||
using Deal.Infrastructure.Persistence.Entities;
|
||||
|
||||
namespace Deal.Tests.Unit;
|
||||
|
||||
public sealed class TenantEntityTests
|
||||
{
|
||||
[Fact]
|
||||
public void TenantEntity_Defaults_AreValid()
|
||||
{
|
||||
var entity = new TenantEntity();
|
||||
Assert.Equal("active", entity.Status);
|
||||
Assert.NotEqual(Guid.Empty, entity.Id == Guid.Empty ? Guid.Empty : entity.Id);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
(тест проверяет дефолты; при необходимости скорректировать под реальную модель)
|
||||
|
||||
- [ ] **Step 11: Зафиксировать в отчёте** `task-7-report.md`.
|
||||
|
||||
---
|
||||
|
||||
### Task 8: Применение миграций ко всем схемам тенантов
|
||||
|
||||
**Files:**
|
||||
- Create: `src/core/Deal.Infrastructure/Migrations/TenantSchemaMigrator.cs`
|
||||
- Test: `tests/Deal.Tests.Unit/TenantSchemaMigratorTests.cs`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `TenantId`.
|
||||
- Produces: `TenantSchemaMigrator` — чистые функции формирования SQL для схем тенантов.
|
||||
|
||||
- [ ] **Step 1: Написать тест**
|
||||
|
||||
`tests/Deal.Tests.Unit/TenantSchemaMigratorTests.cs`:
|
||||
|
||||
```csharp
|
||||
using Deal.Infrastructure.Migrations;
|
||||
|
||||
namespace Deal.Tests.Unit;
|
||||
|
||||
public sealed class TenantSchemaMigratorTests
|
||||
{
|
||||
[Fact]
|
||||
public void CreateSchemaSql_IsEscaped()
|
||||
{
|
||||
var sql = TenantSchemaMigrator.CreateSchemaSql("tenant_abc");
|
||||
Assert.Contains("CREATE SCHEMA IF NOT EXISTS \"tenant_abc\"", sql);
|
||||
Assert.DoesNotContain("; DROP", sql);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateSchemaSql_EscapesQuotes()
|
||||
{
|
||||
var sql = TenantSchemaMigrator.CreateSchemaSql("tenant_a\"b");
|
||||
Assert.DoesNotContain("\"b\"", sql);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: `TenantSchemaMigrator.cs`**
|
||||
|
||||
```csharp
|
||||
namespace Deal.Infrastructure.Migrations;
|
||||
|
||||
/// <summary>Миграции схем тенантов. Чистые функции формирования SQL.</summary>
|
||||
public static class TenantSchemaMigrator
|
||||
{
|
||||
/// <summary>SQL создания схемы тенанта. Имя экранируется (не интерполируется из ввода).</summary>
|
||||
public static string CreateSchemaSql(string schemaName)
|
||||
{
|
||||
var escaped = schemaName.Replace("\"", "\"\"");
|
||||
return $"CREATE SCHEMA IF NOT EXISTS \"{escaped}\"";
|
||||
}
|
||||
|
||||
/// <summary>Имена схем тенантов из БД.</summary>
|
||||
public static string ListTenantSchemasSql() =>
|
||||
"SELECT schema_name FROM information_schema.schemata WHERE schema_name LIKE 'tenant\\_%' ESCAPE '\\'";
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Запустить тесты**
|
||||
|
||||
Run: `dotnet test tests/Deal.Tests.Unit`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 4: Зафиксировать в отчёте** `task-8-report.md`.
|
||||
|
||||
---
|
||||
|
||||
### Task 9: CI-скрипты и финальная проверка этапа
|
||||
|
||||
**Files:**
|
||||
- Create: `scripts/build.sh`
|
||||
- Create: `scripts/test.sh`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: воспроизводимая сборка и тесты одной командой.
|
||||
|
||||
- [ ] **Step 1: `scripts/build.sh`**
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env sh
|
||||
set -e
|
||||
cd "$(dirname "$0")/../src/core"
|
||||
dotnet build Deal.sln
|
||||
```
|
||||
|
||||
- [ ] **Step 2: `scripts/test.sh`**
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env sh
|
||||
set -e
|
||||
cd "$(dirname "$0")/../src/core"
|
||||
dotnet test tests/Deal.Tests.Unit
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Прогнать оба скрипта**
|
||||
|
||||
Run: `sh scripts/build.sh && sh scripts/test.sh`
|
||||
Expected: build succeeded, все тесты PASS.
|
||||
|
||||
- [ ] **Step 4: Итоговая проверка этапа**
|
||||
|
||||
Run:
|
||||
- `dotnet build Deal.sln` — 0 ошибок, 0 предупреждений;
|
||||
- `dotnet test tests/Deal.Tests.Unit` — все PASS;
|
||||
- `docker ps` — `deal-postgres` healthy;
|
||||
- `curl http://localhost:5080/api/health` — `{"ok":true,"service":"deal"}`.
|
||||
|
||||
- [ ] **Step 5: Зафиксировать в отчёте** `task-9-report.md` + обновить `progress.md`.
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
**1. Spec coverage (дизайн-док):**
|
||||
- §2 (стратегия/структура) → Task 1, 3.
|
||||
- §3 (структура src/) → Task 1, 3.
|
||||
- §4 (мультитенантность: схема на тенанта, search_path) → Task 5, 6, 7, 8.
|
||||
- §10 (деплой compose) → Task 5.
|
||||
- §11 (стандарты: editorconfig, анализаторы, 1 тип = 1 файл) → Task 2, все задачи.
|
||||
- Frontend-перенос → Task 1.
|
||||
- Сервисы ml/ai/telegram — пустые каталоги (Task 1); их sln создаются в следующих этапах (вне scope этапа 0).
|
||||
- Auth/инвайты/лимиты — следующие этапы (вне scope «каркаса»).
|
||||
|
||||
**2. Placeholder scan:** код во всех шагах конкретный. Task 7 Step 10 — тест на дефолты TenantEntity упрощён, с пометкой скорректировать под реальную модель.
|
||||
|
||||
**3. Type consistency:** `TenantId`, `ITenantContext`, `TenantContext`, `ConnectionStringProvider`, `TenantSchemaMigrator`, `DealDbContext`, `TenantEntity` — имена и сигнатуры согласованы между задачами 6–8.
|
||||
|
||||
**Вне scope этапа 0:** auth/сессии, модули с бизнес-логикой, gRPC-сервисы, .proto, админка, observability, безопасность сервисов, лимиты — отдельные планы следующих этапов.
|
||||
@@ -0,0 +1,136 @@
|
||||
# Дейл (Deal) — Этап 1: Доступ, мультитенантность и фундамент данных Implementation Plan
|
||||
|
||||
> Исторический документ этапа 1. Актуальное состояние — `docs/superpowers/STATUS.md` и `docs/technical/Техническая-документация-Дейл.md`.
|
||||
|
||||
**Goal:** Сделать ядро «Дейл» способным принимать запросы реального фронта (Vue из LeadRadar):
|
||||
аутентификация (login/logout/me/change-password) на пользователях в `public`, сессии (httpOnly-cookie),
|
||||
tenant-контекст из сессии, физическая схема на тенанта с применением tenant-миграций, первая
|
||||
tenant-таблица (настройки). Этап закладывает паттерн для всех последующих модулей.
|
||||
|
||||
**Architecture:** модульный монолит `src/core`. HTTP-эндпоинты живут в `Deal.Api` (папка `Endpoints/`),
|
||||
вызывают сервисы модулей. Модули содержат домен и прикладные сервисы, НЕ содержат EF. Персистентность —
|
||||
в `Deal.Infrastructure` (два DbContext: системный `public` и tenant-схемы) + сущности/конфигурации
|
||||
модулей подключаются туда по одному соглашению (см. Ruling 1).
|
||||
|
||||
**Spec:** `docs/architecture/2026-09-05-deal-architecture-design.md` §4, 6.1, 8; `docs/spec/ТЗ-дейл-новая-архитектура.md` §3, 8 (частично); контракт: `docs/api/api-map.md` (auth); референс-семантика: `backend/app/auth.py`, `backend/app/routers/auth_routes.py`, `backend/app/main.py`, `backend/app/config.py`.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Проект **НЕ git**; фиксация — отчёты задач и `progress.md` плана. Рабочая папка плана: `.superpowers/sdd/deal-stage1-tenancy/`.
|
||||
- .NET 10 SDK, решение собирается с 0 warnings / 0 errors (TreatWarningsAsErrors).
|
||||
- Код-стайл: 1 тип = 1 файл; XML-doc для public-контрактов; комментарии на русском; явные модификаторы; настройки через `IOptions<T>`; без регионов и snake_case-хелперов.
|
||||
- namespace `Deal.*`. Секретов в коде нет (dev-пароль по умолчанию — только seed, из env `DEAL_BOOTSTRAP_*`).
|
||||
- Сущности тенантов — в схеме `tenant_<id>`; системные — в `public`. `tenantId` только из сессии, никогда из тела запроса.
|
||||
- LeadRadar-контейнеры и `backend/`, `mlservice/`, `src/frontend/` не трогаем. Dev-Postgres `deal-postgres` (:5433) — наша БД.
|
||||
|
||||
## Зафиксированные решения (Rulings этапа)
|
||||
|
||||
- **Ruling 1 (модель персистентности этапа):** сущности этапа 1 — в `Deal.Infrastructure/Persistence/Entities` (POCO, 1 тип = 1 файл), EF-конфигурации — в `Deal.Infrastructure/Persistence` (рядом с контекстами). Модули (`Deal.Modules.*`) НЕ содержат EF и НЕ ссылаются на Infrastructure: они объявляют интерфейсы своих хранилищ/сервисов и работают с record-DTO. Реализации интерфейсов — в Infrastructure (паттерн «port & adapter»). Это эталон для последующих модулей; когда у модуля появится богатая логика, его сущности переедут в модуль без изменения контрактов наружу.
|
||||
- **Ruling 2 (два контекста):** `DealDbContext` остаётся системным (схема `public`, явный `ToTable(...,"public")`;
|
||||
таблицы: tenants, users, sessions). Новый `TenantDbContext` — бессхемная модель (таблицы без указания схемы),
|
||||
живут в схеме через `search_path`. У `TenantDbContext` `MigrationsHistoryTable` получает ИМЯ
|
||||
`__TenantMigrationsHistory` и схему текущего тенанта на этапе применения (см. Ruling 3).
|
||||
- **Ruling 3 (применение tenant-миграций):** `TenantProvisioningService` для каждого тенанта: (1) создать схему
|
||||
`tenant_<id>` (SQL `TenantSchemaMigrator.CreateSchemaSql`), (2) открыть контекст на строке подключения с
|
||||
`Search Path=tenant_<id>` и `MigrationsHistoryTable("__TenantMigrationsHistory", "tenant_<id>")`, (3) `Database.Migrate()`.
|
||||
- **Ruling 4 (dev-сброс схемы):** в `public` уже применена `InitialPublic` (пустая таблица tenants — тестовые данные).
|
||||
Пересоздаём миграции системного контекста начисто: удаляем старую миграцию `InitialPublic`, создаём
|
||||
`InitialSystem` (tenants+users+sessions), дропаем и пересоздаём dev-БД (`deal-postgres`). Реальные данные отсутствуют.
|
||||
- **Ruling 5 (hash пароля):** Argon2id через пакет `Isopoh.Cryptography.Argon2` (чистый managed, без нативных
|
||||
зависимостей). Формат хранения — encoded-строка из `Argon2.Hash(password)`; проверка `Argon2.Verify`.
|
||||
- **Ruling 6 (сессии):** токен = 32 случайных байта (Base64Url); в БД хранится SHA-256 токена. Кука
|
||||
`deal_session`, httpOnly, SameSite=Lax, MaxAge=30 дней, `Secure` — из конфига (dev=false). Смена пароля
|
||||
удаляет все сессии пользователя и выдаёт свежую (семантика прототипа `auth.py`).
|
||||
- **Ruling 7 (эндпоинты):** минимальные API-эндпоинты живут в `Deal.Api/Endpoints/` (статик-классы `MapXxxEndpoints(this IEndpointRouteBuilder)`), делегируют в интерфейсы модулей. Конвенция для всех модулей.
|
||||
- **Ruling 7a (DTO модулей):** модуль объявляет record-DTO (папка `Application/Models`), сериализация наружу — camelCase (ASP.NET default); эндпоинты не видят EF-сущности.
|
||||
- **Ruling 8 (bootstrap/seed):** при старте, если нет тенантов: создаём дефолтного тенанта с ФИКСИРОВАННЫМ id `00000000-0000-0000-0000-000000000001` (схема `tenant_000...0001`, детерминирована) и пользователя `admin` (логин/пароль из env `DEAL_BOOTSTRAP_LOGIN/PASSWORD`, по умолчанию `admin`/`admin`) — повторяет `ensure_creds` прототипа. Seed идемпотентен. Провижининг схемы дефолтного тенанта — тем же `TenantProvisioningService`.
|
||||
- **Ruling 9 (первая tenant-таблица):** `settings` (модуль Settings): `key text PK`, `value_json text NOT NULL`,
|
||||
`updated_at timestamptz NOT NULL`. Без неё tenant-миграции нечего применять; таблица понадобится всем модулям.
|
||||
- **Ruling 10 (DTO/сериализация):** ответы — camelCase JSON (ASP.NET default); ошибки — HTTP-код + `{"detail": "..."}`
|
||||
(семантика FastAPI, см. `api.js`).
|
||||
|
||||
## Задачи
|
||||
|
||||
### Task 1: Карта API
|
||||
Выполнена (артефакт `docs/api/api-map.md`). В этом этапе используется секция Auth.
|
||||
|
||||
### Task 2: Персистентность — системный и tenant-контексты, миграции
|
||||
|
||||
**Files:**
|
||||
- Create: `src/core/Deal.Infrastructure/Persistence/TenantDbContext.cs`
|
||||
- Create: `src/core/Deal.Infrastructure/Persistence/Entities/UserEntity.cs`, `SessionEntity.cs`, `TenantSettingEntity.cs`
|
||||
- Create: `src/core/Deal.Infrastructure/Persistence/UserConfiguration.cs`, `SessionConfiguration.cs`, `TenantSettingConfiguration.cs`
|
||||
- Modify: `Deal.Infrastructure/Persistence/DealDbContext.cs` (добавить DbSet Users/Sessions)
|
||||
- Create: `src/core/Deal.Infrastructure/Persistence/TenantDbDesignTimeFactory.cs`
|
||||
- Delete: старая миграция `InitialPublic*` в `Deal.Infrastructure/Migrations/` (и `DealDbContextModelSnapshot.cs` — пересоздастся)
|
||||
- Migrations: `Migrations/InitialSystem` (контекст DealDbContext), `Migrations/InitialTenant` (контекст TenantDbContext) — обе в общей папке `Migrations/` (без `--output-dir`): имена классов миграций и снапшотов (`DealDbContextModelSnapshot`/`TenantDbContextModelSnapshot`) не конфликтуют.
|
||||
|
||||
**Acceptance:**
|
||||
1. `DealDbContext` (системный): `Tenants`, `Users`, `Sessions` в схеме `public` (явная схема в конфигурациях).
|
||||
2. `TenantDbContext`: модель без схемы, таблица `settings` (см. Ruling 9), `MigrationsHistoryTable` = `__TenantMigrationsHistory` (без схемы в модели; схема задаётся при применении).
|
||||
3. Сущности — в отдельных файлах (1 тип = 1 файл), конфигурации в отдельных файлах.
|
||||
4. Сборка: `dotnet build Deal.sln` — 0 warnings/0 errors.
|
||||
5. Dev-БД пересоздана: `public` содержит `tenants`, `users`, `sessions`, `__EFMigrationsHistory` (одна строка `InitialSystem`).
|
||||
6. Tenant-миграция `InitialTenant` существует и при применении к схеме создаёт там `settings` и историю — проверка через psql (применение выполняет Task 5; здесь достаточно `dotnet ef migrations list` и того, что SQL миграции не содержит схемы).
|
||||
7. Отчёт: `task-2-report.md`.
|
||||
|
||||
### Task 3: Модуль Tenants — домен и прикладные сервисы аутентификации
|
||||
|
||||
**Files:**
|
||||
- Create: `src/core/Deal.Modules.Tenants/Application/IPasswordHasher.cs`, `DefaultPasswordHasher.cs` (Argon2id, Ruling 5)
|
||||
- Create: `src/core/Deal.Modules.Tenants/Application/Models/*.cs` — record-DTO: `UserIdentityDto`, `SessionDto`, `LoginResult` и т.п. (минимум, что нужно сервисам)
|
||||
- Create: `src/core/Deal.Modules.Tenants/Application/IAuthStore.cs` (поиск пользователя по логину, чтение/создание/удаление сессий, смена пароля — на DTO)
|
||||
- Create: `src/core/Deal.Modules.Tenants/Application/AuthService.cs` (login/logout/changePassword/resolveSession)
|
||||
- Create: `src/core/Deal.Modules.Tenants/Application/ITenantRepository.cs`, `TenantService.cs` (реестр тенантов; создание тенанта вызывает `ITenantProvisioner` — интерфейс из модуля)
|
||||
- Modify: `Deal.Infrastructure` — EF-реализации (`Persistence/Repositories/AuthStore.cs`, `TenantRepository.cs`) + регистрация DI (`Deal.Infrastructure/ServiceCollectionExtensions.cs`)
|
||||
- Test: `tests/Deal.Tests.Unit/PasswordHasherTests.cs`, `AuthServiceTests.cs` (с fake-хранилищем)
|
||||
|
||||
**Семантика (референс `backend/app/auth.py`):**
|
||||
- login: неверные данные → 401 «Неверный логин или пароль»; ok → `{ok:true, login}`.
|
||||
- changePassword: `oldPassword` неверен → false→400 «Текущий пароль неверен»; новая длина <4 → 400 «Пароль слишком короткий (минимум 4 символа)»; успех → удалить все сессии пользователя.
|
||||
- resolveSession по токену (с учётом expires) → login.
|
||||
- Сессия живёт 30 дней; «протухшие» сессии удаляются при resolve (очистка).
|
||||
|
||||
**Acceptance:** build 0/0; `dotnet test tests/Deal.Tests.Unit` — все PASS (было 6 + новые ≥6). Тесты: hash/verify, неверный пароль, смена пароля инвалидирует старые сессии, resolve протухшей сессии → null. Отчёт: `task-3-report.md`.
|
||||
|
||||
### Task 4: Эндпоинты auth, middleware сессии, DI, curl-приёмка
|
||||
|
||||
**Files:**
|
||||
- Create: `src/core/Deal.Api/Endpoints/AuthEndpoints.cs`
|
||||
- Create: `src/core/Deal.Api/Middleware/SessionMiddleware.cs` (чтение куки → resolve → `TenantContext` + `CurrentUser` в `HttpContext.Items`; слабые запросы без сессии — дальше, 401 выставляют сами эндпоинты)
|
||||
- Create: `src/core/Deal.Api/Configuration/CookieOptions.cs` (IOptions; Name=deal_session, Days=30, Secure=false)
|
||||
- Modify: `Deal.Api/Program.cs` (CORS dev как в прототипе, cookie-конфиг, DI модулей+инфраструктуры, map auth-группы; статика SPA не нужна)
|
||||
- Test/скрипт приёмки: последовательность curl на :5080 (health → login admin/admin → cookie → me → change-password → старый logout/401)
|
||||
|
||||
**Контракт эндпоинтов (1:1 с прототипом):** `POST /api/auth/login` {login,password} → 200 {ok,login} | 401; `POST /api/auth/logout` → {ok:true}; `GET /api/auth/me` → 200 {login,ok} | 401 {detail:"Требуется авторизация"}; `POST /api/auth/change-password` {oldPassword,newPassword} → {ok:true} | 400.
|
||||
|
||||
**Acceptance:** build 0/0; curl-цепочка проходит (кука выставляется, me работает, после logout — 401). Отчёт: `task-4-report.md`.
|
||||
|
||||
### Task 5: Провижининг схем тенантов и bootstrap при старте
|
||||
|
||||
**Files:**
|
||||
- Create: `src/core/Deal.Infrastructure/Tenancy/TenantProvisioningService.cs` (Ruling 3; реализует `ITenantProvisioner` из модуля)
|
||||
- Create: `src/core/Deal.Api/Hosting/TenantBootstrapService.cs` (IHostedService: seed дефолтного тенанта+admin (Ruling 8), провижининг схем ВСЕХ тенантов при старте; идемпотентно)
|
||||
- Modify: `Deal.Modules.Tenants/Application/IAuthStore.cs` — добавить `Task CreateUserAsync(StoredUserDto user, CancellationToken ct)` (seed через порт модуля, НЕ через DbContext в Api)
|
||||
- Modify: `Deal.Infrastructure/Persistence/Repositories/AuthStore.cs` — реализовать CreateUserAsync
|
||||
- Modify: `Deal.Modules.Tenants/Application/TenantService.cs` — `CreateTenantAsync(string name, CancellationToken)` оставить; при необходимости дать возможность передать явный Guid id (для дефолтного тенанта)
|
||||
- Modify: `Deal.Infrastructure/ServiceCollectionExtensions.cs` — регистрация `ITenantProvisioner→TenantProvisioningService`
|
||||
- Modify: `Deal.Api/Program.cs` — hosted-сервис вместо StartupSeed; удалить `PendingTenantProvisioner`
|
||||
- Delete: `Deal.Api/Hosting/StartupSeed.cs`, временная DI-заглушка `PendingTenantProvisioner`
|
||||
- Modify: `Deal.Api/Configuration/CookieOptions.cs` — `Days` по умолчанию = константа сессии модуля (единый источник «30»)
|
||||
|
||||
**Acceptance:** app стартует, seed создан (psql: tenants строка с фикс. id, users `admin`), схема `tenant_<32hex>` дефолтного тенанта создана с таблицей `settings` и `__TenantMigrationsHistory` (содержит InitialTenant); повторный старт идемпотентен; `dotnet build` 0/0; все тесты PASS; login admin/admin работает после старта. Отчёт: `task-5-report.md`.
|
||||
|
||||
### Task 6: Финал этапа
|
||||
|
||||
- `scripts/build.sh`, `scripts/test.sh` — успешны; `dotnet ef migrations list` — System: InitialSystem, Tenant: InitialTenant.
|
||||
- Полная curl-приёмка (health, login, me, logout) + psql-проверка схем.
|
||||
- Обновить `docs/technical/Техническая-документация-Дейл.md` (раздел «Быстрый старт dev» — актуальные шаги: поднять postgres, мигрировать public, запустить API, креды).
|
||||
- Отчёт `task-6-report.md` + финальная строка в `progress.md`.
|
||||
|
||||
## Self-Review
|
||||
|
||||
1. Spec coverage: ТЗ §3 (роли/доступ) — Task 3–5; архитектура §4 (мультитенантность) — Task 2, 5; §6.1 (контракт /api, auth) — Task 4; §11 (стандарты) — все задачи.
|
||||
2. Placeholder scan: код везде конкретный; референсы на `auth.py`/api-map точные.
|
||||
3. Type consistency: `TenantId`, `ITenantContext`, `TenantContext`, `ConnectionStringProvider`, `TenantProvisioningService`, `DealDbContext`, `TenantDbContext`, сущности — согласованы между задачами 2–5.
|
||||
4. Вне scope этапа 1: kanban/колонки/карточки, проекты, pipeline/очередь/отсев, discovery, сервисы ml/ai/telegram, операторская админка, инвайты, лимиты токенов, валюты — следующие этапы.
|
||||
@@ -0,0 +1,430 @@
|
||||
# Дейл (Deal) — Этап 2: Настройки тенанта (Settings) Implementation Plan
|
||||
|
||||
> Исторический документ этапа 2. Актуальное состояние — `docs/superpowers/STATUS.md` и `docs/technical/Техническая-документация-Дейл.md`.
|
||||
|
||||
**Goal:** Реализовать в модульном монолите `src/core` модуль Settings с 1:1-контрактом `/api`,
|
||||
который потребляет экран «Настройки» Vue-фронта (`src/frontend/src/views/SettingsView.vue`,
|
||||
`components/MLPanel.vue`, `PromptLibraryModal.vue`): чтение/сохранение дерева настроек тенанта
|
||||
(таблица `settings` уже есть), шифрование секретов (ключи AI/Telegram), проверка подключения
|
||||
AI-провайдера, курсы валют, ML-панель на детерминированной локальной заглушке, тестер фильтров
|
||||
входящих. К концу этапа Settings-экран обслуживается бэкендом полностью (кроме зон, помеченных
|
||||
зависимостями этапов 3–6); приёмка — curl/psql/unit-тесты (Vue-фронт полностью оживает только
|
||||
с этапом 3: его `boot()` требует `/api/boards`, `/api/leads`, `/api/projects`, `/api/tg/status` —
|
||||
см. Ruling 11).
|
||||
|
||||
**Architecture:** новый модуль `Deal.Modules.Settings` (чистый, без EF): константы/дефолты,
|
||||
типизированный каталог ключей, порты `ISettingsStore`/`ISecretCipher`/`IRatesSource`/
|
||||
`IAiConnectionChecker`, сервисы `SettingsService` (public-снимок + частичный PATCH), `RatesService`,
|
||||
`IncomingRules` (этап-1 правила тестера). Адаптеры — в `Deal.Infrastructure`: KV `SettingsStore`
|
||||
(таблица `settings`, JSON в `value_json`), `AesGcmSecretCipher`, `CbrRateSource`, HTTP-проверка AI.
|
||||
Интеграционный порт `IMlClient` + record-DTO — в `Deal.Contracts/Integrations`, заглушка
|
||||
`LocalMlClient` — в `Deal.Infrastructure/Integrations`. HTTP-эндпоинты — в `Deal.Api/Endpoints/`
|
||||
(`MapSettingsEndpoints`, `MapMlEndpoints`, `MapFilterTesterEndpoints`). Внешние сервисы
|
||||
(реальные ml/ai/telegram) на этапе 6 заменят заглушки gRPC-адаптерами без правки эндпоинтов.
|
||||
|
||||
**Spec:** `docs/api/api-map.md` §3.4 (L142–152), §3.7 (L187–199), §4.6 (L315–341), §4.7 (L343–346),
|
||||
§4.10 (L363–365), правила L7–24, п.9 «экономия» (L399); `docs/spec/ТЗ-дейл-новая-архитектура.md`
|
||||
§8 (L165–179), §5 (L89–121, фильтры), §7 (L150–161 — только пересечения), §9 (лимиты — НЕ в этап);
|
||||
`docs/architecture/2026-09-05-deal-architecture-design.md` §5 (границы модулей), §8 (секреты L207);
|
||||
референс-семантика: `backend/app/routers/settings_routes.py`, `backend/app/services/rates.py`,
|
||||
`backend/app/services/ai.py` (L36–77, L188–198), `backend/app/routers/ml_routes.py`,
|
||||
`backend/app/services/ml_client.py`, `backend/app/routers/dashboard_routes.py` (admin/check-message
|
||||
L267–284), `backend/app/services/pipeline.py` (stage1_plain L94–124), `backend/app/constants.py`
|
||||
(L30–50, L54–245), `backend/app/crypto.py`, `backend/app/config.py` (L48–51);
|
||||
фронт: `src/frontend/src/store.js` (boot L565–628, applySettings L343–397, applyMlStatus L487–502,
|
||||
schedulePersist L1737–1766, refreshRates L1844–1848), `src/frontend/src/data.js` (L6–141 дефолты
|
||||
промптов; `AI_PROVIDERS` L17–80; `PROMPT_LIBRARY` L180–200 — библиотека по сферам живёт ТОЛЬКО
|
||||
во фронте, бэкенд её не отдаёт), `views/SettingsView.vue` (вкладки L39–49), `components/MLPanel.vue`,
|
||||
`components/PromptLibraryModal.vue`.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Проект **НЕ git**; фиксация — отчёты задач `task-N-report.md` и `progress.md`. Рабочая папка плана: `.superpowers/sdd/deal-stage2-settings/`.
|
||||
- .NET 10 SDK, решение собирается с 0 warnings / 0 errors (`TreatWarningsAsErrors`).
|
||||
- Код-стайл этапа 1: 1 тип = 1 файл; XML-doc на public-контракты; комментарии на русском; явные модификаторы; настройки через `IOptions<T>`; без регионов.
|
||||
- namespace `Deal.*`. Секретов в коде нет; ключи шифрования — env/файл (Ruling 2). `tenantId` — только из сессии.
|
||||
- Таблица `settings` уже в `TenantDbContext` (миграция `InitialTenant`) — новые EF-таблицы в этапе 2 НЕ создаются.
|
||||
- LeadRadar-контейнеры, `backend/`, `mlservice/`, `src/frontend/` не трогаем. Dev-Postgres `deal-postgres` (:5433).
|
||||
- Ответы: camelCase JSON; ошибки — HTTP + `{"detail"}`; «мягкие» ошибки (ml/reset) — HTTP 200 с полем `error`.
|
||||
- Дефолтные значения настроек/промптов — из констант прототипа `constants.py` и `data.js` (фронт — высший авторитет форм; тексты промптов копируются из `data.js` L94–141).
|
||||
|
||||
## Зафиксированные решения (Rulings этапа)
|
||||
|
||||
- **Ruling 1 (модель настроек):** типизированные ключи в существующей таблице `settings`
|
||||
(`key` text PK, `value_json` — JSON-сериализованное значение любого типа, `updated_at`).
|
||||
Модуль хранит только переопределения; дефолты — в коде (`SettingsDefaults`), при чтении
|
||||
снимок = дефолты, перекрытые сохранёнными значениями. Каталог публичных ключей —
|
||||
статический словарь «ключ → категория» (Int/Bool/String/List/Dict/MyPrompts/AiConfigs/TgKeys).
|
||||
Внутренние (непубличные) ключи — `ratesCache`, `mlDecisions`, `aiDecisions` — хранятся в той же
|
||||
таблице через `ISettingsStore`, но в GET/PATCH `/settings` не участвуют. Неизвестные ключи в
|
||||
PATCH игнорируются (семантика `settings_routes.py` L110–185).
|
||||
- **Ruling 2 (шифрование секретов):** AES-256-GCM (`System.Security.Cryptography.AesGcm`),
|
||||
nonce 12 байт, tag 16 байт. Ключ — env `DEAL_ENCRYPTION_KEY` (32 байта, urlsafe-Base64);
|
||||
при отсутствии в dev — файл `<ContentRoot>/data/encryption.key` (генерируется при первом
|
||||
старте, лог-warning; путь переопределяется env `DEAL_ENCRYPTION_KEY_FILE`). Формат значения в
|
||||
БД: `enc:` + Base64(nonce‖ct‖tag). Расшифровка повреждённого/чужого значения → пустая строка
|
||||
+ warning (совместимость `crypto.decrypt_text`, `crypto.py` L52–61). Порт `ISecretCipher` —
|
||||
в модуле Settings, адаптер `AesGcmSecretCipher` — в Infrastructure.
|
||||
- **Ruling 3 (маски и публичная форма):** маска `mask(v)`: пусто → `""`, `len≤8` → как есть,
|
||||
иначе `v[:4]+"…"+v[-4:]` (`settings_routes.py` L28–32). `aiConfigs` наружу —
|
||||
`{id: {baseUrl, model, keySet, keyMasked}}`; `tgKeys` — `{apiId: <маска>, apiHashSet: bool}`.
|
||||
Список `providers` — статический из модуля (`id,name,base,local,models`; зеркало
|
||||
`constants.AI_PROVIDERS` L170–186; `api_style` — внутреннее поле, наружу не отдаётся).
|
||||
- **Ruling 4 (границы интеграционных портов):** порты будущих внешних сервисов (ML/AI/telegram)
|
||||
объявляются в `Deal.Contracts/Integrations` (интерфейс + record-DTO) — их потребляют несколько
|
||||
модулей и Api. Заглушки этапа — детерминированные адаптеры в `Deal.Infrastructure/Integrations`;
|
||||
на этапе 6 заменяются gRPC-клиентами с тем же контрактом. `IAiFacade` на этапе 2 не заводится:
|
||||
классификация/фильтр ИИ — этап 6, проверка соединения — модульный порт `IAiConnectionChecker`.
|
||||
- **Ruling 5 (ML-заглушка):** `IMlClient` (Contracts): `StatusAsync/PredictAsync/ResetAsync`
|
||||
(+ `PushAsync` добавится этапом 3). `LocalMlClient` — детерминированная: `reachable=true`,
|
||||
`ready=false`, `classes={}`, `learned=0`, `eval={count:0,correct:0,accuracy:0}` (обучение на
|
||||
действиях появится с Kanban-этапом 3); `PredictAsync` неготовой модели →
|
||||
`{take:false,label:null,scores:{},hits:0,ready:false,margin:null,terms:[],type:null}`;
|
||||
`ResetAsync` → `{ok:true}`. Таблиц `ml_outbox`/`learning_log` в этапе 2 нет (владельцы —
|
||||
этапы 3/4); счётчики `mlDecisions`/`aiDecisions` — KV-настройки.
|
||||
- **Ruling 6 (курсы валют):** кэш — tenant-настройка `ratesCache` `{rates, source, updatedAtMs}`.
|
||||
Источник по `rateSource` (`cbr`|`mock`); интервал обновления 6 часов (≤4 запроса/сутки,
|
||||
`rates.py` L20); `USDT=USD` (`rates.py` L86–91). Mock-курсы — константа `MockRates`
|
||||
(`constants.py` L41–50). Обновление: лениво на GET при протухании/смене источника, синхронно
|
||||
на `POST /rates/refresh`, фоново-запуск на PATCH `rateSource` (`settings_routes.py` L186–192).
|
||||
Массовый пересчёт карточек (`recompute_conversions`) — этап 3 (таблицы leads нет); в этапе 2 —
|
||||
только чистый `ConvertAmount`.
|
||||
- **Ruling 7 (проверка AI):** реальный HTTP, без LLM-вызовов, 1:1 `settings_routes.py` L195–219:
|
||||
нет ключа → `{ok:false, message:"Не задан API-ключ"}`; локальный провайдер → `{ok:true,
|
||||
message:"Локальный сервер «<name>» (ping в проде)"}`; облачный → `GET {base}/models`
|
||||
(Anthropic: `{base}/v1/models`, заголовок `x-api-key`); HTTP<400 → ok, 401/403 → «Ключ не
|
||||
принят (HTTP n)…», иначе «HTTP n — проверьте Base URL и модель»; сетевой сбой → «Ошибка
|
||||
соединения: …». Ответ — `{ok, message}` + статус провайдера (`provider,name,base,model,local,
|
||||
keySet,keyMasked`, `ai.py` L36–58).
|
||||
- **Ruling 8 (эндпоинты этапа и границы):** файлы `Deal.Api/Endpoints/*`, группы
|
||||
`MapSettingsEndpoints` (GET/PATCH `/settings`), `MapRatesEndpoints` (GET `/rates`,
|
||||
POST `/rates/refresh`), `MapAiCheckEndpoint` (POST `/ai/check`), `MapMlEndpoints` (/ml/*),
|
||||
`MapFilterTesterEndpoints` (POST `/admin/check-message`). «Только для Settings-экрана»:
|
||||
GET/PATCH `/settings`, POST `/ai/check`, GET/POST `/rates*`, POST `/admin/check-message`,
|
||||
ML-статус/сброс/проверка. «Переиспользуются этапами 3+»: `GET/PATCH /settings` — общий
|
||||
источник настроек для pipeline/kanban/projects/discovery; `/api/ml/*` — счётчики и обучение
|
||||
(этап 3), предсказания (этап 4), кандидаты/apply оживают с telegram-данными (этап 6);
|
||||
правила `IncomingRules` — этап-1 пайплайна (этап 4). НЕ входят в этап 2 (зависимости):
|
||||
`/api/tg/*` (этап 6), `admin/tick`, `admin/fts/rebuild` (кнопки «Хранение и очистка» — этапы
|
||||
3/4), `/api/ai/suggest-keywords` и `suggest-columns` (этапы 3/6), `/api/columns/*`,
|
||||
`/api/leads/*`, `/api/boards/*`, `/api/projects/*`, `/api/pipeline/*`, `/api/discovery/*`,
|
||||
`/api/meta/constants` (фронт не вызывает — api-map п.9 L399), `ml/learn`, `ml/flush` (там же),
|
||||
события SSE, лимиты ТЗ §9 (этап 7).
|
||||
- **Ruling 9 (колонки/colState):** колонки и их правила — этап 3 (Kanban). В этапе 2 `colState`
|
||||
— обычный dict-ключ (passthrough в PATCH, дефолт `{}`), отдельные `/api/columns/*` НЕ делаются.
|
||||
- **Ruling 10 (звук/вид/напоминания):** `soundOn`/`volume` и тема — локальное состояние фронта
|
||||
(`store.js` L106–111, в PATCH не шлются) — бэкенд не нужен. Общие напоминания — ключ
|
||||
`remindersEnabled` (passthrough); отложенные напоминания и `reminder_due` — этап 5 (Projects).
|
||||
- **Ruling 11 (приёмка и фронт):** Vue `boot()` (`store.js` L571–581) требует отсутствующие до
|
||||
этапа 3 группы (`/boards`, `/leads`, `/leads/counts`, `/projects`, `/tg/status`,
|
||||
`/columns/state`) — полная работа фронта восстанавливается этапом 3; поэтому приёмка этапа 2 —
|
||||
unit-тесты + curl + psql. Строки ошибок/сообщений — фиксированные из прототипа (см. задачи).
|
||||
|
||||
## Задачи
|
||||
|
||||
Сокращения путей: `S=` `src/core/Deal.Modules.Settings/`, `I=` `src/core/Deal.Infrastructure/`,
|
||||
`A=` `src/core/Deal.Api/`, `C=` `src/core/Deal.Contracts/`, `T=` `src/core/tests/Deal.Tests.Unit/`.
|
||||
|
||||
### Task 1: Шифрование секретов (AES-GCM) — фундамент хранения ключей AI/Telegram
|
||||
|
||||
**Files:**
|
||||
- Create: `S/Application/ISecretCipher.cs` — `Encrypt(string)→string` (префикс `enc:`),
|
||||
`Decrypt(string)→string` (без префикса — вернуть как есть; сбой → `""`), `MaybeEncrypt`.
|
||||
- Create: `I/Security/AesGcmSecretCipher.cs` — AES-256-GCM, nonce 12/tag 16, формат
|
||||
`enc:` + Base64(nonce‖ct‖tag) (Ruling 2).
|
||||
- Create: `I/Security/EncryptionKeyProvider.cs` — ключ из `IConfiguration` (`DEAL_ENCRYPTION_KEY`,
|
||||
Base64 32 байта); fallback: файл `data/encryption.key` (env `DEAL_ENCRYPTION_KEY_FILE`),
|
||||
генерация при первом старте + warning; невалидный env-ключ → исключение при старте
|
||||
(семантика `crypto._get_fernet`, `crypto.py` L22–42).
|
||||
- Create: `A/Configuration/EncryptionOptions.cs` (IOptions: секция `Encryption`: `KeyFilePath`,
|
||||
дефолт `data/encryption.key`).
|
||||
- Modify: `I/ServiceCollectionExtensions.cs` — регистрация `ISecretCipher→AesGcmSecretCipher`
|
||||
(singleton, ключ из provider).
|
||||
- Test: `T/SecretCipherTests.cs` (roundtrip; префикс `enc:`; незашифрованная строка проходит
|
||||
как есть; повреждённый токен → `""`; `MaybeEncrypt("")` → `""`).
|
||||
|
||||
**Источники:** `backend/app/crypto.py` L1–70; `backend/app/config.py` L48–51.
|
||||
|
||||
**Acceptance:** build 0/0; `dotnet test` — SecretCipherTests PASS. Отчёт: `task-1-report.md`.
|
||||
|
||||
### Task 2: Модуль Settings — каталог ключей, дефолты, DTO, порт хранилища
|
||||
|
||||
**Files:**
|
||||
- Create: `S/Application/SettingKind.cs` (enum: Int/Bool/String/List/Dict/MyPrompts/AiConfigs/TgKeys/Internal).
|
||||
- Create: `S/Application/SettingsKeys.cs` — статический каталог публичных ключей
|
||||
(категория каждого ключа, 1:1 список §4.6 и PATCH-список L340): Int — `archiveAfterDays`,
|
||||
`archiveClearDays`, `trashClearDays`, `minLen`, `discJoinLimit`, `discJoinDelayMin/Max`,
|
||||
`discEvalSample`, `discEvalThreshold`; Bool — `autoArchive`, `aiEnabled`, `aiFilterEnabled`,
|
||||
`conversionOn`, `remindersEnabled`, `mlEnabled`, `blockResumes`, `budgetRequiredHire/Order`,
|
||||
`autoMonitorNew`, `discPaused`; String — `targetCurrency`, `rateSource`, `aiProvider`,
|
||||
`aiPrompt`, `aiFilterPrompt`, `cardPrompt`, `domainDescription`, `wantedType`, `hireLabel`,
|
||||
`orderLabel`; List — `stopPhrases`, `domainKeywords`, `hireMarkers`, `levelTerms`,
|
||||
`resumeMarkers`; Dict — `colState`; + special: `myPrompts`, `aiConfigs`, `tgKeys`; Internal:
|
||||
`ratesCache`, `mlDecisions`, `aiDecisions` (в PATCH/GET не участвуют, Ruling 1).
|
||||
- Create: `S/Application/SettingsDefaults.cs` — значения по умолчанию из `constants.py` L189–245
|
||||
(включая дефолтные стоп-фразы L55, `minLen=24`, hire/level/resume-маркеры L144–167,
|
||||
`aiConfigs` для каждого провайдера с первым `model`, `tgKeys={apiId:"",apiHash:""}`).
|
||||
- Create: `S/Application/DefaultPrompts.cs` — константы `DefaultAiPrompt`, `DefaultCardPrompt`,
|
||||
`DefaultAiFilterPrompt` — тексты КОПИРУЮТСЯ из `src/frontend/src/data.js` L94–141 (фронт —
|
||||
источник; в `constants.py` L63–141 те же тексты для сверки).
|
||||
- Create: `S/Application/AiProviderDefinition.cs` (record: Id, Name, Base, Local, Models,
|
||||
ApiStyle? `null`=OpenAI-совместимый, `"anthropic"`), `S/Application/AiProviders.cs`
|
||||
(статический список 7 провайдеров: deepseek/openai/openrouter/anthropic/ollama/lmstudio/custom —
|
||||
`constants.py` L170–186).
|
||||
- Create: `S/Application/MockRates.cs` (константа, `constants.py` L41–50) + `RatesFetchInterval = 6h`.
|
||||
- Create: `S/Application/ISettingsStore.cs` — порт: `Task<object?> GetAsync(string key, ct)`,
|
||||
`Task<Dictionary<string,object?>> GetAllAsync(ct)`, `Task SetAsync(string key, object? value, ct)`
|
||||
(значения JSON-сериализуемые; список/словарь/строка/число/булево).
|
||||
- Test: `T/SettingsCatalogTests.cs` (все ключи §4.6 присутствуют с корректной категорией;
|
||||
внутренние ключи не в каталоге публичных; провайдеры: 7 шт., id/base соответствуют списку;
|
||||
MockRates содержит RUB/USD/EUR/USDT).
|
||||
|
||||
**Источники:** api-map §4.6 L315–341; `constants.py`; `data.js` L6–141.
|
||||
|
||||
**Acceptance:** build 0/0; SettingsCatalogTests PASS. Отчёт: `task-2-report.md`.
|
||||
|
||||
### Task 3: SettingsService — public-снимок и частичное обновление (PATCH-семантика 1:1)
|
||||
|
||||
**Files:**
|
||||
- Create: `S/Application/Models/PublicSettingsDto.cs` — record со всеми полями §4.6
|
||||
(вложенные: `MyPromptDto{Id,Name,Description,Prompt}`, `AiConfigPublicDto{BaseUrl,Model,KeySet,
|
||||
KeyMasked}`, `TgKeysPublicDto{ApiId,ApiHashSet}`, `ProviderPublicDto{Id,Name,Base,Local,Models}`).
|
||||
- Create: `S/Application/SettingsService.cs` — `GetPublicAsync(ct)` (дефолты+сохранённые,
|
||||
маскирование, Ruling 3; для `apiHashSet` — `SecretCipher.Decrypt(apiHash) != ""`, для каждого
|
||||
провайдера — расшифровка ключа + `keySet/keyMasked`); `ApplyPatchAsync(
|
||||
Dictionary<string,JsonElement> body, ct)` с клампами и валидацией (см. ниже), ответ — полный
|
||||
public-снимок (фронт затирает локальный state ответом — api-map L147, L341).
|
||||
- Create: `T/…/FakeSettingsStore.cs` (in-memory Dictionary), `T/SettingsServiceTests.cs`.
|
||||
|
||||
**Семантика PATCH (референс `settings_routes.py` L75–192):**
|
||||
- Int: нечисловое → пропуск ключа; клампы: `archiveAfterDays` 1..30, `minLen` 10..500,
|
||||
`discJoinLimit` 1..200, `discJoinDelayMin/Max` 5..600, `discEvalSample` 3..30,
|
||||
`discEvalThreshold` 1..100; интервалы задержек: при паре — клампы+swap при min>max; при одном
|
||||
конце — кламп относительно сохранённого другого конца (L80–109).
|
||||
- Bool: JSON-булево (строки не «питон-булеватся»). String: `targetCurrency` → Upper;
|
||||
`aiProvider` вне списка провайдеров → пропуск; остальные — строка как есть.
|
||||
- List: только список → строки, срез 200. Dict: `colState` — как есть (Ruling 9).
|
||||
- `myPrompts`: ≤100; name≤80, prompt≤8000, description≤300 (trim); пустые name/prompt — дроп;
|
||||
id ≤40 или генерация `pp_` + 8 hex (Ruling дефолта, референс L143–160).
|
||||
- `aiConfigs`: только существующие провайдеры; `baseUrl`/`model` — строки; `apiKey` непустой,
|
||||
≥8 симв., без префикса `enc:` → шифруется (L161–175).
|
||||
- `tgKeys`: `apiId` — только цифры, длина 6..9 (5<len<10); `apiHash` ≥16 симв. → шифруется
|
||||
(L176–185).
|
||||
- Побочные эффекты PATCH: при `rateSource` — запуск `RatesService.RefreshAsync` (fire-and-forget);
|
||||
при `targetCurrency`/`conversionOn` — в этапе 2 ничего (нет leads; этап 3) (L186–192).
|
||||
|
||||
**Источники:** api-map §4.6 L147, L340–341; `settings_routes.py` целиком; `crypto.py`.
|
||||
|
||||
**Acceptance:** `dotnet test` — SettingsServiceTests PASS: снимок дефолтов; маскирование ключа;
|
||||
каждый кламп; swap интервалов; `myPrompts` clean+id; шифрование aiConfigs/tgKeys (в БД `enc:`);
|
||||
неизвестный ключ игнорируется. Отчёт: `task-3-report.md`.
|
||||
|
||||
### Task 4: KV-адаптер SettingsStore (EF) и DI
|
||||
|
||||
**Files:**
|
||||
- Create: `I/Persistence/Repositories/SettingsStore.cs` — реализует `ISettingsStore` на
|
||||
`TenantDbContext.Settings` (сущность `TenantSettingEntity` уже есть): чтение всех строк,
|
||||
сериализация/десериализация значений в JSON, `updated_at` — UTC-now.
|
||||
- Modify: `I/ServiceCollectionExtensions.cs` — `AddScoped<ISettingsStore, SettingsStore>()`;
|
||||
регистрация `ISecretCipher` из Task 1, `RatesService`-зависимостей из Tasks 6–8.
|
||||
- Modify: `S/SettingsModuleRegistrar.cs` (Create) — `AddSettingsModule()`: `SettingsService`,
|
||||
`RatesService`, `IncomingRules` (scoped); вызывается в `A/Program.cs` (Task 5).
|
||||
- Modify: `A/Deal.Api.csproj` — ProjectReference на `Deal.Modules.Settings`.
|
||||
|
||||
**Источники:** эталон: `I/Persistence/Repositories/AuthStore.cs`, `TenantModuleRegistrar.cs`,
|
||||
`ServiceCollectionExtensions.cs` (этап 1).
|
||||
|
||||
**Acceptance:** build 0/0; psql-проверка: GET через сервис на пустой схеме тенанта возвращает
|
||||
дефолты, `SetAsync` создаёт строку с `value_json`. Отчёт: `task-4-report.md`.
|
||||
|
||||
### Task 5: Эндпоинты GET/PATCH /api/settings + DI + curl-приёмка
|
||||
|
||||
**Files:**
|
||||
- Create: `A/Endpoints/SettingsEndpoints.cs` (`MapSettingsEndpoints`): `GET /api/settings` →
|
||||
PublicSettingsDto; `PATCH /api/settings` — тело произвольный JSON-объект →
|
||||
полный снимок после применения. Авторизация — через `SessionMiddleware`/`CurrentUser`
|
||||
(эталон `AuthEndpoints.cs`), 401 `{"detail":"Требуется авторизация"}`.
|
||||
- Modify: `A/Program.cs` — `AddSettingsModule()`, map групп эндпоинтов.
|
||||
- Модификации предыдущих задач собираются здесь же (порядок исполнения: T1→T4 затем T5).
|
||||
|
||||
**Контракт (api-map §3.4 L146–147, §4.6):** GET — все ключи §4.6 (camelCase, дефолты, маски,
|
||||
`providers` список); PATCH — те же поля-группы, что шлёт фронт (L340), ответ — полный снимок.
|
||||
Ошибок-исключений нет (мягкая семантика: невалидное поле просто не применяется).
|
||||
|
||||
**Acceptance (curl, cookie-сессия admin/admin):**
|
||||
1. `GET /api/settings` → дефолты: `aiEnabled:true, mlEnabled:true, minLen:24,
|
||||
archiveAfterDays:14, stopPhrases:[4 дефолтные], wantedType:"both", rateSource:"cbr",
|
||||
aiProvider:"deepseek", tgKeys:{apiId:"", apiHashSet:false}, colState:{}`, `providers` — 7.
|
||||
2. `PATCH {"archiveAfterDays":99,"minLen":3,"discJoinDelayMin":700,"discJoinDelayMax":5}` →
|
||||
в ответе `archiveAfterDays:30, minLen:10, discJoinDelayMin:5, discJoinDelayMax:700` (swap).
|
||||
3. `PATCH {"myPrompts":[{name:"x",prompt:"y"},{name:"",prompt:""}]}` → 1 элемент, `id` начинается `pp_`.
|
||||
4. `PATCH {"aiConfigs":{"deepseek":{"apiKey":"sk-1234567890ab"}}}` → ответ `keySet:true,
|
||||
keyMasked:"sk-1…90ab"`; psql: `value_json` содержит `enc:` (см. Task 7-контракт psql).
|
||||
5. `PATCH {"tgKeys":{"apiId":"123456","apiHash":"abcdefghijklmnop"}}` → `apiHashSet:true`.
|
||||
6. Неизвестный ключ `{"foo":1}` — без ошибки, снимок без `foo`.
|
||||
Отчёт: `task-5-report.md`.
|
||||
|
||||
### Task 6: ИИ-провайдеры и POST /api/ai/check (проверка подключения)
|
||||
|
||||
**Files:**
|
||||
- Create: `S/Application/IAiConnectionChecker.cs` — `Task<AiCheckResultDto> CheckAsync(
|
||||
AiCheckRequest request, ct)`, `S/Application/Models/AiCheckResultDto.cs` (Ok, Message, Provider,
|
||||
Name, Base, Model, Local, KeySet, KeyMasked), `AiCheckRequest` (ProviderId, BaseUrl, Model,
|
||||
ApiKey, IsLocal, ApiStyle).
|
||||
- Create: `I/Integrations/AiConnectionChecker.cs` — HTTP-реализация (Ruling 7) через
|
||||
`IHttpClientFactory` (таймаут 12 с), переиспользует формат сообщений прототипа.
|
||||
- Create: `A/Endpoints/AiCheckEndpoint.cs` (`MapAiCheckEndpoint`) — читает активную конфигурацию
|
||||
провайдера из `ISettingsStore` (расшифровка ключа через `ISecretCipher`), вызывает checker,
|
||||
отдаёт `{ok,message,provider,name,base,model,local,keySet,keyMasked}` (api-map §4.10 L365).
|
||||
- Test: `T/AiConnectionCheckerTests.cs` (fake `HttpMessageHandler`): без ключа; local; 200;
|
||||
401; 403; HTTP 500; сетевая ошибка.
|
||||
|
||||
**Источники:** `settings_routes.py` L195–219; `ai.py` L36–58 (provider_status + mask_key).
|
||||
|
||||
**Acceptance:** build 0/0; тесты PASS. curl: без ключа → `{"ok":false,"message":"Не задан
|
||||
API-ключ",...}`; провайдер `ollama` → ok:true «Локальный сервер…»; `deepseek` с неверным ключом
|
||||
и недоступным хостом → `"Ошибка соединения: …"` (сеть недоступна — допустимо). Отчёт:
|
||||
`task-6-report.md`.
|
||||
|
||||
### Task 7: Промпты и «Мои промпты» — интеграционная проверка границы с фронтом
|
||||
|
||||
Бэкенд-логика уже в Tasks 2–3 (`DefaultPrompts`, валидация `myPrompts`). Задача — контроль
|
||||
1:1 границы и приёмочные проверки (библиотека по сферам — фронтовая, `data.js` PROMPT_LIBRARY
|
||||
L180–200; `PromptLibraryModal.vue` не ходит в API; наружу идут только промпты-строки и
|
||||
`myPrompts`).
|
||||
|
||||
**Files:**
|
||||
- Test: `T/PromptDefaultsTests.cs` — дефолтные тексты начинаются/содержат маркеры из
|
||||
`data.js` (например `aiPrompt` содержит «Ты — классификатор входящих сообщений» и
|
||||
плейсхолдеры `{domain}`/`{keywords}`; `cardPrompt` — «О заявке»; `aiFilterPrompt` — «страж
|
||||
входящих»); `fill_prompt`-подстановка (аналог `ai.fill_prompt` L63–77): пустой domain →
|
||||
фраза-фолбэк, keywords склейка, ≤60 ключей.
|
||||
- Create: `S/Application/PromptFiller.cs` — подстановка `{domain}`/`{keywords}` (чистая функция,
|
||||
используется этапом 6 для ИИ-вызовов).
|
||||
|
||||
**Acceptance (curl):** 1) PATCH `aiPrompt` с плейсхолдерами → GET возвращает тот же текст;
|
||||
2) PATCH `myPrompts` 3 записи → GET отдаёт их (camelCase `id/name/description/prompt`);
|
||||
3) «Применить из библиотеки» фронта = локальная операция — API не вызывается. `dotnet test`
|
||||
PromptDefaultsTests PASS. Отчёт: `task-7-report.md`.
|
||||
|
||||
### Task 8: Курсы валют — сервис, кэш, эндпоинты /api/rates*
|
||||
|
||||
**Files:**
|
||||
- Create: `S/Application/IRatesSource.cs` — порт: `Task<Dictionary<string,double>?> FetchAsync(ct)`
|
||||
(курсы к RUB). `S/Application/Models/RatesDto.cs` — record `{Base, Rates, Source, UpdatedAtMs?}`.
|
||||
- Create: `S/Application/RatesService.cs` — `GetAsync(ct)` (кэш `ratesCache`; нет кэша → дефолт
|
||||
MockRates/source "mock"/updatedAt null); `RefreshAsync(ct)` (source из настройки: mock →
|
||||
сохранить MockRates; cbr → `IRatesSource`; неуспех → `false`, кэш не трогаем); `ShouldFetch(ct)`
|
||||
(нет кэша / смена источника / ≥6 ч, `rates.py` L77–83); `ConvertAmount(amount, fromCur, toCur)`
|
||||
— USDT→USD (L86–103). Ленивое обновление на GET при `ShouldFetch` — фоновый запуск
|
||||
`RefreshAsync`, ответ — текущий кэш.
|
||||
- Create: `I/Integrations/CbrRateSource.cs` — HTTP GET `https://www.cbr-xml-daily.ru/daily_json.js`
|
||||
(JSON), `Valute[code].Value/Nominal`, `RUB:1`; сбой → null (лог) (`rates.py` L43–59).
|
||||
- Create: `A/Endpoints/RatesEndpoints.cs` (`MapRatesEndpoints`): `GET /api/rates` → RatesDto;
|
||||
`POST /api/rates/refresh` → `{ok, rates: RatesDto}` (ok=false при сбое cbr; при mock — true).
|
||||
- Modify: `A/Program.cs` — map; DI: `IRatesSource→CbrRateSource` (scoped), `AddHttpClient`.
|
||||
- Test: `T/RatesServiceTests.cs` (fake store+source): mock-режим; cbr успех/сбой; ShouldFetch
|
||||
(интервал 6 ч, смена источника); ConvertAmount USDT=USD, отсутствующая валюта → null.
|
||||
|
||||
**Источники:** `services/rates.py` целиком; api-map §3.4 L149–150; `settings_routes.py` L224–232.
|
||||
|
||||
**Acceptance:** build 0/0; тесты PASS. curl: `PATCH {"rateSource":"mock"}` затем
|
||||
`POST /api/rates/refresh` → `{ok:true, rates:{base:"RUB", rates:{RUB:1,USD:92.5,…},
|
||||
source:"mock", updatedAt:<ms>}}`; `GET /api/rates` — тот же кэш. Отчёт: `task-8-report.md`.
|
||||
|
||||
### Task 9: ML-панель — порт IMlClient, детерминированная заглушка, эндпоинты /api/ml
|
||||
|
||||
**Files:**
|
||||
- Create: `C/Integrations/IMlClient.cs` + `C/Integrations/Models/*.cs` — record-DTO:
|
||||
`MlServiceStatusDto {Ready, Classes(Dictionary<string,double>), Learned, Eval{MlEvalDto}}`,
|
||||
`MlEvalDto {Count, Correct, Accuracy}`, `MlPredictResultDto {Take, Label?, Scores, Hits,
|
||||
Ready, Margin?, Terms[], Type?}`, `MlStatusResponseDto {Enabled, Service, Reachable, Stats{
|
||||
MlStatsDto}}`, `MlStatsDto {Ml, Ai, Learning, Ready, Classes, Learned, Reachable, Outbox}`
|
||||
(поля/типы 1:1 `ml_routes.py` L70–75 + `ml_client.snapshot()` L138–150).
|
||||
- Create: `I/Integrations/LocalMlClient.cs` — заглушка Ruling 5 (детерминированная; обучение
|
||||
недоступно до этапа 3 — модель всегда «не готова»; счётчики `mlDecisions/aiDecisions` —
|
||||
из KV settings, Ruling 1).
|
||||
- Create: `A/Endpoints/MlEndpoints.cs` (`MapMlEndpoints`):
|
||||
- `GET /api/ml/status` → `MlStatusResponseDto` (`enabled` = `mlEnabled !== false`);
|
||||
- `POST /api/ml/reset` → `{ok:true}` (мягкая ошибка `{ok:false,error}` — зарезервирована);
|
||||
- `POST /api/ml/predict` `{text}`: trim <2 симв. → 400 «Введите текст»; ответ
|
||||
`{text:<первые 200>, take, label, scores, hits, ready, margin, terms, type}`;
|
||||
- `POST /api/ml/candidates` `{dialogId, limit=10 (clamp 1..60)}` → `{items: []}` (данных
|
||||
telegram нет — этап 6; контракт §3.7 L196);
|
||||
- `POST /api/ml/apply` `{dialogId, msgId, action}` → 404 «Исходное сообщение не найдено»
|
||||
(нет сообщений до этапов 3/6; ветка `skip` — этап 6; контракт §3.7 L197).
|
||||
- НЕ реализуем: `ml/learn`, `ml/flush` (фронт не вызывает, api-map п.9).
|
||||
- Modify: `A/Program.cs` — DI `IMlClient→LocalMlClient` (scoped), map.
|
||||
- Test: `T/LocalMlClientTests.cs` (status-форма; predict неготовой модели — все поля; reset → ok).
|
||||
|
||||
**Источники:** api-map §3.7, §4.10 L363; `ml_routes.py` L66–91, L112–171; `ml_client.py` L127–150;
|
||||
`mlservice/model.py` (predict L184–293, status L325–345 — эталон полей для этапа 6).
|
||||
|
||||
**Acceptance:** build 0/0; тесты PASS. curl: login → `GET /api/ml/status` (все поля, `reachable:
|
||||
true`, `ready:false`, `stats.outbox:0`); `POST /api/ml/predict {"text":"x"}` → 400;
|
||||
`POST /api/ml/predict {"text":"Python backend на fastapi, бот в телеграм"}` → `take:false,
|
||||
label:null, scores:{}, ready:false`; `POST /api/ml/reset` → `{ok:true}`; `POST /api/ml/candidates`
|
||||
→ `{"items":[]}`. Отчёт: `task-9-report.md`.
|
||||
|
||||
### Task 10: Тестер фильтров — этап-1 правила и POST /api/admin/check-message
|
||||
|
||||
**Files:**
|
||||
- Create: `S/Application/IncomingRules.cs` — чистая реализация `stage1_plain` (`pipeline.py`
|
||||
L94–124) поверх `ISettingsStore`: минимальная длина (`minLen`), стоп-фразы (casefold, ответ —
|
||||
конкретная фраза), блокировка резюме (`blockResumes` + `resumeMarkers` с контекстным guard
|
||||
«вакансия… присылайте резюме» — не режем, `pipeline._resume_reason` L644–654), тип заявки
|
||||
(`wantedType` + маркеры найма `hireMarkers`); результат
|
||||
`{pass, reason, stage:1, kind:length|stop|resume|type, kw}`.
|
||||
- Create: `A/Endpoints/FilterTesterEndpoints.cs` (`MapFilterTesterEndpoints`):
|
||||
`POST /api/admin/check-message` `{text}` → `{stage1:{pass,reason}, stage2, passed}`
|
||||
(1:1 `dashboard_routes.py` L267–284): если этап-1 не прошёл → `stage2:{pass:false,reason:null,
|
||||
skipped:true}, passed:false`; иначе `stage2:{pass:true,reason:null,skipped:true}` — ИИ-фильтр
|
||||
на этапе 2 всегда skipped (Ruling 4/8; реальный ИИ-фильтр — этап 6).
|
||||
- Test: `T/IncomingRulesTests.cs`: короткий текст; стоп-фраза из настроек; резюме (маркер);
|
||||
guard «…вакансия… присылайте резюме» → pass; `wantedType:"freelance"` с вакансионным маркером;
|
||||
`wantedType:"vacancy"` с разовым заказом.
|
||||
|
||||
**Источники:** api-map §3.2 L109, §4.10 L364; `dashboard_routes.py` L267–284; `pipeline.py`
|
||||
L94–124; фронт: `SettingsView.vue` L142–155 (тестер), `store.js` L1723–1725.
|
||||
|
||||
**Acceptance:** build 0/0; тесты PASS. curl: с дефолтами текст «Заработок на крипте…» (длина
|
||||
≥24, без стоп-фраз) → `stage1.pass:true, stage2.skipped:true, passed:true`; текст «Ищу работу
|
||||
python» → `stage1.pass:false, kind:"resume"` (если ≥minLen); «взаимный пиар» внутри → `kind:
|
||||
"stop"`. Отчёт: `task-10-report.md`.
|
||||
|
||||
### Task 11: Финал этапа — интеграция и сквозная приёмка
|
||||
|
||||
- `scripts/build.sh`/`scripts/test.sh` — успешны; `dotnet build Deal.sln` 0/0; все unit-тесты PASS.
|
||||
- Сквозной curl-сценарий Settings-экрана: login admin/admin → GET /settings →
|
||||
PATCH-группы из Tasks 5/7 (обработка, ИИ-промпты, myPrompts, aiConfigs, tgKeys, валюта,
|
||||
хранение/уведомления: `autoArchive/archiveAfterDays/remindersEnabled`, colState) →
|
||||
POST /ai/check → GET /rates + POST /rates/refresh (mock) → GET /api/ml/status + predict +
|
||||
reset → POST /api/admin/check-message (pass и отсев).
|
||||
- psql-проверка схемы дефолтного тенанта (`SET search_path TO tenant_00000000000000000000000000000001;`):
|
||||
строки settings созданы, `value_json` для aiConfigs/tgKeys содержит `enc:` и не содержит
|
||||
открытого ключа; внутренние ключи (`ratesCache`, `mlDecisions`) не появляются в GET /settings.
|
||||
- Известные ограничения этапа (зафиксировать в отчёте): Telegram-вкладка, кнопки «Проверить
|
||||
правила сейчас»/«Пересобрать индекс» (admin/tick, admin/fts), «Предложить ключи»
|
||||
(ai/suggest-keywords) и весь канбан-фронт не работают до этапов 3–6 (Ruling 8/11).
|
||||
- Обновить `docs/technical/Техническая-документация-Дейл.md` (раздел настроек: env
|
||||
`DEAL_ENCRYPTION_KEY`, поведение GET/PATCH /settings, креды).
|
||||
- Отчёт `task-11-report.md` + финальная строка в `progress.md`.
|
||||
|
||||
## Self-Review
|
||||
|
||||
1. **Spec coverage:** ТЗ §8 (настройки тенанта) — Tasks 1–10; §5 (этап-1 фильтры/тип/резюме —
|
||||
только настройки+тестер) — Task 10, (ML/ИИ-слои пайплайна — этапы 4/6, вне); §7 (обработка) —
|
||||
вне (этап 4); §9 (лимиты) — вне; api-map §3.4 — Tasks 5/7/8; §3.7 — Task 9; admin/check-message
|
||||
— Task 10; §4.6/4.7 — Tasks 2/3/5/7; tgKeys-часть §4.6 — Task 3/5; шифрование §8 архитектуры —
|
||||
Task 1.
|
||||
2. **Placeholder scan:** конкретные адаптеры и контракты; «заглушки» только там, где разрешено
|
||||
решением владельца (п.5): `LocalMlClient` (Task 9), ИИ-фильтр в тестере = skipped (Task 10);
|
||||
референсы на строки файлов точные. FIXME/TODO нет.
|
||||
3. **Type consistency:** один модуль Settings владеет каталогом ключей/дефолтами — Kanban/Pipeline
|
||||
(этапы 3/4) читают те же ключи через `ISettingsStore`; `IMlClient`-контракт (Contracts)
|
||||
един для панели (этап 2), счётчиков (этап 3) и предсказаний (этап 4); сущность
|
||||
`TenantSettingEntity` не меняется; схемы/миграции не добавляются.
|
||||
4. **Вне scope этапа 2:** канбан-колонки/карточки/архив-корзина и их эндпоинты (этап 3),
|
||||
pipeline/очередь/отсев/дедуп (этап 4), projects/напоминания-отложенные/файлы (этап 5),
|
||||
реальные ml/ai/telegram-сервисы и /api/tg/* (этап 6), discovery, оператор/инвайты/лимиты/
|
||||
аудит (этап 7); colState-эндпоинты; библиотека промптов (фронтовая); звук/вид (фронт).
|
||||
@@ -0,0 +1,535 @@
|
||||
# Дейл (Deal) — Этап 3: Kanban (дашборд): колонки, карточки, архив/корзина Implementation Plan
|
||||
|
||||
> Исторический документ этапа 3. Актуальное состояние — `docs/superpowers/STATUS.md` и `docs/technical/Техническая-документация-Дейл.md`.
|
||||
|
||||
**Goal:** Оживить в модульном монолите `src/core` дашборд Vue-фронта 1:1-контрактом `/api` канбана:
|
||||
колонки-доски и их правила (детерминированная раскладка + «почему карточка в колонке»), карточки
|
||||
(поля ТЗ §5, комментарии, быстрые действия), архив/корзина с правилами хранения (автоархив 1–30 дн.,
|
||||
очистка архива 90 дн. и корзины 7 дн., ручная очистка, возврат), переносы drag&drop с журналом
|
||||
обучения и сигналами ML, полнотекстовый-LIKE поиск по карточкам, SSE-реалтайм (new_lead/toast),
|
||||
ИИ-предложения колонок/ключей на детерминированной эвристике, пересчёт конверсий бюджетов при смене
|
||||
курсов/целевой валюты. К концу этапа канбан-экран фронта (колонки, карточки, архив/корзина, поиск,
|
||||
предложения) полностью обслуживается бэкендом; приёмка — unit/curl/psql + сквозной сценарий на демо-
|
||||
карточках (реальный ввод сообщений — этап 4 Pipeline).
|
||||
|
||||
**Architecture:** новый модуль `Deal.Modules.Kanban` (чистый, без EF): DTO (Board/Card/…), порт
|
||||
`IKanjStore`, сервисы `BoardsService`/`CardsService`/`StorageTickService`/`ConversionRecomputer`,
|
||||
чистые правила колонок `ColumnRules` (перенос `backend/app/services/rules.py`) и ядро эвристик
|
||||
предложений `SuggestHeuristics`. Адаптеры — в `Deal.Infrastructure`: `KanbanStore` (таблицы
|
||||
Boards/Cards/LeadComments/CardMoves/MlOutbox), доработка `LocalMlClient` (PushAsync + счётчики
|
||||
learning/outbox из таблиц), `LocalColumnSuggester` (порт `IColumnSuggester` из `Deal.Contracts`).
|
||||
HTTP-эндпоинты — `Deal.Api/Endpoints/*` (`MapBoardsEndpoints`, `MapLeadsEndpoints`,
|
||||
`MapStorageEndpoints`, `MapDemoEndpoints`, `MapAiSuggestEndpoints`, `MapEventsEndpoint`,
|
||||
`MapBootStubEndpoints`); SSE-брокер per-tenant — в `Deal.Api`. Карточки создаёт пока только демо-путь
|
||||
(simulate-lead, как devtests прототипа) — pipeline-воркер приходит этапом 4; внешний ИИ/ML —
|
||||
этапы 6/4. Один новый EF-контекст не заводится: таблицы добавляются в существующий `TenantDbContext`
|
||||
(миграция `TenantKanban`, применяется провижинером ко всем схемам тенантов, этап 1).
|
||||
|
||||
**Spec:** `docs/api/api-map.md` §3.2 (L60–121), §2 SSE (L27–44), правила (L7–24, п.9 «экономия» L399,
|
||||
кривые места L390–400); §4.1 карточка (L228–257), §4.2 доска (L259–278), §4.6 colState (L333);
|
||||
`docs/spec/ТЗ-дейл-новая-архитектура.md` §5 «Карточка» (L112–121), §6 «Дашборд (канбан)» (L121–135);
|
||||
roadmap (этап 3, L46–53); референс-семантика: `backend/app/routers/dashboard_routes.py` целиком,
|
||||
`backend/app/services/leads.py`, `rules.py`, `suggest.py`, `rates.py` (L62–74, L106–130),
|
||||
`backend/app/services/ml_client.py`, `backend/app/services/pipeline.py` (L433–514, L540–586),
|
||||
`backend/app/sse.py`, `backend/app/main.py` (L43–53 фоновые циклы), `backend/app/constants.py`
|
||||
(PALETTE L12–16, DAY_MS L249–254); фронт: `src/frontend/src/store.js` (boot L565–628 — какие группы
|
||||
обязаны отвечать 200; SSE L650–688; действия лидов L833–975; доски L977–1183; поиск L1185–1206;
|
||||
tickAuto L1855–1863, rebuildFts L1884–1889; colMeta/orderedCols L188–223), `src/frontend/src/api.js`
|
||||
(openEvents L62–104 — слушает только new_lead/toast/reminder_due/system_status), `views/DashboardView.vue`,
|
||||
`components/Column.vue`, `LeadCard.vue`, `LeadDrawer.vue`, `MoveMenu.vue`, `BoardRulesDialog.vue`,
|
||||
`SearchPalette.vue`, `ConfirmDialog.vue`, `data.js`.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Проект **НЕ git**; фиксация — отчёты `task-N-report.md` и `progress.md` в
|
||||
`.superpowers/sdd/deal-stage3-kanban/`.
|
||||
- .NET 10 SDK, `scripts/build.sh`/`scripts/test.sh`; решение собирается 0 warnings / 0 errors
|
||||
(`TreatWarningsAsErrors`). Dev-Postgres `deal-postgres` (:5433), curl-приёмка :5080.
|
||||
- Код-стайл этапов 1–2: 1 тип = 1 файл; XML-doc на public-контракты; комментарии на русском;
|
||||
явные модификаторы; настройки через `ISettingsStore`/`IOptions<T>`; без регионов; без магических
|
||||
чисел; PascalCase-колонки БД; JSON camelCase; ошибки `{"detail"}`.
|
||||
- Модуль Kanban — чистый: без EF и HTTP; зависимости — `Deal.Modules.Settings` (порт `ISettingsStore`)
|
||||
и `Deal.Contracts` (`IMlClient`). Реверс-зависимостей (Settings → Kanban) нет.
|
||||
- LeadRadar-контейнеры, `backend/`, `mlservice/`, `src/frontend/` НЕ трогаем.
|
||||
- Строки ошибок/тостов — фиксированные из прототипа (см. задачи); новые строки только для
|
||||
согласованных заглушек (Ruling 7, Ruling 11).
|
||||
- Vue-фронт не переписывается: формы JSON и эндпоинты 1:1 с api-map; «кривые места» (голый массив
|
||||
`/boards`, `messages: []`, недостижимые SSE-события) сохраняем как в прототипе.
|
||||
|
||||
## Зафиксированные решения (Rulings этапа)
|
||||
|
||||
- **Ruling 1 (а) — миграция TenantKanban и таблицы.** Новая миграция `TenantKanban` контекста
|
||||
`TenantDbContext` (папка `I/Migrations/TenantDb`, применяется провижинером к схемам всех тенантов).
|
||||
Таблицы (PascalCase, соответствие прототипу): `Boards` (= boards; колонки-доски), `Cards`
|
||||
(= leads; карточки дашборда), `LeadComments` (= comments-массив строки leads, нормализуем),
|
||||
`CardMoves` (= learning_log; журнал действий/обучения, id `lm_`), `MlOutbox` (= ml_outbox; очередь
|
||||
обучающих сигналов, id `mle_`). JSON-поля храним как text с сериализованным JSON (как `value_json`
|
||||
настроек). Времена — `timestamptz` (`DateTimeOffset`); наружу epoch-ms конвертирует маппинг.
|
||||
`Cards.Col` — текст без FK (значения `inbox|archive|trash|taken|<b_…>`, как прототип); приложение
|
||||
валидирует существование досок. `LeadComments.CardId` — FK → `Cards.Id` (cascade delete);
|
||||
`CardMoves`/`MlOutbox` — без FK (журнал живёт дольше карточки, прототип `_hard_delete` его не чистит).
|
||||
Индексы: `Cards (Col, ReceivedAt DESC)`, `Cards (Col, IsNew)`, `Boards (Suggested, Position)`
|
||||
(ORDER BY suggested, pos), `LeadComments (CardId)`, `MlOutbox (CreatedAt)`. Колонки Boards:
|
||||
Id/Name/Description/Color/Width/Position/KeywordsJson/Prompt/VisibleFieldsJson/Collapsed/Suggested/
|
||||
RulesJson/Note/CreatedAt; Cards: Id/Col/IsNew/IsVacancy/IsVacancyKnown/Title/Summary/StackJson/
|
||||
BudgetFrom/BudgetTo/BudgetCur/ConvFrom/ConvTo/ConvCur/Contact/ContactsJson/ChannelName/ChannelHandle/
|
||||
ChannelHue/ReceivedAt/SourceMsg/SourceDialogId/SourceMsgId/PrevCol/ArchivedAt/MatchHitsJson/CreatedAt
|
||||
(сущности/конфигурации — 1 тип = 1 файл, эталон TenantSettingEntity+Configuration).
|
||||
- **Ruling 2 (б) — «почему карточка в колонке» (matchHits).** Совпавшие критерии вычисляет модуль
|
||||
Kanban в момент размещения карточки в доску: перенос `move` (`leads.py L163–174`), возврат
|
||||
`restore` (L204–222), назначение при создании (этап 4/демо). Вычисление — чистые функции
|
||||
`ColumnRules` (перенос `rules.py`: `match_text` L176–209, `score_text` L212–227, `excluded_terms`/
|
||||
`is_excluded` L230–248, `board_accepts` L251–268, `hits` L271–296, `hits_for_board` L311–319,
|
||||
`has_active_rules` L322–338, `describe` L341–368, `extract_amounts` L93–144 с grade-алиасами L20–27
|
||||
и `content_text` L51–54). Для `inbox/archive/trash` и досок без активных правил — `[]`. Значение
|
||||
хранится в `Cards.MatchHitsJson`, отдаётся как `matchHits` (§4.1 L251). Страховка «ИИ/ML не кладут
|
||||
в отфильтрованную колонку» (`board_accepts`) понадобится этапам 4/6 — правила готовы сейчас.
|
||||
- **Ruling 3 (в) — порт ИИ-предложений колонок.** В `C/Integrations` объявляется
|
||||
`IColumnSuggester` + record-DTO (`SuggestColumnsResultDto {Ok, Created, Reason, Cooldown}`,
|
||||
`SuggestKeywordsResultDto {Ok, Keywords, Reason}`) — этап 6 заменит реализацию gRPC-клиентом
|
||||
ai-service (тот же контракт). Этап 3 — детерминированная эвристика: чистый `SuggestHeuristics`
|
||||
в модуле Kanban (частотные слова-темы по source_msg карточек «Неразобранного»; MIN_INBOX=6,
|
||||
группа ≥2 карточек, ≤4 колонок, `{mode:"any", keywords:[…]}`) + тонкий адаптер
|
||||
`Infrastructure/Integrations/LocalColumnSuggester` (читает карточки через `IKanjStore`, создаёт
|
||||
колонки-предложения `suggested=true` c `note`, раскладывает карточки). 1:1 ответы `{ok,created}` /
|
||||
`{ok:false, reason}`; детерминированные причины — строками прототипа («мало карточек в
|
||||
«Неразобранном» (нужно от 6)», «похожие колонки уже есть или нечего сгруппировать»). Фоновый
|
||||
автоцикл suggest (180 с, `main.py L114–123`) НЕ заводим — фронт запускает предложение только
|
||||
кнопкой, а `boards_changed` не слушает (Ruling 5).
|
||||
- **Ruling 4 (г) — ML-обучение drag&drop.** Обе таблицы этапа создаём (Ruling 1). Семантика 1:1
|
||||
с `leads.py`/`ml_client.py`: каждое действие (move/trash/restore/comment) пишет строку `CardMoves`
|
||||
(журнал, `_log_learning` L40–44) — из него счётчик `learning`. Обучающие сигналы для модели —
|
||||
`IMlClient.PushAsync(text, label, delta)` (добавляется в контракт, Ruling 5 этапа 2 L76–82):
|
||||
перенос на доску (не inbox) → push(text, `<b_…>`, 1.0); корзина из канбана → push(text, `"spam"`,
|
||||
1.0); возврат из корзины → push(text, `"spam"`, −1.0) (`move_lead` L177–191, `trash_lead` L194–201,
|
||||
`restore_lead` L204–222). `LocalMlClient.PushAsync` пишет строку `MlOutbox` (text≤6000, label, delta,
|
||||
created_at); `StatusAsync` читает `learning = count(CardMoves)`, `outbox = count(MlOutbox)`;
|
||||
`ResetAsync` очищает `MlOutbox` (как `reset_model` L122; CardMoves и KV-счётчики не трогает).
|
||||
KV `mlDecisions`/`aiDecisions` не инкрементируются — это счётчики РЕШЕНИЙ пайплайна (этап 4),
|
||||
на этапе 3 всегда 0. Обоснование: без таблиц нельзя 1:1 держать `learning/outbox` и семантику reset.
|
||||
- **Ruling 5 (д) — SSE.** `GET /api/events` (`text/event-stream`, `Cache-Control: no-cache`,
|
||||
`X-Accel-Buffering: no`; ping каждые 15 с; без сессии — 401). Брокер — singleton `SseBroker` в
|
||||
`Deal.Api`: per-tenant канал по `TenantId` (тенант сессии при подписке; публикация вне tenant-запроса
|
||||
не падает), очередь подписчика ≤200 с вытеснением старых (прототип `sse.py`). События этапа 3 —
|
||||
только те, что фронт реально слушает (`api.js L62–104`) и которые в этапе возникают: `new_lead`
|
||||
(полный объект карточки §4.1; шлёт demo simulate-lead) и `toast` `{text, icon}` (автоархив/очистки,
|
||||
demo, ИИ-предложения). `boards_changed`/`pipeline_stats`/`leads_reclassified` (недостижимы у фронта,
|
||||
api-map L43) и `reminder_due`/`system_status` (этапы 5/6) НЕ публикуем. Публикации делают ТОЛЬКО
|
||||
эндпоинты Api после вызова сервисов модуля — модуль Kanban остаётся чистым.
|
||||
- **Ruling 6 (е) — поиск/FTS.** `GET /api/search?q=` в этапе 3 ищет по карточкам LIKE-дополнением
|
||||
(`leads.py search` L509–551: title/summary/contact/source_msg, `col != 'taken'`, ORDER BY received_at
|
||||
DESC, limit 12; q<2 символов → `{leads:[], messages:[]}`) без FTS-снимка; `messages: []` (api-map
|
||||
п.3 L393 разрешает). `POST /admin/fts/rebuild` — контракт-заглушка `{ok:true, ready:true}` (реального
|
||||
tsvector-индекса нет; кнопка Settings «Пересобрать индекс» получает ожидаемый ok). Полноценный FTS
|
||||
(карточки+отсев) — этап 4.
|
||||
- **Ruling 7 (ж) — пересчёт конверсий.** Владелец — модуль Kanban (`ConversionRecomputer`): читает
|
||||
`conversionOn`/`targetCurrency` через `ISettingsStore`, курсы — из ключа `ratesCache`
|
||||
(`SettingsKeys.RatesCache`), USDT=USD (L86–91), обновляет `ConvFrom/ConvTo/ConvCur` у карточек с
|
||||
`budgetCur != ''` и `col NOT IN ('archive','trash','taken')` (L106–130). Триггеры — через порт модуля
|
||||
Settings `IRatesChangedListener` (объявляется в Settings, реализует `ConversionRecomputer`,
|
||||
регистрация в `AddKanbanModule`): (1) `RatesService.RefreshAsync` — после успешной записи кэша
|
||||
(покрывает и фоновый RatesRefreshScheduler, как `rates.py refresh_rates` L62–74); (2) PATCH
|
||||
`/settings` — если в теле присутствовали `targetCurrency`/`conversionOn` (синхронно,
|
||||
`settings_routes.py` L186–192).
|
||||
Первичный пересчёт «при поступлении» (бюджет → целевая валюта, `ai.py budget_to_target` L342–352) —
|
||||
чистый `BudgetNormalizer` (используется демо-путём и этапом 4).
|
||||
- **Ruling 8 (з) — архив/корзина: тик и фоновый цикл.** Чистый `StorageTickService` (модуль)
|
||||
повторяет `tick_storage` (`leads.py L454–493`): автоархив (`autoArchive`, `archiveAfterDays` 1..30,
|
||||
карточки досок+inbox по `ReceivedAt` старше срока → col=archive, isNew=false, ArchivedAt=now);
|
||||
очистка архива (`ArchivedAt` старше `archiveClearDays`, дефолт 90); очистка корзины (`ReceivedAt`
|
||||
старше `trashClearDays`, дефолт 7); возврат `{archived, purgedArchive, purgedTrash, purgedRejected:0}`.
|
||||
`POST /api/admin/tick` = тик текущего тенанта + `{storage, reminders: [], pipeline: {}, queue: 0}`
|
||||
(reminders/pipeline — этапы 5/4; фронт в `tickAuto` L1855–1863 читает только `storage`) + SSE-toast
|
||||
статистики (`notify_tick_stats` L496–504; тексты 1:1 «Автоархив: N карточек»/«Архив очищен: N
|
||||
(90 дн.)»/«Корзина очищена: N (7 дн.)», иконки clock/trash). Фоновый цикл — `StorageTickScheduler`
|
||||
(Api, IHostedService): каждые 30 с обходит все тенанты системного репозитория, на каждый —
|
||||
собственный scope с `ITenantContext` (паттерн TenantBootstrapService + guard RatesRefreshScheduler);
|
||||
аналог `_storage_loop` `main.py L43–53`.
|
||||
- **Ruling 9 — служебные точки фронта (boot).** `boot()` фронта (`store.js L571–581`) требует 200 от
|
||||
девяти групп сразу; до этапов 5/6 недостающие `GET /api/projects` и `GET /api/tg/status` даём
|
||||
заглушками: `/projects` → `{items: []}` (проектные карточки — этап 5), `/tg/status` → форма §4.9
|
||||
`{phase:"idle", connected:false, listener:false, account:"", monitored:0, keysSet:false, error:null,
|
||||
qrUrl:null}` (telegram — этап 6). Без них фронт на 404 разлогинивается (catch boot).
|
||||
- **Ruling 10 — форматы/маршрутизация/colState.** Времена наружу — epoch-ms; человеческая метка
|
||||
`time` («только что»/«N мин»/«N ч»/«N дн», `human_age` L528–537) вычисляется на лету от ReceivedAt
|
||||
(колонку `time_label` не храним; расхождение — только для demo age-lead). Статические сегменты
|
||||
регистрируются до `/leads/{lead_id}` (api-map L19). colState — KV `colState`
|
||||
(`SettingsKeys.ColState`): `GET /columns/state` → весь объект; `PATCH /columns/{id}/state` → merge +
|
||||
ответ одной колонки (L141–149); свёрнутость/ширина ДОСКИ — поля Boards (`PATCH /boards/{id}`
|
||||
принимает `collapsed`/`width`, ответ `{id}` — quirk L400/п.10). Создание доски: pos = MAX+1,
|
||||
цвет `PALETTE[pos % 8]`, width='md', visibleFields `["budget","stack","contacts"]` (L74–104).
|
||||
Сортировка — ReceivedAt DESC. Удаление карточки навсегда = Cards + LeadComments (cascade),
|
||||
CardMoves/MlOutbox не трогаем (`_hard_delete` L225–234).
|
||||
- **Ruling 11 — границы и согласованные заглушки.** В этап 3 входят эндпоинты: доски (5), состояние
|
||||
колонок (2), карточки 12 из 13 (без `/leads/{id}/seen` — фронт не вызывает, api-map п.9 L399),
|
||||
`/search`, `/admin/tick`, `/admin/fts/rebuild`, `/ai/suggest-columns`, `/ai/suggest-keywords`,
|
||||
`/demo/simulate-lead`, `/demo/age-lead` (флаг `DEAL_DEMO=1`, иначе 404 «Демо-режим отключён»),
|
||||
`/events`, boot-заглушки (Ruling 9). `POST /leads/reclassify` — заглушка всегда
|
||||
`{started:false, busy:false, attempted:0, reason:"ИИ недоступен — переклассификация требует сервиса
|
||||
ИИ"}` (форма ветки `leads.py L424`; реальная классификация — этапы 4/6). НЕ реализуем: admin/wipe,
|
||||
admin/clear-cards, admin/pump-gate, ml/learn, ml/flush, meta/constants, leads/{id}/seen (api-map п.9).
|
||||
За пределами этапа: pipeline/очередь/отсев (этап 4), projects/напоминания/файлы и `reminder_due`
|
||||
(этап 5), реальные ai/telegram/ml и discovery (этап 6), оператор/инвайты/лимиты (этап 7);
|
||||
`ml/candidates` и `ml/apply` остаются как в этапе 2.
|
||||
- **Ruling 12 — DI и зависимости.** `AddKanbanModule()` (модуль) регистрирует сервисы/`IRatesChangedListener`;
|
||||
`AddDealPersistence()` дополнительно — `IKanjStore → KanbanStore`; `AddDealIntegrations()` —
|
||||
`IColumnSuggester → LocalColumnSuggester`; `IMlClient` уже scoped. Порядок вызовов в Program.cs —
|
||||
как в этапе 2, с добавлением map-групп этапа. Новые HTTP-клиенты не нужны. Id-генерация: короткие
|
||||
префиксные id (`l_`/`b_`/`cm_`/`lm_`/`mle_` + случайный hex, прототип `store.uid`) — утилита в
|
||||
модуле Kanban (не GUID: прототип и фронт требуют коротких ключей в JSON).
|
||||
|
||||
## Задачи
|
||||
|
||||
Сокращения путей: `K=` `src/core/Deal.Modules.Kanban/`, `I=` `src/core/Deal.Infrastructure/`,
|
||||
`A=` `src/core/Deal.Api/`, `C=` `src/core/Deal.Contracts/`, `S=` `src/core/Deal.Modules.Settings/`,
|
||||
`T=` `src/core/tests/Deal.Tests.Unit/`. Отчёты — `task-N-report.md` в `.superpowers/sdd/deal-stage3-kanban/`.
|
||||
|
||||
### Task 1: Миграция TenantKanban — таблицы Boards/Cards/LeadComments/CardMoves/MlOutbox
|
||||
|
||||
**Files:**
|
||||
- Create: `I/Persistence/Entities/{BoardEntity,CardEntity,LeadCommentEntity,CardMoveEntity,
|
||||
MlOutboxItemEntity}.cs` (поля Ruling 1; `DateTimeOffset` для времён; text для JSON-полей и SourceMsg).
|
||||
- Create: `I/Persistence/{BoardConfiguration,CardConfiguration,LeadCommentConfiguration,
|
||||
CardMoveConfiguration,MlOutboxItemConfiguration}.cs` (имена таблиц/индексы Ruling 1; `Col` max 200;
|
||||
FK LeadComments→Cards cascade).
|
||||
- Modify: `I/Persistence/TenantDbContext.cs` — DbSet'ы и `ApplyConfiguration`.
|
||||
- EF: миграция `TenantKanban` для `TenantDbContext` (как `InitialTenant`: `dotnet ef migrations add
|
||||
TenantKanban --context TenantDbContext --output-dir Migrations/TenantDb --project
|
||||
src/core/Deal.Infrastructure --startup-project src/core/Deal.Api`); старт Api применяет её к схеме
|
||||
дефолтного тенанта (TenantBootstrapService/провижинер).
|
||||
|
||||
**Источники:** эталон: `I/Persistence/Entities/TenantSettingEntity.cs` +
|
||||
`I/Persistence/TenantSettingConfiguration.cs` + миграции `I/Migrations/TenantDb/`; Ruling 1.
|
||||
|
||||
**Acceptance:** build 0/0; `dotnet test` MarkerTests PASS; psql (`SET search_path TO
|
||||
tenant_00000000000000000000000000000001;`): таблицы Boards/Cards/LeadComments/CardMoves/MlOutbox
|
||||
созданы, PK, индексы `IX_Cards_Col_ReceivedAt`, `IX_Cards_Col_IsNew`, `IX_Boards_Suggested_Position`,
|
||||
`IX_LeadComments_CardId`; `__TenantMigrationsHistory` содержит TenantKanban. Отчёт: `task-1-report.md`.
|
||||
|
||||
### Task 2: Модуль Kanban — DTO, порт IKanjStore, реестр
|
||||
|
||||
**Files:**
|
||||
- Create: `K/Application/Models/BoardDto.cs` (§4.2 L261–277), `BoardRulesDto.cs` (+`BudgetRangeDto.cs`),
|
||||
`BoardPatchDto.cs`, `CardDto.cs` (§4.1 L230–254; `ReceivedAtMs` наружу int64),
|
||||
`CardBudgetDto.cs`, `CardContactDto.cs`, `CardChannelDto.cs`, `CardCommentDto.cs` (id/by/text/time),
|
||||
`MatchHitDto.cs` (label/term/word?), `CardCountsDto.cs`, `CardsQuery.cs` (col-фильтр),
|
||||
`CardSnapshot.cs` (сырая запись для создания карточки — демо/этап 4), `StorageTickStatsDto.cs`.
|
||||
- Create: `K/Application/IKanjStore.cs` — порт: Boards (List/Get/Create/Update/Delete→moved/Reorder);
|
||||
Cards (List(col?), Get, Add(CardSnapshot), UpdateColumn, UpdateSeen(id|col|all), DeleteForever,
|
||||
ClearCol(col)→count, CountsByCol); Comments (List/Add); CardMoves (Add/Count); StorageTick
|
||||
(ListArchiveCandidates/ListTrashCandidates/Purge); Conversion (ListForConversion); Suggest
|
||||
(ListInboxWithSource).
|
||||
- Create: `K/Application/KanbanModuleRegistrar.cs` — `AddKanbanModule()`: scoped `BoardsService`,
|
||||
`CardsService`, `StorageTickService`, `ConversionRecomputer` + `AddScoped<IRatesChangedListener,
|
||||
ConversionRecomputer>()` (Ruling 7). Modify: `K/Deal.Modules.Kanban.csproj` — ProjectReference на
|
||||
`Deal.Modules.Settings` и `Deal.Contracts`.
|
||||
|
||||
**Источники:** Rulings 1–2, 7; api-map §4.1/§4.2; `leads.py` (структуры); `pipeline.py lead_to_dict`
|
||||
L540–586.
|
||||
|
||||
**Acceptance:** build 0/0 (модуль собирается, DTO — record'ы c camelCase при сериализации, проверка
|
||||
Markers: маркер Kanban в MarkerTests). Отчёт: `task-2-report.md`.
|
||||
|
||||
### Task 3: Чистые правила колонок — ColumnRules + BudgetParser + unit-тесты
|
||||
|
||||
**Files:**
|
||||
- Create: `K/Application/ColumnRules/ContentNormalizer.cs` (ссылки/markdown, L47–54), `AmountParser.cs`
|
||||
(extract_amounts L93–144: «к/К», символы/слова валют, «от…до»/«до…»/«A–B», «$1 200»),
|
||||
`GradeAliases.cs` (L20–27), `ColumnMatcher.cs` (match/score/has_active_rules L176–227, L322–338),
|
||||
`ColumnExclusions.cs` (excluded/is_excluded L230–248), `MatchHitBuilder.cs` (hits L271–296, метки
|
||||
«Направление»/«Слова»/«Стек»/«Грейд/уровень»/«Бюджет», `word` для грейдов), `RulesDescriber.cs`
|
||||
(describe L341–368 — для note), `BudgetInRange.cs` (конвертация валюты при сравнении — чистый
|
||||
интерфейс курсов).
|
||||
- Create: `K/Application/BudgetNormalizer.cs` — clean_budget (`ai.py L316–326`: одна сумма → from=to,
|
||||
«до X» → from null; from=0 → null) + conv-поля «при поступлении» (budget_to_target L342–352:
|
||||
conversionOn/targetCurrency, курсы через интерфейс курсов).
|
||||
- Test: `T/ColumnRulesTests.cs`, `T/AmountParserTests.cs`, `T/BudgetNormalizerTests.cs` (кейсы из
|
||||
правил прототипа: alias «mid»→middle, исключение veto, budget-диапазон с конвертацией USDT=USD,
|
||||
«2к», «от 0 до 100» и т.п.).
|
||||
|
||||
**Источники:** `rules.py` целиком (L15–368), `ai.py L316–352`; BoardRulesDialog.vue (поля правил).
|
||||
|
||||
**Acceptance:** `dotnet test` новых тестов PASS; build 0/0. Отчёт: `task-3-report.md`.
|
||||
|
||||
### Task 4: EF-адаптер KanbanStore + DI
|
||||
|
||||
**Files:**
|
||||
- Create: `I/Persistence/Repositories/KanbanStore.cs` — реализация `IKanjStore` на `TenantDbContext`
|
||||
(AsNoTracking для чтения; JSON-поля сериализует/читает модуль — порт оперирует DTO, маппинг вручную,
|
||||
эталон `SettingsStore.cs`). Хранимые id: PrefixGenerator в модуле (Ruling 12) передаёт готовые id.
|
||||
- Modify: `I/ServiceCollectionExtensions.cs` — `AddScoped<IKanjStore, KanbanStore>()`.
|
||||
- Modify: `A/Program.cs` — `AddKanbanModule()`.
|
||||
|
||||
**Источники:** `SettingsStore.cs` (эталон), Ruling 1/12.
|
||||
|
||||
**Acceptance:** build 0/0; psql+curl-проверка пустых чтений (GET /boards → [], GET /leads →
|
||||
`{items:[]}`, counts → 0) после Task 8-map (порядок: T4 затем T8). Отчёт: `task-4-report.md`.
|
||||
|
||||
### Task 5: IMlClient.PushAsync + LocalMlClient (outbox/learning/status/reset)
|
||||
|
||||
**Files:**
|
||||
- Modify: `C/Integrations/IMlClient.cs` — добавить `PushAsync(string text, string label, double delta,
|
||||
CancellationToken)` (ml_client.push L40–49). DTO-метки: label = id доски | `"spam"` | `"t:hire"` |
|
||||
`"t:order"` (полные — этап 4/6).
|
||||
- Modify: `I/Integrations/LocalMlClient.cs` — ctor + `TenantDbContext` (таблицы CardMoves/MlOutbox):
|
||||
PushAsync → INSERT MlOutbox (id `mle_`, text[:6000], label, delta, CreatedAt=UtcNow);
|
||||
StatusAsync: `learning = count(CardMoves)`, `outbox = count(MlOutbox)`, ml/ai — KV
|
||||
(как сейчас); модель не готова (ready=false) до этапа 4; ResetAsync — удалить строки MlOutbox
|
||||
(прототип reset_model L122); predict — не меняется.
|
||||
- Test: `T/LocalMlClientTests.cs` — дополнить PushAsync (пишет outbox, счётчики learning/outbox в
|
||||
status, reset чистит только outbox). Чтобы тест оставался unit — подсчёты вынести за чистый порт
|
||||
`IMlLearningCounters` (модуль Kanban); финальное решение за исполнителем, но LocalMlClient и тесты
|
||||
должны остаться unit-чистыми.
|
||||
|
||||
**Источники:** `ml_client.py` (L40–49, L110–124, L138–150), Ruling 4, этап 2 Task 9.
|
||||
|
||||
**Acceptance:** build 0/0; тесты PASS; curl: login → `GET /api/ml/status` → `stats.learning:0,
|
||||
stats.outbox:0`; после переноса карточки (Task 7/8) — `learning:1`, `outbox:1` (если колонка не inbox);
|
||||
`POST /api/ml/reset` → outbox:0, learning не меняется. Отчёт: `task-5-report.md`.
|
||||
|
||||
### Task 6: BoardsService — колонки-доски и colState + unit-тесты
|
||||
|
||||
**Files:**
|
||||
- Create: `K/Application/BoardsService.cs` — list_boards L49–67 (ORDER BY suggested, pos; дефолты
|
||||
collapsed из поля), create_board L74–104 (цвет/позиция/ширина/visibleFields; name
|
||||
`strip() or «Новая колонка»`), patch_board L107–121 (404-семантика через результат; allowed:
|
||||
name/description/color/width/collapsed/prompt/keywords/visibleFields/suggested/rules/note),
|
||||
delete_board L124–130 (карточки → inbox isNew, prevCol=inbox; вернуть moved), reorder_boards L133–135,
|
||||
get/set_col_state L138–146 (KV colState через ISettingsStore; словарь JSON).
|
||||
- Test: `T/BoardsServiceTests.cs` (fake IKanjStore): создание (pos/цвет/width/visibleFields), патч
|
||||
(JSON-поля), удаление (moved→inbox), colState merge/значения.
|
||||
|
||||
**Источники:** `leads.py` L49–146; api-map §3.2 доски L66–70, §4.2; Rulings 1/10; `constants.py`
|
||||
PALETTE L12–16.
|
||||
|
||||
**Acceptance:** `dotnet test` PASS; build 0/0. Отчёт: `task-6-report.md`.
|
||||
|
||||
### Task 7: CardsService — карточки: чтение, переносы, архив/корзина, комментарии, counts
|
||||
|
||||
**Files:**
|
||||
- Create: `K/Application/CardsService.cs`:
|
||||
- list_leads/get_lead (L151–160): маппинг CardDto (receivedAt ms, time от ReceivedAt, budget,
|
||||
converted, contacts fallback `qualify_contact`-проверка, ch, comments из LeadComments, matchHits);
|
||||
- move_lead (L177–191): валидация `to ∈ inbox ∪ доски` (иначе 400 «Переносить можно только на доски
|
||||
или в «Неразобранное»»), `_move` L163–174 (matchHits пересчёт через ColumnRules для досок),
|
||||
журнал CardMoves(action=move) + PushAsync (текст = sourceMsg или title) при to≠inbox;
|
||||
- trash_lead (L194–201): журнал(action=trash) + Push spam 1.0 (кроме карточек уже в archive/trash);
|
||||
- restore_lead (L204–222): назад в prevCol (валидный), isNew=true, archivedAt=null, matchHits,
|
||||
журнал(action=restore); возврат из корзины — Push spam −1.0;
|
||||
- delete_forever (L225–234), clear_col (L237–247: только trash|archive, 400 «Очищать можно только
|
||||
корзину или архив», вернуть cleared);
|
||||
- mark_seen (L250–256: id|col|all); add_comment (L259–265: 400 «Пустой комментарий», LeadComments
|
||||
вставка, журнал(action=comment));
|
||||
- counts (L268–279): по Cards (col + isNew) + learning/ml/ai из IMlClient.StatusAsync;
|
||||
- search (L509–551, LIKE-вариант) — вызывается эндпоинтом напрямую или через сервис (см. Task 8).
|
||||
- Create: `K/Application/CardMapper.cs` (CardEntity/сырые строки → CardDto; чистая функция;
|
||||
`human_age` L528–537), `K/Application/PrefixId.cs` (Ruling 12).
|
||||
- Test: `T/CardsServiceTests.cs` (fake IKanjStore + fake IMlClient): move с правилами (matchHits),
|
||||
move на неизвестную доску → ошибка 400-текста, trash/restore (журнал+push), clear_col 400 на доске,
|
||||
mark_seen, комментарий пустой/валидный, counts-форма, search лимит/мин-длина.
|
||||
|
||||
**Источники:** `leads.py` L151–279, L509–551; `pipeline.py lead_to_dict` L540–586; `rules.py`
|
||||
hits_for_board; api-map §3.2 лиды L83–95, §4.1; Rulings 2/4/10.
|
||||
|
||||
**Acceptance:** `dotnet test` PASS; build 0/0. Отчёт: `task-7-report.md`.
|
||||
|
||||
### Task 8: Эндпоинты досок/колонок/карточек/поиска + DI + curl-приёмка
|
||||
|
||||
**Files:**
|
||||
- Create: `A/Endpoints/BoardsEndpoints.cs` (`MapBoardsEndpoints`): GET `/api/boards` (голый массив!),
|
||||
POST `/api/boards`, PATCH `/api/boards/{boardId}` (404 «Доска не найдена»), DELETE `/api/boards/{id}`,
|
||||
POST `/api/boards/reorder`, GET `/api/columns/state`, PATCH `/api/columns/{colId}/state`.
|
||||
- Create: `A/Endpoints/LeadsEndpoints.cs` (`MapLeadsEndpoints`): GET `/api/leads?col=` (400 «Неизвестная
|
||||
колонка»), GET `/api/leads/counts`, GET `/api/leads/{leadId}` (404 «Карточка не найдена»),
|
||||
POST `/api/leads/mark-all-seen`, POST `/api/leads/mark-col-seen` {col}, POST `/api/leads/{id}/move`
|
||||
{to} (400 текст move_lead) → обновлённый CardDto, POST `/api/leads/{id}/trash`, POST
|
||||
`/api/leads/{id}/restore` → `{ok, col}`, DELETE `/api/leads/{id}`, POST `/api/leads/clear-col`
|
||||
{col: trash|archive} → `{ok, cleared}`, POST `/api/leads/{id}/comments` {text} → `{comments}`,
|
||||
POST `/api/leads/reclassify` (заглушка Ruling 11), GET `/api/search?q=` (Ruling 6).
|
||||
⚠ Статические сегменты регистрируются до `{leadId}` (Ruling 10). Сессия — `HasUser`/`GetCurrentUser`,
|
||||
401 `AuthHelpers.UnauthorizedDetail` (эталон MlEndpoints).
|
||||
- Create: `A/Endpoints/RequestModels/*` — `BoardCreateRequest`, `BoardPatchRequest`, `OrderBody`,
|
||||
`ColStateBody`, `MoveBody`, `CommentBody`, `MarkColBody`, `ClearColBody`, `ReclassifyBody` (1 тип =
|
||||
1 файл).
|
||||
- Modify: `A/Program.cs` — `MapBoardsEndpoints()`, `MapLeadsEndpoints()`.
|
||||
- Modify: `A/Deal.Api.csproj` — ProjectReference `Deal.Modules.Kanban`.
|
||||
|
||||
**Контракт:** api-map §3.2 L66–101; ответы/детали — Task 6/7/Rulings. GET /boards — без `{items}`.
|
||||
|
||||
**Acceptance (curl, admin/admin):** пустые boards/leads/counts; создание доски POST {name:"Middle
|
||||
Python", keywords:["python"], rules:{mode:"all", stack:["python"]}} → {id:"b_…"}; PATCH width/collapsed;
|
||||
reorder; GET /columns/state {} и PATCH collapsed → `{"collapsed":true}`; затем Task 13 демо-карточки и
|
||||
полный цикл карточек (move/trash/restore/clear-col/комментарий/404-тексты). Отчёт: `task-8-report.md`.
|
||||
|
||||
### Task 9: SSE-брокер + GET /api/events + boot-заглушки /projects и /tg/status
|
||||
|
||||
**Files:**
|
||||
- Create: `A/Events/SseBroker.cs` (singleton; Ruling 5), `A/Events/SseEvent.cs` (record: тип+JSON),
|
||||
`A/Endpoints/EventsEndpoint.cs` (`MapEventsEndpoint`): GET `/api/events` — авторизация (401), заголовки
|
||||
no-cache/X-Accel-Buffering, ping каждые 15 с, подписка на канал тенанта (ITenantContext), отписка при
|
||||
завершении.
|
||||
- Create: `A/Endpoints/BootStubEndpoints.cs` (`MapBootStubEndpoints`): GET `/api/projects` →
|
||||
`{items: []}`; GET `/api/tg/status` → idle-форма Ruling 9 (комментарий: этапы 5/6).
|
||||
- Modify: `A/Program.cs` — singleton SseBroker, map-группы.
|
||||
|
||||
**Источники:** `sse.py` целиком; `api.js openEvents` L62–104; api-map §2, L43; `store.js boot`
|
||||
L571–581; §4.9 L359.
|
||||
|
||||
**Acceptance:** build 0/0; curl: `curl -N` на /api/events без куки → 401; с кукой — поток открыт, ping
|
||||
`:` ~15 с; `GET /api/projects` → `{"items":[]}`, `GET /api/tg/status` — все поля §4.9. (Публикация
|
||||
событий проверяется в Tasks 10/13/14.) Отчёт: `task-9-report.md`.
|
||||
|
||||
### Task 10: StorageTickService + POST /api/admin/tick + /admin/fts/rebuild + SSE-toast
|
||||
|
||||
**Files:**
|
||||
- Create: `K/Application/StorageTickService.cs` — Ruling 8 (архив/очистки через IKanjStore; кандидаты
|
||||
— по ReceivedAt/ArchivedAt с настройками из ISettingsStore; удаление = DeleteForever).
|
||||
- Create: `A/Endpoints/StorageEndpoints.cs` (`MapStorageEndpoints`): POST `/api/admin/tick` →
|
||||
StorageTickService.TickAsync + `{storage, reminders:[], pipeline:{}, queue:0}` + публикация SSE-toast
|
||||
по статистике (тексты/иконки 1:1, Ruling 8) через SseBroker; POST `/api/admin/fts/rebuild` →
|
||||
`{ok:true, ready:true}` (Ruling 6).
|
||||
- Modify: `A/Program.cs` — map.
|
||||
|
||||
**Источники:** `leads.py` tick_storage L454–493 + notify_tick_stats L496–504; `dashboard_routes.py`
|
||||
L327–337 (admin_tick), L261–264 (fts_rebuild); api-map L103–112; `store.js tickAuto` L1855–1863,
|
||||
rebuildFts L1884–1889; Rulings 5/6/8.
|
||||
|
||||
**Acceptance:** `dotnet test` (если юнит для StorageTickService — на fake store); curl: с демо-карточкой
|
||||
на доске PATCH settings archiveAfterDays=1 → POST /api/admin/tick (после demo/age-lead из Task 13) →
|
||||
storage.archived=1, SSE-toast «Автоархив…»; clear-col/trash → purged-тосты; fts/rebuild → ok:true.
|
||||
Отчёт: `task-10-report.md`.
|
||||
|
||||
### Task 11: StorageTickScheduler — фоновый цикл правил хранения по тенантам
|
||||
|
||||
**Files:**
|
||||
- Create: `A/StorageTickScheduler.cs` — IHostedService: Timer 30 с; каждое срабатывание в собственном
|
||||
scope: список тенантов (`ITenantRepository`/системный контекст), на каждый тенант — новый scope,
|
||||
`ITenantContext` set (эталон TenantBootstrapService), `StorageTickService.TickAsync` + SSE-toast через
|
||||
SseBroker (публикация в канал тенанта; без подписчиков — no-op). In-flight guard (Interlocked) и
|
||||
try/catch — как RatesRefreshScheduler.
|
||||
- Modify: `A/Program.cs` — `AddHostedService<StorageTickScheduler>()`.
|
||||
|
||||
**Источники:** `main.py _storage_loop` L43–53; `A/Hosting/TenantBootstrapService.cs`,
|
||||
`A/RatesRefreshScheduler.cs` (эталоны); Ruling 8.
|
||||
|
||||
**Acceptance:** build 0/0; запуск Api — в логе нет ошибок цикла; с демо-возрастом карточки архив
|
||||
срабатывает и без ручного tick (в пределах ~40 с). Отчёт: `task-11-report.md`.
|
||||
|
||||
### Task 12: Пересчёт конверсий — ConversionRecomputer + IRatesChangedListener
|
||||
|
||||
**Files:**
|
||||
- Create: `S/Application/IRatesChangedListener.cs` — порт модуля Settings:
|
||||
`Task OnRatesChangedAsync(bool fullRecompute, CancellationToken ct)`.
|
||||
- Modify: `S/Application/RatesService.cs` — после успешной записи кэша (mock или cbr) вызвать всех
|
||||
`IRatesChangedListener` (список в ctor, пустой — no-op). Modify: `S/Application/SettingsService.cs`
|
||||
— в PATCH, если в теле присутствовали `targetCurrency` или `conversionOn`, вызвать listener'ов (Ruling 7).
|
||||
- Create: `K/Application/ConversionRecomputer.cs` (scoped; `IRatesChangedListener`): полный пересчёт —
|
||||
карточки из `IKanjStore.ListCardsForConversion`; курс из ratesCache (JSON `{rates,…}`, USDT=USD);
|
||||
conversionOn=false → 0; обновление ConvFrom/ConvTo/ConvCur через KanbanStore.
|
||||
- Create: `K/Application/RateTable.cs` — чистый парсинг ratesCache (`{rates,…}`, USDT=USD) +
|
||||
конвертер; RatesService-часть Settings не трогаем.
|
||||
- Modify: `K/Application/KanbanModuleRegistrar.cs` — регистрация (Ruling 12).
|
||||
- Test: `T/ConversionRecomputerTests.cs` (fake settings-store + fake kanban-store: mock-курсы,
|
||||
USDT=USD, conversionOn=false, col archive исключён, targetCurrency смена).
|
||||
|
||||
**Источники:** `rates.py` recompute_conversions L106–130, refresh L62–74, _resolve_rate L86–91;
|
||||
`settings_routes.py` L186–192; Ruling 7.
|
||||
|
||||
**Acceptance:** тесты PASS; curl-сценарий: demo-карточка с бюджетом USD (Task 13) → conv в RUB;
|
||||
PATCH settings {targetCurrency:"USD"} → conv пересчитан; PATCH {rateSource:"mock"} + POST /rates/refresh
|
||||
→ conv обновлён; карточка в архиве — conv не меняется (psql-проверка). Отчёт: `task-12-report.md`.
|
||||
|
||||
### Task 13: Демо-карточки — POST /demo/simulate-lead, /demo/age-lead (флаг DEAL_DEMO)
|
||||
|
||||
**Files:**
|
||||
- Create: `K/Application/DemoLeadFactory.cs` — демо-пул 1:1 с `dashboard_routes.py L77–89` + создание
|
||||
карточки: нормализация бюджета (BudgetNormalizer), контакты (build_contacts/primary_contact —
|
||||
достаточно примитивной версии для заданных полей), matchHits=[] для inbox, prevCol=inbox,
|
||||
sourceMsg/dialogId (`demo_channel`)/ch-поля, isNew=true. Добавление через IKanjStore.Add.
|
||||
- Create: `A/Endpoints/DemoEndpoints.cs` (`MapDemoEndpoints`): POST `/api/demo/simulate-lead` —
|
||||
флаг (appsettings/`DEAL_DEMO`), иначе 404 «Демо-режим отключён»; создание карточки → CardDto;
|
||||
SseBroker: new_lead (полная карточка) + toast «Демо: новый лид» (sparkles); POST `/api/demo/age-lead`
|
||||
— состарить самую старую карточку досок (receivedAt = now − (archiveAfterDays+1) дней; 400 «Нет
|
||||
карточек на досках для демо»), затем тик StorageTickService и toast при архивировании.
|
||||
- Modify: `A/appsettings*.json` — секция `Demo: { Enabled: false }` (Development — true).
|
||||
- Modify: `A/Program.cs` — map + DI.
|
||||
|
||||
**Источники:** `dashboard_routes.py` L287–324; `pipeline.py _store_lead` L433–514; devtests
|
||||
`backend/devtests/{boot_test,e2e_test}.py` (эталон сценариев приёмки); api-map L114.
|
||||
|
||||
**Acceptance:** curl с DEAL_DEMO=1: simulate-lead → полный объект §4.1 (id l_…, col inbox, title,
|
||||
summary, stack, budget, contacts, ch, receivedAt); повторные вызовы наполняют inbox; age-lead → 200;
|
||||
`GET /api/leads?col=inbox` сортировка DESC. Без флага — 404. Отчёт: `task-13-report.md`.
|
||||
|
||||
### Task 14: ИИ-предложения — порт IColumnSuggester + эвристика + /api/ai/suggest-columns|keywords
|
||||
|
||||
**Files:**
|
||||
- Create: `C/Integrations/IColumnSuggester.cs`, `C/Integrations/Models/ColumnSuggestionDto.cs`
|
||||
(Ok/Created/Reason/Cooldown/Keywords) — Ruling 3.
|
||||
- Create: `K/Application/SuggestHeuristics.cs` — чистое ядро: частотные слова-темы по текстам (≥3 букв,
|
||||
lowercase, минус стоп-слова), темы ≥2 карточек (MAX_TEXT=12, MIN_INBOX=6, ≤4 колонок), похожесть с
|
||||
существующими досками (L55–61), правила `{mode:"any", keywords:[…]}` и note-обоснования («Эвристика
|
||||
(этап 3): …N карточек; реальные предложения ИИ — этап 6»); для suggest-keywords — частотные маркеры
|
||||
(≤60, ≤40 симв.).
|
||||
- Create: `I/Integrations/LocalColumnSuggester.cs` — реализует IColumnSuggester: читает inbox через
|
||||
`IKanjStore`, вызывает SuggestHeuristics, создаёт доски `suggested=true` (note/description) и
|
||||
раскладывает карточки (isNew=true), возвращает created; причины — детерминированные строки Ruling 3.
|
||||
suggest-keywords: <3 карточек → «мало карточек — сначала накопите заявки (нужно хотя бы 3)».
|
||||
- Create: `A/Endpoints/AiSuggestEndpoints.cs` (`MapAiSuggestEndpoints`): POST `/api/ai/suggest-columns`
|
||||
→ результат; при ok:true — SSE-toast «ИИ предложил колонок: N — откройте и решите» (sparkles) 1:1
|
||||
(boards_changed не шлём — Ruling 5); POST `/api/ai/suggest-keywords` → `{ok, keywords}` | `{ok:false,
|
||||
reason}`.
|
||||
- Modify: `I/ServiceCollectionExtensions.cs` — `AddScoped<IColumnSuggester, LocalColumnSuggester>()`;
|
||||
`A/Program.cs` — map.
|
||||
|
||||
**Источники:** `suggest.py` целиком (константы L48–52, suggest L76–163, keywords L166–193,
|
||||
_make_note/_store_suggested/_assign_ids/_rollback L196–248); api-map L120–121; `store.js
|
||||
suggestColumns` L1097–1113; Rulings 3/5.
|
||||
|
||||
**Acceptance:** тесты на SuggestHeuristics (детерминированность: одинаковый вход → одинаковый выход);
|
||||
curl: 6+ демо-карточек с общей темой (например, повторяющиеся simulate с «Python») →
|
||||
POST /api/ai/suggest-columns → `{ok:true, created≥1}`; GET /api/boards — доска suggested=true с
|
||||
карточками; PATCH suggested:false → принята; «мало карточек» на пустом inbox → `{ok:false, reason}`.
|
||||
Отчёт: `task-14-report.md`.
|
||||
|
||||
### Task 15: Финал этапа — интеграция и сквозная приёмка
|
||||
|
||||
- `scripts/build.sh`/`scripts/test.sh` — успешны; `dotnet build Deal.sln` 0/0; все unit-тесты PASS
|
||||
(175 этапа 2 + новые).
|
||||
- Сквозной curl-сценарий канбана: login → boot-группы (boards/leads/counts/columns/state/projects/
|
||||
tg/status/settings/rates/ml) → demo simulate-lead ×N → создание доски с правилами → move карточки
|
||||
(matchHits в ответе) → learning/ml-счётчики (status) → mark-col-seen/mark-all-seen → комментарий →
|
||||
trash → restore → clear-col → suggest-columns (эвристика, ok/created) → age-lead + admin/tick
|
||||
(автоархив, SSE-toast) → PATCH targetCurrency + rates/refresh (пересчёт conv, psql) → search?q= →
|
||||
admin/fts/rebuild → 401-проверки без куки.
|
||||
- psql-проверка схемы дефолтного тенанта: строки Boards/Cards/LeadComments/CardMoves/MlOutbox,
|
||||
PascalCase-колонки; matchHits/конвертации корректны; colState в settings.
|
||||
- Обновить `docs/technical/Техническая-документация-Дейл.md`: раздел «Дашборд/канбан» (эндпоинты,
|
||||
таблицы этапа, SSE-события, StorageTickScheduler, демо-режим DEAL_DEMO, пересчёт конверсий).
|
||||
- Отчёт `task-15-report.md` + финальная строка в `progress.md`; roadmap-флаг «этап 3 выполнен».
|
||||
|
||||
## Self-Review
|
||||
|
||||
1. **Spec coverage:** ТЗ §5 «Карточка» (L112–121) — Task 7/13 (поля, «О заявке»-summary — приходит
|
||||
структурой из pipeline/demo; блоки Компания→Условия — формат summary, композиция — этап 4);
|
||||
ТЗ §6 (L121–135) — Tasks 1–14 (колонки/фильтры/отрицательные — Task 3/6; «почему в колонке» —
|
||||
Task 7; свежие сверху/виджеты/ширина/colState — Task 6/8; drag&drop+ML — Task 7; ИИ-предложения —
|
||||
Task 14; архив/корзина — Tasks 10/11); api-map §3.2 (L60–121) — Tasks 8/10/13/14; §2 SSE —
|
||||
Task 9; §4.1/4.2 — Tasks 2/6/7; роадмап-этап 3 — все задачи; рекомендации этапа 2 (Ruling 5 —
|
||||
PushAsync, Ruling 6 — recompute_conversions) — Tasks 5/12; boot-требование фронта — Ruling 9/Task 9.
|
||||
2. **Placeholder scan:** заглушки — только согласованные: `LocalMlClient` (модель не готова до этапа 4,
|
||||
outbox/learning живые), `LocalColumnSuggester` (эвристика до ИИ-этапа 6), reclassify (форма-ветка,
|
||||
Ruling 11), boot-стабы /projects и /tg/status (этапы 5/6), fts/rebuild no-op (этап 4), демо-пул
|
||||
(как прототип). Референсы на строки файлов прототипа — точные; FIXME/TODO нет.
|
||||
3. **Type consistency:** один модуль Kanban владеет карточками/колонками; настройки (архив/colState/
|
||||
счётчики/курсы) — через `ISettingsStore` модуля Settings (общий каталог ключей не дублируется);
|
||||
`IMlClient`-контракт един (панель этапа 2 + обучение этапа 3 + предсказания этапа 4);
|
||||
`IColumnSuggester` в Contracts — подмена реализации на ИИ этапа 6 без правки эндпоинтов;
|
||||
новые сущности/конфиги/миграция следуют конвенции `TenantSettingEntity`; сущности Settings не
|
||||
меняются; время жизни — scoped/singleton как в этапах 1–2.
|
||||
4. **Вне scope этапа 3:** Projects (этап 5; отдаём boot-заглушку), Pipeline/очередь/отсев/FTS-индекс/
|
||||
дедуп и pipeline_stats (этап 4; reclassify — заглушка), Discovery (этап 6), реальные ai/telegram/ml
|
||||
сервисы и /api/tg/* (этап 6; tg/status — boot-заглушка), «Отклонено» (проектный канбан, этап 5),
|
||||
reminder_due (этап 5), оператор/инвайты/лимиты/аудит (этап 7), события boards_changed/
|
||||
leads_reclassified (недостижимы у фронта — не публикуем), админ-эндпоинты wipe/clear-cards/pump-gate
|
||||
(фронт не вызывает), ml/learn|flush, /leads/{id}/seen.
|
||||
@@ -0,0 +1,569 @@
|
||||
# Дейл (Deal) — Этап 4: Pipeline и «Обработка»: очередь, стоп-лист, дедуп, отсев, ML/ИИ-порты, FTS Implementation Plan
|
||||
|
||||
> Исторический документ этапа 4. Актуальное состояние — `docs/superpowers/STATUS.md` и `docs/technical/Техническая-документация-Дейл.md`.
|
||||
|
||||
**Goal:** Оживить в модульном монолите `src/core` вкладку «Обработка» Vue-фронта 1:1-контрактом `/api`
|
||||
пайплайна входящих: приём сообщений (порт + демо-ингвест до telegram-этапа 6), очередь сырых сообщений,
|
||||
разбор фоновым воркером по пути ТЗ §5 **источник → очередь → стоп-лист → дедуп → ML → ИИ → карточка**,
|
||||
отсев с причиной/источником решения (правила/ML/ИИ/система + конкретное слово/фраза), возврат из отсева
|
||||
(ignore-причин + обучение), полнотекстовый поиск по отсеву и карточкам (настоящий FTS в Postgres),
|
||||
автоочистка отсева раз в 3 суток + ручная, счётчики вкладки. К концу этапа ProcessingView полностью
|
||||
обслуживается бэкендом на реальном сквозном пути «демо-сообщение → очередь → фильтры → карточка/отсев»
|
||||
(telegram-источник — этап 6); приёмка — unit/curl/psql. ML-модель не готова (LocalMlClient ready:false) —
|
||||
ML-ветка реализована, но «спит» до этапа 6; ИИ — порт `IAiClassifier` + детерминированный локальный
|
||||
классификатор (реальный ai-service — этап 6).
|
||||
|
||||
**Architecture:** новый модуль `Deal.Modules.Pipeline` (чистый, без EF/HTTP) — владелец таблиц
|
||||
`QueueItems`/`RejectedItems`/`DedupEntries` (миграция `TenantPipeline` в `TenantDbContext`) и логики
|
||||
воркера: DTO очереди/отсева (§4.5), порт `IPipelineStore`, сервисы `PipelineIngestService` (приём,
|
||||
используется демо-ингвестом и, на этапе 6, gRPC-адаптером telegram-service), `PipelineProcessingService`
|
||||
(чтение/поиск очереди и отсева, возврат, очистки, запись отсева), чистое ядро разбора `MessageParseCore`
|
||||
(clean_short/clean_block, normalize_list/stack, qualify/build/primary контакты, dedup-хэш, compose_summary
|
||||
«О заявке», локальные поля `_local_fields`), `PipelineWorkerService` (pump: stale → stage1 → дедуп → ML →
|
||||
ИИ/локальный разбор → карточка/отсев). Настройки — порт `ISettingsStore` + `IncomingRules` модуля Settings
|
||||
(этап-1 готов); доски/правила/карточки — через публичный интерфейс модуля Kanban: порт `IKanjStore`
|
||||
(GetBoardAsync/AddCardAsync), статические чистые `ColumnRules`/`BudgetNormalizer`/`AmountParser`;
|
||||
ML — существующий порт `IMlClient` (Contracts); ИИ — новый порт `IAiClassifier` (Contracts/Integrations) с
|
||||
детерминированным `LocalAiClassifier` в Infrastructure (замена gRPC-клиентом ai-service на этапе 6).
|
||||
Адаптеры EF — в `Deal.Infrastructure`: `PipelineStore`, доработка `KanbanStore` (жёсткое удаление карточки
|
||||
чистит строки `DedupEntries` по LeadId), доработка `LocalMlClient` НЕ требуется (счётчики решений
|
||||
ml/ai инкрементирует сам модуль Pipeline в KV). HTTP — `Deal.Api/Endpoints` (`MapPipelineEndpoints`,
|
||||
`/api/demo/ingest` в `MapDemoEndpoints`); фоновые циклы — `PipelineWorkerScheduler` (2 с) и доработка
|
||||
`StorageTickScheduler` (чистка отсева). Публикации SSE — только из Api-слоя (Ruling 5 этапа 3): `new_lead`
|
||||
при создании карточки воркером, toast при автоочистке отсева; `pipeline_stats` НЕ публикуем (фронт его не
|
||||
слушает — Ruling 5/9).
|
||||
|
||||
**Spec:** `docs/api/api-map.md` §3.6 (L176–186), §2 SSE (L33–43), правила (L7–24; п.9 «экономия» L399,
|
||||
кривые места L390–400, п.1 SSE L43); §4.5 очередь и отсев (L306–314), §4.1 карточка (L228–257), §4.6
|
||||
(L319–341 — настройки обработки: stopPhrases/minLen/blockResumes/wantedType/budgetRequired*/autoArchive/
|
||||
archiveAfterDays/aiEnabled/aiFilterEnabled/mlEnabled/domainKeywords/hireMarkers/resumeMarkers/levelTerms);
|
||||
`docs/spec/ТЗ-дейл-новая-архитектура.md` §5 «Обработка входящих» (L84–121), §7 «Вкладка „Обработка"»
|
||||
(L150–161); roadmap (этап 4, L57–62); референс-семантика прототипа: `backend/app/services/pipeline.py`
|
||||
(целиком: enqueue L53–85, stage1_plain L94–124, clean_short/block L148–193, _skip_no_budget L196–218,
|
||||
compose_summary L225–284, нормализация L294–341, контакты L344–430, _store_lead L433–514, локальные
|
||||
поля L661–798, воркер L803–1183), `backend/app/services/processing.py` (целиком: record L66–101,
|
||||
purge_expired L104–117, clear_all/return_to_queue L120–193, list_queue/list_rejected/stats L201–320),
|
||||
`backend/app/routers/processing_routes.py` (целиком), `backend/app/services/fts.py` (целиком),
|
||||
`backend/app/services/leads.py` (L225–247 _hard_delete/clear_col, L454–504 tick_storage + notify,
|
||||
L509–551 search), `backend/app/services/ai.py` (L261–267 normalize_dedup, L316–352 clean_budget/
|
||||
budget_to_target), `backend/app/services/ml_client.py` (L26–28 веса, L160–162 is_enabled),
|
||||
`backend/app/routers/dashboard_routes.py` (L261–284, L327–337), `backend/app/constants.py`,
|
||||
`backend/app/db.py` (L88–92 dedup, L226–268 pipeline_msg/rejected_msgs);
|
||||
фронт: `src/frontend/src/views/ProcessingView.vue` (вся вкладка: счётчики L221–243, очередь L297–400,
|
||||
отсев L402–556, canReturn/return), `src/frontend/src/store.js` (pipeline-секция L1210–1343: loadPipelineQueue
|
||||
L1213–1223 limit=120, loadRejected L1226–1246 limit=80 offset, refreshPipelineStats L1249–1258,
|
||||
deleteRejectedItem L1284–1295, returnRejected L1300–1318, clearRejectedAll L1320–1333, SSE L676–679 —
|
||||
обработчик pipeline_stats недостижим, api.js L62–104 слушает только 4 события), `src/frontend/src/api.js`,
|
||||
`utils.js` (tgSourceUrl); конвенции планов этапов 1–3 (файлы `docs/superpowers/plans/2026-09-05-deal-stage{1,2,3}-*.md`).
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Проект **НЕ git**; фиксация — отчёты задач `task-N-report.md` и `progress.md` в `.superpowers/sdd/deal-stage4-pipeline/`.
|
||||
- .NET 10 SDK, решение собирается 0 warnings / 0 errors (`TreatWarningsAsErrors`); dev-Postgres `deal-postgres`
|
||||
(:5433); curl-приёмка :5080 (`scripts/build.sh`/`scripts/test.sh`).
|
||||
- Код-стайл этапов 1–3: 1 тип = 1 файл; XML-doc на public-контракты; комментарии на русском; явные
|
||||
модификаторы; без регионов; без магических чисел (именованные константы); PascalCase-колонки БД;
|
||||
времена — `DateTimeOffset` (UTC) в БД, наружу epoch-ms; JSON camelCase; ошибки `{"detail"}`.
|
||||
- Модуль Pipeline — чистый: без EF и HTTP; зависимости — `Deal.Modules.Settings` (порт `ISettingsStore`,
|
||||
сервис `IncomingRules`), `Deal.Modules.Kanban` (порт `IKanjStore`, статические ColumnRules/BudgetNormalizer/
|
||||
AmountParser, модели CardSnapshot/CardDto), `Deal.Contracts` (IMlClient, IAiClassifier). Реверс-зависимостей
|
||||
нет (Kanban/Settings о Pipeline не знают). Kanban НЕ получает ссылок на Pipeline — слияние статистик тика
|
||||
и жёсткое удаление dedup — в адаптерах/Api (Rulings 3/9).
|
||||
- LeadRadar-контейнеры, `backend/`, `mlservice/`, `src/frontend/` НЕ трогаем. Vue-фронт не переписывается:
|
||||
формы JSON 1:1 с api-map; «кривые места» этапа 4: `pipeline_stats` у фронта недостижим (Ruling 9),
|
||||
`GET /api/search` → `messages: []`.
|
||||
- Строки ошибок/тостов/причин — фиксированные из прототипа (см. задачи); новые строки — только для
|
||||
согласованных добавок (демо-ingest, Ruling 11).
|
||||
|
||||
## Зафиксированные решения (Rulings этапа)
|
||||
|
||||
- **Ruling 1 (а) — миграция TenantPipeline и таблицы.** Новая миграция `TenantPipeline` контекста
|
||||
`TenantDbContext` (папка `I/Migrations/TenantDb`, применяется провижинером ко всем схемам). Таблицы
|
||||
(PascalCase, владелец — модуль Pipeline; соответствие db.py L88–92/L226–268): `QueueItems` (= pipeline_msg),
|
||||
`RejectedItems` (= rejected_msgs), `DedupEntries` (= dedup). Колонки QueueItems: Id (`p_`, текст),
|
||||
DialogId, ChannelName/ChannelHandle/ChannelHue (дефолт `#666`), Text (≤6000), MsgId (long?, nullable),
|
||||
MsgAt, Status (`new`|`filtered`), Force (bool), CreatedAt, UpdatedAt; индекс `(Status, CreatedAt)`.
|
||||
RejectedItems: Id (текст; детерминированный `r_<dialog>_<msgId>` при наличии dialog+msgId, иначе `r_`+hex —
|
||||
как processing.record L77; upsert `ON CONFLICT (id) DO UPDATE`), DialogId, MsgId (long?), ChannelName/
|
||||
ChannelHandle/ChannelHue, Text (≤6000), Stage, Reason (≤500), Kw (≤200), Source, MsgAt, RejectedAt,
|
||||
Returned (bool), ReturnedAt (nullable), ReturnReason (≤500), SearchTsv (см. Ruling 6); индекс `(RejectedAt)`
|
||||
+ GIN `(SearchTsv)`. DedupEntries: Hash (текст, PK), LeadId (nullable, БЕЗ FK — «мягкая» ссылка на Cards,
|
||||
как прототип; чистка при жёстком удалении карточки — Ruling 3), CreatedAt. JSON-полей нет (все поля —
|
||||
плоские колонки); связи с Cards нет FK (журнал/отсев живут дольше карточки, конвенция Ruling 1 этапа 3).
|
||||
В той же миграции — FTS: `Cards.SearchTsv` и `RejectedItems.SearchTsv` (Ruling 6). Индексы/конфиги — 1
|
||||
файл на сущность, эталон CardEntity+CardConfiguration.
|
||||
- **Ruling 2 (б) — порт приёма сообщений и демо-ингвест.** Приём — публичный scoped-сервис модуля
|
||||
`PipelineIngestService.EnqueueAsync(QueuedMessage message, CancellationToken)` (1:1 prototype enqueue L53–85:
|
||||
trim текста, пустой текст/нет dialog → no-op; text[:6000]; msg_id-дубль-гвард на уровне адаптера
|
||||
`SELECT 1 FROM QueueItems WHERE DialogId=? AND MsgId=?` — защита от двойного события Telethon; id `p_`).
|
||||
На этапе 4 его вызывает ТОЛЬКО демо-эндпоинт `POST /api/demo/ingest` (Ruling 11; флаг DEAL_DEMO, иначе
|
||||
404 «Демо-режим отключён»); этап 6 — gRPC-ингресс telegram-service вызовет тот же сервис (контракт
|
||||
стабилен, интерфейс не плодим — YAGNI). Разбор очереди — воркер (Ruling 8) + `POST /api/admin/tick`
|
||||
(Ruling 10), как прототип (pump L890–918 вызывается из `_pipeline_loop` и admin_tick L336).
|
||||
- **Ruling 3 (в-1) — кто пишет карточку и доступ к Kanban.** Карточку создаёт модуль Pipeline, но ТОЛЬКО
|
||||
через публичный интерфейс модуля-владельца Kanban (архитектура §5 L130–131): `IKanjStore.AddCardAsync
|
||||
(CardSnapshot)` + `GetBoardAsync`; проверка назначения колонки и «почему в колонке» — статические чистые
|
||||
`ColumnRules.BoardAccepts/HasActiveRules/ComputeHits` и `BudgetNormalizer`/`AmountParser` модуля Kanban
|
||||
(доступ к чистым помощникам владельца — не дублируем). Добавление ссылки `Pipeline → Kanban` цикла не
|
||||
создаёт (Kanban про Pipeline не знает). Подготовка полного `CardSnapshot` — `CardComposer` в модуле Pipeline
|
||||
(перенос `_store_lead` L433–514, Ruling 4). Жёсткое удаление карточки (Kanban DELETE /leads/{id}, clear-col,
|
||||
очистки тика) по контракту api-map §3.2 L92 — «leads+dedup+messages»: дорабатываем EF-адаптер `KanbanStore`
|
||||
(DeleteForeverAsync/PurgeAsync/ClearColAsync дополнительно удаляют `DedupEntries WHERE LeadId=?` — «сирота»
|
||||
не должна блокировать повторное создание, leads.py _hard_delete L229). Порт Kanban и его XML-doc обновляются
|
||||
(семантика «полное удаление»).
|
||||
- **Ruling 4 (г) — карточка из сообщения (CardComposer).** Перенос `_store_lead` (L433–514) в чистый
|
||||
`CardComposer` модуля Pipeline: title = clean_short(raw.title, 140) или clean_short(text, 140); summary =
|
||||
compose_summary (блоки «О заявке» Компания→Формат→О задаче→Требования→Будет плюсом→Условия, 1:1 с
|
||||
cardPrompt и compose_summary L225–284; локальный путь без структуры — «О задаче: …», _local_summary
|
||||
L294–314; футер-хинты L288–291) ≤2000 через clean_block; stack = normalize_stack ≤12 (L332–341);
|
||||
бюджет: нормализованный из разбора (`BudgetNormalizer.Normalize`), иначе fallback из первой суммы
|
||||
`AmountParser.Parse` по исходнику/суммари (L459–468), конверсия один раз при поступлении —
|
||||
`BudgetNormalizer.ToTarget` (conversionOn/targetCurrency/ratesCache, USDT=USD, мок-фолбэк, как
|
||||
ConversionRecomputer/CardsService.LoadRatesAsync); контакты: `ContactsQualifier.Build` из разбора или
|
||||
текста (L389–421, ≤6, типы tg/phone/email/linkedin/whatsapp/site, отбрасывание ботов/сервисных t.me/
|
||||
«постовых» сайтов L344–386), primary_contact (tg→phone→whatsapp→email→linkedin→site, L424–430, ≤200);
|
||||
ch-поля канала; sourceMsg = text[:4000]; sourceDialogId/sourceMsgId; prevCol=inbox; matchHits =
|
||||
ComputeHits доски, если назначена и прошла BoardAccepts (иначе колонка сбрасывается в inbox — страховка
|
||||
L449–450); isVacancyKnown = признак ИИ. Создание: `IKanjStore.AddCardAsync` затем
|
||||
`IPipelineStore.LinkDedupAsync(hash, cardId)` (порядок как L512–513).
|
||||
- **Ruling 5 (в-2) — ML/ИИ-ветки этапа 4.** ML-слой вызывает существующий порт `IMlClient.PredictAsync`
|
||||
когда `mlEnabled` (не false) и не force (L966). Локальная модель не готова (LocalMlClient ready:false →
|
||||
predict `{take:false,...}`) — все сообщения уходят к ИИ-ветке; ветки «решил сам» реализуются ПОЛНОСТЬЮ
|
||||
1:1 с L969–1061 (spam → отсев `{source:ml, stage:spam_ml, reason:«ML уверен, что это спам/не заявка
|
||||
(score …)»}`; доска → разрешена только не-suggested без активных правил, карточка в доску с
|
||||
локальными полями + типом ML + докладом terms в стек; typeDrop по wantedType; тип известен + aiEnabled
|
||||
false → карточка inbox) и покрываются юнит-тестами на fake-клиенте с ready:true (FakeMlClient в тестах
|
||||
расширяется). ИИ-слой: новый порт `IAiClassifier` (Contracts/Integrations; этап 6 заменит реализацию
|
||||
gRPC-клиентом ai-service) с record-DTO `AiFilterResult {Pass, Reason, Skipped}` и `AiParsedLead`
|
||||
(title/company/format/task/requirements/plus/conditions/stack/budget/contacts/is_vacancy/is_vacancy_known/
|
||||
is_spam/board — структура классификации ТЗ §5 L104–106 и ai.py classify). Этап 4 — детерминированный
|
||||
`LocalAiClassifier` (Infrastructure/Integrations): фильтр — всегда `{pass:true, skipped:true}` (реального
|
||||
ИИ-фильтра нет; при aiFilterEnabled=true это ветка «ИИ недоступен» прототипа L1103–1106; отсевы
|
||||
spam_ai/filter_ai недостижимы — их причины готовы для этапа 6); классификатор — локальный разбор ядра
|
||||
`MessageParseCore` (Ruling 4/Ruling 7: budget из AmountParser, контакты qualify, is_vacancy по hire-маркерам,
|
||||
is_vacancy_known=false, board=null — «смысловые колонки до ИИ не назначаем», L954–958). aiEnabled=false →
|
||||
тот же локальный разбор напрямую (прототип L1081–1096), без вызова порта. Возврат (force): ИИ-фильтр
|
||||
пропускается (L1097–1100), вердикт «спам» ИИ отменяется (L1117–1121). Счётчики решений: KV
|
||||
`mlDecisions`/`aiDecisions` инкрементирует модуль Pipeline после pump (`ml=mlStored+mlDrop,
|
||||
ai=aiStored+aiDrop`, ml_client.track_decisions L153–157) через ISettingsStore read-modify-write —
|
||||
LocalMlClient.StatusAsync их уже читает (этап 3), контракт IMlClient не меняется.
|
||||
- **Ruling 6 (е) — FTS.** Механизм — встроенный полнотекстовый поиск Postgres БЕЗ внешних расширений
|
||||
(pg_trgm и DuckDB-FTS НЕ нужны: LIKE-дополнение на объёмах этапа выполняется сканом, а русская морфология
|
||||
есть в конфигурации `russian`): в миграции TenantPipeline добавляются генерируемые колонки
|
||||
`Cards.SearchTsv` и `RejectedItems.SearchTsv` = `to_tsvector('russian', coalesce(<текст.поля>,''))`
|
||||
(Cards: Title+Summary+SourceMsg+Contact — поля поиска leads L527–529; Rejected: Text — fts.py
|
||||
`_FTS_TARGETS` L23–27) `STORED` + GIN-индексы. Колонки авто-актуальны (аналог DuckDB «rebuild каждые
|
||||
сутки» не нужен). Поиск карточки `/api/search?q=` (q≥2) — один SQL: `col != 'taken' AND (SearchTsv @@
|
||||
plainto_tsquery('russian', q) OR lower(title/summary/source_msg/contact) LIKE '%q%')`, порядок
|
||||
`ts_rank DESC, ReceivedAt DESC`, limit 12 — кандидаты FTS ∪ LIKE как в leads.search L509–551 (`messages:[]`
|
||||
— api-map п.3). Поиск отсева `GET /pipeline/rejected?q=` — FTS-кандидаты (`SearchTsv @@ plainto_tsquery`)
|
||||
∪ LIKE-дополнение по `lower(text)/reason/kw/ch_name` (processing.list_rejected L246–277, лимиты
|
||||
limit*2 на каждую выборку, total = размер объединения, страницы по offset/limit ≤500). `POST
|
||||
/admin/fts/rebuild` — реальная идемпотентная обслуживающая операция `FtsMaintenance.RebuildAsync`:
|
||||
`CREATE INDEX IF NOT EXISTS` + `ANALYZE` обеих таблиц (самовосстановление индекса, если отсутствует),
|
||||
ответ `{ok:true, ready:true}`.
|
||||
- **Ruling 7 (в-3) — чистое ядро разбора в модуле Pipeline.** Перенос функций pipeline.py в чистые классы
|
||||
модуля `MessageParseCore` (1 тип = 1 файл): `MessageTextCleaner` (clean_short L148–156 / clean_block
|
||||
L158–193 — markdown-ссылки, **__`~~, ||, голые URL, эмодзи-диапазоны, «C#»-защита, схлопывание, обрезка
|
||||
по границе), `MessageListNormalizer` (normalize_list L317–329, normalize_stack L332–341),
|
||||
`ContactsQualifier` (L344–430: qualify_contact/build_contacts/primary_contact + регэкспы/наборы L345–347,
|
||||
L597–604), `DedupHasher` (normalize_dedup ai.py L261–267: `[^\wа-яё]+` → SHA1 hex), `SummaryComposer`
|
||||
(compose_summary + _local_summary + футер-хинты L288–291), `LocalFieldsParser` (_local_fields L718–798:
|
||||
метки `Стек/Грейд/Контакты/Бюджет` L591–596 через `_field_of`-эквивалент, fallback-извлечения, заголовок,
|
||||
суть, is_vacancy по hireMarkers, is_vacancy_known=false, board=null) + словарь стоп-слов стека
|
||||
(`_STOP_STACK` L604–610), маркеры найма/грейда/резюме читаются из настроек (S) как в IncomingRules.
|
||||
Эти же классы использует `LocalAiClassifier` (Infrastructure). Unit-тесты — на эталонных текстах
|
||||
(кейсы из devtests/e2e прототипа + примеры вакансий/заказов с контактами и бюджетами).
|
||||
- **Ruling 8 (ж/з) — воркер, очистки, счётчики, SSE.** `PipelineWorkerService.PumpOnceAsync` (модуль)
|
||||
— перенос `_pump_unlocked` L920–1183 (порядок строго 1:1): для status='new' (лимит 12): force? →
|
||||
stale-проверка (только не force; msgAt старше archiveAfterDays*суток при autoArchive=true → отсев
|
||||
`{source:stale, stage:stale, reason:«сообщение старше N дн. (срок до автоархива) — не заводим в
|
||||
систему»}`, строка удаляется, карточка НЕ создаётся — правка владельца «устаревшие не попадают в
|
||||
систему») → `IncomingRules.CheckAsync` (не прошёл → отсев `{source:stop, stage=kind(length|stop|resume|
|
||||
type), reason, kw}`, строка+dedup-claim удаляются) → дедуп (`DedupHasher`; хэш уже в DedupEntries →
|
||||
отсев `{source:dup, stage:dup, reason:«сообщение уже в системе: карточка создана ранее или этот текст
|
||||
уже обрабатывается»}`, удаление строки и её dedup-claim; иначе INSERT claim LeadId=null) → ML-слот
|
||||
(Ruling 5) → не решено → status='filtered'. Для status='filtered' (лимит 4): force? → stale (только не
|
||||
force) → aiEnabled=false: локальный разбор + no-budget(не force) → карточка inbox (счётчик aiStored —
|
||||
имя прототипа) ; aiEnabled=true: force → фильтр-пропуск; иначе `IAiClassifier.FilterAsync` (локально
|
||||
pass/skipped); `ClassifyAsync`; сбой/пустой разбор → локальный разбор (aiFail); is_spam → отсев
|
||||
`{source:ai, stage:filter_ai|spam_ai, reason:«ИИ-фильтр: …»|«ИИ: не заявка — спам, реклама, скам или
|
||||
служебное сообщение»}` + `IMlClient.PushAsync(text, "spam", AI_WEIGHT 0.4)`; no-budget (не force) → отсев
|
||||
`{source:stop, stage:budget, reason:«включён фильтр „не создавать карточку без суммы" — в тексте не
|
||||
указан бюджет»}`; карточка (CardComposer) + new-лид-сигнал; обучение ML: ИИ назначил доску (не inbox и
|
||||
не-suggested, без активных правил) → `PushAsync(text, boardId, 0.4)`; тип известен → `PushAsync(text,
|
||||
"t:hire"|"t:order", 0.4)` (L1155–1180). Результат pump — `PipelinePumpResult`: счётчики {staged,
|
||||
rulesStored, mlStored, mlDrop, typeDrop, aiStored, aiDrop, aiFail, noBudget} (1:1 имена wire-ключами
|
||||
admin/tick pipeline-словаря) + `IReadOnlyList<CardDto> CreatedCards` (для SSE, Ruling 9) + счётчики
|
||||
решений для KV. Воркер-гейт «не параллелить pump одного тенанта» — `PipelinePumpGate` (Api, singleton,
|
||||
Interlocked/ConcurrentDictionary; аналог asyncio.Lock L40). Очистка отсева: `PipelineProcessingService.PurgeExpiredAsync`
|
||||
(RejectedAt старше 3 суток, processing.purge_expired L104–117) вызывается из тика (Ruling 10); полная
|
||||
ручная очистка — отдельный эндпоинт /rejected/clear. Счётчики вкладки — `GET /pipeline/stats`
|
||||
(queue.counts из QueueItems по status + rejected count). SSE этапа 4: `pipeline_stats` НЕ публикуем —
|
||||
api-map L43 фиксирует, что фронтовый `openEvents()` слушает только new_lead/toast/reminder_due/
|
||||
system_status, а «Обработка» живёт на поллинге (ProcessingView reloadAll 2,6 с + store.js 60 с);
|
||||
публикуем: `new_lead` (полный CardDto; из Api после PumpOnce — admin/tick и PipelineWorkerScheduler,
|
||||
Ruling 5 этапа 3) и toast «Отсев очищен: N записей (3 дн.)» (trash) при ненулевой автоочистке
|
||||
(доработка StorageToastPublisher, notify_tick_stats L503–504).
|
||||
- **Ruling 9 (и) — интеграция с тиком/настройками без циклов.** `StorageTickService` (Kanban) НЕ трогаем
|
||||
(purgedRejected=0 у него остаётся). Автоочистку отсева выполняет модуль Pipeline
|
||||
(`PipelineProcessingService.PurgeExpiredAsync`) в рамках тика: оркестрацию делает Api — `POST /api/admin/tick`
|
||||
вызывает Kanban-тик + purge-отсева + pump (Ruling 10), фоновый `StorageTickScheduler` — Kanban-тик +
|
||||
purge-отсева на каждый тенант; ответ тика объединяет статистику (`storage = {…, purgedRejected}` 1:1 с
|
||||
leads.tick_storage L488–493). Публикация тостов — StorageToastPublisher. Настройки этапа-1 переиспользуют
|
||||
`IncomingRules` (Settings, scoped) и новые порции настроек читаются через ISettingsStore/SettingsKeys +
|
||||
дефолты SettingsDefaults (без дублирования каталога ключей). Спам-квоты/«системный отсев сверх
|
||||
stale|dup» в прототипе нет — НЕ реализуем (за этапом; roadmap §L57–62 трактуем как stale/dup source
|
||||
= «система», уже покрыто).
|
||||
- **Ruling 10 — эндпоинты этапа и DI.** Входят: 6 эндпоинтов `/api/pipeline/*` (api-map §3.6) — GET
|
||||
/stats, GET /queue (limit ≤500, дефолт 100; ответ `{items, counts:{new,ai,total}, rejected}`),
|
||||
GET /rejected (q/offset/limit ≤500; `{items,total,offset,limit}`), POST /rejected/clear →
|
||||
`{ok:true, cleared}`, DELETE /rejected/{rejId} → `{ok:true}`, POST /rejected/{rejId}/return
|
||||
`{reason=""}` → `{id, returned:true, returnedAt}` (404 «Запись не найдена»; 400 «Сообщение уже возвращено
|
||||
в обработку»/«Повтор: карточка с таким текстом уже есть в системе — возвращать нечего»/«В записи нет
|
||||
текста сообщения»; при stage ∈ {spam_ml, spam_ai, filter_ai} — `PushAsync(text,"spam",-1.0)`; строки
|
||||
очереди с force=true; запись отсева помечается returned/returnedAt/returnReason, НЕ удаляется) +
|
||||
демо-ingest `POST /api/demo/ingest` `{text, dialogId?, channelName?, channelHandle?, channelHue?, msgId?,
|
||||
msgAt?}` → `{ok:true, id, queue:{new,ai,total}}` (400 «Текст сообщения пуст»; DEAL_DEMO guard).
|
||||
Возврат из отсева «мимо ML к ИИ» (ТЗ §5 L100) обеспечивает force. Модифицируются: `POST /api/admin/tick`
|
||||
(ответ 1:1 `{storage, reminders:[], pipeline:<dict pump>, queue:int}` + тосты + new_lead по созданным
|
||||
карточкам), `POST /api/admin/fts/rebuild` (Ruling 6). НЕ реализуем (фронт не вызывает, api-map п.9):
|
||||
admin/wipe|clear-cards|pump-gate, ml/learn|flush, /leads/{id}/seen; `reclassify` остаётся заглушкой
|
||||
Ruling 11 этапа 3. DI: `AddPipelineModule()` (модуль: Ingest/Processing/Worker/Rejects/ядра), адаптеры
|
||||
в `AddDealPersistence` (IPipelineStore → PipelineStore), `AddDealIntegrations` (+IAiClassifier →
|
||||
LocalAiClassifier); Program.cs — AddPipelineModule + MapPipelineEndpoints + hosted services (Ruling 8/10).
|
||||
Id-префиксы Pipeline — `p_` (очередь), `r_` (отсев; детерминированный вариант), хэш-ключ без префикса.
|
||||
- **Ruling 11 (к) — демонстрация сквозного пути без telegram.** Пресеты демо НЕ заводим: `POST
|
||||
/api/demo/ingest` принимает произвольный текст (детерминированная приёмка curl-текстами из Task 13:
|
||||
вакансия с бюджетом/контактами → карточка; короткое сообщение/стоп-фраза/резюме/чужой тип → отсев;
|
||||
одинаковый текст дважды → «повтор»; msgAt старше срока → «устарело»; без суммы при
|
||||
budgetRequiredHire=true → «нет суммы»). `simulate-lead`/`age-lead` этапа 3 не меняются. После ingest
|
||||
очередь разбирается фоном (2 с) или `POST /api/admin/tick` (детерминированно в curl). Вне этапа 4:
|
||||
реальные ai/telegram/ml-сервисы и gRPC (этап 6), Projects/reminder_due (этап 5), discovery,
|
||||
оператор/лимиты (этап 7), события pipeline_stats/boards_changed/leads_reclassified (недостижимы у фронта).
|
||||
|
||||
## Задачи
|
||||
|
||||
Сокращения путей: `P=` `src/core/Deal.Modules.Pipeline/`, `K=` `src/core/Deal.Modules.Kanban/`,
|
||||
`S=` `src/core/Deal.Modules.Settings/`, `C=` `src/core/Deal.Contracts/`, `I=` `src/core/Deal.Infrastructure/`,
|
||||
`A=` `src/core/Deal.Api/`, `T=` `src/core/tests/Deal.Tests.Unit/`. Отчёты —
|
||||
`task-N-report.md` в `.superpowers/sdd/deal-stage4-pipeline/`.
|
||||
|
||||
### Task 1: Миграция TenantPipeline — QueueItems/RejectedItems/DedupEntries + FTS-колонки
|
||||
|
||||
**Files:**
|
||||
- Create: `I/Persistence/Entities/{QueueItemEntity,RejectedItemEntity,DedupEntryEntity}.cs` и
|
||||
`I/Persistence/{QueueItemConfiguration,RejectedItemConfiguration,DedupEntryConfiguration}.cs`
|
||||
(поля/индексы Ruling 1; Text/Reason/Kw — text; времена — `DateTimeOffset`; SearchTsv — computed).
|
||||
- Modify: `I/Persistence/Entities/CardEntity.cs` + `I/Persistence/CardConfiguration.cs` — свойство
|
||||
`SearchTsv` (`HasComputedColumnSql("to_tsvector('russian', coalesce(\"Title\",'')||' '||coalesce(\"Summary\",'')||' '||coalesce(\"SourceMsg\",'')||' '||coalesce(\"Contact\",''))", stored:true)` + GIN-индекс) — Ruling 6.
|
||||
- Modify: `I/Persistence/TenantDbContext.cs` — DbSet'ы + `ApplyConfiguration`.
|
||||
- EF: миграция `TenantPipeline` для `TenantDbContext` (как TenantKanban: `dotnet ef migrations add
|
||||
TenantPipeline --context TenantDbContext --output-dir Migrations/TenantDb --project
|
||||
src/core/Deal.Infrastructure --startup-project src/core/Deal.Api`); старт Api применяет её к схеме
|
||||
дефолтного тенанта.
|
||||
|
||||
**Источники:** db.py L88–92, L226–268; Rulings 1/6; эталон: TenantKanban-миграция, CardEntity/Configuration.
|
||||
|
||||
**Acceptance:** build 0/0; `dotnet test` MarkerTests PASS; psql (search_path дефолтного тенанта): таблицы
|
||||
QueueItems/RejectedItems/DedupEntries + PK; `Cards` получила `SearchTsv` (generated, stored) и
|
||||
`RejectedItems.SearchTsv`; индексы `IX_QueueItems_Status_CreatedAt`, `IX_RejectedItems_RejectedAt`,
|
||||
GIN на SearchTsv (обоих таблиц); `__TenantMigrationsHistory` содержит TenantPipeline. Отчёт: `task-1-report.md`.
|
||||
|
||||
### Task 2: Модуль Pipeline — DTO, словари отсева, порт IPipelineStore, реестр
|
||||
|
||||
**Files:**
|
||||
- Create: `P/Application/Models/QueueItemDto.cs` (§4.5 очередь L308: id/dialogId/msgId/text/status/ch{name,
|
||||
handle,hue}/msgAt/queuedAt), `RejectedItemDto.cs` (§4.5 отсев L310–313: +stage/stageLabel/reason/kw/
|
||||
source/sourceLabel/rejectedAt/returned/returnedAt/returnReason; наружу epoch-ms), `QueueCountsDto.cs`
|
||||
({new,ai,total}), `RejectRecord.cs` (команда записи отсева: source/stage/reason/kw + канальные поля),
|
||||
`QueuedMessage.cs` (команда приёма: dialog/ch/msgId/text/msgAt/force), `PipelinePumpResult.cs` (счётчики
|
||||
Ruling 8 + CreatedCards), `PipelineRejectConstants.cs` (словари stage→stageLabel, source→sourceLabel,
|
||||
«система», Ruling 1/Ruling 9; processing.py L26–46).
|
||||
- Create: `P/Application/IPipelineStore.cs` — порт (реализация — EF-адаптер Task 3): Queue
|
||||
(ExistsDuplicateAsync(dialogId,msgId), AddAsync, ListAsync(limit), CountByStatusAsync, SetStatusAsync,
|
||||
RemoveAsync); Rejects (UpsertAsync(RejectRecord) с детерминированным id, ListPageAsync(offset,limit),
|
||||
SearchIdsAsync(q, limitFts, limitLike) → упорядоченный список id, CountAsync, RemoveAsync, ClearAsync,
|
||||
PurgeExpiredAsync(olderThan), GetAsync(id), MarkReturnedAsync(id, reason, at)); Dedup (ExistsAsync(hash),
|
||||
ClaimAsync(hash), DeleteClaimAsync(hash) (только LeadId=null), LinkAsync(hash, cardId),
|
||||
DeleteByLeadAsync(cardId)).
|
||||
- Create: `P/Application/PipelineIdPrefixes.cs` (`p_`, `r_`) + переиспользование `PrefixId` (модуль Kanban)
|
||||
— при необходимости вынести общий генератор в SharedKernel (на усмотрение исполнителя, без дублирования).
|
||||
- Create: `P/Application/PipelineModuleRegistrar.cs` — `AddPipelineModule()` (регистрация сервисов задач
|
||||
4/5/7/9 по мере появления). Modify: `P/Deal.Modules.Pipeline.csproj` — ProjectReference на
|
||||
`Deal.Modules.Settings` и `Deal.Modules.Kanban`.
|
||||
|
||||
**Источники:** api-map §4.5 L306–314; processing.py L26–46, L218–320; Rulings 1/2/8/10.
|
||||
|
||||
**Acceptance:** build 0/0; DTO — record'ы (camelCase при сериализации); словари 1:1 (length→«короткое
|
||||
сообщение», …, dup→«повтор»; stop→«правила», ml→«ML», ai→«ИИ», stale|dup→«система»); MarkerTests PASS.
|
||||
Отчёт: `task-2-report.md`.
|
||||
|
||||
### Task 3: EF-адаптер PipelineStore + DI + жёсткое удаление карточек (DedupEntries)
|
||||
|
||||
**Files:**
|
||||
- Create: `I/Persistence/Repositories/PipelineStore.cs` — реализация `IPipelineStore` на `TenantDbContext`
|
||||
(эталон KanbanStore.cs; AsNoTracking для чтений; маппинг вручную; времена ↔ epoch-ms наружу).
|
||||
Детали: `ExistsDuplicateAsync` — `SELECT 1 FROM QueueItems WHERE DialogId=? AND MsgId=?` (Ruling 2);
|
||||
добавление строки очереди — id `p_` генерирует модуль; `UpsertAsync` для RejectedItems — raw SQL
|
||||
`INSERT … ON CONFLICT (id) DO UPDATE SET …` (processing.record L79–101: детерминированный id
|
||||
`r_<dialog>_<msgId>` либо `r_`+hex; пустой текст — no-op); `SearchIdsAsync` — FTS-кандидаты
|
||||
`plainto_tsquery('russian', q)` по `SearchTsv` (rank DESC) + LIKE-дополнение по
|
||||
lower(text)/reason/kw/ch_name (limit*2 каждое), объединение без дублей (processing L252–270);
|
||||
`PurgeExpiredAsync`/`ClearAsync`/`RemoveAsync` — по RejectedAt/безвозвратно; `ClaimAsync` —
|
||||
`INSERT … ON CONFLICT DO NOTHING`; `DeleteClaimAsync` удаляет только строки с `LeadId IS NULL`;
|
||||
`LinkAsync` — `UPDATE DedupEntries SET LeadId=? WHERE Hash=?`.
|
||||
- Modify: `I/Persistence/Repositories/KanbanStore.cs` — жёсткое удаление карточки (DeleteForeverAsync,
|
||||
PurgeAsync, ClearColAsync) дополнительно `DELETE FROM DedupEntries WHERE LeadId=?` (Ruling 3).
|
||||
- Modify: `K/Application/IKanjStore.cs` — XML-doc метода DeleteForeverAsync/PurgeAsync (семантика
|
||||
«Cards + комментарии + DedupEntries», Ruling 3).
|
||||
- Modify: `I/ServiceCollectionExtensions.cs` — `AddScoped<IPipelineStore, PipelineStore>()`.
|
||||
|
||||
**Источники:** processing.py L66–117, L246–312; leads.py _hard_delete L225–247; Rulings 1/3; эталон
|
||||
KanbanStore.cs/SettingsStore.cs.
|
||||
|
||||
**Acceptance:** build 0/0; unit (LocalMlClient-стиль не нужен — PipelineStore на EF покрывается curl/psql):
|
||||
upsert отсева дважды с тем же dialog+msgId → одна строка с обновлёнными полями; psql+curl — после Task 9.
|
||||
Отчёт: `task-3-report.md`.
|
||||
|
||||
### Task 4: Чистое ядро разбора сообщения — cleaners, нормализация, контакты, dedup, «О заявке»
|
||||
|
||||
**Files:**
|
||||
- Create в `P/Application/Parse/`: `MessageTextCleaner.cs` (clean_short/clean_block L148–193 + регэкспы/
|
||||
наборы эмодзи/футер-хинты L131–145, L288–291), `MessageListNormalizer.cs` (normalize_list/normalize_stack
|
||||
L317–341 + стоп-слова стека L604–610), `ContactsQualifier.cs` (L344–430 + _contacts_from L666–679,
|
||||
_norm_phone L661–663), `DedupHasher.cs` (ai.py L261–267), `SummaryComposer.cs` (compose_summary L225–284 +
|
||||
_local_summary L294–314), `LocalFieldsParser.cs` (_local_fields L718–798 + _field_of L686–698, метки
|
||||
L591–596, маркеры/токены L597–612; hireMarkers/levelTerms/resumeMarkers — через ISettingsStore +
|
||||
SettingsDefaults, нормализация как в IncomingRules), `AmountRangeBudgetFallback.cs` (fallback бюджета из
|
||||
`AmountParser.Parse`, L459–468).
|
||||
- Test: `T/MessageParseCoreTests.cs` — кейсы: markdown/URL/эмодзи-чистка, «C#» не режется, обрезка по
|
||||
границе; normalize_list «Java, Kotlin»/«;»-список; qualify: @user, @…bot → нет, t.me-ссылка, email,
|
||||
телефон +7, linkedin, site-спам (teletype.in → нет); build_contacts из текста (≤6, дедуп); dedup-хэш
|
||||
детерминирован (регистр/пунктуация не влияют, «Тест!» ≡ «тест»); compose_summary: блоки
|
||||
Компания→…→Условия в порядке; локальный путь «О задаче: …»; _local_fields на объявлении с метками
|
||||
«Стек:/Бюджет:/Контакты:» и без меток (fallback по тексту; is_vacancy по hire-маркерам, known=false,
|
||||
board=null).
|
||||
|
||||
**Источники:** pipeline.py L131–341, L591–798; ai.py L261–267; Rulings 4/7.
|
||||
|
||||
**Acceptance:** `dotnet test` новых тестов PASS; build 0/0. Отчёт: `task-4-report.md`.
|
||||
|
||||
### Task 5: PipelineService — приём (ingest), очередь, отсев, возврат, очистки, счётчики
|
||||
|
||||
**Files:**
|
||||
- Create: `P/Application/PipelineIngestService.cs` — `EnqueueAsync(QueuedMessage)` (Ruling 2: trim, no-op
|
||||
пустого текста/нет dialogId, text[:6000], msg_id-дубль-гвард, id `p_`, status=new, CreatedAt/UpdatedAt).
|
||||
- Create: `P/Application/PipelineProcessingService.cs` — запись отсева (Ruling 1/8: детерминированный
|
||||
upsert), чтение очереди (list_queue L218–241: limit clamp 1..500), queue_counts (L207–215),
|
||||
rejected_count, list_rejected (L246–312: q-путь FTS+LIKE/страницы/лимиты, no-q путь по RejectedAt DESC),
|
||||
`ReturnAsync` (processing.return_to_queue L128–193: 404 «Запись не найдена»; 400-строки Ruling 10;
|
||||
stage∈{spam_ml,spam_ai,filter_ai} → `IMlClient.PushAsync(text,"spam",-1.0)`; пометка записи returned +
|
||||
return_reason; enqueue force=true с msg_at из записи), `ClearAsync`, `DeleteAsync`, `PurgeExpiredAsync`
|
||||
(3 суток от RejectedAt), stats (форма `/pipeline/stats`).
|
||||
- Test: `T/PipelineProcessingServiceTests.cs` + `T/FakePipelineStore.cs` (+ использование существующих
|
||||
FakeSettingsStore/FakeMlClient): ingest (trim/no-op/дубль-dialog+msgId), возврат: dup → 400-текст;
|
||||
повторный → 400; не найдена → 404-результат; спам-этап → PushAsync(spam, −1.0) вызван; очистки/счётчики.
|
||||
|
||||
**Источники:** pipeline.py L53–85; processing.py L66–193, L201–320; processing_routes.py L17–74; Rulings 2/8/10.
|
||||
|
||||
**Acceptance:** `dotnet test` PASS; build 0/0. Отчёт: `task-5-report.md`.
|
||||
|
||||
### Task 6: Порт IAiClassifier + детерминированный LocalAiClassifier
|
||||
|
||||
**Files:**
|
||||
- Create: `C/Integrations/IAiClassifier.cs` + `C/Integrations/Models/{AiFilterResultDto,AiParsedLeadDto,
|
||||
AiBudgetDto}.cs` — порт Ruling 5: `FilterAsync(string text, ct)` и `ClassifyAsync(string text, ct)`.
|
||||
- Create: `I/Integrations/LocalAiClassifier.cs` — реализация: фильтр всегда `{pass:true, skipped:true}`
|
||||
(aiFilterEnabled НЕ читает — выключатель обрабатывает воркер, как прототип filter_incoming L190–192:
|
||||
выключен → skipped, включён при недоступном ИИ → pass+skipped, L1103–1106); классификатор —
|
||||
`LocalFieldsParser` (модуль Pipeline) → `AiParsedLeadDto` (title/summary/stack/budget из AmountParser/
|
||||
BudgetNormalizer.Normalize/contacts через ContactsQualifier/is_vacancy/is_vacancy_known=false/board=null).
|
||||
- Modify: `I/ServiceCollectionExtensions.cs` — `AddScoped<IAiClassifier, LocalAiClassifier>()` (секция
|
||||
AddDealIntegrations).
|
||||
- Test: `T/LocalAiClassifierTests.cs` — фильтр-пропуск; классификатор детерминирован (одинаковый текст →
|
||||
одинаковый DTO); бюджет «до 2к$» → {from:null, to:2000, cur:USD}; контакты квалифицированы.
|
||||
|
||||
**Источники:** ai.py L188–198, L316–352; Rulings 5/7; эталон LocalColumnSuggester.cs (адаптер, зовущий
|
||||
модульное ядро).
|
||||
|
||||
**Acceptance:** `dotnet test` PASS; build 0/0. Отчёт: `task-6-report.md`.
|
||||
|
||||
### Task 7: CardComposer — карточка из разобранного сообщения через публичный интерфейс Kanban
|
||||
|
||||
**Files:**
|
||||
- Create: `P/Application/CardComposer.cs` — сборка `CardSnapshot` из `AiParsedLeadDto`/локального разбора
|
||||
+ метаданных сообщения (Ruling 4): title (clean 140), summary (compose_summary + clean_block 2000,
|
||||
fallback clean_short(text,2000)), stack ≤12, бюджет Normalize + fallback AmountParser по
|
||||
text/summary (первая сумма), ToTarget (conversionOn/targetCurrency/rates из ratesCache с мок-фолбэком,
|
||||
USDT=USD), contacts/primary contact, ch/source-поля, text[:4000], prevCol=inbox, isVacancy/Known.
|
||||
`BuildAsync` читает доску, если назначена (board): `ColumnRules.BoardAccepts` — иначе col=inbox;
|
||||
matchHits = `ColumnRules.ComputeHits` для прошедшей доски (иначе пусто).
|
||||
- Create: `P/Application/PipelineCardWriter.cs` — тонкая обёртка создания: `PrefixId.New("l_")` →
|
||||
`IKanjStore.AddCardAsync(snapshot)` → `IPipelineStore.LinkDedupAsync(hash, cardId)` →
|
||||
`store.GetCardAsync(cardId)` (CardDto для SSE). (id `l_` генерирует KanbanIdPrefixes — переиспользуем.)
|
||||
- Test: `T/CardComposerTests.cs` (FakeKanjStore/FakeSettingsStore): сборка полной карточки (блоки «О
|
||||
заявке», бюджет+conv, контакты, sourceMsg ≤4000); назначенная доска без правил → колонка доски +
|
||||
matchHits; доска с несовпадающими правилами → inbox (BoardAccepts-страховка); fallback-бюджет из текста;
|
||||
conv выключен (conversionOn=false) → conv-поля пусты.
|
||||
|
||||
**Источники:** pipeline.py L433–514, L540–586; rules.py board_accepts/hits (Kanban ColumnRules); Rulings 3/4.
|
||||
|
||||
**Acceptance:** `dotnet test` PASS; build 0/0. Отчёт: `task-7-report.md`.
|
||||
|
||||
### Task 8: PipelineWorkerService — воркер pump (stale/правила/дедуп/ML/ИИ/карточка/обучение)
|
||||
|
||||
**Files:**
|
||||
- Create: `P/Application/PipelineWorkerService.cs` — `PumpOnceAsync(newLimit=12, aiLimit=4)` (Ruling 8,
|
||||
порядок 1:1 `_pump_unlocked` L920–1183): проход new → проход filtered; создание карточек через
|
||||
`PipelineCardWriter`; отсевы через `PipelineProcessingService`; счётчики KV ml/ai инкремент после pump;
|
||||
возврат `PipelinePumpResult` (+CreatedCards). Зависимости: IPipelineStore, ISettingsStore, IncomingRules
|
||||
(Settings), IKanjStore, IMlClient, IAiClassifier, PipelineProcessingService, CardComposer, DedupHasher.
|
||||
Константы: `PushWeightAi = 0.4`, сроки из SettingsDefaults. Решения ML-ветки (Ruling 5) — на порту
|
||||
IMlClient: не готов/не уверен → filtered; spam/доска/тип — полные ветки.
|
||||
- Test: `T/PipelineWorkerServiceTests.cs` (+ доработка `T/FakeMlClient.cs` — настраиваемый ready/take/
|
||||
label/type/terms; `T/FakeAiClassifier.cs`): (1) короткое → отсев length, строка удалена; (2) стоп-фраза →
|
||||
отсев stop с kw; (3) резюме → отсев resume; (4) dup: дважды один текст — второй отсев dup; (5) stale
|
||||
(msgAt старше срока, autoArchive=true) → отсев stale БЕЗ карточки; (6) вакансия с бюджетом → карточка
|
||||
inbox (aiStored=1, счётчики KV aiDecisions+1, CreatedCards=1); (7) no-budget при budgetRequiredHire →
|
||||
отсев budget, dedup-claim удалён; (8) ML ready+spam → отсев spam_ml + счётчик mlDecisions; (9) ML
|
||||
ready+доска (без правил, не suggested) → карточка в доску БЕЗ обучающего push (ML-путь не учит, L1017–1021);
|
||||
(10) ML ready+тип+aiEnabled=false → карточка inbox is_vacancy/known; (11) force: минует правила/
|
||||
stale/no-budget и создаёт карточку; (12) ИИ-слот с fake-классификатором: доска назначена → BoardAccepts-
|
||||
страховка; is_spam → отсев spam_ai + Push(spam, 0.4); пустой разбор → локальный (aiFail).
|
||||
|
||||
**Источники:** pipeline.py L803–1183; ml_client.py L26–28; Rulings 5/8.
|
||||
|
||||
**Acceptance:** `dotnet test` новых тестов PASS; build 0/0. Отчёт: `task-8-report.md`.
|
||||
|
||||
### Task 9: Эндпоинты /api/pipeline/* + /api/demo/ingest + DI + curl-приёмка
|
||||
|
||||
**Files:**
|
||||
- Create: `A/Endpoints/PipelineEndpoints.cs` (`MapPipelineEndpoints`): GET `/api/pipeline/stats`, GET
|
||||
`/api/pipeline/queue?limit=` (фронт шлёт 120; clamp 1..500; ответ `{items, counts, rejected}`), GET
|
||||
`/api/pipeline/rejected?q=&offset=&limit=` (clamp offset≥0/limit 1..500; `{items,total,offset,limit}`),
|
||||
POST `/api/pipeline/rejected/clear` → `{ok, cleared}`, DELETE `/api/pipeline/rejected/{rejId}` →
|
||||
`{ok:true}` (прототип delete_one L196–198 всегда ok, 404 не шлём), POST `/api/pipeline/rejected/{rejId}/return`
|
||||
`{reason}` → 200 `{id, returned:true, returnedAt}` | 400 | 404 (детали Ruling 10). Статические
|
||||
сегменты до `{rejId}`; сессия 401 (эталон MlEndpoints/StorageEndpoints).
|
||||
- Create: `A/Endpoints/RequestModels/ReturnReasonRequest.cs`, `PipelineIngestRequest.cs`.
|
||||
- Modify: `A/Endpoints/DemoEndpoints.cs` — `POST /api/demo/ingest` (флаг DEAL_DEMO; тело Ruling 11;
|
||||
400 «Текст сообщения пуст»; вызов `PipelineIngestService.EnqueueAsync`; ответ
|
||||
`{ok:true, id, queue:{new,ai,total}}`).
|
||||
- Modify: `A/Program.cs` — `AddPipelineModule()`, `MapPipelineEndpoints()`; `A/Deal.Api.csproj` — ссылка
|
||||
на `Deal.Modules.Pipeline`.
|
||||
- Test: `T/PipelineEndpointsContractsTests.cs` — не нужен (endpoint-слои покрываются curl); достаточно
|
||||
существующих MarkerTests.
|
||||
|
||||
**Контракт:** api-map §3.6 L178–186; §4.5; processing_routes.py.
|
||||
|
||||
**Acceptance (curl admin/admin, DEAL_DEMO=1):** stats/queue/rejected пустые формы; demo/ingest → очередь 1;
|
||||
ingest того же (dialogId+msgId) снова → очередь не растёт (гвард); queue?limit=120 — items/counts/rejected;
|
||||
rejected пуст; 401 без куки. Отчёт: `task-9-report.md`.
|
||||
|
||||
### Task 10: POST /admin/tick и /admin/fts/rebuild реальные + SSE-тост отсева
|
||||
|
||||
**Files:**
|
||||
- Create: `I/Services/FtsMaintenance.cs` (или `I/Persistence/Repositories/`): `RebuildAsync(context)` —
|
||||
`CREATE INDEX IF NOT EXISTS` для `Cards(SearchTsv)`/`RejectedItems(SearchTsv)` (raw SQL; имена —
|
||||
внутренние константы) + `ANALYZE Cards/RejectedItems` (Ruling 6).
|
||||
- Modify: `A/Endpoints/StorageEndpoints.cs` — `AdminTickAsync`: `StorageTickService.TickAsync` +
|
||||
`PipelineProcessingService.PurgeExpiredAsync` (merge в `storage.purgedRejected`) + `PipelineWorkerService.PumpOnceAsync`
|
||||
(один раз) + ответ `{storage, reminders:[], pipeline:<PipelinePumpResult wire-dict>, queue:<count>}`
|
||||
(dashboard_routes.py L327–337); публикации: тосты StorageToastPublisher, `new_lead` на каждую карточку
|
||||
CreatedCards (Ruling 8/9). `FtsRebuildAsync` → FtsMaintenance + `{ok:true, ready:true}`.
|
||||
- Modify: `A/Events/StorageToastPublisher.cs` — ветка `PurgedRejected > 0` → toast «Отсев очищен: N
|
||||
записей (3 дн.)» (trash) (notify_tick_stats L503–504; тест `T/StorageToastPublisherTests.cs` дополняется).
|
||||
|
||||
**Источники:** dashboard_routes.py L261–264, L327–337; leads.py L486–504; fts.py L48–67; Rulings 6/8/10.
|
||||
|
||||
**Acceptance:** curl: demo/ingest вакансии → POST /api/admin/tick → pipeline содержит aiStored/созданную
|
||||
карточку (GET /leads), queue:0; после отсева (стоп-фраза) tick → pipeline-счётчики, /pipeline/rejected
|
||||
содержит запись; fts/rebuild → ok/ready. Отчёт: `task-10-report.md`.
|
||||
|
||||
### Task 11: Фоновые циклы — PipelineWorkerScheduler (2 с) + purge-отсева в StorageTickScheduler
|
||||
|
||||
**Files:**
|
||||
- Create: `A/PipelineWorkerScheduler.cs` — IHostedService (эталон StorageTickScheduler/RatesRefreshScheduler):
|
||||
Timer 2 с; на каждое срабатывание — обход тенантов (системный репозиторий), на тенант — свой scope с
|
||||
`ITenantContext`; воркер-гейт `A/PipelinePumpGate.cs` (Interlocked per-tenant: admin/tick и цикл не
|
||||
разбирают очередь тенанта одновременно — аналог asyncio.Lock pipeline.py L40); после PumpOnce — публикация
|
||||
`new_lead` для CreatedCards (Ruling 8/9); try/catch + без подписчиков no-op.
|
||||
- Modify: `A/Hosting/StorageTickScheduler.cs` — после Kanban-тика каждого тенанта вызывать
|
||||
`PipelineProcessingService.PurgeExpiredAsync` и учесть в тостах (Ruling 8/9).
|
||||
- Modify: `A/Program.cs` — `AddHostedService<PipelineWorkerScheduler>()`.
|
||||
|
||||
**Источники:** main.py `_pipeline_loop`/`_storage_loop` (L43–53); pipeline.py L40; StorageTickScheduler.cs;
|
||||
Rulings 8/9/10.
|
||||
|
||||
**Acceptance:** build 0/0; запуск Api — лог без ошибок цикла; demo/ingest → в пределах ~5 с очередь
|
||||
разобрана (карточка в /leads или запись в /rejected) без ручного tick; psql: отсев со старым
|
||||
RejectedAt удаляется фоном (в пределах тика) + toast при подписанном SSE. Отчёт: `task-11-report.md`.
|
||||
|
||||
### Task 12: Полнотекстовый поиск карточек — /api/search (FTS + LIKE)
|
||||
|
||||
**Files:**
|
||||
- Modify: `K/Application/IKanjStore.cs` + `K/Application/Models/CardsQuery.cs` (или новый метод):
|
||||
`SearchCardsAsync(string q, int limit, CancellationToken)` — упорядоченный список CardDto по Ruling 6.
|
||||
- Modify: `I/Persistence/Repositories/KanbanStore.cs` — реализация: raw SQL по `Cards.SearchTsv`
|
||||
(`plainto_tsquery('russian')` + `ts_rank DESC, ReceivedAt DESC` + LIKE по title/summary/source_msg/contact,
|
||||
`col != 'taken'`, limit=12), затем полные CardDto (существующий маппинг/комментарии/time).
|
||||
- Modify: `K/Application/CardsService.cs` — `SearchCardsAsync` делегирует порту (старый перебор удаляется;
|
||||
поведение для q<2 — как сейчас, пусто).
|
||||
- Test: `T/CardsServiceTests.cs` — дополнить: вызов порта с q≥2/лимитом; q<2 → пусто (порт не зовётся).
|
||||
|
||||
**Источники:** leads.py search L509–551; fts.py; api-map §3.2 L101; Rulings 6; этап 3 Task 7 (текущий LIKE-путь).
|
||||
|
||||
**Acceptance:** `dotnet test` PASS; build 0/0; curl (после Task 10-приёмки, карточки созданы): /api/search?q=
|
||||
<слово из title/source> → карточка; морфология «разработчик»/«разработчику» (по summary) → карточка
|
||||
(tsvector); messages: []. Отчёт: `task-12-report.md`.
|
||||
|
||||
### Task 13: Финал этапа — интеграция и сквозная приёмка
|
||||
|
||||
- `scripts/build.sh`/`scripts/test.sh` — успешны; `dotnet build Deal.sln` 0/0; все unit-тесты PASS
|
||||
(410 этапа 3 + новые).
|
||||
- Сквозной curl-сценарий (DEAL_DEMO=1, admin/admin): boot-группы → demo/ingest вакансии
|
||||
(«Middle Python…, бюджет 1600–2200$, @crm_head, tg…», dialog demo_channel) → admin/tick → GET /leads:
|
||||
карточка l_… inbox (title/summary-«О заявке»/stack/budget/converted/contacts/ch/sourceMsg) → ingest
|
||||
короткого текста → tick → GET /pipeline/rejected: stageLabel «короткое сообщение», source «правила»;
|
||||
ingest текста со стоп-фразой (PATCH settings stopPhrases) → отсев stop c kw; повторный ingest того же
|
||||
текста вакансии → отсев dup (карточка уже есть); ingest без суммы при budgetRequiredHire=true → отсев
|
||||
«нет суммы»; ingest с msgAt старше archiveAfterDays → отсев «устарело» (карточки нет);
|
||||
GET /pipeline/queue?limit=120 — статусы new/filtered по ходу; GET /pipeline/stats — счётчики;
|
||||
GET /pipeline/rejected?q=<слово> (FTS) и ?q=<имя канала> (LIKE) → записи; DELETE /rejected/{id} →
|
||||
ok; POST /rejected/{id}/return {reason} (запись не dup/не returned) → возвращена в очередь (queue=1,
|
||||
запись returned=true), tick → карточка создана; повторный return той же записи → 400; POST
|
||||
/rejected/clear → {ok, cleared}; GET /api/search?q= по созданным карточкам; POST /admin/fts/rebuild →
|
||||
{ok,ready}; 401-проверки.
|
||||
- psql дефолтного тенанта: строки QueueItems/RejectedItems/DedupEntries; карточка ↔ dedup-связь
|
||||
(LeadId=карточка); удаление карточки (DELETE /leads/{id}) чистит DedupEntries; SearchTsv заполнены.
|
||||
- Обновить `docs/technical/Техническая-документация-Дейл.md`: раздел «Обработка/Pipeline» (таблицы
|
||||
этапа, эндпоинты /pipeline, демо-ingest, воркер-цикл 2 с, FTS, автоочистка отсева 3 дня, SSE-политика).
|
||||
- Отчёт `task-13-report.md` + финальная строка `progress.md`; roadmap-флаг «этап 4 выполнен».
|
||||
|
||||
## Self-Review
|
||||
|
||||
1. **Spec coverage:** ТЗ §5 (L84–121) — путь сообщения Tasks 5/8/10/11 (очередь→стоп-лист→дедуп→ML→ИИ→
|
||||
карточка); этап-1 (длина/стоп-фразы/резюме/тип) — Task 8 через IncomingRules; «устаревшее» — Task 8;
|
||||
ML-слой (уверен — сам, иначе ИИ, возврат мимо ML) — Ruling 5/Task 8; ИИ-слой (фильтр/классификация/
|
||||
колонка с проверкой правил) — Rulings 5/4, Tasks 6/7/8 (локальный детерминированный классификатор,
|
||||
реальный ИИ — этап 6); глобальные фильтры «без суммы» — Task 8; карточка (структура «О заявке»,
|
||||
поля §5/§4.1, контакты-квалификация, конверсия) — Task 7; ТЗ §7 (L150–161) — очередь/отсев/причины/
|
||||
поиск/возврат/автоочистка/счётчики — Tasks 5/9 + Rulings 1/8; api-map §3.6/§4.5 — Tasks 2/3/5/9;
|
||||
§3.2 admin-tick/fts — Task 10; §2 SSE — Ruling 8/9; roadmap этап 4 — все задачи.
|
||||
2. **Placeholder scan:** заглушки — только согласованные: `LocalMlClient` (ready:false — ML-ветка «спит»,
|
||||
ветки покрыты тестами на фейках), `LocalAiClassifier` (детерминированный до ai-service этапа 6;
|
||||
фильтр — pass+skipped, ветки отсева spam_ai/filter_ai готовы к этапу 6), demo-ingest (до telegram-этапа
|
||||
6; контракт приёма — публичный сервис модуля), `messages:[]` в /api/search (api-map п.3), reclassify —
|
||||
заглушка этапа 3. Референсы на строки прототипа — точные; FIXME/TODO нет.
|
||||
3. **Type consistency:** Pipeline → Settings (порты/IncomingRules) и Pipeline → Kanban (IKanjStore + чистые
|
||||
помощники) — без циклов; Kanban не знает Pipeline; оркестрация тика и SSE — в Api (Ruling 5 этапа 3);
|
||||
FTS-колонки — в миграции TenantPipeline, владельцы таблиц не меняются (Kanban: Cards; Pipeline:
|
||||
QueueItems/RejectedItems/DedupEntries); контракт IMlClient не меняется (счётчики решений — KV через
|
||||
ISettingsStore); IAiClassifier в Contracts — подмена на gRPC этапа 6 без правки эндпоинтов; словари
|
||||
отсева/причины/тексты — 1:1 с прототипом; сущности/конфиги — конвенция TenantSettingEntity/CardEntity.
|
||||
4. **Вне scope этапа 4:** реальные ai/telegram/ml-сервисы и их gRPC-ингресс (этап 6; приём только demo-
|
||||
ingest), Projects/reminder_due (этап 5), discovery (этап 6), события pipeline_stats/boards_changed/
|
||||
leads_reclassified (фронт не слушает — не публикуем), «спам-квоты»/новые глобальные exclude-настройки
|
||||
(в api-map/прототипе нет), admin/wipe|clear-cards|pump-gate, ml/learn|flush, /leads/{id}/seen,
|
||||
reclassify-реализация (этап 6), оператор/лимиты/аудит (этап 7).
|
||||
@@ -0,0 +1,528 @@
|
||||
# Дейл (Deal) — Этап 5: Projects («Выбранные»): стадии, напоминания, файлы/ссылки, история, ручное создание Implementation Plan
|
||||
|
||||
> Исторический документ этапа 5. Актуальное состояние — `docs/superpowers/STATUS.md` и `docs/technical/Техническая-документация-Дейл.md`.
|
||||
|
||||
**Goal:** Оживить в модульном монолите `src/core` вкладку «Выбранные» Vue-фронта 1:1-контрактом `/api`
|
||||
проектного канбана: карточки, взятые «в работу» из дашборда (лид уходит безвозвратно, `col='taken'`) и
|
||||
созданные вручную («локальные»), путь по 9 предзаданным стадиям (planned → … → ready/hold, терминальные
|
||||
finished/rejected), редактирование суммы/стека/контактов/ТЗ, комментарии, ссылки, файлы (тип по MIME/расширению;
|
||||
хранение через порт `IFileStorage`: локальный диск по умолчанию и MinIO при конфигурации), история движения
|
||||
под спойлером, напоминания стадии «Отложено» (окно настройки — фронт, бэкенд хранит `at`; фоновая проверка
|
||||
в 30-с цикле; SSE `reminder_due` + баннер), очистка «Отклонено». К концу этапа ProjectsView полностью
|
||||
обслуживается бэкендом (boot-заглушка `GET /api/projects {items:[]}` заменяется реальным списком), приёмка —
|
||||
unit/curl/psql; «взятые» в архив/корзину дашборда не попадают, автоархив тика их не касается.
|
||||
|
||||
**Architecture:** новый модуль `Deal.Modules.Projects` (чистый, без EF/HTTP) — владелец таблицы
|
||||
`ProjectCards` (миграция `TenantProjects` в `TenantDbContext`) и логики «Выбранных»: стадии-константы
|
||||
`ProjectStages` (1:1 constants.py PIPELINE_STAGES), DTO карточки (§4.3), порт `IProjectStore`, сервисы
|
||||
`ProjectsService` (чтение, ручное создание, «взять в работу», правка полей, move+история, clear-rejected,
|
||||
комментарии, ссылки), `ProjectFilesService` (добавить/удалить файл: детект типа → `IFileStorage.Put` →
|
||||
метаданные в карточку), `ProjectReminderService` (set/clear/snooze и фоновая проверка due). Чужие владения
|
||||
модуль не трогает: чтение лида и пометку `col='taken'` выполняет через публичный порт Kanban
|
||||
(`IKanjStore.GetCardAsync` + новый `MarkTakenAsync`, Ruling 5); настройки — порт Settings `ISettingsStore`
|
||||
(`remindersEnabled` уже в каталоге ключей, дефолт true). Файлы — внешний порт `IFileStorage`
|
||||
(Contracts/Integrations) с двумя адаптерами в Infrastructure: `LocalFileStorage` (корень
|
||||
`data/attachments`, dev-режим по умолчанию) и `MinioFileStorage` (MinIO S3-клиент, включается секцией
|
||||
`Storage:Minio`/`DEAL_MINIO_*`; бакет `deal-files` создаётся лениво; сервис minio добавляется в
|
||||
`deploy/compose.dev.yml`). HTTP — `Deal.Api/Endpoints/ProjectsEndpoints.cs` (`MapProjectsEndpoints`); фоновая
|
||||
проверка напоминаний — внутри существующего `StorageTickScheduler` (30 с, паттерн Kanban-тика по тенантам) и
|
||||
ручного `POST /api/admin/tick` (`AdminTickOrchestrator`); SSE `reminder_due` публикуется только из Api-слоя
|
||||
(Ruling 5 этапа 3); boot-заглушка GET /api/projects удаляется (остаётся /tg/status).
|
||||
|
||||
**Spec:** `docs/api/api-map.md` §3.5 (L153–174), §2 SSE (L33–43: `reminder_due` = `{id, title, stage}`), правила
|
||||
(L7–24: контент-типы multipart/octet-stream, 410/404, «кривые места» L390–400 — п.5 reminder_due, п.6
|
||||
DELETE-400, п.9 экономия: `/projects/reminders` НЕ реализуем), §4.3 проектная карточка (L280–300), §4.4
|
||||
стадии (L302–304), §3.2 admin/tick reminders (L103–112), §4.6 remindersEnabled (L328, L340);
|
||||
`docs/spec`/ТЗ.md §4.8 «Выбранные» (L119–132); roadmap (этап 5, L69–73); референс-семантика прототипа:
|
||||
`backend/app/services/projects.py` (целиком: _insert/_row_to_card L31–100, create_local_card L103–124,
|
||||
take_lead_to_projects L127–156, patch_card L159–199, add_comment L194–199, move_stage L202–216,
|
||||
clear_stage L223–231, напоминания L236–282), `backend/app/routers/projects_routes.py` (целиком),
|
||||
`backend/app/services/files.py` (целиком: KIND_BY_EXT/KIND_LABELS L13–28, detect L31–45, add_file L57–75,
|
||||
get_file_entry L78–83, remove_file L86–94), `backend/app/services/object_store.py` (целиком: configured,
|
||||
put/get/remove, локальный fallback L54–79), `backend/app/services/leads.py` (L151–156, L526–545 — взятые
|
||||
исключены из списков/поиска), `backend/app/db.py` (L103–125 — таблица projects), `backend/app/constants.py`
|
||||
(L16–27 — PIPELINE_STAGES), `backend/app/sse.py`, `backend/app/main.py` (L47–53 — 30-с цикл с
|
||||
check_reminders), `backend/app/routers/dashboard_routes.py` (admin_tick L327–337);
|
||||
фронт: `src/frontend/src/views/ProjectsView.vue` (колонки по PIPELINE_STAGES data.js), `components/
|
||||
{ProjectColumn,ProjectCard,ProjectDrawer,HoldReminderDialog,ReminderNotice}.vue`, `store.js` (boot L571–593;
|
||||
startProject L1954–1966; moveProject L1968–1980; patchProject/addProjectComment/addProjectLink/removeProjectLink
|
||||
L1985–2027; addProjectFiles/removeProjectFile L2031–2053; setHoldReminder/clearHoldReminder/snooze/
|
||||
clearDueReminder L2060–2146; startRealtime L670–674 — reminder_due), `api.js` (openEvents L62–83 — слушает
|
||||
reminder_due); конвенции/образцы планов этапов 1–4 (файлы `docs/superpowers/plans/2026-09-05-deal-stage{1,2,3,4}-*.md`).
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Проект **НЕ git**; фиксация — отчёты задач `task-N-report.md` и `progress.md` в `.superpowers/sdd/deal-stage5-projects/`.
|
||||
- .NET 10 SDK, решение собирается 0 warnings / 0 errors (`TreatWarningsAsErrors`); dev-Postgres `deal-postgres`
|
||||
(:5433); curl-приёмка :5080 (`scripts/build.sh`/`scripts/test.sh`); NuGet `Minio` — только в этапе файлов (Task 6).
|
||||
- Код-стайл этапов 1–4: 1 тип = 1 файл; XML-doc на public-контракты; комментарии на русском; без регионов;
|
||||
без магических чисел (именованные константы); PascalCase-колонки БД; времена — `DateTimeOffset` (UTC) в БД,
|
||||
наружу epoch-ms; JSON camelCase; ошибки `{"detail"}`.
|
||||
- Модуль Projects — чистый: без EF и HTTP; зависимости — `Deal.Contracts` (IFileStorage), `Deal.Modules.Settings`
|
||||
(порт ISettingsStore), `Deal.Modules.Kanban` (порт IKanjStore и его read-DTO CardDto/CardBudgetDto/CardCommentDto).
|
||||
Реверс-зависимостей нет: Kanban/Settings/Contracts о Projects не знают; публикации SSE — только из Api (Ruling 5
|
||||
этапа 3); оркестрация тика — `AdminTickOrchestrator`/`StorageTickScheduler`.
|
||||
- LeadRadar-контейнеры, `backend/`, `mlservice/`, `src/frontend/` НЕ трогаем; Vue-фронт не переписывается: формы
|
||||
JSON 1:1 с api-map. Проектные карточки живут до терминальной стадии: автоархив/корзина тика (StorageTickService,
|
||||
таблица Cards) их не касается; единственный hard-delete — ручная очистка стадии «Отклонено».
|
||||
- Строки ошибок/тостов/комментариев — фиксированные из прототипа (см. задачи): «Карточка не найдена», «Лид не
|
||||
найден», «Пустой комментарий», «Пустая ссылка», «Неизвестная стадия», «Напоминания об отложенных выключены в
|
||||
настройках», «Удаление проектных карточек отключено» (не используется — DELETE не реализуем), «Файл не найден
|
||||
в MinIO», «Файл не сохранён в объектном хранилище», «Взял в работу из лида.».
|
||||
|
||||
## Зафиксированные решения (Rulings этапа)
|
||||
|
||||
- **Ruling 1 (а) — таблицы tenant-схемы (миграция TenantProjects) и стадии.** Новая миграция `TenantProjects`
|
||||
контекста `TenantDbContext` (папка `I/Migrations/TenantDb`, применяется провижинером ко всем схемам). Таблиц
|
||||
ОДНА — `ProjectCards` (1:1 с таблицей `projects` db.py L103–125; владелец — модуль Projects). Колонки
|
||||
(PascalCase, JSON-массивы — text-колонками как `Cards.StackJson`): Id (`pr_`, PK), Stage (строка),
|
||||
Local (bool), LeadId (nullable, БЕЗ FK — «мягкая» ссылка на `Cards.Id`, конвенция DedupEntries Ruling 1 этапа 4),
|
||||
Title, Summary (text), StackJson (text), BudgetFrom/BudgetTo (double?), BudgetCur (пусто — бюджета нет),
|
||||
Contact, CommentsJson/LinksJson/FilesJson/HistoryJson/TzText (text), ReminderAt (nullable), ReminderFired (bool),
|
||||
CreatedAt, UpdatedAt (`DateTimeOffset`). JSON-массивы хранят wire-формы элементов (комментарий {id,by,text,time};
|
||||
ссылка {id,name,url}; файл {id,name,size,kind,label,objectKey}; история {id,at,type|stage}) — как python хранит
|
||||
готовые dict-ы (projects.py _insert L71–100). Индексы: `(Stage)` (idx_projects_stage L125), `(UpdatedAt)` DESC
|
||||
(порядок списка), частичный UNIQUE `(LeadId)` `WHERE LeadId IS NOT NULL` — «один лид → одна проектная карточка»
|
||||
(страховка гонки take). Комментарии/история НЕ выносятся в отдельные таблицы (внешних читателей нет — YAGNI).
|
||||
**Стадии канбана «Выбранных» ПРЕДЗАДАНЫ и не являются сущностями** (прототип: константа, не таблица) — проверено
|
||||
по прототипу/фронту: 9 фиксированных стадий §4.4 = planned Запланировано `#818cf8`, reply Отклик `#38bdf8`,
|
||||
agree Согласование `#a78bfa`, work В работе `#fbbf24`, review Проверка `#f97316`, ready Готово `#4ade80`,
|
||||
hold Отложено `#94a3b8` (не terminal), finished Выполнено `#2bd576` (terminal), rejected Отклонено `#ff6b6b`
|
||||
(terminal) (constants.py L17–27). Пользовательских стадий/досок проектного канбана в прототипе НЕТ.
|
||||
- **Ruling 2 (б) — миграция/владелец/границы.** Владелец схемы — модуль `Deal.Modules.Projects` (Ruling 1);
|
||||
EF-адаптер `ProjectStore` — в `Deal.Infrastructure`; регистрация `AddProjectsModule()` + `AddScoped<IProjectStore, ProjectStore>`
|
||||
(в AddDealPersistence). Порт `IProjectStore` объявлен в модуле (эталон IKanjStore/IPipelineStore); DTO-модели —
|
||||
в `P/Application/Models/`. Публичный контракт наружу (эндпоинты) — сервисы модуля: `ProjectsService`,
|
||||
`ProjectFilesService`, `ProjectReminderService`. csproj модуля: ProjectReference на `Deal.Modules.Settings`,
|
||||
`Deal.Modules.Kanban`, `Deal.Contracts`. HTTP — `A/Endpoints/ProjectsEndpoints.cs`, `Program.cs` —
|
||||
`AddProjectsModule()` + `MapProjectsEndpoints()` + `AddDealFileStorage(...)` (Task 6/8); Api.csproj — ссылка на модуль.
|
||||
- **Ruling 3 (в) — напоминания «Отложено»: механика и границы «бэкенд/фронт».** Окно при переносе в «Отложено»
|
||||
(`HoldReminderDialog`) — ФРОНТ: после успешного `move` на hold store.js L1976–1980 сам открывает окно, если
|
||||
`state.remindersEnabled`, и никакого напоминания при move не шлёт; бэкенд получает напоминание отдельным
|
||||
`POST /{card}/reminder {at}` (setHoldReminder L2060–2089: «через N дней (1–30)» или «дата+время» — расчёт `at`
|
||||
полностью на клиенте, epoch-ms). Семантика 1:1 с projects.py L236–282: (1) `set_reminder`: если
|
||||
`remindersEnabled` == false → 400 «Напоминания об отложенных выключены в настройках»; иначе запись
|
||||
reminder_at + reminder_fired=false; стадия карточки НЕ проверяется (фронт шлёт только для hold); (2)
|
||||
`clear_reminder` и `snooze` (at = now + 24 ч) выключатель НЕ проверяют (1:1); (3) ЛЮБОЙ move сбрасывает
|
||||
напоминание (reminder_at=NULL, reminder_fired=false — move_stage L210–213); (4) фоновая проверка
|
||||
`ProjectReminderService.CheckDueAsync`: выключено → ТОЛЬКО очистка протухших (reminder_at ≤ now; чтобы при
|
||||
включении старые не «выстрелили»), возврат []; включено → строки `stage='hold' AND reminder_fired=false AND
|
||||
reminder_at ≤ now` помечаются fired и возвращаются списком `[{id,title,stage}]`; (5) SSE `reminder_due` по каждой
|
||||
записи публикует Api-слой (Ruling 5 этапа 3) — в ручном тике и фоновом цикле; ответ `POST /admin/tick` →
|
||||
`reminders: [те же записи — «уже выстрелившие», после SSE]` (api-map §3.2 L103–112); (6) цикл проверки — 30 с в
|
||||
существующем `StorageTickScheduler` (main.py L47–53: тик → тосты → check_reminders), отдельный hosted-сервис НЕ
|
||||
заводим; ручной путь — `POST /api/admin/tick` (dashboard_routes.py L327–337). Настройка — уже готовый публичный
|
||||
ключ SettingsKeys.RemindersEnabled (дефолт true, SettingsDefaults L117; PATCH /api/settings работает с этапа 2).
|
||||
- **Ruling 4 (г) — файлы: порт IFileStorage, адаптеры, ключи, тип.** Новый внешний порт
|
||||
`C/Integrations/IFileStorage.cs`: `PutAsync(objectKey, Stream, contentType, ct)` (возвращает objectKey),
|
||||
`GetAsync(objectKey, ct) → Stream?` (null — объекта нет), `DeleteAsync(objectKey, ct)` — как object_store.py
|
||||
L61–107. Адаптеры в `Deal.Infrastructure/Integrations/` (секция AddDealIntegrations/отдельный
|
||||
`AddDealFileStorage(IConfiguration, contentRoot)`): `LocalFileStorage` — root `data/attachments` под ContentRoot
|
||||
(fallback прототипа object_store.py L54–79: `_local_path` строит путь из objectKey и не даёт выйти за root),
|
||||
`MinioFileStorage` — MinIO S3-клиент (NuGet `Minio`; ленивая проверка/создание бакета при первом put —
|
||||
object_store.py L26–51; креды `Storage:Minio` {Endpoint, AccessKey, SecretKey, Bucket="deal-files", Secure} из
|
||||
appsettings/env `Storage__Minio__*`). Выбор на старте: Minio-адаптер регистрируется, только если Endpoint и
|
||||
AccessKey/SecretKey заполнены; иначе LocalFileStorage — dev/curl/unit по умолчанию идут БЕЗ MinIO (требование
|
||||
«заглушка-адаптер, если MinIO недоступен» из roadmap). В `deploy/compose.dev.yml` добавляется сервис `minio`
|
||||
(порты 9000/9001, volume deal_minio_data, root-пользователь) — опциональная ручная проверка MinIO-режима.
|
||||
objectKey = `projects/{cardId}/{unixMs}_{safeName}` — 1:1 с object_store.put L65 (safeName: имя файла
|
||||
санитизируется — path-разделители/кавычки заменяются; единственный бакет и отсутствие tenant-префикса — как в
|
||||
прототипе: бакет один, доступ к объекту только через метаданные карточки в БД тенанта; мульти-аренда
|
||||
объектного хранилища — этап 7 SaaS). Тип файла — чистый `FileKindDetector` модуля Projects: MIME-префиксы
|
||||
image|video|audio → kind, иначе расширение по наборам files.py L13–28 (KIND_BY_EXT, метки KIND_LABELS:
|
||||
Изображение/Видео/Аудио/Архив/Документ/Файл). Метаданные — в `ProjectCards.FilesJson` (запись
|
||||
{id `pf_`, name, size, kind, label, objectKey}); значки-счётчики на карточке — длина массивов links/files в
|
||||
ProjectCardDto. Download: stream, `application/octet-stream`, `Content-Disposition: attachment; filename="…"`
|
||||
(кавычки имени убираются, projects_routes.py L174–179); отсутствие objectKey у записи → 410 «Файл не сохранён
|
||||
в объектном хранилище»; GetAsync == null → 404 «Файл не найден в MinIO» (фиксированная строка прототипа);
|
||||
запись/карточка не найдены → 404 «Карточка не найдена» (прототип на этом пути отдаёт 500 — для .NET выбираем
|
||||
корректный 404, фронт таких запросов не шлёт). Upload — multipart/form-data, поле `files` (несколько файлов),
|
||||
ответ `{items: [файл]}`; фронт после upload/delete перечитывает карточку (store.js L2031–2053).
|
||||
- **Ruling 5 (д) — «взять в работу».** Эндпоинт `POST /api/projects/take {leadId}` принадлежит модулю Projects
|
||||
(api-map §3.5 L161 — не leads). Поток 1:1 с take_lead_to_projects (projects.py L127–156): (1) лид читается
|
||||
через публичный порт Kanban `IKanjStore.GetCardAsync` — null → 404 «Лид не найден»; (2) по LeadId ищется
|
||||
существующая проектная карточка (`IProjectStore.GetByLeadAsync`) — есть → возврат её (идемпотентность);
|
||||
(3) создаётся ProjectCard: stage=planned, local=false, title/summary/stack/budget/contact копируются из CardDto
|
||||
лида, comments=[{id `cm_`, by «Вы», text «Взял в работу из лида.», time «только что»}], history=[{id `h_`, at,
|
||||
type:"created"}], tzText=""; (4) лид помечается `IKanjStore.MarkTakenAsync(leadId)` — новый метод порта Kanban
|
||||
(UPDATE Cards SET Col='taken', IsNew=false WHERE Id=?; возвращает bool «строка обновлена»), реализация — в
|
||||
KanbanStore; метод НЕ пишет CardMoves, не трогает matchHits/prevCol/архивные поля (1:1 с проектом L155 — только
|
||||
col и is_new). Гонка двух take: частичный UNIQUE `ProjectCards.LeadId` (Ruling 1) — вторая вставка падает,
|
||||
сервис перечитывает и возвращает существующую карточку. Никаких журналов/ML-сигналов/SSE при take. matchHits и
|
||||
dedup-связь лида НЕ удаляются (текст остаётся в системе — повтор не заведётся); лид остаётся строкой Cards
|
||||
(col=taken) и уже исключён из списков/поиска/счётчиков (leads.py L151–156, L526–545; этапы 3–4). Обратного пути
|
||||
«Выбранные → дашборд» НЕТ (ТЗ L124–125). «Отклонено»/«Выполнено» — терминальные стадии проектного канбана;
|
||||
проектные карточки в архив/корзину дашборда не попадают (отдельная таблица, автоархив StorageTickService
|
||||
оперирует только Cards) — StorageTickService/Kanban НЕ меняем.
|
||||
- **Ruling 6 (е) — ручное создание.** `POST /api/projects` с телом {title, summary, stack?, budget?, contact,
|
||||
tzText?, stage?} (projects_routes.py L20–28): local=true, history=[{type:"createdLocal"}], title — Trim(),
|
||||
stage = переданный, если в каталоге ProjectStages, иначе "planned" (create_local_card L103–124). Фронт шлёт
|
||||
`{title:''}` (store.js L1909–1915) — пустой заголовок допустим (1:1).
|
||||
- **Ruling 7 (ж) — история движения.** Пишется ТОЛЬКО на создание (запись {id `h_`, at, type:"created"|"createdLocal"})
|
||||
и на каждую смену стадии (запись {id, at, stage:<новая>}) — move_stage L207–215; правка полей, комментарии,
|
||||
ссылки, файлы, напоминания в историю НЕ пишутся (1:1 прототип). Хранится JSON-массивом в карточке; фронт
|
||||
показывает под спойлером «История движения» (ProjectDrawer). Ответы мутаций несут полную `history`.
|
||||
- **Ruling 8 (з) — SSE `reminder_due`.** Событие `reminder_due` несёт `{id, title, stage}` (api-map §2 L33–42; id —
|
||||
проектной карточки, stage всегда "hold"); фронт слушает событие (api.js L62–83) и для баннера берёт карточку из
|
||||
локального `projectCards` по id (api-map п.5 L395) — публикуем только после того, как карточки ушли в
|
||||
`GET /api/projects`. Публикации — только из Api (ручной тик AdminTickOrchestrator и StorageTickScheduler);
|
||||
дополнительный toast НЕ шлём (у фронта — модалка ReminderNotice с действиями Открыть/Позже/Снять).
|
||||
- **Ruling 9 (и) — эндпоинты этапа.** Реализуем 16 из 18 эндпоинтов §3.5 (столько вызывает фронт). НЕ реализуем:
|
||||
`GET /api/projects/reminders` (api-map п.9 L399 — фронт не вызывает: активные напоминания фронт берёт из
|
||||
projectCards; список в настройках-UI отсутствует) и `DELETE /api/projects/{card_id}` (п.6 L396 — всегда 400
|
||||
«отключено», фронт кнопки не имеет; по истории правок пользователя «удаление проектной карточки не делаем»).
|
||||
Удаление карточек — только `POST /api/projects/clear-rejected` (hard-delete строк стадии rejected, 1:1
|
||||
clear_stage L223–231; при пустой стадии {ok:true, cleared:0}). Порядок маршрутов: статические сегменты
|
||||
(`/clear-rejected`, `/take`) регистрируются до `/{cardId}`; вложенные (`/move`, `/comments`, `/links`,
|
||||
`/files`, `/reminder`) — за `/{cardId}` (методы разные, конфликтов GET/POST нет, но соблюдаем конвенцию api-map
|
||||
L19–24). Смежные доработки: `POST /api/admin/tick` возвращает reminders (Ruling 3), boot-заглушка GET /api/projects
|
||||
удаляется из BootStubEndpoints (остаётся /tg/status — этап 6).
|
||||
- **Ruling 10 (к) — «жизненный цикл» проектной карточки.** Карточка живёт от создания (take/local) до
|
||||
терминальной стадии; hard-delete только через clear-rejected. Никаких автоочисток «Выполнено» (готово живёт в
|
||||
списке). Напоминание не мешает move на другие стадии; переход на терминальную стадию не архивирует и не
|
||||
удаляет карточку (фронт считает её в «всего»). Локальный флаг `local` (wire) — пометка «создано локально» на
|
||||
карточке (ProjectCard.vue L61–70: local, «из лида» = leadId && !local).
|
||||
- **Ruling 11 (л) — сервисы модуля, id и wire.** Проектные id (короткие, генератор PrefixId этапа 3): карточка
|
||||
`pr_`, комментарий `cm_` (общий префикс Kanban), ссылка `pl_`, файл `pf_`, история `h_` (python store.uid).
|
||||
ProjectCardDto — формы §4.3 (camelCase; createdAt/updatedAt/at — epoch-ms); stack — массив строк; budget —
|
||||
объект {from,to,cur}|null (DTO Kanban CardBudgetDto переиспользуется; Cur пустой строкой означает «нет
|
||||
бюджета» → null наружу); комментарий — форма {id,by,text,time} (DTO Kanban CardCommentDto). Сортировка списка —
|
||||
UpdatedAt DESC, опциональный фильтр `?stage=` (list_cards L58–63). Настройки модуль читает портом
|
||||
ISettingsStore.GetBoolAsync(SettingsKeys.RemindersEnabled) (дефолт — через SettingsDefaults).
|
||||
- **Ruling 12 (м) — детерминированная приёмка без внешних сервисов.** Unit — fake-зависимости
|
||||
(FakeProjectStore/FakeIFileStorage/FakeKanjStore/FakeSettingsStore); файловая приёмка — локальный режим
|
||||
LocalFileStorage (data/attachments); MinIO-режим проверяется вручную при поднятом compose-сервисе (не входит в
|
||||
обязательную приёмку); напоминания приёмки — ручной POST /admin/tick (фоновый 30-с цикл не ждём).
|
||||
|
||||
## Задачи
|
||||
|
||||
Сокращения путей: `P=` `src/core/Deal.Modules.Projects/`, `K=` `src/core/Deal.Modules.Kanban/`,
|
||||
`S=` `src/core/Deal.Modules.Settings/`, `C=` `src/core/Deal.Contracts/`, `I=` `src/core/Deal.Infrastructure/`,
|
||||
`A=` `src/core/Deal.Api/`, `T=` `src/core/tests/Deal.Tests.Unit/`. Отчёты —
|
||||
`task-N-report.md` в `.superpowers/sdd/deal-stage5-projects/`.
|
||||
|
||||
### Task 1: Миграция TenantProjects — таблица ProjectCards
|
||||
|
||||
**Files:**
|
||||
- Create: `I/Persistence/Entities/ProjectCardEntity.cs` и `I/Persistence/ProjectCardConfiguration.cs`
|
||||
(поля/типы Ruling 1; JSON-колонки `.HasColumnType("text")`; индексы `(Stage)`, `(UpdatedAt)` (DESC),
|
||||
частичный UNIQUE `(LeadId)` — `HasFilter("\"LeadId\" IS NOT NULL")`; ReminderAt — nullable).
|
||||
- Modify: `I/Persistence/TenantDbContext.cs` — DbSet `ProjectCards` + `ApplyConfiguration`.
|
||||
- EF: миграция `TenantProjects` для `TenantDbContext` (как TenantKanban: `dotnet ef migrations add
|
||||
TenantProjects --context TenantDbContext --output-dir Migrations/TenantDb --project
|
||||
src/core/Deal.Infrastructure --startup-project src/core/Deal.Api`); старт Api применяет её к схеме
|
||||
дефолтного тенанта (провижинер).
|
||||
|
||||
**Источники:** db.py L103–125 (таблица projects); projects.py L31–100 (_insert/_row_to_card); Ruling 1;
|
||||
эталон: TenantPipeline-миграция, CardEntity/CardConfiguration.
|
||||
|
||||
**Acceptance:** build 0/0; `dotnet test` MarkerTests PASS; psql (search_path дефолтного тенанта): таблица
|
||||
ProjectCards с PK/колонками; индексы `IX_ProjectCards_Stage`, `IX_ProjectCards_UpdatedAt` (DESC), UNIQUE
|
||||
`IX_ProjectCards_LeadId` (partial: два NULL-а допустимы, два одинаковых LeadId — нет); `__TenantMigrationsHistory`
|
||||
содержит TenantProjects. Отчёт: `task-1-report.md`.
|
||||
|
||||
### Task 2: Модуль Projects — стадии, DTO карточки, порт IProjectStore, реестр
|
||||
|
||||
**Files:**
|
||||
- Create: `P/Application/ProjectStage.cs` (record Id/Name/Color/Terminal) и `P/Application/ProjectStages.cs`
|
||||
(каталог 9 стадий Ruling 1 в порядке planned→rejected + `Contains(stage)`; 1:1 constants.py L17–27/§4.4).
|
||||
- Create: `P/Application/ProjectIdPrefixes.cs` (`pr_`/`pf_`/`pl_`/`h_`; комментарий — KanbanIdPrefixes.Comment).
|
||||
- Create: `P/Application/Models/`: `ProjectFileDto.cs` (id/name/size/kind/label/objectKey), `ProjectLinkDto.cs`
|
||||
(id/name/url), `ProjectHistoryEntryDto.cs` (id/at; **или** type="created"|"createdLocal" — запись {Id, At, Type},
|
||||
либо stage — отдельный record с nullable-полями и фабриками `Created(now, local)`/`Moved(now, stage)`),
|
||||
`ProjectReminderDto.cs` ({At} объект|null на карточке), `ProjectCardDto.cs` (§4.3: id/stage/local/leadId/title/
|
||||
summary/stack/budget(CardBudgetDto?)/contact/comments(CardCommentDto[])/links/files/tzText/history/reminder/
|
||||
createdAt/updatedAt — наружу epoch-ms), `ProjectCardRow.cs` (полная запись для InsertAsync),
|
||||
`ProjectCardPatch.cs` (partial-поля правки: title/summary/contact/tzText/stack/budget/comments/links/files).
|
||||
- Create: `P/Application/IProjectStore.cs` — порт: ListAsync(stage?), GetAsync, GetByLeadAsync, CreateAsync(row),
|
||||
PatchAsync(cardId, patch) → bool, MoveStageAsync(cardId, stage, historyEntry, at) → bool (стадия+история+
|
||||
сброс reminder+bump UpdatedAt), SetReminderAsync(cardId, at) (bump), ClearReminderAsync(cardId),
|
||||
ClearStageAsync(stage) → int, ListDueAsync(now) → мини-DTO {Id,Title,Stage}, MarkFiredAsync(ids),
|
||||
ClearExpiredAsync(now) → int, RemoveAsync(cardId) (откат take, Ruling 5).
|
||||
- Create: `P/Application/ProjectsModuleRegistrar.cs` — `AddProjectsModule()` (сервисы задач 4/5/7 — по мере
|
||||
появления). Modify: `P/Deal.Modules.Projects.csproj` — ProjectReference на `Deal.Modules.Settings`,
|
||||
`Deal.Modules.Kanban`, `Deal.Contracts`.
|
||||
|
||||
**Источники:** api-map §4.3 L280–300, §4.4 L302–304; projects.py L31–100; constants.py L16–27; db.py L103–125;
|
||||
Rulings 1/2/11.
|
||||
|
||||
**Acceptance:** build 0/0; стадии 1:1 (имена/цвета/terminal, порядок); DTO — record'ы (camelCase); MarkerTests
|
||||
PASS. Отчёт: `task-2-report.md`.
|
||||
|
||||
### Task 3: EF-адаптер ProjectStore + DI
|
||||
|
||||
**Files:**
|
||||
- Create: `I/Persistence/Repositories/ProjectStore.cs` — реализация `IProjectStore` на `TenantDbContext`
|
||||
(эталон PipelineStore.cs/KanbanStore.cs): чтения AsNoTracking; JSON-опции camelCase (эталон KanbanStore
|
||||
JsonOptions L38–42); маппинг строки ↔ ProjectCardDto вручную (JSON-разбор stack/comments/links/files/history,
|
||||
бюджет → CardBudgetDto|null, reminder → ProjectReminderDto|null, времена ↔ epoch-ms); `CreateAsync` —
|
||||
INSERT; `PatchAsync` — точечные UPDATE по присутствующим полям патча (текстовые — как есть; stack —
|
||||
сериализация; budget — from/to/cur; comments/links/files — полная замена массива) + bump UpdatedAt;
|
||||
`MoveStageAsync` — один UPDATE (stage, reminder_at=NULL, reminder_fired=false, updated_at) + перезапись
|
||||
history-массива с добавленной записью; `ClearStageAsync` — DELETE WHERE Stage=; `ListDueAsync` —
|
||||
SELECT hold-карточек (ReminderAt ≤ now, ReminderFired=false, ORDER BY ReminderAt); `MarkFiredAsync` —
|
||||
UPDATE ... SET ReminderFired=true; `ClearExpiredAsync` — UPDATE ReminderAt=NULL WHERE ReminderAt ≤ now (1:1
|
||||
check_reminders L266–268: fired не важен — чистим все протухшие).
|
||||
- Modify: `I/ServiceCollectionExtensions.cs` — `AddScoped<IProjectStore, ProjectStore>()`.
|
||||
|
||||
**Источники:** projects.py L31–100, L159–199, L202–231, L264–282; Ruling 1/2; эталон KanbanStore.cs/PipelineStore.cs.
|
||||
|
||||
**Acceptance:** build 0/0; EF-путь покрывается psql/curl последующих задач (юнит на EF-адаптерах не пишем —
|
||||
конвенция этапа 4); базовые проверки psql (вставка/патч/move/список). Отчёт: `task-3-report.md`.
|
||||
|
||||
### Task 4: «Взять в работу» — порт Kanban MarkTakenAsync + ProjectsService (чтение/создание/take/патч/move/очистка)
|
||||
|
||||
**Files:**
|
||||
- Modify: `K/Application/IKanjStore.cs` — новый метод `MarkTakenAsync(string cardId, CancellationToken ct) →
|
||||
Task<bool>` (XML-doc: UPDATE Cards SET Col='taken', IsNew=false WHERE Id=? — «взять в работу» projects
|
||||
take_lead_to_projects L155; журнал CardMoves/архивные поля/matchHits не трогает, Ruling 5).
|
||||
- Modify: `I/Persistence/Repositories/KanbanStore.cs` — реализация `MarkTakenAsync` (affected == 1).
|
||||
- Create: `P/Application/ProjectsService.cs` — публичный сервис (Rulings 5/6/7/10): `ListAsync(stage?, ct)`,
|
||||
`GetAsync(cardId, ct)`; `CreateLocalAsync(ProjectCardPatch-начальные поля, ct)` (local=true, history createdLocal,
|
||||
stage-валидация); `TakeLeadAsync(leadId, ct)` (Ruling 5: GetCardAsync → 404-результат; GetByLeadAsync → возврат
|
||||
существующей; CreateAsync с комментарием «Взял в работу из лида.» + history created; MarkTakenAsync — false →
|
||||
RemoveAsync-откат и 404; конфликт UNIQUE LeadId (DbUpdateException) → перечитать GetByLeadAsync);
|
||||
`PatchAsync(cardId, patch, ct)` (404-результат); `MoveAsync(cardId, stage, ct)` (валидация ProjectStages →
|
||||
400-результат; запись истории + сброс reminder); `ClearRejectedAsync(ct)`; методы-результаты — тонкие
|
||||
record-результаты/исключения модуля (эталон CardsService/LeadsEndpoints-паттернов: сервис кидает доменные
|
||||
ошибки, эндпоинт мапит в 400/404 с точными строками).
|
||||
- Test: `T/FakeProjectStore.cs`, `T/ProjectsServiceTests.cs` (+ расширение `T/FakeKanjStore.cs` — GetCardAsync/
|
||||
MarkTakenAsync): take создаёт карточку (поля из лида, local=false, planned, комментарий-«Взял в работу из
|
||||
лида.», history created) и вызывает MarkTakenAsync; повторный take того же лида возвращает ту же карточку
|
||||
(GetByLeadAsync) без новой вставки; лид не найден → 404; create local (local=true, createdLocal, stage из тела/
|
||||
planned); move (история + запись stage + сброс reminder); move на неизвестную стадию → 400; patch полей (в т.ч.
|
||||
budget {from,to,cur}/null, stack) и bump UpdatedAt; clear-rejected удаляет только rejected и возвращает счётчик.
|
||||
|
||||
**Источники:** projects.py L103–124, L127–156, L159–231; projects_routes.py L78–121; leads.py L151–156;
|
||||
Rulings 5/6/7/10; эталон CardsService + IKanjStore-порт.
|
||||
|
||||
**Acceptance:** `dotnet test` новых тестов PASS; build 0/0. Отчёт: `task-4-report.md`.
|
||||
|
||||
### Task 5: Комментарии и ссылки (ProjectsService) + тесты
|
||||
|
||||
**Files:**
|
||||
- Modify: `P/Application/ProjectsService.cs` — `AddCommentAsync(cardId, text, ct)`: пустой после Trim → 400
|
||||
«Пустой комментарий»; новый {id `cm_`, by «Вы», text, time «только что»}; ответ — список comments
|
||||
(routes L124–128, projects.py add_comment L194–199). `AddLinkAsync(cardId, name, url, ct)`: url Trim, пустой →
|
||||
400 «Пустая ссылка»; без схемы → префикс `https://`; запись {id `pl_`, name: name.Trim() или url, url};
|
||||
через PatchAsync(files-нет → links-замена). `RemoveLinkAsync(cardId, linkId, ct)` (удаление из массива).
|
||||
- Test: `T/ProjectsServiceTests.cs` — комментарий (id/форма, пустой → 400, 404 карточки), ссылка (префикс
|
||||
https://, name=url по умолчанию, удаление по id, 400 пустой url).
|
||||
|
||||
**Источники:** projects_routes.py L124–150; projects.py add_comment L194–199, patch_card L159–187; Ruling 11.
|
||||
|
||||
**Acceptance:** `dotnet test` PASS; build 0/0. Отчёт: `task-5-report.md`.
|
||||
|
||||
### Task 6: Файлы — порт IFileStorage, Local/MinIO-адаптеры, FileKindDetector, compose-minio, DI
|
||||
|
||||
**Files:**
|
||||
- Create: `C/Integrations/IFileStorage.cs` (Ruling 4; XML-doc: objectKey — opaque, `projects/<card>/<ms>_<name>`).
|
||||
- Create: `P/Application/FileKindDetector.cs` — чистый детектор: `Detect(name, mime) → ProjectFileKind {Kind,
|
||||
Label}`; MIME-префиксы image/video/audio; иначе расширение по наборам (1:1 files.py L13–28: image/video/audio/
|
||||
archive/document + «other» → «Файл»).
|
||||
- Create: `I/Integrations/Storage/StorageOptions.cs` (секция Storage: Local {Root} + Minio {Endpoint, AccessKey,
|
||||
SecretKey, Bucket, Secure}), `LocalFileStorage.cs` (root `data/attachments` под ContentRoot; Put — mkdir + write,
|
||||
Get — FileStream|null, Delete — unlink; безопасный путь из objectKey: Path.GetFileName сегментов, object_store.py
|
||||
L54–79), `MinioFileStorage.cs` (Minio SDK: ленивый клиент + bucket_exists/make_bucket бакета `deal-files`,
|
||||
PutObject/GetObject/RemoveObject; NuGet `Minio` в `I/Deal.Infrastructure.csproj`).
|
||||
- Create: `I/Integrations/Storage/FileStorageRegistrar.cs` (или в ServiceCollectionExtensions) — метод
|
||||
`AddDealFileStorage(IConfiguration, string contentRoot)`: секция Storage:Minio заполнена → MinioFileStorage,
|
||||
иначе LocalFileStorage (root из Storage:Local:Root или дефолт).
|
||||
- Modify: `deploy/compose.dev.yml` — сервис `minio` (image minio/minio, container_name deal-minio, порты
|
||||
9000:9000/9001:9001, env MINIO_ROOT_USER/PASSWORD=deal_minio/deal_minio_secret, volume deal_minio_data,
|
||||
command server /data --console-address ":9001") + volume.
|
||||
- Test: `T/FileKindDetectorTests.cs` (png/jpg/webp → image; mp4 → video; mp3 → audio; pdf/docx/txt → document;
|
||||
zip/7z → archive; mime-image поверх неизвестного расширения; неизвестное → other/«Файл»);
|
||||
`T/LocalFileStorageTests.cs` (put/get round-trip; get отсутствующего → null; delete; objectKey с `..` не выходит
|
||||
за root).
|
||||
|
||||
**Источники:** files.py L13–45; object_store.py L26–107; ТЗ §4.8 L130; Ruling 4.
|
||||
|
||||
**Acceptance:** `dotnet test` PASS; build 0/0; запуск Api — LocalFileStorage (лог/путь data/attachments);
|
||||
compose config валиден (`docker compose -f deploy/compose.dev.yml config`). Отчёт: `task-6-report.md`.
|
||||
|
||||
### Task 7: ProjectFilesService — добавить/удалить файл (мета + объект)
|
||||
|
||||
**Files:**
|
||||
- Create: `P/Application/ProjectFilesService.cs` (Ruling 4): `AddAsync(cardId, fileName, contentType, dataStream/
|
||||
bytes, ct)` → ProjectFileDto: карточка существует (GetAsync → иначе 404-результат); `FileKindDetector.Detect`;
|
||||
objectKey = `projects/{cardId}/{unixMs}_{safeName}` (safeName: имя без path-символов/кавычек); `IFileStorage.Put`;
|
||||
запись {id `pf_`, name (как прислано), size (length), kind, label, objectKey} → PatchAsync(files-замена);
|
||||
`RemoveAsync(cardId, fileId, ct)` — entry из FilesJson → `IFileStorage.Delete(objectKey)` + PatchAsync(files без
|
||||
записи); `GetEntryAsync(cardId, fileId, ct)` → (entry|null) для download-эндпоинта. Зависимости: IProjectStore,
|
||||
IFileStorage. (Файл-контент читает эндпоинт из multipart; в сервис приходит Stream + длина.)
|
||||
- Test: `T/FakeFileStorage.cs`, `T/ProjectFilesServiceTests.cs`: add (детект kind по mime/имени, objectKey-форма,
|
||||
мета в карточке, порядок файлов сохраняется); remove (объект удалён, мета обновлена); 404 карточки; add на
|
||||
несуществующей карточке не пишет объект.
|
||||
|
||||
**Источники:** files.py L57–94; object_store.py L61–107; projects_routes.py L153–186; Ruling 4/11.
|
||||
|
||||
**Acceptance:** `dotnet test` PASS; build 0/0. Отчёт: `task-7-report.md`.
|
||||
|
||||
### Task 8: Эндпоинты /api/projects — карточки, стадии, комментарии, ссылки; замена boot-заглушки; curl-приёмка
|
||||
|
||||
**Files:**
|
||||
- Create: `A/Endpoints/ProjectsEndpoints.cs` (`MapProjectsEndpoints`, Ruling 9) — 10 эндпоинтов карточек/
|
||||
комментариев/ссылок (файл- и reminder-эндпоинты — задачи 9/10): GET
|
||||
`/api/projects?stage=` → `{items:[…]}`; GET `/api/projects/{cardId}` → карточка | 404 «Карточка не найдена»;
|
||||
POST `/api/projects` (CreateLocalRequest: title/summary/stack?/budget?{from,to,cur}/contact/tzText/stage?) →
|
||||
карточка; POST `/api/projects/take` {leadId} → карточка | 404 «Лид не найден»; POST `/api/projects/clear-rejected`
|
||||
→ `{ok:true, cleared}`; PATCH `/api/projects/{cardId}` (PartialUpdateRequest — все поля optional, budget может
|
||||
быть null) → карточка | 404; POST `/api/projects/{cardId}/move` {stage} → карточка | 400 «Неизвестная стадия» |
|
||||
404; POST `/api/projects/{cardId}/comments` {text} → `{comments:[…]}` | 400 «Пустой комментарий» | 404;
|
||||
POST `/api/projects/{cardId}/links` {name?,url} → карточка | 400 «Пустая ссылка» | 404; DELETE
|
||||
`/api/projects/{cardId}/links/{linkId}` → карточка; статические `/take`+`/clear-rejected` до `/{cardId}`.
|
||||
Сессия 401 (эталон LeadsEndpoints/StorageEndpoints: проверка HasUser + RequestServices-резолв ПОСЛЕ).
|
||||
- Create: `A/Endpoints/RequestModels/{CreateLocalProjectRequest,TakeLeadRequest,MoveStageRequest,
|
||||
ProjectCommentRequest,ProjectLinkRequest,ProjectPatchRequest}.cs`.
|
||||
- Modify: `A/Endpoints/BootStubEndpoints.cs` — удалить GET /api/projects-заглушку и константу ProjectsPath
|
||||
(остаётся /api/tg/status; класс-комментарий обновить). Modify: `A/Program.cs` — `AddProjectsModule()`,
|
||||
`MapProjectsEndpoints()`; `A/Deal.Api.csproj` — ProjectReference на `Deal.Modules.Projects`.
|
||||
- Test: `T/ProjectsEndpointsContractsTests.cs` НЕ нужен (endpoint-слои покрываются curl); MarkerTests остаются.
|
||||
|
||||
**Контракт:** api-map §3.5 L155–168; §4.3; projects_routes.py L15–150.
|
||||
|
||||
**Acceptance (curl admin/admin):** GET /api/projects → {items:[]}; POST /api/projects {title:''} → карточка
|
||||
(local=true, stage=planned, createdLocal-история); PATCH (title/stack/budget) → карточка с изменениями и
|
||||
возросшим updatedAt; POST /move {stage:'work'} → история пополнена {id,at,stage:work}, reminder null;
|
||||
move невалидной стадии → 400; POST /comments (пустой → 400 «Пустой комментарий»; текст → {comments:[…]});
|
||||
POST /links без схемы → https://…; DELETE /links/{id} → карточка без ссылки; POST /take {leadId=несуществующий}
|
||||
→ 404 «Лид не найден»; boot-группа (GET /api/projects) 200 — заглушка снята; 401 без куки. Отчёт: `task-8-report.md`.
|
||||
|
||||
### Task 9: Файл-эндпоинты /api/projects/{cardId}/files* — upload/download/delete + curl-приёмка
|
||||
|
||||
**Files:**
|
||||
- Modify: `A/Endpoints/ProjectsEndpoints.cs` — POST `/api/projects/{cardId}/files` (multipart/form-data, поле
|
||||
`files`; `request.ReadFormAsync`; каждый файл: имя/ContentType/Stream → `ProjectFilesService.AddAsync`);
|
||||
ответ `{items:[§4.3 файл]}` (404 «Карточка не найдена» при отсутствии карточки); GET
|
||||
`/api/projects/{cardId}/files/{fileId}/download` — entry через GetEntryAsync: нет записи → 404 «Карточка не
|
||||
найдена»/404 файла нет в метаданных; objectKey пуст → 410 «Файл не сохранён в объектном хранилище»;
|
||||
`IFileStorage.GetAsync` → null → 404 «Файл не найден в MinIO»; иначе `Results.Stream(stream,
|
||||
"application/octet-stream", fileDownloadName: имя без кавычек)` (Content-Disposition attachment, 1:1
|
||||
projects_routes.py L164–179); DELETE `/api/projects/{cardId}/files/{fileId}` → `{ok:true}` (404 карточки).
|
||||
Скачивание: один файл — в ответ Stream (Results.Stream сам диспозит).
|
||||
- Modify: DI-проверка — AddDealFileStorage вызван в Program.cs (Task 6; если Task 6 не успел — здесь).
|
||||
|
||||
**Контракт:** api-map L7–10 (multipart/octet-stream), L169–171; projects_routes.py L155–186; store.js L2031–2053.
|
||||
|
||||
**Acceptance (curl, local-режим):** загрузить 2 файла (`-F files=@tz.pdf -F files=@photo.png`) → {items:[2]};
|
||||
GET /api/projects/{id} — files с kind/label (document/«Документ», image/«Изображение»), size;
|
||||
download → 200 attachment + байты совпадают; DELETE файла → {ok:true}, карточка без файла, объект удалён из
|
||||
data/attachments; download удалённого → 404. Отчёт: `task-9-report.md`.
|
||||
|
||||
### Task 10: Напоминания — ProjectReminderService + эндпоинты reminder/reminder/snooze
|
||||
|
||||
**Files:**
|
||||
- Create: `P/Application/ProjectReminderService.cs` (Ruling 3): `SetAsync(cardId, atMs, ct)` — GetBoolAsync
|
||||
(ISettingsStore, RemindersEnabled) false → 400-результат «Напоминания об отложенных выключены в настройках»;
|
||||
карточки нет → 404; SetReminderAsync + возврат полной карточки; `ClearAsync(cardId, ct)` (404-результат);
|
||||
`SnoozeAsync(cardId, ct)` (now + 24 ч, не проверяет выключатель); `CheckDueAsync(ct)` → `IReadOnlyList<
|
||||
ProjectReminderDueDto{Id,Title,Stage}>` (Ruling 3: disabled → ClearExpiredAsync + []; enabled → ListDueAsync +
|
||||
MarkFiredAsync + due-список). Константа `ReminderSnoozeMs = 24 ч` (имя, не магия).
|
||||
- Modify: `A/Endpoints/ProjectsEndpoints.cs` — POST `/api/projects/{cardId}/reminder` {at: epoch-ms} → карточка |
|
||||
400 (напоминания выключены) | 404; DELETE `/api/projects/{cardId}/reminder` → `{ok:true}` | 404; POST
|
||||
`/api/projects/{cardId}/reminder/snooze` → `{ok:true}` | 404. `POST /{cardId}/reminder` и `DELETE
|
||||
/{cardId}/reminder` — до `/{cardId}/reminder/snooze` (snooze — статический сегмент за параметром).
|
||||
- Test: `T/FakeSettingsStore.cs` — уже умеет задавать значения; `T/ProjectReminderServiceTests.cs`: set при
|
||||
remindersEnabled=false → 400-текст; set ok → карточка с reminder.at; clear; snooze (+24 ч); CheckDueAsync:
|
||||
disabled → ClearExpired вызван, due пуст; enabled + due-строки → fired проставлены (MarkFired), возвращены
|
||||
{id,title,stage}; не-hold/будущие не «выстреливают».
|
||||
|
||||
**Источники:** projects.py L236–282; projects_routes.py L189–211; api-map L172–174, §4.6 L328/L340;
|
||||
Rulings 3/11.
|
||||
|
||||
**Acceptance:** `dotnet test` PASS; build 0/0. Отчёт: `task-10-report.md`.
|
||||
|
||||
### Task 11: POST /api/admin/tick — reminders + SSE reminder_due
|
||||
|
||||
**Files:**
|
||||
- Modify: `A/AdminTickOrchestrator.cs` — зависимость `ProjectReminderService`; порядок 1:1 с admin_tick
|
||||
(L327–337): (1) Kanban-тик → (2) purge отсева → (3) тосты → (4) **check-reminders** → SSE `reminder_due`
|
||||
({id,title,stage}, broker) по каждому due → (5) pump → (6) new_lead → (7) queue; ответ — reminders списком due
|
||||
(после SSE, api-map §3.2 L103–112). Ошибки проверки напоминаний не роняют тик (лог + reminders:[]).
|
||||
- Modify: `A/AdminTickResultDto.cs` — `Reminders: IReadOnlyList<object>` → типизированный
|
||||
`IReadOnlyList<ProjectReminderDueDto>` (XML-doc: этап 5 — реальный список).
|
||||
- Modify: `A/Program.cs` — регистрация ProjectReminderService уже через AddProjectsModule (Task 8).
|
||||
|
||||
**Источники:** dashboard_routes.py L327–337; projects.py check_reminders L264–274; main.py L47–53; api-map §3.2;
|
||||
Rulings 3/8.
|
||||
|
||||
**Acceptance:** build 0/0; unit — AdminTickOrchestratorTests (существуют): тик вызывает CheckDueAsync, публикует
|
||||
reminder_due по каждому due, reminders ответа = due; сбой reminder-проверки → reminders:[] без падения тика
|
||||
(обновить тесты под новую зависимость — fake ProjectReminderService). curl: reminder на hold-карточку в прошлом
|
||||
(at=now−1 мин) → POST /admin/tick → в SSE-подписке приходит reminder_due, ответ tick содержит reminders:[{id,
|
||||
title, stage:'hold'}]. Отчёт: `task-11-report.md`.
|
||||
|
||||
### Task 12: Фоновая проверка напоминаний — StorageTickScheduler (30 с)
|
||||
|
||||
**Files:**
|
||||
- Modify: `A/Hosting/StorageTickScheduler.cs` — в `TickTenantAsync` после Kanban-тика/purge/тостов:
|
||||
`ProjectReminderService.CheckDueAsync` из tenant-scope (резолв после SetTenant) → SSE `reminder_due` в канал
|
||||
тенанта (`SseBroker` — новая singleton-зависимость конструктора, эталон StorageToastPublisher L36–44); ошибки
|
||||
ветки логируются (тик тенанта продолжается, паттерн существующего catch). Порядок 1:1 с _storage_loop main.py
|
||||
L47–53 (тик → тосты → напоминания). Класс-комментарий обновить.
|
||||
- Modify: `A/Program.cs` — (регистрация уже есть) AddHostedService<StorageTickScheduler> остаётся; DI singleton
|
||||
SseBroker уже зарегистрирован.
|
||||
- Test: `T/StorageTickSchedulerTests.cs` — дополнить: тик тенанта вызывает CheckDueAsync и публикует reminder_due
|
||||
по due-записям (fake ProjectReminderService + реальный SseBroker с подпиской, как в существующих тестах
|
||||
тостов); disabled → событий нет.
|
||||
|
||||
**Источники:** main.py L47–53; projects.py check_reminders L264–274; StorageTickScheduler.cs L152–192;
|
||||
Rulings 3/8.
|
||||
|
||||
**Acceptance:** `dotnet test` PASS; build 0/0; запуск Api: hold-карточка с прошедшим reminder_at → в пределах
|
||||
30-с тика в SSE-подписке приходит reminder_due (psql: reminder_fired=true). Отчёт: `task-12-report.md`.
|
||||
|
||||
### Task 13: Финал этапа — интеграция и сквозная приёмка
|
||||
|
||||
- `scripts/build.sh`/`scripts/test.sh` — успешны; `dotnet build Deal.sln` 0/0; все unit-тесты PASS (535 этапа 4
|
||||
+ новые).
|
||||
- Сквозной curl-сценарий (DEAL_DEMO=1, admin/admin, local-файлы): демо-ingest вакансии → admin/tick →
|
||||
карточка в /leads; POST /api/projects/take {leadId} → проектная карточка (local=false, planned, leadId, история
|
||||
created, комментарий «Взял в работу из лида.»); повторный take → та же карточка; лид исчез из GET /leads и
|
||||
/api/search (taken); GET /api/projects — список (UpdatedAt DESC); PATCH карточки (title/stack/budget/contact/
|
||||
tzText) → поля обновлены; POST /move по стадиям planned→reply→work→hold (история: 4 записи stage) → reminder на
|
||||
past-время → admin/tick → SSE reminder_due + reminders ответа; move hold→ready (напоминание снято — reminder
|
||||
null); POST /comments, POST/DELETE /links; upload 2 файлов (kind по MIME/расширению) → счётчики в карточке →
|
||||
download (attachment, байты) → DELETE файла; локальная карточка POST /api/projects {title} (local=true,
|
||||
createdLocal); перенос локальной в rejected → POST /clear-rejected {ok, cleared:1}; 401-проверки без куки;
|
||||
GET /api/projects/reminders и DELETE /api/projects/{id} — 404 маршрута нет (сознательно не реализованы, Ruling 9).
|
||||
- psql дефолтного тенанта: ProjectCards — строки всех сценариев; reminder_at/fired; UNIQUE-индекс (вставка
|
||||
второго проекта с тем же LeadId → ошибка unique); лид в Cards col='taken' + is_new=false; файлы в
|
||||
data/attachments соответствуют objectKey.
|
||||
- Обновить `docs/technical/Техническая-документация-Дейл.md`: раздел «Projects/„Выбранные“» (таблица
|
||||
ProjectCards, стадии, эндпоинты, напоминания/SSE reminder_due, файлы/IFileStorage/MinIO-compose, take-семантика,
|
||||
исключённые эндпоинты) и зафиксировать roadmap-флаг «этап 5 выполнен» (roadmap L69–73 → «Выполнено»).
|
||||
- Отчёт `task-13-report.md` + финальная строка `progress.md`.
|
||||
|
||||
## Self-Review
|
||||
|
||||
1. **Spec coverage:** ТЗ §4.8 (L119–132): стадии-канбан и терминальные статусы — Rulings 1/10, Task 4;
|
||||
«взять в работу» с уходом лида безвозвратно — Ruling 5, Task 4 (+ исключение из списков/поиска — уже в этапах
|
||||
3/4); ручное создание «локальных» — Ruling 6, Task 4; редактирование суммы/стека/контактов/ТЗ и комментарии —
|
||||
Tasks 4/5; ссылки и значки-счётчики — Task 5 + DTO; файлы с определением типа (MIME+расширение) и хранением
|
||||
MinIO/локальный fallback — Rulings 4, Tasks 6/7/9; история движения под спойлером (создание/каждая стадия,
|
||||
статус-дата-время) — Rulings 7, Tasks 2/4; напоминания «Отложено» (окно 1–30 дней/календарь — фронт;
|
||||
выключено → окно не показывается и не срабатывают; автоснятие при уходе с hold) — Ruling 3, Tasks 10/11/12;
|
||||
очистка «Отклонено» и «не попадают в архив/корзину» — Rulings 5/10, Task 4. api-map: §3.5 — Tasks 8/9/10;
|
||||
§4.3/§4.4 — Task 2; §2 SSE reminder_due — Rulings 3/8, Tasks 11/12; admin/tick reminders — Task 11; boot-фронт
|
||||
(`GET /api/projects` в boot L571–593) — Task 8. Roadmap этапа 5 (L69–73) — все задачи.
|
||||
2. **Placeholder scan:** Заглушек нет: единственная «заглушка» — dev-файловое хранилище LocalFileStorage по
|
||||
умолчанию (1:1 с прототипом без MinIO, объектный ключ в БД тот же) при полной реализации MinIO-адаптера
|
||||
(включается конфигурацией); GET /api/projects/reminders и DELETE /{card_id} сознательно НЕ реализуются
|
||||
(api-map п.9/п.6, Ruling 9) — это не TODO, а решения. Референсы строк прототипа точные; FIXME/TODO нет.
|
||||
3. **Type consistency:** ProjectCardDto собирается из JSON-полей ProjectCards (тексты wire-форм 1:1 с python,
|
||||
хранятся/читаются с camelCase-опциями адаптера); IProjectStore (Task 2) реализуется ProjectStore (Task 3) без
|
||||
расхождений имён (ListAsync/GetAsync/GetByLeadAsync/CreateAsync/PatchAsync/MoveStageAsync/SetReminderAsync/
|
||||
ClearReminderAsync/ClearStageAsync/ListDueAsync/MarkFiredAsync/ClearExpiredAsync/RemoveAsync); IKanjStore
|
||||
расширяется одним методом MarkTakenAsync (Kanban не узнаёт о Projects); IFileStorage в Contracts не знает о
|
||||
таблицах (objectKey opaque), метаданные — владение Projects; ModuleProjects csproj → Settings/Kanban/Contracts —
|
||||
циклов нет; SSE-публикации только в Api (AdminTickOrchestrator/StorageTickScheduler), модули чистые; карточки
|
||||
Kanban (`Cards`) и Projects (`ProjectCards`) — разные таблицы, Kanban-тик не пересекается; хранилище
|
||||
LocalFileStorage/MinioFileStorage закрывают один порт по конфигурации (на старте) — юнит-тесты на fake.
|
||||
4. **Вне scope этапа 5:** реальные ai/telegram/ml-сервисы и их gRPC-ингресс (этап 6; demo-ingest остаётся
|
||||
источником), discovery (этап 6), telegram-вкладка и /tg/status-реализация (этап 6; boot-заглушка остаётся),
|
||||
события pipeline_stats/boards_changed/leads_reclassified (фронт не слушает), «список активных напоминаний в
|
||||
настройках» (ТЗ L131; у фронта UI нет — GET /reminders не реализуем), DELETE проектной карточки (отключено по
|
||||
решению, п.6), мульти-аренда бакетов/тенант-префиксы объектов MinIO и SaaS-контур (этап 7), загрузка файлов
|
||||
по прямой ссылке в MinIO с подписанными URL (не в прототипе).
|
||||
@@ -0,0 +1,542 @@
|
||||
# Дейл (Deal) — Этап 6: Сервисы telegram/ai/ml (отдельные процессы) + Discovery + gRPC-ингресс Implementation Plan
|
||||
|
||||
> Исторический документ этапа 6. Актуальное состояние — `docs/superpowers/STATUS.md` и `docs/technical/Техническая-документация-Дейл.md`.
|
||||
|
||||
**Goal:** Подключить к модульному монолиту `src/core` реальные автономные сервисы telegram/ai/ml как отдельные
|
||||
процессы (свои sln/контейнеры), общаясь по gRPC (`.proto` в `src/contracts/`), и оживить вкладки Vue-фронта
|
||||
«Каналы» (ChannelsView) и Discovery 1:1-контрактом `/api`: telegram-вкладка заменяет boot-заглушку
|
||||
`GET /api/tg/status` реальным статусом/QR-входом/списком диалогов/мониторингом/«Перечитать»; Discovery —
|
||||
полноценный модуль ядра (задачи поиска, кандидаты с оценкой по каскаду фильтров, чёрный список, авто-вступление
|
||||
с квотами, лог). Пайплайн и канбан начинают получать настоящие сообщения (входящий gRPC → `EnqueueAsync`),
|
||||
настоящие ИИ-классификацию/фильтр и ML-предсказания/обучение — за конфиг-флагами, с Local-заглушками как
|
||||
фолбэком, когда сервис недоступен/выключен.
|
||||
|
||||
**Architecture:** сервисы — самодостаточные процессы (namespace `Deal.Telegram`/`Deal.Ml`/`Deal.Ai`): telegram
|
||||
исполняет только команды ядра (сессии по тенантам 1:1, анти-бан, mark-as-read; ни БД-бизнеса, ни настроек), ml
|
||||
держит пул инкрементальных моделей per-tenant с сохраняемыми весами (онлайн-обучение без дата-сайентиста — 1:1
|
||||
с проверенным python `mlservice/model.py`, не ONNX), ai — фасад LLM-провайдеров без БД: core передаёт заполненные
|
||||
промпты и конфиг провайдера в теле каждого запроса, сервис возвращает JSON-ответ модели + оценку токенов.
|
||||
В ядре: новый модуль `Deal.Modules.Telegram` (владелец tenant-таблиц Dialogs/TgMessages, каталог каналов и
|
||||
статус) с портом-гейтом `ITelegramGateway`, gRPC-сервер ингресса в `Deal.Api` (PushMessage → IngestService,
|
||||
SyncDialogs, StatusReport → SSE); новые модульные части Discovery (таблицы/сервисы/воркер 5 с/оценка/анти-бан);
|
||||
gRPC-адаптеры в `Deal.Infrastructure` заменяют Local-заглушки за флагом `Services:{Ml,Ai,Telegram}:UseLocal`.
|
||||
|
||||
**Tech Stack:** .NET 10 (Grpc.Tools/Google.Protobuf/Grpc.AspNetCore), WTelegramClient (NuGet), Net.Codecrete.QrCodeGenerator
|
||||
(SVG QR), Microsoft.Data.Sqlite (веса моделей), HttpClient (OpenAI-совместимые + Anthropic), существующие порты
|
||||
Contracts. Docker: сервисы добавляются в `deploy/compose.dev.yml`.
|
||||
|
||||
**Spec:** `docs/architecture/2026-09-05-deal-architecture-design.md` §6.2–7 (L145–187: gRPC-контракты, сервисы, пул
|
||||
моделей, учёт токенов, mTLS+service-token); `docs/api/api-map.md` §3.3/3.7/3.8 (L123–142, L187–217), §2 SSE (L27–43),
|
||||
§4.6/4.8/4.9/4.10 (настройки, каналы/discovery, статус), «кривые места» п.4/п.8/п.9 (L390–400); roadmap этапа 6
|
||||
(L82–91); ТЗ §4.2/4.3/4.9, §5, §8; референс-семантика прототипа: `backend/app/services/telegram.py` (целиком),
|
||||
`services/{discovery,discovery_worker,discovery_eval,ai,suggest,ml_client,ban_guard}.py`, `routers/{tg_routes,
|
||||
discovery_routes,ml_routes}.py`, `mlservice/model.py`, `backend/app/{db.py,constants.py,config.py,main.py}`; фронт
|
||||
`ChannelsView.vue`/`DiscoveryView.vue`/`store.js`/`api.js`; образцы планов этапов 1–5.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Проект **НЕ git**; фиксация — отчёты задач `task-N-report.md` и `progress.md` в `.superpowers/sdd/deal-stage6-services/`.
|
||||
- .NET 10 SDK; каждая sln собирается 0 warnings/0 errors (`TreatWarningsAsErrors`); dev-Postgres `deal-postgres`
|
||||
(:5433); curl-приёмка core :5080 (`scripts/build.sh`/`scripts/test.sh` — собирают/тестируют только `src/core`).
|
||||
- Код-стайл этапов 1–5: 1 тип = 1 файл; XML-doc на public; комментарии на русском; без регионов; без магических
|
||||
чисел (именованные константы); времена `DateTimeOffset` (UTC), наружу epoch-ms; JSON camelCase; `{detail}`-ошибки.
|
||||
- Сервисы — отдельные sln (`src/{telegram-service,ml-service,ai-service}`), ничего общего с core, кроме `.proto`
|
||||
и NuGet; ни один сервис не ходит в БД тенантов и не знает домен. Core — единственное место с БД и бизнес-логикой.
|
||||
- Vue-фронт, `backend/`, `mlservice/` (python), корневой `docker-compose.yml` НЕ трогаем.
|
||||
- Сервисы подключаются флагами: по умолчанию dev = Local-заглушки (этапы 2–5), реальные сервисы — `UseLocal=false`.
|
||||
- Строки ошибок/тостов/причин 1:1 с прототипом (см. задачи): «Telegram не подключён», «Сначала сохраните Telegram
|
||||
api_id и api_hash в настройках», «QR не активен — начните вход по QR», «Неверный код», «Код истёк — запросите
|
||||
новый», «Неверный облачный пароль», «Telegram подключён, сессия сохранена», «Telegram отключён», «Уже вступили в
|
||||
этот источник», «Уже вступили — удалите источник из каналов», «Задача не найдена», «Кандидат не найден» и т.д.
|
||||
- НЕ выполнять автоматических сетевых подключений к Telegram/LLM в тестах: приёмка сервисов — unit + in-proc gRPC
|
||||
с фейками; живые проверки Telegram помечены «ручная проверка» (нужны api_id/api_hash/QR).
|
||||
- Новые NuGet в сервисах: `Grpc.AspNetCore`, `Grpc.Tools`, `Google.Protobuf`, `WTelegramClient`,
|
||||
`Net.Codecrete.QrCodeGenerator`, `Microsoft.Data.Sqlite`; в core: `Grpc.AspNetCore`, `Grpc.Tools`,
|
||||
`Google.Protobuf`, `Microsoft.Extensions.Http` (есть).
|
||||
|
||||
## Зафиксированные решения (Rulings этапа)
|
||||
|
||||
Сокращения путей: `TG=` `src/telegram-service/`, `ML=` `src/ml-service/`, `AI=` `src/ai-service/`, `PR=` `src/contracts/`,
|
||||
`C=` `src/core/Deal.Contracts/`, `I=` `src/core/Deal.Infrastructure/`, `A=` `src/core/Deal.Api/`, `PL=` `src/core/Deal.Modules.Pipeline/`,
|
||||
`KB=` `src/core/Deal.Modules.Kanban/`, `ST=` `src/core/Deal.Modules.Settings/`, `TM=` `src/core/Deal.Modules.Telegram/`,
|
||||
`DC=` `src/core/Deal.Modules.Discovery/`, `T=` `src/core/tests/Deal.Tests.Unit/`, `DEP=` `deploy/compose.dev.yml`.
|
||||
|
||||
- **Ruling 1 (а) — контракты `.proto`, кодогенерация, metadata.** Три файла: `PR/telegram.proto`,
|
||||
`PR/ai.proto`, `PR/ml.proto` (пакеты `deal.telegram.v1`/`deal.ai.v1`/`deal.ml.v1`, `option csharp_namespace`
|
||||
`Deal.Grpc.Telegram`/`Deal.Grpc.Ai`/`Deal.Grpc.Ml`). Каждый RPC несёт обязательные gRPC-metadata:
|
||||
`tenant-id` (строка) и `service-token`; серверный interceptor (общий шаблон в каждом процессе) проверяет
|
||||
`service-token` против env `DEAL_SERVICE_TOKEN` (общий в compose; отказ — `UNAUTHENTICATED`). Каждый сервис
|
||||
проверяет принадлежность по своей модели (сессия/модель тенанта есть — иначе `NOT_FOUND`/`FAILED_PRECONDITION`),
|
||||
полю не доверяет. Ошибки домена — `INVALID_ARGUMENT`/`NOT_FOUND`/`UNAVAILABLE` с `detail` = текст причины 1:1;
|
||||
FloodWait → `RESOURCE_EXHAUSTED` с кодом `flood`. Кодогенерация — Grpc.Tools: каждый процесс компилирует только свои `.proto` через `<Protobuf
|
||||
Include="..\..\contracts\X.proto" GrpcServices="Both" Link="Protos/X.proto"/>` (генерация client+server в одном
|
||||
проходе; неиспользуемая сторона игнорируется): telegram-service — telegram.proto, ml/ai-сервисы — свои; core
|
||||
(Deal.Api и Deal.Infrastructure по месту использования) — все три (telegram: сервер Ingress + клиент-гейт; ai/ml:
|
||||
клиенты). Контракты — единственный «язык» между процессами (дизайн-док L145–152).
|
||||
- **Ruling 2 (безопасность dev/prod).** Dev (этап 6): gRPC **без mTLS** — plaintext в локальной сети/хосте
|
||||
(`localhost`/compose-сеть) + **обязательный service-token** вторым фактором. mTLS-сертификаты, их генерация и
|
||||
prod-compose — этап 7 (roadmap L95–97: «compose-prod … безопасность (mTLS…)»); код интерцепторов один и тот же,
|
||||
включение TLS в этап 7 не меняет контракты. Обоснование: 4 процесса + генерация/ротация сертификатов в dev —
|
||||
высокая трудоёмкость без защиты реальных данных; service-token закрывает сценарий «случайный процесс в сети».
|
||||
- **Ruling 3 (б) — telegram-service: библиотека и сессии.** Библиотека — **WTelegramClient** (де-факто стандарт
|
||||
.NET, активная поддержка, API-уровень MTProto; TeleSharp/TLSharp заброшены). Один клиент на тенанта
|
||||
(`tenantId → WTelegram.Client`, 1:1; команды исполняются только на сессии своего тенанта; нет сессии → отказ).
|
||||
Хранение сессий — **файлы** `data/sessions/<tenantId>.session` (session_pathname WTelegramClient; volume в
|
||||
compose). Шифрование at-rest: файл сессии оборачивается AES-GCM (существующий AesGcmSecretCipher-паттерн этапа 2;
|
||||
ключ — env `DEAL_TELEGRAM_SESSION_KEY`, 32 байта base64): сервис держит расшифрованный файл только в памяти
|
||||
процесса (temp-файл под личным каталогом процесса) и перешифровывает при сохранении/остановке. api_id/api_hash —
|
||||
НЕ env, а настройка `tgKeys` тенанта (Settings, шифруется AES-GCM с этапа 2; api-map §4.6 L337); core
|
||||
расшифровывает и передаёт в теле запросов подключения. Внутренний анти-бан сервиса (паузы между сетевыми
|
||||
операциями одной сессии): backfill 1.5–3 с/сообщение и 3–6 с/диалог, поиск 2–4 с (константы telegram.py L35–36,
|
||||
ban_guard.search_pause L78–80); mark-as-read сразу после приёма/чтения. Внешний анти-бан (суточная квота
|
||||
авто-вступлений, паузы 50–70 с, flood-день, стоп-кран) — владение core (воркер Discovery), счётчики в tenant-БД.
|
||||
- **Ruling 4 (в) — ml-service: алгоритм и сохраняемость.** НЕ ONNX и НЕ ML.NET: переносим **инкрементальную
|
||||
наивно-байесовскую модель по терминам** 1:1 с `mlservice/model.py` (tokenize L78–87, upsert L105–131,
|
||||
predict L184–293, adaptive margin L42–55, самооценка eval L296–322, status/reset L325–354). Обоснование:
|
||||
(1) python-прототип уже даёт работающее онлайн-обучение на русском тексте без дата-сайентиста, порт-контракт
|
||||
Deal (`MlPredictResultDto`/status) спроектирован 1:1 под его ответы; (2) ONNX Runtime не умеет онлайн-обучение
|
||||
(нужен экспорт/переобучение вне процесса), ML.NET — не для инкрементального обучения; (3) сохраняемость весов =
|
||||
три таблицы. Хранилище — **SQLite-файл на тенанта** `data/ml/<tenantId>.sqlite` (Microsoft.Data.Sqlite), таблицы
|
||||
`classes(label,n,updated_at)/terms(label,term,count)/eval_log(created_at,expected,predicted,correct)` 1:1 db-схемы
|
||||
model.py L64–75; запись — транзакциями, batch-вставка терминов (executemany-эквивалент). Пул:
|
||||
`ConcurrentDictionary<tenantId, TenantModel>`, модель лениво грузится по первому обращению, у каждой — свой lock
|
||||
(predict/learn сериализованы на тенанта). Перенос «мозгов» между инстансами (экспорт/импорт, дизайн-док L176) —
|
||||
по решению владельца НЕ делаем; сохранение между рестартами обязательно (файлы). Пороги: 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); веса сигналов 1.0 (пользователь), 0.4 (ИИ), 0.6 (правила) —
|
||||
константы ml_client.py L26–28.
|
||||
- **Ruling 5 (г) — ai-service: устройство и контракт с core.** ai-service **без БД**: core передаёт в теле
|
||||
каждого запроса (1) заполненные промпты (`fill_prompt` L63–77: подстановка `{domain}`/`{keywords}` из настроек
|
||||
тенанта делает core), (2) конфиг активного провайдера (id/base/model/apiKey/api_style — расшифрованный core из
|
||||
`aiConfigs`), (3) текст. Методы: `Filter` (промпт aiFilterPrompt, текст) → `{pass,reason}`; `Classify`
|
||||
(system_prompt = aiPrompt+cardPrompt, user-контекст «Доски + примеры разметки + Сообщение» — собирает core)
|
||||
→ `{ok,json}` — **json-строка** извлечённого ответа модели (типовая схема ответа задаётся промптом, python
|
||||
держит его сырым dict; строгий маппинг json→`AiParsedLeadDto` делает core, 1:1 normalize_stack/clean_budget/
|
||||
build_contacts/python `_store_lead`); `GenerateKeywords` (фикс. промпт L36–47 routes + описание) → `{keywords}`
|
||||
(очистка `_clean_keywords` в core); `EvaluateFit` (текст + description + keywords задачи, промпт discovery_eval
|
||||
L50–54) → `{fit,reason}`. Вызовы LLM: OpenAI-совместимые `POST {base}/chat/completions` (Bearer), Anthropic
|
||||
`POST {base}/v1/messages` (x-api-key+anthropic-version); temperature 0.2; таймауты 90 с (openai) / 60 с
|
||||
(anthropic); retry `max_retries=2` с паузами 0.8/2 с; извлечение JSON из markdown-обёрток (extract_json L175–183);
|
||||
ошибки провайдера наружу как `UNAVAILABLE` с текстом «ИИ (имя) не ответил корректно — повторите попытку через
|
||||
несколько секунд». Учёт токенов: ответ несёт `usage{prompt/completion/total}` — берётся из usage API-ответа
|
||||
провайдера, при отсутствии оценивается по символам (≈chars/4); core копит в tenant-KV `aiTokenUsage` (этап 7 —
|
||||
лимиты/бюджеты). Выключатели aiEnabled/aiFilterEnabled читает core (как в воркере этапа 4) — сервис их не знает.
|
||||
- **Ruling 6 (д) — core-интеграция ML/AI: флаги, адаптеры, судьба MlOutbox.** Секция конфигурации
|
||||
`Services:Ml|Ai` → `{UseLocal: bool (default true), Endpoint: string}` (env `SERVICES__ML__USELOCAL=false`,
|
||||
`SERVICES__ML__ENDPOINT=http://localhost:5103`). В `AddDealIntegrations` регистрируются gRPC-адаптеры
|
||||
(`GrpcMlClient: IMlClient`, `GrpcAiClassifier: IAiClassifier`, `GrpcAiTools: IAiTools` — новый порт, Ruling 9),
|
||||
когда `UseLocal=false`, иначе текущие Local-* (фолбэк). Никакой логики переключения в рантайме — выбор на старте.
|
||||
**Судьба MlOutbox:** PushAsync ВСЕГДА пишет в MlOutbox (этап 3), новый фоновый `MlOutboxFlushScheduler` (10 с,
|
||||
per-tenant цикл, эталон PipelineWorkerScheduler) выгружает по 10 строк (`ORDER BY created_at`), батч ≤100/цикл, в
|
||||
`ml.proto TrainBatch`; удаляет строки только после успеха; при недоступности сервиса строки остаются (python
|
||||
L56–82). `ResetAsync`: сервис Reset + `ClearOutboxAsync` (1:1 reset_model L110–124). Кэш статуса сервиса 15 с
|
||||
(python L30–31, refresh_status) → `reachable` в `/api/ml/status`; недоступен — Predict → «не уверен», Status →
|
||||
кэш. Счётчики/выключатели/SSE воркера не меняются (Ruling 5 этапа 4; исключения порта воркер уже ловит).
|
||||
Входящий gRPC telegram: сервер в Deal.Api (отдельный порт) — см. Ruling 7.
|
||||
- **Ruling 7 (д/ж) — Telegram-ингресс и каталог каналов.** Новый чистый модуль `TM` `Deal.Modules.Telegram` —
|
||||
владелец tenant-таблиц (миграция `TenantTelegram` контекста TenantDbContext): `Dialogs` (Id string PK,
|
||||
Name/Handle/Kind/Hue, Monitor bool, LastText/LastAt, Backfilled bool, UpdatedAt; 1:1 db.py L76–86) и `TgMessages`
|
||||
(Id `m_<dialog>_<msg>` PK, DialogId, Text, MsgAt, LeadId nullable; L67–74). Порт `ITelegramStore` + DTO
|
||||
(диалог §4.8 L349, сообщение превью L351) + `DialogsService`: `List`, `SetMonitor` (первое включение → фон
|
||||
Backfill), `SetMonitorAll` (1:1 L548–567), `SyncFromTelegram(entries)` — авто-мониторинг новых по `autoMonitorNew`,
|
||||
обновление имени/типа, удаление отсутствующих (1:1 `_persist_dialogs` L468–503), `MarkBackfilled`, `SavePreview`.
|
||||
Порт-гейт `C/Integrations/ITelegramGateway.cs` (команды наружу): `StatusAsync`, `StartQrAsync`, `StartPhoneAsync`,
|
||||
`SendCodeAsync`, `SendPasswordAsync`, `LogoutAsync`, `RefreshDialogsAsync` (→entries), `SetMonitorAsync` (id,
|
||||
enabled), `SetMonitorAllAsync`, `BackfillAsync(id, force)`, `ReadRecentAsync(id, limit)` (превью), `SearchAsync`,
|
||||
`InfoAsync`, `ReadForEvalAsync(id, limit)`, `JoinAsync(username)`, `LeaveAsync(id)`; недоступность сервиса →
|
||||
исключение → ветки эндпоинтов как «не подключён». **Входящий gRPC в core** (сервер `A/Telegram/TelegramIngressService.cs`,
|
||||
RPC `PushMessage`/`SyncDialogs`/`ReportStatus`): kestrel-порт :5082 (env `GRPC_INGRESS_PORT`), Http2; интерцептор
|
||||
service-token; tenantId из metadata → собственный scope с `ITenantContext.SetTenant` (доверенный источник, не
|
||||
сессия); `PushMessage` (dialogId/msgId/text/канальные поля/hue/msgAt — hue считает сервис по DIALOG_HUES-палитре)
|
||||
→ `PipelineIngestService.EnqueueAsync` (тот же контракт, что demo-ingest, L7–59) + пишет превью в TgMessages;
|
||||
`SyncDialogs` → `DialogsService.SyncFromTelegram`, ответ = актуальный список monitored id (сервис держит зеркало
|
||||
мониторинга в памяти); `ReportStatus{phase,connected,listener,account,error,qrUrl}` → KV `tgAccount`/`tgStatus`
|
||||
(внутренние ключи SettingsKeys) + из Api-слоя SSE `system_status` и тосты «Telegram подключён, сессия
|
||||
сохранена»/«Telegram отключён» при переходах фаз (python L178–207). Сервис сам фильтрует события по своему
|
||||
зеркалу monitored (обновляется ответом SyncDialogs и командой SetMonitor) — как python `_monitored`.
|
||||
- **Ruling 8 (ж) — /api/tg и статус.** Снимается boot-заглушка `BootStubEndpoints` (остаётся в коде до Task 14).
|
||||
Эндпоинты 1:1 api-map §3.3 (13 шт., фронт): статус/start-phone/start-qr/send-code/send-password/logout/qr-image/
|
||||
dialogs/refresh/monitor-all/backfill-all/{id}/monitor/{id}/backfill(сервер-only)/preview. `GET /api/tg/status`
|
||||
(§4.9): live-поля (phase/connected/listener/error/qrUrl) из gateway (сервис недоступен → idle-форма), account из
|
||||
KV tgAccount, monitored = count(Dialogs WHERE Monitor), keysSet из настроек. `GET /qr-image` — SVG через
|
||||
**Net.Codecrete.QrCodeGenerator** (SVG-first, без внешних зависимостей; 404 «QR не активен — начните вход по QR»).
|
||||
Публикации SSE system_status/toast из Api-слоя (Ruling 5 этапа 3); фронт-флоу 1:1 (store.js L1419–1508).
|
||||
- **Ruling 9 (д/е) — Discovery: модуль, таблицы, порт ИИ-инструментов.** Новый модуль `DC` (чистый) — владелец
|
||||
таблиц (миграция `TenantDiscovery`): `DiscTasks`/`DiscCandidates`/`DiscBlacklist`/`DiscLog` 1:1 db.py L136–196
|
||||
(+idx L177/196; json-колонки marks/topics/keywords text). DTO §4.8 L353–355; `IDiscoveryStore`; сервисы
|
||||
`DiscoveryTasksService`/`DiscoveryCandidatesService`/`DiscoveryBlacklistService`/`DiscoveryLogService` +
|
||||
`DiscoveryPlanGuard` — 1:1 discovery.py: create (имя; plan 1..discJoinLimit; бюджет активных задач, L234–282),
|
||||
patch (рост plan с бюджетом), delete (с кандидатами и логом), start (пустые ключи → 400 «Нет ключевых слов для
|
||||
поиска — добавьте их в задачу»; reset прогресса для done/failed), pause, advance_search, кандидаты (add с
|
||||
исключениями «уже мониторится»/«чёрный список»/«кандидат есть», L409–453; set_candidate; mark_joined/rejected
|
||||
L497–567; blacklist), лог. Новый порт `C/Integrations/IAiTools.cs`: `GenerateKeywordsAsync(description)` →
|
||||
`{ok, keywords, error}`, `EvaluateFitAsync(text, description, keywords)` → `{fit, reason}` — локальные реализации
|
||||
на этапе 6 не нужны (Disco-воркер сам падает в эвристику при сбое/aiEnabled=false, python L187–194); порт
|
||||
реализуется gRPC-адаптером `GrpcAiTools` за тем же флагом `Services:Ai:UseLocal=false`.
|
||||
- **Ruling 10 (е) — Discovery: воркер, оценка, анти-бан.** `DiscoveryWorkerScheduler` (5 с, per-tenant, эталон
|
||||
PipelineWorkerScheduler) + `DiscoveryWorkerService.TickAsync` — одно действие за тик, порядок шагов 1:1
|
||||
discovery_worker.tick L444–484: (1) план достигнут → done+лог; (2) поиск — следующий ключ задачи через
|
||||
gateway.Search (личные чаты/боты пропускаются, kind→channel/group/forum); (3) оценка первого `new` кандидата:
|
||||
info (kind/forum/участники; minSubscribers → skip), чтение выборки (ReadForEval; история недоступна →
|
||||
метки «канал: история недоступна»/«закрытая группа (история скрыта) — вступите сами»; язык ru → skip при
|
||||
«не русский», иначе метка; <3 сообщений → метка «мало сообщений»), фит: ML-спам (Predict через IMlClient, только
|
||||
mlEnabled) → не подходит; ИИ (IAiTools.EvaluateFit, если aiEnabled) → иначе эвристика по ключам; форумы — по
|
||||
темам (group_by_topic; passed — есть проходная тема); вердикт — `total>=3 && ratio*100>=threshold`
|
||||
(passed L229–237). (4) авто-вступление первого `review` при autoJoin: повторная проверка «не состоим» →
|
||||
пауза discJoinDelayMin..Max (core) → join; FloodWait/ошибка → лог flood/error, join_failures (3 → delete);
|
||||
успех → mark_joined(auto), +в Dialogs (монитор on), +фоновый Backfill, −чёрный список. Лимит: авто-вступления за
|
||||
сутки по DiscLog event='join_auto' (UTC) < discJoinLimit; discFloodDay (внутренний KV, ключ SettingsKeys
|
||||
DiscFloodDay — новый) и discPaused стопят сетевые шаги. Метки/поля кандидата и fitRatio 1:1 (L154–173, marks
|
||||
L85–89).
|
||||
- **Ruling 11 (е) — эндпоинты Discovery.** 1:1 api-map §3.8 (13 шт.): tasks CRUD+start/pause+generate-keywords,
|
||||
candidates(статус-фильтр), join/reject (ручные, вне квот; ошибки 400 «Уже вступили…»), blacklist, log.
|
||||
generate-keywords: aiEnabled/ключ-недоступность → `{keywords:[], error}` HTTP 200 (мягкие ошибки, api-map L209,
|
||||
«кривое место» п.7), успех — `_clean_keywords`-фильтр в core (≤30, ≤60 симв., дедуп). Счётчики/статусы задач и
|
||||
кандидатов — как discovery.py.
|
||||
- **Ruling 12 (з) — compose и окружение dev.** В `DEP` добавляются сервисы `telegram-service`/`ai-service`/
|
||||
`ml-service`: build из `src/<svc>/Deal.*.sln` (Dockerfile в корне сервиса), порты 5101/5102/5103 на host, volumes
|
||||
`deal_tg_sessions` (`/data/sessions`), `deal_ml_data` (`/data/ml`), общий env `DEAL_SERVICE_TOKEN`; healthcheck —
|
||||
gRPC health (встроенный Grpc.HealthCheck, порт health на том же endpoint). core dev запускается из хоста и ходит
|
||||
на `localhost:5101..5103` (`SERVICES__*__ENDPOINT`), сервисы ходят в core-ингресс через
|
||||
`SERVICES__CORE__INGRESS=http://host.docker.internal:5082` (env). Порядок подъёма не критичен: Local-фолбэки
|
||||
переживают отсутствие сервисов; сквозная приёмка — при поднятых процессах.
|
||||
- **Ruling 13 (и/к) — события/безопасность.** Новых типов SSE нет: используются system_status/toast (telegram),
|
||||
существующие new_lead (после карточки — уже в PipelineWorkerScheduler). Аудит команд сервиса
|
||||
`(tenantId, действие, диалог, результат)` — структурированные логи Serilog на каждом RPC (этап 7 — аудит-поток);
|
||||
rate-лимиты gRPC-ингресса — этап 7. Ключи/секреты не логируются; `DEAL_ENCRYPTION_KEY`/`DEAL_SERVICE_TOKEN`/
|
||||
`DEAL_TELEGRAM_SESSION_KEY` — только env.
|
||||
|
||||
## Задачи
|
||||
|
||||
Отчёты — `task-N-report.md` в `.superpowers/sdd/deal-stage6-services/`. Пути сокращены по Rulings.
|
||||
|
||||
### Task 1: `.proto`-контракты telegram/ai/ml + спецификация
|
||||
|
||||
**Files:** Create: `PR/telegram.proto`, `PR/ai.proto`, `PR/ml.proto`, `PR/README.md` (сервисы/RPC/messages/поля,
|
||||
metadata `tenant-id`+`service-token`, коды ошибок, deadline-рекомендации). telegram.proto: `TelegramService`
|
||||
GetStatus/StartQr/StartPhone/SendCode/SendPassword/Logout/RefreshDialogs(→entries[])/SetMonitor/SetMonitorAll/
|
||||
Backfill/ReadRecent/Search/GetInfo/ReadForEval/Join/Leave + `IngressService` PushMessage/SyncDialogs/ReportStatus
|
||||
(контракты Rulings 7). ai.proto: `AiService` Filter/Classify/GenerateKeywords/EvaluateFit (Ruling 5; usage в каждом
|
||||
reply). ml.proto: `MlService` Predict/Status/Reset/TrainBatch (поля 1:1 с `MlPredictResultDto`/status: classes map,
|
||||
eval{count,correct,accuracy}, take/label/scores/hits/ready/margin/terms/type).
|
||||
|
||||
**Источники:** Rulings 1/3/5/7; IMlClient/IAiClassifier + Models/*.cs (core Contracts, формы DTO);
|
||||
mlservice/model.py predict/status; ai.py filter_incoming/classify; telegram.py методы (имена L134–873).
|
||||
|
||||
**Acceptance:** файлы + README со схемой каждого RPC (поля/messages/коды) согласованы; контракты валидируются
|
||||
компиляцией в Task 2–4 (кодогенерация — первый прогон здесь невозможен без csproj). Отчёт: `task-1-report.md`.
|
||||
|
||||
### Task 2: Каркас telegram-service (sln, host gRPC, health, service-token)
|
||||
|
||||
**Files:** Create: `TG/Deal.Telegram.sln`, `TG/Deal.Telegram/Deal.Telegram.csproj` (link telegram.proto, Server),
|
||||
`TG/Deal.Telegram/Program.cs` (Kestrel :5101 Http2; AddGrpc+HealthChecks; env `PORT`/`GRPC_PORT`),
|
||||
`TG/Deal.Telegram/ServiceTokenInterceptor.cs`, `TG/Deal.Telegram/TelegramServiceImpl.cs` (заглушки: методы →
|
||||
`UNIMPLEMENTED`), `TG/Deal.Telegram/Dockerfile`, `TG/Deal.Telegram.Tests/` (хост поднимается, health OK, запрос без
|
||||
токена → UNAUTHENTICATED), `DEP` — запись `telegram-service`.
|
||||
|
||||
**Источники:** Rulings 1/2/12; эталон gRPC-сервера — настройка AddGrpc/HealthChecks (документация Grpc.AspNetCore).
|
||||
|
||||
**Acceptance:** `dotnet build Deal.Telegram.sln` 0/0 (доказывает кодогенерацию telegram.proto); юнит-тесты: health
|
||||
ready; интерцептор отклоняет пустой/неверный токен. Отчёт: `task-2-report.md`.
|
||||
|
||||
### Task 3: Каркас ml-service (sln, host gRPC, health)
|
||||
|
||||
**Files:** Create: `ML/Deal.Ml.sln`, `ML/Deal.Ml/Deal.Ml.csproj` (link ml.proto Server), `ML/Deal.Ml/Program.cs`
|
||||
(Kestrel :5103, env `GRPC_PORT`), `ML/Deal.Ml/ServiceTokenInterceptor.cs`, `ML/Deal.Ml/MlServiceImpl.cs` (заглушки),
|
||||
`ML/Deal.Ml/Dockerfile`, `ML/Deal.Ml.Tests/` (health; token), запись `ml-service` в `DEP`.
|
||||
|
||||
**Источники:** Rulings 1/2/12; Task 2 (эталон).
|
||||
|
||||
**Acceptance:** build 0/0 (кодогенерация ml.proto); тесты health/token PASS. Отчёт: `task-3-report.md`.
|
||||
|
||||
### Task 4: Каркас ai-service (sln, host gRPC, health)
|
||||
|
||||
**Files:** Create: `AI/Deal.Ai.sln`, `AI/Deal.Ai/Deal.Ai.csproj` (link ai.proto Server), `AI/Deal.Ai/Program.cs`
|
||||
(Kestrel :5102, env `GRPC_PORT`), `AI/Deal.Ai/ServiceTokenInterceptor.cs`, `AI/Deal.Ai/AiServiceImpl.cs` (заглушки),
|
||||
`AI/Deal.Ai/Dockerfile`, `AI/Deal.Ai.Tests/` (health; token), запись `ai-service` в `DEP`.
|
||||
|
||||
**Источники:** Rulings 1/2/12; Task 2.
|
||||
|
||||
**Acceptance:** build 0/0 (кодогенерация ai.proto); тесты PASS. Отчёт: `task-4-report.md`.
|
||||
|
||||
### Task 5: ml-service — движок инкрементальной модели (per-tenant, SQLite)
|
||||
|
||||
**Files:** Create: `ML/Deal.Ml/Model/ModelConstants.cs` (пороги Ruling 4), `ML/Deal.Ml/Model/MlTokenizer.cs`
|
||||
(снятие ссылок regex + токены [a-zа-яё0-9@+.#]+, len≥3 и «~prefix» len≥6 — 1:1 L78–87),
|
||||
`ML/Deal.Ml/Model/OnlineNaiveBayes.cs` (upsert/learn/batch/predict/status/reset/_maybe_eval, математика L184–323:
|
||||
score термина w<1→1.0 иначе 1+(w−1)/(w+1); prior n/total; best=score+3·prior; adaptive margin; type-решение),
|
||||
`ML/Deal.Ml/Storage/MlDb.cs` (Microsoft.Data.Sqlite; EnsureSchema/Tables), `ML/Deal.Ml/Model/TenantModel.cs` +
|
||||
`ModelPool.cs` (lazy-load по тенанту, lock на модель), `ML/Deal.Ml/Model/ModelState.cs` (состояние: классы/термины/
|
||||
eval-окно, JSON). Тесты `ML/Deal.Ml.Tests/`: tokenize; learn→predict спам/колонка; ready-пороги (20/6/4/2);
|
||||
адаптивный margin; delta<0 «разучивание»; eval-окно (50/200); перезапуск пула сохраняет веса (2-й инстанс на тот
|
||||
же файл).
|
||||
|
||||
**Источники:** `mlservice/model.py` целиком; Ruling 4; референс predict-математики L184–293.
|
||||
|
||||
**Acceptance:** build 0/0; тесты PASS (обучение/предсказание на русских примерах: «нужен middle python…» →
|
||||
колонка/тип; «резюме…» → spam после обучения). Отчёт: `task-5-report.md`.
|
||||
|
||||
### Task 6: ml-service — gRPC-сервис поверх пула
|
||||
|
||||
**Files:** Modify: `ML/Deal.Ml/MlServiceImpl.cs` — Predict/Status/Reset/TrainBatch; tenantId metadata → `ModelPool`
|
||||
(модели нет — она создаётся лениво: для Predict отсутствие опыта даёт «не готов» — не ошибка; Ruling 4);
|
||||
TrainBatch = learn_batch (1 транзакция) → число примеров; Reset — reset модели + пересоздание файла (очистка);
|
||||
Status — ready/classes/learned/eval 1:1. Тесты: in-proc gRPC (GrpcChannel к тестовому хосту): train → predict;
|
||||
train-батч из 3; reset обнуляет; неверный service-token → UNAUTHENTICATED.
|
||||
|
||||
**Источники:** mlservice/server.py (эталон форм ответов), model.py status/reset; Rulings 1/4/6.
|
||||
|
||||
**Acceptance:** build 0/0; in-proc gRPC-тесты PASS. Отчёт: `task-6-report.md`.
|
||||
|
||||
### Task 7: ai-service — LLM-фасад (OpenAI-совместимые + Anthropic)
|
||||
|
||||
**Files:** Create: `AI/Deal.Ai/Llm/LlmConfig.cs` (provider: id/name/base/model/key/apiStyle/local), `AI/Deal.Ai/Llm/
|
||||
LlmHttpClient.cs` (HttpClientFactory; OpenAI `POST {base}/chat/completions` Bearer temperature 0.2 max_tokens 8000;
|
||||
Anthropic `POST {base}/v1/messages` x-api-key+version; таймауты 90/60 с), `AI/Deal.Ai/Llm/LlmRetryPolicy.cs` (2
|
||||
ретрая: 0.8 с/2 с — ai.py L96–117), `AI/Deal.Ai/Llm/JsonExtractor.cs` (extract_json L175–183),
|
||||
`AI/Deal.Ai/Llm/TokenEstimator.cs` (usage провайдера или chars/4), `AI/Deal.Ai/Llm/ProviderCaller.cs` (ошибки →
|
||||
AiException с кодом). Тесты: фейковый HttpMessageHandler: OpenAI-ответ; Anthropic-ответ; markdown-обёртка;
|
||||
usage из ответа и оценка; 3 неудачи → исключение с текстом L115–117; таймаут.
|
||||
|
||||
**Источники:** ai.py `_call_openai`/`_call_anthropic`/`chat_json`/`extract_json` (L80–183); Ruling 5.
|
||||
|
||||
**Acceptance:** build 0/0; unit-тесты PASS (без сети). Отчёт: `task-7-report.md`.
|
||||
|
||||
### Task 8: ai-service — gRPC AiService
|
||||
|
||||
**Files:** Modify: `AI/Deal.Ai/AiServiceImpl.cs` — Filter (chat_json по фильтр-промпту → pass/reason; при `ok=false`
|
||||
из модели — pass:true,skipped? нет: воркер шлёт только при aiFilterEnabled; ошибка → UNAVAILABLE), Classify (json →
|
||||
reply{ok,json}), GenerateKeywords (промпт Ruling 5 → keywords), EvaluateFit (промпт discovery_eval → fit/reason);
|
||||
каждый reply + usage. Тесты in-proc: все 4 метода с фейковым провайдером; недоступный провайдер → UNAVAILABLE.
|
||||
|
||||
**Источники:** ai.py L188–258; discovery_routes L36–47/189–211; discovery_eval L50–54/153–194; Rulings 1/5.
|
||||
|
||||
**Acceptance:** build 0/0; in-proc тесты PASS. Отчёт: `task-8-report.md`.
|
||||
|
||||
### Task 9: telegram-service — сессии, подключение, QR, статус
|
||||
|
||||
**Files:** Create: `TG/Deal.Telegram/Sessions/TgOptions.cs` (session dir, DEAL_TELEGRAM_SESSION_KEY), `TG/Deal.Telegram/
|
||||
Sessions/SessionFileCipher.cs` (AES-GCM обёртка файла), `TG/Deal.Telegram/Sessions/TenantSession.cs` (id тенанта,
|
||||
клиент WTelegramClient, состояние), `TG/Deal.Telegram/Sessions/SessionFarm.cs` (пул 1 акк/тенант, auto_resume на
|
||||
старте — авторизованная сессия → ready, L209–222), `TG/Deal.Telegram/Telegram/ClientFactory.cs` (конфиг:
|
||||
api_id/api_hash из запроса; session_pathname; внутренние паузы). Реализация методов: StartQr/StartPhone/SendCode/
|
||||
SendPassword/Logout/GetStatus (фазы idle|phone|code|password|qr|ready, account, qrUrl; heartbeat/авто-возобновление
|
||||
фоновым циклом 30 с). Тесты: cipher roundtrip; farm: tenant-изоляция (нет сессии → отказ); фазовые переходы на
|
||||
fake-клиенте (абстракция `ISessionClient`); ручная проверка QR — отдельно.
|
||||
|
||||
**Источники:** telegram.py L82–222, L286–329; config.py L31 (SESSIONS_DIR); Rulings 1/3.
|
||||
|
||||
**Acceptance:** build 0/0; unit PASS. ⚠ **Ручная проверка:** реальный QR-вход/код/2FA с кредов (api_id/api_hash),
|
||||
auto_resume после рестарта контейнера. Отчёт: `task-9-report.md`.
|
||||
|
||||
### Task 10: telegram-service — диалоги, мониторинг, backfill, поток в core
|
||||
|
||||
**Files:** Create: `TG/Deal.Telegram/Dialogs/DialogCatalog.cs` (зеркало monitored-набора тенанта: SetMonitor/
|
||||
SetMonitorAll/актуализация ответом SyncDialogs), `TG/Deal.Telegram/Dialogs/RealtimeListener.cs` (NewMessage →
|
||||
фильтр по зеркалу → PushMessage в core; mark-as-read sendReadAcknowledge; сохранение last_text? нет — только пуш),
|
||||
`TG/Deal.Telegram/Dialogs/BackfillService.cs` (последние 10 с паузами 1.5–3 с/сообщение и 3–6 с/диалог; read-ack;
|
||||
реверс-порядок от старых к новым; force; L331–390), `TG/Deal.Telegram/Dialogs/RealtimeSweep.cs` (30 с: догон
|
||||
непрочитанных по unread_count, паузы, read-ack, L392–456), `TG/Deal.Telegram/Core/CoreIngressClient.cs` (gRPC-клиент
|
||||
к `SERVICES__CORE__INGRESS`; PushMessage/SyncDialogs; сбой — лог, упущенное догоняет sweep). Реализация RPC
|
||||
RefreshDialogs/SetMonitor/SetMonitorAll/Backfill/ReadRecent (превью, свежие из TG). Тесты: фильтр мониторинга;
|
||||
backfill-паузы (fake clock); PushMessage-клиент к in-proc fake-серверу ингресса.
|
||||
|
||||
**Источники:** telegram.py L244–283, L331–456, L505–620; Rulings 3/7.
|
||||
|
||||
**Acceptance:** build 0/0; unit/in-proc PASS (без реальной сети). ⚠ **Ручная проверка:** refresh/подписка/backfill
|
||||
живого аккаунта. Отчёт: `task-10-report.md`.
|
||||
|
||||
### Task 11: telegram-service — discovery-операции (search/info/read/join)
|
||||
|
||||
**Files:** Create: `TG/Deal.Telegram/Discovery/DiscoveryOps.cs` — Search (contacts.SearchRequest, пауза 2–4 с,
|
||||
кэш entities, выходные id подписанные, kind «канал»/«группа»/«чат», L624–664), GetInfo (participants via
|
||||
GetFullChannel/GetFullChat, is_forum, L666–716), ReadForEval (обычная лента; форумы — темы GetForumTopics + на
|
||||
тему get_messages(reply_to), per-topic 3..10, cap 5 тем; ошибки → ok:false no_history; L718–816), Join (по
|
||||
username, FloodWait → RpcException RESOURCE_EXHAUSTED + код flood, L818–839), Leave (L841–848). Тесты: нормализация
|
||||
kind/username; формат ответов (fake-слой TL не трогаем — тесты на чистых мапперах ответов).
|
||||
|
||||
**Источники:** telegram.py L622–873; ban_guard.search_pause; Rulings 3/7.
|
||||
|
||||
**Acceptance:** build 0/0; unit PASS (мапперы/валидация). ⚠ **Ручная проверка:** поиск/чтение/join живого аккаунта.
|
||||
Отчёт: `task-11-report.md`.
|
||||
|
||||
### Task 12: core — gRPC-ингресс telegram (PushMessage/SyncDialogs/ReportStatus)
|
||||
|
||||
**Files:** Modify: `A/Program.cs` (второй Kestrel-listen :5082, Http2, `GRPC_INGRESS_PORT`; AddGrpc; AddAuthentication
|
||||
не нужен — интерцептор), `A/Infrastructure/` не трогаем. Create: `A/Telegram/IngressServiceTokenInterceptor.cs`,
|
||||
`A/Telegram/TelegramIngressService.cs` (Grpc `Deal.Grpc.Telegram.IngressServiceBase`): PushMessage → scope с
|
||||
`SetTenant(metadata tenant-id)` → `PipelineIngestService.EnqueueAsync` (+ `ITelegramStore.SavePreview`) → reply
|
||||
{accepted/duplicate}; SyncDialogs → `DialogsService.SyncFromTelegram` → reply{monitoredIds}; ReportStatus → KV
|
||||
`tgStatus`/`tgAccount` + публикация (через DI Api-слоя) SSE system_status/тостов на переходах фаз. Create:
|
||||
`A/Telegram/TelegramIngressAuth.md`? нет. Тесты (in-proc WebApplicationFactory+gRPC-канал): PushMessage кладёт
|
||||
строку очереди тенанта (эмуляция входящего сообщения — сквозная проверка без Telegram); неверный токен → отказ;
|
||||
PushMessage для несуществующего тенанта не падает (нет схемы → ошибка ловится, reply not-accepted).
|
||||
|
||||
**Источники:** Rulings 7/13; PipelineIngestService L7–59; паттерн scope/SetTenant — PipelineWorkerScheduler L169–213.
|
||||
|
||||
**Acceptance:** build 0/0; тесты PASS (см. выше). Отчёт: `task-12-report.md`.
|
||||
|
||||
### Task 13: core — модуль Telegram (таблицы, DTO, порт, DialogsService)
|
||||
|
||||
**Files:** Create: `TM/.../TelegramModuleMarker.cs`, `I/Persistence/Entities/{DialogEntity,TgMessageEntity}.cs` +
|
||||
конфигурации (JSON не нужен; индексы DialogId/MsgAt), `TM/Application/Models/{TelegramDialogDto,TelegramMessageDto,TgStatusDto}.cs`,
|
||||
`TM/Application/ITelegramStore.cs`, `TM/Application/DialogsService.cs` (List/SetMonitor/SetMonitorAll/SyncFromTelegram/
|
||||
MarkBackfilled/SavePreview — Ruling 7), `TM/Application/TelegramModuleRegistrar.cs`, `I/Persistence/Repositories/
|
||||
TelegramStore.cs`, `C/Integrations/ITelegramGateway.cs` (Ruling 7). Modify: `I/Persistence/TenantDbContext.cs` —
|
||||
DbSet `Dialogs`/`TgMessages` + ApplyConfiguration. EF: миграция `TenantTelegram` для TenantDbContext
|
||||
(`dotnet ef migrations add TenantTelegram --context TenantDbContext --output-dir Migrations/TenantDb --project
|
||||
src/core/Deal.Infrastructure --startup-project src/core/Deal.Api`; старт Api применяет к дефолтной схеме).
|
||||
csproj-ссылки: TM → ST (настройки) + Contracts; реестр `AddTelegramModule()` в Api. Тесты: SyncFromTelegram (новый+autoMonitorNew/обновление/удаление отсутствующих),
|
||||
SetMonitor-семантика; порт-контракт гейта.
|
||||
|
||||
**Источники:** db.py L67–86; telegram.py `_persist_dialogs`/list_dialogs/set_monitor* L468–581; api-map §4.8 L349–351;
|
||||
Rulings 7/8.
|
||||
|
||||
**Acceptance:** build 0/0; миграция применяется к дефолтной схеме; тесты PASS. Отчёт: `task-13-report.md`.
|
||||
|
||||
### Task 14: core — эндпоинты /api/tg (каналы, статус, QR) + SSE; замена boot-заглушки
|
||||
|
||||
**Files:** Modify: `A/Program.cs` (+`MapTelegramEndpoints`), `A/Endpoints/BootStubEndpoints.cs` (удаляется вызов
|
||||
`MapBootStubEndpoints`, файл — delete). Create: `A/Endpoints/TelegramEndpoints.cs` (13 шт. api-map §3.3; тела
|
||||
запросов — record'ы; ошибки гейта → `{detail}` 400; refresh → upsert через DialogsService и ответ {ok,count} или
|
||||
{ok:false, reason:"not-connected", count:0}; monitor/backfill по Ruling 8 с фоновым backfill-спуском при первом
|
||||
включении), `A/Endpoints/QrImageEndpoint.cs` (SVG Net.Codecrete; 404 «QR не активен — начните вход по QR»),
|
||||
`A/Telegram/TgStatusService.cs` (сборка §4.9: гейт+KV+monitored count+keysSet), события SSE/toast при ReportStatus
|
||||
(из Task 12). Tests: юнит-тесты TgStatusService (сервис недоступен → idle-форма); curl-приёмка эндпоинтов со
|
||||
стаб-гейтом (фейк-реализация ITelegramGateway в тестах, не Local).
|
||||
|
||||
**Источники:** api-map §3.3/§4.9; tg_routes.py целиком (тексты и статусы); store.js L1352–1508 (фронт-флоу);
|
||||
Rulings 7/8.
|
||||
|
||||
**Acceptance:** build 0/0; юнит+curl: GET /api/tg/status (idle без гейта), dialogs, monitor, backfill-all, preview,
|
||||
start-qr (фейк) → phase/qrUrl; 401 без куки. Отчёт: `task-14-report.md`.
|
||||
|
||||
### Task 15: core — ai-интеграция: контекст запроса, GrpcAiClassifier/GrpcAiTools, маппер, usage
|
||||
|
||||
**Files:** Modify: `C/Integrations/IAiClassifier.cs` — контракт остаётся, НО Classify/Filter переходят на
|
||||
запросные record'ы: `ClassifyAsync(AiClassifyRequest, ct)`, `FilterAsync(AiFilterRequest, ct)` (в C/Integrations/
|
||||
Models/: AiClassifyRequest{Text, SystemPrompt, UserContext}, AiFilterRequest{Text, SystemPrompt}); сигнатуры
|
||||
LocalAiClassifier адаптируются (строит запрос сам: Filter — skipped; Classify — локальный разбор, Ruling 5 этапа 4).
|
||||
Modify: `PL/Application/PipelineWorkerService.cs` — call-site'ы фильтра/классификации переходят на новые сигнатуры
|
||||
через `AiClassifyContextBuilder` (Логика веток/выключателей/обучения ML не меняется — Ruling 5 этапа 4/6).
|
||||
Create: `PL/Application/AiClassifyContextBuilder.cs` (fill_prompt 1:1 ai.py L63–77; доски non-suggested с правилами/
|
||||
ключами — python L226–243; примеры разметки по CardMoves/learning-истории, ≤8, L201–215), `PL/Application/
|
||||
AiRawLeadMapper.cs` (json-ответ модели → AiParsedLeadDto 1:1 python: title ≤140, стек normalize, бюджет
|
||||
BudgetNormalizer=clean_budget L316–339, контакты ContactsQualifier=build_contacts L389–421, типы/spam/board;
|
||||
доску решает CardComposer BoardAccepts — как сейчас), `I/Integrations/GrpcAiClassifier.cs`,
|
||||
`I/Integrations/GrpcAiTools.cs` (IAiTools: GenerateKeywords/EvaluateFit; usage→KV `aiTokenUsage` — SettingsKeys
|
||||
новый внутренний ключ), `I/Integrations/LocalAiTools.cs` (для UseLocal: методы не поддерживаются → исключение/
|
||||
пустой результат — воркер Discovery сам выбирает эвристику), регистрация в `AddDealIntegrations` по флагу
|
||||
`Services:Ai` (Ruling 6). Tests: контекст-билдер (промпты/доски/примеры); маппер json→DTO (бюджет «2к»/валюты/
|
||||
контакты); адаптеры (in-proc gRPC ai-service); Local-фолбэк.
|
||||
|
||||
**Источники:** ai.py L61–258; pipeline.py `_store_lead` L433–514; CardComposer; Rulings 5/6.
|
||||
|
||||
**Acceptance:** build 0/0; тесты PASS; воркер с GrpcAiClassifier (UseLocal=false) проходит фильтр/классификацию
|
||||
против in-proc ai-service. Отчёт: `task-15-report.md`.
|
||||
|
||||
### Task 16: core — ml-интеграция: GrpcMlClient + MlOutboxFlushScheduler
|
||||
|
||||
**Files:** Create: `I/Integrations/GrpcMlClient.cs` (IMlClient: Predict/Status/Reset/PushAsync — Push остаётся
|
||||
записью в MlOutbox через IMlLearningStore как LocalMlClient; Predict → gRPC, сбой → NotReadyPrediction;
|
||||
Status → service-статус + кэш 15 с (reachable), статистика из KV/таблиц; Reset → gRPC Reset + ClearOutbox),
|
||||
`A/Hosting/MlOutboxFlushScheduler.cs` (10 с per-tenant; по 10 строк, ≤100 за цикл, TrainBatch; delete после успеха;
|
||||
эталон PipelineWorkerScheduler). Регистрация по флагу `Services:Ml` (Ruling 6). Modify: `I/Integrations/
|
||||
LocalMlClient.cs` — не трогаем (фолбэк); `ST/Application/SettingsKeys.cs` — внутренние ключи `AiTokenUsage`/
|
||||
`DiscFloodDay`/`TgStatus`/`TgAccount`. Tests: flush (фейк-gRPC): 25 строк → 3 батча, строки удалены, сбой → строки
|
||||
остались; reset; reachable false при недоступности; predict-fallback.
|
||||
|
||||
**Источники:** ml_client.py L30–31/56–135; Rulings 4/6; LocalMlClient (эталон Push/Status).
|
||||
|
||||
**Acceptance:** build 0/0; тесты PASS. Отчёт: `task-16-report.md`.
|
||||
|
||||
### Task 17: core — Discovery: таблицы, порт, сервисы задач/кандидатов/чёрного списка/лога
|
||||
|
||||
**Files:** Create: `DC/Application/Models/*.cs` (§4.8 DTO: задача L353, кандидат L355, чёрный список, лог),
|
||||
`DC/Application/DiscoveryIdPrefixes.cs` (`dt_`/`dl_`), `DC/Application/IDiscoveryStore.cs`, `DC/Application/
|
||||
DiscoveryTasksService.cs` (create/patch/delete/start/pause/advance/bump, план-бюджет 1:1 L234–381),
|
||||
`DC/Application/DiscoveryCandidatesService.cs` (add с исключениями, set_candidate, mark_joined/mark_rejected,
|
||||
delete; метки/топики JSON), `DC/Application/DiscoveryBlacklistService.cs`, `DC/Application/DiscoveryLogService.cs`,
|
||||
`DC/Application/DiscoveryModuleRegistrar.cs`; миграция `TenantDiscovery` (TenantDbContext — DbSet `DiscTasks`/`DiscCandidates`/`DiscBlacklist`/`DiscLog` +
|
||||
ApplyConfiguration; команда как в Task 13); `I/Persistence/Repositories/DiscoveryStore.cs`; csproj DC → ST + Contracts. Tests: валидации (имя/бюджет/план), start без ключей, исключения
|
||||
add_candidate, mark_joined→joined/autoJoined/счётчики, blacklist-перезапись rejected.
|
||||
|
||||
**Источники:** discovery.py (создание/кандидаты/чёрный список/лог L234–608), db.py L136–196, api-map §3.8/§4.8;
|
||||
Rulings 9/10.
|
||||
|
||||
**Acceptance:** build 0/0; миграция применяется; тесты PASS. Отчёт: `task-17-report.md`.
|
||||
|
||||
### Task 18: core — Discovery-воркер (5 с): поиск/оценка/авто-join, бан-гард
|
||||
|
||||
**Files:** Create: `DC/Application/DiscoveryBanGuard.cs` (лимит дня по DiscLog join_auto за UTC-сутки; discFloodDay;
|
||||
discPaused; wait-пауза из настроек 1:1 ban_guard.py), `DC/Application/DiscoveryLangDetector.cs` (detect_lang_ru
|
||||
L62–83), `DC/Application/DiscoveryEvaluator.cs` (фит: короткие → нет; ML-спам при mlEnabled (IMlClient.Predict);
|
||||
ИИ EvaluateFit при aiEnabled (IAiTools), сбой → эвристика; форумы по темам — group_by_topic L96–117 + passed
|
||||
L229–237), `DC/Application/DiscoveryWorkerService.cs` (шаги 1–4 tick L444–484 через ITelegramGateway; маркеры и
|
||||
логи 1:1; join_failures=3→delete), `A/Hosting/DiscoveryWorkerScheduler.cs` (5 с, per-tenant, эталон
|
||||
PipelineWorkerScheduler). Тесты: бан-гард (лимит/флуд/пауза), оценка (язык/фит/порог/форумы/метки), воркер-шаги с
|
||||
фейковым гейтом (search→candidate; eval→review; join с паузами; план выполнен → done).
|
||||
|
||||
**Источники:** discovery_worker.py целиком; discovery_eval.py целиком; ban_guard.py целиком; Rulings 9/10.
|
||||
|
||||
**Acceptance:** build 0/0; тесты PASS. Отчёт: `task-18-report.md`.
|
||||
|
||||
### Task 19: core — эндпоинты /api/discovery + generate-keywords; curl-приёмка
|
||||
|
||||
**Files:** Create: `A/Endpoints/DiscoveryEndpoints.cs` — 13 эндпоинтов api-map §3.8: tasks (list/create/patch/delete/
|
||||
start/pause), generate-keywords (мягкая ошибка HTTP 200 `{keywords:[], error}`; `_clean_keywords` в core),
|
||||
candidates(фильтр), join (ручной: валидация статуса, Join→add в Dialogs monitor→фон backfill→mark_joined(auto:false)
|
||||
→remove_blacklist, ошибки 400 с текстом), reject (→blacklist reason «отклонено вручную»), blacklist list/delete,
|
||||
log. Curl-приёмка discovery: создание задачи → start (после добавления ключей) → симуляция работы воркера
|
||||
(фейк-гейт в тестовом host) → кандидаты new/review → reject → blacklist → лог; 404/400 ветки.
|
||||
|
||||
**Источники:** discovery_routes.py целиком; store.js L2171–2370; Rulings 9/11.
|
||||
|
||||
**Acceptance:** build 0/0; curl PASS (или тестовая приёмка) по сценарию выше. Отчёт: `task-19-report.md`.
|
||||
|
||||
### Task 20: compose-dev, сквозная интеграция и финал этапа
|
||||
|
||||
- `DEP`: сервисы из Task 2–4 доводятся (healthcheck gRPC, volumes, env `DEAL_SERVICE_TOKEN`, ingress env);
|
||||
`docker compose -f deploy/compose.dev.yml config` валиден; локальный подъём всех процессов (ручной шаг — docker).
|
||||
- Сквозная эмуляция (без реального Telegram/LLM): подняты core+3 сервиса (`SERVICES__*__USELOCAL=false`); gRPC-вызов
|
||||
PushMessage в core (клиент-эмулятор, скрипт `scripts/grpc-emit.ps1`/`.sh` на grpcurl или тест-проект) → очередь →
|
||||
admin/tick → карточка (new_lead); ml: TrainBatch → `/api/ml/status` показывает ready; ai: фильтр/классификация
|
||||
через фейковый OpenAI-сервер? НЕТ — ai-сервис без ключа отдаёт UNAVAILABLE, воркер падает в локальный разбор
|
||||
(проверяем); затем `SERVICES__AI__USELOCAL=true` — фолбэк жив.
|
||||
- Обновить `docs/technical/Техническая-документация-Дейл.md` (сервисы/порты/gRPC-контракты, каналы-вкладка,
|
||||
Discovery, флаги, ml-модель и веса) и roadmap (этап 6 → «Выполнено», ограничения этапа 7).
|
||||
- Полный прогон: `scripts/build.sh` + `scripts/test.sh` (620 + новые PASS), build каждой sln 0/0.
|
||||
- Отчёт `task-20-report.md` + финальная строка `progress.md`.
|
||||
|
||||
**Источники:** Rulings 2/12/13; compose.dev.yml (эталон minio-записи); паттерны отчётов этапов 1–5.
|
||||
|
||||
**Acceptance:** см. пункты выше; любые живые проверки Telegram/LLM — ⚠ ручные, по возможности, с кредами.
|
||||
|
||||
## Self-Review
|
||||
|
||||
1. **Spec coverage:** прото-контракты (а) — Task 1 + Rulings 1/3/5/7; каркасы сервисов — Task 2–4; ml-алгоритм/
|
||||
сохраняемость — Task 5/6 (Ruling 4); ai-фасад/промпты/таймауты/токены — Task 7/8/15 (Ruling 5); core gRPC-клиенты
|
||||
за флагом и судьба MlOutbox — Task 15/16 (Ruling 6); входящий telegram-gRPC→EnqueueAsync — Task 12 (Ruling 7);
|
||||
замена Local-заглушек с фолбэком — Task 15/16/20; Discovery (таблицы/воркер/оценка/чёрный список/квоты/история/
|
||||
генерация ключей/эндпоинты) — Task 17/18/19 (Rulings 9–11); каналы-эндпоинты и QR/статус/марк-as-рид/мониторинг/
|
||||
«Перечитать»/ключи/авто-мониторинг — Task 10/13/14 (Rulings 3/7/8); SSE system_status/toast/new_lead — Task 12/14
|
||||
(Ruling 13); compose-dev — Task 2–4/20; безопасность dev (service-token, mTLS-решение) — Rulings 1/2, Task 2–4/12.
|
||||
Roadmap-скоуп (L82–91) покрыт; ТЗ §4/§5/§8 — через api-map/референсы выше.
|
||||
2. **Placeholder scan:** TODO/«добавьте обработку» нет; «ручная проверка» — явно помеченные живые проверки с
|
||||
кредами (задачи 9/10/11/20), авто-приёмка — эмуляция ингресса и фейки. Onnx/TeleSharp альтернативы не
|
||||
оставлены «на потом» — зафиксированы решения (Rulings 3/4). IColumnSuggester (LocalColumnSuggester) сознательно
|
||||
НЕ заменяется gRPC (эвристика читает карточки тенанта в ядре; ai-service участвует только через IAiTools
|
||||
GenerateKeywords — Kanban-suggest остаётся локальным, api-map L120–121 без изменений) — это решение, не TODO.
|
||||
3. **Type consistency:** имена контрактов и методы: IAiClassifier переходит на запросные record'ы (Task 15) —
|
||||
воркер Pipeline (Ruling 5 этапа 4) вызывает ClassifyAsync/FilterAsync; адаптеры Local/Grpc реализуют один порт;
|
||||
IMlClient не меняет сигнатур (Predict/Status/Reset/Push) — GrpcMlClient/LocalMlClient взаимозаменяемы; новые
|
||||
внутренние SettingsKeys (AiTokenUsage/DiscFloodDay/TgStatus/TgAccount) добавляются в ST-каталог как внутренние;
|
||||
ITelegramGateway (Task 13) реализуется клиентом Task 12–14 и потребляется эндпоинтами/воркером Discovery (Task
|
||||
18) — единый список методов Ruling 7; `QueuedMessage` (контракт ингресса) тот же, что у demo-ingest;
|
||||
`MlPredictResultDto`/status-поля 1:1 с ml.proto (Task 1/6). Циклов ссылок нет: TM→ST+Contracts; DC→ST+Contracts;
|
||||
TM/DC не знают друг о друге; Api оркестрирует.
|
||||
4. **Вне scope этапа 6:** mTLS-сертификаты и prod-compose (этап 7); лимиты/бюджеты токенов (учёт уже есть);
|
||||
оператор/админка/аудит-поток; экспорт/импорт ML-моделей (решение владельца); мультиаккаунтность на тенанта;
|
||||
события pipeline_stats/boards_changed/leads_reclassified (фронт не слушает); reclassify ИИ-переклассификации на
|
||||
реальном ИИ (контракт-заглушка остаётся; реальный вызов — вместе с операторским контуром этапа 7);
|
||||
шифрование сессий и их бэкап-интеграция (сессии шифруются файлово, но ротация ключей/бэкап-политика — этап 7).
|
||||
@@ -0,0 +1,586 @@
|
||||
# Дейл (Deal) — Этап 7: SaaS-контур (оператор, инвайты, лимиты, аудит, безопасность, prod-деплой, финальные доки) Implementation Plan
|
||||
|
||||
> Исторический документ этапа 7. Актуальное состояние — `docs/superpowers/STATUS.md` и `docs/technical/Техническая-документация-Дейл.md`.
|
||||
|
||||
**Goal:** Замкнуть SaaS-контур «Дейла» поверх готового мультитенантного ядра этапов 0–6: отдельный
|
||||
изолированный контур **оператора** (вход, тенанты, инвайты, лимиты/бюджеты, health, impersonation,
|
||||
чтение аудита) в `public`-схеме и на новых REST-ручках `/api/operator/*` + `/api/join` (активация
|
||||
инвайта); **бюджет токенов** на тенанта с автоматическим fallback на ML/локальный разбор и
|
||||
уведомлением (приём не блокируется); **аудит-поток** (входы, инвайты, impersonation, действия
|
||||
оператора — append-only); **безопасность**: лимит попыток входа, rate limiting (приложение + gRPC-
|
||||
ингресс), Origin-проверка мутаций, security-заголовки, mTLS за флагом для внутренних сервисов;
|
||||
**prod-деплой**: `deploy/compose.prod.yml` (postgres, minio, core, 3 сервиса, Caddy, grafana/loki/
|
||||
promtail) + ежедневные бэкапы; **observability**: Serilog (JSON-логи в core и сервисах) → Promtail →
|
||||
Loki → Grafana; **финальные доки** (техдок §11/§13, roadmap, STATUS, user-guide, api-map-дополнение)
|
||||
и сквозная SaaS-приёмка. Фронт Vue не переписывается: операторская админка — API-only (UI — вне).
|
||||
|
||||
**Architecture:** все SaaS-сущности живут в **`public`** (системная схема), владелец — существующий
|
||||
модуль `Deal.Modules.Tenants` (дизайн-док §5 L128: «тенанты, пользователи, инвайты, лимиты, аудит,
|
||||
операторская админка»), EF-адаптеры — в `Deal.Infrastructure`, HTTP — в `Deal.Api/Endpoints`. Оператор —
|
||||
НЕ тенант: отдельные таблицы `Operators`/`OperatorSessions`, отдельная кука `deal_operator_session`,
|
||||
отдельный bootstrap из env. Тенант-сессия остаётся как есть (`deal_session`, SessionMiddleware).
|
||||
Активация инвайта создаёт пользователя + тенанта (при необходимости) и провижинит схему существующим
|
||||
`TenantService`/`ITenantProvisioner`. Учёт токенов ИИ, который этап 6 копил в tenant-KV
|
||||
(`SettingsKeys.AiTokenUsage`, `AiUsageLedger`), на этапе 7 пишется в `public.tenant_limits` (период +
|
||||
`UsedTokens`, ленивый reset) — это источник истины для бюджетного гейта; KV-ключ остаётся как
|
||||
«lifetime»-счётчик. Гейт ставится НЕ внутрь ai-service, а в core на границе вызова ИИ (декораторы
|
||||
`IAiClassifier`/`IAiTools` с fallback на Local-реализации — ровно семантика «aiEnabled=false/aiFail»
|
||||
этапов 4–6), поэтому контракты/сервисы этапа 6 не меняются. Rate limiting — встроенный
|
||||
`AddRateLimiter` ASP.NET Core + прикладной `LoginAttemptGuard`; mTLS — за флагом (dev остаётся
|
||||
plaintext + service-token). Observability: Serilog JSON во всех процессах, сбор логов контейнеров
|
||||
Promtail → Loki → Grafana (compose-prod); OTel-метрики задекларированы follow-up (минимум-объём).
|
||||
|
||||
**Tech Stack:** .NET 10, существующие порты/паттерны этапов 1–6; новые пакеты в core: `Serilog`,
|
||||
`Serilog.Sinks.Console`, `Serilog.Sinks.File`, `Grpc.HealthCheck` (клиент health для операторского
|
||||
health-эндпоинта). Rate limiting — shared-framework (`System.Threading.RateLimiting`/`AddRateLimiter`,
|
||||
новый NuGet не нужен). Инфраструктурные файлы (не код): `deploy/compose.prod.yml`, `deploy/caddy/
|
||||
Caddyfile`, `deploy/observability/{promtail.yml,loki.yml,grafana-provisioning/*}`, `deploy/.env.prod.
|
||||
example`, `scripts/mtls-certs.sh`, `scripts/backup.sh`. Docker-движок в ходе этапа может быть выключен:
|
||||
все acceptance-задачи — без docker там, где можно; «живые» шаги явно помечены ⚠ Manual.
|
||||
|
||||
**Spec:** `docs/architecture/2026-09-05-deal-architecture-design.md` §8 (безопасность, L187–216),
|
||||
§9 (observability/админка/бэкапы, L216–233), §10 (деплой, L233–243), §12.5; `docs/spec/
|
||||
ТЗ-дейл-новая-архитектура.md` §3 (роли), §9 (лимиты), §10 (админка), §11 (НФТ); решения владельца в
|
||||
`docs/superpowers/plans/2026-09-05-deal-roadmap.md` (L107–121 + «Выполнено» этапов 1–6 + «Оставшиеся
|
||||
этапы» L101–105); ограничения этапа 6 (roadmap L82–84, STATUS.md); текущий код: `Deal.Modules.Tenants`
|
||||
(AuthService/TenantService/порты), `Deal.Infrastructure` (миграции/конфигурации/репозитории),
|
||||
`Deal.Api` (Program.cs, SessionMiddleware, AuthEndpoints, хостинг-циклы, SseBroker), `AiUsageLedger`
|
||||
+ `GrpcAiClassifier`/`GrpcAiTools`, `PipelineWorkerService` (ветки aiEnabled/fallback), `deploy/
|
||||
compose.dev.yml`, техдок §8–§11/§13, api-map §3.9/§5.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Проект **НЕ git**; фиксация — отчёты `task-N-report.md` и `progress.md` в `.superpowers/sdd/deal-stage7-saas/`.
|
||||
- .NET 10; все sln собираются 0 warnings/0 errors (`TreatWarningsAsErrors`); dev-Postgres `deal-postgres`
|
||||
(:5433); системные миграции применяются командой `dotnet ef database update --context DealDbContext`
|
||||
(из `src/core`), tenant-миграции — провижинером на старте (не меняется).
|
||||
- Код-стайл этапов 1–6: 1 тип = 1 файл; XML-doc на public; комментарии на русском; без регионов; без
|
||||
магических чисел (именованные константы); времена `DateTimeOffset` (UTC); JSON camelCase; ошибки
|
||||
API — `{detail}`; кука httpOnly/SameSite=Lax.
|
||||
- Vue-фронт, `backend/`, `mlservice/` (python), корневой `docker-compose.yml` — **не трогаем**.
|
||||
Новые SaaS-ручки — дополнение к `/api` (фронт их не вызывает); контракт api-map для фронта не ломается.
|
||||
- Секреты — только env/файлы (`DEAL_*`), никогда в коде/БД в открытом виде; в аудит и логи секреты не пишутся.
|
||||
- Все SaaS-таблицы — `public`; `TenantDbContext`/схемы тенантов не меняются (кроме случаев, когда
|
||||
требуется новое tenant-поле, — в этапе 7 таких нет).
|
||||
- Креды оператора/инвайт-коды в тестах и примерах — фиксированные dev-значения; живые проверки
|
||||
(Docker-стек, mTLS-рукопожатие, бэкап-прогон) — ⚠ Manual, по возможности.
|
||||
|
||||
## Зафиксированные решения (Rulings этапа)
|
||||
|
||||
Сокращения путей: `TM=` `src/core/Deal.Modules.Tenants/`, `I=` `src/core/Deal.Infrastructure/`,
|
||||
`A=` `src/core/Deal.Api/`, `C=` `src/core/Deal.Contracts/`, `ST=` `src/core/Deal.Modules.Settings/`,
|
||||
`PL=` `src/core/Deal.Modules.Pipeline/`, `T=` `src/core/tests/Deal.Tests.Unit/`, `DEP=` `deploy/compose.dev.yml`,
|
||||
`PROD=` `deploy/compose.prod.yml`, `TG=` `src/telegram-service/`, `AI=` `src/ai-service/`, `ML=` `src/ml-service/`.
|
||||
|
||||
- **Ruling 1 (а) — модель оператора/сессий: public-таблицы, изоляция, bootstrap.** Новые таблицы
|
||||
`public` (системная миграция `SystemSaaS`, команда EF как в техдок §13.2): `Operators` (Id Guid PK,
|
||||
Login unique (нижний регистр), PasswordHash Argon2id, Status, CreatedAt), `OperatorSessions`
|
||||
(TokenHash PK, OperatorId FK→Operators, Login, ExpiresAt, CreatedAt; срок жизни **12 часов**),
|
||||
`Invites`, `TenantLimits`, `AuditLog` (Rulings 4/5/8). Сущности/конфигурации — по образцу
|
||||
TenantEntity/UserEntity/SessionConfiguration (ToTable в `public`, нижний регистр имён). Кука
|
||||
оператора — **`deal_operator_session`** (отдельная от тенантной `deal_session`; httpOnly,
|
||||
SameSite=Lax, Secure из конфига, секция `OperatorCookies`). Операторская сессия разрешается
|
||||
**отдельным** `OperatorSessionMiddleware` (после SessionMiddleware) в `HttpContext.Items["CurrentOperator"]`;
|
||||
эндпоинты `/api/operator/*` требуют именно операторскую сессию (403/401), тенантные `/api`-ручки её
|
||||
не видят (другое имя куки — взаимной подмены нет). **Bootstrap оператора**: env
|
||||
`DEAL_OPERATOR_LOGIN`/`DEAL_OPERATOR_PASSWORD`; в `Development` при их отсутствии — дефолт
|
||||
`operator`/`operator` (зеркало dev-seed admin/admin). В `Production` при отсутствии кред — стартовый
|
||||
warning и пропуск (оператор заводится позже через env + рестарт; кода регистрации оператора нет).
|
||||
**Dev-seed дефолтного тенанта/admin/admin становится dev-only**: `TenantBootstrapService` создаёт
|
||||
дефолтного тенанта только в `Development` или при `DEAL_BOOTSTRAP_DEFAULT_TENANT=1`; провижининг схем
|
||||
всех зарегистрированных тенантов выполняется всегда. Прод-тенантов заводит оператор.
|
||||
- **Ruling 2 (б) — инвайты и активация.** `Invites` (public): Code PK (случайный url-safe, 16 симв.,
|
||||
префикса нет), TenantId Guid **nullable** (null = «новый тенант»), Email (нормализованный, unique по
|
||||
активным), Status (`pending`/`activated`/`revoked`/`expired`), ExpiresAt (**72 ч**, константа),
|
||||
CreatedById (оператор), ActivatedAt null, CreatedAt. Создание/отзыв — только оператор. Активация —
|
||||
публичная ручка **`POST /api/join`** `{code, email, name?, password}`: email обязан совпасть с
|
||||
инвайтом; проверка статуса/expiry (expired → 410-семантика текстом «Срок действия приглашения
|
||||
истёк»); пароль ≥4 (как в AuthService); создание пользователя (login=email, Argon2id) и, если
|
||||
TenantId пуст, тенанта (`TenantService.CreateTenantAsync(name, newId)` — провижинит схему сам);
|
||||
отметка `activated` + аудит. Глобальная уникальность email обеспечена unique-индексом `users.login`
|
||||
(конфликт → 400 «Этот email уже зарегистрирован»). Инвайт на существующего тенанта (TenantId задан)
|
||||
создаёт пользователя в нём. Отдельной страницы-активации во фронте нет — ручка API-only
|
||||
(curl/будущий UI); в user-guide фиксируется описание.
|
||||
- **Ruling 3 (в) — лимиты: модель, период, списание, гейт, fallback, уведомление.** Таблица
|
||||
`TenantLimits` (public): TenantId PK (FK→tenants, Restrict), BudgetTokens bigint, Period
|
||||
(`month`|`day`, default `month`), PeriodStart, UsedTokens bigint (с начала периода), Warned80 bool,
|
||||
NotifiedExhausted bool, UpdatedAt. **Списание**: там, где этап 6 звал `AiUsageLedger.AddAsync`
|
||||
(GrpcAiClassifier/GrpcAiTools, успешные RPC ai-service), новый `TokenUsageRecorder.AddAsync` пишет
|
||||
(1) инкремент `UsedTokens` в `tenant_limits` (тот же scoped DealDbContext) и (2) по-прежнему
|
||||
lifetime-сумму в KV `aiTokenUsage` (существующий ключ — счётчик «всего», оператор/будущий UI).
|
||||
**Reset** — ленивый: при чтении/записи, если сейчас ≥ конца периода (PeriodStart+месяц/сутки),
|
||||
`UsedTokens`/флаги обнуляются и PeriodStart=now; отдельного фонового цикла нет. **Гейт** — порт
|
||||
`ITokenBudgetGate.CheckAsync(tenantId)` → `{Allowed, Exceeded, Status}`; статус тенанта
|
||||
(`suspended`) трактуется как Not Allowed (приостановка замораживает ИИ). Гейт спрашивают
|
||||
**декораторы** `BudgetedAiClassifier`/`BudgetedAiTools` (регистрируются в `AddDealIntegrations`,
|
||||
только когда `Services:Ai:UseLocal=false`, поверх gRPC-адаптеров): исчерпано → фильтр/классификация
|
||||
через Local-реализации (семантика aiEnabled=false / aiFail), IAiTools.EvaluateFit → исключение
|
||||
`AiUnavailableException` (Discovery-воркер сам уходит в эвристику — код не меняется),
|
||||
GenerateKeywords → мягкая ошибка `{keywords:[], error}`. **Уведомление**: пороги 80% и 100% от
|
||||
бюджета; обнаружение перехода и публикация SSE-тоста («ИИ-бюджет израсходован на 80%» /
|
||||
«ИИ-бюджет исчерпан — обработка в локальном режиме», иконка `bell`) — Api-хостинг
|
||||
`BudgetAlertScheduler` (60 с, эталон StorageTickScheduler), флаги Warned80/NotifiedExhausted
|
||||
гарантируют один тост на период на порог; смена бюджета оператором сбрасывает флаги. Приём и
|
||||
базовая обработка сообщений не блокируются (fallback по замыслу ТЗ §9). Дефолт-бюджет нового
|
||||
тенанта — константа модуля `TokenBudgetDefaults` (10 000 000 токенов/месяц), оператор задаёт
|
||||
бюджет при создании или меняет позже.
|
||||
- **Ruling 4 (г) — аудит: append-only поток.** Таблица `AuditLog` (public): Id bigint identity PK,
|
||||
At, ActorType (`operator`|`tenant`|`system`), ActorId Guid null, TenantId Guid null, EventType
|
||||
(строковая константа), Ip string null, DetailJson (JSON, без секретов). События (каталог
|
||||
`AuditEvents`): `tenant_login_ok`, `tenant_login_failed`, `operator_login_ok`, `operator_login_failed`,
|
||||
`invite_created`, `invite_revoked`, `invite_activated`, `tenant_created`, `tenant_status_changed`,
|
||||
`tenant_limit_changed`, `impersonation_started`. Пишет **только** `AuditService` (модуль Tenants,
|
||||
порт `IAuditLogStore` → адаптер `AuditLogStore`), вызывается из эндпоинтов/сервисов; UPDATE/DELETE в
|
||||
приложении отсутствуют (append-only на уровне кода и конвенции; DB-триггеры не добавляем).
|
||||
Читает — только оператор: `GET /api/operator/audit?eventType=&actorType=&tenantId=&from=&to=&limit=`
|
||||
(сортировка At DESC, limit ≤500). TTL/авто-очистка — **не делаем** (retention 180 дней и выгрузка —
|
||||
на усмотрение оператора, документируется в техдок §9); purge-скрипт — вне этапа.
|
||||
- **Ruling 5 (д) — rate limiting и защита входа.** Реализация — встроенный `AddRateLimiter`
|
||||
ASP.NET Core (политики-именованные, без нового NuGet) + прикладной guard. Порядок middleware:
|
||||
SessionMiddleware → OperatorSessionMiddleware → **UseRateLimiter** → OriginGuard → эндпоинты
|
||||
(политика «api» берёт ключ из `CurrentUser.TenantId` либо IP анонима — SessionMiddleware уже
|
||||
отработал). Политики и флаги — секция `RateLimit` (класс `RateLimitOptions`): `Enabled` (**false**
|
||||
в dev/тестах по умолчанию — curl-приёмки не режутся; true в PROD-окружении), `AuthPerMinute`
|
||||
(10/мин на IP для `/api/auth/login` и `/api/operator/auth/login`), `ApiPerMinute` (600/мин на
|
||||
тенанта/IP), `GrpcIngressPerMinute` (600/мин на тенанта gRPC-ингресса :5082, интерцептор
|
||||
`IngressRateLimitInterceptor` — фиксированное окно по metadata `tenant-id`; health освобождён).
|
||||
Ответ 429 — `{"detail":"Слишком много запросов. Повторите позже"}`. **Лимит попыток входа** —
|
||||
прикладной `LoginAttemptGuard` (singleton, in-memory фиксированное окно по ключу
|
||||
`ip|normalizedLogin`, как в прототипе лимитов нет — новый): ≥5 неудач за 15 мин → 429 «Слишком
|
||||
много попыток входа. Попробуйте через 15 минут»; успешный вход сбрасывает счётчик ключа. Один
|
||||
инстанс core (compose) — in-memory достаточно; multi-instance — задел (зафиксировать в техдок §11).
|
||||
- **Ruling 6 (е) — mTLS за флагом.** Dev остаётся как есть: plaintext + обязательный service-token
|
||||
(Ruling 2 этапа 6). Новое: секция/`DEAL_MTLS_*` (`Enabled=false` default, `ServerCertPfx`,
|
||||
`ServerCertPassword`, `ClientCertPfx`, `ClientCertPassword`, `CaPem`): при `Enabled=true` —
|
||||
(1) Kestrel внутренних gRPC-эндпоинтов (сервисы :5101–5103, ингресс core :5082) включает HTTPS
|
||||
с серверным сертификатом и **требует** клиентский сертификат (chain → CA из `CaPem`);
|
||||
(2) исходящие gRPC-клиенты core (Grpc*Client + health-пробы) и сервисы→ингресс подписывают запрос
|
||||
клиентским сертификатом и проверяют CA сервера. Основной HTTP :5080 core остаётся http — TLS
|
||||
терминирует Caddy (Ruling 9). Сертификаты генерируются **скриптом `scripts/mtls-certs.sh`**
|
||||
(openssl: dev-CA + серверные сертификаты на `core`, `telegram-service`, `ai-service`,
|
||||
`ml-service`, `localhost` + общий клиентский сертификат `deal-client`) в `deploy/certs/`
|
||||
(в репозиторий не попадают — вне git, но и проект не git: каталог в `.dockerignore`/README-пометка).
|
||||
Код интерцепторов/контрактов не меняется — меняется только транспорт (решение-рамка этапа 6).
|
||||
Живое mTLS-рукопожатие — ⚠ Manual.
|
||||
- **Ruling 7 (ж) — observability: минимально рабочий набор.** Serilog добавляется во **все четыре
|
||||
процесса** (core + TG/AI/ML): консоль в формате JSON (prod-стиль; dev можно текст) + rolling-файл
|
||||
`data/logs/deal-*.json` (core — под volume). Секреты/пароли/ключи не логируются (правило уже есть).
|
||||
OTel-метрики/трейсы и Prometheus **в этапе 7 не добавляем** — объём ограничен, стек фиксируется
|
||||
как «Serilog-логи → Promtail → Loki → Grafana», метрики ASP.NET Core задекларированы в техдок §7
|
||||
TODO (решение-рамка архитектуры §9 соблюдена наполовину: структурированные логи + дашборды по
|
||||
логам/health). Дашборды Grafana — минимальные (health-контейнеры и поиск по логам), provisioning-
|
||||
файлами (datasource Loki + dashboard JSON), без коммерческих плагинов.
|
||||
- **Ruling 8 (з) — бэкапы.** `scripts/backup.sh`: (1) Postgres — `docker compose exec -T postgres
|
||||
pg_dump -Fc` всех схем (public+tenant_*) → `data/backups/pg/`; (2) MinIO — `mc mirror` бакета
|
||||
`deal-files` в архив (или `docker run`-контейнер minio/mc); (3) файловые volume'ы telegram-сессий и
|
||||
ML-моделей (`deal_tg_sessions`, `deal_ml_data`) — `docker run --rm -v`-тар (busybox), сессии уже
|
||||
зашифрованы AES-GCM — архив без доп. шифрования, доступ только root; (4) core `data` (ключ
|
||||
шифрования DEAL_ENCRYPTION_KEY/файл + attachments, если Local) — тар. Retention: **14 копий**
|
||||
(find -mtime +14 -delete), имя файла `backup-YYYYMMDD-HHMMSS.*`. Планировщик — вне контейнера:
|
||||
systemd timer/cron пример в шапке скрипта и техдок §9 (документировано, НЕ ставится скриптом).
|
||||
Восстановление — раздел в техдок §9 (шаги: поднять compose → pg_restore → распаковать volume →
|
||||
перезапуск сервисов). Реальный прогон бэкапа и restore-тест — ⚠ Manual (нужен docker).
|
||||
- **Ruling 9 (и) — compose-prod и границы.** `PROD`: сервисы `postgres` (без host-портов; volume),
|
||||
`minio` (без host-портов), `core` (:5080 в compose-сети + :5082 ингресс), `telegram-service`,
|
||||
`ai-service`, `ml-service` (mTLS env из Ruling 6), `caddy` (единственный наружу: 80/443; терминация
|
||||
TLS `tls internal` — для реального домена заменить на Cloudflare-origin/сертификаты, комментарий в
|
||||
Caddyfile; статика `src/frontend/dist` + `reverse_proxy /api → core:5080`; security-заголовки),
|
||||
`loki`/`promtail` (docker-логи по label'ам)/`grafana` (датасорс Loki, dashboard-провижининг,
|
||||
publish **127.0.0.1:3001:3000** — доступ оператору по SSH-туннелю). Секреты — только из `.env`
|
||||
(шаблон `.env.prod.example`, **без дефолтных паролей** — fail-fast на отсутствующие);
|
||||
healthcheck'и как в dev (grpc_health_probe/`pg_isready`); rate limiting включён, CORS — явный
|
||||
allowlist (`Security:AllowedOrigins`), куки Secure=true. Все сервисы — в одной внутренней сети,
|
||||
наружу — только caddy. **Вне этапа:** Cloudflare (конфигурация вне кода, документируется), k8s,
|
||||
биллинг-провайдер, саморегистрация, UI админки, multi-instance rate-limit. Живой подъём PROD —
|
||||
⚠ Manual; авто-приёмка — `docker compose -f deploy/compose.prod.yml config` (rc=0).
|
||||
- **Ruling 10 (к) — безопасность-доработки в коде.** (1) Защита входа — Ruling 5. (2) **Origin-
|
||||
проверка мутаций**: `OriginGuardMiddleware` — для не-GET/HEAD/OPTIONS запросов `/api`, у которых есть
|
||||
заголовок `Origin`, значение обязано совпасть с Host запроса либо быть в allowlist
|
||||
`Security:AllowedOrigins` (CORS-дев-режим уже разрешает любой origin — middleware работает только
|
||||
с явным allowlist из конфига; при пустом списке правило = «Origin == Host»); несовпадение → 403.
|
||||
SameSite=Lax кук остаётся первым рубежом CSRF (документируется). (3) **Security-заголовки**:
|
||||
`SecurityHeadersMiddleware` на весь core (X-Content-Type-Options: nosniff, X-Frame-Options: DENY,
|
||||
Referrer-Policy: no-referrer); CSP/HSTS — на Caddy (фронт-статика; CSP для Vue требует аккуратной
|
||||
настройки nonce — документируется в техдок §10, фронт не меняется). (4) Секреты/параметризация
|
||||
SQL/Argon2id — уже есть, новых исключений не вводим. (5) Приостановка тенанта: вход заблокирован
|
||||
(AuthService проверяет статус тенанта через `ITenantRepository.GetByIdAsync`), ИИ-расход заморожен
|
||||
(гейт Ruling 3); активные тенант-сессии доживают до expiry (мгновенный разлогин — вне этапа,
|
||||
документируется). (6) IDOR: tenantId новых сущностей всегда из сессии/реестра, никогда из тела;
|
||||
перекрёстные проверки — unit-сценарии в задачах-владельцах + сквозной curl-сценарий финальной
|
||||
задачи (оператор против тенант-ручек и наоборот, чужой инвайт/чужой тенант).
|
||||
- **Ruling 11 (л) — где живут новые ручки и кто их зовёт.** Операторская админка — **API-only** под
|
||||
`/api/operator/*` (фронт не трогаем, UI админки — будущий отдельный инкремент): auth (login/logout/
|
||||
me), тенанты (list/create/status/impersonate), инвайты (list/create/revoke), лимиты (view/change
|
||||
по тенанту + сводка usage), аудит (list), health (core/БД/сервисы). Публичная активация — `/api/join`.
|
||||
Ни одна из этих ручек не конфликтует с замороженным контрактом `/api` (api-map §3): тенантные
|
||||
`/api/admin/*` (`tick`/`fts`/`check-message`) остаются тенантными. Новых SSE-типов нет (используются
|
||||
существующие `toast`); событий `pipeline_stats`/`boards_changed`/`leads_reclassified` это не касается.
|
||||
|
||||
## Задачи
|
||||
|
||||
Отчёты — `task-N-report.md` в `.superpowers/sdd/deal-stage7-saas/`. Пути сокращены по Rulings.
|
||||
|
||||
### Task 1: SystemSaaS — public-таблицы оператора/инвайтов/лимитов/аудита + миграция
|
||||
|
||||
**Files:** Create: `I/Persistence/Entities/{OperatorEntity,OperatorSessionEntity,InviteEntity,
|
||||
TenantLimitEntity,AuditLogEntity}.cs` (поля по Rulings 1/3/4; PascalCase-свойства), `I/Persistence/
|
||||
{OperatorConfiguration,OperatorSessionConfiguration,InviteConfiguration,TenantLimitConfiguration,
|
||||
AuditLogConfiguration}.cs` (ToTable("operators"|"operator_sessions"|"invites"|"tenant_limits"|
|
||||
"audit_log", "public"); unique: operators.Login, invites.Email **partial** (активные), FK: OperatorSessions
|
||||
→Operators (Cascade), Invites.CreatedById→Operators (Restrict), TenantLimits→Tenants (Restrict),
|
||||
AuditLog без FK; индексы AuditLog(At), AuditLog(TenantId, EventType)). Modify: `I/Persistence/
|
||||
DealDbContext.cs` — DbSet'ы `Operators/OperatorSessions/Invites/TenantLimits/AuditLog` + ApplyConfiguration.
|
||||
EF: миграция `SystemSaaS` (`dotnet ef migrations add SystemSaaS --context DealDbContext --output-dir
|
||||
Migrations --project src/core/Deal.Infrastructure --startup-project src/core/Deal.Api`).
|
||||
|
||||
**Источники:** Rulings 1/3/4; эталоны TenantEntity/TenantConfiguration и SessionConfiguration;
|
||||
техдок §13.2 (команда system-миграции).
|
||||
|
||||
**Acceptance:** build 0/0; миграция применяется к dev-PG (нужен поднятый `deal-postgres` — если
|
||||
контейнер не поднят, применение и psql-проверка ⚠ Manual); psql: 5 новых таблиц в `public`,
|
||||
уникальные индексы на месте. Отчёт: `task-1-report.md`.
|
||||
|
||||
### Task 2: Оператор — модели/порт/сервис auth, bootstrap из env, dev-only дефолтный тенант
|
||||
|
||||
**Files:** Create: `TM/Application/Models/{StoredOperatorDto,OperatorIdentityDto,OperatorSessionDto,
|
||||
OperatorLoginResultDto}.cs`; `TM/Application/IOperatorAuthStore.cs` (FindByLogin/Create/FindSession/
|
||||
CreateSession/DeleteSession/DeleteExpired), `TM/Application/OperatorAuthService.cs` (Login/Logout/
|
||||
ResolveSession; срок жизни 12 ч, нормализация login, Argon2id через IPasswordHasher — эталон
|
||||
AuthService), `TM/Application/OperatorBootstrapService.cs` (IHostedService-подобный шаг **внутри**
|
||||
существующего TenantBootstrapService или отдельным hosted после него — идемпотентно: env
|
||||
`DEAL_OPERATOR_LOGIN/PASSWORD`, в Development дефолт operator/operator, в Production без env —
|
||||
warning и пропуск). Modify: `A/Hosting/TenantBootstrapService.cs` — дефолтный тенант создаётся только
|
||||
в Development/`DEAL_BOOTSTRAP_DEFAULT_TENANT=1` (Ruling 1); `A/Configuration/CookieOptions.cs` или
|
||||
новый `OperatorCookieOptions` — секция `OperatorCookies` (Name=deal_operator_session, Secure из конфига).
|
||||
Tests: `T/OperatorAuthServiceTests.cs` (login ok/неверный пароль/нормализация/12 ч expiry),
|
||||
`T/FakeOperatorAuthStore.cs`; bootstrap (идемпотентность, dev-default, prod-без env → skip).
|
||||
|
||||
**Источники:** AuthService/SessionTokens/TenantBootstrapService (эталоны); Rulings 1.
|
||||
|
||||
**Acceptance:** build 0/0; unit PASS. Отчёт: `task-2-report.md`.
|
||||
|
||||
### Task 3: Оператор — HTTP-контур /api/operator/auth + операторская сессия
|
||||
|
||||
**Files:** Create: `A/Middleware/OperatorSessionMiddleware.cs` (кука deal_operator_session →
|
||||
OperatorAuthService.ResolveSession → `HttpContext.Items["CurrentOperator"]`; pass-through как
|
||||
SessionMiddleware; Reset не нужен — общий ITenantContext не трогается), `A/Http/AuthHelpers.cs` —
|
||||
добавить `GetCurrentOperator()`/`RequireOperator` (403 «Требуется вход оператора» или 401 —
|
||||
согласовать с текстами: для `/api/operator/*` без операторской сессии — **401** `{"detail":
|
||||
"Требуется вход оператора"}`), `A/Endpoints/OperatorAuthEndpoints.cs` (POST login/logout, GET me —
|
||||
тела/ответы как AuthEndpoints, текст ошибки «Неверный логин или пароль оператора»). Modify:
|
||||
`A/Program.cs` — регистрация OperatorAuthService/IOperatorAuthStore (AddTenantsModule расширяется),
|
||||
`UseMiddleware<OperatorSessionMiddleware>()`, `MapOperatorAuthEndpoints()`, секция OperatorCookies.
|
||||
Login-попытки пишут аудит-события (Task 4) — на этом шаге заглушка-вызов отсутствует, добавится в Task 4.
|
||||
|
||||
**Источники:** AuthEndpoints/SessionMiddleware/CookieOptions (эталоны); api-map §3.1 (форма ответов);
|
||||
Rulings 1/4.
|
||||
|
||||
**Acceptance:** build 0/0; curl-приёмка на :5080 (Postgres поднят): login operator/operator → кука
|
||||
deal_operator_session + `{ok:true,login}`; GET /api/operator/auth/me → login; неверный пароль → 401;
|
||||
logout → ok и 401 после; тенантная кука deal_session НЕ проходит на /api/operator/auth/me (401);
|
||||
операторская кука НЕ проходит на /api/auth/me (401). Отчёт: `task-3-report.md`.
|
||||
|
||||
### Task 4: Аудит-поток — AuditService, события входов, чтение оператором
|
||||
|
||||
**Files:** Create: `TM/Application/AuditEvents.cs` (константы Ruling 4), `TM/Application/Models/
|
||||
AuditRecordDto.cs`, `TM/Application/IAuditLogStore.cs` (AppendAsync/QueryAsync(filter)/— без Update/
|
||||
Delete), `TM/Application/AuditService.cs` (Append через store; хелперы ActorFromUser/Operator),
|
||||
`I/Persistence/Repositories/AuditLogStore.cs` (EF: Append — Add+Save; Query — фильтры At-range/
|
||||
EventType/TenantId/ActorType, At DESC, limit ≤500), регистрация в `I/ServiceCollectionExtensions.cs`
|
||||
(AddDealPersistence). Modify: `A/Endpoints/AuthEndpoints.cs` и `A/Endpoints/OperatorAuthEndpoints.cs` —
|
||||
после успеха/неудачи login вызывают `AuditService.Append` (tenant_login_ok/failed с login и IP,
|
||||
operator_login_*); tenant_login_failed пишется и при неверном пароле, и при заблокированном
|
||||
(suspended) входе (Task 7). Create: `A/Endpoints/OperatorAuditEndpoints.cs` (GET /api/operator/audit
|
||||
с фильтрами-query; ответ `{items:[…], total}`). Tests: `T/AuditServiceTests.cs`,
|
||||
`T/FakeAuditLogStore.cs`; endpoint-хелперы фильтров.
|
||||
|
||||
**Источники:** Rulings 4; эталон DiscoveryLogService/DiscLog (паттерн лога); техдок §10 (аудит-лог).
|
||||
|
||||
**Acceptance:** build 0/0; unit PASS (append-only: у порта нет Update/Delete); curl: failed login →
|
||||
запись audit (psql или GET /api/operator/audit), успешный login → запись ok. Отчёт: `task-4-report.md`.
|
||||
|
||||
### Task 5: Инвайты — сервис/адаптер/операторские ручки + аудит
|
||||
|
||||
**Files:** Create: `TM/Application/Models/InviteDto.cs`, `TM/Application/IInviteStore.cs`
|
||||
(Create/GetByCode/List/UpdateStatus/FindActiveByEmail), `TM/Application/InviteCodeGenerator.cs`
|
||||
(url-safe, 16 симв.), `TM/Application/InvitesService.cs` (CreateInvite(tenantId?, email) — валидация
|
||||
email, одна активная на email → 400 «Для этого email уже есть активное приглашение», expiry = +72 ч;
|
||||
Revoke; List; GetByCode с вычислением статуса expired при чтении), `I/Persistence/Repositories/
|
||||
InviteStore.cs`. Modify: `A/Endpoints/` — создать `A/Endpoints/OperatorInvitesEndpoints.cs` (GET list,
|
||||
POST create `{email, tenantId?}`, POST `{code}/revoke`; ответы: create → `{code, email, tenantId?,
|
||||
expiresAt, status}`; revoke → `{ok:true}`), вызовы AuditService (invite_created/invite_revoked с email
|
||||
и code в DetailJson). Tests: `T/InvitesServiceTests.cs`, `T/FakeInviteStore.cs` (создание/expiry при
|
||||
чтении протухшего/revoke/дубль email на активном/revoked позволяет новый); curl-минимум на ручки
|
||||
(create → list → revoke, 401 без оператора).
|
||||
|
||||
**Источники:** Rulings 2/4; эталон DiscoveryTasksService (валидации/статусы); ТЗ §3 (инвайты).
|
||||
|
||||
**Acceptance:** build 0/0; unit PASS; curl-сценарий ручек PASS. Отчёт: `task-5-report.md`.
|
||||
|
||||
### Task 6: Активация инвайта — POST /api/join (пользователь + тенант + провижининг)
|
||||
|
||||
**Files:** Create: `A/Endpoints/JoinEndpoint.cs` (POST /api/join `{code,email,name?,password}`; без
|
||||
сессии): InvitesService.GetByCode (expired → 400 «Срок действия приглашения истёк»; статус ≠ pending
|
||||
→ 400 «Приглашение уже использовано»/«отозвано»), сверка email (400 «Email не совпадает с
|
||||
приглашением»), существующий users.login (400 «Этот email уже зарегистрирован»), создание тенанта
|
||||
при TenantId=null через `TenantService.CreateTenantAsync(name ?? email, new Guid)` + создание
|
||||
пользователя `authStore.CreateUserAsync` (Argon2id, login=email), `TenantLimits`-строка с
|
||||
дефолт-бюджетом (Ruling 3 — вставка через порт `ITenantLimitStore` из Task 8; до Task 8 допускается
|
||||
прямая вставка адаптером Task 1-таблицы в этой же задаче — см. Task 8), статус invite → activated,
|
||||
аудит `invite_activated`. Ответ: `{ok:true, login}` (кука НЕ ставится — далее обычный /api/auth/login).
|
||||
Валидация пароля ≥4 (текст как в AuthEndpoints). Modify: регистрация `MapJoinEndpoint()` в Program.cs.
|
||||
Tests: `T/JoinFlowTests.cs` — модульный сценарий на Fake-сторах: код+email+пароль → пользователь +
|
||||
тенант (создан через фейк-провижинер, вызван 1 раз) + invite activated + аудит; ошибки (код/email/
|
||||
дубль/протух/revoked).
|
||||
|
||||
**Источники:** Rulings 2/3/11; TenantService.CreateTenantAsync + AuthService (эталоны создания);
|
||||
ТЗ §3 (инвайты/регистрация).
|
||||
|
||||
**Acceptance:** build 0/0; unit PASS; curl-сценарий: оператор создаёт инвайт → /api/join (новый
|
||||
email) → psql: тенант в tenants + схема tenant_* провижинена + пользователь в users + invite
|
||||
activated; повторный /api/join тем же кодом → 400. Отчёт: `task-6-report.md`.
|
||||
|
||||
### Task 7: Оператор-тенанты — список/создание/статус/приостановка/impersonation
|
||||
|
||||
**Files:** Create: `A/Endpoints/OperatorTenantsEndpoints.cs`: GET /api/operator/tenants (реестр +
|
||||
счётчики: пользователи, статус, бюджет/использовано — чтение лимитов из Task 8 по мере готовности;
|
||||
на этом шаге — без лимит-полей или через Task 8-порт после него), POST /api/operator/tenants
|
||||
`{name, email?, budget?}` — email-опция создаёт сразу пользователя-владельца тенанта (иначе — через
|
||||
инвайт), PATCH /api/operator/tenants/{id} `{status: "active"|"suspended"}` (аудит tenant_status_changed),
|
||||
POST /api/operator/tenants/{id}/impersonate `{login?}` — mint сессии целевого пользователя
|
||||
(переиспользуя механизм AuthService.CreateSession), ответ `{sessionToken, expiresAt, tenantId}` +
|
||||
аудит `impersonation_started` (DetailJson: targetLogin, tenantId); завершение — logout'ом
|
||||
пользователя (документируется). Modify: `TM/Application/ITenantRepository.cs` +
|
||||
`I/Persistence/Repositories/TenantRepository.cs` — `GetByIdAsync`/`UpdateStatusAsync`;
|
||||
`TM/Application/AuthService.cs` — Login блокирует suspended-тенант (LoginResultDto получает
|
||||
опциональный `Error = "tenant_suspended"`, endpoint-текст «Учётная запись приостановлена. Обратитесь
|
||||
к оператору»). Tests: `T/TenantAdminServiceTests`-сценарии или прямо на сервисах (suspend → login
|
||||
заблокирован; impersonation: оператор ≠ тенант — сессия выдаётся пользователю тенанта, а не
|
||||
оператору; аудит-записи); IDOR-кейсы: оператор не читает settings тенанта, тенант не вызывает
|
||||
/operator (403/401 — через curl финальной задачи).
|
||||
|
||||
**Источники:** Rulings 1/4/10; TenantService/AuthService/SessionTokens; ТЗ §10 (тенанты/impersonation).
|
||||
|
||||
**Acceptance:** build 0/0; unit PASS; curl-минимум: create → suspend → login тенанта 401-текст →
|
||||
resume → login ok; impersonate → полученный токен работает как deal_session на /api/auth/me.
|
||||
Отчёт: `task-7-report.md`.
|
||||
|
||||
### Task 8: Лимиты-ядро — хранилище/период/рекордер/дефолт-бюджет
|
||||
|
||||
**Files:** Create: `TM/Application/Models/{TenantLimitDto,BudgetStateDto}.cs` (BudgetState: TenantId,
|
||||
BudgetTokens, Period, PeriodStart, UsedTokens, Status, Allowed, Warned80, NotifiedExhausted),
|
||||
`TM/Application/ITenantLimitStore.cs` (GetOrCreateAsync(tenantId, defaults), GetStateAsync, AddUsageAsync
|
||||
(инкремент + ленивый reset периода + пересчёт флагов в одной транзакции/сохранении), UpdateBudgetAsync
|
||||
(сброс флагов), TryMarkWarned/Notified), `TM/Application/TokenBudgetDefaults.cs` (DefaultBudgetTokens
|
||||
= 10_000_000, Period = month), `TM/Application/TokenBudgetService.cs` (период-математика: начало
|
||||
периода, ленивый reset, пороги 80/100), `I/Persistence/Repositories/TenantLimitStore.cs` (EF на
|
||||
DealDbContext; AddUsage — `UPDATE tenant_limits SET UsedTokens = UsedTokens + @n ...` через ExecuteSql
|
||||
не используем — читаем строку и пишем в транзакции с rowversion-семантикой: одиночный инстанс core,
|
||||
конкурентность на тенанта сериализована воркер-гейтами; фиксируем простое read-modify-write).
|
||||
Modify: `I/Integrations/AiUsageLedger.cs` → переименовать/расширить до `TokenUsageRecorder` (добавляет
|
||||
вызов ITenantLimitStore.AddUsageAsync поверх lifetime-KV `aiTokenUsage`); call-site'ы в
|
||||
`I/Integrations/GrpcAiClassifier.cs` и `I/Integrations/GrpcAiTools.cs`. Тесты: `T/TokenBudgetServiceTests.cs`
|
||||
(reset месяца/дня, пороги, дефолты), `T/FakeTenantLimitStore.cs`.
|
||||
|
||||
**Источники:** Rulings 3/4; AiUsageLedger/GrpcAiClassifier (эталон учёта); ТЗ §9; архитектура §19.
|
||||
|
||||
**Acceptance:** build 0/0; unit PASS (ленивый reset: запись с PeriodStart прошлого месяца обнуляет
|
||||
UsedTokens и ставит новый PeriodStart; порог 80% выставляет Warned80). Отчёт: `task-8-report.md`.
|
||||
|
||||
### Task 9: Бюджетный гейт ИИ + fallback-декораторы + SSE-уведомления
|
||||
|
||||
**Files:** Create: `I/Integrations/BudgetedAiClassifier.cs`, `I/Integrations/BudgetedAiTools.cs`
|
||||
(декораторы портов IAiClassifier/IAiTools: перед каждым вызовом `ITokenBudgetGate` (или
|
||||
ITenantLimitStore.GetStateAsync + TokenBudgetService) — исчерпано/suspended → Local-реализации
|
||||
(классификатор/фильтр) или `AiUnavailableException` (инструменты); gRPC-адаптеры не меняются),
|
||||
`A/Hosting/BudgetAlertScheduler.cs` (60 с, per-tenant: GetState → переход 80/100% → SseBroker-тост +
|
||||
TryMarkWarned/Notified; сброс флагов при смене бюджета уже в Task 8). Modify: `I/Integrations/
|
||||
ServiceCollectionExtensions.cs`/`AddDealIntegrations` — регистрация декораторов только при
|
||||
`Services:Ai:UseLocal=false` (порядок: Grpc → Budgeted → наружу), регистрация `TokenUsageRecorder`,
|
||||
`TokenBudgetService`, `ITenantLimitStore` (scoped), BudgetAlertScheduler в `A/Program.cs`. Тесты:
|
||||
`T/BudgetedAiClassifierTests.cs` (лимит 0 → Local-ветка; лимит большой → gRPC-фейк вызван;
|
||||
suspended → Local), `T/BudgetedAiToolsTests.cs` (исчерпано → AiUnavailableException), тест
|
||||
`BudgetAlertScheduler`-логики на фейках (тост один раз на порог).
|
||||
|
||||
**Источники:** Rulings 3/5/7/11; LocalAiClassifier/LocalAiTools/AiUnavailableException (эталон
|
||||
fallback); StorageTickScheduler/SseBroker (эталон тостов); ТЗ §9.
|
||||
|
||||
**Acceptance:** build 0/0; unit PASS. Отчёт: `task-9-report.md`.
|
||||
|
||||
### Task 10: Оператор-лимиты/usage/health — эндпоинты
|
||||
|
||||
**Files:** Create: `A/Endpoints/OperatorLimitsEndpoints.cs` (GET /api/operator/limits — сводка по всем
|
||||
тенантам `{items:[{tenantId, name, budget, period, used, percent, status}]}`; GET/PATCH
|
||||
/api/operator/tenants/{id}/limit — просмотр/смена `{budget?, period?}`; PATCH сбрасывает
|
||||
Warned80/NotifiedExhausted; аудит tenant_limit_changed), `A/Endpoints/OperatorHealthEndpoints.cs`
|
||||
(GET /api/operator/health: core+БД (`SELECT 1` через DealDbContext) + gRPC-health ml/ai/telegram по
|
||||
`Services:*:Endpoint` через `Grpc.HealthCheck`-клиента; при UseLocal=true — `{reachable:false,
|
||||
mode:"local"}`), `I/Integrations/ServiceHealthProbe.cs` (gRPC health-проба с таймаутом 3 с, клиентские
|
||||
сертификаты из Ruling 6-конфига). Tests: `T/ServiceHealthProbeTests.cs` (in-proc health-сервер фейк),
|
||||
хелперы percent-расчёта.
|
||||
|
||||
**Источники:** Rulings 3/9/11; DiscoveryEndpoints (формат items), техдок §8 (health); ТЗ §10 (health,
|
||||
лимиты).
|
||||
|
||||
**Acceptance:** build 0/0; unit PASS; curl: GET/PATCH лимита оператором (psql-проверка строки),
|
||||
health-эндпоинт 200 (в dev Local-режиме сервисы помечены local). Отчёт: `task-10-report.md`.
|
||||
|
||||
### Task 11: Rate limiting (приложение + gRPC-ингресс) и защита входа
|
||||
|
||||
**Files:** Create: `A/Configuration/RateLimitOptions.cs` (Enabled, AuthPerMinute=10, ApiPerMinute=600,
|
||||
GrpcIngressPerMinute=600, LoginAttemptsMax=5, LoginAttemptWindowMin=15), `A/Middleware/
|
||||
RateLimitPolicies.cs` (AddRateLimiter: политики `auth` — fixed window по IP, `api` — по
|
||||
`CurrentUser.TenantId`/IP анонима; OnRejected → 429 `{detail:"Слишком много запросов. Повторите
|
||||
позже"}`), `A/Http/LoginAttemptGuard.cs` (in-memory окно `ip|login`, блок 15 мин после 5 неудач,
|
||||
сброс при успехе), `A/Telegram/IngressRateLimitInterceptor.cs` (gRPC: фиксированное окно по
|
||||
metadata tenant-id, health-метод освобождён). Modify: `A/Program.cs` — `AddRateLimiter` (если
|
||||
Enabled), порядок middleware (Session → Operator → RateLimiter), RequireRateLimiting на группах
|
||||
auth/operator/auth; `A/Endpoints/AuthEndpoints.cs`/`OperatorAuthEndpoints.cs` — вызов
|
||||
LoginAttemptGuard до AuthService; `A/Program.cs` Kestrel-gRPC — AddGrpc interceptor при Enabled.
|
||||
Tests: `T/LoginAttemptGuardTests.cs` (5 неудач → блок, успех сбрасывает), unit политики-ключей
|
||||
(tenant vs IP), interceptor-окно.
|
||||
|
||||
**Источники:** Rulings 5/10; IngressServiceTokenInterceptor (эталон); техдок §8/§10 (rate limit).
|
||||
|
||||
**Acceptance:** build 0/0; unit PASS; dev-прогон не режет curl-приёмки (Enabled=false). Отчёт:
|
||||
`task-11-report.md`.
|
||||
|
||||
### Task 12: Безопасность — Origin-проверка, security-заголовки, CORS-allowlist
|
||||
|
||||
**Files:** Create: `A/Configuration/SecurityOptions.cs` (AllowedOrigins string[]), `A/Middleware/
|
||||
OriginGuardMiddleware.cs` (не-GET/HEAD/OPTIONS и есть Origin → Origin ∈ {Host} ∪ AllowedOrigins, иначе
|
||||
403), `A/Middleware/SecurityHeadersMiddleware.cs` (X-Content-Type-Options/X-Frame-Options/
|
||||
Referrer-Policy). Modify: `A/Program.cs` — порядок middleware и регистрация (после RateLimiter),
|
||||
CORS-политика: при пустом AllowedOrigins — dev-режим «любой» (текущий), при непустом — строгий
|
||||
allowlist+credentials (для PROD). Tests: `T/OriginGuardTests.cs` (совпадение Host ok, чужой Origin →
|
||||
403, allowlist ok, GET без Origin ok), headers-присутствие (in-proc host или unit на делегате).
|
||||
|
||||
**Источники:** Rulings 10; архитектура §8 (CSRF/XSS/headers); техдок §10 (прокси-заголовки — теперь
|
||||
и кодом).
|
||||
|
||||
**Acceptance:** build 0/0; unit PASS; curl: мутация с `Origin: http://evil` → 403, без Origin → ok.
|
||||
Отчёт: `task-12-report.md`.
|
||||
|
||||
### Task 13: mTLS — флаг/сертификаты в 4 процессах + скрипт генерации
|
||||
|
||||
**Files:** Create: `scripts/mtls-certs.sh` (openssl: CA + серверные PFX для core/telegram/ai/ml +
|
||||
клиентский сертификат deal-client; SAN: localhost + имена compose-сервисов; вывод в
|
||||
`deploy/certs/`), в каждом процессе класс `MtlsOptions` (Enabled/ServerCertPfx/ServerCertPassword/
|
||||
ClientCertPfx/ClientCertPassword/CaPem; env `DEAL_MTLS_*`) и его применение: Modify: `TG/Deal.Telegram/
|
||||
Program.cs`, `AI/Deal.Ai/Program.cs`, `ML/Deal.Ml/Program.cs` (Kestrel gRPC-endpoint: `UseHttps(serverPfx,
|
||||
opts => opts.ClientCertificateMode = RequireCertificate; opts.ClientCertificateValidation = цепочка на
|
||||
CaPem)`; исходящий канал в core-ингресс — клиентский сертификат), `A/Program.cs` (ингресс :5082 —
|
||||
аналогично) и `I/Integrations/*GrpcConnection.cs` (каналы: HttpClientHandler с клиентским
|
||||
сертификатом + проверка CA, только при Enabled). Dev-дефолт неизменен (plaintext). Тесты: unit на
|
||||
опции/загрузку сертификата из файла (тестовые PFX генерируются в тесте скриптом? нет — фиктивные
|
||||
сертификаты через `CertificateRequest` в памяти). Живое mTLS-рукопожатие между контейнерами —
|
||||
⚠ Manual.
|
||||
|
||||
**Источники:** Rulings 6; этап 6 Ruling 2 (рамка dev/prod); техдок §8/§10; архитектура §8.
|
||||
|
||||
**Acceptance:** build 0/0 (все sln); `sh -n scripts/mtls-certs.sh`; unit PASS; PROD-compose-файл
|
||||
ссылается на env mTLS (Task 14). Отчёт: `task-13-report.md`.
|
||||
|
||||
### Task 14: Observability + compose.prod (Caddy/Loki/Promtail/Grafana)
|
||||
|
||||
**Files:** Create/Modify: Serilog — `A/Program.cs`, `TG|AI|ML/.../Program.cs` (Serilog JSON console +
|
||||
rolling file `data/logs/`; конфиг из appsettings/env; секреты не логируются), csproj'ы + пакеты.
|
||||
Create: `PROD` (postgres/minio/core/3 сервиса по compose.dev.yml-образцу, но: без host-портов у
|
||||
хранилищ, mTLS-env из Ruling 6, rate-limit/CORS/куки-Secure-флаги, `depends_on`-healthcheck'и,
|
||||
frontend-сборка — из `src/frontend/dist` volume, комментарий), `deploy/caddy/Caddyfile`
|
||||
(80/443, `tls internal`, статика dist, `reverse_proxy /api/* core:5080`, security-заголовки,
|
||||
CSP-комментарий), `deploy/observability/promtail.yml` (docker_sd, labels, loki-адрес),
|
||||
`deploy/observability/loki.yml` (local-storage, retention 7d), `deploy/observability/grafana/
|
||||
{datasources.yml, dashboards/Deal-Health.json}` (Loki-датасорс, минимальный health/лог-дашборд),
|
||||
`deploy/.env.prod.example` (все секреты БЕЗ значений-дефолтов). Modify: техдок §7/§8 (актуализация
|
||||
под реальные файлы) — в Task 16 (доки). Acceptance-без-docker: `docker compose -f PROD config` rc=0
|
||||
(если docker CLI недоступен — ⚠ Manual). Живой подъём PROD-стека — ⚠ Manual.
|
||||
|
||||
**Источники:** Rulings 6/7/9; compose.dev.yml (эталон); техдок §7/§8/§10; архитектура §9/§10.
|
||||
|
||||
**Acceptance:** build 0/0 всех sln; старт Api (dev, без docker) показывает JSON-логи в консоли/файле;
|
||||
`PROD config` валиден. Отчёт: `task-14-report.md`.
|
||||
|
||||
### Task 15: Бэкапы — scripts/backup.sh + документация восстановления
|
||||
|
||||
**Files:** Create: `scripts/backup.sh` (Ruling 8: pg_dump -Fc через compose exec; mc mirror MinIO или
|
||||
minio/mc-контейнер; tar volume'ов сессий/ML/core-data через busybox-контейнер; retention 14;
|
||||
имена `backup-<ts>.*`; trap-очистка; заголовок с примером systemd-timer/cron; exit non-zero при
|
||||
сбое любого шага), `docs/technical/...` §9 — раздел «Восстановление» (шаги pg_restore/распаковка
|
||||
volume/перезапуск; тест восстановления раз в месяц) — в Task 16. Acceptance: `sh -n scripts/backup.sh`;
|
||||
прогон скрипта и restore-тест — ⚠ Manual (нужен docker-стек PROD/DEV).
|
||||
|
||||
**Источники:** Rulings 8; техдок §9 (текущий текст — основа); архитектура §18 (ежедневные бэкапы).
|
||||
|
||||
**Acceptance:** `sh -n` rc=0; скрипт покрывает 4 источника данных из Ruling 8; retention-логика
|
||||
читаема. Отчёт: `task-15-report.md`.
|
||||
|
||||
### Task 16: Финал — доки, сквозная SaaS-приёмка, полный прогон
|
||||
|
||||
- **Доки:** техдок — §11 (TODO-сводка: закрыть пункты этапа 7, оставить только реальные заделы:
|
||||
OTel-метрики, multi-instance rate-limit, мгновенный разлогин suspended, ML-экспорт, reclassify на
|
||||
реальном ИИ, мультиаккаунтность, k8s/биллинг/саморегистрация/UI-админки), новый блок §13.8 (этап 7:
|
||||
оператор/инвайты/лимиты/аудит/rate-limit/mTLS/бэкапы/compose-prod — быстрый старт оператора),
|
||||
§7/§8/§9/§10 актуализируются по ходу (compose.prod, бэкапы-restore, Serilog/Loki, mTLS-флаги,
|
||||
Origin/заголовки, ограничения in-memory guard); api-map — раздел «Этап 7 (API-only, фронт не
|
||||
вызывает)»: /api/operator/* + /api/join (формы/ответы); roadmap — этап 7 «Выполнено» (ограничения
|
||||
→ заделы), «Открытые точки» — закрыть п.2 (инвайты/оператор реализованы, dev-seed остаётся dev-only);
|
||||
STATUS.md — строка этапа 7 ✅, проценты, «Итого»; `docs/user-guide/Инструкция-пользователя-Дейл.md` —
|
||||
раздел «Регистрация по приглашению» (как оператор пришлёт, как активировать, что такое бюджет ИИ и
|
||||
fallback-уведомление).
|
||||
- **Сквозная SaaS-curl-приёмка** (dev-stack, Postgres; без docker-сервисов — AI в Local-режиме,
|
||||
бюджет-сценарий проверяется через Local-счётчики/прямые вызовы; полный стек с сервисами —
|
||||
⚠ Manual после поднятия Docker, `sh scripts/dev-smoke.sh` + бюджет-прогон): оператор login →
|
||||
создать тенанта → инвайт → /api/join → вход тенанта → работа /api (me/settings) → оператор:
|
||||
лимит-бюджет мал → симуляция ИИ-вызова (через recorder) → fallback-декоратор (Local-ветка) →
|
||||
тост-флаг в tenant_limits → аудит-лента (входы/инвайты/impersonation) → suspend → login 401 →
|
||||
resume → IDOR-негативы (тенант на /operator → 401, чужой tenantId в /operator-фильтрах не отдаёт
|
||||
чужие данные, чужой инвайт-код/email → 400).
|
||||
- **Полный прогон:** `scripts/build.sh` + `scripts/test.sh` (830 + новые unit PASS), build каждого
|
||||
сервисного sln 0/0; итоговые числа в отчёт.
|
||||
- Отчёт `task-16-report.md` + финальная строка `progress.md`.
|
||||
|
||||
**Источники:** Rulings 1–11; все предыдущие задачи; паттерны финальных задач этапов 1–6.
|
||||
|
||||
**Acceptance:** пункты выше; живые проверки (docker-стек, mTLS, бэкап, реальные LLM/Telegram) —
|
||||
⚠ Manual и помечены в отчёте.
|
||||
|
||||
## Self-Review
|
||||
|
||||
1. **Spec coverage:** оператор/роли/изоляция — Rulings 1, Task 2/3/7; инвайты + invite-only +
|
||||
email-unique — Rulings 2, Task 5/6 (ТЗ §3); лимиты-бюджеты/fallback/уведомление — Rulings 3,
|
||||
Task 8/9 (ТЗ §9); админка (тенанты/статусы/лимиты/health/impersonation/аудит/подозрительная
|
||||
активность) — Rulings 4/11, Task 4/5/7/10 (ТЗ §10; «подозрительная активность» = операторский
|
||||
фильтр по audit eventType login_failed, документируется); rate limiting + попытки входа —
|
||||
Ruling 5, Task 11; mTLS/service-token — Ruling 6, Task 13; observability (Serilog+Loki+Grafana,
|
||||
минимально) — Ruling 7, Task 14; бэкапы — Ruling 8, Task 15; compose-prod — Ruling 9, Task 14;
|
||||
безопасность-доработки (Origin/headers/IDOR/приостановка) — Ruling 10, Task 7/12 (+IDOR-кейсы в
|
||||
Task 5–7, сквозные в Task 16); финальные доки/приёмка — Task 16. Решения владельца учтены: dev-seed
|
||||
admin/admin остаётся dev-only (Ruling 1), «ELF — B» = Loki+Promtail+Grafana (Ruling 7), «Админка А»
|
||||
= API-контур оператора без фронта (Rulings 1/11), бэкапы раз в сутки (Ruling 8), лимиты в токенах с
|
||||
fallback (Ruling 3), «compose, k8s отложен» (Ruling 9).
|
||||
2. **Placeholder scan:** TODO/«позже сделать» не закладывается внутрь задач; осознанно вынесено за
|
||||
этап (см. п.4). AiUsageLedger не «висит» дублирующим механизмом — он становится TokenUsageRecorder
|
||||
с той же точкой вызова (Ruling 3). Fallback-семантика переиспользует существующие Local-реализации,
|
||||
новых «заглушек» не появляется. OpenAPI-карта операторских ручек фиксируется в api-map (Task 16),
|
||||
отдельной спеки не создаём.
|
||||
3. **Type consistency:** все новые порты — в модуле Tenants (`IOperatorAuthStore`/`IInviteStore`/
|
||||
`ITenantLimitStore`/`IAuditLogStore`), адаптеры — `I/Persistence/Repositories/*` (регистрация в
|
||||
AddDealPersistence), сервисы — `TM/Application/*` (реестр AddTenantsModule расширяется в Task 3);
|
||||
декораторы бюджета реализуют **существующие** порты IAiClassifier/IAiTools и регистрируются
|
||||
последними в AddDealIntegrations (внешний контракт для PL/Discovery не меняется); изменения
|
||||
AuthService/LoginResultDto — обратносовместимы (опциональное поле); TenantBootstrapService меняет
|
||||
только условие создания дефолтного тенанта (dev/prod), провижининг — всегда. Циклов ссылок нет:
|
||||
TM не знает Api/Infrastructure, Infrastructure оркестрирует, Api вызывает сервисы модуля и шлёт SSE.
|
||||
4. **Вне scope этапа 7 (заделы):** UI операторской админки и UI активации (API-only + curl);
|
||||
OTel-метрики/Prometheus и дашборды метрик (задекларировано, Ruling 7); multi-instance rate-limit и
|
||||
бэкенд для попыток входа (in-memory, один инстанс); мгновенный разлогин suspended-сессий;
|
||||
экспорт/импорт ML-моделей; reclassify на реальном ИИ; мультиаккаунтность Telegram на тенанта;
|
||||
биллинг-провайдер/планы; k8s/Cloudflare-конфигурация; purge/retention-автоматика audit_log;
|
||||
auto-purge tenant_limits-истории. Все перечислены в техдок §11 (Task 16).
|
||||
|
||||
⚠ **Manual-пункты этапа (требуют docker/живых кред):** применение system-миграции и curl-приёмки без
|
||||
поднятого `deal-postgres` невозможны (Postgres — контейнер dev-stack, поднимается по требованию);
|
||||
живой подъём `compose.prod.yml` и `dev-smoke.sh`-прогон полного стека с сервисами (Task 14/16);
|
||||
mTLS-рукопожатие между контейнерами (Task 13); реальный прогон `scripts/backup.sh` и restore-тест
|
||||
(Task 15); реальные LLM/Telegram-проверки — с кредами (вне этапа, как и в этапе 6).
|
||||
@@ -0,0 +1,71 @@
|
||||
# Дейл (Deal) — Этап 9: единая карточка (unified card) Implementation Plan
|
||||
|
||||
> Исторический документ этапа 9. Актуальное состояние — `docs/superpowers/STATUS.md` и `docs/technical/Техническая-документация-Дейл.md`.
|
||||
|
||||
**Goal:** Устранить дуальность «карточка канбана / проектная карточка». Одна сущность **карточка**
|
||||
(ядро id/title/source + опциональные модули) работает во всех дашбордах; «лид» как понятие и
|
||||
`ProjectCards`-дублирование упраздняются; колонки/стадии/зоны — единый контейнер с политиками.
|
||||
Бэк (C#) и фронт (Vue) переписываются на единую модель; данные тестовые, схема пересоздаётся.
|
||||
|
||||
**Spec:** `docs/architecture/2026-09-09-unified-card.md`; ТЗ: `docs/spec/ТЗ-дейл-новая-архитектура.md`
|
||||
(термины §2, карточка §5.5, канбаны §6); код: модули Kanban/Projects/Pipeline, Deal.Infrastructure
|
||||
(миграции/адаптеры), Deal.Api (LeadsEndpoints/ProjectsEndpoints/PipelineEndpoints), фронт
|
||||
`store/{leads,projects}.js`, компоненты LeadCard/ProjectCard/LeadDrawer/ProjectDrawer/Column/ProjectColumn.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Проект **НЕ git**; фиксация — отчёты `task-N-report.md` и `progress.md` в `.superpowers/sdd/deal-stage9-unified-card/`.
|
||||
- .NET 10; sln собираются 0 warnings/0 errors; dev-Postgres `deal-postgres` (:5433); системные миграции —
|
||||
`dotnet ef database update --context DealDbContext` из `src/core`; tenant-миграции — провижинер на старте.
|
||||
- Код-стайл: 1 тип = 1 файл; XML-doc на public; русские комментарии; без регионов; без магических чисел;
|
||||
времена `DateTimeOffset` (UTC); JSON camelCase; ошибки API — `{detail}`.
|
||||
- Фронт: Vue 3 + чистый JS, без TS/роутера; Composition API; `npm run build` зелёный после каждого шага.
|
||||
- Тесты: core `Deal.Tests.Unit` (1139), telegram 118, ai 52, ml 38 — прогон после каждой фазы.
|
||||
- Секреты — только env (`DEAL_*`).
|
||||
- Вне рамок: Kafka, k8s, саморегистрация, «третий» дашборд (архитектура готова, реализация — позже).
|
||||
|
||||
## Ключевые решения (Rulings этапа)
|
||||
|
||||
- **R1 — единый агрегат карточки.** Ядро `Card { Id, Title, Source }`; модули-роли (контент, бюджет,
|
||||
контакты, атрибуты, комментарии, ссылки, файлы, ТЗ, история, напоминание, размещение) — опциональные
|
||||
части агрегата (jsonb/колонки одной таблицы), а не классы-наследники. Вид = композиция модулей.
|
||||
- **R2 — Source.** `ISource` + варианты: Local/Web/File/Telegram/Row/Api/Ai/Composite (Origin+Pipeline).
|
||||
У карточки из пайплайна — `Composite(Origin: Telegram, Pipeline: [Ai/ML])`.
|
||||
- **R3 — единый контейнер.** Одна таблица/реестр контейнеров (kind: inbox/board/stage/archive/trash/
|
||||
terminal), политики — роли (`IContainerPolicy`), не enum-свойства. Стадии «Выбранных» — контейнеры
|
||||
kind=stage (предзаданный каталог), доски — kind=board (создаёт пользователь/ИИ).
|
||||
- **R4 — переход.** Один `ICardMover.MoveAsync(card, toContainerId, ctx)`; «взять в работу» = переход в
|
||||
контейнер planned той же карточки (никакого `col=taken` + клона в ProjectCards); «Выбранные → архив/
|
||||
корзина дашборда» запрещено политикой пространства; терминальные зоны — политика.
|
||||
- **R5 — API.** `/api/cards` + `/api/containers` (единый контракт); `/api/leads`, `/api/projects`
|
||||
упраздняются; фронт переписывается. SSE-события переходят на карточки.
|
||||
- **R6 — пайплайн.** Создаёт карточку (не «лид»): `CardComposer` → `ICardStore.Add`; дедуп/отсев/ML/ИИ
|
||||
не знают «лидов». Названия в коде/БД: lead→card, project card→card in stage-container.
|
||||
|
||||
## Задачи этапа
|
||||
|
||||
- **T1. Доменные контракты единой карточки (C#)** — модуль Cards: ICard/ICard<TSource>, ISource-иерархия,
|
||||
модули-роли, IContainer/IContainerPolicy, ICardMover; реестры (контейнеры по умолчанию, стадии,
|
||||
SourceKind). Без изменения поведения текущих модулей (новые типы + тесты чистых правил).
|
||||
- **T2. EF-модель и миграция** — одна таблица `Cards` (общие поля + jsonb-модули + source + container_id),
|
||||
таблица `Containers` (доски/стадии/зоны), удаление ProjectCards/LeadComments-дублей; системная и
|
||||
tenant-миграции; провижининг контейнеров по умолчанию.
|
||||
- **T3. Адаптер ICardStore** — единый EF-адаптер (слияние KanbanStore/ProjectStore), чтение/запись
|
||||
карточки целиком (jsonb-модули), контейнеры, атомарные append (комментарии/ссылки/файлы), move с
|
||||
историей/напоминаниями.
|
||||
- **T4. Сервисы карточек/контейнеров** — CardsService (переходы, правила колонок, обучение ML),
|
||||
ContainersService (CRUD колонок, принятие ИИ-предложений, reorder), перенос логики Projects
|
||||
(файлы/ТЗ/напоминания/история) в модули карточки.
|
||||
- **T5. Pipeline** — создание карточки через ICardStore; терминология; дедуп на карточку.
|
||||
- **T6. API единый** — `/api/cards` и `/api/containers`; SSE; удаление старых ручек; интеграционные
|
||||
тесты/curl-приёмка.
|
||||
- **T7. ML-сервис/контракты** — обучение на действиях с карточками (колонки/стадии едино), без «lead».
|
||||
- **T8. Фронт: store** — единый слайс карточек/контейнеров вместо leads.js+projects.js; API-клиент.
|
||||
- **T9. Фронт: компоненты** — единые LeadCard-база→Card, Column/ProjectColumn→ContainerColumn,
|
||||
LeadDrawer/ProjectDrawer→CardDrawer; экраны Дашборд/«Выбранные» — один канбан по пространству.
|
||||
- **T10. Финал** — сквозная приёмка, доки (ТЗ/техдок/api-map), чистка, ledger.
|
||||
|
||||
## Порядок и зависимости
|
||||
|
||||
T1 → T2 → T3 → (T4, T5) → T6 → T7 → (T8, T9) → T10. Каждая задача завершается зелёной сборкой и
|
||||
прогоном тестов; API-контракт меняется один раз на T6 (до этого новые типы живут рядом со старыми).
|
||||
@@ -0,0 +1,60 @@
|
||||
# Дейл (Deal) — Этап 10: оператор-консоль, аналитика и аудит действий
|
||||
|
||||
> Исторический документ этапа 10. Актуальное состояние — `docs/superpowers/STATUS.md` и `docs/technical/Техническая-документация-Дейл.md`.
|
||||
|
||||
**Goal:** закрыть SaaS-контур снаружи: UI операторской админки и страница активации инвайта; сквозной
|
||||
аудит (входы/выходы/действия пользователей); аналитика расхода токенов; дашборды по логам (ELK/Loki).
|
||||
|
||||
**Контекст:** этапы 0–9 завершены. Операторский API уже есть (`/api/operator/*`: auth, tenants, invites,
|
||||
limits, audit, health; `/api/join`), но **UI отсутствует**. Аудит (`public.audit_log`, append-only) покрывает
|
||||
SaaS-события (входы, инвайты, тенанты, лимиты, impersonation), но **не покрывает выходы и действия
|
||||
тенант-пользователей**. Расход токенов хранится агрегатом (`public.tenant_limits.UsedTokens`), **истории нет**.
|
||||
|
||||
## Решения этапа
|
||||
|
||||
- **A1. Роутинг фронта.** Проект без vue-router. Ввести минимальный hash-роутер: `#/` — основное
|
||||
приложение (как сейчас), `#/operator` — консоль, `#/join?code=…` — активация инвайта. Без новых зависимостей.
|
||||
- **A2. Аудит — единая точка.** Только `AuditService` пишет в `public.audit_log` (append-only).
|
||||
Действия тенант-пользователей пишутся оттуда же (actor=tenant). Секреты не логируются.
|
||||
- **A3. Расход токенов — событийная история.** Новая таблица `public.token_usage_events`
|
||||
(time-series: тенант, время, провайдер, модель, вид (ai|ml), токены). Агрегат `tenant_limits`
|
||||
остаётся для гейта; история — для аналитики.
|
||||
- **A4. Аналитика — операторские read-only эндпоинты** под `/api/operator/analytics/*`; никаких
|
||||
изменений существующих контрактов (только расширение `/api/operator/audit` пагинацией/фильтром actorId).
|
||||
- **A5. ELK.** Логи структурированы Serilog JSON. Аналитика по логам — Grafana/Loki: provisioning
|
||||
datasource + дашборды (входы/выходы/неудачные входы, ошибки, RPS, действия).
|
||||
|
||||
## Задачи
|
||||
|
||||
- **T1. Аудит действий (бэк).** Дополнить `AuditEvents`: `tenant_logout`, `operator_logout`,
|
||||
`invite_joined` (активация/join), действия карточек (`card_created`, `card_moved`, `card_trashed`,
|
||||
`card_restored`, `card_deleted`, `card_comment_added`), контейнеры (`container_created`,
|
||||
`container_updated`, `container_deleted`), настройки (`settings_updated`), каналы
|
||||
(`channel_enabled`/`channel_created`), Telegram (`telegram_linked`). Записать в соответствующих
|
||||
сервисах/эндпоинтах (без секретов). Войти обязаны: logout тенанта и оператора.
|
||||
- **T2. История расхода токенов (бэк).** Таблица `public.token_usage_events` + EF-конфигурация +
|
||||
системная миграция. Запись события в точке списания токенов (AI- и ML-путь). Порт для чтения
|
||||
агрегатов/серий.
|
||||
- **T3. Аналитика (бэк).** `/api/operator/analytics/overview`, `/tokens`, `/activity`; расширить
|
||||
`/api/operator/audit` (offset/пагинация, actorId, total). Контракт:
|
||||
`docs/architecture/2026-09-10-operator-analytics-contract.md`.
|
||||
- **T4. Оператор-консоль (фронт).** Hash-роутер; экраны: вход оператора, тенанты (список/создать/
|
||||
suspend/resume/impersonate), инвайты (создать/отозвать/ссылка), лимиты (список/правка), аудит-лента
|
||||
(фильтры/пагинация), аналитика (обзор/токены/действия).
|
||||
- **T5. Страница активации (фронт).** `#/join?code=…` → форма (email/имя/пароль) → `POST /api/join`.
|
||||
- **T6. Наблюдаемость (ELK/Loki).** Grafana provisioning (datasource Loki + дашборды), promtail-лейблы;
|
||||
дашборды: входы/выходы/неудачные входы, ошибки 5xx, RPS, действия пользователей.
|
||||
- **T7. Приёмка/доки.** Сквозная проверка (operator → tenant → invite → join → действия → аудит/аналитика),
|
||||
обновить `docs/api`, `docs/technical`, `docs/user-guide`, `docs/superpowers/STATUS.md`.
|
||||
|
||||
## Границы
|
||||
|
||||
- Kafka/k8s/биллинг/саморегистрация — вне рамок.
|
||||
- Реальные Telegram/LLM-креды — не требуются (аналитика токенов наполняется на любых AI/ML-вызовах).
|
||||
- Данные тестовые; схема system (`public`) расширяется одной миграцией.
|
||||
|
||||
## Порядок
|
||||
|
||||
T1+T2+T3 (бэк, контракт) → T4+T5 (фронт по контракту) → T6 (наблюдаемость, параллельно) → T7 (приёмка).
|
||||
|
||||
Каждая задача: `dotnet build Deal.sln` 0/0, core-тесты зелёные, `npm run build` зелёный.
|
||||
@@ -0,0 +1,71 @@
|
||||
# Дейл (Deal) — Этап 11: Локализация интерфейса (i18n)
|
||||
|
||||
> Исторический документ этапа 11. Актуальное состояние — `docs/superpowers/STATUS.md` и `docs/technical/Техническая-документация-Дейл.md`.
|
||||
|
||||
> Статус: план (не начат). Требование владельца от 2026-09-10.
|
||||
> Связанные документы: `docs/superpowers/plans/2026-09-05-deal-roadmap.md` (Этап 11),
|
||||
> `docs/spec/ТЗ-дейл-новая-архитектура.md` (§11, локализация), `docs/superpowers/STATUS.md` (Заделы).
|
||||
|
||||
## Цель
|
||||
|
||||
Весь интерфейс — на русском; **все** пользовательские тексты вынесены в ресурсы (словари), чтобы
|
||||
можно было добавлять новые языки и менять язык **на лету**. Русский — язык по умолчанию.
|
||||
|
||||
## Требования
|
||||
|
||||
- **Русский по умолчанию.** Все видимые строки UI: экраны, кнопки, подписи, заголовки, пустые состояния,
|
||||
подсказки, тултипы, тексты подтверждений, уведомления/тосты, страницы оператора и активации инвайта.
|
||||
- **Без хардкода.** Ни одна пользовательская строка не хранится в компонентах/шаблонах напрямую —
|
||||
только ключ в словаре. Технические строки (id/ключи/логи) не локализуются.
|
||||
- **Ошибки API.** Ответы бэка остаются `{detail}` + HTTP-код; фронт показывает локализованный текст по
|
||||
коду/ключу ошибки (расширяемый словарь ошибок). При необходимости бэк отдаёт код ошибки, а не только текст.
|
||||
- **Переключение на лету.** Смена языка без перезагрузки страницы; выбранный язык сохраняется
|
||||
(localStorage/настройки пользователя) и восстанавливается при входе.
|
||||
- **Расширяемость.** Новый язык = новый файл словаря (+ регистрация), без правок компонентов.
|
||||
- **Форматирование.** Даты/время/числа/валюты — через i18n-форматтеры; плюрализация — по правилам языка.
|
||||
Бэкенд-форматирование human-меток («только что», «N мин») — перевести на клиентские форматтеры или ключи.
|
||||
- **Ключи.** Стабильные, сгруппированные по областям (`nav/`, `cards/`, `settings/`, `operator/`, `errors/`…).
|
||||
Отсутствующий ключ в языке → фолбэк на русский (и, при необходимости, лог о пропуске).
|
||||
|
||||
## Область
|
||||
|
||||
- Основное приложение: дашборд, «Выбранные», настройки (все вкладки), каналы, обработка/состояние, вход.
|
||||
- Оператор-консоль (этап 10): все разделы + страница активации инвайта.
|
||||
|
||||
## Объём (по факту кода на 2026-09-10)
|
||||
|
||||
- 69 `.vue` + 20 `.js`; ~708 строковых литералов на кириллице в ~67 файлах
|
||||
(components ≈478, views ≈300, store ≈82) + текст прямо в шаблонах.
|
||||
- Области: навигация/шапка, карточки и колонки, драйвер карточки, настройки (все вкладки), каналы,
|
||||
обработка/состояние, вход, оператор-консоль (все разделы), страница активации, тосты/подтверждения.
|
||||
|
||||
## Решение владельца (2026-09-10)
|
||||
|
||||
- На этом этапе — **только русский**. Переключатель языка и второй язык — **в бэклоге**: делаем, когда
|
||||
возникнет потребность (см. «Отложено» ниже).
|
||||
- Задача этапа — **вынести все строки в ресурсы**, чтобы язык можно было добавить позже без правок компонентов.
|
||||
- Визуал и тексты — **1:1 с текущими** (вынос не меняет отображаемый текст).
|
||||
|
||||
## Задачи
|
||||
|
||||
- **T1. i18n-ядро (без тяжёлых зависимостей).** Composable/модуль: `t(key, params)`, реактивный `locale`
|
||||
(значение по умолчанию `ru`), `setLocale()` (архитектурно готов, UI-переключателя нет), загрузка
|
||||
словарей, фолбэк на ru при отсутствии ключа. `src/i18n/` + `locales/ru.js`.
|
||||
- **T2. Инвентаризация и словарь ru.** Вынести все строки в `locales/ru.js`, ключи сгруппированы по
|
||||
областям (`common/`, `nav/`, `cards/`, `drawer/`, `settings/`, `channels/`, `processing/`, `auth/`,
|
||||
`operator/`, `join/`, `errors/`). Значения — 1:1 с текущими.
|
||||
- **T3. Миграция основного приложения** на `t()` (компоненты + вьюхи + store-слайсы).
|
||||
- **T4. Миграция оператор-консоли и страницы активации** (`operator/`, `join/`).
|
||||
- **T5. Локализация ошибок/статусов.** Маппинг известных `{detail}`/HTTP-кодов и статусов на ключи
|
||||
(`errors/*`); неизвестное — как есть.
|
||||
- **T6. Проверки.** Скрипт-«линтер»: нет кириллицы в шаблонах/логике вне словарей; `npm run build` зелёный.
|
||||
- **T7. Доки и STATUS.** Инструкция/техдок: устройство i18n и как добавить язык позже.
|
||||
|
||||
### Отложено (в бэклоге — делаем при появлении потребности)
|
||||
- Переключатель языка в UI и второй язык (en) — при потребности (ядро/`registerLocale` готовы).
|
||||
- Форматтеры Intl/плюрализация — вместе с языком.
|
||||
|
||||
## Границы
|
||||
|
||||
- Машинный автоперевод не делаем — словари добавляются вручную.
|
||||
- Локализация писем/внешних уведомлений — если появятся, отдельной задачей.
|
||||
@@ -0,0 +1,46 @@
|
||||
# Дейл (Deal) — Этап 12: Наблюдаемость, устойчивость и производительность
|
||||
|
||||
> Исторический документ этапа 12. Актуальное состояние — `docs/superpowers/STATUS.md` и `docs/technical/Техническая-документация-Дейл.md`.
|
||||
|
||||
**Goal:** закрыть автономные заделы (без кредов и продуктовых решений): метрики Prometheus, распределённый
|
||||
rate-limit и инвалидация сессий, авто-очистки, перф фронта/бэка.
|
||||
|
||||
**Пакеты (порядок исполнения A → B → C → D).**
|
||||
|
||||
## Пакет A — Метрики (Prometheus + Grafana)
|
||||
|
||||
- Экспорт метрик по всем 4 процессам: HTTP/gRPC RPS, latency (p50/p95), ошибки 5xx, активные сессии,
|
||||
глубины очередей (pipeline, ML-outbox), счётчики токенов/аудита.
|
||||
- Общая обвязка для 3 сервисов — в `Deal.Grpc.Hosting`; core — в `Deal.Api`.
|
||||
- Эндпоинт `/metrics` (Prometheus-формат); сервис `prometheus` в профиле observability (`deploy/compose*.yml`),
|
||||
scrape-конфиг, Grafana-дашборды метрик + провайжининг datasource Prometheus.
|
||||
- Документация: как поднять профиль, где графики.
|
||||
|
||||
## Пакет B — Безопасность/устойчивость
|
||||
|
||||
- Распределённый rate-limit (хранилище на Postgres — без новой инфры) вместо in-memory; бэкенд учёта
|
||||
попыток входа (`LoginAttemptGuard`) на Postgres.
|
||||
- Мгновенный разлогин suspended-сессий: при suspend тенанта активные сессии перестают действовать (проверка
|
||||
статуса/инвалидация).
|
||||
- Авто-purge `audit_log` (retention, настройка/константа) и auto-purge истории `tenant_limits`.
|
||||
- Юнит-тесты + curl-приёмка в Docker.
|
||||
|
||||
## Пакет C — Производительность
|
||||
|
||||
- Фронт: вынести словарь i18n в ленивый чанк (устранить предупреждение >500 kB); пагинация/виртуализация
|
||||
длинных колонок.
|
||||
- telegram-service: LRU-кэши WTelegram (снижение памяти).
|
||||
- Механизм миграций на 1000 схем (производительность провижининга).
|
||||
- Линтер i18n включить в общий прогон `scripts/test.sh`.
|
||||
|
||||
## Пакет D — ИИ/ML без кредов
|
||||
|
||||
- `reclassify` на реальном ИИ: проводка + graceful-fallback/заглушка без кредов; тесты на Local-stub.
|
||||
- Расширение учёта токенов ML-пути (метрики/события).
|
||||
|
||||
## Границы
|
||||
|
||||
- Не входит (нужны креды/решения владельца): реальный Telegram-вход, живые LLM-вызовы, биллинг/планы,
|
||||
саморегистрация, Kafka, k8s/Cloudflare, ML export/import, переключатель языка/второй язык (в бэклоге — по потребности).
|
||||
- Каждый пакет: build 0/0, core-тесты, `npm run build`; при поднятии Docker — приёмка и **полная остановка**
|
||||
в конце (правило «без хвостов»).
|
||||
@@ -0,0 +1,35 @@
|
||||
# План: закрытие остатков код-стайла (2026-09-11, вечер)
|
||||
|
||||
> Источник: `backlog.md` — `TD-COMMENTS-IFACE` (п.3, п.4), `TD-STYLE-ANALYZERS`, найденное при проверке
|
||||
> проекта. Правила — `docs/spec/Код-стайл-Дейл.md`, отчёт — `docs/spec/Код-стайл-аудит-2026-09-11.md` §2.
|
||||
> Ограничения захода: без поднятия Docker-стека и без внешних кредов.
|
||||
|
||||
## Задачи
|
||||
|
||||
1. **Замер остатков** (dry-run, без правок): сканами по тексту и по имени члена проверить дубли
|
||||
`<summary>` реализации ↔ интерфейса; разбивку `var`; латинские комментарии; членов интерфейсов без
|
||||
дока; TODO; переводы строк по расширениям.
|
||||
2. **`var` для встроенных типов**: `.editorconfig` → `csharp_style_var_for_built_in_types = false:warning`
|
||||
(гейт ломает сборку), остаток выправить `dotnet format style --diagnostics IDE0008` по 5 решениям.
|
||||
«Очевидный тип» и «прочие» — оставить `silent` (субъективно, ~1600 замен).
|
||||
3. **Дедупликация `<summary>`→`<inheritdoc/>`**: по результатам замера — либо codemod, либо закрытие «дублей нет».
|
||||
4. **Переводы строк**: решение политики + нормализация (`.gitattributes`, `.editorconfig`, конверсия файлов,
|
||||
`git add --renormalize`); проверить, что `.sh` — LF (Linux CI).
|
||||
5. **Попутные доки/комментарии**: недостающие `<summary>` членам интерфейсов; англоязычные `//`-комментарии;
|
||||
повторный прогон `fix_private_docs.py`; устаревший блок в `STATUS.md`; трекаемые `.pyc` из индекса.
|
||||
6. **Приёмка**: build 5 sln 0/0, все тесты зелёные; обновить `backlog.md`/`STATUS.md`.
|
||||
|
||||
## Решения
|
||||
|
||||
- Явные реализации интерфейсов (§11) — **не автоматизировать**: остаётся точечным ревью владельца
|
||||
(замер: 54 интерфейса с XML-doc, 43 с реализациями; массовая правка ломает публичную поверхность классов).
|
||||
- Переводы строк — **LF** (инструменты проекта пишут LF; CRLF-.sh ломают `sh scripts/ci.sh` на Linux CI;
|
||||
большинство файлов уже LF). Откат — `git revert` нормализации.
|
||||
- Гейт `var` — только на встроенные типы: правило §4 запрет говорит про встроенные/неочевидные,
|
||||
«неочевидность» не проверяется машиной.
|
||||
|
||||
## Приёмка
|
||||
|
||||
- build 5 sln: 0 warnings / 0 errors (гейт IDE0008 проходит).
|
||||
- Тесты: core / telegram / ai / ml / storage — зелёные, счётчики в `STATUS.md`.
|
||||
- Фронт не менялся содержательно (только концы строк) — `build`/`lint:i18n` не прогонялись.
|
||||
@@ -0,0 +1,228 @@
|
||||
# Ревью качества кода «Дейл» (2026-09-08)
|
||||
|
||||
> Исторический документ этапа 8 (ревью, 2026-09-08). Актуальное состояние — `docs/superpowers/STATUS.md` и `docs/technical/Техническая-документация-Дейл.md`.
|
||||
|
||||
Многоосевое ревью (корректность/читаемость/архитектура/безопасность/производительность) бэкенда и
|
||||
фронтенда. Проводилось 5 ревьюерами по непересекающимся зонам (чтение; правок не вносилось), ключевые
|
||||
находки перепроверены по коду. Проект НЕ git. Метки: **[Critical]/[Required]/[Nit]/[Optional]**
|
||||
(Required = исправить до прода; Nit = желательно; Optional = задел).
|
||||
|
||||
## Сводка
|
||||
|
||||
| Зона | Объём | Critical | Required | Nit | Optional |
|
||||
|---|---|---|---|---|---|
|
||||
| Frontend (Vue3, JS) | 25 файлов / 11.3k LOC | 0 | 6 | 6 | 1 |
|
||||
| Core-каркас (Api/Infrastructure/Contracts) | ~370 файлов | 0 | 9 | 7 | 5 |
|
||||
| Модули Kanban/Pipeline/Projects | ~140 файлов | 0 | 10 | 5 | 2 |
|
||||
| Модули Settings/Telegram/Tenants/Discovery | ~130 файлов | 0 | 7 | 8 | 3 |
|
||||
| gRPC-сервисы (telegram/ai/ml) + proto | ~135 файлов | 0 | 8 | 8 | 4 |
|
||||
| **Итого** | **~1100 файлов** | **0** | **40** | **34** | **15** |
|
||||
|
||||
Общий вердикт: **код высокого качества** — чистая port&adapter-архитектура, 1 тип=1 файл, тенант-
|
||||
изоляция через схему на тенанта спроектирована сильно, SQL параметризован, XSS/секреты на фронте и в
|
||||
сервисах чистые. Найдено 0 критических дыр класса «ключ наружу/доступ к чужому тенанту». Ниже — что
|
||||
требует исправления и что стоит улучшить. Подробности по зонам — в рабочем журнале сессии (5 отчётов
|
||||
субагентов с file:line); здесь — консолидированный список.
|
||||
|
||||
---
|
||||
|
||||
## A. Безопасность (приоритет 1)
|
||||
|
||||
1. **[Required] SSRF через baseUrl ИИ-провайдера.** `Deal.Infrastructure/Integrations/AiConnectionChecker.cs`
|
||||
(проверка `ok:false/true`) + PATCH настроек разрешает тенанту задать произвольный `baseUrl` (в т.ч.
|
||||
`http://127.0.0.1:...` — подтверждено acceptance-логом task-6). На не-local провайдере ключ API уходит
|
||||
на указанный адрес → аутентифицированный тенант мультитенантного SaaS получает blind-сканер внутренней
|
||||
сети/метаданных. Исправить: резолв DNS + запрет private/link-local/loopback при проверке и вызове
|
||||
(или egress-фильтр); не принимать переопределение хоста для каталоговых провайдеров.
|
||||
2. **[Required] Rate-limit и анти-брутфорс выключены по умолчанию.** `Deal.Api/Program.cs` (регистрация
|
||||
лимитера), `RateLimitOptions` дефолт `Enabled=false` → без env в проде нет ни лимитов, ни
|
||||
`LoginAttemptGuard`. compose.prod форсирует `true`, но дефолт кода опасен при запуске вне compose.
|
||||
Исправить: стартовая проверка «Production ⇒ RateLimit:Enabled задан явно» (fail-closed).
|
||||
3. **[Required] CORS fail-open при пустом allowlist.** `Program.cs` (AddCors): пустой
|
||||
`Security:AllowedOrigins` = любой origin + `AllowCredentials` (задумано для dev). Исправить: в Production
|
||||
пустой список = отказ на старте; «any origin» только в Development.
|
||||
4. **[Required] Код инвайта пишется в audit_log сырым.** `JoinEndpoint.cs` — capability-токен в вечном
|
||||
аудите операторов. Исправить: не логировать код (или его SHA-256).
|
||||
5. **[Required] Пароль: минимум 4 символа.** `AuthEndpoints.cs`, `JoinEndpoint.cs`. Для публичного SaaS —
|
||||
минимум 8–10 + проверка на границе; единая константа.
|
||||
6. **[Required] Политика «ключ не перезаписывается маской» не реализована.** `SettingsService.cs`
|
||||
(aiConfigs и tgKeys): PATCH со значением-маской (например `sk-1…90ab`, ≥8 симв., без `enc:`) зашифрует
|
||||
маску и безвозвратно потеряет ключ. Комментарий «пустой/маска → не меняется» не подкреплён кодом.
|
||||
Исправить: не шифровать значение, содержащее `…` (U+2026) либо пустое; тест на roundtrip.
|
||||
7. **[Required] DDL прикладной ролью на старте и из tenant-ручки.** `TenantProvisioningService.cs`,
|
||||
`FtsMaintenance.cs` — `CREATE SCHEMA/Migrate/INDEX` на каждом старте и `/api/admin/fts/rebuild`.
|
||||
В проде это нарушение least privilege. Исправить: отдельные креды мигратора и runtime; fts-rebuild —
|
||||
операторской ручкой.
|
||||
8. **[Required] TenantId без инварианта формата.** `Deal.SharedKernel/Tenants/TenantId.cs` — значение идёт
|
||||
в Search Path строки подключения и в DDL; `new TenantId(внешняя_строка)` = connection-string-инъекция.
|
||||
Сейчас все потоки дают Guid, но тип не защищён. Исправить: конструктор от Guid / валидация 32 hex.
|
||||
9. **[Required] gRPC-сервисы: нет серверных лимитов на входные данные.** AiServiceImpl, MlServiceImpl,
|
||||
TelegramServiceImpl — контракты фиксируют лимиты («ядро обрежет»), но сервис их не enforcement:
|
||||
платные LLM-вызовы на мегабайтных промптах, гигантские SQLite-транзакции. Исправить:
|
||||
INVALID_ARGUMENT на границе + MaxReceiveMessageSize.
|
||||
10. **[Required] mTLS по умолчанию выключен — тихая деградация до plaintext.** `MtlsOptions.cs` —
|
||||
отсутствие/опечатка env молча даёт plaintext+только service-token. Исправить: fail-closed для
|
||||
Production (или warn-on-startup) как для session-ключа.
|
||||
11. **[Required] Инвайт: не проверяется существование/статус тенанта.** `JoinService.cs` — активация по
|
||||
«битому» инвайту даёт FK-500 или пользователя на несуществующем тенанте.
|
||||
12. **[Required] AddUsageAsync не атомарно.** `ITenantLimitStore.cs` — read-modify-write теряет списания
|
||||
при параллельных ИИ-вызовах. Исправить: `UPDATE ... SET Used=Used+@n`.
|
||||
13. **[Required] Echo-маска: секрет ≤8 символов отдаётся как есть.** `SettingsService.Mask` — маскировать
|
||||
всегда (кроме пустого).
|
||||
|
||||
## B. Корректность / потеря данных (приоритет 2)
|
||||
|
||||
14. **[Required] Потеря данных при параллельных мутациях JSON-массивов проектной карточки.**
|
||||
`ProjectsService.cs` (add_comment/add_link/remove_link), `ProjectFilesService.cs`: комментарии/ссылки/
|
||||
файлы дописываются «read → PATCH полной заменой массива» без версии/транзакции; double-click теряет
|
||||
запись. Исправить: append одним SQL (`jsonb ||`/`array_append`) или optimistic concurrency по `updated_at`.
|
||||
15. **[Required] Коллизия objectKey файла.** `ProjectFilesService.cs` — «проект/карточка/мс_имя»: две
|
||||
загрузки в одну мс = перезапись объекта. Исправить: случайный суффикс / id записи в ключе.
|
||||
16. **[Required] Дедуп-pump не атомарен.** `PipelineWorkerService.cs` — Exists→Claim→create без проверки
|
||||
результата claim — два конкурентных прохода создадут две карточки. Исправить: повторный Exists/
|
||||
проверка результата Claim перед созданием.
|
||||
17. **[Required] Move из trash/archive на доску минует снятие спам-сигнала.** `CardsService.cs` —
|
||||
валидируется только цель; «spam +1» не снимается (unlearn только в restore). Исправить: запрет исхода
|
||||
из archive/trash/taken в MoveLeadAsync (или симметричный unlearn).
|
||||
18. **[Required] Параллельные пустые `catch { }` в модулях Telegram/Discovery** — сбои зеркала/превью/
|
||||
backfill невидимы (ILogger в модулях не используется). Исправить: логировать.
|
||||
19. **[Required] ChangePassword (фронт) шлёт захардкоженный oldPassword='admin'.** `store.js`,
|
||||
`SettingsView.vue` — после смены пароля повторная смена невозможна, и пароль живёт в реактивном state.
|
||||
Исправить: поле «текущий пароль», не хранить пароль в store.
|
||||
20. **[Required] boot() роняет всё приложение одним сбоем** (фронт). `store.js`: параллельные get без
|
||||
.catch — падение /api/rates (например) = toast «Сервер недоступен» + разлогин. Исправить:
|
||||
необязательные секции в индивидуальные .catch; разлогин только при 401.
|
||||
21. **[Required] applySettings затирает несохранённые промпты** (фронт). `store.js` — автосейв тумблера
|
||||
применяет полный ответ и перезаписывает textarea промптов. Исправить: применять только запатченные ключи.
|
||||
22. **[Required] Гонки устаревших ответов поиска** (фронт). `store.js` — старый ответ может перетереть
|
||||
свежий/очищенный. Исправить: seq-токен/AbortController.
|
||||
23. **[Required] DeleteExpiredSessionsAsync на каждое разрешение сессии.** `AuthService.cs`,
|
||||
`OperatorAuthService.cs` — глобальный DELETE по public-таблицам в hot-path каждого запроса.
|
||||
Исправить: фоновый цикл или «с вероятностью N%»/логин.
|
||||
24. **[Required] ServiceTokenInterceptor проверяет токен только для unary RPC** — первый же
|
||||
server-streaming RPC пройдёт без проверки; то же в access-логе. Исправить: все 4 handler'а.
|
||||
25. **[Required] gRPC-логгер не логирует «прочие» исключения** (только OCE/RpcException) — 500-эквивалент
|
||||
уходит мимо лога. Исправить: catch (Exception) → log + RpcException.
|
||||
26. **[Required] Heartbeat/reconnect без таймаута** — зависший ConnectAsync последовательно блокирует
|
||||
все тенанты и shutdown. Исправить: CancelAfter на попытку.
|
||||
27. **[Required] QR: отмена RPC до первого URL не отменяет фоновую задачу** — «скрытая» авторизация.
|
||||
Исправить: отменять саму задачу при отмене ожидания.
|
||||
28. **[Required] TelegramBackfill fire-and-forget Task.Run из tenant-запроса без in-flight guard**
|
||||
(параллельные полные перечитывания); фоновые задачи не отслеживаются хостом. Исправить: гейт операции
|
||||
+ токен остановки хоста.
|
||||
29. **[Required] int.Parse(apiId)** из пользовательской KV-настройки `TelegramEndpoints.cs` —
|
||||
FormatException маскируется под 400 «не подключён». Исправить: TryParse + понятная ошибка.
|
||||
|
||||
## C. Архитектура / дублирование (приоритет 3)
|
||||
|
||||
30. **[Required]** 9 независимых реализаций чтения настроек (GetAsync+JsonDocument.Parse+дефолт) в
|
||||
Settings/IncomingRules/RatesService/Discovery*/DialogsService — расхождение семантики уже видно.
|
||||
**+** ~8 копий KV-хелперов (ReadBool/ReadInt/ReadString/ReadStringList) и 3 копии LoadRatesAsync в
|
||||
Kanban/Pipeline/Projects. Исправить: один публичный снапшот настроек в Settings или SharedKernel +
|
||||
общий RatesCacheReader.
|
||||
31. **[Required]** Обвязка gRPC-сервисов (ServiceTokenInterceptor/RpcCallLogging/MtlsOptions/MtlsCertificates/
|
||||
Logging + Host) скопирована в 3 независимых sln. Исправить: общий проект `Deal.Grpc.Hosting`.
|
||||
32. **[Required]** Большие файлы: PipelineWorkerService (914), KanbanStore (726), DiscoveryStore (632),
|
||||
ProjectsService (576), ProjectsEndpoints (568), CardsService (475), Program.cs (695), LocalFieldsParser
|
||||
(438), GrpcTelegramClient (447), TelegramIngressService (409); фронт: SettingsView.vue (1779),
|
||||
DiscoveryView.vue (1243), store.js (2434). Исправить: декомпозиция (см. ниже).
|
||||
33. **[Required] Фронт: MoveMenu вешает document-слушатель на каждую карточку** (сотни карточек → сотни
|
||||
слушателей). Исправить: один глобальный обработчик + id открытого меню в store.
|
||||
34. **[Required] Фронт: квадратичные пересчёты колонок.** `store.js` — filter+sort на каждую колонку/
|
||||
счётчик при каждом ре-рендере. Исправить: один computed Map<colId, sorted[]>.
|
||||
35. **[Nit]** Дублирование доменных констант между модулями (EmptyCommentDetail/JustNowLabel/MlSpamLabel/
|
||||
DefaultChannelHue/PlannedStage-литералы) и расхождение предиката «активные правила» (Kanban vs
|
||||
AiClassifyContextBuilder) — вынести в единые реестры.
|
||||
36. **[Nit]** Middleware сессий (Session vs OperatorSession) и токен-генераторы (SessionTokens/
|
||||
InviteCodeGenerator/TenantAdminService) дублируются — обобщить.
|
||||
37. **[Nit]** Легаси-ссылки на строки Python-прототипа в XML-doc (L177–191 и т.п.) — устаревают;
|
||||
оставить «зачем/инвариант», убрать номера строк.
|
||||
38. **[Nit]** Форматтеры времени и «знание» о контактах/типах файлов в 3–4 местах (фронт) — единый
|
||||
модуль форматов и словари меток.
|
||||
39. **[Nit]** `window.prompt` в renameBoard на фоне единого ConfirmDialog; дубликаты 86400000; ширины
|
||||
колонок sm/md/lg в 3 местах — константы/единый RenameDialog.
|
||||
|
||||
## D. Мёртвый код (кандидаты на удаление)
|
||||
|
||||
- Фронт: `utils.js` fileTypeInfo/EXT_KINDS/KIND_LABELS (не импортируется); `store.js` — curName/fmtMoney
|
||||
вне store, moveLead-мёртвая ветка, trashLead-пустой if, openDialog (не используется), checkReminders
|
||||
(нигде не вызывается); опция «mock»-курсов — проверить, жив ли режим на бэкенде.
|
||||
- Бэкенд: Kanban DemoLeadFactory недостижимый fallback PrimaryContact; DiscoverySearchErrorCounter —
|
||||
singleton-счётчик без TTL/эвикции и с межтенантным ключом (переделать per-tenant или чистить).
|
||||
|
||||
## E. Что соответствует хорошим практикам (подтверждено)
|
||||
|
||||
- Тенант-изоляция сильная: схема на тенанта через Search Path, TenantDbContext запрещён вне tenant-запроса
|
||||
(fail-fast), AsyncLocal сбрасывается в finally, gRPC-ингресс берёт tenant-id только из metadata, SSE
|
||||
per-tenant.
|
||||
- SQL параметризован везде (FromSqlInterpolated/ExecuteSqlInterpolated); массовые операции —
|
||||
ExecuteUpdate/Delete; комментарии-батчи без N+1; AsNoTracking.
|
||||
- Секреты не покидают систему: ключи шифруются (enc:+nonce‖ct‖tag), наружу маски; токены сессий — SHA-256
|
||||
хэши; пароли Argon2id; куки httpOnly+SameSite=Lax; fail-closed service-token (с явным гардом
|
||||
«пусто≠пусто»); path traversal защищён (SessionStore/ModelPool валидируют tenant-id как имя файла).
|
||||
- Фронт: XSS-аудит чистый (v-html только через экранирующий renderSourceMessage со схемами http/tg),
|
||||
токенов в localStorage нет (httpOnly-кука), все target=_blank с rel=noreferrer.
|
||||
- Чистая архитектура port&adapter в модулях (нет EF/HTTP в Application), DTO-рекорды, DI-Registrar'ы,
|
||||
направленные зависимости без циклов, константы-каталоги вместо магических строк.
|
||||
|
||||
## F. Рекомендуемый порядок исправлений
|
||||
|
||||
1. **Безопасность (A1–A13)** — до любого прода. Точечные правки + тесты.
|
||||
2. **Потеря данных/корректность (B14–B29)** — гонки, дедуп, маски, boot/applySettings фронта.
|
||||
3. **Архитектура (C30–C34)** — вынос общего grpc-hosting, снапшот настроек, декомпозиция больших файлов,
|
||||
фронт: leadsByCol-компьютед и глобальный слушатель меню.
|
||||
4. **Чистка мёртвого кода (D)** + реестры констант (C35–C39) — в рамках рефакторингов, не отдельно.
|
||||
5. **Заделы (Optional)** — пагинация колонок, виртуализация списков, LRU для кэшей сессий WTelegram,
|
||||
батчинг провижининга схем, MinIO tenant-префикс, per-request size-лимиты загрузок, single-flight
|
||||
DiscoveryWorker.
|
||||
|
||||
---
|
||||
|
||||
## Статус исправлений (2026-09-08, после ревью)
|
||||
|
||||
Выполнено в ходе rework-захода (детали — `.superpowers/sdd/deal-stage8-quality-rework/progress.md` и
|
||||
`docs/superpowers/STATUS.md`). Тесты: core **1135/1135**, telegram **118/118**, ai **52/52**, ml **38/38**,
|
||||
фронт `npm run build` OK.
|
||||
|
||||
**A. Безопасность — закрыто (A1–A13):**
|
||||
- A1 SSRF: `SettingsService` — baseUrl каталоговых облачных провайдеров не переопределяется (только
|
||||
local/custom); `AiConnectionChecker` — запрет private/loopback/link-local адресов (в т.ч. 169.254.169.254).
|
||||
- A2/A3: fail-closed в Production (RateLimit:Enabled обязателен, CORS-allowlist непустой, conn-string без
|
||||
фолбэка) — стартовые проверки `Program.cs`.
|
||||
- A4: код инвайта в аудите → SHA-256 `codeHash` (3 события, тесты обновлены).
|
||||
- A5: пароль минимум 8 (единый `AuthService.MinNewPasswordLength`).
|
||||
- A6: PATCH с маской ключа («…») больше не шифрует маску (терялся бы ключ); A13: короткие секреты
|
||||
маскируются всегда (`MaskSecret`), apiId остаётся как есть (не секрет).
|
||||
- A7: DDL (провижининг схем/миграции) — опциональная мигратор-строка `ConnectionStrings:DealMigrator`
|
||||
(`ConnectionStringProvider.ForSchemaDdl`); dev/тесты — прежнее поведение.
|
||||
- A8: `TenantId` — инвариант 32 hex (Guid N), фабрика FromGuid.
|
||||
- A9: gRPC-сервисы — лимиты входных данных (INVALID_ARGUMENT) + MaxReceiveMessageSize=4MiB.
|
||||
- A10: mTLS fail-closed в Production (сервисы).
|
||||
- A11: `JoinService` — целевой тенант обязан существовать и быть активным до резервирования кода.
|
||||
- A12: атомарный инкремент токенов (`UPDATE ... UsedTokens=UsedTokens+@n`) для Npgsql; EF-путь для InMemory.
|
||||
- Доп.: int.TryParse apiId; ResolveSession учитывает статус пользователя; очистка протухших сессий — вне
|
||||
hot-path.
|
||||
|
||||
**B. Корректность/потеря данных — закрыто (B14–B29):** атомарные append (comment/link/file) в ProjectStore
|
||||
(1 SQL), objectKey файла с id записи, дедуп-pump атомарен (Claim→bool), move из trash/archive/taken запрещён,
|
||||
пустые catch логируются (DiscLog/ILogger), фронт: смена пароля (oldPass), boot с .catch, applySettings не
|
||||
затирает промпты, seq-токены поиска; интерцепторы gRPC на все 4 вида RPC, логгер catch(Exception), reconnect
|
||||
с таймаутом, QR-cancel; backfill с in-flight guard + lifetime-токеном.
|
||||
|
||||
**C. Архитектура — закрыто:** C30 (единый `TenantSettingsSnapshot` вместо ~9 копий чтения настроек и 3 копий
|
||||
`LoadRatesAsync`; удалён клон `RateTable.cs`), C31 (общий `src/grpc-hosting/Deal.Grpc.Hosting`; 15 файлов
|
||||
дублей удалены), C32-декомпозиция (KanbanStore→5, PipelineWorkerService→8, DiscoveryStore→5, ProjectsService→4,
|
||||
CardsService→3, SettingsService→6, DiscoveryWorkerService→6 partial; фронт: store.js→слайсы store/, вынесены
|
||||
Telegram/Stop/Scope-вкладки SettingsView, DiscoveryCandidateCard), C33/C34 (MoveMenu, leadsByCol), C36
|
||||
(`UrlSafeToken`). **Закрыто после ревью (2026-09-09):** C35 — общие реестры
|
||||
`MlLearningLabels`/`SourceDefaults` в Deal.Contracts (метки обучения ML «spam»/«t:hire»/«t:order» и дефолтный
|
||||
цвет источника «#666») вместо дублей MlSpamLabel/DefaultChannelHue/DefaultDialogHue/SpamLabel в
|
||||
Pipeline/Discovery/Telegram/Infrastructure; единый предикат «активные правила» — AiClassifyContextBuilder
|
||||
переведён на `ColumnRules.HasActiveRules` (Kanban; было расхождение Count>0 vs терм после trim); реестр
|
||||
`ProjectStages` (9 id-констант вместо литералов) и общий `CardsService.JustNowLabel` (Projects/адаптер
|
||||
KanbanStore); DiscoverySearchErrorCounter — TTL-эвикция (см. D). **Задел:** полный вынос остальных вкладок
|
||||
SettingsView (риск без e2e).
|
||||
|
||||
**D. Мёртвый код:** удалён (фронт: fileTypeInfo/EXT_KINDS/curName/fmtMoney/openDialog/checkReminders и др.;
|
||||
бэкенд: недостижимый PrimaryContact DemoLeadFactory и др.). DiscoverySearchErrorCounter — добавлена TTL-эвикция
|
||||
записей (EntryTtlSeconds=1 ч, ленивая при Next/Reset, часы инъекцией; +4 теста) — задел D закрыт.
|
||||
@@ -0,0 +1,113 @@
|
||||
# Аудит документации «Дейл»: сверка с кодом/конфигами
|
||||
|
||||
> Исторический документ (аудит документации, 2026-09-10; следующий — `2026-09-11-docs-final-sweep.md`). Актуальное состояние — `docs/superpowers/STATUS.md` и `docs/technical/Техническая-документация-Дейл.md`.
|
||||
|
||||
> Дата: 2026-09-11
|
||||
> Проверено: `docs/spec/ТЗ-дейл-новая-архитектура.md`,
|
||||
> `docs/user-guide/Инструкция-пользователя-Дейл.md`,
|
||||
> `docs/technical/Техническая-документация-Дейл.md`,
|
||||
> `docs/api/api-map.md`, плюс `docs/superpowers/STATUS.md`.
|
||||
> Метод: сверка утверждений с кодом (`src/core/Deal.Api/Endpoints/*`,
|
||||
> `src/core/Deal.Infrastructure/**`, `src/frontend/src/**`, `src/{ai,ml,telegram}-service`),
|
||||
> конфигами (`deploy/compose.*.yml`, `appsettings*.json`) и скриптами (`scripts/*.sh`).
|
||||
> Докер не поднимался, тесты не перезапускались (см. «непроверяемое»).
|
||||
|
||||
## Сводка
|
||||
|
||||
- Найдено расхождений: **30** (по пунктам таблиц ниже).
|
||||
- Исправлено прямо в доках: **30**.
|
||||
- Значимые подтверждённые факты, с которыми доки сходятся: порты (core 5080/5082, telegram 5101,
|
||||
ai 5102, ml 5103, metrics 9464, postgres 5433, minio 9000/9001, grafana 3001), единые домены
|
||||
`/api/cards` + `/api/containers`, оператор-консоль `#/operator` и активация `#/join`,
|
||||
ключи Telegram — у оператора (`global_settings`), команды запуска.
|
||||
|
||||
## Расхождения (файл:строка → в доке → реальность → исправлено)
|
||||
|
||||
### `docs/technical/Техническая-документация-Дейл.md`
|
||||
|
||||
| # | Место | В доке | Реальность (код) | Статус |
|
||||
|---|---|---|---|---|
|
||||
| 1 | §2 «Структура» (~L43) | проект `Deal.Modules.Projects/` | каталога нет; есть `Deal.Modules.Telegram/` | ✅ исправлено на `Deal.Modules.Telegram` |
|
||||
| 2 | §3 «Модули core» (таблица, ~L74) | «Выбранные» владеет `Deal.Modules.Projects`; нет Telegram | сервисы «Выбранных» — в `Deal.Modules.Kanban` (`CardsService.Selected`); модуль `Deal.Modules.Telegram` существует | ✅ исправлено + добавлена строка Telegram |
|
||||
| 3 | §3 (абзац, ~L80) | «`Projects` — сервисами пространства…» | модуля `Projects` нет (перенесено в Kanban) | ✅ исправлено |
|
||||
| 4 | §4 «Ключевые таблицы public» (~L109-112) | `tenants(…, limits_json)`, `users(…, email, role)`, `invites(id, tenant_id, email, code, expires_at, used_at)`, `app_settings` | `tenants(Id,Name,Status,CreatedAt)`, `users(…,Login,…)`, `invites(Code PK,Email,TenantId,Status,ExpiresAt,ActivatedAt,CreatedById,CreatedAt)`, `global_settings`; таблицы `app_settings` нет | ✅ исправлено |
|
||||
| 5 | §4 сноска (~L122) | `Operators`, `OperatorSessions` | таблицы — `operators`, `operator_sessions` (миграция `SystemSaaS`) | ✅ исправлено |
|
||||
| 6 | §6 «Файлы» (~L202) | ключ объекта = `tenant_<id>/<card_id>/<file_id>` | `CardsService` строит `projects/<card_id>/<file_id>_<unixMs>_<safeName>` | ✅ исправлено |
|
||||
| 7 | §8 «Развёртывание» (сноска, ~L304) | «корневой `docker-compose.yml` — наследие LeadRadar» | файл перенесён в `archive/leadradar-legacy/`; в корне его нет | ✅ исправлено |
|
||||
| 8 | §11 этап 5 (~L510) | модуль/таблица `Deal.Modules.Projects`/`ProjectCards` без пометки | упразднены с этапа 9 | ✅ добавлена пометка «историческое состояние» |
|
||||
| 9 | §11 TODO (~L619-620) | «OpenAPI-карта снимается с LeadRadar», «миграции на 1000 схем — в плане этапа 0» | api-map и контракты есть; пакетная миграция реализована (этап 12) | ✅ исправлено |
|
||||
| 10 | §13.4a (~L717) | секреты включают `tgKeys.apiHash` в настройках тенанта, маска `apiHashSet` | `tgKeys` у тенанта нет; ключи — у оператора (`global_settings`, `GET/PUT /api/operator/settings/telegram-keys`) | ✅ исправлено + пометка |
|
||||
| 11 | §13.5 «Проверка схем» (~L888) | схема тенанта содержит `Boards`, `ProjectCards`; public — неполный | `Boards`/`ProjectCards` удалены (этап 9); актуальны `Containers`, `Dialogs`, `Disc*` и т.д. | ✅ исправлено на актуальный список |
|
||||
| 12 | §13.6 «Тесты» (~L905) | `dotnet test` ожидает **1203 PASS** | актуальный core — **1275** | ✅ исправлено |
|
||||
| 13 | §13.7 env (~L976) | core в compose задаёт `DEAL_DEMO=1` | в `compose.dev.yml` `DEAL_DEMO` нет; демо-ручки удалены | ✅ исправлено |
|
||||
| 14 | §13.7 smoke (~L994) | `POST /api/demo/simulate-lead` → `/api/leads/{id}/trash` | `dev-smoke.sh`: `POST /api/cards` → `POST /api/cards/{id}/trash` | ✅ исправлено |
|
||||
| 15 | §13.7 ручные проверки (~L1061) | `PATCH /api/settings tgKeys` | ключи — у оператора (вариант A) | ✅ исправлено |
|
||||
| 16 | §13.8 (~L1074) | `public.Operators`/`OperatorSessions` | `operators`/`operator_sessions` | ✅ исправлено |
|
||||
| 17 | §13.8 (~L1111) | «приостановка тенанта (вход **401**…)» | вход приостановленного тенанта — **403** (`AuthEndpoints`) | ✅ исправлено |
|
||||
| 18 | §13 заголовок (~L636) | «актуально для этапов 0–10» | актуально по этап 12 | ✅ исправлено |
|
||||
| 19 | §13.4e (~L841) | исторический раздел этапа 5 без пометки | операции переехали в `/api/cards*`, модуль/таблица удалены | ✅ добавлена пометка |
|
||||
| 20 | §16 «Добивка» (~L1339) | core-тесты **1245/1245** | актуально **1275/1275** | ✅ исправлено |
|
||||
|
||||
### `docs/user-guide/Инструкция-пользователя-Дейл.md`
|
||||
|
||||
| # | Место | В доке | Реальность | Статус |
|
||||
|---|---|---|---|---|
|
||||
| 21 | §1 «Особенности» (~L32-34) | демо-кнопки («демо-карточка», «демо-сообщение») при `DEAL_DEMO=1` | во фронте демо-кнопок нет, ручки `POST /api/demo/*` и флаг удалены | ✅ исправлено |
|
||||
|
||||
### `docs/api/api-map.md`
|
||||
|
||||
| # | Место | В доке | Реальность | Статус |
|
||||
|---|---|---|---|---|
|
||||
| 22 | §3.1 (~L59) | `change-password` — минимум **4** символа | `AuthEndpoints` — минимум **8** | ✅ исправлено |
|
||||
| 23 | §4.1 (~L248) | `objectKey: "cards/c_…/pf_…"` | формат `projects/<cardId>/<fileId>_<ms>_<name>` | ✅ исправлено |
|
||||
| 24 | §5 «Прочие домены» (~L400) | Operator + join = **21** | 24 операторских ручки + `/api/join` = **25** | ✅ исправлено |
|
||||
|
||||
### `docs/superpowers/STATUS.md`
|
||||
|
||||
| # | Место | В доке | Реальность | Статус |
|
||||
|---|---|---|---|---|
|
||||
| 25 | (~L30) | core **1203/1203 PASS** | 1275 | ✅ исправлено |
|
||||
| 26 | (~L52) | «демо `DEAL_DEMO`» | демо удалено | ✅ исправлено |
|
||||
| 27 | (~L56) | `public.Operators/OperatorSessions` | `operators`/`operator_sessions` | ✅ исправлено |
|
||||
| 28 | (~L76) | «демо-пространство, `DEAL_DEMO=1`» | dev-seed `admin/admin`, демо удалено | ✅ исправлено |
|
||||
| 29 | (~L90) | «settings/boards/demo-карточка» (live-приёмка) | актуальные ручки — `/api/settings`, `/api/cards` | ✅ исправлено + историческая пометка |
|
||||
| 30 | (~L94) | «simulate-lead → карточка inbox» | `dev-smoke.sh`: `POST /api/cards` → карточка `planned` | ✅ исправлено |
|
||||
|
||||
> Нумерация строк приблизительная (после правок сместилась).
|
||||
|
||||
## Проверено и сходится (выборка)
|
||||
|
||||
- **Порты**: core HTTP 5080 / gRPC-ингресс 5082, telegram-service 5101, ai-service 5102,
|
||||
ml-service 5103, metrics 9464 (`METRICS_PORT`), postgres host-порт 5433, minio 9000/9001,
|
||||
grafana `127.0.0.1:3001`, prometheus `127.0.0.1:9090` — совпадают с `deploy/compose.*.yml`
|
||||
и Dockerfile.
|
||||
- **Команды**: `docker compose -f deploy/compose.dev.yml up -d --build`, `scripts/dev-smoke.sh`,
|
||||
`scripts/test.sh` (+ `npm run lint:i18n`), фронт `npm run dev` — совпадают.
|
||||
- **Единый API**: `/api/cards` + `/api/containers`; домены `/api/leads|projects|boards|columns`
|
||||
удалены — совпадает с `Endpoints/*` и `api-map`.
|
||||
- **Оператор-консоль**: hash-роутер `#/` / `#/operator` / `#/join?code=…` —
|
||||
`src/frontend/src/router.js`; ключи Telegram — `global_settings` + `OperatorSettingsEndpoints`.
|
||||
- **БД**: `Containers` вместо `Boards`, `ProjectCards` нет, `Cards` с модульными JSON-полями;
|
||||
публичные таблицы `audit_log`/`token_usage_events`/`global_settings`/`rate_limit_counters`
|
||||
и lowercase `operators`/`operator_sessions` — подтверждено EF-конфигами и миграциями.
|
||||
- **Файлы**: `objectKey = projects/<cardId>/<fileId>_<ms>_<name>` — `CardsService.Files`.
|
||||
- **Наблюдаемость**: `/metrics` на отдельном HTTP/1.1-эндпоинте :9464, Serilog, promtail/loki/grafana —
|
||||
подтверждено `DealMetricsHosting`, `compose.prod.yml`.
|
||||
|
||||
## Осталось / непроверяемое
|
||||
|
||||
- **Счётчики тестов сервисов** (telegram 125, ai 52, ml 38): перезапуск тестов не выполнялся
|
||||
(запрет на долгие процессы). В доках трогали только core-счётчик (1203/1245 → 1275) по
|
||||
ground-truth задания; сами цифры сервисов не подтверждались кодом.
|
||||
- **Точное число операторских ручек (25)** — подсчёт по `Endpoints/Operator*` + `JoinEndpoint`;
|
||||
группировка может отличаться от авторской (ранее было 21 — вероятно, до этапа 12).
|
||||
- **Исторические разделы-журналы** (§11 этапы 1–7, §13.4c/4d/4e, live-приёмки в STATUS/планах)
|
||||
намеренно сохраняют легаси-термины (`Boards`, `/api/leads`, `/api/projects`, `DEAL_DEMO`,
|
||||
`ProjectCards`). Добавлены точечные пометки «историческое состояние»; полный перепис
|
||||
не выполнялся (вне правил задачи).
|
||||
- **Планы/архитектурные доки** (`docs/superpowers/plans/*`, `docs/architecture/*`) содержат
|
||||
легаси-термины (`Boards`, `ProjectCards`, `docker-compose.yml`) — вне периметра аудита.
|
||||
- **Живые контуры** (Telegram-вход, реальные LLM-вызовы, mTLS-рукопожатие, backup/restore на
|
||||
docker-стеке) не проверялись — нужны креды/Docker; в доках они помечены ⚠ Manual.
|
||||
- **Дубли/внутренние противоречия**: техдок §11 этап 5 и §13.4e описывают снятый контур
|
||||
«Выбранных» как историю; при следующей редакции их, возможно, стоит свернуть в ссылку на §3.
|
||||
@@ -0,0 +1,329 @@
|
||||
# Аудит соответствия ТЗ «Дейл (Deal) — новая архитектура»
|
||||
|
||||
> Исторический документ (аудит соответствия ТЗ, 2026-09-10). Актуальное состояние — `docs/superpowers/STATUS.md` и `docs/technical/Техническая-документация-Дейл.md`.
|
||||
|
||||
> Дата: 2026-09-10
|
||||
> Проверяется: `docs/spec/ТЗ-дейл-новая-архитектура.md` (§1–§12) + расширенные требования этапов 8–12.
|
||||
> Метод: **только исходный код и артефакты репозитория** (`C:\telbase`). Док-документам на слово
|
||||
> не верим — каждое утверждение подкреплено файлом/символом. Единственное запущенное — линтер
|
||||
> `npm run lint:i18n` (быстрый, read-only); остальное не запускалось.
|
||||
> Проект не git; правок кода/доков не вносилось, создан только настоящий отчёт.
|
||||
|
||||
## Сводка
|
||||
|
||||
| Статус | Кол-во |
|
||||
|---|---|
|
||||
| ✅ реализовано | 131 |
|
||||
| ⚠️ частично | 12 |
|
||||
| ❌ отсутствует | 1 |
|
||||
| Всего проверено пунктов | 144 |
|
||||
|
||||
Топ-находок — в разделе «Найденные пропуски/расхождения».
|
||||
(Каждая строка таблицы = один проверяемый пункт ТЗ/расширенных требований.)
|
||||
|
||||
---
|
||||
|
||||
## §1. О продукте
|
||||
|
||||
| № | Требование | Статус | Доказательство |
|
||||
|---|---|---|---|
|
||||
| 1.1 | Приём сообщений из источников в реальном времени | ✅ | `src/telegram-service/Deal.Telegram/Dialogs/RealtimeListener.cs` (PushMessage + mark-read), `Hosting/RealtimeMonitorService.cs` |
|
||||
| 1.2 | Отсев мусора (реклама/скам/служебное/дубли/устаревшее) | ✅ | `PipelineRejectConstants.cs` (stage labels `stop/spam_ml/spam_ai/filter_ai/dup/stale`), `IncomingRules.cs`, `PipelineWorkerService.Checks.cs` |
|
||||
| 1.3 | Структурирование в карточки по профилю (сфера/стек/бюджет/локация) | ✅ | `Pipeline/AiCardMapper.cs`, `Parse/LocalFieldsParser.cs`, `PipelineCardWriter.cs` |
|
||||
| 1.4 | Раскладка по колонкам-фильтрам | ✅ | `Kanban/ColumnRules/ColumnRules.cs`, `CardsService` (ContainerAccepts) |
|
||||
| 1.5 | Самообучение на действиях (ML) | ✅ | `Kanban/CardsService.Operations.cs` (PushAsync на move/trash/restore), `MlOutboxFlushScheduler` |
|
||||
| 1.6 | Discovery — поиск/подключение источников | ✅ | `Deal.Modules.Discovery/*`, `DiscoveryWorkerService.Search/Evaluate/Join` |
|
||||
|
||||
## §2. Термины
|
||||
|
||||
| № | Требование | Статус | Доказательство |
|
||||
|---|---|---|---|
|
||||
| 2.1 | Тенант владеет схемой БД/настройками/ML | ✅ | `Data/TenantContext.cs`, модель на тенанта (`TenantDb` миграции), модель ML per-tenant (`ml.proto`, `data/ml/<tenantId>.sqlite`) |
|
||||
| 2.2 | Аккаунт Telegram (1 на тенанта) | ✅ | `Telegram/Sessions/TenantSession.cs` («1 аккаунт на тенанта») |
|
||||
| 2.3 | Источник (канал/группа/чат) | ✅ | `Deal.Modules.Telegram/Application/ITelegramStore.cs`, `DialogEntity` |
|
||||
| 2.4 | Сырое сообщение → очередь | ✅ | `Pipeline/Application/Models/QueuedMessage.cs`, `PipelineIngestService.cs` |
|
||||
| 2.5 | Карточка — ядро + модули | ✅ | `Deal.Modules.Cards/Application/Card.cs`, интерфейсы `IContentCard/IBudgetedCard/IContactCard/IFileCard/ITzCard/IRemindableCard/…` |
|
||||
| 2.6 | Типы источника (локально/ссылка/файл/Telegram/импорт/API/ИИ/составной) | ✅ | `Deal.Modules.Cards/Application/ILocalSource.cs`, `IWebSource.cs`, `ITelegramSource.cs`, `IApiSource.cs`, `IFileSource.cs`, `IRowSource.cs`, `IAiSource.cs`, `ICompositeSource.cs` |
|
||||
| 2.7 | Контейнер + политика | ✅ | `Kanban/Application/Models/ContainerPolicyDto.cs`, `ContainersService.cs` |
|
||||
| 2.8 | Отсев с причиной | ✅ | `Pipeline/Application/Models/RejectedItemDto.cs`, `PipelineProcessingService.Rejected` |
|
||||
|
||||
## §3. Роли и доступ
|
||||
|
||||
| № | Требование | Статус | Доказательство |
|
||||
|---|---|---|---|
|
||||
| 3.1 | Оператор: тенанты/инвайты/лимиты/health/impersonation с аудитом | ✅ | `Endpoints/Operator*`, `OperatorTenantsEndpoints.Impersonate`, `AuditEvents.ImpersonationStarted/Stopped` |
|
||||
| 3.2 | Тенант: вход по инвайту, пароль, TG-аккаунт, обработка, дашборд | ✅ | `Tenants/Application/JoinService.cs`, `AuthService.cs`, `Endpoints/JoinEndpoint.cs` |
|
||||
| 3.3 | Регистрация только по инвайту | ✅ | `IInviteStore`, `InviteCodeGenerator` (16 симв., 72 ч), публичной регистрации нет |
|
||||
| 3.4 | Логин email+пароль, email уникален в SaaS | ✅ | `AuthService`, `users` (public), уникальность email |
|
||||
| 3.5 | `tenantId` — в сессии | ✅ | `Models/SessionDto.cs`, кука `deal_session`; JWT не используется (сессии) — допустимо формулировкой «сессии/JWT» |
|
||||
| 3.6 | Вход оператора изолирован от тенантов | ✅ | `Configuration/OperatorCookieOptions.cs` (`deal_operator_session`), `OperatorAuthEndpoints` |
|
||||
|
||||
## §4. Подключение Telegram-аккаунта
|
||||
|
||||
| № | Требование | Статус | Доказательство |
|
||||
|---|---|---|---|
|
||||
| 4.1 | Оператор **глобально** задаёт `api_id`/`api_hash` | ⚠️ | Ключи хранятся в **настройке тенанта** `tgKeys` (`SettingsKeys.TgKeys`, `Deal.Api/Telegram/TelegramKeysService.cs`) и задаются в UI тенанта (`settings/TelegramTab.vue`). Глобальной (операторской) настройки/ручки нет — расхождение с §4.1/§8 |
|
||||
| 4.2 | Подключение: QR или телефон+код | ✅ | `TelegramTab.vue` (qr/phone/code/password), `TelegramEndpoints` (start-qr/start-phone/submit-code/password), `TenantSession.StartQrAsync` |
|
||||
| 4.3 | Сессия сохраняется, статус подключения показан | ✅ | `Sessions/SessionStore.cs`, `SessionFileCipher.cs` (AES-GCM), `TgStatusService`, `GET /api/tg/status` |
|
||||
| 4.4 | 1 аккаунт на тенанта (схема допускает расширение) | ✅ | `TenantSession` (один на тенанта), `SessionFarm` |
|
||||
| 4.5 | Список диалогов подтягивается при подключении и обновляется на экране + в фоне | ✅ | `Dialogs/RealtimeSweep.cs` (SyncDialogs каждые 30 с), `TelegramEndpoints` `/dialogs/refresh`, `ChannelsView.vue` |
|
||||
| 4.6 | Вкл/выкл мониторинга по источнику | ✅ | `DialogsService.SetMonitorAsync`, `TelegramEndpoints` `/dialogs/{id}/monitor` |
|
||||
| 4.7 | «Новый чат → мониторинг автоматически» (вкл/выкл) | ✅ | `SettingsKeys.AutoMonitorNew`, `DialogsService.SyncFromTelegramAsync`, `TelegramStore.SyncFromTelegramAsync` |
|
||||
| 4.8 | Удалённые/покинутые источники исчезают | ✅ | `TelegramStore.SyncFromTelegramAsync` (удаление отсутствующих) |
|
||||
| 4.9 | «Перечитать»: догон ~10 сообщений включённых источников, анти-бан-паузы | ✅ | `Dialogs/BackfillService.cs` (`MessagesLimit=10`, паузы 1.5–3 с / 3–6 с), `POST /api/tg/dialogs/backfill-all` |
|
||||
| 4.10 | Полученные сообщения сразу помечаются прочитанными | ✅ | `RealtimeListener.OnMessageReceivedAsync` (MarkReadAsync после Push), `BackfillService` (read-ack) |
|
||||
| 4.11 | Discovery: задача поиска → ИИ ключевые слова | ✅ | `DiscoveryEndpoints` (generate-keywords), `IAiTools.GenerateKeywordsAsync` |
|
||||
| 4.12 | Поиск каналов, где аккаунт не состоит | ✅ | `DiscoveryWorkerService.Search.cs`, `IsDialogMonitoredAsync` |
|
||||
| 4.13 | Каскад: участники → язык → содержание (порог ≥40%) | ✅ | `DiscoveryWorkerService.Evaluate.cs`, `SettingsDefaults.DiscEvalThreshold = 40` |
|
||||
| 4.14 | Кандидаты «на рассмотрение» с метаданными/fit/темами/метками (закрытая группа) | ✅ | `DiscoveryWorkerService.Constants.cs` (`MarkClosedGroup`…), `FinishReviewAsync`, `Models/DiscoveryTopicDto` |
|
||||
| 4.15 | Действия: вручную «Вступить» / авто-вступление с квотами (50/сутки, 50–70 с) | ✅ | `DiscoveryBanGuard.cs` (`DiscJoinLimit=50`), `DiscoveryPacer.cs` (`DiscJoinDelayMin/Max=50/70`) |
|
||||
| 4.16 | «Отклонить» → чёрный список; список исключает во всех задачах; снимается вручную | ✅ | `DiscoveryBlacklistService.cs`, `DiscoveryBlacklistList.vue`, `RemoveBlacklistAsync` |
|
||||
|
||||
## §5. Обработка входящих (пайплайн)
|
||||
|
||||
| № | Требование | Статус | Доказательство |
|
||||
|---|---|---|---|
|
||||
| 5.1 | Путь: источник → очередь → стоп-лист → дедуп → ML → ИИ → карточка | ✅ | `PipelineWorkerService.Pump.cs`, `SignificantPath`, `PipelineIngestService` |
|
||||
| 5.2 | Этап 1: минимальная длина текста | ✅ | `IncomingRules.Evaluate` (`KindLength`), `SettingsDefaults.MinLen=24` |
|
||||
| 5.3 | Этап 1: стоп-фразы (настраиваемый список) | ✅ | `SettingsKeys.StopPhrases`, `IncomingRules` (`KindStop`), `settings/StopTab.vue` |
|
||||
| 5.4 | Этап 1: отсев резюме соискателей (настройка) | ✅ | `SettingsKeys.BlockResumes/ResumeMarkers`, `IncomingRules` (`KindResume`, guard «резюме» при маркере найма) |
|
||||
| 5.5 | Этап 1: тип заявки (только вакансии / только заказы) | ✅ | `SettingsKeys.WantedType`, `IncomingRules` (`KindType`), `hireMarkers` |
|
||||
| 5.6 | Этап 1: дедуп (нормализованный хэш) | ✅ | `Parse/DedupHasher.cs`, `DedupEntries` (миграция `TenantPipeline`) |
|
||||
| 5.7 | Этап 1: устаревшее сообщение → отсев | ✅ | `PipelineWorkerService.Checks.cs` `IsStaleAsync` (`ArchiveAfterDays`) |
|
||||
| 5.8 | ML: уверена → решает сама (спам/колонка); не уверена → ИИ | ✅ | `PipelineWorkerService.Pump.cs` (`run.MlEnabled && !force`), `ml.proto` (take/label/margin) |
|
||||
| 5.9 | Возврат из отсева (force) идёт мимо ML к ИИ | ✅ | `Pump.cs` (`force` пропускает ML), `PipelineProcessingService.ReturnAsync` (`Force = true`) |
|
||||
| 5.10 | ИИ-фильтр: не про заявки → отсев; выключатель `aiFilterEnabled` | ✅ | `Pump.cs`, `SettingsKeys.AiFilterEnabled`, `AiFilterResultDto.Skipped` |
|
||||
| 5.11 | Классификация: структурированный разбор (компания/формат/задача/требования/плюсы/условия/бюджет/стек/контакты/тип) | ✅ | `Parse/ParsedCardContent.cs`, `AiCardMapper.cs`, `ai.proto` ClassifyReply |
|
||||
| 5.12 | Назначение колонки с проверкой правил | ✅ | `ContainerAccepts`, `AiCardLearning.cs`, `ColumnRules.cs` |
|
||||
| 5.13 | Глобальный фильтр «без суммы» отдельно для вакансий и заказов | ✅ | `SettingsKeys.BudgetRequiredHire/Order`, `PipelineWorkerService.Checks.cs` `SkipNoBudgetAsync` |
|
||||
| 5.14 | Глобальные исключения по ключевым словам/технологиям/бюджету/локации | ⚠️ | Глобальных настроек-исключений нет: в `SettingsKeys` только `StopPhrases` (стоп-фразы) и per-column `exclude` (`ColumnExclusions.cs`). Исключений «ключевые слова/технологии/бюджет/локация» отдельного глобального уровня не найдено |
|
||||
| 5.15 | Карточка — одна строка одной таблицы `Cards`; `ProjectCards` упразднена | ✅ | Миграция `TenantUnifiedCard.cs` (`DropTable("ProjectCards")` + `AddColumn` `StackJson/LinksJson/FilesJson/HistoryJson/TzText/Reminder…`) |
|
||||
| 5.16 | Комментарии — общая таблица `LeadComments` | ✅ | Миграция `TenantKanban.cs` (`LeadComments`), `KanbanStore.Comments.cs` |
|
||||
| 5.17 | Единый реестр контейнеров; пространства не пересекаются; «взять в работу» = смена контейнера | ✅ | `ContainerSpaces.cs`, `ContainersService.cs`, `CardsService.Selected.cs` (`TakeAsync`) |
|
||||
| 5.18 | Исходное сообщение хранится и доступно (открыть в Telegram / форматированно) | ✅ | `CardDrawer.vue` (`sourceMsg`, `tgSourceUrl`, `renderSourceMessage`), `ProcessingView.vue` |
|
||||
|
||||
## §6. Дашборд (канбан)
|
||||
|
||||
| № | Требование | Статус | Доказательство |
|
||||
|---|---|---|---|
|
||||
| 6.1 | Колонки: «Неразобранное», пользовательские, «Архив», «Корзина» | ✅ | `CardIds` (inbox/archive/trash), `ContainerKinds`, `KanbanColumns` |
|
||||
| 6.2 | Пользователь создаёт колонки; ИИ **предлагает** с обоснованием; принять/отклонить/переименовать | ✅ | `AiSuggestEndpoints`, `SuggestHeuristics.cs`, `ContainerColumn.vue` (`acceptSuggestedBoard`, `suggested` badge) |
|
||||
| 6.3 | Колонка = сложный набор фильтров (ключевые слова/стек/грейд/уровень/цена/бюджет/локация/тип + отрицательные) | ⚠️ | `ContainerRulesDto` содержит только `Mode/Direction/Keywords/Stack/Grade/Exclude/Budget`. Отдельных групп «уровень/цена/локация/тип» нет (частично покрыты `direction`/`keywords`); отрицательные — `exclude` ✅ |
|
||||
| 6.4 | При помещении указаны критерии попадания | ✅ | `ColumnRules.ComputeHits`, `MatchHitBuilder`, `MatchHitDto` |
|
||||
| 6.5 | Свежие сверху; drag&drop между колонками с обучением ML | ✅ | `KanbanStore.Cards.cs` (`OrderByDescending(ReceivedAt)`), `composables/dnd.js`, `PushAsync` on move |
|
||||
| 6.6 | Быстрые действия: комментарий, корзина, контакт, «открыть исходник» | ⚠️ | Комментарий/корзина/контакт — `Card.vue` (кнопки). «Открыть исходник» на самой карточке нет — только в `CardDrawer.vue` и `ProcessingView.vue` |
|
||||
| 6.7 | Виджеты-счётчики свёрнутых колонок; двигать/менять размер | ✅ | `Sidebar.vue`, `cards.js` (`cycleWidth`, `colExtra`, `reorder`), `COLUMN_WIDTHS` |
|
||||
| 6.8 | Архив: старше N дней (1–30), очистка через 90 дней | ✅ | `StorageTickService.cs`, `SettingsDefaults.ArchiveAfterDays=14 (кламп 1..30)`, `ArchiveClearDays=90` |
|
||||
| 6.9 | Корзина: очистка раз в 7 дней; возврат из архива/корзины | ✅ | `SettingsDefaults.TrashClearDays=7`, `CardsService.Operations.cs` (`RestoreCardAsync`) |
|
||||
| 6.10 | «Выбранные»: стадии Запланировано→…→Готово/Отложено | ✅ | `CardsDefaultContainers.cs` (planned/reply/agree/work/review/ready/hold) |
|
||||
| 6.11 | «Взять в работу» — переход в контейнер, не клон | ✅ | `CardsService.Selected.cs` `TakeAsync` |
|
||||
| 6.12 | Модули работы: комментарии/сумма/стек/контакты, ссылки, ТЗ, файлы (S3/MinIO), значки количества | ✅ | `CardsService.Files.cs`, `CardFileKind.cs`, `FileKindDetector.cs`, `CardDrawer.vue` |
|
||||
| 6.13 | Отложенные: напоминания (срок+время, календарь); выключатель; выключено → не срабатывают | ✅ | `CardsService.Reminders.cs` (`RemindersDisabledDetail`, snooze +24 ч), `HoldReminderDialog.vue`, `SettingsDefaults.RemindersEnabled` |
|
||||
| 6.14 | История движения — под спойлером | ✅ | `CardDrawer.vue` (`<details>` «История движения», `historyReversed`) |
|
||||
| 6.15 | Ручное создание карточки (пометка «создано локально») | ✅ | `CardDetailsEndpoints` `POST /api/cards`, `Local` флаг, `Card.vue`/`CardDrawer.vue` бейдж «Локальная» |
|
||||
| 6.16 | Терминальные зоны «Отклонено»/«Выполнено»; в архив/корзину дашборда не попадают | ✅ | `CardsDefaultContainers.finished/rejected` (terminal), `ContainerPolicyDto.IsTerminal`, `ClearRejected` |
|
||||
|
||||
## §7. Вкладка «Обработка»
|
||||
|
||||
| № | Требование | Статус | Доказательство |
|
||||
|---|---|---|---|
|
||||
| 7.1 | Очередь (этап 1 / ожидают ИИ) с автопрокруткой | ✅ | `ProcessingView.vue` (таймер-опрос ~2.6 с, статусы `etap-1-bez-ii`/`ozhidaet-ii`); «автопрокрутка» реализована как авто-обновление |
|
||||
| 7.2 | Отсев с причиной и источником решения (правила/ML/ИИ/система) + конкретная фраза | ✅ | `PipelineRejectConstants.cs` (`StageLabels`/`SourceLabels`), `RejectedItemDto` (`kw`, `reason`) |
|
||||
| 7.3 | Метаданные, «Открыть исходник», «Исходное сообщение (форматированно)» | ✅ | `ProcessingView.vue` (`metaRows`, `sourceUrl`, `srcHtml`) |
|
||||
| 7.4 | Полнотекстовый поиск по отсеву | ✅ | `PipelineEndpoints` `/rejected?q=` (FTS ∪ LIKE), `Store` поиск |
|
||||
| 7.5 | Возврат из отсева: причины игнорируются, ML/ИИ обучаются, причина возврата | ✅ | `PipelineProcessingService.ReturnAsync` (`Force=true`, `PushAsync(spam,−1.0)`, `returnReason`) |
|
||||
| 7.6 | Автоочистка отсева раз в 3 дня; ручная очистка | ✅ | `PipelineRejectConstants.RetentionDays=3`, `POST /pipeline/rejected/clear`, `DELETE /rejected/{id}` |
|
||||
| 7.7 | Счётчик обработки в боковой панели; отсев в панели не показывается | ✅ | `Sidebar.vue` (`state.pQueueCounts.total`), отсев — только внутри `ProcessingView.vue` |
|
||||
|
||||
## §8. Настройки тенанта
|
||||
|
||||
| № | Требование | Статус | Доказательство |
|
||||
|---|---|---|---|
|
||||
| 8.1 | Telegram: ключи приложения (**оператор**), подключение, авто-мониторинг | ⚠️ | Подключение/авто-мониторинг ✅ (`TelegramTab.vue`, `AutoMonitorNew`). Ключи — настройка **тенанта** `tgKeys`, а не глобальная операторская (см. §4.1) |
|
||||
| 8.2 | ИИ: провайдер (в т.ч. локальные), модель, ключ зашифрован | ✅ | `AiProviders.cs`, `SettingsService.PatchSecrets.cs` (`enc:`), `ISecretCipher` |
|
||||
| 8.3 | Промпты: базовый + свой; библиотека по сферам + «мои промпты» | ✅ | `PromptLibraryModal.vue` (`PROMPT_LIBRARY`/`PROMPT_CATEGORIES`, поиск), `SettingsService.PatchMyPrompts.cs`, `AiTab.vue` |
|
||||
| 8.4 | ИИ вкл/выкл; ИИ-фильтр вкл/выкл | ✅ | `SettingsKeys.AiEnabled/AiFilterEnabled`, `Pump.cs` |
|
||||
| 8.5 | ML: вкл/выкл, обучение на действиях, **проверка на сообщении/канале**, сброс, самооценка | ⚠️ | `mlEnabled`, обучение (`PushAsync`), predict (сообщение) ✅, сброс ✅ (`/api/ml/reset`), самооценка ✅ (`MlEvalDto`). **Проверка на канале не реализована**: `POST /api/ml/candidates` возвращает пустой список (заглушка), `POST /api/ml/apply` — всегда 404 (`MlEndpoints.cs:130–155`) |
|
||||
| 8.6 | Обработка: стоп-фразы, длина, резюме, тип, домен/ключи, маркеры найма/заказа | ✅ | `SettingsKeys.StopPhrases/MinLen/BlockResumes/WantedType/DomainKeywords/HireMarkers`, `StopTab.vue`/`ScopeTab.vue` |
|
||||
| 8.7 | Колонки: набор, правила, отрицательные фильтры, исключения | ✅ | `ContainersEndpoints`, `BoardRulesDialog.vue`, `ColumnExclusions.cs` (см. замечание 6.3 по составу групп) |
|
||||
| 8.8 | Валюта: целевая, источник (4 запроса/сутки), конвертация при приёме + пересчёт старых (кроме архива/корзины), USDT=USD | ✅ | `RatesService.cs` (`RatesFetchInterval` = 6 ч = 4/сутки; USDT→USD), `ConversionRecomputer.cs` (`ConversionExcludedCols` archive/trash) |
|
||||
| 8.9 | Хранение: срок архивации (1–30), очистка архива/корзины | ✅ | `StorageTickService.cs`, `SettingsDefaults.ArchiveAfterDays/ArchiveClearDays/TrashClearDays`, `StorageTab.vue` |
|
||||
| 8.10 | Уведомления и напоминания; отложенные — отдельно | ✅ | `NotifyTab.vue`, `SettingsKeys.RemindersEnabled`, `CardsService.Reminders.cs` |
|
||||
| 8.11 | Звук | ✅ | `NotifyTab.vue` (`soundOn`, `volume`, `testSound`), `utils.js` (Web Audio) — клиентская настройка, без серверного ключа |
|
||||
| 8.12 | Внешний вид | ❌ | В `SettingsView.vue` вкладок Telegram/AI/Storage/Stop/Scope/ML/Notify/Currency/Profile — раздела «Внешний вид» (тема/оформление) нет; `style.css` содержит единственную тёмную тему |
|
||||
|
||||
## §9. Лимиты (бюджет токенов)
|
||||
|
||||
| № | Требование | Статус | Доказательство |
|
||||
|---|---|---|---|
|
||||
| 9.1 | Бюджет токенов на LLM, период настраивается | ✅ | `TenantLimitDto` (`BudgetTokens`, `Period` month/day), `OperatorLimitUpdateRequest` |
|
||||
| 9.2 | ai-service оценивает вызов в токенах, списывает с бюджета | ✅ | `TokenUsageRecorder.cs`, `BudgetedAiClassifier.cs`, `BudgetedAiTools.cs`, `ai.proto` Usage |
|
||||
| 9.3 | При исчерпании: fallback + уведомление; приём не блокируется | ✅ | `BudgetedAiClassifier` (Local-фолбэк), `Warned80/NotifiedExhausted`, условия `pipeline` не блокируются |
|
||||
| 9.4 | Оператор видит расход и меняет бюджет | ✅ | `OperatorLimitsEndpoints` (`/limits`, `/tenants/{id}/limit`), `AnalyticsService` |
|
||||
|
||||
## §10. Админка оператора
|
||||
|
||||
| № | Требование | Статус | Доказательство |
|
||||
|---|---|---|---|
|
||||
| 10.1 | Тенанты: создание, инвайты, статус, лимиты, приостановка | ✅ | `OperatorTenantsEndpoints` (create/suspend/unsuspend), `OperatorInvitesEndpoints` |
|
||||
| 10.2 | Health всех сервисов и очередей | ⚠️ | Сервисы ✅ (`OperatorHealthEndpoints`, ml/ai/telegram по gRPC-пробам). «Очереди» в health нет — глубины очередей публикуются только в метриках (`Observability/DealMetricsCollector.cs` → `/metrics`) |
|
||||
| 10.3 | Аудит: входы/выходы, инвайты, impersonation, действия оператора и тенанта | ✅ | `AuditEvents.cs` (login/logout/invite/impersonation/card_*/container_*/settings/channels/telegram), `AuditService` |
|
||||
| 10.4 | Аналитика: расход токенов (день/тенант/провайдер/модель) + лента действий с фильтрами | ✅ | `AnalyticsService.TokensAsync` (groupBy), `OperatorAnalyticsEndpoints`, `AuditSection.vue`/`AnalyticsSection.vue` |
|
||||
| 10.5 | Подозрительная активность (по логам безопасности) | ⚠️ | Отдельного разбора/детектора подозрительной активности не найдено; есть счётчики неудачных входов в `AnalyticsService.OverviewAsync` (`failedLogins`) и общие Grafana-дашборды |
|
||||
| 10.6 | Метрики сервисов (Prometheus/Grafana) | ✅ | `DealMetricsHosting.cs` (`/metrics` :9464), `deploy/observability/prometheus.yml`, `prometheus-rules.yml`, Grafana-дашборды |
|
||||
| 10.7 | UI: `#/operator` и `#/join` | ✅ | `router.js`, `views/operator/OperatorConsole.vue`, `views/JoinView.vue` |
|
||||
|
||||
## §11. Нефункциональные требования
|
||||
|
||||
| № | Требование | Статус | Доказательство |
|
||||
|---|---|---|---|
|
||||
| 11.1 | Безопасность: TLS, mTLS между сервисами | ✅ | `scripts/mtls-certs.sh`, `MtlsCertificates.cs`, `compose.prod.yml` (`DEAL_MTLS_*`), `MtlsOptions.cs` |
|
||||
| 11.2 | Параметризованный SQL | ✅ | EF Core / Npgsql по всему `Deal.Infrastructure`; ручной SQL — параметризованный (`ExecuteSqlRawAsync` без конкатенации) |
|
||||
| 11.3 | IDOR/XSS/SSRF/CSRF | ✅ | IDOR — session+tenant-scope middleware; XSS — `renderSourceMessage` (экранирование); SSRF — `AiConnectionChecker.cs` (`IsPrivateEndpoint`, allowlist `AiProviders`), `CbrRateSource` (fixed URL); CSRF — `OriginGuardMiddleware.cs` + SameSite |
|
||||
| 11.4 | Argon2id | ✅ | `DefaultPasswordHasher.cs` (Isopoh Argon2, Variant Argon2id) |
|
||||
| 11.5 | Rate limiting (прокси + приложение), счётчики распределённые в БД | ✅ | `StoreBackedFixedWindowRateLimiter.cs`, `IRateLimitCounterStore` → `RateLimitCounterStore` (public.rate_limit_counters), `LoginAttemptGuard.cs`, `RateLimitPolicies.cs` |
|
||||
| 11.6 | Cloudflare | ⚠️ | В коде нет интеграции/конфигурации Cloudflare; edge — Caddy (`deploy/caddy/Caddyfile`, TLS `internal`). Требование внешнего периметра, вне репозитория |
|
||||
| 11.7 | Ежедневные бэкапы (Postgres/файлы/сессии), outbox для событий | ✅ | `scripts/backup.sh`/`restore.sh`/`deal-backup-lib.sh`; outbox — `MlOutboxQueue.cs`, `MlOutboxFlushScheduler.cs` |
|
||||
| 11.8 | Авто-очистки (retention аудита/лимитов/счётчиков), разлогин suspended | ✅ | `DataRetentionScheduler.cs`, `DataRetentionOptions.cs`; `AuthService.ResolveSessionAsync` (suspended → null) |
|
||||
| 11.9 | Наблюдаемость: логи → Loki, метрики OTel→Prometheus→Grafana + алерты, `token_usage_events` | ✅ | `Logging/DealLogging.cs`, `deploy/observability/{promtail,loki}.yml`, `prometheus-rules.yml`; миграция `AddTokenUsageEvents` |
|
||||
| 11.10 | Масштабируемость: модульный монолит + сервисы ml/ai/telegram; k8s позже | ✅ | `Deal.Modules.*`, отдельные проекты `src/{ai,ml,telegram}-service`, `compose.*.yml`; k8s отсутствует (заявлено позже) |
|
||||
| 11.11 | Производительность: без потерь; анти-бан-паузы не блокируют обработку | ✅ | `PipelineIngestService`/`DedupEntries`, фоновые `PipelineWorkerScheduler`/`BackfillService`, `progressive.js` |
|
||||
| 11.12 | i18n: строки вынесены, RU по умолчанию, новые языки, переключение на лету с сохранением, форматтеры дат/чисел/валют, фолбэк RU | ⚠️ | Ядро i18n есть (`i18n/index.js`, `ru.js`/`ru.data.js`, `$t`), линтер проходит зелёным (проверено: `npm run lint:i18n` → ✓). Но: **нет UI-переключателя языка, нет второго языка и нет сохранения выбора** (в `index.js` прямо: «UI-переключателя на этом этапе нет»); даты/числа форматируются жёстко через `toLocale*('ru-RU', …)` (`store/core.js`, `store/settings.js`, `fmtNum` в `store/operator.js`), а не через locale-aware i18n-форматтеры |
|
||||
|
||||
## §12. Ограничения и допущения
|
||||
|
||||
| № | Требование | Статус | Доказательство |
|
||||
|---|---|---|---|
|
||||
| 12.1 | Фронтенд Vue 3 + Vite + Tailwind; единый контракт `/api/cards` + `/api/containers` с этапа 9 | ✅ | `package.json` (vue/vite/tailwind), `api.js`, `CardsEndpoints.cs`, `ContainersEndpoints.cs` |
|
||||
| 12.2 | Данные LeadRadar тестовые — не мигрируются | ✅ | Отдельные миграции Deal; данных-миграций из LeadRadar нет |
|
||||
| 12.3 | Kafka, k8s, биллинг-провайдер, саморегистрация — вне рамок | ✅ | В коде отсутствуют |
|
||||
| 12.4 | 1 Telegram-аккаунт на тенанта; несколько — позже | ✅ | `TenantSession` (1 на тенанта) |
|
||||
|
||||
---
|
||||
|
||||
## Расширенные требования (этапы 8–12)
|
||||
|
||||
| № | Требование | Статус | Доказательство |
|
||||
|---|---|---|---|
|
||||
| E1 | Библиотека готовых промптов по специальностям | ✅ | `ru.data.js` `PROMPT_LIBRARY` (IT/дизайн/недвижимость/стройка/услуги/красота/обучение), `PromptLibraryModal.vue` |
|
||||
| E2 | Категории и поиск в библиотеке | ✅ | `PROMPT_CATEGORIES`, фильтр `query`/`cat` в `PromptLibraryModal.vue` |
|
||||
| E3 | Раздел «Мои промпты» + свой промпт | ✅ | `SettingsService.PatchMyPrompts.cs`, `AiTab.vue` (`addMyPrompt`/`removeMyPrompt`), лимит ≤100 |
|
||||
| E4 | Двухэтапный стоп-лист: стоп-фразы без ИИ, затем ИИ-фильтр с возможностью отключить | ✅ | Этап 1 `IncomingRules` (без ИИ); ИИ-фильтр `FilterSafelyAsync` под `AiFilterEnabled` |
|
||||
| E5 | Исключения внутри колонки | ✅ | `ColumnExclusions.cs` (veto `Exclude`), `BoardRulesDialog.vue` |
|
||||
| E6 | Discovery: поиск/вступление в каналы и группы | ✅ | `DiscoveryWorkerService.Search/Join`, `DiscoveryOps` (telegram-service) |
|
||||
| E7 | Discovery: квоты/интервалы, закрытые группы, темы, список на рассмотрение | ✅ | `DiscoveryBanGuard`, `DiscoveryPacer`, `MarkClosedGroup`, `DiscoveryTopicGroup`, статус `review` |
|
||||
| E8 | «Перечитать каналы»/backfill, пометка прочитанными, мгновенный приём | ✅ | `BackfillService.cs` (10 сообщений, паузы), read-ack; `RealtimeListener.cs` |
|
||||
| E9 | ML отдельным контейнером | ✅ | `src/ml-service/Deal.Ml/Dockerfile` + `compose.dev.yml`/`compose.prod.yml` (`ml-service`, gRPC :5103) |
|
||||
| E10 | ML: обучение на действиях пользователя **и** ИИ | ✅ | Пользователь — `CardsService.Operations.cs` (`PushAsync(…,1.0)`); ИИ — `AiCardLearning.cs`, `CardReclassifier.cs` (`AiPushWeight`) |
|
||||
| E11 | Отдельная настройка проверки ML на сообщении/канале | ⚠️ | Проверка на **сообщении** ✅ (`POST /api/ml/predict`, `MLPanel.vue`); проверка на **канале** ❌ (`/api/ml/candidates` — пустая заглушка, `/api/ml/apply` — 404) |
|
||||
| E12 | Архив/корзина (сроки, возврат, ручная очистка) | ✅ | `StorageTickService.cs`, `CardsService.Operations.cs`, `clear-col`/`DELETE`, `Restore` |
|
||||
| E13 | Напоминания «Отложено» (календарь, отключение) | ✅ | `HoldReminderDialog.vue`, `CardsService.Reminders.cs`, `RemindersEnabled` |
|
||||
| E14 | История карточки под спойлером | ✅ | `CardDrawer.vue` `<details>` «История движения» |
|
||||
| E15 | Контакты квалифицированные (tg/phone/email/linkedin/site) | ✅ | `Parse/ContactsQualifier.cs` (типы `tg/phone/email/linkedin/whatsapp/site`, дедуп, отбой ботов/сервисных ссылок) |
|
||||
| E16 | «Открыть исходник» | ✅ | `CardDrawer.vue` (`sourceUrl`), `ProcessingView.vue` |
|
||||
| E17 | Источник не на карточке (только в деталях) | ✅ | `Card.vue` показывает лишь бейдж «Локальная»/контакты; канал и исходное сообщение — в `CardDrawer.vue` |
|
||||
| E18 | Бюджет: диапазон/вакансия/валюта + конвертация (4 раза в сутки) | ✅ | `CardBudget.cs`, `BudgetNormalizer.cs`, `RatesService.cs` (6 ч = 4/сутки), `ConversionRecomputer.cs` |
|
||||
| E19 | Обязательность суммы (опционально для вакансий) | ✅ | `SettingsKeys.BudgetRequiredHire/BudgetRequiredOrder`, `SkipNoBudgetAsync` |
|
||||
| E20 | Вкладка «Обработка» (очередь + отсев + причины + поиск) | ✅ | `ProcessingView.vue`, `PipelineEndpoints` |
|
||||
| E21 | Возврат из отсева с обучением | ✅ | `PipelineProcessingService.ReturnAsync` (`PushAsync(spam,−1.0)`, `Force`) |
|
||||
| E22 | Оператор-консоль | ✅ | `views/operator/*` (Tenants/Invites/Limits/Audit/Analytics/Health), `router.js` |
|
||||
| E23 | Аналитика токенов | ✅ | `AnalyticsService.cs`, `OperatorAnalyticsEndpoints.cs`, `token_usage_events` |
|
||||
| E24 | Аудит входов/выходов/действий (этап 10) | ✅ | `AuditEvents.cs`, `AuditService.cs`, `AuditSection.vue` |
|
||||
| E25 | i18n (вынос строк) | ⚠️ | Строки вынесены и линтер зелёный, но нет переключателя языка/второго языка/персистентности и locale-форматтеров (см. 11.12) |
|
||||
| E26 | Метрики Prometheus | ✅ | `DealMetricsHosting.cs`, `SharedKernel/Observability/DealMetrics.cs`, `prometheus.yml` (таргеты 5/5) |
|
||||
| E27 | Распределённый rate-limit | ✅ | `RateLimitCounterStore.cs` (Postgres), `StoreBackedFixedWindowRateLimiter.cs`, миграция `RateLimitCounters` |
|
||||
| E28 | reclassify (реальный, этап 12) | ✅ | `CardsEndpoints` `/reclassify` и `/{id}/reclassify`, `CardReclassifier.cs` (локальный фолбэк), `ReclassifyGate.cs`, audit `card_reclassified` |
|
||||
|
||||
---
|
||||
|
||||
## Найденные пропуски/расхождения
|
||||
|
||||
### ❌ Отсутствует
|
||||
|
||||
1. **§8.12 «Внешний вид» (настройки оформления).** В `SettingsView.vue` нет вкладки/раздела внешнего вида;
|
||||
тема одна (тёмная, `style.css` `@theme`). Отдельной настройки «внешний вид» не найдено.
|
||||
|
||||
### ⚠️ Частично
|
||||
|
||||
2. **§8.5 / E11 «проверка ML на канале».** `POST /api/ml/candidates` (`MlEndpoints.cs:131–141`) возвращает
|
||||
`{items: []}` с комментарием «До этапа 6 telegram-данных нет» — устаревшая заглушка; `POST /api/ml/apply`
|
||||
(`MlEndpoints.cs:144–155`) всегда отвечает 404 «Исходное сообщение не найдено». Реального разбора
|
||||
сообщений канала/ручного применения решения ML нет, хотя telegram-данные в системе уже есть
|
||||
(проверка на сообщении — `POST /api/ml/predict` — работает).
|
||||
3. **§5.14 «Глобальные исключения по ключевым словам/технологиям/бюджету/локации».** Глобальных настроек
|
||||
такого исключения в `SettingsKeys` нет: есть только `stopPhrases` (стоп-фразы) и per-column `exclude`
|
||||
(`ColumnExclusions.cs`). Исключения уровня «технология/бюджет/локация» как общий фильтр не найдены.
|
||||
4. **§4.1/§8.1 ключи Telegram.** Хранятся как настройка тенанта `tgKeys` (`TelegramKeysService.cs`) и
|
||||
вводятся в UI тенанта (`TelegramTab.vue`). ТЗ требует, чтобы `api_id`/`api_hash` задавал **оператор
|
||||
глобально** — глобальной операторской настройки/ручки нет.
|
||||
5. **§11.12 / E25 i18n.** Строки вынесены в словари (`i18n/locales/ru.js`, `ru.data.js`), `npm run lint:i18n`
|
||||
проходит. Но отсутствуют: UI-переключатель языка, второй язык, сохранение выбора, «переключение на лету»
|
||||
(в `i18n/index.js` явно сказано «UI-переключателя на этом этапе нет»). Форматирование дат/чисел жёстко
|
||||
`ru-RU` (`store/core.js:232–276`, `store/settings.js:351–406`, `store/operator.js:356`), не через
|
||||
locale-aware i18n-форматтеры.
|
||||
6. **§6.3 состав фильтров колонки.** `ContainerRulesDto` = `Mode/Direction/Keywords/Stack/Grade/Exclude/Budget`.
|
||||
ТЗ перечисляет также «уровень/цена/локация/тип» отдельными опциями — явных групп нет (частично
|
||||
покрываются `direction`/`keywords`).
|
||||
7. **§6.6 «открыть исходник» как быстрое действие карточки.** На `Card.vue` есть комментарий/корзина/контакт,
|
||||
но ссылки «открыть исходник» нет — она доступна только в `CardDrawer.vue` и `ProcessingView.vue`.
|
||||
8. **§10.2 health очередей.** `/api/operator/health` проверяет БД и сервисы ml/ai/telegram, но глубины
|
||||
очередей (пайплайн, MlOutbox) в JSON health не отдаёт — они только в метриках
|
||||
(`DealMetricsCollector.cs` → `/metrics`).
|
||||
9. **§10.5 подозрительная активность.** Специализированного детектора/ленты подозрительной активности по
|
||||
логам безопасности не найдено; есть лишь счётчик `failedLogins` в обзорной аналитике и общие
|
||||
Grafana-дашборды.
|
||||
10. **§11.6 Cloudflare.** В репозитории нет конфигурации/интеграции Cloudflare (edge — Caddy,
|
||||
`deploy/caddy/Caddyfile`). Требование периметра, вне кода приложения.
|
||||
11. **§7.1 «автопрокрутка» очереди.** Реализована как периодическое авто-обновление списка (~2.6 с,
|
||||
`ProcessingView.vue`), а не как буквальная авто-прокрутка. Семантически покрывает требование, но не
|
||||
дословно.
|
||||
|
||||
### Дефекты/легаси, замеченные при проверке (не пункты ТЗ, но влияют на заявленные функции)
|
||||
|
||||
12. **`NotifyTab.vue` — сломан список активных напоминаний.** `const holdReminders = computed(() => state.projectCards.filter(...))`
|
||||
(`settings/NotifyTab.vue:6–8`), при этом `state.projectCards` больше нигде в `src/` не определяется
|
||||
(grep даёт ровно одно совпадение — этот файл). После этапа 9 (`projectCards`/`stage` упразднены) обращение
|
||||
к `state.projectCards.filter` даёт `undefined.filter` → ошибка рендера вкладки «Уведомления».
|
||||
13. **Легаси-артефакты LeadRadar.** В корне остались `docker-compose.yml` (сервисы `app`/`ml`/`minio`
|
||||
старого стека), каталог `backend/` (python `app/`) и `mlservice/` (python). Текущая архитектура — `deploy/compose.*.yml`
|
||||
+ `src/{core,ai,ml,telegram}-service`. Прямого нарушения ТЗ нет, но это риск путаницы (в STATUS.md
|
||||
«судьба legacy `docker-compose.yml`» помечена как открытый вопрос).
|
||||
|
||||
---
|
||||
|
||||
## Чего проверка не покрывает
|
||||
|
||||
- **Живые внешние интеграции без кредов.** Реальный Telegram-вход (`api_id`/`api_hash`/QR) и реальные
|
||||
LLM-вызовы не проверялись (нет кредов; см. STATUS.md, п.5 «нужны живые креды»). Проверяется только
|
||||
наличие кода/контрактов и локальных заглушек.
|
||||
- **Живой контур Docker/k8s, mTLS-рукопожатие, Grafana/Loki/Prometheus.** Проверены конфиги
|
||||
(`compose.*.yml`, `deploy/observability/*`) и код обвязки, но не факт поднятия/скрейпа в этой сессии
|
||||
(сервисы не поднимались).
|
||||
- **Скрипты бэкапа/восстановления и нагрузочные тесты.** Наличие и читаемость проверены (`scripts/backup.sh`,
|
||||
`scripts/restore.sh`, `scripts/loadtest/`), но не выполнялись.
|
||||
- **Корректность чисел в тестах.** Тест-счётчики (STATUS.md: core 1203 и т.п.) не пересчитывались —
|
||||
тесты не запускались (кроме быстрого `lint:i18n`).
|
||||
- **UI-поведение в браузере.** Выводы по фронту основаны на чтении `.vue`/`.js`; реальные клики,
|
||||
drag&drop и рендер не воспроизводились.
|
||||
- **Внешний периметр (Cloudflare, TLS в проде, DNS, egress-контроль).** Вне репозитория.
|
||||
- **Соответствие формальным юридическим требованиям/биллингу** — вне рамок ТЗ (заявлено как «позже»).
|
||||
|
||||
---
|
||||
|
||||
## Обновление (2026-09-10, вечер) — статус после добивки
|
||||
|
||||
Часть найденных ⚠️/❌ закрыта в тот же день (детали — `.superpowers/sdd/deal-stage12-observability-hardening/task-tz-*.md`):
|
||||
|
||||
| Пункт | Было | Стало |
|
||||
|---|---|---|
|
||||
| §8.12 «Внешний вид» | ❌ | ✅ раздел настроек + темы тёмная/светлая/системная (§15 техдока) |
|
||||
| §8/E11 ML-проверка на канале | ⚠️ заглушка | ✅ `MlReviewService` (`/api/ml/candidates|apply`) |
|
||||
| §5.14 глобальные исключения | ⚠️ | ✅ `excludeKeywords/Locations/Types/Budget*` на стоп-этапе |
|
||||
| §6.3 группы фильтров колонки | ⚠️ | ✅ `levels/locations/types/prices` + matchHits |
|
||||
| §6.6 «открыть исходник» на карточке | ⚠️ | ✅ быстрое действие в `Card.vue` |
|
||||
| §10.2 health очередей | ⚠️ | ✅ `queues`/`sessions` в `/api/operator/health` |
|
||||
| §10.5 подозрительная активность | ⚠️ | ✅ `SuspiciousActivityService` + `/api/operator/analytics/suspicious` |
|
||||
|
||||
Остаются требующими владельца/кредов (осознанно): глобальные Telegram-ключи оператора (§4.1/§8.1),
|
||||
переключатель языка (§11.12 — **в бэклоге**, по потребности), живые Telegram/LLM-вызовы, Cloudflare/прод-периметр.
|
||||
Итог после добивки: core-тесты **1245/1245**; фронт build + `lint:i18n` зелёные.
|
||||
@@ -0,0 +1,95 @@
|
||||
# Финальная «подбивка» документации «Дейл» (2026-09-11)
|
||||
|
||||
> Дата: 2026-09-11
|
||||
> Периметр: все `docs/**` (актуальные доки — spec/user-guide/technical/api/STATUS; исторические —
|
||||
> `plans/*`, `reviews/*`, `specs/*`, старые `architecture/*`).
|
||||
> Метод: сквозной поиск по проблемным терминам (`Boards`, `ProjectCards`, `Deal.Modules.Projects`,
|
||||
> `ProjectStages`, корневой `docker-compose.yml`, `DEAL_DEMO`, демо-эндпоинты, `l_`/`pr_`, `app_settings`,
|
||||
> «лид» как сущность, старые порты/пути/счётчики тестов) + чтение актуальных доков и сверка с кодом
|
||||
> (`src/**`, `deploy/compose.*.yml`, `scripts/dev-smoke.sh`, `deploy/observability/grafana/dashboards/`).
|
||||
> Докер не поднимался, тесты не перезапускались. Предшествующий аудит — `2026-09-10-docs-audit.md`
|
||||
> (30 расхождений, уже помечен как исторический).
|
||||
|
||||
## Сводка
|
||||
|
||||
- Найдено новых расхождений: **9** (по таблице ниже).
|
||||
- Исправлено в актуальных доках: **9**.
|
||||
- Добавлено исторических пометок: **20** файлов.
|
||||
- Переписывание содержания исторических артефактов не выполнялось (по правилам задачи).
|
||||
|
||||
## Расхождения (файл:строка → в доке → реальность → действие)
|
||||
|
||||
| # | Файл:строка | В доке | Реальность | Действие |
|
||||
|---|---|---|---|---|
|
||||
| 1 | `docs/superpowers/STATUS.md` ~L4 | «Все этапы **0–10** выполнены (100%)» | таблица этапов — **0–12**, «Итого 0–12 = 100%» (строка ниже) | ✅ исправлено на 0–12 |
|
||||
| 2 | `docs/superpowers/STATUS.md` ~L25 | этап 10 — «**ELK**-дашборды» | стек — Loki + promtail + Grafana (`deploy/observability/grafana/dashboards/Deal-*.json`); Elasticsearch/Kibana нет | ✅ «Grafana/Loki-дашборды» |
|
||||
| 3 | `docs/superpowers/STATUS.md` ~L54 | «Настройки (ключи **AI/Telegram** enc:, промпты, валюты)» у тенанта | ключи Telegram — глобально у оператора (`public.global_settings`); у тенанта только подключение аккаунта | ✅ «ключи AI enc: …; Telegram-ключи — глобально у оператора» |
|
||||
| 4 | `docs/superpowers/STATUS.md` ~L134 | «судьба legacy `docker-compose.yml`» (открытый вопрос) | файл перенесён в `archive/leadradar-legacy/` (2026-09-10) | ✅ «перенесён в `archive/leadradar-legacy/`» |
|
||||
| 5 | `docs/superpowers/STATUS.md` ~L141 | «Core-тесты **1245/1245**» | актуально **1275/1275** (в том же разделе ниже уже 1275) | ✅ исправлено на 1275/1275 |
|
||||
| 6 | `docs/superpowers/STATUS.md` ~L167 | «реестр id-стадий `ProjectStages`» | с этапа 9 каталог — `CardsDefaultContainers` (`Deal.Modules.Cards/Application/CardsDefaultContainers.cs`) | ✅ аннотировано «(с этапа 9 — `CardsDefaultContainers`)» |
|
||||
| 7 | `docs/superpowers/STATUS.md` ~L7, ~L94 | «dev-smoke **12/12**» (в двух местах) | `scripts/dev-smoke.sh` выполняет **14** проверок (config + 6 контейнеров + login/status/containers/create/list/trash/ML-флашер); таблица этапа 9 уже фиксирует `PASS=14` | ✅ исправлено на 14/14 |
|
||||
| 8 | `docs/technical/Техническая-документация-Дейл.md` §8 ~L319 | `dev-smoke.sh`: «… → `/api/tg/status` → **simulate-lead** → флашер MlOutbox …» | скрипт: `/api/tg/status` → `POST /api/cards` → trash → флашер MlOutbox | ✅ заменено на `POST /api/cards` → trash |
|
||||
| 9 | `docs/user-guide/Инструкция-пользователя-Дейл.md` ~L6, L32-34, L41, L262 | «dev/демо-окружение», «демо-пространство с входом `admin`/`admin`» | демо удалено; dev-seed создаёт bootstrap-тенанта `Default` (env `DEAL_BOOTSTRAP_LOGIN/PASSWORD`, дефолт `admin`/`admin`) | ✅ «Dev-окружение», «bootstrap-пространство `Default`» |
|
||||
|
||||
## Добавленные исторические пометки
|
||||
|
||||
Единая шапка: `> Исторический документ этапа N. Актуальное состояние — docs/superpowers/STATUS.md и docs/technical/Техническая-документация-Дейл.md.`
|
||||
|
||||
Планы (`docs/superpowers/plans/`, 15 файлов):
|
||||
`2026-09-04-channel-discovery.md` (план Discovery прототипа LeadRadar),
|
||||
`2026-09-05-deal-roadmap.md` (roadmap этапов 0–7),
|
||||
`2026-09-05-deal-scaffold.md` (этап 0),
|
||||
`2026-09-05-deal-stage1-tenancy.md` … `2026-09-05-deal-stage7-saas.md` (этапы 1–7),
|
||||
`2026-09-09-deal-stage9-unified-card.md` (этап 9),
|
||||
`2026-09-10-deal-stage10-operator-analytics.md` (этап 10),
|
||||
`2026-09-10-deal-stage11-i18n.md` (этап 11),
|
||||
`2026-09-10-deal-stage12-observability-hardening.md` (этап 12).
|
||||
|
||||
Ревью (`docs/superpowers/reviews/`):
|
||||
`2026-09-08-code-quality-review.md` (этап 8),
|
||||
`2026-09-10-tz-compliance-audit.md` (аудит соответствия ТЗ),
|
||||
`2026-09-10-docs-audit.md` (аудит документации; добавлена ссылка на текущий отчёт).
|
||||
|
||||
Специи/архитектура:
|
||||
`docs/superpowers/specs/2026-09-04-channel-discovery-design.md` (дизайн Discovery прототипа),
|
||||
`docs/architecture/2026-09-05-deal-architecture-design.md` (архдизайн-черновик),
|
||||
`docs/architecture/2026-09-09-unified-card.md` (дизайн единой карточки, этап 9).
|
||||
|
||||
Не тронуты по существу (актуальны): `docs/architecture/2026-09-10-unified-api-contract.md`,
|
||||
`docs/architecture/2026-09-10-operator-analytics-contract.md`.
|
||||
|
||||
## Проверено и сходится
|
||||
|
||||
- **Единый API**: `/api/cards` + `/api/containers`; `api-map` §1/§3.5 корректно фиксирует удаление
|
||||
`/api/leads|projects|boards|columns` и переименование `new_lead → new_card`; ссылки на «бывшие» домены —
|
||||
в контексте «удалено», а не как действующие.
|
||||
- **Ключи Telegram**: spec §4.1/§8, user-guide §3, api-map §6, technical §13.7/§13.10 — везде у оператора
|
||||
(`/api/operator/settings/telegram-keys`, `public.global_settings`).
|
||||
- **Оператор-консоль/активация**: `#/operator`, `#/join?code=…` — spec §10, user-guide §11, technical §13.10,
|
||||
api-map — совпадают.
|
||||
- **Порты**: core 5080/5082, telegram 5101, ai 5102, ml 5103, metrics 9464, postgres 5433, minio 9000/9001,
|
||||
grafana 3001, prometheus 9090 — совпадают между spec/user-guide/technical/api и compose-файлами.
|
||||
- **Core-тесты**: 1275 (technical §13.6/§16, STATUS таблица/итоги) — противоречий в актуальных доках нет.
|
||||
- **Префиксы id**: `c_` (единый) — api-map §4.1, technical §11/§12/§8; `l_`/`pr_` в актуальных доках отсутствуют
|
||||
(остались только в помеченных исторических разделах и внешних исторических артефактах).
|
||||
- **`app_settings`**: в актуальных доках нет; актуальная таблица — `global_settings` (`public`).
|
||||
- **Исторические артефакты**: `Boards`/`ProjectCards`/`Deal.Modules.Projects`/`ProjectStages`/`DEAL_DEMO`/
|
||||
демо-ручки/`docker-compose.yml` встречаются только в документах, получивших историческую пометку.
|
||||
|
||||
## Осталось спорным / намеренно не тронуто
|
||||
|
||||
1. **Ссылка из spec на исторический архдизайн.** `docs/spec/ТЗ-дейл-новая-архитектура.md` (шапка)
|
||||
указывает среди связанных `docs/architecture/2026-09-05-deal-architecture-design.md` — документ теперь
|
||||
помечен историческим. Формально не ошибка (файл существует, помечен), но при следующей редакции ссылку,
|
||||
возможно, стоит заменить на `2026-09-10-unified-api-contract.md`.
|
||||
2. **`tgKeys` в historical §13.4b технического дока** (`GET /api/settings` перечисляет `tgKeys`): раздел
|
||||
помечен историческим (§13 шапка + заметка §13.4a о переносе ключей к оператору). По правилам задачи
|
||||
содержание исторических разделов не переписывалось.
|
||||
3. **Счётчики тестов сервисов** (telegram 125, ai 52, ml 38): не перепроверялись кодом/прогоном
|
||||
(запрет на долгие процессы); в актуальных доках они не противоречат друг другу.
|
||||
4. **«Live SaaS 15/15»** — цифра из исторических приёмок, независимо не подтверждалась.
|
||||
5. **Число операторских ручек (25)** в api-map §5 — подсчёт по `Endpoints/Operator*` + `/api/join`;
|
||||
группировка может отличаться от авторской (ранее было 21). Не перепроверялось.
|
||||
6. **Исторический журнал §11 техдока** (этапы 1–7) и §13.4c/4d/4e намеренно сохраняют легаси-термины
|
||||
под пометками; сведение их в ссылки на §3 — задача следующей редакции, а не этой подбивки.
|
||||
7. **`docs/architecture/2026-09-10-*`** (контракты) по условию задачи не редактировались; они актуальны.
|
||||
@@ -0,0 +1,212 @@
|
||||
# Поиск и подключение каналов (Discovery) — дизайн
|
||||
|
||||
> Исторический документ (дизайн Discovery прототипа LeadRadar, 2026-09-04). Актуальное состояние — `docs/superpowers/STATUS.md` и `docs/technical/Техническая-документация-Дейл.md`.
|
||||
|
||||
Дата: 2026-09-04
|
||||
Статус: согласован с пользователем (правки от 2026-09-04 учтены)
|
||||
|
||||
## 1. Цель
|
||||
|
||||
Пользователь даёт системе «задание»: найти Telegram-каналы и группы, в которых мы ещё
|
||||
**не состоим**, по описанию цели (например, «вакансии и фриланс для разработки») и
|
||||
подключить их к мониторингу. Система сама ищет кандидатов, оценивает их (по метаданным,
|
||||
языку и содержанию сообщений) и показывает человеку список «на рассмотрение»; человек
|
||||
решает — вступить и мониторить или отклонить. Возможен режим авто-вступления в рамках
|
||||
суточных квот и с паузами против бана.
|
||||
|
||||
Ключевое правило: **источники, в которых мы уже состоим (вступили/мониторим), исключаются
|
||||
сразу и безусловно — независимо от запроса, ключей и настроек задачи.** Это глобальное
|
||||
правило системы: действует на всех этапах (поиск → оценка → вступление) и для всех задач.
|
||||
|
||||
## 2. Ограничения Telegram API (факты, на которых строится дизайн)
|
||||
|
||||
1. Глобального «поиска по критериям» в API нет. `contacts.search(q)` возвращает
|
||||
публичные каналы/группы/боты по **имени/username/запросу** — без фильтров по
|
||||
участникам, языку и содержимому. Всю дальнейшую фильтрацию делаем сами.
|
||||
2. Число участников/описание — через `channels.getFullChannel`. Для публичных каналов
|
||||
доступно без вступления; для групп часто доступно только членам.
|
||||
3. Чтение истории без вступления: публичные **каналы** — обычно можно; публичные
|
||||
**группы** — только если история открыта; иначе — только членам.
|
||||
4. Массовый поиск/чтение/вступления с юзер-аккаунта ограничены эмпирически — нужны
|
||||
квоты, паузы и обработка `FloodWaitError`.
|
||||
|
||||
## 3. Понятия
|
||||
|
||||
- **Задача (task)** — конфиг поиска: описание цели, ключи, фильтры, план, режим
|
||||
авто-вступления, статус/счётчики. Задач может быть несколько.
|
||||
- **Кандидат (candidate)** — найденный источник (канал/группа, для форумов — оценка по
|
||||
темам). Проходит стадии: `new → evaluated → review → joined | rejected`.
|
||||
- **Метки кандидата** — человекочитаемые пометки: «закрытая группа/канал», «форум»,
|
||||
«не прочитано», «участники не подтверждены», «язык не подтверждён», «есть проходные
|
||||
темы».
|
||||
- **Чёрный список** — источники, отклонённые пользователем; поиск их больше не
|
||||
возвращает (снимается вручную).
|
||||
- **BanGuard** — единый менеджер квот и пауз для всех действий discovery
|
||||
(search/read/join/leave), общий для задач.
|
||||
|
||||
## 4. Задача: конфигурация и правила создания
|
||||
|
||||
Поля задачи:
|
||||
|
||||
| Поле | Назначение | По умолчанию |
|
||||
| --- | --- | --- |
|
||||
| `name` | название задачи | — |
|
||||
| `description` | описание цели (что ищем) | — |
|
||||
| `keywords` | поисковые ключи (генерирует ИИ, редактируются перед стартом) | [] |
|
||||
| `minSubscribers` | минимум участников (0 = не важно) | 0 |
|
||||
| `lang` | язык источников (`ru` / `any`) | `ru` |
|
||||
| `threshold` | доля подходящих сообщений, % | 40 |
|
||||
| `sampleSize` | сколько сообщений смотреть при оценке | 10 |
|
||||
| `planJoins` | план вступлений N | 1..50 |
|
||||
| `autoJoin` | авто-вступление подходящих | false |
|
||||
| `status` | `draft → running → paused → done | failed` | draft |
|
||||
|
||||
Правила создания:
|
||||
|
||||
- **Бюджет планов:** сумма `planJoins` всех задач в статусе не `done/failed` + `planJoins`
|
||||
новой ≤ суточного лимита вступлений (по умолчанию 50). Задача с планом 50 не даёт
|
||||
создать другую; план 25 оставляет максимум 25.
|
||||
- Запуск возможен только после генерации/подтверждения ключей.
|
||||
- При редактировании активной задачи план нельзя увеличить сверх свободного бюджета.
|
||||
|
||||
## 5. Пайплайн поиска (каскад фильтров)
|
||||
|
||||
Выполняется фоновым воркером задачи строго через BanGuard (по одному действию, с паузами).
|
||||
|
||||
Для каждого кандидата фильтры идут **по нарастающей стоимости**; при первом «нет»
|
||||
источник пропускается и берётся следующий:
|
||||
|
||||
1. **Поиск** — `contacts.search` по каждому ключу (с паузами). Кандидаты
|
||||
дедуплицируются по `dialog_id`/username.
|
||||
2. **«Мы не состоим» — глобальный фильтр, применяется сразу и безусловно:** как только
|
||||
источник найден (независимо от запроса/ключей), он отбрасывается, если уже есть
|
||||
в `dialogs` (вступили/мониторим), в чёрном списке или уже обрабатывается/вступил/ждёт
|
||||
рассмотрения в другой задаче (глобальная дедупликация кандидатов). Остальные фильтры
|
||||
(участники/язык/контент) применяются уже после этого. Проверка повторяется
|
||||
непосредственно перед вступлением (между оценкой и join'ом кандидат мог быть добавлен
|
||||
вручную).
|
||||
3. **Число участников** — если `minSubscribers` задано:
|
||||
- значение получено и меньше минимума → пропуск;
|
||||
- значение получить не удалось → **не пропускаем**, ставим метку «участники не
|
||||
подтверждены».
|
||||
4. **Язык** — если `lang=ru`: по выборке сообщений эвристикой кириллицы (без ИИ);
|
||||
не удалось прочитать → метка «язык не подтверждён» (не пропуск).
|
||||
5. **Содержимое** — оценка выборки сообщений (см. §6).
|
||||
|
||||
Пометки «не подтверждено» — не ошибка, а сигнал человеку на экране рассмотрения.
|
||||
|
||||
## 6. Оценка содержимого (по темам, для форумов)
|
||||
|
||||
- **Что считается «подходящим сообщением»:** сообщение проходит те же правила, что в
|
||||
основной системе (этап 1 → ML → ИИ), но **профиль оценки = профиль задачи**
|
||||
(описание + ключи задачи), а не глобальные настройки дашборда. Оценка ничего не
|
||||
создаёт: ни карточек, ни очереди, ни обучения ML.
|
||||
- Если ИИ выключен — оценка локальным разбором/ML.
|
||||
- **Каналы:** читаем до `sampleSize` последних сообщений; доля подходящих ≥ `threshold`
|
||||
→ в «на рассмотрение».
|
||||
- **Открытые группы:** то же; чтение не удалось → «на рассмотрение» с меткой
|
||||
«открытая группа, не прочитана».
|
||||
- **Закрытые группы** (нашлись по ключам, история скрыта): сразу «на рассмотрение» с
|
||||
меткой «закрытая группа/канал» (+ метки неподтверждённых фильтров). Пользователь
|
||||
вступает сам.
|
||||
- **Форумы (группы с темами):** группа раскладывается по темам (`reply_to_top_id`):
|
||||
читаем выборку по активным темам, оценка считается **по темам** («тема: подходит
|
||||
X из N»). Группа подходящая, если есть ≥1 проходная тема. В превью — список тем с
|
||||
пометками проходная/нет. Имена тем, если API не отдаёт без членства, подставляем
|
||||
сниппетом первого сообщения темы.
|
||||
- Порог «40%» применяется к сообщениям темы/канала; если в выборке меньше 3
|
||||
содержательных сообщений — кандидат идёт «на рассмотрение» с меткой «мало сообщений».
|
||||
|
||||
## 7. «На рассмотрение» и действия человека
|
||||
|
||||
Экран по задаче содержит списки: **В обработке / На рассмотрении / Вступили /
|
||||
Отклонены**, плюс история.
|
||||
|
||||
Кандидат на рассмотрении показывает: тип (канал/группа/форум), число участников,
|
||||
метки, долю «подходит X из N» и **почему подошло** (перечень подходящих сообщений/тем
|
||||
с причинами — как блок «попала по фильтру» в карточках), превью сообщений (для
|
||||
форумов — по темам).
|
||||
|
||||
Действия:
|
||||
|
||||
- **«Вступить и мониторить»** — `channels.joinChannel` (по username), добавление в
|
||||
`dialogs` с `monitor=1`, backfill последних ~10 сообщений. Ручной клик — **вне квот**.
|
||||
После вступления источник автоматически попадает под правило «мы состоим» и из
|
||||
поиска исключается.
|
||||
- **«Отклонить»** — источник в чёрный список (исключается из поиска во всех задачах).
|
||||
Если для оценки пришлось вступать — выходим (`channels.leaveChannel`) в рамках квот.
|
||||
Чёрный список редактируется вручную (можно снять).
|
||||
- **Закрытые группы:** вместо авто-вступления — кнопка-ссылка `t.me/<username>`; система
|
||||
замечает вступление при синхронизации диалогов и предлагает добавить источник в
|
||||
мониторинг (метка «вступили, добавить в мониторинг?»).
|
||||
|
||||
## 8. Авто-вступление, квоты и анти-бан (BanGuard)
|
||||
|
||||
- Суточный лимит вступлений — **50** (настройка), общий для всех задач, считаются только
|
||||
автоматические вступления. Ручные — без ограничений.
|
||||
- Авто-вступление включается на задачу (`autoJoin`). Подходящие кандидаты вступают сами.
|
||||
- Интервалы между автоматическими вступлениями: **случайно 50–70 секунд**; по одному
|
||||
действию, без параллелей. Поиск и чтение — мягкие паузы (единицы секунд + джиттер,
|
||||
переиспользуем значения анти-бана из telegram.py).
|
||||
- Задача «выполнена» при достижении плана вступлений. Если за сутки упёрлись в общий
|
||||
бюджет — авто-режим продолжает на следующий день (новый суточный бюджет).
|
||||
- `FloodWaitError` → пауза по секундам из ответа + запас; авто-вступления останавливаются
|
||||
до следующего дня при флуде. Общий «стоп-кран» — пауза всего discovery.
|
||||
- Все квоты/интервалы — настройки в UI.
|
||||
|
||||
## 9. Хранилище
|
||||
|
||||
| Таблица | Назначение / ключевые поля |
|
||||
| --- | --- |
|
||||
| `disc_tasks` | задачи: name, description, keywords(JSON), min_subscribers, lang, threshold, sample_size, plan_joins, auto_join, status, counters (found/evaluated/joined/rejected), created/updated |
|
||||
| `disc_candidates` | dialog_id/username/name/kind(channel|group|forum)/hue, participants, lang_ru, join_failures, marks(JSON), topics(JSON: {topicId,title,fitCount,total,fitRatio,passed}), fit_ratio, status(new/review/joined/rejected), task_id, times |
|
||||
| `disc_blacklist` | dialog_id, name, reason, created_at |
|
||||
| `disc_log` | история задачи: task_id, event(search/evaluate/join/leave/flood/error/review/blacklist), text, created_at |
|
||||
|
||||
Дубли кандидатов не создаются; источник, попавший в другую задачу или `dialogs`,
|
||||
из поиска исключается (правило «мы состоим» — глобальное).
|
||||
|
||||
Реализация: у кандидата нет транзитного статуса `evaluated` (счётчик оценённых — на
|
||||
задаче); `join_failures` — неудачные авто-вступления подряд, после 3 кандидат удаляется.
|
||||
|
||||
## 10. API
|
||||
|
||||
- `GET/POST/PATCH/DELETE /api/discovery/tasks` (создание с валидацией бюджета планов),
|
||||
`POST /api/discovery/tasks/{id}/start|pause`
|
||||
- `POST /api/discovery/tasks/{id}/generate-keywords` — ИИ генерирует ключи по описанию
|
||||
- `GET /api/discovery/tasks/{id}/candidates?status=review|joined|rejected`
|
||||
- `POST /api/discovery/candidates/{id}/join` (вступить и мониторить), `.../reject`
|
||||
- `GET /api/discovery/blacklist`, `DELETE /api/discovery/blacklist/{dialog_id}`
|
||||
- `GET /api/discovery/tasks/{id}/log`
|
||||
|
||||
## 11. UI
|
||||
|
||||
Подвкладка **«Поиск»** на экране «Каналы»:
|
||||
- список задач (статус, прогресс, план/вступили, авто-режим) + «Новая задача»;
|
||||
- мастер задачи: описание → «Сгенерировать ключи ИИ» → редактирование ключей →
|
||||
фильтры/план/авто-режим → запуск;
|
||||
- по задаче: статус-лента (поиск → оценка → вступление), вкладки «В обработке /
|
||||
На рассмотрении / Вступили / Отклонены», история;
|
||||
- кандидат на рассмотрении раскрывается с превью и действиями; форум — по темам;
|
||||
- настройки квот (лимит/интервалы) — в том же экране или «Настройки → Telegram».
|
||||
|
||||
## 12. Интеграция с существующим кодом
|
||||
|
||||
- Фоновый воркер discovery — отдельный цикл в `main.py` (как `_pipeline_loop`),
|
||||
сервис `app/services/discovery.py`, Telegram-действия — методы `TelegramManager`
|
||||
(поиск/join/leave/чтение) с общим pacing.
|
||||
- Переиспользуем: `rules/stage1/ML/AI` для оценки сообщений (новый лёгкий вызов с
|
||||
профилем задачи, без записи карточек), список `dialogs` для фильтра «мы состоим»,
|
||||
синхронизацию диалогов для авто-добавления закрытых групп.
|
||||
- К основному пайплайну карточек, ML и ТЗ-логике не прикасаемся.
|
||||
|
||||
## 13. Вне рамок (сейчас)
|
||||
|
||||
- Агрегаторы-каталоги как источник кандидатов.
|
||||
- «Похожие каналы» (`getChannelRecommendations`) от наших подписок — отдельная опция позже.
|
||||
- Авто-вступление в закрытые группы по инвайт-ссылкам (глобальным поиском они не находятся).
|
||||
|
||||
## 14. Значения по умолчанию (настраиваются в UI)
|
||||
|
||||
суточный лимит вступлений = 50; интервал авто-вступлений = 50–70 с; выборка = 10
|
||||
сообщений; порог = 40%; мин. содержательных сообщений для оценки = 3; язык = ru.
|
||||
@@ -0,0 +1,86 @@
|
||||
# Открытые вопросы: вложения источников (media → Storage) и просмотр исходника
|
||||
|
||||
Дата: 2026-09-11. Статус: решения владельца получены (см. §0).
|
||||
|
||||
## 0. Решения владельца (2026-09-11)
|
||||
|
||||
- **А) Медиа-посты пропускаем.** Сообщения без текста (только медиа/вложение) в систему не попадают.
|
||||
Извлечение вложений Telegram и выгрузка их в Storage не делаются. Generic-контракт по-прежнему умеет
|
||||
нести `DataRef` — этим смогут пользоваться другие источники (файл/диск/таблица) и ручные вложения карточки.
|
||||
- **Б) Проверка без живого Telegram** — реализуем с юнит-тестами на фейковой сессии/фейковом Storage,
|
||||
без реального API.
|
||||
- **В)** Объяснение термина — в §1.5. **Решение: делаем.** Реализован remote-просмотр: `TelegramService.ReadSource`,
|
||||
`ITelegramGateway.ReadSourceAsync`, `TelegramSourceContentProvider` (Kind=telegram) в ядре,
|
||||
`GET /api/cards/{id}/source` и кнопка «Обновить из источника» в подробной карточке.
|
||||
|
||||
## 1.5. Что такое «remote-просмотр исходника»
|
||||
|
||||
Карточка хранит **ссылку на источник** (`SourceRef`) и **содержимое** (`SourceContent`). Содержимое попадает
|
||||
в карточку в момент приёма. «Просмотр исходника» — это возможность по кнопке догрузить/показать **оригинальное
|
||||
сообщение у источника** (то, что было в канале/письме/строке), если контент в карточке устарел или урезан.
|
||||
|
||||
Сейчас содержимое уже отдаётся в `CardDto.content` и через `GET /api/cards/{id}/source`. Для локальных
|
||||
источников этого достаточно. Для **внешних** источников (например Telegram) данные лежат не в ядре, а в
|
||||
сервисе-владельце; чтобы их догрузить, ядру нужен провайдер `ISourceContentProvider` для `kind`, который
|
||||
ходит по gRPC к сервису-владельцу (условный RPC `ReadSource(dialogId, msgId)`) и возвращает исходный текст/медиа.
|
||||
|
||||
Это и есть «remote-просмотр» — расширение extension-point, которое не требуется до появления реальной
|
||||
необходимости (напр. если карточки хранят урезанный текст или нужно открыть живой первоисточник).
|
||||
|
||||
## 1. Что уже готово (не требует решений)
|
||||
|
||||
- Единый контракт источника несёт вложения: `SourceContent.Data: IReadOnlyList<DataRef>` —
|
||||
ссылки на объекты Storage-сервиса (`DataRef.Id/Ref/Kind/MimeType/...`).
|
||||
- Контракт входящего потока (`src/contracts/sources.proto`, `PushSource`) передаёт
|
||||
`DataRefProto`/`ContactRefProto` — источник может прислать вложения сразу со ссылками.
|
||||
- Storage-сервис (`src/storage-service/Deal.Storage`, `storage.proto`) умеет `Upload/Download/Stat/Delete`,
|
||||
сам определяет `kind`/`mimeType`/размеры (контент-снифинг), бэкенд — MinIO.
|
||||
- Ядро хранит `SourceContent` карточки (в т.ч. `Data`) и отдаёт его в `CardDto.content` и через
|
||||
`GET /api/cards/{id}/source` (extension-point `ISourceContentProvider` + `SourceContentResolver`).
|
||||
- Фронт рендерит вложения: `SourceContentView.vue` (image/video/audio/document/archive по `kind`),
|
||||
ссылки и контакты — списками.
|
||||
|
||||
## 2. Проблемная часть (требует живого Telegram)
|
||||
|
||||
Извлечение и выгрузка медиа из Telegram не проверяемы офлайн:
|
||||
|
||||
1. **Медиа-сообщения сейчас отбрасываются.** `TlMessageMapper.ToMessage`
|
||||
(`src/telegram-service/Deal.Telegram/Telegram/TlMessageMapper.cs`) принимает только `Message`
|
||||
с непустым `message` (текстом). Посты с одним вложением и подписью (`media` + `caption`) не попадают
|
||||
в поток вообще. Нужно: определять `Message.media`, читать `caption`, тип/размеры/длительность.
|
||||
2. **Скачивание и выгрузка.** Требуется `client.DownloadMedia(...)` (WTelegram) → поток →
|
||||
`StorageService.Upload(meta + data)` → `DataRefProto`. В telegram-сервисе нет gRPC-клиента Storage
|
||||
и соответствующей конфигурации в compose (endpoint/токен). Проверить можно только с реальным
|
||||
Telegram-аккаунтом и живым MinIO.
|
||||
3. **Подпись без текста.** Даже если вложение извлечено, в посте может не быть текста: нужен ли такой
|
||||
пост «карточкой» (сейчас `PipelineIngestService` пропускает записи без `Content.Text`)? Предлагается
|
||||
принимать запись, если есть текст **или** вложения/ссылки/контакты, а классификацию медиа-онли
|
||||
строить по подписи (`caption`) и метаданным. Требуется подтверждение продуктовой логики.
|
||||
4. **Просмотр исходника из другого контура.** «Открыть исходник» для remote-источников (Telegram — это
|
||||
лишь один из них) требует провайдера `ISourceContentProvider`, который ходит по gRPC к сервису-владельцу
|
||||
источника (новый RPC, например `ReadSource(dialogId, msgId)`), возвращая текст/медиа. Это тоже
|
||||
живой Telegram.
|
||||
|
||||
## 3. Предлагаемый план (после подтверждения)
|
||||
|
||||
1. `TelegramMessage` расширить моделью `TelegramAttachment` (caption, fileName, mimeType, size, width,
|
||||
height, durationSec, `Task<Stream> Open(cancellationToken)`), заполнять в `TlMessageMapper` из
|
||||
`Message.media`/`Document`/`Photo`.
|
||||
2. В telegram-сервисе добавить `StorageClient` (gRPC, `Deal.Grpc.Storage`) + `SourceAttachmentUploader`:
|
||||
загрузка каждого вложения → `DataRefProto`.
|
||||
3. `DialogProtoMapper.ToSourceRequest(message, dataRefs)` — прокинуть `content.data` и `caption`.
|
||||
4. `PipelineIngestService`: принимать запись при непустом тексте **или** непустых `Data`/`Links`/`Contacts`
|
||||
(нужно продуктовое решение по п.2.3).
|
||||
5. `ISourceContentProvider` для `kind="telegram"` — gRPC-провайдер к telegram-сервису (RPC `ReadSource`).
|
||||
6. Настройки: endpoint/токен Storage в `deploy/compose.dev.yml`/`compose.prod.yml` для telegram-сервиса.
|
||||
|
||||
## 4. Что нужно от владельца
|
||||
|
||||
- А) Делать ли медиа-сообщения без текста карточками (по подписи/метаданным), или пропускать?
|
||||
- Б) Для проверки вложений нужны живые Telegram api_id/api_hash и работающий MinIO — будет ли прогон
|
||||
на вашей стороне, или реализуем «слепо» с юнит-тестами на фейковой сессии и фейковом Storage?
|
||||
- В) Нужен ли remote-просмотр исходника (`ReadSource`) в этом объёме, или достаточно того, что
|
||||
содержимое хранится в карточке?
|
||||
|
||||
Пока эти пункты не закрыты, они вынесены в `backlog.md` (`TD-STORE-ATTACH`, `TD-SOURCE-PROVIDER`),
|
||||
а generic-часть (контракт, Storage-сервис, хранение, API, рендер) реализована.
|
||||
@@ -0,0 +1,193 @@
|
||||
# Дизайн: единый контракт источника + общий Storage-сервис данных
|
||||
|
||||
Дата: 2026-09-11. Статус: реализовано в ядре (домен, Storage-сервис, персистентность, конвейер, wire, фронт); адаптер/провайдер telegram-сервиса и Storage-выгрузка — следующие шаги. Контракт не плодит типы вложений; файлы — в общем Storage.
|
||||
|
||||
## 1. Принцип
|
||||
|
||||
1. **Единый строго типизированный контракт.** Любой источник (Telegram, WhatsApp, Avito, сайт, файл,
|
||||
Excel) через адаптер приводит данные к одному типу `SourceItem`. Ядро, AI и ML работают только с ним.
|
||||
2. **Данные файлов — в общем Storage-сервисе.** Каждый сервис-источник сам выгружает свои данные
|
||||
(картинки, видео, аудио, документы, любые файлы) в общий Storage с **токеном валидации**. Storage сам
|
||||
определяет тип и метаданные. В контракте хранится **ссылка** на файл, а не сам файл.
|
||||
3. **Никаких подтипов вложений в контракте.** Не плодим `ImagePart/VideoPart/...`; есть универсальный
|
||||
`DataRef` с полем `Kind`, которое заполняет Storage.
|
||||
4. Ссылки, контакты и прочее, что **не является файлом**, идут отдельными полями контента.
|
||||
5. В ядре нет Telegram-полей и слова Telegram (только в telegram-сервисе); в комментариях нет упоминаний
|
||||
задач/этапов/ТЗ.
|
||||
|
||||
## 2. Единый контракт (Deal.Modules.Cards)
|
||||
|
||||
```csharp
|
||||
public sealed record SourceItem
|
||||
{
|
||||
public required SourceRef Source { get; init; }
|
||||
public required SourceContent Content { get; init; }
|
||||
}
|
||||
|
||||
public sealed record SourceRef
|
||||
{
|
||||
public required string Kind { get; init; } // "telegram", "whatsapp", "avito", "file", "excel", ...
|
||||
public string? ExternalId { get; init; } // id в источнике (сообщение/строка/файл)
|
||||
public string? DisplayName { get; init; } // подпись в UI
|
||||
public string? OriginRef { get; init; } // url / deep-link / путь
|
||||
public string? Author { get; init; }
|
||||
public DateTimeOffset ReceivedAt { get; init; }
|
||||
public IReadOnlyDictionary<string, string>? Extra { get; init; }
|
||||
}
|
||||
|
||||
public sealed record SourceContent
|
||||
{
|
||||
public string? Text { get; init; } // основной текст
|
||||
public string? Html { get; init; } // разметка (если есть)
|
||||
public string? Author { get; init; } // отправитель
|
||||
public string? Subject { get; init; } // тема/заголовок
|
||||
public IReadOnlyList<DataRef> Data { get; init; } = []; // ссылки на файлы в Storage
|
||||
public IReadOnlyList<string>? Links { get; init; } // ссылки (не файлы)
|
||||
public IReadOnlyList<ContactRef>? Contacts { get; init; } // контакты
|
||||
public IReadOnlyDictionary<string, string>? Extra { get; init; } // прочее (не файл/не ссылка/не контакт)
|
||||
}
|
||||
```
|
||||
|
||||
`DataRef` — ссылка на объект в Storage; тип и метаданные определил Storage (nullable, чтобы не плодить типы):
|
||||
|
||||
```csharp
|
||||
public sealed record DataRef
|
||||
{
|
||||
public required string Id { get; init; } // идентификатор объекта в Storage
|
||||
public required string Ref { get; init; } // ссылка (url/путь) для скачивания/отображения
|
||||
public string? Kind { get; init; } // определил Storage: image/video/audio/document/archive/other
|
||||
public string? MimeType { get; init; }
|
||||
public string? FileName { get; init; }
|
||||
public long? Size { get; init; }
|
||||
public int? Width { get; init; }
|
||||
public int? Height { get; init; }
|
||||
public double? DurationSec { get; init; }
|
||||
public string? PreviewRef { get; init; } // превью/thumbnail
|
||||
public string? Caption { get; init; }
|
||||
public int? Order { get; init; }
|
||||
public IReadOnlyDictionary<string, string>? Meta { get; init; } // прочие метаданные от Storage
|
||||
}
|
||||
```
|
||||
|
||||
`ContactRef`: `Name?`, `Phone?`, `Email?`, `Url?`, `Kind?` (контакт может быть квалифицирован).
|
||||
|
||||
## 3. Storage-сервис (общий)
|
||||
|
||||
Отдельный сервис (как ai/ml/telegram), владелец — данные. Источники и ядро только ссылаются на объекты.
|
||||
|
||||
- **Загрузка:** `Upload(stream, token, fileName?) → DataRef`. Каждый сервис-источник выгружает свои данные
|
||||
**сам**, передавая **токен валидации** (сервисный токен/mTLS — уже есть в gRPC-обвязке).
|
||||
- **Определение типа:** Storage сам решает `Kind`/`MimeType`/размеры/длительность (контент-снифинг);
|
||||
контракт типы не задаёт.
|
||||
- **Чтение:** `Get(id) → (stream, DataRef)` либо выдача ссылки/временного URL.
|
||||
- **Бэкенд:** объектное хранилище (MinIO/S3). Путь/бакет — по тенанту.
|
||||
- **Владение:** единый общий сервис; каждый источник пишет в него со своим токеном, ядро/AI/ML читают по ссылке.
|
||||
|
||||
## 4. Адаптеры источников
|
||||
|
||||
```csharp
|
||||
public interface ISourceAdapter { string Kind { get; } SourceItem Normalize(object native); }
|
||||
```
|
||||
|
||||
Владельцы: `telegram` → telegram-сервис; `local` → ручное создание (Cards); `whatsapp`/`avito`/`web`/`file`/
|
||||
`excel` → соответствующий сервис. Файлы адаптер сам выгружает в Storage и кладёт в контракт `DataRef`.
|
||||
|
||||
## 5. Загрузка исходника карточки
|
||||
|
||||
Единый способ: по `SourceRef.Kind` — провайдер, возвращающий `SourceContent` (для файла — через Storage по
|
||||
`DataRef.Ref`, для сообщения — у источника). `ISourceContentProvider { Kind; LoadAsync(SourceRef) }` + реестр.
|
||||
API ядра: `GET /api/cards/{id}/source` → generic контент.
|
||||
|
||||
## 6. Персистентность
|
||||
|
||||
В карточках вместо плоских Telegram-колонок:
|
||||
|
||||
- `SourceKind` (text); `SourceJson` (jsonb, `SourceRef`);
|
||||
- `ContentJson` (jsonb, `SourceContent` — текст + `DataRef`-ссылки + прочее);
|
||||
- `SourceText` (text, FTS);
|
||||
- `SourceRefUrl` (text?, `OriginRef`).
|
||||
|
||||
Конвертер контента общий (без per-source сериализаторов). Миграции: старые удаляем → новый init с нуля.
|
||||
|
||||
## 7. Wire и фронт
|
||||
|
||||
- `CardDto.Source` = `{ kind, displayName?, originRef?, receivedAt }`.
|
||||
- `GET /api/cards/{id}/source` → `{ text?, html?, author?, subject?, data[], links[], contacts[], extra? }`.
|
||||
- Фронт: generic блок источника + универсальный просмотрщик (по `DataRef.Kind` — картинка/видео/аудио/файл;
|
||||
ссылки/контакты — списками).
|
||||
|
||||
## 8. Этапы
|
||||
|
||||
1. Домен: `SourceItem/SourceRef/SourceContent/DataRef/ContactRef`; удалить Telegram-маркеры из Cards.
|
||||
2. Storage-сервис: контракт gRPC, определение типа, токен валидации, бэкенд MinIO; регистрация.
|
||||
3. Персистентность: `SourceKind/SourceJson/ContentJson/SourceText/SourceRefUrl`, общий конвертер, новый init,
|
||||
маппинг KanbanStore.
|
||||
4. Pipeline: приём `SourceItem`, загрузка вложений в Storage адаптером, без Telegram-полей.
|
||||
5. Wire/API: generic `Source` в `CardDto`, `GET /api/cards/{id}/source`, провайдеры.
|
||||
6. Frontend: generic источник + универсальный просмотрщик.
|
||||
7. Telegram: адаптер + провайдер исходника (только в telegram-сервисе) + выгрузка в Storage.
|
||||
8. Комментарии: убрать упоминания Telegram из ядра и задачи/этапы — везде.
|
||||
|
||||
## 9. Реализация: зафиксированные сигнатуры
|
||||
|
||||
### Ядро: домен
|
||||
|
||||
- `SourceRefs` (Deal.Modules.Cards/Application/Sources): `Empty`, `DefaultHue = "#666"`,
|
||||
`HueKey = "hue"`, расширения `DedupeKey()` (вид|оригинал|внешний id), `ResolveHue()`.
|
||||
- `CardSnapshot`: вместо `ChannelName/ChannelHandle/ChannelHue/SourceMsg/SourceDialogId/SourceMsgId` —
|
||||
`SourceRef Source` + `SourceContent Content`; `ReceivedAt` остаётся.
|
||||
- `CardDto`: вместо `Channel`/`SourceMsg`/`SourceDialogId`/`SourceMsgId`/прежнего `Source` —
|
||||
`SourceRef Source` + `SourceContent Content`; `ReceivedAtMs` остаётся. `CardChannelDto`/`CardSourceDto` удалены.
|
||||
- `ICardStore.GetCardBySourceAsync(SourceRef source, CancellationToken ct)`.
|
||||
|
||||
### Ядро: конвейер
|
||||
|
||||
- `QueuedMessage { required SourceItem Item; bool Force; }`.
|
||||
- `QueueItemDto { string Id; SourceRef Source; SourceContent Content; string Text; string Status;
|
||||
long MsgAtMs; long QueuedAtMs; bool Force; }` (JsonIgnore на Force).
|
||||
- `RejectRecord { SourceRef Source; SourceContent Content; string Text; long MsgAtMs;
|
||||
string DecidedBy; string Stage; string Reason; string Kw; string? DeterministicId; }`
|
||||
(`DeterministicId = r_{Kind}_{OriginRef}_{ExternalId}`).
|
||||
- `RejectedItemDto`: `Source`/`Content`, `DecidedBy`/`DecidedByLabel` (решение), остальное как было.
|
||||
- `IPipelineStore.ExistsDuplicateAsync(SourceRef source, CancellationToken ct)`.
|
||||
- `PipelineChannelDto` удалён.
|
||||
|
||||
### Схема БД (схема тенанта)
|
||||
|
||||
- Cards: удалить `ChannelName/ChannelHandle/ChannelHue/SourceMsg/SourceDialogId/SourceMsgId`;
|
||||
добавить `SourceKind`, `SourceExternalId`, `SourceOriginRef` (text, для запросов), `SourceJson` (text),
|
||||
`ContentJson` (text), `SourceText` (text). FTS: Title+Summary+SourceText+Contact.
|
||||
- QueueItems: удалить `DialogId/ChannelName/ChannelHandle/ChannelHue/MsgId`; добавить
|
||||
`SourceKey` (text, уникальный ключ дедупа), `SourceJson`, `ContentJson`. `Text/MsgAt/Status/Force/CreatedAt/UpdatedAt` остаются.
|
||||
- RejectedItems: удалить `DialogId/MsgId/ChannelName/ChannelHandle/ChannelHue`; добавить
|
||||
`SourceKey`, `SourceJson`, `ContentJson`. FTS — по `Text`.
|
||||
- Миграции tenant: старые удалить, сгенерировать новый init с нуля (данных нет).
|
||||
|
||||
### Маппинг источника
|
||||
|
||||
- Telegram-адаптер (в ядре — тонкий край приёма): `Kind="telegram"`, `ExternalId=MsgId`,
|
||||
`OriginRef=DialogId`, `DisplayName=ChannelName`, `Extra["hue"]=ChannelHue` (иначе дефолт),
|
||||
`ReceivedAt=msgAt`, `Content.Text=Text`, `Content.Author=ChannelName`.
|
||||
- Дашборды/карточки/конвейер работают только с `SourceRef`/`SourceContent`; Telegram-поля не проходят дальше адаптера.
|
||||
|
||||
### Входящий поток (generic, 2026-09-11)
|
||||
|
||||
- `src/contracts/sources.proto` → сервис `SourceIngressService.PushSource` с generic-типами
|
||||
`SourceRefProto`/`SourceContentProto`/`DataRefProto`/`ContactRefProto`; вложения — ссылки на Storage.
|
||||
- Ядро: `Deal.Api/Sources/SourceIngressGrpcService` (приём) + `SourceProtoMapper` (proto → домен) +
|
||||
`ISourceIngestObserver` (вторичная обработка принятой записи, сбой наблюдателя не влияет на приём) +
|
||||
`IngressTenantResolver` (тенант по metadata).
|
||||
- Из `telegram.proto` удалён `IngressService.PushMessage` (остались `SyncDialogs`/`ReportStatus`);
|
||||
telegram-сервис шлёт записи через `PushSource` (`kind="telegram"`). Превью каталога/TgMessages сохраняет
|
||||
`TelegramSourceIngestObserver` (ядро, telegram-модуль — единственное место с telegram-спецификой приёма).
|
||||
- Любой другой источник (whatsapp/avito/файл/excel) шлёт тот же `PushSource` со своим `source.kind`.
|
||||
|
||||
### Remote-просмотр исходника (2026-09-11)
|
||||
|
||||
- `TelegramService.ReadSource(ReadSourceRequest{dialog_id, msg_id})` → `ReadSourceReply{found, text?, time?}`
|
||||
(`src/contracts/telegram.proto`); telegram-сервис достаёт конкретное сообщение
|
||||
(`ISessionClient.GetMessageAsync` → TL `Messages_GetMessages`). Медиа без текста → `found=false`.
|
||||
- Ядро: `ITelegramGateway.ReadSourceAsync` + `TelegramSourceContentProvider` (`ISourceContentProvider`,
|
||||
`Kind="telegram"`, `Deal.Infrastructure/Integrations/Sources`) — резолвится `SourceContentResolver`.
|
||||
- `GET /api/cards/{id}/source` отдаёт результат провайдера либо сохранённое содержимое карточки.
|
||||
Фронт: кнопка «Обновить из источника» в подробной карточке (`CardDrawer.vue` → `loadCardSource`).
|
||||
@@ -0,0 +1,53 @@
|
||||
# Дизайн: разбиение проектов на логические папки (namespace = папка)
|
||||
|
||||
Дата: 2026-09-11. Статус: согласовано владельцем (решения 1–5).
|
||||
|
||||
## 1. Цель
|
||||
|
||||
Упорядочить код по назначению: вместо «свалки» файлов разных видов в одной папке с единым
|
||||
`namespace` — подпапки по назначению, при этом `namespace` соответствует пути папки.
|
||||
|
||||
## 2. Таксономия папок (по назначению)
|
||||
|
||||
| Папка | Что кладём |
|
||||
| --- | --- |
|
||||
| `Abstractions/` | интерфейсы `I*.cs` |
|
||||
| `Services/` | прикладная логика: `*Service`, `*WorkerService.*`, `*Guard`, `*Pacer`, `*Evaluator`, `*Counter`, `*Detector`, `*Normalizer`, `*Matcher`, `*Composer`, `*Cleaner`, `*Classifier`, `*Mapper`, `*Builder`, `*Writer`, `*Recomputer`, `*Suggester`, `*Filler`, `*Generator`, `*Hasher` и аналогичные исполнители |
|
||||
| `Models/` | доменные типы: сущности, value-объекты, enum, статусы/виды, константные реестры (`*Statuses`, `*Kinds`, `*Prefixes`, `*Keys`, `*Events`, `*Periods`, `*Sources`, `*Field`, `*Defaults`) |
|
||||
| `Dtos/` | транспортные типы: `*Dto`, `*Request`, `*Response`, `*Patch` |
|
||||
| `Extensions/` | `*Extensions` |
|
||||
| `Options/` | `*Options` |
|
||||
| `Exceptions/` | `*Exception` |
|
||||
| `Registrars/` | `*ModuleRegistrar` |
|
||||
|
||||
Существующие feature-папки (`ColumnRules`, `Parse`, существующие `Models`) сохраняются.
|
||||
|
||||
## 3. Правила
|
||||
|
||||
1. `namespace` строго соответствует пути папки.
|
||||
2. Имена типов и публичные контракты не меняются — только расположение и `namespace`.
|
||||
3. Один тип = один файл (уже соблюдается).
|
||||
4. Частичные классы (`Foo.cs`, `Foo.Part.cs`) переносятся вместе.
|
||||
5. Тестовые проекты группируются по областям: `Modules/<X>`, `Api`, `Infrastructure` и т.п.,
|
||||
`namespace` = `Deal.Tests.Unit.<Область>`.
|
||||
|
||||
## 4. Механика переноса (на проект)
|
||||
|
||||
1. Классифицировать файлы по таблице §2.
|
||||
2. Перенести файлы в подпапки и заменить `namespace`.
|
||||
3. Миграция `using`: в файлах-потребителях заменить несуществующий старый `using <OldNs>;` на
|
||||
`using` всех новых подпространств (пере-добавление безопасно; при коллизии имён — ручное разрешение).
|
||||
Файлы внутри проекта-источника получают `using` соседних подпространств.
|
||||
4. `dotnet build` → исправить остатки (полные имена, `cref`), `dotnet test`.
|
||||
5. Отдельный коммит (русский) после каждого проекта.
|
||||
|
||||
## 5. Порядок
|
||||
|
||||
Пилот — `Deal.Modules.Cards` (чистый домен). Далее: остальные `Deal.Modules.*`, затем
|
||||
`Deal.Infrastructure`, `Deal.Api`, `Deal.Contracts`/`Deal.SharedKernel`, сервисы `telegram/ai/ml`,
|
||||
затем тестовые проекты. После каждого шага — сборка + тесты + коммит.
|
||||
|
||||
## 6. Риски
|
||||
|
||||
- Коллизия простых имён при пере-добавлении `using` → разрешается вручную по ошибкам сборки.
|
||||
- Не забыть `cref`/полные имена в XML-док и `nameof` — выявляются сборкой.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,271 @@
|
||||
# Дейл (Deal) — Инструкция пользователя
|
||||
|
||||
> Версия: 1.3 (этапы 0–12)
|
||||
> Дата: 2026-09-10
|
||||
> Назначение: как работать с продуктом — от регистрации до ежедневного использования.
|
||||
> Описывает итоговое состояние; dev-особенности помечены отдельно.
|
||||
|
||||
---
|
||||
|
||||
## 1. Регистрация по приглашению
|
||||
|
||||
«Дейл» — сервис с доступом по приглашениям: рабочее пространство (тенант) и пользователей заводит
|
||||
**оператор** (администратор сервиса); самостоятельной регистрации нет.
|
||||
|
||||
1. Оператор создаёт приглашение **на вашу почту** — в новое пространство или в уже существующее.
|
||||
2. Откройте присланную **ссылку приглашения** — откроется страница «Активация приглашения». Введите
|
||||
**email** (обязательно тот же, на который приглашение), имя пространства (необязательно) и
|
||||
**пароль** (минимум 8 символов).
|
||||
3. Нажмите «Активировать аккаунт». Приглашение «на новое пространство» при активации создаёт ваше
|
||||
рабочее пространство — данные изолированы от других клиентов. Приглашение «в существующее» добавляет
|
||||
вас пользователем в него.
|
||||
4. После активации вход — **email + пароль** (система сразу не входит — это отдельный шаг).
|
||||
|
||||
Особенности:
|
||||
- приглашение действует **72 часа**; истекло — оператор пришлёт новое;
|
||||
- email должен быть свободен: «Этот email уже зарегистрирован» → обратитесь к оператору;
|
||||
- пароль — **минимум 8 символов**; код в ссылке можно открыть только целиком (без кода страница
|
||||
сообщит, что ссылка неполная);
|
||||
- **забыли пароль** — самостоятельного восстановления нет, обратитесь к оператору;
|
||||
- вход приостановленного пространства невозможен («Учётная запись приостановлена») — вопросы к оператору.
|
||||
|
||||
> **Dev-окружение** (разработка/показ): регистрация не нужна — bootstrap-пространство `Default` с входом
|
||||
> `admin`/`admin`. Демо-ручки/кнопки (`POST /api/demo/*`, флаг `DEAL_DEMO`) удалены — новые карточки
|
||||
> создаются вручную; в обычной (прод) сборке — штатный контур оператор → инвайт.
|
||||
|
||||
---
|
||||
|
||||
## 2. Вход в систему
|
||||
|
||||
1. Вход — **email + пароль**, созданные при активации приглашения (раздел 1).
|
||||
2. В dev-окружении — `admin`/`admin` (без приглашения).
|
||||
3. Выход/смена пароля завершают текущую сессию.
|
||||
|
||||
> Все данные (каналы, карточки, настройки) принадлежат только вашему пространству и не видны другим
|
||||
> клиентам. Оператор видит только служебное: список пространств и их статусы, аудит входов/выходов и
|
||||
> действий (включая действия пользователей), расход ИИ-бюджета; к содержимому ваших данных и настроек
|
||||
> доступа у оператора нет.
|
||||
|
||||
---
|
||||
|
||||
## 3. Первый запуск: подключение Telegram
|
||||
|
||||
Приложение Telegram работает через **api_id и api_hash**. Их задаёт **оператор сервиса** глобально
|
||||
(раздел «Telegram» в оператор-консоли) — тенант ключи не вводит. Запущенные сервисы (полный стек)
|
||||
поднимает оператор. В dev-окружении без стека раздел показывает неподключённый статус.
|
||||
|
||||
1. Убедитесь, что оператор задал ключи приложения (без них подключение недоступно).
|
||||
2. Перейдите в раздел **«Настройки → Telegram»** и нажмите **«Добавить аккаунт»**.
|
||||
3. Отсканируйте **QR-код** (или введите телефон + код подтверждения).
|
||||
4. Дождитесь статуса «Telegram подключён». Сессия сохраняется — повторный вход не нужен.
|
||||
|
||||
После подключения система загрузит список ваших каналов и групп. Ключи приложения хранятся зашифрованно
|
||||
на стороне сервиса.
|
||||
|
||||
---
|
||||
|
||||
## 4. Каналы и источники
|
||||
|
||||
Раздел **«Каналы»** — список диалогов вашего аккаунта и управление мониторингом.
|
||||
|
||||
- **Включить мониторинг** у нужного канала/группы — система начнёт читать новые сообщения.
|
||||
- **«Перечитать»** — догнать последние ~10 сообщений всех включённых источников (например, после подключения).
|
||||
- **Включить все** — включить мониторинг всех каналов разом.
|
||||
- Новые чаты, которые вы добавили в Telegram, появятся в списке автоматически.
|
||||
Если включена настройка «новый чат → мониторинг», они начнут читаться сами.
|
||||
- Удалённые/покинутые чаты исчезают из списка.
|
||||
- Прочитанные системой сообщения помечаются прочитанными и в вашем Telegram.
|
||||
|
||||
### Поиск новых каналов (Discovery)
|
||||
Во вкладке **«Каналы → Поиск»** можно найти новые источники по вашей теме:
|
||||
|
||||
1. Нажмите **«Новая задача»**, опишите, что ищете (например: «каналы с вакансиями для C#-разработчика»).
|
||||
2. Нажмите **«Сгенерировать ключи»** — ИИ предложит поисковые слова (можно отредактировать).
|
||||
3. Задайте параметры: минимум подписчиков, язык, план вступлений, авто-вступление.
|
||||
4. Запустите задачу. Система найдёт каналы/группы, в которых вы **не состоите**, и оценит их
|
||||
(участники, язык, содержание — «подходит X из N»).
|
||||
5. В списке кандидатов выберите действие:
|
||||
- **«Вступить и мониторить»** — система вступит и начнёт читать;
|
||||
- **«Отклонить»** — источник уйдёт в чёрный список и больше не будет предлагаться.
|
||||
6. Закрытые группы/каналы помечаются — в них система вступить не может, решение за вами.
|
||||
7. **Авто-вступление**: если включено, система вступает сама с паузами и в рамках
|
||||
суточного лимита (настройки квот — в этом же разделе). Общий лимит делится между задачами.
|
||||
|
||||
---
|
||||
|
||||
## 5. Дашборд (карточки)
|
||||
|
||||
**«Дашборд»** — канбан с колонками. Карточка здесь и на экране «Выбранные» — **одна и та же
|
||||
сущность**: сняв её с дашборда «В работу», вы не создаёте копию, а переносите карточку в стадии.
|
||||
|
||||
- **Неразобранное** — сообщения, которые не подошли ни под одну колонку.
|
||||
- **Ваши колонки** — например, «WPF», «Фриланс», «Резюме» — куда система складывает подходящие карточки.
|
||||
- **Архив** и **Корзина** — служебные.
|
||||
|
||||
### Карточка
|
||||
На карточке: тип заявки (вакансия/заказ), время, заголовок, структурированная суть
|
||||
(компания → формат → о задаче → требования → условия), стек, бюджет (в валюте и в пересчёте),
|
||||
контакт и быстрые действия. Свежие карточки — сверху.
|
||||
|
||||
Действия с карточкой:
|
||||
- **«Взять в работу»** — перенести карточку на экран «Выбранные» (в стадию «Запланировано»);
|
||||
это тот же объект, а не копия;
|
||||
- **клик по карточке** — подробный просмотр (справа);
|
||||
- **комментарий** — иконка сообщения;
|
||||
- **в корзину** — иконка корзины;
|
||||
- **перетащить** в другую колонку — система запомнит (ML обучится) и в следующий раз
|
||||
похожие заявки положит туда же;
|
||||
- **контакт** — скопировать или открыть диалог;
|
||||
- **«Открыть исходник»** — перейти к оригинальному сообщению в Telegram.
|
||||
|
||||
В подробном просмотре доступны: полная структура заявки, исходное сообщение (под спойлером),
|
||||
комментарии, история, действия.
|
||||
|
||||
### Колонки
|
||||
- **Создать колонку** — задайте имя, описание и фильтры (ключевые слова, стек, уровень,
|
||||
бюджет, локация и т.д.). Все фильтры опциональны и могут сочетаться.
|
||||
- **Отрицательные фильтры** — что НЕ должно попадать в колонку (например, без английского языка).
|
||||
- ИИ может **предлагать колонки** по вашим карточкам — вы решаете: принять, переименовать или удалить.
|
||||
- В карточке видно, **по каким критериям** она попала в колонку.
|
||||
- Колонки можно сворачивать в виджет-счётчик, двигать, менять ширину, разворачивать на весь экран.
|
||||
|
||||
### Архив и корзина
|
||||
- В **архив** карточки уходят автоматически, если лежат дольше установленного срока
|
||||
(настройка 1–30 дней). Архив очищается через 90 дней.
|
||||
- В **корзину** попадают удалённые карточки; очищается раз в 7 дней.
|
||||
- Из архива/корзины карточку можно **вернуть** на канбан, пока её не очистили.
|
||||
- Полная ручная очистка архива/корзины — кнопка в шапке колонки (безвозвратно).
|
||||
|
||||
---
|
||||
|
||||
## 6. «Выбранные» (работа с заявками)
|
||||
|
||||
Экран **«Выбранные»** — канбан для **тех же карточек**, которые вы взяли в работу (не копии):
|
||||
|
||||
1. Возьмите карточку с дашборда кнопкой **«Взять в работу»** — система перенесёт её в стадию
|
||||
«Запланировано» (или создайте вручную — будет пометка «создано локально»).
|
||||
2. Ведите её по стадиям: *Запланировано → Отклик → Согласование → В работе → Проверка → Готово* (или «Отложено»).
|
||||
3. Перетаскивайте карточки между стадиями; наполняйте модули карточки (сумма, стек, контакты, ссылки,
|
||||
ТЗ, файлы, комментарии).
|
||||
|
||||
В карточке «Выбранных» доступно:
|
||||
- комментарии и **история движения** (статус, дата, время — под спойлером);
|
||||
- изменение суммы, стека, контактов;
|
||||
- прикрепление **ссылок** и **текста ТЗ**;
|
||||
- прикрепление **файлов** (изображения, документы и др. — тип определяется автоматически);
|
||||
на карточке видны значки количества файлов и ссылок.
|
||||
|
||||
Особенности:
|
||||
- карточки «Выбранных» **не попадают** в архив/корзину дашборда; свои состояния — «Отклонено» и «Выполнено»;
|
||||
- **«Отложено»**: при переносе система спросит, через какой срок напомнить и в какое время
|
||||
(можно выбрать дату в календаре). Если напоминания выключены в настройках — окно не появится.
|
||||
|
||||
---
|
||||
|
||||
## 7. Обработка (очередь и отсев)
|
||||
|
||||
Раздел **«Обработка»** — что происходит с сообщениями до карточек.
|
||||
|
||||
- **Очередь** — сырые сообщения, ждущие обработки. Обычно быстро пустеет.
|
||||
- **Отсев** — что система отклонила и **почему**:
|
||||
- «правила» — стоп-фраза (указана), резюме, тип заявки, нет суммы;
|
||||
- «ML» / «ИИ» — модель или ИИ посчитали сообщение спамом/не вашим;
|
||||
- «система» — устарело или повтор (карточка уже есть).
|
||||
- У записи: кнопка **«Открыть исходник»** (в Telegram) и исходное сообщение с форматированием.
|
||||
- **Поиск** по отсеву — полный текст.
|
||||
- **«Вернуть в обработку»**: если система ошиблась — верните сообщение, и оно создаст карточку.
|
||||
Причины отсева для него будут проигнорированы, а система обучится на вашем решении.
|
||||
- Отсев очищается автоматически раз в 3 дня (можно очистить вручную).
|
||||
|
||||
---
|
||||
|
||||
## 8. Настройки
|
||||
|
||||
**Настройки → Telegram:** подключение аккаунта, авто-мониторинг новых чатов.
|
||||
|
||||
**Настройки → ИИ:**
|
||||
- провайдер и модель (можно выбрать один, включая локальные OpenAI-совместимые);
|
||||
- ключ API (хранится зашифрованно);
|
||||
- **промпты**: базовый (не меняется) + свой промпт; библиотека готовых промптов по сферам
|
||||
с поиском и категориями; сохранённые свои промпты («Мои промпты»);
|
||||
- вкл/выкл ИИ и ИИ-фильтр. Если ML уже уверенно обрабатывает поток — система подскажет,
|
||||
что ИИ можно отключить.
|
||||
|
||||
**Настройки → ML:** включение, обучение на ваших действиях, проверка модели на сообщении/канале,
|
||||
сброс обучения, показатели самооценки.
|
||||
|
||||
**Настройки → Обработка:** стоп-фразы, минимальная длина, блокировка резюме, тип заявок,
|
||||
ключевые слова вашей сферы. **Глобальные исключения** — ключевые слова/технологии, локации, тип
|
||||
(вакансия/фриланс/объявление) и диапазон бюджета: такие сообщения отсекаются сразу, ещě до ML и ИИ
|
||||
(токены не расходуются). Отдельно — «не создавать карточку без суммы» (для вакансий и заказов отдельно).
|
||||
|
||||
**Настройки → Валюта и курсы:** валюта отображения, конвертация при получении,
|
||||
пересчёт старых карточек при смене валюты.
|
||||
|
||||
**Настройки → Хранение:** срок до архива (1–30 дней), очистка архива и корзины.
|
||||
|
||||
**Настройки → Уведомления:** общие напоминания и уведомления (в т.ч. об отложенных).
|
||||
|
||||
**Настройки → Внешний вид:** тема оформления — «Тёмная» (по умолчанию), «Светлая» или «Системная».
|
||||
Переключается мгновенно и запоминается.
|
||||
|
||||
---
|
||||
|
||||
## 9. ИИ-бюджет и уведомления
|
||||
|
||||
Обработка сообщений использует ИИ (классификация, ИИ-фильтр, генерация ключевых слов). У каждого
|
||||
пространства — **ИИ-бюджет** (обычно месячный, в токенах), который устанавливает оператор.
|
||||
|
||||
- При расходе **80% бюджета** приходит уведомление «ИИ-бюджет израсходован на 80%».
|
||||
- При **исчерпании** — уведомление «ИИ-бюджет исчерпан — обработка в локальном режиме»: система
|
||||
автоматически переходит на локальную обработку (правила/ML без ИИ); приём и разбор сообщений
|
||||
**не останавливается**, но глубина разбора снижается.
|
||||
- Увеличить бюджет/сменить период может только оператор; после смены предупреждения сбрасываются.
|
||||
- При **приостановке пространства** вход и ИИ-обработка недоступны — обратитесь к оператору.
|
||||
- Системные уведомления (архив/очистки, подключение Telegram, бюджет) приходят значком-колокольчиком
|
||||
в интерфейсе.
|
||||
|
||||
## 10. Советы
|
||||
|
||||
- Начните с подключения аккаунта → включите 2–3 канала → нажмите «Перечитать».
|
||||
- Создайте колонки под ваши типичные заказы и задайте им фильтры.
|
||||
- Переносите карточки руками — система учится и скоро начнёт раскладывать сама.
|
||||
- Заглядывайте в «Отсев»: если там ваши реальные заказы — верните их, система исправится.
|
||||
- Проверяйте вкладку «ИИ»: когда ML станет уверенной, можно отключить ИИ и сэкономить токены.
|
||||
|
||||
---
|
||||
|
||||
## 11. Для оператора сервиса (консоль)
|
||||
|
||||
> Этот раздел — для администратора сервиса «Дейл». Обычным пользователям он не нужен.
|
||||
|
||||
Оператор управляет сервисом из отдельной консоли: она открывается по адресу основного приложения
|
||||
с добавлением **`#/operator`** (например, `https://<адрес-сервиса>/#/operator`). Консоль — отдельный
|
||||
вход со своими учётными данными (логин/пароль выдаёт владелец сервиса).
|
||||
|
||||
Разделы консоли:
|
||||
|
||||
- **Тенанты** — список рабочих пространств с числом пользователей и статусом. Здесь можно создать
|
||||
пространство (при необходимости сразу с владельцем — ему выдаётся одноразовый пароль),
|
||||
**приостановить** и **возобновить** доступ, а также **войти от имени пользователя** пространства
|
||||
(impersonation) — удобно для поддержки; завершается обычным выходом.
|
||||
- **Приглашения** — создание приглашения на email (в новое или существующее пространство), отзыв
|
||||
и копирование **ссылки активации**, которую вы передаёте клиенту.
|
||||
- **Лимиты ИИ** — сводка расхода ИИ-бюджета по пространствам; можно изменить месячный/дневной бюджет
|
||||
и период. После смены предупреждения о расходе сбрасываются.
|
||||
- **Аудит** — лента действий (входы, выходы, приглашения, действия пользователей, изменения по
|
||||
пространствам и лимитам) с фильтрами по типу события, актору, пространству и периоду; есть пагинация.
|
||||
- **Аналитика** — обзор за период (число пространств, расход токенов, входы/выходы/неудачные входы),
|
||||
расход токенов с группировкой по дням/пространствам/провайдерам/моделям и лента действий.
|
||||
- **Состояние системы** — доступность ядра, базы данных и сервисов (Telegram, ИИ, ML).
|
||||
|
||||
> **Dev-окружение:** вход в консоль — `operator`/`operator`. В обычной (прод) сборке учётные
|
||||
> данные оператора задаются владельцем сервиса при развёртывании.
|
||||
|
||||
---
|
||||
|
||||
## 12. Язык интерфейса
|
||||
|
||||
Интерфейс «Дейла» — на русском. Все тексты (кнопки, подписи, подсказки, пустые состояния, уведомления,
|
||||
экраны оператора и страница активации) хранятся в словарях-ресурсах, а не в самих экранах.
|
||||
Переключателя языка нет — интерфейс всегда на русском.
|
||||
Reference in New Issue
Block a user