Compare commits

Author SHA1 Message Date
stepan 09358f07f2 Переиспользовать каталог полей конфигурации ИИ и покрыть битый шифротекст
ci / build-test (pull_request) Successful in 2m49s
2026-09-15 22:09:17 +03:00
stepan ea4ed73327 Обновить документацию под глобальную конфигурацию ИИ
ci / build-test (pull_request) Successful in 2m58s
ТЗ, api-map, техническая документация, инструкция пользователя и контракт операторских настроек: настройки ИИ-провайдера перенесены в консоль оператора, пользовательский POST /api/ai/check удалён.
2026-09-15 21:59:43 +03:00
stepan 6b7bfa5e4d Добавить раздел «ИИ» в консоль оператора
Оператор выбирает провайдера из каталога, задаёт модель, baseUrl (для локальных и «Другого») и API-ключ, проверяет связь. Во вкладке «ИИ» пользователя выбор провайдера, модель и поле ключа убраны — остались тумблер ИИ, промпты и библиотека; прочие функции не изменились.
2026-09-15 21:56:14 +03:00
stepan 78fe64f531 Перенести настройку ИИ-провайдера в глобальные настройки оператора
Провайдера, модель, baseUrl и API-ключ задаёт оператор: конфигурация хранится в public.global_settings (ключ aiConfig, шифрование enc:), читается общей для всех тенантов и используется AiProviderConfigBuilder во всех ИИ-вызовах. Настройки тенанта больше не содержат aiProvider/aiConfigs, пользовательский POST /api/ai/check удалён. Добавлены операторские ручки GET/PUT /api/operator/settings/ai-config и POST .../check с аудитом ai_config_changed.
2026-09-15 21:56:11 +03:00
rust dda314cddc Merge pull request 'Аудит: читаемые имена и «было → стало» вместо сырых данных' (#28) from t25_audit_readable into main
ci / build-test (push) Successful in 2m48s
Reviewed-on: #28
2026-09-14 23:36:10 +03:00
stepan 16f4270995 Скрывать в аудите изменения без изменения значения
ci / build-test (pull_request) Successful in 2m59s
2026-09-13 21:45:26 +03:00
stepan ac9d92d196 Подписывать настройки и колонки в аудите по-русски
ci / build-test (pull_request) Successful in 2m59s
2026-09-13 21:39:03 +03:00
stepan fb964f9b4f Показывать в аудите имена карточек и колонок вместо идентификаторов 2026-09-13 21:39:03 +03:00
stepan f7459069d1 Нормализовать список изменённых настроек в аудите 2026-09-13 21:39:02 +03:00
stepan 37b7524665 Исправить пробел в строке изменения аудита
ci / build-test (pull_request) Successful in 2m59s
2026-09-13 21:21:02 +03:00
stepan d0e202fbc6 Показывать в аудите значения изменённых настроек
settings_updated пишет по каждому ключу «поле: было → стало» (скаляры и списки; секреты и снимки состояния — пометкой «изменено»), длинные значения обрезаются.
2026-09-13 21:21:02 +03:00
stepan 905129effa Добавить подпись поля идентификатора в аудите
ci / build-test (pull_request) Successful in 3m0s
2026-09-13 21:08:15 +03:00
stepan f31989a133 Показывать в аудите изменение «было → стало»
Перемещение карточки пишет прежнюю и новую колонку (CardMoveResultDto.From/To), изменение колонки — переименование «было → стало», разбор старого плоского формата спаривает oldBudget/budgetTokens и oldPeriod/period. Добавлены тесты разбора.
2026-09-13 21:08:15 +03:00
rust 44d2ee61f7 Merge pull request 'Операторский аудит: понятные тексты действий и изменений вместо JSON' (#27) from t25_audit_readable into main
ci / build-test (push) Successful in 3m28s
Reviewed-on: #27
2026-09-13 21:01:40 +03:00
stepan 28cf8f23ca Починить сборку сервисов в Docker после ссылок на общие контракты
ci / build-test (pull_request) Successful in 2m57s
Dockerfile ai/ml/storage/telegram копируют core/Deal.SharedKernel (нужен Deal.Grpc.Hosting), telegram — ещё core/Deal.Contracts (каталоги операций и фаз Telegram).
2026-09-13 20:54:23 +03:00
stepan b190ac75b1 Использовать каталоги операций и фаз Telegram
ci / build-test (pull_request) Canceled after 2m33s
2026-09-13 20:51:43 +03:00
stepan b7c5e13fc3 Вынести виды файлов Storage в каталог 2026-09-13 20:51:42 +03:00
stepan 04d7a0f6fb Вынести wire-ключи и операции ИИ-сервиса в каталоги 2026-09-13 20:51:42 +03:00
stepan 1fbe29a838 Вынести магические строки статусов, видов и ключей в каталоги
Литералы статусов/видов/фаз и wire-ключей заменены каталогами: значения сущностей и репозиториев — TenantStatuses/TenantLimitPeriods/InviteStatuses/ContainerKinds/ContainerSpaces/CardIds/Discovery*/PipelineQueueStatuses; контакты и виды вложений — CardContactTypes/CardFileKinds; источники и этапы отсева — PipelineRejectStages/PipelineRejectSources; ключи промптов/провайдеров — SettingsFieldKeys; wire-ключи канбана — KanbanWireKeys; режимы правил — ColumnRuleModes; операции/фазы/виды диалогов/контакты Telegram и общий BoolText — в Deal.Contracts/Deal.SharedKernel; имена метрик — DealMetrics. Значения не менялись.
2026-09-13 20:51:36 +03:00
stepan 8dde35de49 Использовать каталог AuditFields в тестах
ci / build-test (pull_request) Successful in 2m59s
2026-09-13 20:32:24 +03:00
stepan 71bbdd4dd5 Вынести булевы литералы аудита в константы 2026-09-13 20:32:24 +03:00
stepan 482b345438 Вынести коды параметров аудита и ключи JSON в константы
Магические строки кодов деталей аудита заменены каталогом AuditFields (68 мест), ключи элемента изменения и разбора JSON — константами, ключи хранилища ключей Telegram и геометрия маски — именованными константами.
2026-09-13 20:32:11 +03:00
stepan 6a88032414 Уточнить документацию аудита: логин реального пользователя
ci / build-test (pull_request) Successful in 3m12s
2026-09-13 20:23:19 +03:00
stepan 525759b232 Проверить логин владельца в ответе аудита
Подставка хранилища пользователей умеет FindOwnerLoginsByTenantIdsAsync; тест аудита проверяет userName.
2026-09-13 20:23:19 +03:00
stepan a99bb4a713 Показывать реального пользователя в аудите и лимитах
Добавлен батч-резолв логинов владельцев пространств (IAuthStore.FindOwnerLoginsByTenantIdsAsync). Ответы аудита дополнены userName (владелец пространства либо логин попытки), ответы лимитов — ownerLogin; имя пространства остаётся вторичным.
2026-09-13 20:23:12 +03:00
stepan c46bbe9332 Вывести логин пользователя в консоли оператора
В аудите и лимитах основной строкой показывается логин реального пользователя, имя пространства и id — вторично.
2026-09-13 20:22:59 +03:00
stepan ac2bf4b302 Описать контракт деталей аудита в документации
ci / build-test (pull_request) Successful in 2m58s
2026-09-13 19:18:16 +03:00
stepan 4a6fa374c5 Обновить тесты аудита под контракт изменений
HTTP-тесты аудита и лимитов проверяют changes вместо плоского detailJson; подставки реестра умеют FindNamesByIdsAsync. Добавлены тесты разбора деталей (новый формат, старый плоский, повреждённый JSON) и проверка ответа аудита с именем пользователя и изменениями.
2026-09-13 19:18:16 +03:00
stepan ad5fa7c083 Показать понятный аудит в консоли оператора
Названия событий, акторов, параметров и значений вынесены в ресурсы ru.js (operator.event/actor/field/value) с заделом под языки. Таблица аудита показывает понятное название действия, сырой ключ вторично, изменения «было → стало» и сырой JSON под спойлером. В колонке пользователя основное — имя, id мелко под ним. Термин «тенант» в UI оператора заменён на «Пользователь».
2026-09-13 19:18:06 +03:00
stepan 02a4f9c749 Добавить контракт изменений деталей аудита
Детали событий описываются списком изменений «параметр: было → стало» (AuditChangeDto, AuditDetails), а не произвольным JSON. Запись везде идёт через конструктор AuditDetails; разбор старого плоского формата сохранён. Операторские ручки аудита отдают AuditRecordViewDto с changes и именем пользователя (батч-резолв через ITenantRepository.FindNamesByIdsAsync). Детектор подозрительной активности читает логин через новый разбор.
2026-09-13 19:17:57 +03:00
174 changed files with 4266 additions and 1564 deletions
+9 -10
View File
@@ -136,17 +136,19 @@
| `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
### 3.4 Settings / rates / meta (settings_routes.py) — 5
| 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}` + поля статуса провайдера |
| `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; `myPrompts` ≤100. ⚠ `tgKeys`, `aiProvider` и `aiConfigs` удалены из настроек тенанта — ключи Telegram и конфигурацию ИИ задаёт оператор глобально. ⚠ Ответ — **весь** public settings (фронт затирает локальное состояние ответом) | произвольный dict из публичных ключей | §4.6 |
| `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)* |
`POST /ai/check` удалён: проверка связи с провайдером ИИ теперь операторская —
`POST /api/operator/settings/ai-config/check` (см. `2026-09-10-operator-analytics-contract.md`).
### 3.5 Детальные операции карточки (бывший Projects, projects_routes.py) — 15
Все операции — над ресурсом `/api/cards/{cardId}` (см. §4.1); отдельного `/api/projects` больше нет.
@@ -347,14 +349,12 @@ links/files/history/tzText/reminder` — модули; `isNew/prevCol/isVacancy/
"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 (источник истины после клампов).
Ключи, которые фронт шлёт в PATCH (по одному/группами): `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`/`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`).
**Изменение (2026-09-14):** настройки ИИ-провайдера (`aiProvider`, `aiConfigs`, `providers`) из настроек тенанта убраны — провайдера, модель, адрес и ключ задаёт оператор в консоли (раздел «ИИ», `GET/PUT /api/operator/settings/ai-config`); все ИИ-вызовы всех пользователей идут на эту конфигурацию.
### 4.7 Промпты
- `aiPrompt`, `aiFilterPrompt`, `cardPrompt` — plain string, редактируются на вкладке ИИ; содержат плейсхолдеры `{domain}`/`{keywords}`.
@@ -381,7 +381,6 @@ links/files/history/tzText/reminder` — модули; `isNew/prevCol/isVacancy/
`{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` «только что».
---
@@ -400,7 +399,7 @@ links/files/history/tzText/reminder` — модули; `isNew/prevCol/isVacancy/
|---|---:|
| Auth `/api/auth` | 4 |
| Telegram `/api/tg` | 14 |
| Settings/rates/meta (`/api/settings`, `/api/ai/check`, `/api/rates`) | 5 |
| Settings/rates/meta (`/api/settings`, `/api/rates`) | 4 |
| Processing `/api/pipeline` (+ `/api/admin/check-message`) | 7 |
| ML `/api/ml` | 5 |
| Discovery `/api/discovery` | 13 |
@@ -149,8 +149,14 @@
"actorType": "tenant",
"actorId": "1f2e3d4c-5b6a-7980-1234-56789abcdef0",
"tenantId": "aabbccdd-eeff-0011-2233-445566778899",
"userName": "owner@example.com",
"tenantName": "ООО «Ромашка»",
"ip": "203.0.113.7",
"detailJson": "{\"cardId\":\"c_1a2b3c4d5e6f\",\"to\":\"planned\"}",
"changes": [
{ "field": "cardId", "to": "c_1a2b3c4d5e6f" },
{ "field": "to", "to": "planned" }
],
"detailJson": "{\"changes\":[{\"field\":\"cardId\",\"to\":\"c_1a2b3c4d5e6f\"},{\"field\":\"to\",\"to\":\"planned\"}]}",
"at": "2026-09-10T15:22:46.123Z",
"id": 1042
}
@@ -162,7 +168,19 @@
```
- `items` — новые сверху (`at` DESC). `total` — полное число по фильтру (без `limit`/`offset`).
- `detailJson`**строка** JSON деталей события (без секретов), может быть `null`.
- `userName` — логин реального пользователя (владелец пространства либо логин попытки); `null`, если не разрешён.
- `tenantName` — имя пространства события (join с реестром); `null`, если не разрешено.
- `changes` — человекочитаемые изменения параметров события (см. «Детали события»); `[]`, если деталей нет.
- `detailJson`**строка** сырого JSON деталей события (без секретов) для спойлера; может быть `null`.
### Детали события (`changes`)
Детали события хранятся как JSON вида `{ "changes": [ { "field": "<код>", "from": "<было>", "to": "<стало>" } ] }`,
где `field` — стабильный код параметра, `from` — предыдущее значение (`null` — параметр задан впервые),
`to` — новое. Сериализация деталей — `AuditService.ToDetailJson`, форма изменения — `AuditDetails.Set`/
`AuditDetails.Change`. Ядро отдаёт `changes` как есть; человекочитаемые названия событий, акторов, параметров
и значений — в ресурсах интерфейса (`ru.js`: `operator.event`/`operator.actor`/`operator.field`/`operator.value`).
Прежние записи плоского формата (`{ "login": "..." }`, пары `old*`/`new*`) читаются обратно совместимо.
**Коды**: `200`, `401`.
@@ -250,3 +268,73 @@
> Примечание для вкладки Telegram у тенанта: `GET /api/tg/status` остаётся (подключение аккаунта),
> поле `keysSet` отражает глобальные ключи; команды `start-phone`/`start-qr` без ключей отвечают
> `400 { "detail": "Ключи Telegram не заданы оператором" }`.
---
## Операторские настройки: глобальная конфигурация ИИ
Провайдер, модель, адрес API и ключ ИИ задаёт оператор **глобально**, едины для всех тенантов
(в настройках тенанта ключей `aiProvider`/`aiConfigs` больше нет). Хранилище — системная таблица
`public.global_settings` (ключ `aiConfig`), `apiKey` хранится зашифрованным (`enc:`) и наружу
не отдаётся. На эту конфигурацию работают все ИИ-вызовы всех тенантов: классификация, фильтр,
ключи поиска, карточки (`AiProviderConfigBuilder`).
### GET /api/operator/settings/ai-config
Маскированный снимок конфигурации вместе с каталогом провайдеров для выбора.
**200**
```json
{
"providerId": "deepseek",
"baseUrl": "https://api.deepseek.com",
"model": "deepseek-v4-flash",
"keySet": true,
"keyMasked": "sk-o…-123",
"providers": [
{ "id": "deepseek", "name": "DeepSeek", "base": "https://api.deepseek.com", "local": false, "models": ["…"] }
]
}
```
- `providerId` — пусто, если конфигурацию ещё не задавали.
- `baseUrl`/`model` — эффективные значения (адрес каталога, первая модель каталога), если не переопределены.
- `keyMasked`**маска** (пусто / `x…` / `1234…5678`); открытый ключ не возвращается никогда.
- `providers` — каталог `AiProviders` (`id`/`name`/`base`/`local`/`models`); адрес каталогных облачных
провайдеров фиксирован (SSRF-гейт), свой `baseUrl` задаётся только локальным и `custom`.
**Коды**: `200`, `401`.
### PUT /api/operator/settings/ai-config
Сохранение/смена. Поля можно передавать **по отдельности** (частичное обновление): непереданное поле
(`null` или отсутствие в JSON) сохраняет текущее значение. Если конфигурации ещё нет, `providerId` обязателен.
При смене провайдера `baseUrl`/`model`/`apiKey` не переносятся от старого (дефолты каталога); ключ
меняется только при явной передаче (маска не принимается).
**Тело**
```json
{ "providerId": "deepseek", "model": "deepseek-v4-pro", "apiKey": "sk-…" }
```
**200** — маскированный снимок (форма как у GET).
**Ошибки**
- `400 { "detail": "Укажите хотя бы одно поле (providerId, baseUrl, model, apiKey)" }`
- `400 { "detail": "Провайдер не из списка разрешённых" }`
- `400 { "detail": "Конфигурация ИИ ещё не задана — укажите providerId" }`
- `400 { "detail": "Укажите model — у выбранного провайдера нет моделей по умолчанию" }`
- `400 { "detail": "API-ключ должен быть не короче 8 символов, без маски" }`
- `401 { "detail": "Требуется вход оператора" }`
### POST /api/operator/settings/ai-config/check
Проверка связи с сохранённым провайдером (`200` — результат `AiCheckResultDto`). Локальный провайдер
отвечает `ok: true` без HTTP; облачный — запрос к списку моделей (приватные адреса запрещены, SSRF-гейт).
`400 { "detail": "Сначала сохраните конфигурацию ИИ" }` — конфигурации ещё нет.
**Аудит**: событие `ai_config_changed` (актор `operator`, `tenantId: null`, детали
`{providerId, baseUrl, model, keySet}` — без ключа).
@@ -189,9 +189,9 @@ Telegram-аккаунт, выбирает каналы/группы для мо
## 8. Настройки тенанта
- Telegram: ключи приложения (оператор), подключение аккаунта, авто-мониторинг новых.
- ИИ: провайдер (один; включая локальные), модель, ключ (хранится зашифрованно),
промпты (базовый + свой), библиотека готовых промптов по сферам + «мои промпты»,
вкл/выкл ИИ, вкл/выкл ИИ-фильтр.
- ИИ: провайдер (один; включая локальные), модель и ключ задаёт **оператор** глобально (едины для всех
тенантов; в консоли оператора раздел «ИИ», ключ хранится зашифрованно); пользователю — промпты
(базовый + свой), библиотека готовых промптов по сферам + «мои промпты», вкл/выкл ИИ, вкл/выкл ИИ-фильтр.
- ML: вкл/выкл, обучение на действиях, проверка на сообщении/канале, сброс, самооценка
(«ML справляется с последними N сообщениями — ИИ можно отключить»).
- Обработка: стоп-фразы, длина, резюме, тип заявки, домен/ключи, маркеры найма/заказа.
@@ -219,6 +219,8 @@ Telegram-аккаунт, выбирает каналы/группы для мо
## 10. Админка оператора
- Тенанты: создание, инвайты, статус, лимиты/бюджеты, приостановка.
- Глобальные настройки сервиса: ключи приложения Telegram и конфигурация ИИ-провайдера
(провайдер/модель/baseUrl/ключ; ключ зашифрован, наружу — маска) с проверкой связи.
- Health всех сервисов и очередей.
- Аудит: входы/выходы, инвайты, impersonation, действия оператора и пользователей тенанта
(создание/перенос/удаление карточек, комментарии, контейнеры, настройки, каналы).
@@ -119,8 +119,10 @@ global_settings(key, value, updated_at)
> `token_usage_events` — история расхода токенов (этап 10, T2; подробнее — §13.10).
> `global_settings` — глобальные настройки уровня сервиса; сейчас хранит ключи приложения Telegram
> (`telegramKeys`: `api_id`/`api_hash`, hash — в `enc:`), которые задаёт **оператор** глобально
> (ручки `GET/PUT /api/operator/settings/telegram-keys`); тенант ключи не видит/не задаёт.
> (`telegramKeys`: `api_id`/`api_hash`, hash — в `enc:`) и конфигурацию ИИ-провайдера (`aiConfig`:
> `providerId`/`baseUrl`/`model`/`apiKey` — в `enc:`), которые задаёт **оператор** глобально
> (ручки `GET/PUT /api/operator/settings/telegram-keys` и `/ai-config`, проверка связи —
> `POST /api/operator/settings/ai-config/check`); тенант эти настройки не видит и не задаёт.
> Операторские таблицы этапа 7 (`operators`, `operator_sessions`, `tenant_limits`, `audit_log`) и их
> контур описаны в §13.8. С этапа 12 счётчики распределённого rate-limit и попыток входа —
> `public.rate_limit_counters` (см. §10).
@@ -496,7 +498,8 @@ DEAL_MTLS_ENABLED=0|1 DEAL_MTLS_CERT_PASSWORD=... DEAL_DEFAULT_AI_BU
ответ — полный снимок; секреты наружу только масками `keyMasked`/`apiId`; внутренние ключи
`ratesCache`/`mlDecisions`/`aiDecisions` не публикуются);
- шифрование секретов AI/Telegram: AES-256-GCM, в БД — `enc:` + Base64 (ключ — env/file, см. §13.4a);
- проверка подключения ИИ: `POST /api/ai/check` (локальный провайдер / HTTP-проверка облачного);
- проверка подключения ИИ: `POST /api/operator/settings/ai-config/check` (операторская; локальный провайдер /
HTTP-проверка облачного);
- курсы валют: `GET /api/rates`, `POST /api/rates/refresh` (кэш `ratesCache` в settings; `mock`/ЦБ);
- ML-панель на детерминированной заглушке: `GET /api/ml/status`, `POST /api/ml/reset|predict`
(candidates → `{items:[]}`, apply → 404 — нет telegram-данных до этапа 6);
@@ -774,28 +777,30 @@ health — `GET /api/health` → `{"ok":true,"service":"deal"}`.
### 4a. Шифрование секретов настроек (ключи AI/Telegram)
Секреты (`aiConfigs[].apiKey`) хранятся в `settings.ValueJson` шифротекстом:
`enc:` + Base64(nonce‖ct‖tag), AES-256-GCM (nonce 12 Б, tag 16 Б). Ключ шифрования — env
`DEAL_ENCRYPTION_KEY` (32 байта в urlsafe-Base64); при отсутствии в dev берётся/создаётся файл
`<ContentRoot>/data/encryption.key` (путь переопределяется env `DEAL_ENCRYPTION_KEY_FILE`) —
при генерации лог-warning. Невалидный env-ключ — ошибка при старте. Наружу секреты не отдаются:
в GET/PATCH `/api/settings` только маски `keyMasked` (первые 4 + «…» + последние 4, len≤8 — как есть)
Секреты хранятся шифротекстом `enc:` + Base64(nonce‖ct‖tag), AES-256-GCM (nonce 12 Б, tag 16 Б).
Ключ шифрования — env `DEAL_ENCRYPTION_KEY` (32 байта в urlsafe-Base64); при отсутствии в dev
берётся/создаётся файл `<ContentRoot>/data/encryption.key` (путь переопределяется env
`DEAL_ENCRYPTION_KEY_FILE`) — при генерации лог-warning. Невалидный env-ключ — ошибка при старте.
Наружу секреты не отдаются: только маски `keyMasked` (первые 4 + «…» + последние 4, len≤8 — как есть)
и `keySet`.
> Исторический раздел (этап 2). С этапа 12 ключей Telegram (`tgKeys`/`apiId`/`apiHash`) в настройках
> тенанта нет — они задаются **оператором** глобально (таблица `public.global_settings`,
> `GET/PUT /api/operator/settings/telegram-keys`; hash шифруется тем же AES-256-GCM).
>
> С 2026-09-14 там же живёт и конфигурация ИИ-провайдера (ключ `aiConfig` в `public.global_settings`,
> `GET/PUT /api/operator/settings/ai-config`): провайдер, модель, baseUrl и API-ключ задаёт оператор,
> все ИИ-вызовы всех тенантов идут на эту конфигурацию; в настройках тенанта ключей `aiProvider`/
> `aiConfigs` больше нет.
### 4b. Эндпоинты этапа 2 (настройки тенанта; сессия `deal_session` обязательна, иначе 401)
- `GET /api/settings` — публичный снимок дерева настроек: дефолты модуля, перекрытые
переопределениями из `settings` тенанта; включает списки `providers`/`aiConfigs`/`tgKeys`/`myPrompts`.
переопределениями из `settings` тенанта; включает `myPrompts` и колонки, но не настройки
ИИ-провайдера (они операторские).
`PATCH /api/settings` — частичное обновление (невалидное поле мягко пропускается, ответ — полный
снимок). Побочные эффекты: при `rateSource` — фоновый refresh курсов. Внутренние ключи
(`ratesCache`, `mlDecisions`, `aiDecisions`) в GET/PATCH не участвуют.
- `POST /api/ai/check` — проверка подключения активного провайдера (`aiProvider` + `aiConfigs`, ключ
расшифровывается): локальный провайдер → `ok:true` «Локальный сервер…»; облачный — HTTP `GET
{base}/models`; без ключа → «Не задан API-ключ».
- `GET /api/rates` / `POST /api/rates/refresh` — курсы к RUB (`base` = `RUB`); источник по `rateSource`
(`mock` — константа, `cbr` — ЦБ РФ, ≤4 запроса/сутки, интервал 6 ч; `USDT`=`USD`); кэш — внутренняя
настройка `ratesCache` `{rates, source, updatedAtMs}`.
@@ -1149,8 +1154,9 @@ docker compose -f deploy/compose.dev.yml down # погасить ст
(`PUT /api/operator/settings/telegram-keys`, hash шифруется) → `POST /api/tg/start-qr` → QR-скан →
фаза `ready` («Telegram подключён, сессия сохранена»), затем реальные диалоги/мониторинг/«Перечитать»/
discovery-поиск и вступления. В настройках тенанта ключей нет (решение владельца, вариант A).
- LLM: `PATCH /api/settings` `aiConfigs`/`aiProvider` (напр. DeepSeek или локальный OpenAI-совместимый) →
`POST /api/ai/check`; реальная классификация/фильтр/генерация ключей при `Services__Ai__UseLocal=false`.
- LLM: оператор задаёт провайдера и модель в консоли (`PUT /api/operator/settings/ai-config`, напр. DeepSeek
или локальный OpenAI-совместимый) → `POST /api/operator/settings/ai-config/check`; реальная
классификация/фильтр/генерация ключей при `Services__Ai__UseLocal=false`.
- Сквозной smoke стека — `scripts/dev-smoke.sh` (одна команда; Docker Desktop должен быть поднят).
### 8. Этап 7 — SaaS-контур (Tasks 114; бэкапы — §13.9; финальные доки — Task 16): оператор/инвайты/лимиты/аудит/rate-limit/mTLS/логи/compose-prod
@@ -184,12 +184,13 @@
**Настройки → Telegram:** подключение аккаунта, авто-мониторинг новых чатов.
**Настройки → ИИ:**
- провайдер и модель (можно выбрать один, включая локальные OpenAI-совместимые);
- ключ API (хранится зашифрованно);
- **промпты**: базовый (не меняется) + свой промпт; библиотека готовых промптов по сферам
с поиском и категориями; сохранённые свои промпты («Мои промпты»);
- вкл/выкл ИИ и ИИ-фильтр. Если ML уже уверенно обрабатывает поток — система подскажет,
что ИИ можно отключить.
что ИИ можно отключить;
- **промпты**: базовый (не меняется) + свой промпт; библиотека готовых промптов по сферам
с поиском и категориями; сохранённые свои промпты («Мои промпты»).
Провайдера, модель и API-ключ задаёт оператор сервиса — они едины для всех пользователей
и в кабинете не настраиваются.
**Настройки → ML:** включение, обучение на ваших действиях, проверка модели на сообщении/канале,
сброс обучения, показатели самооценки.
@@ -257,6 +258,9 @@
пространствам и лимитам) с фильтрами по типу события, актору, пространству и периоду; есть пагинация.
- **Аналитика** — обзор за период (число пространств, расход токенов, входы/выходы/неудачные входы),
расход токенов с группировкой по дням/пространствам/провайдерам/моделям и лента действий.
- **ИИ** — глобальный провайдер ИИ: выбор провайдера из каталога, модель, адрес API (для локальных
и «Другого») и API-ключ, а также проверка связи. Конфигурация единая для всех пользователей;
ключ хранится зашифрованным и показывается только маской.
- **Состояние системы** — доступность ядра, базы данных и сервисов (Telegram, ИИ, ML).
> **Dev-окружение:** вход в консоль — `operator`/`operator`. В обычной (прод) сборке учётные
+27
View File
@@ -0,0 +1,27 @@
namespace Deal.Ai;
/// <summary>
/// Имена операций AiService для аудита
/// </summary>
public static class AiOperations
{
/// <summary>
/// Операция ИИ-фильтра входящих сообщений.
/// </summary>
public const string Filter = "filter";
/// <summary>
/// Операция полного разбора лида.
/// </summary>
public const string Classify = "classify";
/// <summary>
/// Операция генерации ключевых слов discovery-задачи.
/// </summary>
public const string GenerateKeywords = "generate_keywords";
/// <summary>
/// Операция оценки соответствия сообщения задаче поиска.
/// </summary>
public const string EvaluateFit = "evaluate_fit";
}
+4 -4
View File
@@ -150,7 +150,7 @@ public sealed class AiServiceImpl : AiService.AiServiceBase
}
catch (LlmCallException callError)
{
LogAiUnavailable("filter", tenantId, config, callError);
LogAiUnavailable(AiOperations.Filter, tenantId, config, callError);
throw ToUnavailable(callError);
}
}
@@ -191,7 +191,7 @@ public sealed class AiServiceImpl : AiService.AiServiceBase
}
catch (LlmCallException callError)
{
LogAiUnavailable("classify", tenantId, config, callError);
LogAiUnavailable(AiOperations.Classify, tenantId, config, callError);
throw ToUnavailable(callError);
}
}
@@ -226,7 +226,7 @@ public sealed class AiServiceImpl : AiService.AiServiceBase
}
catch (LlmCallException callError)
{
LogAiUnavailable("generate_keywords", tenantId, config, callError);
LogAiUnavailable(AiOperations.GenerateKeywords, tenantId, config, callError);
throw ToUnavailable(callError);
}
}
@@ -267,7 +267,7 @@ public sealed class AiServiceImpl : AiService.AiServiceBase
}
catch (LlmCallException callError)
{
LogAiUnavailable("evaluate_fit", tenantId, config, callError);
LogAiUnavailable(AiOperations.EvaluateFit, tenantId, config, callError);
throw ToUnavailable(callError);
}
}
+2
View File
@@ -12,6 +12,7 @@ WORKDIR /repo
# Restore-слой: только csproj/props (кэш слоёв Docker — restore не повторяется при правке исходников).
COPY src/contracts/Deal.Proto.csproj src/contracts/
COPY src/grpc-hosting/Deal.Grpc.Hosting/Deal.Grpc.Hosting.csproj src/grpc-hosting/Deal.Grpc.Hosting/
COPY src/core/Deal.SharedKernel/Deal.SharedKernel.csproj src/core/Deal.SharedKernel/
COPY src/ai-service/Directory.Build.props src/ai-service/
COPY src/ai-service/Deal.Ai/Deal.Ai.csproj src/ai-service/Deal.Ai/
RUN dotnet restore src/ai-service/Deal.Ai/Deal.Ai.csproj
@@ -19,6 +20,7 @@ RUN dotnet restore src/ai-service/Deal.Ai/Deal.Ai.csproj
# Исходники: контракты (.proto) + общая gRPC-обвязка + проект сервиса.
COPY src/contracts/ src/contracts/
COPY src/grpc-hosting/ src/grpc-hosting/
COPY src/core/Deal.SharedKernel/ src/core/Deal.SharedKernel/
COPY src/ai-service/Deal.Ai/ src/ai-service/Deal.Ai/
RUN dotnet publish src/ai-service/Deal.Ai/Deal.Ai.csproj -c Release -o /app/publish
@@ -0,0 +1,17 @@
namespace Deal.Ai.Llm;
/// <summary>
/// Роли сообщений чата в wire-формате ИИ-провайдеров
/// </summary>
public static class LlmChatRoles
{
/// <summary>
/// Роль системного сообщения.
/// </summary>
public const string System = "system";
/// <summary>
/// Роль пользовательского сообщения.
/// </summary>
public const string User = "user";
}
+25 -25
View File
@@ -100,12 +100,12 @@ public sealed class LlmHttpClient : IProviderClient
var body = new JsonObject
{
["model"] = config.Model,
["messages"] = new JsonArray(
ChatMessage("system", systemPrompt),
ChatMessage("user", userText)),
["temperature"] = Temperature,
["max_tokens"] = MaxResponseTokens,
[LlmWireKeys.Model] = config.Model,
[LlmWireKeys.Messages] = new JsonArray(
ChatMessage(LlmChatRoles.System, systemPrompt),
ChatMessage(LlmChatRoles.User, userText)),
[LlmWireKeys.Temperature] = Temperature,
[LlmWireKeys.MaxTokens] = MaxResponseTokens,
};
request.Content = JsonBody(body);
@@ -123,10 +123,10 @@ public sealed class LlmHttpClient : IProviderClient
var body = new JsonObject
{
["model"] = config.Model,
["max_tokens"] = MaxResponseTokens,
["system"] = systemPrompt,
["messages"] = new JsonArray(ChatMessage("user", userText)),
[LlmWireKeys.Model] = config.Model,
[LlmWireKeys.MaxTokens] = MaxResponseTokens,
[LlmWireKeys.System] = systemPrompt,
[LlmWireKeys.Messages] = new JsonArray(ChatMessage(LlmChatRoles.User, userText)),
};
request.Content = JsonBody(body);
@@ -156,7 +156,7 @@ public sealed class LlmHttpClient : IProviderClient
private static ProviderChatResult ReadOpenAiBody(string body)
{
JsonObject? payload = ParseObjectOrThrow(body);
JsonArray? choices = payload["choices"] as JsonArray;
JsonArray? choices = payload[LlmWireKeys.Choices] as JsonArray;
if (choices is null || choices.Count == 0)
{
// Тип сбоя без содержимого тела (замечание code-review: тело ответа наружу/в лог не уходит).
@@ -164,14 +164,14 @@ public sealed class LlmHttpClient : IProviderClient
}
// choices[i] — объект выбора {message, finish_reason, …}; текст — в message.content.
JsonObject? message = (choices[0] as JsonObject)?["message"] as JsonObject;
string? content = ReadStringField(message, "content");
if (string.IsNullOrEmpty(content) && !string.IsNullOrEmpty(ReadStringField(message, "reasoning_content")))
JsonObject? message = (choices[0] as JsonObject)?[LlmWireKeys.Message] as JsonObject;
string? content = ReadStringField(message, LlmWireKeys.Content);
if (string.IsNullOrEmpty(content) && !string.IsNullOrEmpty(ReadStringField(message, LlmWireKeys.ReasoningContent)))
{
throw new LlmHttpException("Модель вернула только reasoning без ответа");
}
return new ProviderChatResult(content ?? string.Empty, ReadOpenAiUsage(payload["usage"]));
return new ProviderChatResult(content ?? string.Empty, ReadOpenAiUsage(payload[LlmWireKeys.Usage]));
}
private static ProviderChatResult ReadAnthropicBody(string body)
@@ -179,18 +179,18 @@ public sealed class LlmHttpClient : IProviderClient
JsonObject? payload = ParseObjectOrThrow(body);
var text = new StringBuilder();
if (payload["content"] is JsonArray contentBlocks)
if (payload[LlmWireKeys.Content] is JsonArray contentBlocks)
{
foreach (JsonNode? blockNode in contentBlocks)
{
if (blockNode is JsonObject block)
{
text.Append(ReadStringField(block, "text"));
text.Append(ReadStringField(block, LlmWireKeys.Text));
}
}
}
return new ProviderChatResult(text.ToString(), ReadAnthropicUsage(payload["usage"]));
return new ProviderChatResult(text.ToString(), ReadAnthropicUsage(payload[LlmWireKeys.Usage]));
}
// Возвращает usage OpenAI-совместимого ответа (prompt/completion/total_tokens) либо null.
@@ -202,9 +202,9 @@ public sealed class LlmHttpClient : IProviderClient
return null;
}
int? promptTokens = ReadIntField(usage, "prompt_tokens");
int? completionTokens = ReadIntField(usage, "completion_tokens");
int? totalTokens = ReadIntField(usage, "total_tokens");
int? promptTokens = ReadIntField(usage, LlmWireKeys.PromptTokens);
int? completionTokens = ReadIntField(usage, LlmWireKeys.CompletionTokens);
int? totalTokens = ReadIntField(usage, LlmWireKeys.TotalTokens);
return promptTokens is null || completionTokens is null || totalTokens is null
? null
: new ProviderUsage(promptTokens.Value, completionTokens.Value, totalTokens.Value);
@@ -219,8 +219,8 @@ public sealed class LlmHttpClient : IProviderClient
return null;
}
int? inputTokens = ReadIntField(usage, "input_tokens");
int? outputTokens = ReadIntField(usage, "output_tokens");
int? inputTokens = ReadIntField(usage, LlmWireKeys.InputTokens);
int? outputTokens = ReadIntField(usage, LlmWireKeys.OutputTokens);
return inputTokens is null || outputTokens is null
? null
: new ProviderUsage(inputTokens.Value, outputTokens.Value, inputTokens.Value + outputTokens.Value);
@@ -254,8 +254,8 @@ public sealed class LlmHttpClient : IProviderClient
private static JsonObject ChatMessage(string role, string content)
=> new()
{
["role"] = role,
["content"] = content,
[LlmWireKeys.Role] = role,
[LlmWireKeys.Content] = content,
};
// Создаёт JSON-содержимое запроса (application/json).
+92
View File
@@ -0,0 +1,92 @@
namespace Deal.Ai.Llm;
/// <summary>
/// JSON-ключи wire-формата запросов и ответов ИИ-провайдеров
/// </summary>
public static class LlmWireKeys
{
/// <summary>
/// Ключ имени модели.
/// </summary>
public const string Model = "model";
/// <summary>
/// Ключ списка сообщений чата.
/// </summary>
public const string Messages = "messages";
/// <summary>
/// Ключ системного промпта (Anthropic — отдельным полем).
/// </summary>
public const string System = "system";
/// <summary>
/// Ключ температуры генерации (OpenAI-совместимый формат).
/// </summary>
public const string Temperature = "temperature";
/// <summary>
/// Ключ лимита токенов ответа.
/// </summary>
public const string MaxTokens = "max_tokens";
/// <summary>
/// Ключ списка вариантов ответа (OpenAI-совместимый формат).
/// </summary>
public const string Choices = "choices";
/// <summary>
/// Ключ выбранного варианта ответа (OpenAI-совместимый формат).
/// </summary>
public const string Message = "message";
/// <summary>
/// Ключ текстового содержимого.
/// </summary>
public const string Content = "content";
/// <summary>
/// Ключ текста рассуждений модели (reasoning).
/// </summary>
public const string ReasoningContent = "reasoning_content";
/// <summary>
/// Ключ оценки токенов вызова.
/// </summary>
public const string Usage = "usage";
/// <summary>
/// Ключ текста блока контента (Anthropic).
/// </summary>
public const string Text = "text";
/// <summary>
/// Ключ числа токенов запроса (OpenAI-совместимый формат).
/// </summary>
public const string PromptTokens = "prompt_tokens";
/// <summary>
/// Ключ числа токенов ответа (OpenAI-совместимый формат).
/// </summary>
public const string CompletionTokens = "completion_tokens";
/// <summary>
/// Ключ суммарного числа токенов (OpenAI-совместимый формат).
/// </summary>
public const string TotalTokens = "total_tokens";
/// <summary>
/// Ключ числа входных токенов (Anthropic).
/// </summary>
public const string InputTokens = "input_tokens";
/// <summary>
/// Ключ числа выходных токенов (Anthropic).
/// </summary>
public const string OutputTokens = "output_tokens";
/// <summary>
/// Ключ роли сообщения чата.
/// </summary>
public const string Role = "role";
}
@@ -1,153 +0,0 @@
using System.Text.Json;
using Deal.Api.Extensions;
using Deal.Api.Services;
using Deal.Modules.Settings.Application.Abstractions;
using Deal.Modules.Settings.Application.Models;
namespace Deal.Api.Endpoints;
/// <summary>
/// Эндпоинт проверки подключения AI-провайдера
/// </summary>
public static class AiCheckEndpoint
{
private const string ApiGroupPrefix = "/api";
// Путь проверки подключения AI-провайдера.
private const string AiCheckPath = "/ai/check";
private const string OpenApiTag = "settings";
/// <summary>
/// Регистрирует POST /api/ai/check.
/// </summary>
/// <param name="app">Построитель маршрутов приложения.</param>
/// <returns>Построитель маршрутов для цепочки вызовов.</returns>
public static IEndpointRouteBuilder MapAiCheckEndpoint(this IEndpointRouteBuilder app)
{
var group = app.MapGroup(ApiGroupPrefix).WithTags(OpenApiTag);
group.MapPost(AiCheckPath, CheckAsync);
return app;
}
// POST /api/ai/check: проверка соединения с активным AI-провайдером тенанта.
private static async Task<IResult> CheckAsync(HttpContext context, CancellationToken ct)
{
if (context.GetCurrentUser() is null)
{
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
}
// Резолв после 401-гейта: ISettingsStore — scoped на TenantDbContext (tenant-контекст запроса).
ISettingsStore store = context.RequestServices.GetRequiredService<ISettingsStore>();
ISecretCipher secretCipher = context.RequestServices.GetRequiredService<ISecretCipher>();
IAiConnectionChecker checker = context.RequestServices.GetRequiredService<IAiConnectionChecker>();
AiCheckRequest checkRequest = await BuildActiveCheckRequestAsync(store, secretCipher, ct);
AiCheckResultDto result = await checker.CheckAsync(checkRequest, ct);
return Results.Ok(result);
}
private static async Task<AiCheckRequest> BuildActiveCheckRequestAsync(
ISettingsStore store,
ISecretCipher secretCipher,
CancellationToken ct)
{
string providerId = await ReadActiveProviderIdAsync(store, ct);
AiProviderDefinition? meta = AiProviders.All.FirstOrDefault(provider => provider.Id == providerId);
// Неизвестный id (ручное вмешательство в БД — PATCH-гейт SettingsService не даёт сохранить):
// HTTP не выполняется — ответит SSRF-гейт checker (allowlist).
if (meta is null)
{
return new AiCheckRequest(providerId, string.Empty, string.Empty, string.Empty, IsLocal: false, ApiStyle: null);
}
AiConfigSetting config = await ReadEffectiveConfigAsync(store, providerId, ct);
string apiKey = secretCipher.Decrypt(config.ApiKey);
string baseUrl = string.IsNullOrEmpty(config.BaseUrl) ? meta.Base : config.BaseUrl;
string model = string.IsNullOrEmpty(config.Model)
? meta.Models.FirstOrDefault() ?? string.Empty
: config.Model;
return new AiCheckRequest(providerId, baseUrl, model, apiKey, meta.Local, meta.ApiStyle);
}
// Читает активный провайдер: сохранённый aiProvider или дефолт (повреждённое значение — дефолт).
// store: KV-хранилище настроек тенанта.
// ct: Токен отмены.
// Возвращает: id провайдера.
private static async Task<string> ReadActiveProviderIdAsync(ISettingsStore store, CancellationToken ct)
{
SettingValue? row = await store.GetAsync(SettingsKeys.AiProvider, ct);
if (row is null)
{
return SettingsDefaults.AiProvider;
}
try
{
using JsonDocument document = JsonDocument.Parse(row.ValueJson);
if (document.RootElement.ValueKind == JsonValueKind.String)
{
return document.RootElement.GetString() ?? SettingsDefaults.AiProvider;
}
}
catch (JsonException)
{
// Повреждённая строка — дефолт (мягкая семантика, как в SettingsService).
}
return SettingsDefaults.AiProvider;
}
// Эффективный конфиг провайдера: дефолт SettingsDefaults, перекрытый сохранённым aiConfigs.
// store: KV-хранилище настроек тенанта.
// providerId: Активный провайдер (id из каталога).
// ct: Токен отмены.
// Возвращает: Конфиг {apiKey, baseUrl, model}; повреждённая строка aiConfigs — дефолт.
private static async Task<AiConfigSetting> ReadEffectiveConfigAsync(
ISettingsStore store,
string providerId,
CancellationToken ct)
{
AiConfigSetting defaults = SettingsDefaults.AiConfigs[providerId];
SettingValue? row = await store.GetAsync(SettingsKeys.AiConfigs, ct);
if (row is null)
{
return defaults;
}
try
{
using JsonDocument document = JsonDocument.Parse(row.ValueJson);
JsonElement root = document.RootElement;
if (root.ValueKind == JsonValueKind.Object
&& root.TryGetProperty(providerId, out JsonElement entry)
&& entry.ValueKind == JsonValueKind.Object)
{
return new AiConfigSetting(
ApiKey: ReadField(entry, "apiKey") ?? defaults.ApiKey,
BaseUrl: ReadField(entry, "baseUrl") ?? defaults.BaseUrl,
Model: ReadField(entry, "model") ?? defaults.Model);
}
}
catch (JsonException)
{
// Повреждённая строка — дефолт (не роняем проверку).
}
return defaults;
}
// Читает строковое поле объекта конфигурации (имена полей camelCase, как пишет SettingsService).
// entry: JSON-объект конфигурации провайдера.
// field: Имя поля (apiKey/baseUrl/model).
// Возвращает: Значение или null, если поле отсутствует/не строка.
private static string? ReadField(JsonElement entry, string field)
{
return entry.TryGetProperty(field, out JsonElement value) && value.ValueKind == JsonValueKind.String
? value.GetString()
: null;
}
}
+5 -5
View File
@@ -68,7 +68,7 @@ public static class AuthEndpoints
ActorId: result.UserId,
TenantId: result.TenantId,
Ip: ClientIp(context),
DetailJson: AuditService.ToDetailJson(new { login = NormalizeLogin(body.Login) })), ct);
DetailJson: AuditService.ToDetailJson([AuditDetails.Set(AuditFields.Login, NormalizeLogin(body.Login))])), ct);
return EndpointResults.Forbidden(TenantSuspendedDetail);
}
@@ -84,7 +84,7 @@ public static class AuthEndpoints
ActorId: null,
TenantId: null,
Ip: ClientIp(context),
DetailJson: AuditService.ToDetailJson(new { login = attemptedLogin })), ct);
DetailJson: AuditService.ToDetailJson([AuditDetails.Set(AuditFields.Login, attemptedLogin)])), ct);
}
return EndpointResults.Unauthorized(InvalidCredentialsDetail);
@@ -98,7 +98,7 @@ public static class AuthEndpoints
ActorId: result.UserId,
TenantId: result.TenantId,
Ip: ClientIp(context),
DetailJson: AuditService.ToDetailJson(new { login = result.Login })), ct);
DetailJson: AuditService.ToDetailJson([AuditDetails.Set(AuditFields.Login, result.Login)])), ct);
SessionCookieWriter.Append(context, cookieOptions.Value, result.Token);
return Results.Ok(new { ok = true, login = result.Login });
@@ -125,12 +125,12 @@ public static class AuthEndpoints
ActorId: logout.OperatorId,
TenantId: logout.TenantId,
Ip: ClientIp(context),
DetailJson: AuditService.ToDetailJson(new { login = logout.Login })), ct);
DetailJson: AuditService.ToDetailJson([AuditDetails.Set(AuditFields.Login, logout.Login)])), ct);
}
if (user is not null)
{
await AuditAppender.AppendTenantAsync(context, AuditEvents.TenantLogout, new { login = user.Login }, ct);
await AuditAppender.AppendTenantAsync(context, AuditEvents.TenantLogout, [AuditDetails.Set(AuditFields.Login, user.Login)], ct);
}
context.Response.Cookies.Delete(cookieName);
@@ -120,7 +120,7 @@ public static class CardDetailsEndpoints
CardsService service = context.RequestServices.GetRequiredService<CardsService>();
CardDto created = await service.CreateLocalCardAsync(ToCreateLocalDto(body), ct);
await AuditAppender.AppendTenantAsync(context, AuditEvents.CardCreated, new { cardId = created.Id }, ct);
await AuditAppender.AppendTenantAsync(context, AuditEvents.CardCreated, [AuditDetails.Set(AuditFields.CardId, created.Id)], ct);
return await ReadCardAsync(context, created.Id, ct);
}
+22 -10
View File
@@ -105,15 +105,15 @@ public static class CardsEndpoints
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
CardCountsDto counts = await cardsService.CountsAsync(ct);
var wire = new Dictionary<string, object> { ["new"] = counts.New };
var wire = new Dictionary<string, object> { [KanbanWireKeys.New] = counts.New };
foreach ((string col, CardColumnCountDto column) in counts.Columns)
{
wire[col] = column;
}
wire["learning"] = counts.Learning;
wire["ml"] = counts.Ml;
wire["ai"] = counts.Ai;
wire[KanbanWireKeys.Learning] = counts.Learning;
wire[KanbanWireKeys.Ml] = counts.Ml;
wire[KanbanWireKeys.Ai] = counts.Ai;
return Results.Ok(wire);
}
@@ -182,7 +182,14 @@ public static class CardsEndpoints
return EndpointResults.BadRequest(outcome.Error);
}
await AuditAppender.AppendTenantAsync(context, AuditEvents.CardMoved, new { cardId, to = body.To }, ct);
await AuditAppender.AppendTenantAsync(
context,
AuditEvents.CardMoved,
[
AuditDetails.Set(AuditFields.CardId, cardId),
AuditDetails.Change(AuditFields.ContainerId, outcome.From, outcome.To),
],
ct);
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
CardDto unified = await cardsService.GetCardAsync(cardId, ct);
@@ -202,7 +209,7 @@ public static class CardsEndpoints
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
await cardsService.TrashCardAsync(cardId, ct);
await AuditAppender.AppendTenantAsync(context, AuditEvents.CardTrashed, new { cardId }, ct);
await AuditAppender.AppendTenantAsync(context, AuditEvents.CardTrashed, [AuditDetails.Set(AuditFields.CardId, cardId)], ct);
return Results.Ok(new { ok = true });
}
@@ -219,7 +226,7 @@ public static class CardsEndpoints
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
string col = await cardsService.RestoreCardAsync(cardId, ct);
await AuditAppender.AppendTenantAsync(context, AuditEvents.CardRestored, new { cardId, col }, ct);
await AuditAppender.AppendTenantAsync(context, AuditEvents.CardRestored, [AuditDetails.Set(AuditFields.CardId, cardId), AuditDetails.Set(AuditFields.Column, col)], ct);
return Results.Ok(new { ok = true, col });
}
@@ -240,7 +247,7 @@ public static class CardsEndpoints
return EndpointResults.NotFound(CardNotFoundDetail);
}
await AuditAppender.AppendTenantAsync(context, AuditEvents.CardDeleted, new { cardId }, ct);
await AuditAppender.AppendTenantAsync(context, AuditEvents.CardDeleted, [AuditDetails.Set(AuditFields.CardId, cardId)], ct);
return Results.Ok(new { ok = true });
}
@@ -284,7 +291,7 @@ public static class CardsEndpoints
return EndpointResults.NotFound(CardNotFoundDetail);
}
await AuditAppender.AppendTenantAsync(context, AuditEvents.CardCommentAdded, new { cardId }, ct);
await AuditAppender.AppendTenantAsync(context, AuditEvents.CardCommentAdded, [AuditDetails.Set(AuditFields.CardId, cardId)], ct);
return Results.Ok(new { comments = result.Comments });
}
@@ -401,7 +408,12 @@ public static class CardsEndpoints
return AuditAppender.AppendTenantAsync(
context,
AuditEvents.CardReclassified,
new { attempted = result.Attempted, reclassified = result.Reclassified, moved = result.Moved, trashed = result.Trashed },
[
AuditDetails.Set(AuditFields.Attempted, result.Attempted),
AuditDetails.Set(AuditFields.Reclassified, result.Reclassified),
AuditDetails.Set(AuditFields.Moved, result.Moved),
AuditDetails.Set(AuditFields.Trashed, result.Trashed),
],
ct);
}
@@ -94,7 +94,7 @@ public static class ContainersEndpoints
Note: body.Note ?? string.Empty),
ct);
await AuditAppender.AppendTenantAsync(context, AuditEvents.ContainerCreated, new { id = created.Id, name = created.Name }, ct);
await AuditAppender.AppendTenantAsync(context, AuditEvents.ContainerCreated, [AuditDetails.Set(AuditFields.ContainerId, created.Id), AuditDetails.Set(AuditFields.Name, created.Name)], ct);
return Results.Ok(new { id = created.Id });
}
@@ -140,6 +140,7 @@ public static class ContainersEndpoints
}
ContainersService containers = context.RequestServices.GetRequiredService<ContainersService>();
ContainerDto before = await containers.GetAsync(containerId, ct);
ContainerDto updated = await containers.PatchAsync(
containerId,
new ContainerPatchDto(
@@ -153,7 +154,13 @@ public static class ContainersEndpoints
patchBody.Policy),
ct);
await AuditAppender.AppendTenantAsync(context, AuditEvents.ContainerUpdated, new { id = updated.Id }, ct);
await AuditAppender.AppendTenantAsync(
context,
AuditEvents.ContainerUpdated,
string.Equals(before.Name, updated.Name, StringComparison.Ordinal)
? [AuditDetails.Set(AuditFields.ContainerId, updated.Id)]
: [AuditDetails.Change(AuditFields.Name, before.Name, updated.Name)],
ct);
return Results.Ok(new { id = updated.Id });
}
@@ -171,7 +178,7 @@ public static class ContainersEndpoints
ContainersService containers = context.RequestServices.GetRequiredService<ContainersService>();
ContainerDto accepted = await containers.AcceptSuggestedAsync(containerId, ct);
await AuditAppender.AppendTenantAsync(context, AuditEvents.ContainerUpdated, new { id = accepted.Id }, ct);
await AuditAppender.AppendTenantAsync(context, AuditEvents.ContainerUpdated, [AuditDetails.Set(AuditFields.ContainerId, accepted.Id)], ct);
return Results.Ok(accepted);
}
@@ -189,7 +196,7 @@ public static class ContainersEndpoints
ContainersService containers = context.RequestServices.GetRequiredService<ContainersService>();
int moved = await containers.DeleteAsync(containerId, ct);
await AuditAppender.AppendTenantAsync(context, AuditEvents.ContainerDeleted, new { id = containerId }, ct);
await AuditAppender.AppendTenantAsync(context, AuditEvents.ContainerDeleted, [AuditDetails.Set(AuditFields.ContainerId, containerId)], ct);
return Results.Ok(new { ok = true, movedToInbox = moved });
}
@@ -288,12 +295,12 @@ public static class ContainersEndpoints
var wire = new Dictionary<string, object>();
if (state.Collapsed is { } collapsed)
{
wire["collapsed"] = collapsed;
wire[KanbanWireKeys.Collapsed] = collapsed;
}
if (state.Width is not null)
{
wire["width"] = state.Width;
wire[KanbanWireKeys.Width] = state.Width;
}
return wire;
+2 -1
View File
@@ -69,7 +69,8 @@ public static class JoinEndpoint
TenantId: result.TenantId,
Ip: ClientIp(context),
// Код инвайта — capability-токен: в аудит пишется только SHA-256-хэш (Security review).
DetailJson: AuditService.ToDetailJson(new { email = result.Login, codeHash = SessionTokens.HashToken(body.Code?.Trim() ?? string.Empty) })), ct);
DetailJson: AuditService.ToDetailJson(
[AuditDetails.Set(AuditFields.Email, result.Login), AuditDetails.Set(AuditFields.CodeHash, SessionTokens.HashToken(body.Code?.Trim() ?? string.Empty))])), ct);
return Results.Ok(new { ok = true, login = result.Login });
}
@@ -1,5 +1,6 @@
using Deal.Api.Extensions;
using Deal.Api.Services;
using Deal.Modules.Tenants.Application.Abstractions;
using Deal.Modules.Tenants.Application.Models;
using Deal.Modules.Tenants.Application.Services;
@@ -132,6 +133,9 @@ public static class OperatorAnalyticsEndpoints
int? offset,
HttpContext context,
AnalyticsService analyticsService,
ITenantRepository tenantRepository,
IAuthStore authStore,
IAuditReferenceResolver referenceResolver,
CancellationToken ct)
{
if (context.GetCurrentOperator() is null)
@@ -141,7 +145,8 @@ public static class OperatorAnalyticsEndpoints
AnalyticsActivityDto activity = await analyticsService.ActivityAsync(
eventType, actorType, actorId, tenantId, from, to, limit, offset, ct);
return Results.Ok(activity);
IReadOnlyList<AuditRecordViewDto> view = await AuditViewFactory.ProjectAsync(activity.Items, tenantRepository, authStore, referenceResolver, ct);
return Results.Ok(new { items = view, total = activity.Total, limit = activity.Limit, offset = activity.Offset });
}
/// <summary>
@@ -1,5 +1,6 @@
using Deal.Api.Extensions;
using Deal.Api.Services;
using Deal.Modules.Tenants.Application.Abstractions;
using Deal.Modules.Tenants.Application.Models;
using Deal.Modules.Tenants.Application.Services;
@@ -39,6 +40,9 @@ public static class OperatorAuditEndpoints
int? offset,
HttpContext context,
AuditService auditService,
ITenantRepository tenantRepository,
IAuthStore authStore,
IAuditReferenceResolver referenceResolver,
CancellationToken ct)
{
var operatorIdentity = context.GetCurrentOperator();
@@ -51,7 +55,8 @@ public static class OperatorAuditEndpoints
eventType, actorType, tenantId, from, to, NormalizeLimit(limit), actorId, NormalizeOffset(offset));
IReadOnlyList<AuditRecordDto> items = await auditService.QueryAsync(filter, ct);
int total = await auditService.CountAsync(filter, ct);
return Results.Ok(new { items, total });
IReadOnlyList<AuditRecordViewDto> view = await AuditViewFactory.ProjectAsync(items, tenantRepository, authStore, referenceResolver, ct);
return Results.Ok(new { items = view, total });
}
/// <summary>
@@ -64,7 +64,7 @@ public static class OperatorAuthEndpoints
ActorId: null,
TenantId: null,
Ip: ClientIp(context),
DetailJson: AuditService.ToDetailJson(new { login = attemptedLogin })), ct);
DetailJson: AuditService.ToDetailJson([AuditDetails.Set(AuditFields.Login, attemptedLogin)])), ct);
}
return EndpointResults.Unauthorized(InvalidCredentialsDetail);
@@ -78,7 +78,7 @@ public static class OperatorAuthEndpoints
ActorId: result.OperatorId,
TenantId: null,
Ip: ClientIp(context),
DetailJson: AuditService.ToDetailJson(new { login = result.Login })), ct);
DetailJson: AuditService.ToDetailJson([AuditDetails.Set(AuditFields.Login, result.Login)])), ct);
SetOperatorSessionCookie(context, cookieOptions.Value, result.Token);
return Results.Ok(new { ok = true, login = result.Login });
@@ -100,7 +100,7 @@ public static class OperatorAuthEndpoints
if (operatorIdentity is not null)
{
await AuditAppender.AppendOperatorAsync(context, AuditEvents.OperatorLogout, new { login = operatorIdentity.Login }, ct);
await AuditAppender.AppendOperatorAsync(context, AuditEvents.OperatorLogout, [AuditDetails.Set(AuditFields.Login, operatorIdentity.Login)], ct);
}
return Results.Ok(new { ok = true });
@@ -92,7 +92,8 @@ public static class OperatorInvitesEndpoints
Ip: ClientIp(context),
// Код инвайта — capability-токен (по нему активируется приглашение): в аудит пишется
// только его SHA-256-хэш, чтобы утечка ленты не давала рабочие коды (Security review).
DetailJson: AuditService.ToDetailJson(new { email = result.Invite.Email, codeHash = SessionTokens.HashToken(result.Invite.Code) })), ct);
DetailJson: AuditService.ToDetailJson(
[AuditDetails.Set(AuditFields.Email, result.Invite.Email), AuditDetails.Set(AuditFields.CodeHash, SessionTokens.HashToken(result.Invite.Code))])), ct);
InviteDto invite = result.Invite;
return Results.Ok(new { invite.Code, invite.Email, invite.TenantId, invite.ExpiresAt, invite.Status });
@@ -126,7 +127,8 @@ public static class OperatorInvitesEndpoints
ActorId: operatorIdentity.OperatorId,
TenantId: null,
Ip: ClientIp(context),
DetailJson: AuditService.ToDetailJson(new { email = result.Invite!.Email, codeHash = SessionTokens.HashToken(result.Invite.Code) })), ct);
DetailJson: AuditService.ToDetailJson(
[AuditDetails.Set(AuditFields.Email, result.Invite!.Email), AuditDetails.Set(AuditFields.CodeHash, SessionTokens.HashToken(result.Invite.Code))])), ct);
return Results.Ok(new { ok = true });
}
@@ -58,6 +58,7 @@ public static class OperatorLimitsEndpoints
private static async Task<IResult> ListSummaryAsync(
HttpContext context,
ITenantRepository tenantRepository,
IAuthStore authStore,
ITenantLimitStore limitStore,
CancellationToken ct)
{
@@ -68,6 +69,8 @@ public static class OperatorLimitsEndpoints
}
IReadOnlyList<TenantRecordDto> tenants = await tenantRepository.ListAsync(ct);
IReadOnlyDictionary<Guid, string> owners = await authStore.FindOwnerLoginsByTenantIdsAsync(
tenants.Select(tenant => tenant.Id).ToArray(), ct);
var items = new List<object>(tenants.Count);
foreach (TenantRecordDto tenant in tenants)
{
@@ -75,6 +78,7 @@ public static class OperatorLimitsEndpoints
items.Add(new
{
tenantId = tenant.Id,
ownerLogin = owners.GetValueOrDefault(tenant.Id),
name = tenant.Name,
budget = state.BudgetTokens,
period = state.Period,
@@ -92,6 +96,7 @@ public static class OperatorLimitsEndpoints
Guid id,
HttpContext context,
ITenantRepository tenantRepository,
IAuthStore authStore,
ITenantLimitStore limitStore,
CancellationToken ct)
{
@@ -108,7 +113,7 @@ public static class OperatorLimitsEndpoints
}
BudgetStateDto state = await limitStore.GetStateAsync(id, ct);
return Results.Ok(BuildDetailDto(tenant.Name, state));
return Results.Ok(BuildDetailDto(tenant.Name, await ResolveOwnerLoginAsync(authStore, id, ct), state));
}
// PATCH /api/operator/tenants/{id}/limit: смена бюджета/периода (сброс флагов + аудит tenant_limit_changed).
@@ -117,6 +122,7 @@ public static class OperatorLimitsEndpoints
OperatorLimitUpdateRequest? body,
HttpContext context,
ITenantRepository tenantRepository,
IAuthStore authStore,
ITenantLimitStore limitStore,
AuditService auditService,
CancellationToken ct)
@@ -154,10 +160,11 @@ public static class OperatorLimitsEndpoints
BudgetStateDto current = await limitStore.GetStateAsync(id, ct);
long newBudget = body.Budget ?? current.BudgetTokens;
string newPeriod = body.Period ?? current.Period;
string? ownerLogin = await ResolveOwnerLoginAsync(authStore, id, ct);
if (newBudget == current.BudgetTokens && newPeriod == current.Period)
{
// Идемпотентный повторный PATCH: без изменения хранилища и без дубля аудита.
return Results.Ok(BuildDetailDto(tenant.Name, current));
return Results.Ok(BuildDetailDto(tenant.Name, ownerLogin, current));
}
BudgetStateDto updated = await limitStore.UpdateBudgetAsync(id, newBudget, newPeriod, ct);
@@ -167,16 +174,10 @@ public static class OperatorLimitsEndpoints
ActorId: operatorIdentity.OperatorId,
TenantId: id,
Ip: ClientIp(context),
DetailJson: AuditService.ToDetailJson(new
{
tenantId = id,
oldBudget = current.BudgetTokens,
oldPeriod = current.Period,
budgetTokens = newBudget,
period = newPeriod,
})), ct);
DetailJson: AuditService.ToDetailJson(
[AuditDetails.Change(AuditFields.Budget, current.BudgetTokens, newBudget), AuditDetails.Change(AuditFields.Period, current.Period, newPeriod)])), ct);
return Results.Ok(BuildDetailDto(tenant.Name, updated));
return Results.Ok(BuildDetailDto(tenant.Name, ownerLogin, updated));
}
/// <summary>
@@ -197,11 +198,13 @@ public static class OperatorLimitsEndpoints
// Форма деталей лимита тенанта (GET и ответ PATCH — единая).
// name: Имя тенанта (реестр).
// ownerLogin: Логин владельца пространства; null, если пользователей нет.
// state: Состояние бюджета (после ленивого reset).
// Возвращает: Объект ответа: лимит + расход + флаги порогов + статус тенанта.
private static object BuildDetailDto(string name, BudgetStateDto state) => new
private static object BuildDetailDto(string name, string? ownerLogin, BudgetStateDto state) => new
{
tenantId = state.TenantId,
ownerLogin,
name,
status = state.Status,
allowed = state.Allowed,
@@ -215,6 +218,17 @@ public static class OperatorLimitsEndpoints
notifiedExhausted = state.NotifiedExhausted,
};
// Логин владельца пространства (первый пользователь по времени создания).
// authStore: Хранилище пользователей.
// tenantId: Идентификатор пространства.
// ct: Токен отмены.
// Возвращает: Логин либо null.
private static async Task<string?> ResolveOwnerLoginAsync(IAuthStore authStore, Guid tenantId, CancellationToken ct)
{
IReadOnlyDictionary<Guid, string> owners = await authStore.FindOwnerLoginsByTenantIdsAsync([tenantId], ct);
return owners.GetValueOrDefault(tenantId);
}
// IP-адрес клиента для аудита (без порта; null, если недоступен).
// context: Контекст запроса.
// Возвращает: Строковое представление IP или null.
@@ -2,6 +2,9 @@ using Deal.Api.Endpoints.RequestModels;
using Deal.Api.Extensions;
using Deal.Api.Services;
using Deal.Api.Telegram;
using Deal.Modules.Settings.Application.Abstractions;
using Deal.Modules.Settings.Application.Models;
using Deal.Modules.Settings.Application.Services;
using Deal.Modules.Tenants.Application.Models;
using Deal.Modules.Tenants.Application.Services;
@@ -21,6 +24,9 @@ public static class OperatorSettingsEndpoints
// Относительный путь глобальных ключей Telegram (GET/PUT).
private const string TelegramKeysPath = "/telegram-keys";
// Относительный путь глобальной конфигурации ИИ (GET/PUT/POST-check).
private const string AiConfigPath = "/ai-config";
// Текст 400: пустое тело PUT (ни одного поля).
private const string EmptyBodyDetail = "Укажите api_id и api_hash";
@@ -33,6 +39,24 @@ public static class OperatorSettingsEndpoints
// Текст 400: api_hash пустой/маска/с префиксом enc:.
private const string InvalidApiHashDetail = "Укажите непустой api_hash";
// Текст 400: пустое тело PUT конфига ИИ (ни одного поля).
private const string EmptyAiConfigBodyDetail = "Укажите хотя бы одно поле (providerId, baseUrl, model, apiKey)";
// Текст 400: провайдер не из каталога AiProviders.
private const string InvalidProviderDetail = "Провайдер не из списка разрешённых";
// Текст 400: api-ключ короче 8 символов/маска/с префиксом enc:.
private const string InvalidAiKeyDetail = "API-ключ должен быть не короче 8 символов, без маски";
// Текст 400: конфигурации ИИ ещё нет, а провайдер в PUT не передан.
private const string MissingProviderDetail = "Конфигурация ИИ ещё не задана — укажите providerId";
// Текст 400: custom-провайдер без модели (в каталоге нет моделей-дефолтов).
private const string MissingAiModelDetail = "Укажите model — у выбранного провайдера нет моделей по умолчанию";
// Текст 400: проверка связи при незаданной конфигурации ИИ.
private const string AiConfigNotSetDetail = "Сначала сохраните конфигурацию ИИ";
/// <summary>
/// Регистрирует группу /api/operator/settings
/// </summary>
@@ -43,6 +67,9 @@ public static class OperatorSettingsEndpoints
var group = app.MapGroup(SettingsGroupPrefix).WithTags(SettingsOpenApiTag);
group.MapGet(TelegramKeysPath, GetTelegramKeysAsync);
group.MapPut(TelegramKeysPath, PutTelegramKeysAsync);
group.MapGet(AiConfigPath, GetAiConfigAsync);
group.MapPut(AiConfigPath, PutAiConfigAsync);
group.MapPost(AiConfigPath + "/check", CheckAiConfigAsync);
return app;
}
@@ -128,12 +155,159 @@ public static class OperatorSettingsEndpoints
ActorId: operatorIdentity.OperatorId,
TenantId: null,
Ip: ClientIp(context),
DetailJson: AuditService.ToDetailJson(new { apiId = effectiveApiId, apiHashSet = true })), ct);
DetailJson: AuditService.ToDetailJson(
[AuditDetails.Set(AuditFields.ApiId, effectiveApiId), AuditDetails.Set(AuditFields.ApiHashSet, true)])), ct);
TelegramKeysMaskedDto snapshot = await keys.GetMaskedAsync(ct);
return Results.Ok(snapshot);
}
// GET /api/operator/settings/ai-config: маскированная глобальная конфигурация ИИ.
// context: Контекст запроса.
// config: Сервис глобальной конфигурации ИИ (scoped).
// ct: Токен отмены.
// Возвращает: 200 маскированный снимок или 401 без операторской сессии.
private static async Task<IResult> GetAiConfigAsync(
HttpContext context,
AiGlobalConfigService config,
CancellationToken ct)
{
if (context.GetCurrentOperator() is null)
{
return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail);
}
AiGlobalConfigMaskedDto snapshot = await config.GetMaskedAsync(ct);
return Results.Ok(snapshot);
}
// PUT /api/operator/settings/ai-config: частичное сохранение глобальной конфигурации ИИ.
// Поля передаются по отдельности: непереданное (null) сохраняет текущее значение. Если
// конфигурации ещё нет, providerId обязателен. Ключ передаётся только при смене (маска не
// принимается). Смена провайдера сохраняет ключ, только если передан явно.
// body: Тело {providerId?, baseUrl?, model?, apiKey?} (хотя бы одно поле).
// context: Контекст запроса.
// config: Сервис глобальной конфигурации ИИ (scoped).
// auditService: Сервис аудита (событие ai_config_changed).
// ct: Токен отмены.
// Возвращает: 200 маскированный снимок, 400 при невалидных/недостающих полях или 401 без операторской сессии.
private static async Task<IResult> PutAiConfigAsync(
OperatorAiConfigRequest? body,
HttpContext context,
AiGlobalConfigService config,
AuditService auditService,
CancellationToken ct)
{
var operatorIdentity = context.GetCurrentOperator();
if (operatorIdentity is null)
{
return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail);
}
if (body is null)
{
return EndpointResults.BadRequest(EmptyAiConfigBodyDetail);
}
// null — поле не передано (сохраняем текущее); явное значение валидируется ниже.
string? providerId = body.ProviderId?.Trim();
string? baseUrl = body.BaseUrl?.Trim();
string? model = body.Model?.Trim();
string? apiKey = body.ApiKey?.Trim();
if (providerId is null && baseUrl is null && model is null && apiKey is null)
{
return EndpointResults.BadRequest(EmptyAiConfigBodyDetail);
}
if (providerId is not null
&& AiProviders.All.All(provider => provider.Id != providerId))
{
return EndpointResults.BadRequest(InvalidProviderDetail);
}
if (apiKey is not null && apiKey.Length > 0 && !AiGlobalConfigService.IsValidApiKey(apiKey))
{
return EndpointResults.BadRequest(InvalidAiKeyDetail);
}
// Частичное обновление: недостающие поля берём из текущей конфигурации. При смене
// провайдера поля не переносятся от старого (дефолты каталога), ключ — только если
// передан явно.
AiGlobalConfigSnapshot current = await config.GetSnapshotAsync(ct);
string effectiveProviderId = providerId ?? current.ProviderId;
if (effectiveProviderId.Length == 0)
{
return EndpointResults.BadRequest(MissingProviderDetail);
}
bool providerChanged = providerId is not null && providerId != current.ProviderId;
string effectiveBaseUrl = baseUrl ?? (providerChanged ? string.Empty : current.BaseUrl);
string effectiveModel = model ?? (providerChanged ? string.Empty : current.Model);
string effectiveApiKey = apiKey ?? (providerChanged ? string.Empty : current.ApiKey);
try
{
await config.SaveAsync(effectiveProviderId, effectiveBaseUrl, effectiveModel, effectiveApiKey, ct);
}
catch (ArgumentException)
{
// Остаточный случай валидации: custom-провайдер без модели (провайдер и ключ проверены выше).
return EndpointResults.BadRequest(MissingAiModelDetail);
}
AiGlobalConfigSnapshot saved = await config.GetSnapshotAsync(ct);
await auditService.AppendAsync(new AuditRecordDto(
AuditEvents.AiConfigChanged,
AuditActorTypes.Operator,
ActorId: operatorIdentity.OperatorId,
TenantId: null,
Ip: ClientIp(context),
DetailJson: AuditService.ToDetailJson(
[
AuditDetails.Set(AuditFields.ProviderId, saved.ProviderId),
AuditDetails.Set(AuditFields.BaseUrl, saved.BaseUrl),
AuditDetails.Set(AuditFields.Model, saved.Model),
AuditDetails.Set(AuditFields.KeySet, saved.ApiKey.Length > 0),
])), ct);
AiGlobalConfigMaskedDto snapshot = await config.GetMaskedAsync(ct);
return Results.Ok(snapshot);
}
// POST /api/operator/settings/ai-config/check: проверка связи с сохранённым ИИ-провайдером.
// context: Контекст запроса.
// config: Сервис глобальной конфигурации ИИ (scoped).
// checker: Проверка подключения провайдера (HTTP к списку моделей провайдера).
// ct: Токен отмены.
// Возвращает: 200 результат проверки, 400 без сохранённой конфигурации или 401 без операторской сессии.
private static async Task<IResult> CheckAiConfigAsync(
HttpContext context,
AiGlobalConfigService config,
IAiConnectionChecker checker,
CancellationToken ct)
{
if (context.GetCurrentOperator() is null)
{
return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail);
}
AiGlobalConfigSnapshot snapshot = await config.GetSnapshotAsync(ct);
if (snapshot.ProviderId.Length == 0 || snapshot.Meta is null)
{
return EndpointResults.BadRequest(AiConfigNotSetDetail);
}
var request = new AiCheckRequest(
snapshot.ProviderId,
snapshot.BaseUrl,
snapshot.Model,
snapshot.ApiKey,
snapshot.Meta.Local,
snapshot.Meta.ApiStyle);
AiCheckResultDto result = await checker.CheckAsync(request, ct);
return Results.Ok(result);
}
// IP-адрес клиента для аудита (без порта; null, если недоступен).
// context: Контекст запроса.
// Возвращает: Строковое представление IP или null.
@@ -113,7 +113,8 @@ public static class OperatorTenantsEndpoints
ActorId: operatorIdentity.OperatorId,
TenantId: createdTenant.Id,
Ip: ClientIp(context),
DetailJson: AuditService.ToDetailJson(new { tenantId = createdTenant.Id, name = createdTenant.Name, email = result.OwnerLogin })), ct);
DetailJson: AuditService.ToDetailJson(
[AuditDetails.Set(AuditFields.Name, createdTenant.Name), AuditDetails.Set(AuditFields.Email, result.OwnerLogin)])), ct);
if (result.OwnerLogin is not null)
{
@@ -202,7 +203,8 @@ public static class OperatorTenantsEndpoints
ActorId: operatorIdentity.OperatorId,
TenantId: result.Tenant.Id,
Ip: ClientIp(context),
DetailJson: AuditService.ToDetailJson(new { tenantId = result.Tenant.Id, status = result.Tenant.Status })), ct);
DetailJson: AuditService.ToDetailJson(
[AuditDetails.Change(AuditFields.Status, result.PreviousStatus, result.Tenant.Status)])), ct);
}
return Results.Ok(new { ok = true, status = result.Tenant.Status });
@@ -242,7 +244,7 @@ public static class OperatorTenantsEndpoints
ActorId: operatorIdentity.OperatorId,
TenantId: result.TenantId,
Ip: ClientIp(context),
DetailJson: AuditService.ToDetailJson(new { targetLogin = result.Login, tenantId = result.TenantId })), ct);
DetailJson: AuditService.ToDetailJson([AuditDetails.Set(AuditFields.TargetLogin, result.Login)])), ct);
// Токен — это tenant-сессия (как после /api/auth/login): СТАВИМ ту же httpOnly-куку deal_session
// на ответ, чтобы браузер оператора сразу получил tenant-сессию (JS не может записать httpOnly-куку).
@@ -0,0 +1,14 @@
namespace Deal.Api.Endpoints.RequestModels;
/// <summary>
/// Тело PUT /api/operator/settings/ai-config
/// </summary>
/// <param name="ProviderId">Id провайдера из каталога AiProviders; null — не менялся.</param>
/// <param name="BaseUrl">Базовый URL API (учитывается для local/custom провайдеров); null — не менялся.</param>
/// <param name="Model">Активная модель; null — не менялась (у каталогных провайдеров пустая = первая из каталога).</param>
/// <param name="ApiKey">API-ключ открытым текстом (хранится зашифрованным); null — не менялся (маска не принимается).</param>
public sealed record OperatorAiConfigRequest(
string? ProviderId,
string? BaseUrl,
string? Model,
string? ApiKey);
@@ -5,6 +5,7 @@ using Deal.Modules.Settings.Application.Abstractions;
using Deal.Modules.Settings.Application.Models;
using Deal.Modules.Settings.Application.Services;
using Deal.Modules.Tenants.Application.Models;
using Deal.SharedKernel.Utilities;
namespace Deal.Api.Endpoints;
@@ -18,6 +19,15 @@ public static class SettingsEndpoints
private const string SettingsOpenApiTag = "settings";
private const string InvalidBodyDetail = "Тело запроса должно быть JSON-объектом";
// Максимальная длина значения в деталях аудита (промпты/списки бывают длинными).
private const int MaxAuditValueLength = 120;
// Пометка для составных настроек, чьи значения в аудит не пишутся (секреты, снимки состояния).
private const string ChangedMarker = "изменено";
// Опции сериализации снимка настроек в деталях аудита (camelCase, как на wire).
private static readonly JsonSerializerOptions AuditJsonOptions = new(JsonSerializerDefaults.Web);
/// <summary>
/// Регистрирует GET/PATCH /api/settings.
/// </summary>
@@ -72,9 +82,10 @@ public static class SettingsEndpoints
}
SettingsService settingsService = context.RequestServices.GetRequiredService<SettingsService>();
PublicSettingsDto before = await settingsService.GetPublicAsync(ct);
PublicSettingsDto result = await settingsService.ApplyPatchAsync(body, ct);
await AuditAppender.AppendTenantAsync(context, AuditEvents.SettingsUpdated, new { fields = body.Keys }, ct);
await AuditAppender.AppendTenantAsync(context, AuditEvents.SettingsUpdated, BuildSettingsChanges(before, result, body.Keys), ct);
if (ShouldScheduleRatesRefresh(body))
{
@@ -84,6 +95,60 @@ public static class SettingsEndpoints
return Results.Ok(result);
}
// Строит изменения настроек «поле: было → стало» по применённому PATCH.
// before: Снимок настроек до применения.
// after: Снимок настроек после применения.
// keys: Ключи PATCH-тела (изменённые).
// Возвращает: Изменения по каждому ключу (составные — пометкой «изменено»).
private static IReadOnlyList<AuditChangeDto> BuildSettingsChanges(
PublicSettingsDto before,
PublicSettingsDto after,
IEnumerable<string> keys)
{
using JsonDocument beforeDoc = JsonDocument.Parse(JsonSerializer.Serialize(before, AuditJsonOptions));
using JsonDocument afterDoc = JsonDocument.Parse(JsonSerializer.Serialize(after, AuditJsonOptions));
var changes = new List<AuditChangeDto>();
var keyList = new List<string>();
foreach (string key in keys)
{
keyList.Add(key);
SettingKind? kind = SettingsKeys.FindPublicKind(key);
if (kind is SettingKind.Dict or SettingKind.MyPrompts)
{
changes.Add(AuditDetails.Set(key, ChangedMarker));
continue;
}
changes.Add(AuditDetails.Change(
key,
ReadSettingText(beforeDoc.RootElement, key),
ReadSettingText(afterDoc.RootElement, key)));
}
return changes.Count > 0
? changes
: [AuditDetails.Set(AuditFields.Fields, string.Join(", ", keyList))];
}
// Читает значение ключа снимка настроек как текст для деталей аудита.
private static string? ReadSettingText(JsonElement root, string key) =>
root.TryGetProperty(key, out JsonElement value) ? ToText(value) : null;
// Приводит значение JSON к строке отображения (длинные значения обрезаются).
private static string? ToText(JsonElement value) => value.ValueKind switch
{
JsonValueKind.Null or JsonValueKind.Undefined => null,
JsonValueKind.String => Truncate(value.GetString()),
JsonValueKind.True => BoolText.True,
JsonValueKind.False => BoolText.False,
JsonValueKind.Array => Truncate(string.Join(", ", value.EnumerateArray().Select(ToText))),
_ => Truncate(value.GetRawText()),
};
// Обрезает длинное значение деталей аудита.
private static string? Truncate(string? value) =>
value is not null && value.Length > MaxAuditValueLength ? value[..MaxAuditValueLength] + "…" : value;
private static bool ShouldScheduleRatesRefresh(Dictionary<string, JsonElement> body)
{
if (!body.TryGetValue(SettingsKeys.RateSource, out JsonElement element))
@@ -67,7 +67,12 @@ public static class TelegramEndpoints
private const string NotConnectedReason = "not-connected";
private const string ReadyPhase = "ready";
private const string ReadyPhase = TelegramAuthPhases.Ready;
// Русские подписи видов диалогов для операторского UI.
private const string RussianChannelLabel = "канал";
private const string RussianGroupLabel = "группа";
private const string RussianChatLabel = "чат";
private const int PreviewDefaultLimit = 24;
@@ -170,7 +175,7 @@ public static class TelegramEndpoints
TelegramAuthResultDto result = await gateway.StartQrAsync(apiId, keys.ApiHash, ct);
if (result.Phase == ReadyPhase)
{
await AuditAppender.AppendTenantAsync(context, AuditEvents.TelegramLinked, new { phase = result.Phase }, ct);
await AuditAppender.AppendTenantAsync(context, AuditEvents.TelegramLinked, [AuditDetails.Set(AuditFields.Phase, result.Phase)], ct);
}
return Results.Ok(new { phase = result.Phase, qrUrl = result.QrUrl ?? string.Empty });
@@ -197,7 +202,7 @@ public static class TelegramEndpoints
string phase = await gateway.SendCodeAsync((body.Code ?? string.Empty).Trim(), ct);
if (phase == ReadyPhase)
{
await AuditAppender.AppendTenantAsync(context, AuditEvents.TelegramLinked, new { phase }, ct);
await AuditAppender.AppendTenantAsync(context, AuditEvents.TelegramLinked, [AuditDetails.Set(AuditFields.Phase, phase)], ct);
}
return Results.Ok(new { phase });
@@ -224,7 +229,7 @@ public static class TelegramEndpoints
string phase = await gateway.SendPasswordAsync(body.Password ?? string.Empty, ct);
if (phase == ReadyPhase)
{
await AuditAppender.AppendTenantAsync(context, AuditEvents.TelegramLinked, new { phase }, ct);
await AuditAppender.AppendTenantAsync(context, AuditEvents.TelegramLinked, [AuditDetails.Set(AuditFields.Phase, phase)], ct);
}
return Results.Ok(new { phase });
@@ -343,7 +348,7 @@ public static class TelegramEndpoints
if (body.Enabled)
{
await AuditAppender.AppendTenantAsync(context, AuditEvents.ChannelEnabled, new { all = true, count = result.Count }, ct);
await AuditAppender.AppendTenantAsync(context, AuditEvents.ChannelEnabled, [AuditDetails.Set(AuditFields.All, true), AuditDetails.Set(AuditFields.Count, result.Count)], ct);
}
return Results.Ok(new { ok = true, count = result.Count, enabled = body.Enabled });
@@ -393,7 +398,7 @@ public static class TelegramEndpoints
if (result.Enabled)
{
await AuditAppender.AppendTenantAsync(context, AuditEvents.ChannelEnabled, new { dialogId = dialog_id }, ct);
await AuditAppender.AppendTenantAsync(context, AuditEvents.ChannelEnabled, [AuditDetails.Set(AuditFields.DialogId, dialog_id)], ct);
}
return Results.Ok(new { ok = true, enabled = result.Enabled });
@@ -482,9 +487,9 @@ public static class TelegramEndpoints
{
return kind switch
{
"channel" => "канал",
"group" or "forum" => "группа",
"chat" => "чат",
TelegramDialogKinds.Channel => RussianChannelLabel,
TelegramDialogKinds.Group or TelegramDialogKinds.Forum => RussianGroupLabel,
TelegramDialogKinds.Chat => RussianChatLabel,
_ => kind,
};
}
+4 -1
View File
@@ -25,6 +25,7 @@ using Deal.Modules.Kanban.Application.Registrars;
using Deal.Modules.Pipeline.Application.Registrars;
using Deal.Modules.Settings.Application.Abstractions;
using Deal.Modules.Settings.Application.Registrars;
using Deal.Modules.Settings.Application.Services;
using Deal.Modules.Telegram.Application;
using Deal.Modules.Tenants.Application.Models;
using Deal.Modules.Tenants.Application.Registrars;
@@ -160,11 +161,14 @@ builder.Services.AddDiscoveryModule();
builder.Services.AddScoped<TgStatusService>();
builder.Services.AddScoped<TelegramKeysService>();
builder.Services.AddScoped<AiGlobalConfigService>();
builder.Services.AddSingleton<TelegramBackfillScheduler>();
builder.Services.AddScoped<AdminTickOrchestrator>();
builder.Services.AddScoped<IAuditReferenceResolver, AuditReferenceResolver>();
builder.Services.AddScoped<FtsMaintenance>();
builder.Services.AddSingleton<SseBroker>();
@@ -354,7 +358,6 @@ app.MapOperatorSettingsEndpoints();
app.MapOperatorMaintenanceEndpoints();
app.MapJoinEndpoint();
app.MapSettingsEndpoints();
app.MapAiCheckEndpoint();
app.MapRatesEndpoints();
app.MapMlEndpoints();
app.MapFilterTesterEndpoints();
+15 -13
View File
@@ -13,12 +13,14 @@ public static class AuditAppender
/// <summary>
/// Пишет событие действия пользователя тенанта
/// </summary>
/// <param name="context">Контекст запроса (сессия пользователя, IP).</param>
/// <param name="eventType">Тип события — константа <see cref="AuditEvents"/>.</param>
/// <param name="details">Минимальные детали события (обычно анонимный объект) или null.</param>
/// <param name="changes">Изменения параметров события.</param>
/// <param name="ct">Токен отмены.</param>
public static async Task AppendTenantAsync(
HttpContext context,
string eventType,
object? details,
IReadOnlyList<AuditChangeDto> changes,
CancellationToken ct)
{
CurrentUser? user = context.GetCurrentUser();
@@ -35,19 +37,21 @@ public static class AuditAppender
ActorId: user.UserId,
TenantId: user.TenantId,
Ip: ClientIp(context),
DetailJson: DetailJson(details)),
DetailJson: DetailJson(changes)),
ct);
}
/// <summary>
/// Пишет событие действия оператора
/// </summary>
/// <param name="context">Контекст запроса (операторская сессия, IP).</param>
/// <param name="eventType">Тип события — константа <see cref="AuditEvents"/>.</param>
/// <param name="details">Минимальные детали события (обычно анонимный объект) или null.</param>
/// <param name="changes">Изменения параметров события.</param>
/// <param name="ct">Токен отмены.</param>
public static async Task AppendOperatorAsync(
HttpContext context,
string eventType,
object? details,
IReadOnlyList<AuditChangeDto> changes,
CancellationToken ct)
{
CurrentOperator? operatorIdentity = context.GetCurrentOperator();
@@ -64,18 +68,16 @@ public static class AuditAppender
ActorId: operatorIdentity.OperatorId,
TenantId: null,
Ip: ClientIp(context),
DetailJson: DetailJson(details)),
DetailJson: DetailJson(changes)),
ct);
}
// Сериализует детали события (null — деталей нет).
// details: Объект деталей или null.
// Возвращает: JSON деталей (camelCase) или null.
private static string? DetailJson(object? details) =>
details is null ? null : AuditService.ToDetailJson(details);
// Сериализует детали события (пустой список — деталей нет).
// changes: Изменения параметров события.
// Возвращает: JSON деталей или null.
private static string? DetailJson(IReadOnlyList<AuditChangeDto> changes) =>
changes.Count == 0 ? null : AuditService.ToDetailJson(changes);
// IP-адрес клиента для аудита (без порта; null, если недоступен).
// context: Контекст запроса.
// Возвращает: Строковое представление IP или null.
private static string? ClientIp(HttpContext context) => context.Connection.RemoteIpAddress?.ToString();
}
@@ -0,0 +1,133 @@
using Deal.Modules.Cards.Application.Models;
using Deal.Modules.Kanban.Application.Abstractions;
using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Tenants.Application.Models;
using Deal.SharedKernel.Tenants.Abstractions;
using Deal.SharedKernel.Tenants.Models;
namespace Deal.Api.Services;
/// <summary>
/// Резолвер ссылок аудита на данные тенанта
/// </summary>
/// <param name="scopeFactory">Фабрика scope для чтения схемы тенанта.</param>
/// <param name="logger">Логгер сбоев разрешения ссылок.</param>
public sealed class AuditReferenceResolver(
IServiceScopeFactory scopeFactory,
ILogger<AuditReferenceResolver> logger) : IAuditReferenceResolver
{
async Task<IReadOnlyDictionary<string, string>> IAuditReferenceResolver.ResolveAsync(
IReadOnlyList<AuditRecordDto> records,
CancellationToken ct)
{
var names = new Dictionary<string, string>(StringComparer.Ordinal);
foreach (IGrouping<Guid, AuditRecordDto> group in GroupByTenant(records))
{
await ResolveTenantAsync(group.Key, group, names, ct);
}
return names;
}
private static IEnumerable<IGrouping<Guid, AuditRecordDto>> GroupByTenant(IReadOnlyList<AuditRecordDto> records) =>
records
.Where(record => record.TenantId is not null)
.GroupBy(record => record.TenantId!.Value);
private async Task ResolveTenantAsync(
Guid tenantId,
IEnumerable<AuditRecordDto> records,
Dictionary<string, string> names,
CancellationToken ct)
{
var cardIds = new HashSet<string>(StringComparer.Ordinal);
var boardIds = new HashSet<string>(StringComparer.Ordinal);
foreach (AuditRecordDto record in records)
{
foreach (AuditChangeDto change in record.AuditChanges())
{
Collect(change.From, cardIds, boardIds);
Collect(change.To, cardIds, boardIds);
}
}
if (cardIds.Count == 0 && boardIds.Count == 0)
{
return;
}
await using AsyncServiceScope scope = scopeFactory.CreateAsyncScope();
ITenantContext tenantContext = scope.ServiceProvider.GetRequiredService<ITenantContext>();
try
{
tenantContext.SetTenant(new TenantId(tenantId.ToString("N")));
ICardStore store = scope.ServiceProvider.GetRequiredService<ICardStore>();
await ResolveCardsAsync(store, cardIds, names, ct);
await ResolveContainersAsync(store, boardIds, names, ct);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception exception)
{
logger.LogWarning(exception, "Резолвер аудита: ссылки тенанта {TenantId} не разрешены", tenantId);
}
finally
{
tenantContext.Reset();
}
}
private static void Collect(
string? value,
HashSet<string> cardIds,
HashSet<string> boardIds)
{
if (string.IsNullOrEmpty(value))
{
return;
}
if (value.StartsWith(CardIds.CardPrefix, StringComparison.Ordinal))
{
cardIds.Add(value);
}
else if (value.StartsWith(KanbanIdPrefixes.Board, StringComparison.Ordinal))
{
boardIds.Add(value);
}
}
private static async Task ResolveCardsAsync(
ICardStore store,
HashSet<string> cardIds,
Dictionary<string, string> names,
CancellationToken ct)
{
foreach (string cardId in cardIds)
{
CardDto? card = await store.GetCardAsync(cardId, ct);
if (card is not null && !string.IsNullOrWhiteSpace(card.Title))
{
names[cardId] = card.Title;
}
}
}
private static async Task ResolveContainersAsync(
ICardStore store,
HashSet<string> boardIds,
Dictionary<string, string> names,
CancellationToken ct)
{
foreach (string boardId in boardIds)
{
ContainerDto? container = await store.GetContainerAsync(boardId, ct);
if (container is not null && !string.IsNullOrWhiteSpace(container.Name))
{
names[boardId] = container.Name;
}
}
}
}
@@ -0,0 +1,138 @@
using Deal.Modules.Tenants.Application.Abstractions;
using Deal.Modules.Tenants.Application.Models;
namespace Deal.Api.Services;
/// <summary>
/// Проекция записей аудита для операторской консоли
/// </summary>
public static class AuditViewFactory
{
/// <summary>
/// Дополняет записи аудита реальными пользователями и разбирает изменения деталей
/// </summary>
/// <param name="records">Записи аудита.</param>
/// <param name="tenantRepository">Реестр пространств для разрешения имён.</param>
/// <param name="authStore">Хранилище пользователей для разрешения логинов владельцев.</param>
/// <param name="referenceResolver">Резолвер ссылок на карточки и колонки.</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>Записи для чтения оператором.</returns>
public static async Task<IReadOnlyList<AuditRecordViewDto>> ProjectAsync(
IReadOnlyList<AuditRecordDto> records,
ITenantRepository tenantRepository,
IAuthStore authStore,
IAuditReferenceResolver referenceResolver,
CancellationToken ct)
{
Guid[] tenantIds = DistinctTenantIds(records);
IReadOnlyDictionary<Guid, string> names = await ResolveNamesAsync(tenantIds, tenantRepository, ct);
IReadOnlyDictionary<Guid, string> owners = await ResolveOwnerLoginsAsync(tenantIds, authStore, ct);
IReadOnlyDictionary<string, string> references = await referenceResolver.ResolveAsync(records, ct);
var view = new List<AuditRecordViewDto>(records.Count);
foreach (AuditRecordDto record in records)
{
string? tenantName = record.TenantId is { } tenantId && names.TryGetValue(tenantId, out string? name)
? name
: null;
view.Add(new AuditRecordViewDto(
record.EventType,
record.ActorType,
record.ActorId,
record.TenantId,
ResolveUserName(record, owners),
tenantName,
record.Ip,
ResolveChanges(record, references),
record.DetailJson,
record.At,
record.Id));
}
return view;
}
// Заменяет идентификаторы ссылок изменения значениями из карты имён и убирает пустые изменения.
// record: Запись аудита.
// references: Имена по идентификаторам ссылок.
// Возвращает: Изменения с читаемыми значениями ссылок без пар «без изменения».
private static IReadOnlyList<AuditChangeDto> ResolveChanges(
AuditRecordDto record,
IReadOnlyDictionary<string, string> references)
{
IReadOnlyList<AuditChangeDto> changes = record.AuditChanges();
var resolved = new List<AuditChangeDto>(changes.Count);
foreach (AuditChangeDto change in changes)
{
string? from = ResolveReference(references, change.From);
string? to = ResolveReference(references, change.To);
if (IsUnchanged(from, to))
{
continue;
}
resolved.Add(change with { From = from, To = to });
}
return resolved;
}
// Сравнивает значения «было» и «стало».
// from: Значение до изменения.
// to: Значение после изменения.
// Возвращает: True — значения заданы и совпадают.
private static bool IsUnchanged(string? from, string? to) =>
from is not null && to is not null && string.Equals(from, to, StringComparison.Ordinal);
// Подставляет имя вместо идентификатора ссылки, если оно известно.
// references: Имена по идентификаторам ссылок.
// value: Значение изменения.
// Возвращает: Имя ссылки либо исходное значение.
private static string? ResolveReference(
IReadOnlyDictionary<string, string> references,
string? value) =>
value is not null && references.TryGetValue(value, out string? name) ? name : value;
// Логин реального пользователя: владелец пространства, иначе логин/email из деталей события.
// record: Запись аудита.
// owners: Логины владельцев по идентификаторам пространств.
// Возвращает: Логин либо null.
private static string? ResolveUserName(
AuditRecordDto record,
IReadOnlyDictionary<Guid, string> owners)
{
if (record.TenantId is { } tenantId && owners.TryGetValue(tenantId, out string? owner))
{
return owner;
}
return record.DetailValue(AuditFields.Login) ?? record.DetailValue(AuditFields.Email);
}
// Уникальные идентификаторы пространств выборки.
private static Guid[] DistinctTenantIds(IReadOnlyList<AuditRecordDto> records) =>
records
.Select(record => record.TenantId)
.Where(id => id is not null)
.Select(id => id!.Value)
.Distinct()
.ToArray();
// Разрешает имена пространств выборкой по уникальным идентификаторам.
private static async Task<IReadOnlyDictionary<Guid, string>> ResolveNamesAsync(
Guid[] tenantIds,
ITenantRepository tenantRepository,
CancellationToken ct) =>
tenantIds.Length == 0
? new Dictionary<Guid, string>()
: await tenantRepository.FindNamesByIdsAsync(tenantIds, ct);
// Разрешает логины владельцев пространств выборкой по уникальным идентификаторам.
private static async Task<IReadOnlyDictionary<Guid, string>> ResolveOwnerLoginsAsync(
Guid[] tenantIds,
IAuthStore authStore,
CancellationToken ct) =>
tenantIds.Length == 0
? new Dictionary<Guid, string>()
: await authStore.FindOwnerLoginsByTenantIdsAsync(tenantIds, ct);
}
@@ -0,0 +1,19 @@
using Deal.Modules.Tenants.Application.Models;
namespace Deal.Api.Services;
/// <summary>
/// Разрешает идентификаторы ссылок аудита в читаемые имена
/// </summary>
public interface IAuditReferenceResolver
{
/// <summary>
/// Разрешает ссылки на карточки и колонки в их заголовки и имена
/// </summary>
/// <param name="records">Записи аудита выборки.</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>Имена по идентификаторам ссылок; неразрешённые идентификаторы отсутствуют.</returns>
public Task<IReadOnlyDictionary<string, string>> ResolveAsync(
IReadOnlyList<AuditRecordDto> records,
CancellationToken ct);
}
@@ -12,6 +12,10 @@ public sealed class TelegramBackfillScheduler(
IHostApplicationLifetime applicationLifetime,
ILogger<TelegramBackfillScheduler> logger)
{
// Имена фоновых задач (метки запуска).
private const string BackfillMonitoredJob = "backfill_monitored";
private const string FirstBackfillJob = "first_backfill";
// Флаг in-flight «Перечитать» всех каналов (Interlocked): повторный вызов не плодит
// параллельные полные перечитывания (Security review, как RatesRefreshScheduler).
private int _readRecentInProgress;
@@ -27,7 +31,7 @@ public sealed class TelegramBackfillScheduler(
return;
}
RunBackground("backfill_monitored", RunReadRecentAsync);
RunBackground(BackfillMonitoredJob, RunReadRecentAsync);
}
/// <summary>
@@ -50,7 +54,7 @@ public sealed class TelegramBackfillScheduler(
return;
}
RunBackground("first_backfill", (provider, runLogger, ct) => RunFirstBackfillsAsync(provider, runLogger, dialogIds, ct));
RunBackground(FirstBackfillJob, (provider, runLogger, ct) => RunFirstBackfillsAsync(provider, runLogger, dialogIds, ct));
}
// Выполняет работу в собственном scope; любые ошибки — warning в лог (как RatesRefreshScheduler).
@@ -21,6 +21,11 @@ public sealed class SourceIngressGrpcService(
IngressTenantResolver tenants,
ILogger<SourceIngressGrpcService> logger) : SourceIngressService.SourceIngressServiceBase
{
// Исходы приёма источника для лога аудита.
private const string DuplicateOutcome = "duplicate";
private const string NoOpOutcome = "no-op";
private const string QueuedOutcome = "queued";
/// <summary>
/// PushSource — запись источника в очередь пайплайна тенанта.
/// </summary>
@@ -57,7 +62,7 @@ public sealed class SourceIngressGrpcService(
tenant.Id,
item.Source.Kind,
item.Source.ExternalId ?? "-",
result.Duplicate ? "duplicate" : result.Id is null ? "no-op" : "queued");
result.Duplicate ? DuplicateOutcome : result.Id is null ? NoOpOutcome : QueuedOutcome);
return new PushSourceReply
{
@@ -26,6 +26,15 @@ public sealed class TelegramKeysService(IGlobalSettingsStore store, ISecretCiphe
private const string EncryptedPrefix = "enc:";
// Ключи JSON значения telegramKeys (camelCase, как пишет SaveAsync).
private const string ApiIdProperty = "apiId";
private const string ApiHashProperty = "apiHash";
// Геометрия маски секрета: короткий (≤8) → «x…»; иначе «1234…5678».
private const int MaskShortMaxLength = 8;
private const int MaskShortVisibleChars = 1;
private const int MaskEdgeVisibleChars = 4;
// Опции JSON значения telegramKeys: camelCase (как пишет SaveAsync) + терпимость регистра.
private static readonly JsonSerializerOptions KeysJsonOptions = new()
{
@@ -49,8 +58,8 @@ public sealed class TelegramKeysService(IGlobalSettingsStore store, ISecretCiphe
{
using JsonDocument document = JsonDocument.Parse(row.ValueJson);
JsonElement root = document.RootElement;
string apiId = ReadString(root, "apiId");
string apiHash = ReadString(root, "apiHash");
string apiId = ReadString(root, ApiIdProperty);
string apiHash = ReadString(root, ApiHashProperty);
return new TgKeysSnapshot(apiId, cipher.Decrypt(apiHash));
}
catch (JsonException)
@@ -143,8 +152,11 @@ public sealed class TelegramKeysService(IGlobalSettingsStore store, ISecretCiphe
return value.Length switch
{
0 => string.Empty,
<= 8 => string.Concat(value.AsSpan(0, 1), MaskEllipsis),
_ => string.Concat(value.AsSpan(0, 4), MaskEllipsis, value.AsSpan(value.Length - 4)),
<= MaskShortMaxLength => string.Concat(value.AsSpan(0, MaskShortVisibleChars), MaskEllipsis),
_ => string.Concat(
value.AsSpan(0, MaskEdgeVisibleChars),
MaskEllipsis,
value.AsSpan(value.Length - MaskEdgeVisibleChars)),
};
}
}
@@ -0,0 +1,42 @@
namespace Deal.Contracts.Integrations.Models;
/// <summary>
/// Типы контактов заказчика
/// </summary>
public static class CardContactTypes
{
/// <summary>
/// Telegram (@username)
/// </summary>
public const string Telegram = "tg";
/// <summary>
/// Телефон
/// </summary>
public const string Phone = "phone";
/// <summary>
/// Email
/// </summary>
public const string Email = "email";
/// <summary>
/// WhatsApp
/// </summary>
public const string WhatsApp = "whatsapp";
/// <summary>
/// LinkedIn
/// </summary>
public const string LinkedIn = "linkedin";
/// <summary>
/// Сайт
/// </summary>
public const string Site = "site";
/// <summary>
/// Прочее (не опознано)
/// </summary>
public const string Other = "other";
}
@@ -0,0 +1,37 @@
namespace Deal.Contracts.Integrations.Models;
/// <summary>
/// Фазы входа в аккаунт Telegram
/// </summary>
public static class TelegramAuthPhases
{
/// <summary>
/// Аккаунт не подключён
/// </summary>
public const string Idle = "idle";
/// <summary>
/// Ожидание номера телефона
/// </summary>
public const string Phone = "phone";
/// <summary>
/// Ожидание кода
/// </summary>
public const string Code = "code";
/// <summary>
/// Ожидание облачного пароля
/// </summary>
public const string Password = "password";
/// <summary>
/// Ожидание сканирования QR
/// </summary>
public const string Qr = "qr";
/// <summary>
/// Аккаунт авторизован
/// </summary>
public const string Ready = "ready";
}
@@ -0,0 +1,27 @@
namespace Deal.Contracts.Integrations.Models;
/// <summary>
/// Виды диалогов Telegram
/// </summary>
public static class TelegramDialogKinds
{
/// <summary>
/// Канал
/// </summary>
public const string Channel = "channel";
/// <summary>
/// Группа
/// </summary>
public const string Group = "group";
/// <summary>
/// Форум (темы внутри группы)
/// </summary>
public const string Forum = "forum";
/// <summary>
/// Личный чат
/// </summary>
public const string Chat = "chat";
}
@@ -0,0 +1,102 @@
namespace Deal.Contracts.Integrations.Models;
/// <summary>
/// Имена операций Telegram-гейта для логов и диагностики
/// </summary>
public static class TelegramOperations
{
/// <summary>
/// Статус аккаунта
/// </summary>
public const string Status = "status";
/// <summary>
/// Вход по номеру телефона
/// </summary>
public const string StartPhone = "start_phone";
/// <summary>
/// Вход по QR
/// </summary>
public const string StartQr = "start_qr";
/// <summary>
/// Подтверждение кода
/// </summary>
public const string SendCode = "send_code";
/// <summary>
/// Подтверждение облачного пароля
/// </summary>
public const string SendPassword = "send_password";
/// <summary>
/// Выход из аккаунта
/// </summary>
public const string Logout = "logout";
/// <summary>
/// Обновление каталога диалогов
/// </summary>
public const string RefreshDialogs = "refresh_dialogs";
/// <summary>
/// Включение/выключение мониторинга диалога
/// </summary>
public const string SetMonitor = "set_monitor";
/// <summary>
/// Включение/выключение мониторинга всех диалогов
/// </summary>
public const string SetMonitorAll = "set_monitor_all";
/// <summary>
/// Перечитать последние сообщения
/// </summary>
public const string Backfill = "backfill";
/// <summary>
/// Чтение последних сообщений
/// </summary>
public const string ReadRecent = "read_recent";
/// <summary>
/// Чтение исходного сообщения
/// </summary>
public const string ReadSource = "read_source";
/// <summary>
/// Поиск источников
/// </summary>
public const string Search = "search";
/// <summary>
/// Информация об источнике
/// </summary>
public const string GetInfo = "get_info";
/// <summary>
/// Чтение сообщений для оценки
/// </summary>
public const string ReadForEval = "read_for_eval";
/// <summary>
/// Вступление в источник
/// </summary>
public const string Join = "join";
/// <summary>
/// Выход из источника
/// </summary>
public const string Leave = "leave";
/// <summary>
/// Информация об источнике (Discovery)
/// </summary>
public const string DiscoveryInfo = "discovery_info";
/// <summary>
/// Чтение сообщений источника (Discovery)
/// </summary>
public const string DiscoveryRead = "discovery_read";
}
@@ -6,6 +6,9 @@ namespace Deal.Infrastructure.Integrations.Extensions;
// Расширения Uri для SSRF-гейта интеграций
internal static class UriExtensions
{
// Имя loopback-хоста.
private const string LocalhostHost = "localhost";
/// <summary>
/// Проверяет, указывает ли URL на приватный/loopback/link-local адрес
/// </summary>
@@ -14,7 +17,7 @@ internal static class UriExtensions
public static bool IsPrivateEndpoint(this Uri uri)
{
string host = uri.Host;
if (string.Equals(host, "localhost", StringComparison.OrdinalIgnoreCase))
if (string.Equals(host, LocalhostHost, StringComparison.OrdinalIgnoreCase))
{
return true;
}
@@ -1,5 +1,7 @@
using Microsoft.Extensions.Configuration;
using Deal.SharedKernel.Utilities;
namespace Deal.Infrastructure.Integrations.Options;
/// <summary>
@@ -86,13 +88,16 @@ public sealed class MtlsOptions
};
}
// Строковое представление включённого флага числом.
private const string OneLiteral = "1";
/// <summary>
/// Разбирает значение флага DEAL_MTLS_ENABLED
/// </summary>
/// <param name="rawValue">Сырое значение env (null/пусто — выключено).</param>
public static bool IsEnabled(string? rawValue)
=> string.Equals(rawValue, "1", StringComparison.Ordinal)
|| string.Equals(rawValue, "true", StringComparison.OrdinalIgnoreCase);
=> string.Equals(rawValue, OneLiteral, StringComparison.Ordinal)
|| string.Equals(rawValue, BoolText.True, StringComparison.OrdinalIgnoreCase);
// Обрезает путь конфигурации (env-значения с пробелами/кавычками не передаются в файловые API).
// rawValue: Сырое значение env.
@@ -83,8 +83,8 @@ public sealed class AiConnectionChecker : IAiConnectionChecker
string name = meta?.Name ?? request.ProviderId;
// SSRF-гейт (allowlist, preflight): проверка возможна только для провайдера фиксированного
// каталога AiProviders. В штатном потоке недостижимо (PATCH-гейт aiProvider/aiConfigs в
// SettingsService) — защита от ручного изменения БД/повреждённого хранилища.
// каталога AiProviders. В штатном потоке недостижимо (конфигурацию задаёт оператор из
// каталога через OperatorSettingsEndpoints) — защита от ручного изменения БД.
if (meta is null)
{
return BuildResult(request, name, ok: false, message: ProviderNotAllowedMessage);
@@ -1,150 +1,49 @@
using System.Text.Json.Nodes;
using Deal.Grpc.Ai;
using Deal.Modules.Settings.Application.Abstractions;
using Deal.Infrastructure.Integrations.Exceptions;
using Deal.Modules.Settings.Application.Models;
using Deal.Modules.Settings.Application.Services;
namespace Deal.Infrastructure.Integrations.Services;
/// <summary>
/// Собирает конфиг активного ИИ-провайдера для запросов ai-service.
/// Собирает конфиг ИИ-провайдера для запросов ai-service из глобальной конфигурации
/// оператора (общая для всех тенантов; провайдера, модель и ключ задаёт оператор).
/// </summary>
public sealed class AiProviderConfigBuilder
/// <param name="configService">Сервис глобальной конфигурации ИИ (ключ aiConfig).</param>
public sealed class AiProviderConfigBuilder(AiGlobalConfigService configService)
{
// Ключ aiConfigs: поле apiKey переопределения провайдера.
private const string ApiKeyField = "apiKey";
// Ключ aiConfigs: поле baseUrl переопределения провайдера.
private const string BaseUrlField = "baseUrl";
// Ключ aiConfigs: поле model переопределения провайдера.
private const string ModelField = "model";
private const string EncryptedPrefix = "enc:";
private readonly ISettingsStore _store;
private readonly ISecretCipher _secretCipher;
// Текст AiUnavailableException, когда оператор ещё не сохранил конфигурацию ИИ.
private const string AiNotConfiguredMessage = "ИИ не настроен оператором системы";
/// <summary>
/// Создаёт сборщик конфига провайдера.
/// Собирает ProviderConfig для тела запроса ai-service
/// </summary>
/// <param name="store">KV-хранилище настроек тенанта (aiProvider/aiConfigs).</param>
/// <param name="secretCipher">Расшифровка секрета aiConfigs.apiKey.</param>
public AiProviderConfigBuilder(ISettingsStore store, ISecretCipher secretCipher)
{
ArgumentNullException.ThrowIfNull(store);
ArgumentNullException.ThrowIfNull(secretCipher);
_store = store;
_secretCipher = secretCipher;
}
/// <summary>
/// Собирает ProviderConfig активного провайдера для тела запроса ai-service.
/// </summary>
/// <returns>Конфиг: provider_id/base/model/api_key (расшифрованный)/api_style (см. ai.proto).</returns>
/// <returns>Конфиг: provider_id/base/model/api_key/api_style (см. ai.proto).</returns>
/// <exception cref="AiUnavailableException">Конфигурация ИИ не задана оператором.</exception>
public async Task<ProviderConfig> BuildAsync(CancellationToken ct)
{
string providerId = await ReadProviderIdAsync(ct);
AiProviderDefinition meta = AiProviders.All.FirstOrDefault(provider => provider.Id == providerId)
?? AiProviders.All[0]; // неизвестный id — дефолтный провайдер (python L28)
JsonObject? overrides = await ReadAiConfigsOverrideAsync(ct);
JsonObject? raw = overrides?[meta.Id] as JsonObject;
string apiKey = ReadField(raw, ApiKeyField);
if (apiKey.StartsWith(EncryptedPrefix, StringComparison.Ordinal))
AiGlobalConfigSnapshot snapshot = await configService.GetSnapshotAsync(ct);
if (!snapshot.Configured)
{
apiKey = _secretCipher.Decrypt(apiKey);
}
string baseUrl = ReadField(raw, BaseUrlField);
if (baseUrl.Length == 0)
{
baseUrl = meta.Base; // python L90: cfg.baseUrl or meta.base
}
string model = ReadField(raw, ModelField);
if (model.Length == 0)
{
model = meta.Models.FirstOrDefault() ?? string.Empty; // python L91: cfg.model or models[0]
throw new AiUnavailableException(AiNotConfiguredMessage);
}
var config = new ProviderConfig
{
ProviderId = meta.Id,
BaseUrl = baseUrl,
Model = model,
ProviderId = snapshot.ProviderId,
BaseUrl = snapshot.BaseUrl,
Model = snapshot.Model,
};
if (apiKey.Length > 0)
if (snapshot.ApiKey.Length > 0)
{
config.ApiKey = apiKey;
config.ApiKey = snapshot.ApiKey;
}
if (meta.ApiStyle is { Length: > 0 } apiStyle)
if (snapshot.Meta?.ApiStyle is { Length: > 0 } apiStyle)
{
config.ApiStyle = apiStyle;
}
return config;
}
private async Task<string> ReadProviderIdAsync(CancellationToken ct)
{
SettingValue? row = await _store.GetAsync(SettingsKeys.AiProvider, ct);
if (row is null)
{
return SettingsDefaults.AiProvider;
}
try
{
JsonNode? value = JsonNode.Parse(row.ValueJson);
if (value is JsonValue scalar && scalar.TryGetValue<string>(out string? providerId)
&& !string.IsNullOrWhiteSpace(providerId))
{
return providerId;
}
}
catch (System.Text.Json.JsonException)
{
// Повреждённая строка — дефолт (мягкая семантика, как в SettingsService).
}
return SettingsDefaults.AiProvider;
}
// Переопределение aiConfigs тенанта (JSON-объект «id провайдера → конфиг»); null — дефолты.
// ct: Токен отмены.
// Возвращает: Объект переопределения или null.
private async Task<JsonObject?> ReadAiConfigsOverrideAsync(CancellationToken ct)
{
SettingValue? row = await _store.GetAsync(SettingsKeys.AiConfigs, ct);
if (row is null)
{
return null;
}
try
{
return JsonNode.Parse(row.ValueJson) as JsonObject;
}
catch (System.Text.Json.JsonException)
{
// Повреждённая строка — дефолты (мягкая семантика, как в SettingsService).
return null;
}
}
// Строковое поле конфига провайдера (отсутствие/null/не-строка → пустая строка).
// config: Объект конфига провайдера (может быть null — дефолты).
// field: Имя поля (apiKey/baseUrl/model).
// Возвращает: Значение строкой или пустая строка.
private static string ReadField(JsonObject? config, string field)
{
if (config is null || !config.TryGetPropertyValue(field, out JsonNode? node) || node is not JsonValue value)
{
return string.Empty;
}
return value.TryGetValue<string>(out string? text) ? text ?? string.Empty : string.Empty;
}
}
@@ -73,7 +73,7 @@ public sealed class GrpcTelegramClient : ITelegramGateway
}
catch (Exception exception)
{
throw TranslateTransportFailure(exception, tenantId, "status");
throw TranslateTransportFailure(exception, tenantId, TelegramOperations.Status);
}
}
@@ -95,7 +95,7 @@ public sealed class GrpcTelegramClient : ITelegramGateway
}
catch (Exception exception)
{
throw TranslateTransportFailure(exception, tenantId, "start_phone");
throw TranslateTransportFailure(exception, tenantId, TelegramOperations.StartPhone);
}
}
@@ -118,7 +118,7 @@ public sealed class GrpcTelegramClient : ITelegramGateway
}
catch (Exception exception)
{
throw TranslateTransportFailure(exception, tenantId, "start_qr");
throw TranslateTransportFailure(exception, tenantId, TelegramOperations.StartQr);
}
}
@@ -135,7 +135,7 @@ public sealed class GrpcTelegramClient : ITelegramGateway
}
catch (Exception exception)
{
throw TranslateTransportFailure(exception, tenantId, "send_code");
throw TranslateTransportFailure(exception, tenantId, TelegramOperations.SendCode);
}
}
@@ -152,7 +152,7 @@ public sealed class GrpcTelegramClient : ITelegramGateway
}
catch (Exception exception)
{
throw TranslateTransportFailure(exception, tenantId, "send_password");
throw TranslateTransportFailure(exception, tenantId, TelegramOperations.SendPassword);
}
}
@@ -167,7 +167,7 @@ public sealed class GrpcTelegramClient : ITelegramGateway
}
catch (Exception exception)
{
throw TranslateTransportFailure(exception, tenantId, "logout");
throw TranslateTransportFailure(exception, tenantId, TelegramOperations.Logout);
}
}
@@ -183,7 +183,7 @@ public sealed class GrpcTelegramClient : ITelegramGateway
}
catch (Exception exception)
{
throw TranslateTransportFailure(exception, tenantId, "refresh_dialogs");
throw TranslateTransportFailure(exception, tenantId, TelegramOperations.RefreshDialogs);
}
}
@@ -202,7 +202,7 @@ public sealed class GrpcTelegramClient : ITelegramGateway
}
catch (Exception exception)
{
throw TranslateTransportFailure(exception, tenantId, "set_monitor");
throw TranslateTransportFailure(exception, tenantId, TelegramOperations.SetMonitor);
}
}
@@ -218,7 +218,7 @@ public sealed class GrpcTelegramClient : ITelegramGateway
}
catch (Exception exception)
{
throw TranslateTransportFailure(exception, tenantId, "set_monitor_all");
throw TranslateTransportFailure(exception, tenantId, TelegramOperations.SetMonitorAll);
}
}
@@ -238,7 +238,7 @@ public sealed class GrpcTelegramClient : ITelegramGateway
}
catch (Exception exception)
{
throw TranslateTransportFailure(exception, tenantId, "backfill");
throw TranslateTransportFailure(exception, tenantId, TelegramOperations.Backfill);
}
}
@@ -260,7 +260,7 @@ public sealed class GrpcTelegramClient : ITelegramGateway
}
catch (Exception exception)
{
throw TranslateTransportFailure(exception, tenantId, "read_recent");
throw TranslateTransportFailure(exception, tenantId, TelegramOperations.ReadRecent);
}
}
@@ -283,7 +283,7 @@ public sealed class GrpcTelegramClient : ITelegramGateway
}
catch (Exception exception)
{
throw TranslateTransportFailure(exception, tenantId, "read_source");
throw TranslateTransportFailure(exception, tenantId, TelegramOperations.ReadSource);
}
}
@@ -303,7 +303,7 @@ public sealed class GrpcTelegramClient : ITelegramGateway
}
catch (Exception exception)
{
throw TranslateTransportFailure(exception, tenantId, "search");
throw TranslateTransportFailure(exception, tenantId, TelegramOperations.Search);
}
}
@@ -328,7 +328,7 @@ public sealed class GrpcTelegramClient : ITelegramGateway
}
catch (Exception exception)
{
throw TranslateTransportFailure(exception, tenantId, "get_info");
throw TranslateTransportFailure(exception, tenantId, TelegramOperations.GetInfo);
}
}
@@ -358,7 +358,7 @@ public sealed class GrpcTelegramClient : ITelegramGateway
}
catch (Exception exception)
{
throw TranslateTransportFailure(exception, tenantId, "read_for_eval");
throw TranslateTransportFailure(exception, tenantId, TelegramOperations.ReadForEval);
}
}
@@ -374,7 +374,7 @@ public sealed class GrpcTelegramClient : ITelegramGateway
}
catch (Exception exception)
{
throw TranslateTransportFailure(exception, tenantId, "join");
throw TranslateTransportFailure(exception, tenantId, TelegramOperations.Join);
}
}
@@ -390,7 +390,7 @@ public sealed class GrpcTelegramClient : ITelegramGateway
}
catch (Exception exception)
{
throw TranslateTransportFailure(exception, tenantId, "leave");
throw TranslateTransportFailure(exception, tenantId, TelegramOperations.Leave);
}
}
@@ -39,8 +39,6 @@ ContainersService containersService) : IColumnSuggester
private const string KeywordsEmptyReason = "ИИ не смог выделить ключи — попробуйте ещё раз";
private const string RulesModeAny = "any";
/// <inheritdoc />
public async Task<SuggestColumnsResultDto> SuggestColumnsAsync(CancellationToken ct)
{
@@ -121,7 +119,7 @@ ContainersService containersService) : IColumnSuggester
foreach (SuggestedColumnPlan plan in plans)
{
var rules = new ContainerRulesDto(
Mode: RulesModeAny,
Mode: ColumnRuleModes.Any,
Direction: Array.Empty<string>(),
Keywords: [plan.Word],
Stack: Array.Empty<string>(),
@@ -5,6 +5,8 @@ namespace Deal.Infrastructure.Integrations.Services;
public sealed class LocalTelegramGateway : ITelegramGateway
{
// Причина пустого чтения: сообщений в источнике нет.
private const string NoHistoryReason = "no_history";
// Фаза idle-формы (аккаунт не подключён — сервиса нет).
private const string IdlePhase = "idle";
@@ -72,7 +74,7 @@ public sealed class LocalTelegramGateway : ITelegramGateway
string dialogId,
int limit,
CancellationToken ct)
=> Task.FromResult(new TelegramEvalReadDto(false, "no_history", []));
=> Task.FromResult(new TelegramEvalReadDto(false, NoHistoryReason, []));
Task ITelegramGateway.JoinAsync(string username, CancellationToken ct) => Task.CompletedTask;
@@ -1,8 +1,13 @@
using Deal.SharedKernel.Utilities;
namespace Deal.Infrastructure.Integrations.Storage.Extensions;
// Расширения string для разбора конфигурационных значений.
internal static class StringExtensions
{
// Строковое представление включённого флага числом.
private const string OneLiteral = "1";
/// <summary>
/// Разбирает строковое значение как булев флаг конфигурации
/// </summary>
@@ -10,6 +15,6 @@ internal static class StringExtensions
/// <returns>True — значение распознано как включённое.</returns>
public static bool IsTrue(this string raw)
{
return string.Equals(raw, "true", StringComparison.OrdinalIgnoreCase) || raw == "1";
return string.Equals(raw, BoolText.True, StringComparison.OrdinalIgnoreCase) || raw == OneLiteral;
}
}
@@ -1,3 +1,4 @@
using Deal.Modules.Cards.Application.Models;
using NpgsqlTypes;
namespace Deal.Infrastructure.Persistence.Entities;
@@ -130,7 +131,7 @@ public sealed class CardEntity
/// <summary>
/// Предыдущая колонка
/// </summary>
public string PrevCol { get; set; } = "inbox";
public string PrevCol { get; set; } = CardIds.Inbox;
/// <summary>
/// Время помещения в архив
@@ -1,3 +1,4 @@
using Deal.Modules.Kanban.Application.Models;
using NpgsqlTypes;
namespace Deal.Infrastructure.Persistence.Entities;
@@ -35,12 +36,12 @@ public sealed class ContainerEntity
/// <summary>
/// Вид контейнера: board
/// </summary>
public string Kind { get; set; } = "board";
public string Kind { get; set; } = ContainerKinds.Board;
/// <summary>
/// Пространство: dashboard | selected
/// </summary>
public string Space { get; set; } = "dashboard";
public string Space { get; set; } = ContainerSpaces.Dashboard;
/// <summary>
/// Свёрнутость колонки на дашборде
@@ -1,3 +1,5 @@
using Deal.Modules.Discovery.Application.Models;
namespace Deal.Infrastructure.Persistence.Entities;
/// <summary>
@@ -28,7 +30,7 @@ public sealed class DiscCandidateEntity
/// <summary>
/// Тип источника: channel|group|forum.
/// </summary>
public string Kind { get; set; } = "channel";
public string Kind { get; set; } = DiscoveryCandidateKinds.Channel;
/// <summary>
/// Цвет источника из палитры DIALOG_HUES
@@ -63,7 +65,7 @@ public sealed class DiscCandidateEntity
/// <summary>
/// Статус кандидата
/// </summary>
public string Status { get; set; } = "new";
public string Status { get; set; } = DiscoveryCandidateStatuses.New;
/// <summary>
/// Вступили автоматически
@@ -1,3 +1,5 @@
using Deal.Modules.Discovery.Application.Models;
namespace Deal.Infrastructure.Persistence.Entities;
/// <summary>
@@ -55,7 +57,7 @@ public sealed class DiscTaskEntity
/// <summary>
/// Статус задачи: draft|running|paused|done|failed.
/// </summary>
public string Status { get; set; } = "draft";
public string Status { get; set; } = DiscoveryTaskStatuses.Draft;
/// <summary>
/// Индекс текущего ключа поиска
@@ -1,3 +1,5 @@
using Deal.Modules.Tenants.Application.Models;
namespace Deal.Infrastructure.Persistence.Entities;
/// <summary>
@@ -20,7 +22,7 @@ public sealed class InviteEntity
/// </summary>
public Guid? TenantId { get; set; }
public string Status { get; set; } = "pending";
public string Status { get; set; } = InviteStatuses.Pending;
public DateTimeOffset ExpiresAt { get; set; }
@@ -1,3 +1,5 @@
using Deal.Modules.Tenants.Application.Models;
namespace Deal.Infrastructure.Persistence.Entities;
/// <summary>
@@ -14,7 +16,7 @@ public sealed class OperatorEntity
public string PasswordHash { get; set; } = string.Empty;
public string Status { get; set; } = "active";
public string Status { get; set; } = TenantStatuses.Active;
public DateTimeOffset CreatedAt { get; set; }
}
@@ -1,3 +1,5 @@
using Deal.Modules.Pipeline.Application.Models;
namespace Deal.Infrastructure.Persistence.Entities;
/// <summary>
@@ -38,7 +40,7 @@ public sealed class QueueItemEntity
/// <summary>
/// Статус строки: <c>new</c> — ждёт разбора воркером, <c>filtered</c> — прошла фильтры и ждёт ИИ/ML.
/// </summary>
public string Status { get; set; } = "new";
public string Status { get; set; } = PipelineQueueStatuses.New;
/// <summary>
/// Признак возврата из отсева
@@ -1,5 +1,7 @@
using NpgsqlTypes;
using Deal.Modules.Pipeline.Application.Models;
namespace Deal.Infrastructure.Persistence.Entities;
/// <summary>
@@ -50,7 +52,7 @@ public sealed class RejectedItemEntity
/// <summary>
/// Кто вынес решение
/// </summary>
public string Source { get; set; } = "stop";
public string Source { get; set; } = PipelineRejectSources.Rules;
/// <summary>
/// Время получения исходного сообщения
@@ -1,3 +1,5 @@
using Deal.Modules.Tenants.Application.Models;
namespace Deal.Infrastructure.Persistence.Entities;
/// <summary>
@@ -9,7 +11,7 @@ public sealed class TenantEntity
public string Name { get; set; } = string.Empty;
public string Status { get; set; } = "active";
public string Status { get; set; } = TenantStatuses.Active;
public DateTimeOffset CreatedAt { get; set; }
}
@@ -1,3 +1,5 @@
using Deal.Modules.Tenants.Application.Models;
namespace Deal.Infrastructure.Persistence.Entities;
/// <summary>
@@ -18,7 +20,7 @@ public sealed class TenantLimitEntity
/// <summary>
/// Тип периода: month|day.
/// </summary>
public string Period { get; set; } = "month";
public string Period { get; set; } = TenantLimitPeriods.Month;
/// <summary>
/// Начало текущего периода
@@ -1,3 +1,5 @@
using Deal.Modules.Tenants.Application.Models;
namespace Deal.Infrastructure.Persistence.Entities;
/// <summary>
@@ -16,7 +18,7 @@ public sealed class UserEntity
public string PasswordHash { get; set; } = string.Empty;
public string Status { get; set; } = "active";
public string Status { get; set; } = TenantStatuses.Active;
public DateTimeOffset CreatedAt { get; set; }
}
@@ -67,6 +67,32 @@ public sealed class AuthStore(DealDbContext dbContext) : IAuthStore
return entities;
}
public async Task<IReadOnlyDictionary<Guid, string>> FindOwnerLoginsByTenantIdsAsync(
IReadOnlyCollection<Guid> tenantIds,
CancellationToken ct)
{
if (tenantIds.Count == 0)
{
return new Dictionary<Guid, string>();
}
var users = await dbContext.Users
.AsNoTracking()
.Where(u => tenantIds.Contains(u.TenantId))
.OrderBy(u => u.CreatedAt)
.ThenBy(u => u.Login)
.Select(u => new { u.TenantId, u.Login })
.ToListAsync(ct);
var owners = new Dictionary<Guid, string>();
foreach (var user in users)
{
owners.TryAdd(user.TenantId, user.Login);
}
return owners;
}
/// <inheritdoc />
public async Task CreateSessionAsync(SessionDto session, CancellationToken ct)
{
@@ -59,7 +59,7 @@ public sealed partial class DiscoveryStore
Username = row.Username,
Kind = row.Kind,
Hue = row.Hue,
Status = "new",
Status = DiscoveryCandidateStatuses.New,
CreatedAt = now,
UpdatedAt = now,
});
@@ -123,7 +123,7 @@ public sealed partial class DiscoveryStore
return false;
}
row.Status = "joined";
row.Status = DiscoveryCandidateStatuses.Joined;
row.AutoJoined = autoJoined;
row.UpdatedAt = DateTimeOffset.UtcNow;
await _dbContext.SaveChangesAsync(ct);
@@ -135,7 +135,7 @@ public sealed partial class DiscoveryStore
{
DiscCandidateEntity? row = await _dbContext.DiscCandidates
.FirstOrDefaultAsync(candidate => candidate.DialogId == dialogId, ct);
if (row is null || row.Status != "review")
if (row is null || row.Status != DiscoveryCandidateStatuses.Review)
{
return null;
}
@@ -156,7 +156,7 @@ public sealed partial class DiscoveryStore
return false;
}
row.Status = "rejected";
row.Status = DiscoveryCandidateStatuses.Rejected;
row.UpdatedAt = DateTimeOffset.UtcNow;
await _dbContext.SaveChangesAsync(ct);
return true;
@@ -45,7 +45,7 @@ public sealed partial class DiscoveryStore
SampleSize = row.SampleSize,
PlanJoins = row.PlanJoins,
AutoJoin = row.AutoJoin,
Status = "draft",
Status = DiscoveryTaskStatuses.Draft,
CreatedAt = now,
UpdatedAt = now,
});
@@ -101,7 +101,7 @@ public sealed partial class DiscoveryStore
return false;
}
row.Status = "running";
row.Status = DiscoveryTaskStatuses.Running;
if (resetProgress)
{
row.SearchIdx = 0;
@@ -127,7 +127,7 @@ public sealed partial class DiscoveryStore
return false;
}
row.Status = "paused";
row.Status = DiscoveryTaskStatuses.Paused;
row.UpdatedAt = DateTimeOffset.UtcNow;
await _dbContext.SaveChangesAsync(ct);
return true;
@@ -143,7 +143,7 @@ public sealed partial class DiscoveryStore
return false;
}
row.Status = "done";
row.Status = DiscoveryTaskStatuses.Done;
row.UpdatedAt = DateTimeOffset.UtcNow;
await _dbContext.SaveChangesAsync(ct);
return true;
@@ -211,7 +211,7 @@ public sealed partial class DiscoveryStore
async Task<int> IDiscoveryStore.SumActivePlanAsync(string? excludeTaskId, CancellationToken ct)
{
IQueryable<DiscTaskEntity> query = _dbContext.DiscTasks
.Where(task => task.Status != "done" && task.Status != "failed");
.Where(task => task.Status != DiscoveryTaskStatuses.Done && task.Status != DiscoveryTaskStatuses.Failed);
if (excludeTaskId is not null)
{
query = query.Where(task => task.Id != excludeTaskId);
@@ -61,6 +61,23 @@ public sealed class TenantRepository(DealDbContext dbContext) : ITenantRepositor
return entities;
}
public async Task<IReadOnlyDictionary<Guid, string>> FindNamesByIdsAsync(
IReadOnlyCollection<Guid> ids,
CancellationToken ct)
{
if (ids.Count == 0)
{
return new Dictionary<Guid, string>();
}
var rows = await dbContext.Tenants
.AsNoTracking()
.Where(t => ids.Contains(t.Id))
.Select(t => new { t.Id, t.Name })
.ToListAsync(ct);
return rows.ToDictionary(row => row.Id, row => row.Name);
}
/// <inheritdoc />
public async Task<bool> UpdateStatusAsync(
Guid id,
@@ -22,6 +22,6 @@ public sealed class CardMover(CardsService cardsService) : ICardMover
CardResultDto result = CardsDefaultContainers.Contains(toContainerId)
? await cardsService.MoveStageCardAsync(cardId, toContainerId, ct)
: await cardsService.MoveDashboardCardAsync(cardId, toContainerId, ct);
return new CardMoveResultDto(result.Error);
return new CardMoveResultDto(result.Error, From: result.Card?.PrevCol, To: result.Card?.Col);
}
}
@@ -6,4 +6,6 @@ namespace Deal.Modules.Cards.Application.Dtos;
/// Результат перехода карточки единым механизмом <see cref="ICardMover"/>.
/// </summary>
/// <param name="Error">Текст 400-ошибки либо null (успех).</param>
public sealed record CardMoveResultDto(string? Error);
/// <param name="From">Контейнер-источник после перехода; null — переход не выполнен.</param>
/// <param name="To">Контейнер-назначение после перехода; null — переход не выполнен.</param>
public sealed record CardMoveResultDto(string? Error, string? From = null, string? To = null);
@@ -1,3 +1,5 @@
using Deal.Contracts.Integrations.Models;
namespace Deal.Modules.Cards.Application.Models;
/// <summary>
@@ -8,7 +10,7 @@ public sealed record CardContact
/// <summary>
/// Тип контакта: tg/phone/email/linkedin/whatsapp/site/other.
/// </summary>
public string Type { get; init; } = "other";
public string Type { get; init; } = CardContactTypes.Other;
/// <summary>
/// Значение: @username, +7…, name@mail, url.
@@ -23,7 +23,7 @@ public sealed record CardFile
/// <summary>
/// Тип контента: image/video/audio/archive/document/other
/// </summary>
public string Kind { get; init; } = "other";
public string Kind { get; init; } = CardFileKinds.Other;
public string ObjectKey { get; init; } = string.Empty;
}
@@ -0,0 +1,37 @@
namespace Deal.Modules.Cards.Application.Models;
/// <summary>
/// Категории вложений карточки
/// </summary>
public static class CardFileKinds
{
/// <summary>
/// Изображение
/// </summary>
public const string Image = "image";
/// <summary>
/// Видео
/// </summary>
public const string Video = "video";
/// <summary>
/// Аудио
/// </summary>
public const string Audio = "audio";
/// <summary>
/// Документ
/// </summary>
public const string Document = "document";
/// <summary>
/// Архив
/// </summary>
public const string Archive = "archive";
/// <summary>
/// Прочее (не опознано)
/// </summary>
public const string Other = "other";
}
@@ -19,4 +19,24 @@ public static class DiscoveryCandidateKinds
/// Форум (группа с темами; topics заполняются оценкой).
/// </summary>
public const string Forum = "forum";
/// <summary>
/// Русское написание «канал» (как приходит из Telegram)
/// </summary>
public const string ChannelAliasRu = "канал";
/// <summary>
/// Русское написание «форум»
/// </summary>
public const string ForumAliasRu = "форум";
/// <summary>
/// Английское написание «chat»
/// </summary>
public const string ChatAlias = "chat";
/// <summary>
/// Русское написание «чат»
/// </summary>
public const string ChatAliasRu = "чат";
}
@@ -28,7 +28,7 @@ public sealed record DiscoveryCandidateRow
/// <summary>
/// Тип источника: channel|group|forum.
/// </summary>
public string Kind { get; init; } = "channel";
public string Kind { get; init; } = DiscoveryCandidateKinds.Channel;
/// <summary>
/// Цвет источника
@@ -0,0 +1,17 @@
namespace Deal.Modules.Discovery.Application.Models;
/// <summary>
/// Источники решения о соответствии сообщения
/// </summary>
public static class DiscoveryFitOrigins
{
/// <summary>
/// Эвристика по ключевым словам
/// </summary>
public const string Heuristic = "heuristic";
/// <summary>
/// Локальная ML-модель
/// </summary>
public const string Ml = "ml";
}
@@ -0,0 +1,17 @@
namespace Deal.Modules.Discovery.Application.Models;
/// <summary>
/// Языки источников Discovery
/// </summary>
public static class DiscoveryLanguages
{
/// <summary>
/// Русскоязычные источники
/// </summary>
public const string Russian = "ru";
/// <summary>
/// Любой язык
/// </summary>
public const string Any = "any";
}
@@ -116,7 +116,7 @@ public sealed class DiscoveryEvaluator
string raw = text ?? string.Empty;
if (raw.Trim().Length < MinTextLength)
{
return new DiscoveryMessageFit(false, "слишком короткое", "heuristic");
return new DiscoveryMessageFit(false, "слишком короткое", DiscoveryFitOrigins.Heuristic);
}
if (settingsSnapshot.GetBool(SettingsKeys.MlEnabled, SettingsDefaults.MlEnabled))
@@ -167,11 +167,11 @@ public sealed class DiscoveryEvaluator
string kw = (keyword ?? string.Empty).Trim();
if (kw.Length > 0 && haystack.Contains(kw.ToLowerInvariant(), StringComparison.Ordinal))
{
return new DiscoveryMessageFit(true, $"совпал ключ \"{kw}\"", "heuristic");
return new DiscoveryMessageFit(true, $"совпал ключ \"{kw}\"", DiscoveryFitOrigins.Heuristic);
}
}
return new DiscoveryMessageFit(false, "нет совпадений с ключами", "heuristic");
return new DiscoveryMessageFit(false, "нет совпадений с ключами", DiscoveryFitOrigins.Heuristic);
}
/// <summary>
@@ -65,7 +65,7 @@ public sealed class DiscoveryTasksService(IDiscoveryStore store, DiscoveryPlanGu
await planGuard.AssertPlanAsync(planJoins, ct).ConfigureAwait(false);
await planGuard.AssertBudgetAsync(planJoins, excludeTaskId: null, ct).ConfigureAwait(false);
string lang = draft.Lang is "ru" or "any" ? draft.Lang : "ru";
string lang = draft.Lang is DiscoveryLanguages.Russian or DiscoveryLanguages.Any ? draft.Lang : DiscoveryLanguages.Russian;
// Дефолты threshold/sampleSize — из типизированного снимка настроек (C30: один GetAllAsync на создание).
TenantSettingsSnapshot settingsSnapshot = await TenantSettingsSnapshot.LoadAsync(settings, ct);
@@ -230,7 +230,7 @@ public sealed class DiscoveryTasksService(IDiscoveryStore store, DiscoveryPlanGu
int? threshold = patch.Threshold is int value ? Clamp(value, min: 1, max: 100) : null;
int? sampleSize = patch.SampleSize is int sample ? Math.Max(1, sample) : null;
int? minSubscribers = patch.MinSubscribers is int min ? Math.Max(0, min) : null;
string? lang = patch.Lang is "ru" or "any" ? patch.Lang : patch.Lang is null ? null : "ru";
string? lang = patch.Lang is DiscoveryLanguages.Russian or DiscoveryLanguages.Any ? patch.Lang : patch.Lang is null ? null : DiscoveryLanguages.Russian;
string? name = patch.Name is null ? null : patch.Name.Trim();
string? description = patch.Description;
return new DiscoveryTaskPatch
@@ -7,12 +7,16 @@ namespace Deal.Modules.Discovery.Application.Services;
// источника (KindCode) и тексты сообщений для оценки (GroupTexts).
public sealed partial class DiscoveryWorkerService
{
// Префиксы сообщения об ошибке Telegram при FloodWait.
private const string FloodPrefix = "flood:";
private const string FloodSpacePrefix = "flood ";
internal static bool IsFlood(Exception exception)
{
string message = exception.Message ?? string.Empty;
return message.StartsWith("flood:", StringComparison.OrdinalIgnoreCase)
|| message.StartsWith("flood ", StringComparison.OrdinalIgnoreCase)
|| message.Contains("flood:", StringComparison.OrdinalIgnoreCase);
return message.StartsWith(FloodPrefix, StringComparison.OrdinalIgnoreCase)
|| message.StartsWith(FloodSpacePrefix, StringComparison.OrdinalIgnoreCase)
|| message.Contains(FloodPrefix, StringComparison.OrdinalIgnoreCase);
}
private static string KindCode(string kind, bool isForum = false)
@@ -24,9 +28,9 @@ public sealed partial class DiscoveryWorkerService
return kind.Trim().ToLowerInvariant() switch
{
DiscoveryCandidateKinds.Channel or "канал" => DiscoveryCandidateKinds.Channel,
DiscoveryCandidateKinds.Forum or "форум" => DiscoveryCandidateKinds.Forum,
"chat" or "чат" => DiscoveryCandidateKinds.Group,
DiscoveryCandidateKinds.Channel or DiscoveryCandidateKinds.ChannelAliasRu => DiscoveryCandidateKinds.Channel,
DiscoveryCandidateKinds.Forum or DiscoveryCandidateKinds.ForumAliasRu => DiscoveryCandidateKinds.Forum,
DiscoveryCandidateKinds.ChatAlias or DiscoveryCandidateKinds.ChatAliasRu => DiscoveryCandidateKinds.Group,
_ => DiscoveryCandidateKinds.Group,
};
}
@@ -56,7 +56,7 @@ public sealed partial class DiscoveryWorkerService
foreach (TelegramDialogEntryDto item in results)
{
string rawKind = (item.Kind ?? string.Empty).Trim().ToLowerInvariant();
if (rawKind == "чат" || rawKind == "chat")
if (rawKind == DiscoveryCandidateKinds.ChatAliasRu || rawKind == DiscoveryCandidateKinds.ChatAlias)
{
await _log.AddAsync(taskId, DiscoveryLogEvents.Skip, $"{item.Name}: личный чат/бот", ct)
.ConfigureAwait(false);
@@ -56,7 +56,7 @@ public static class ColumnMatcher
bool budgetOk = !budgetEnabled || BudgetInRange.IsInRange(AmountParser.Parse(text), rules.Budget!, rates);
bool pricesOk = !pricesEnabled || BudgetInRange.IsInRange(AmountParser.Parse(text), rules.Prices!, rates);
if (string.Equals(rules.Mode, "any", StringComparison.OrdinalIgnoreCase))
if (string.Equals(rules.Mode, ColumnRuleModes.Any, StringComparison.OrdinalIgnoreCase))
{
// «любое»: совпасть должна хотя бы одна ВКЛЮЧЁННАЯ (непустая) группа.
return (keywords.Count > 0 && keywordsOk)
@@ -69,7 +69,7 @@ public static class RulesDescriber
return NoRulesText;
}
string mode = string.Equals(rules.Mode, "any", StringComparison.OrdinalIgnoreCase)
string mode = string.Equals(rules.Mode, ColumnRuleModes.Any, StringComparison.OrdinalIgnoreCase)
? AnyModePrefix
: AllModePrefix;
return mode + " · " + string.Join("; ", parts);
@@ -0,0 +1,17 @@
namespace Deal.Modules.Kanban.Application.Models;
/// <summary>
/// Режимы сопоставления правил колонки
/// </summary>
public static class ColumnRuleModes
{
/// <summary>
/// Достаточно любого условия
/// </summary>
public const string Any = "any";
/// <summary>
/// Нужны все условия
/// </summary>
public const string All = "all";
}
@@ -15,8 +15,8 @@ public sealed record ContainerCreateDto(
string Name,
string Description = "",
string? Color = null,
string Space = "dashboard",
string Kind = "board",
string Space = ContainerSpaces.Dashboard,
string Kind = ContainerKinds.Board,
bool Suggested = false,
ContainerRulesDto? Rules = null,
string Note = "");
@@ -0,0 +1,52 @@
namespace Deal.Modules.Kanban.Application.Models;
/// <summary>
/// Ключи wire-контракта канбана (ответы API и патчи карточки)
/// </summary>
public static class KanbanWireKeys
{
/// <summary>
/// Счётчик новых карточек
/// </summary>
public const string New = "new";
/// <summary>
/// Счётчик в обучении ML
/// </summary>
public const string Learning = "learning";
/// <summary>
/// Счётчик решённых ML
/// </summary>
public const string Ml = "ml";
/// <summary>
/// Счётчик решённых ИИ
/// </summary>
public const string Ai = "ai";
/// <summary>
/// Признак свёрнутой колонки
/// </summary>
public const string Collapsed = "collapsed";
/// <summary>
/// Ширина колонки
/// </summary>
public const string Width = "width";
/// <summary>
/// Нижняя граница бюджета
/// </summary>
public const string From = "from";
/// <summary>
/// Верхняя граница бюджета
/// </summary>
public const string To = "to";
/// <summary>
/// Валюта бюджета
/// </summary>
public const string Currency = "cur";
}
@@ -361,9 +361,9 @@ public sealed partial class CardsService
return ClearedBudget;
}
double? from = ReadNumber(element, "from");
double? to = ReadNumber(element, "to");
string cur = ReadText(element, "cur");
double? from = ReadNumber(element, KanbanWireKeys.From);
double? to = ReadNumber(element, KanbanWireKeys.To);
string cur = ReadText(element, KanbanWireKeys.Currency);
return new CardBudgetDto(from, to, cur);
}
@@ -0,0 +1,107 @@
namespace Deal.Modules.Pipeline.Application.Models;
/// <summary>
/// Ключи JSON-схемы разбора карточки от ИИ
/// </summary>
public static class AiRawCardKeys
{
/// <summary>
/// Заголовок
/// </summary>
public const string Title = "title";
/// <summary>
/// Колонка/доска
/// </summary>
public const string Board = "board";
/// <summary>
/// Компания
/// </summary>
public const string Company = "company";
/// <summary>
/// Формат работы
/// </summary>
public const string Format = "format";
/// <summary>
/// О задаче
/// </summary>
public const string Task = "task";
/// <summary>
/// Требования
/// </summary>
public const string Requirements = "requirements";
/// <summary>
/// Плюсы
/// </summary>
public const string Plus = "plus";
/// <summary>
/// Условия
/// </summary>
public const string Conditions = "conditions";
/// <summary>
/// Краткое описание
/// </summary>
public const string Summary = "summary";
/// <summary>
/// Стек
/// </summary>
public const string Stack = "stack";
/// <summary>
/// Бюджет
/// </summary>
public const string Budget = "budget";
/// <summary>
/// Контакты
/// </summary>
public const string Contacts = "contacts";
/// <summary>
/// Признак вакансии
/// </summary>
public const string IsVacancy = "is_vacancy";
/// <summary>
/// Признак определённого типа заявки
/// </summary>
public const string IsVacancyKnown = "is_vacancy_known";
/// <summary>
/// Признак спама
/// </summary>
public const string IsSpam = "is_spam";
/// <summary>
/// Значение контакта
/// </summary>
public const string Value = "value";
/// <summary>
/// Валюта бюджета
/// </summary>
public const string Currency = "currency";
/// <summary>
/// Короткий код валюты бюджета
/// </summary>
public const string CurrencyShort = "cur";
/// <summary>
/// Нижняя граница бюджета
/// </summary>
public const string From = "from";
/// <summary>
/// Верхняя граница бюджета
/// </summary>
public const string To = "to";
}
@@ -16,20 +16,20 @@ public static class PipelineRejectConstants
public static readonly IReadOnlyDictionary<string, string> StageLabels =
new Dictionary<string, string>(StringComparer.Ordinal)
{
["length"] = "короткое сообщение",
["stop"] = "стоп-фраза",
["resume"] = "резюме соискателя",
["type"] = "тип заявки",
["budget"] = "нет суммы",
["exclude_kw"] = "исключение: слова/технологии",
["exclude_location"] = "исключение: локация/язык",
["exclude_type"] = "исключение: тип",
["exclude_budget"] = "исключение: бюджет",
["stale"] = "устарело",
["spam_ml"] = "спам (ML)",
["spam_ai"] = "спам (ИИ)",
["filter_ai"] = "ИИ-фильтр",
["dup"] = "повтор",
[PipelineRejectStages.Length] = "короткое сообщение",
[PipelineRejectStages.Stop] = "стоп-фраза",
[PipelineRejectStages.Resume] = "резюме соискателя",
[PipelineRejectStages.Type] = "тип заявки",
[PipelineRejectStages.Budget] = "нет суммы",
[PipelineRejectStages.ExcludeKeywords] = "исключение: слова/технологии",
[PipelineRejectStages.ExcludeLocation] = "исключение: локация/язык",
[PipelineRejectStages.ExcludeType] = "исключение: тип",
[PipelineRejectStages.ExcludeBudget] = "исключение: бюджет",
[PipelineRejectStages.Stale] = "устарело",
[PipelineRejectStages.SpamMl] = "спам (ML)",
[PipelineRejectStages.SpamAi] = "спам (ИИ)",
[PipelineRejectStages.FilterAi] = "ИИ-фильтр",
[PipelineRejectStages.Duplicate] = "повтор",
};
/// <summary>
@@ -38,11 +38,11 @@ public static class PipelineRejectConstants
public static readonly IReadOnlyDictionary<string, string> SourceLabels =
new Dictionary<string, string>(StringComparer.Ordinal)
{
["stop"] = "правила",
["ml"] = "ML",
["ai"] = "ИИ",
["stale"] = "система",
["dup"] = "система",
[PipelineRejectSources.Rules] = "правила",
[PipelineRejectSources.Ml] = "ML",
[PipelineRejectSources.Ai] = "ИИ",
[PipelineRejectSources.System] = "система",
[PipelineRejectSources.Duplicate] = "система",
};
/// <summary>
@@ -0,0 +1,32 @@
namespace Deal.Modules.Pipeline.Application.Models;
/// <summary>
/// Источники решения об отсеве
/// </summary>
public static class PipelineRejectSources
{
/// <summary>
/// Правила входящих (стоп-фразы, тип и т.п.)
/// </summary>
public const string Rules = "stop";
/// <summary>
/// Локальная ML-модель
/// </summary>
public const string Ml = "ml";
/// <summary>
/// ИИ-фильтр
/// </summary>
public const string Ai = "ai";
/// <summary>
/// Система (устаревание, дубликаты)
/// </summary>
public const string System = "system";
/// <summary>
/// Дубликат
/// </summary>
public const string Duplicate = "dup";
}
@@ -0,0 +1,77 @@
namespace Deal.Modules.Pipeline.Application.Models;
/// <summary>
/// Этапы отсева входящих сообщений
/// </summary>
public static class PipelineRejectStages
{
/// <summary>
/// Слишком короткое сообщение
/// </summary>
public const string Length = "length";
/// <summary>
/// Стоп-фраза
/// </summary>
public const string Stop = "stop";
/// <summary>
/// Резюме соискателя
/// </summary>
public const string Resume = "resume";
/// <summary>
/// Неподходящий тип заявки
/// </summary>
public const string Type = "type";
/// <summary>
/// Нет суммы
/// </summary>
public const string Budget = "budget";
/// <summary>
/// Исключение по словам/технологиям
/// </summary>
public const string ExcludeKeywords = "exclude_kw";
/// <summary>
/// Исключение по локации/языку
/// </summary>
public const string ExcludeLocation = "exclude_location";
/// <summary>
/// Исключение по типу
/// </summary>
public const string ExcludeType = "exclude_type";
/// <summary>
/// Исключение по бюджету
/// </summary>
public const string ExcludeBudget = "exclude_budget";
/// <summary>
/// Устаревшее сообщение
/// </summary>
public const string Stale = "stale";
/// <summary>
/// Спам по ML
/// </summary>
public const string SpamMl = "spam_ml";
/// <summary>
/// Спам по ИИ
/// </summary>
public const string SpamAi = "spam_ai";
/// <summary>
/// Отсев ИИ-фильтром
/// </summary>
public const string FilterAi = "filter_ai";
/// <summary>
/// Дубликат
/// </summary>
public const string Duplicate = "dup";
}
@@ -1,4 +1,5 @@
using System.Text.RegularExpressions;
using Deal.Contracts.Integrations.Models;
using Deal.Modules.Kanban.Application.Models;
namespace Deal.Modules.Pipeline.Application.Parse;
@@ -65,12 +66,12 @@ public static class ContactsQualifier
private static readonly IReadOnlyDictionary<string, int> PrimaryOrder = new Dictionary<string, int>(StringComparer.Ordinal)
{
["tg"] = 0,
["phone"] = 1,
["whatsapp"] = 2,
["email"] = 3,
["linkedin"] = 4,
["site"] = 5,
[CardContactTypes.Telegram] = 0,
[CardContactTypes.Phone] = 1,
[CardContactTypes.WhatsApp] = 2,
[CardContactTypes.Email] = 3,
[CardContactTypes.LinkedIn] = 4,
[CardContactTypes.Site] = 5,
};
private const int UnknownTypePriority = 9;
@@ -93,7 +94,7 @@ public static class ContactsQualifier
string name = s[1..].Trim();
if (ProfileHandleRe.IsMatch(name) && !name.EndsWith(BotSuffix, StringComparison.OrdinalIgnoreCase))
{
return new CardContactDto("tg", "@" + name);
return new CardContactDto(CardContactTypes.Telegram, "@" + name);
}
return null;
@@ -106,7 +107,7 @@ public static class ContactsQualifier
if (!ReservedProfileNames.Contains(name.ToLowerInvariant())
&& !name.EndsWith(BotSuffix, StringComparison.OrdinalIgnoreCase))
{
return new CardContactDto("tg", "@" + name);
return new CardContactDto(CardContactTypes.Telegram, "@" + name);
}
return null;
@@ -114,23 +115,23 @@ public static class ContactsQualifier
if (EmailRe.IsMatch(s))
{
return new CardContactDto("email", s.ToLowerInvariant());
return new CardContactDto(CardContactTypes.Email, s.ToLowerInvariant());
}
string digits = new string(s.Where(char.IsDigit).ToArray());
if (PhoneRe.IsMatch(s) && digits.Length is >= 10 and <= 15)
{
return new CardContactDto("phone", (s.StartsWith('+') ? "+" : string.Empty) + digits);
return new CardContactDto(CardContactTypes.Phone, (s.StartsWith('+') ? "+" : string.Empty) + digits);
}
if (LinkedinRe.IsMatch(s))
{
return new CardContactDto("linkedin", s);
return new CardContactDto(CardContactTypes.LinkedIn, s);
}
if (WhatsappRe.IsMatch(s))
{
return new CardContactDto("whatsapp", s);
return new CardContactDto(CardContactTypes.WhatsApp, s);
}
if (s.StartsWith("http", StringComparison.OrdinalIgnoreCase))
@@ -144,7 +145,7 @@ public static class ContactsQualifier
return null;
}
return new CardContactDto("site", s);
return new CardContactDto(CardContactTypes.Site, s);
}
return null;
@@ -4,7 +4,9 @@ using System.Text.Json.Nodes;
using Deal.Contracts.Integrations.Models;
using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Kanban.Application.Services;
using Deal.Modules.Pipeline.Application.Models;
using Deal.Modules.Pipeline.Application.Parse;
using Deal.SharedKernel.Utilities;
namespace Deal.Modules.Pipeline.Application.Services;
@@ -19,9 +21,9 @@ public static class AiRawCardMapper
private const string BudgetAmountPattern = @"[0-9.,]+\s*[кkКK]?";
private const string CurrencyFieldName = "currency";
private const string CurrencyFieldNameShort = "cur";
// Строковые значения bool-полей, которые считаются «ложью».
private static readonly IReadOnlySet<string> FalsyAnswerValues =
new HashSet<string>(StringComparer.Ordinal) { "0", BoolText.False, "no", "нет", "null", "none" };
// Регулярное выражение числа бюджета: число + необязательные «к»/валюта-суффиксы.
private static readonly System.Text.RegularExpressions.Regex BudgetNumRe =
@@ -39,7 +41,7 @@ public static class AiRawCardMapper
JsonObject? root = JsonNode.Parse(json) as JsonObject
?? throw new JsonException("Ответ ИИ-классификатора — не JSON-объект (ok=true, но схема нарушена).");
string title = MessageTextCleaner.CleanShort(ReadRawString(root, "title"), MaxTitleCodePoints);
string title = MessageTextCleaner.CleanShort(ReadRawString(root, AiRawCardKeys.Title), MaxTitleCodePoints);
if (title.Length == 0)
{
title = MessageTextCleaner.CleanShort(text, MaxFallbackTextCodePoints); // python L455 fallback
@@ -49,7 +51,7 @@ public static class AiRawCardMapper
AiBudgetDto? budget = ReadBudget(root);
IReadOnlyList<AiContactDto> contacts = ReadContacts(root, text);
string? board = ReadRawString(root, "board").Trim();
string? board = ReadRawString(root, AiRawCardKeys.Board).Trim();
if (board.Length == 0)
{
board = null;
@@ -57,19 +59,19 @@ public static class AiRawCardMapper
return new AiParsedCardDto(
Title: title,
Company: ReadNullableString(root, "company"),
Format: ReadNullableString(root, "format"),
Task: ReadNullableString(root, "task"),
Requirements: ReadItems(root, "requirements"),
Plus: ReadItems(root, "plus"),
Conditions: ReadNullableString(root, "conditions"),
Summary: ReadNullableString(root, "summary"),
Company: ReadNullableString(root, AiRawCardKeys.Company),
Format: ReadNullableString(root, AiRawCardKeys.Format),
Task: ReadNullableString(root, AiRawCardKeys.Task),
Requirements: ReadItems(root, AiRawCardKeys.Requirements),
Plus: ReadItems(root, AiRawCardKeys.Plus),
Conditions: ReadNullableString(root, AiRawCardKeys.Conditions),
Summary: ReadNullableString(root, AiRawCardKeys.Summary),
Stack: stack,
Budget: budget,
Contacts: contacts,
IsVacancy: ReadBool(root, "is_vacancy"),
IsVacancyKnown: ReadBool(root, "is_vacancy_known"), // false — воркер стемпит true после успешного ИИ
IsSpam: ReadBool(root, "is_spam"),
IsVacancy: ReadBool(root, AiRawCardKeys.IsVacancy),
IsVacancyKnown: ReadBool(root, AiRawCardKeys.IsVacancyKnown), // false — воркер стемпит true после успешного ИИ
IsSpam: ReadBool(root, AiRawCardKeys.IsSpam),
Board: board);
}
@@ -114,7 +116,7 @@ public static class AiRawCardMapper
if (value.TryGetValue<string>(out string? text))
{
string lower = (text ?? string.Empty).Trim().ToLowerInvariant();
return lower.Length > 0 && lower is not ("0" or "false" or "no" or "нет" or "null" or "none");
return lower.Length > 0 && !FalsyAnswerValues.Contains(lower);
}
return false;
@@ -147,7 +149,7 @@ public static class AiRawCardMapper
private static IReadOnlyList<string> ReadStack(JsonObject root)
{
if (!root.TryGetPropertyValue("stack", out JsonNode? node) || node is null)
if (!root.TryGetPropertyValue(AiRawCardKeys.Stack, out JsonNode? node) || node is null)
{
return Array.Empty<string>();
}
@@ -161,19 +163,19 @@ public static class AiRawCardMapper
private static AiBudgetDto? ReadBudget(JsonObject root)
{
if (!root.TryGetPropertyValue("budget", out JsonNode? node) || node is not JsonObject budget)
if (!root.TryGetPropertyValue(AiRawCardKeys.Budget, out JsonNode? node) || node is not JsonObject budget)
{
return null;
}
string currency = ReadRawString(budget, CurrencyFieldName);
string currency = ReadRawString(budget, AiRawCardKeys.Currency);
if (currency.Length == 0)
{
currency = ReadRawString(budget, CurrencyFieldNameShort); // ai.py L327: currency или cur
currency = ReadRawString(budget, AiRawCardKeys.CurrencyShort); // ai.py L327: currency или cur
}
double? from = ParseBudgetBound(budget, "from");
double? to = ParseBudgetBound(budget, "to");
double? from = ParseBudgetBound(budget, AiRawCardKeys.From);
double? to = ParseBudgetBound(budget, AiRawCardKeys.To);
CardBudgetDto? normalized = BudgetNormalizer.Normalize(new BudgetRangeDto(from, to, currency));
return normalized is null
? null
@@ -226,7 +228,7 @@ public static class AiRawCardMapper
private static IReadOnlyList<AiContactDto> ReadContacts(JsonObject root, string text)
{
List<string> candidates = new();
if (root.TryGetPropertyValue("contacts", out JsonNode? node) && node is not null)
if (root.TryGetPropertyValue(AiRawCardKeys.Contacts, out JsonNode? node) && node is not null)
{
if (node is JsonValue)
{
@@ -245,7 +247,7 @@ public static class AiRawCardMapper
break;
case JsonObject contactObject:
{
string value = ReadRawString(contactObject, "value"); // python L402403: dict.get("value")
string value = ReadRawString(contactObject, AiRawCardKeys.Value);
if (value.Length > 0)
{
candidates.Add(value);
@@ -220,7 +220,7 @@ public sealed class MlReviewService(
{
// TrashCardAsync(teach:true) сам шлёт обучающий сигнал «спам» — второй сигнал не нужен.
CardDto? trashed = await cards.TrashCardAsync(card.Id, teach: true, ct);
return new MlApplyResult(null, Ok: true, Learned: true, Moved: "trash", LeadId: trashed?.Id ?? card.Id);
return new MlApplyResult(null, Ok: true, Learned: true, Moved: CardIds.Trash, LeadId: trashed?.Id ?? card.Id);
}
await mlClient.PushAsync(text, MlLearningLabels.Spam, UserPushWeight, ct);
@@ -47,16 +47,16 @@ public sealed class PipelineProcessingService(
// Вес снятия метки «спам»: реальное действие пользователя «это не спам» (delta=1.0).
private const double SpamUnlearnDelta = -1.0;
private const string DuplicateSource = "dup";
private const string DuplicateSource = PipelineRejectSources.Duplicate;
// Имя сущности для текста ошибки «не найдено».
private const string RejectedEntityName = "Запись отсева";
private static readonly HashSet<string> SpamStages = new(StringComparer.Ordinal)
{
"spam_ml",
"spam_ai",
"filter_ai",
PipelineRejectStages.SpamMl,
PipelineRejectStages.SpamAi,
PipelineRejectStages.FilterAi,
};
@@ -3,9 +3,9 @@ namespace Deal.Modules.Settings.Application.Models;
/// <summary>
/// Данные проверки подключения AI-провайдера.
/// </summary>
/// <param name="ProviderId">Идентификатор провайдера (id из каталога <c>AiProviders</c>, ключ в aiConfigs).</param>
/// <param name="BaseUrl">Эффективный базовый URL: переопределение aiConfigs или дефолт каталога.</param>
/// <param name="Model">Активная модель: переопределение aiConfigs или первая модель каталога.</param>
/// <param name="ProviderId">Идентификатор провайдера (id из каталога <c>AiProviders</c>).</param>
/// <param name="BaseUrl">Эффективный базовый URL провайдера.</param>
/// <param name="Model">Активная модель.</param>
/// <param name="ApiKey">API-ключ открытым текстом (пустая строка — ключ не задан).</param>
/// <param name="IsLocal">True — локальный сервер (Ollama/LM Studio): ключ не требуется, HTTP не выполняется.</param>
/// <param name="ApiStyle">Стиль API: null — OpenAI-совместимый, <c>"anthropic"</c> — Messages API.</param>
@@ -1,7 +1,7 @@
namespace Deal.Modules.Settings.Application.Models;
/// <summary>
/// Результат проверки подключения AI-провайдера — тело POST /api/ai/check.
/// Результат проверки подключения AI-провайдера — тело проверки связи операторской конфигурации ИИ.
/// </summary>
/// <param name="Ok">True — подключение успешно (включая локальные серверы).</param>
/// <param name="Message">Сообщение для UI.</param>
@@ -1,10 +0,0 @@
namespace Deal.Modules.Settings.Application.Models;
/// <summary>
/// Публичная форма конфигурации AI-провайдера в <c>aiConfigs</c>.
/// </summary>
/// <param name="BaseUrl">Базовый URL API провайдера.</param>
/// <param name="Model">Активная модель.</param>
/// <param name="KeySet">True — API-ключ задан (после расшифровки не пустой).</param>
/// <param name="KeyMasked">Маскированный ключ: пусто, как есть (len ≤ 8) или «1234…5678».</param>
public sealed record AiConfigPublicDto(string BaseUrl, string Model, bool KeySet, string KeyMasked);
@@ -1,9 +0,0 @@
namespace Deal.Modules.Settings.Application.Models;
/// <summary>
/// Сохранённая конфигурация AI-провайдера в настройке <c>aiConfigs</c>
/// </summary>
/// <param name="ApiKey">API-ключ: пустая строка или зашифрованное значение с префиксом <c>enc:</c>.</param>
/// <param name="BaseUrl">Базовый URL API (переопределение дефолта провайдера).</param>
/// <param name="Model">Активная модель.</param>
public sealed record AiConfigSetting(string ApiKey, string BaseUrl, string Model);
@@ -0,0 +1,18 @@
namespace Deal.Modules.Settings.Application.Models;
/// <summary>
/// Маскированная форма глобальной конфигурации ИИ — тело ответа операторской ручки GET/PUT ai-config.
/// </summary>
/// <param name="ProviderId">Id провайдера из каталога <see cref="AiProviders"/>; пусто — конфигурация не задана.</param>
/// <param name="BaseUrl">Базовый URL API.</param>
/// <param name="Model">Активная модель.</param>
/// <param name="KeySet">True — API-ключ задан (после расшифровки не пустой).</param>
/// <param name="KeyMasked">Маскированный ключ: пусто, как есть (len ≤ 8) или «1234…5678».</param>
/// <param name="Providers">Каталог провайдеров для выбора оператором.</param>
public sealed record AiGlobalConfigMaskedDto(
string ProviderId,
string BaseUrl,
string Model,
bool KeySet,
string KeyMasked,
IReadOnlyList<AiProviderPublicDto> Providers);
@@ -0,0 +1,29 @@
namespace Deal.Modules.Settings.Application.Models;
/// <summary>
/// Снимок глобальной конфигурации ИИ-провайдера с расшифрованным ключом (только для внутреннего использования).
/// </summary>
/// <param name="ProviderId">Id провайдера из каталога <see cref="AiProviders"/>; пусто — конфигурация не задана.</param>
/// <param name="BaseUrl">Базовый URL API (эффективный: сохранённый или дефолт каталога).</param>
/// <param name="Model">Активная модель (эффективная: сохранённая или первая из каталога).</param>
/// <param name="ApiKey">API-ключ открытым текстом (пусто — не задан; локальным провайдерам не нужен).</param>
public sealed record AiGlobalConfigSnapshot(
string ProviderId,
string BaseUrl,
string Model,
string ApiKey)
{
/// <summary>
/// Провайдер каталога по <see cref="ProviderId"/> или null для неизвестного/незаданного id
/// </summary>
public AiProviderDefinition? Meta => AiProviders.All.FirstOrDefault(provider => provider.Id == ProviderId);
/// <summary>
/// Конфигурация готова к вызовам ИИ: провайдер известен, адрес и модель непустые,
/// ключ задан (или не нужен локальному провайдеру)
/// </summary>
public bool Configured => Meta is not null
&& BaseUrl.Length > 0
&& Model.Length > 0
&& (Meta.Local || ApiKey.Length > 0);
}
@@ -0,0 +1,14 @@
namespace Deal.Modules.Settings.Application.Models;
/// <summary>
/// Сериализуемая форма значения глобальной конфигурации ИИ (ключ — зашифрованный).
/// </summary>
/// <param name="ProviderId">Id провайдера.</param>
/// <param name="ApiKey">Зашифрованный ключ (префикс <c>enc:</c>) или пусто.</param>
/// <param name="BaseUrl">Базовый URL.</param>
/// <param name="Model">Модель.</param>
public sealed record AiGlobalConfigValue(
string ProviderId,
string ApiKey,
string BaseUrl,
string Model);
@@ -0,0 +1,16 @@
namespace Deal.Modules.Settings.Application.Models;
/// <summary>
/// Провайдер ИИ для выбора оператором
/// </summary>
/// <param name="Id">Идентификатор провайдера из каталога.</param>
/// <param name="Name">Человекочитаемое имя.</param>
/// <param name="Base">Базовый URL API по умолчанию.</param>
/// <param name="Local">True — локальный сервер (ключ не нужен).</param>
/// <param name="Models">Доступные модели каталога; пусто — оператор задаёт модель вручную.</param>
public sealed record AiProviderPublicDto(
string Id,
string Name,
string Base,
bool Local,
IReadOnlyList<string> Models);
@@ -6,4 +6,9 @@ namespace Deal.Modules.Settings.Application.Models;
public static class GlobalSettingsKeys
{
public const string TelegramKeys = "telegramKeys";
/// <summary>
/// Глобальная конфигурация ИИ-провайдера (задаётся оператором, общая для всех тенантов)
/// </summary>
public const string AiConfig = "aiConfig";
}
@@ -1,11 +0,0 @@
namespace Deal.Modules.Settings.Application.Models;
/// <summary>
/// Провайдер ИИ в public-снимке настроек.
/// </summary>
/// <param name="Id">Идентификатор провайдера.</param>
/// <param name="Name">Человекочитаемое имя.</param>
/// <param name="Base">Базовый URL API.</param>
/// <param name="Local">True — локальный сервер (ключ не нужен).</param>
/// <param name="Models">Доступные модели.</param>
public sealed record ProviderPublicDto(string Id, string Name, string Base, bool Local, IReadOnlyList<string> Models);
@@ -210,20 +210,4 @@ public sealed record PublicSettingsDto
/// </summary>
public IReadOnlyDictionary<string, object?> ColState { get; init; } =
new Dictionary<string, object?>();
/// <summary>
/// Активный AI-провайдер
/// </summary>
public string AiProvider { get; init; } = string.Empty;
/// <summary>
/// Публичные конфигурации AI-провайдеров
/// </summary>
public IReadOnlyDictionary<string, AiConfigPublicDto> AiConfigs { get; init; } =
new Dictionary<string, AiConfigPublicDto>();
/// <summary>
/// Статический список AI-провайдеров.
/// </summary>
public IReadOnlyList<ProviderPublicDto> Providers { get; init; } = Array.Empty<ProviderPublicDto>();
}

Some files were not shown because too many files have changed in this diff Show More