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
68 changed files with 2034 additions and 1148 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 |
@@ -268,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 1–14; бэкапы — §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`. В обычной (прод) сборке учётные
@@ -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, SettingsFieldKeys.ApiKey) ?? defaults.ApiKey,
BaseUrl: ReadField(entry, SettingsFieldKeys.BaseUrl) ?? defaults.BaseUrl,
Model: ReadField(entry, SettingsFieldKeys.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;
}
}
@@ -182,7 +182,14 @@ public static class CardsEndpoints
return EndpointResults.BadRequest(outcome.Error);
}
await AuditAppender.AppendTenantAsync(context, AuditEvents.CardMoved, [AuditDetails.Set(AuditFields.CardId, cardId), AuditDetails.Set(AuditFields.Destination, 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);
@@ -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, [AuditDetails.Set(AuditFields.ContainerId, 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 });
}
@@ -135,6 +135,7 @@ public static class OperatorAnalyticsEndpoints
AnalyticsService analyticsService,
ITenantRepository tenantRepository,
IAuthStore authStore,
IAuditReferenceResolver referenceResolver,
CancellationToken ct)
{
if (context.GetCurrentOperator() is null)
@@ -144,7 +145,7 @@ public static class OperatorAnalyticsEndpoints
AnalyticsActivityDto activity = await analyticsService.ActivityAsync(
eventType, actorType, actorId, tenantId, from, to, limit, offset, ct);
IReadOnlyList<AuditRecordViewDto> view = await AuditViewFactory.ProjectAsync(activity.Items, tenantRepository, authStore, ct);
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 });
}
@@ -42,6 +42,7 @@ public static class OperatorAuditEndpoints
AuditService auditService,
ITenantRepository tenantRepository,
IAuthStore authStore,
IAuditReferenceResolver referenceResolver,
CancellationToken ct)
{
var operatorIdentity = context.GetCurrentOperator();
@@ -54,7 +55,7 @@ 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);
IReadOnlyList<AuditRecordViewDto> view = await AuditViewFactory.ProjectAsync(items, tenantRepository, authStore, ct);
IReadOnlyList<AuditRecordViewDto> view = await AuditViewFactory.ProjectAsync(items, tenantRepository, authStore, referenceResolver, ct);
return Results.Ok(new { items = view, total });
}
@@ -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;
}
@@ -135,6 +162,152 @@ public static class OperatorSettingsEndpoints
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.
@@ -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, [AuditDetails.Set(AuditFields.Fields, string.Join(", ", 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))
+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();
@@ -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;
}
}
}
}
+45 -1
View File
@@ -14,17 +14,20 @@ public static class AuditViewFactory
/// <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)
@@ -40,7 +43,7 @@ public static class AuditViewFactory
ResolveUserName(record, owners),
tenantName,
record.Ip,
record.AuditChanges(),
ResolveChanges(record, references),
record.DetailJson,
record.At,
record.Id));
@@ -49,6 +52,47 @@ public static class AuditViewFactory
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: Логины владельцев по идентификаторам пространств.
@@ -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);
}
@@ -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;
}
}
@@ -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);
@@ -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>();
}
@@ -35,11 +35,6 @@ public enum SettingKind
/// </summary>
MyPrompts,
/// <summary>
/// Конфигурации AI-провайдеров с ключами
/// </summary>
AiConfigs,
/// <summary>
/// Внутренний (непубличный) ключ
/// </summary>
@@ -207,18 +207,6 @@ public static class SettingsDefaults
/// </summary>
public static readonly IReadOnlyDictionary<string, object?> ColState = new Dictionary<string, object?>();
// ── ИИ ──
/// <summary>
/// Дефолт «aiProvider»
/// </summary>
public const string AiProvider = "deepseek";
/// <summary>
/// Дефолт «aiConfigs»
/// </summary>
public static readonly IReadOnlyDictionary<string, AiConfigSetting> AiConfigs = BuildDefaultAiConfigs();
// ── Telegram / Discovery ──
/// <summary>
@@ -255,16 +243,4 @@ public static class SettingsDefaults
/// Дефолт «discPaused»
/// </summary>
public const bool DiscPaused = false;
private static IReadOnlyDictionary<string, AiConfigSetting> BuildDefaultAiConfigs()
{
var result = new Dictionary<string, AiConfigSetting>(AiProviders.All.Count);
foreach (AiProviderDefinition provider in AiProviders.All)
{
string firstModel = provider.Models.FirstOrDefault() ?? string.Empty;
result.Add(provider.Id, new AiConfigSetting(ApiKey: "", BaseUrl: provider.Base, Model: firstModel));
}
return result;
}
}
@@ -104,8 +104,6 @@ public static class SettingsKeys
public const string RateSource = "rateSource";
public const string AiProvider = "aiProvider";
public const string AiPrompt = "aiPrompt";
public const string AiFilterPrompt = "aiFilterPrompt";
@@ -147,8 +145,6 @@ public static class SettingsKeys
public const string MyPrompts = "myPrompts";
public const string AiConfigs = "aiConfigs";
/// <summary>
/// Каталог публичных ключей
/// </summary>
@@ -181,7 +177,6 @@ public static class SettingsKeys
// String
[TargetCurrency] = SettingKind.String,
[RateSource] = SettingKind.String,
[AiProvider] = SettingKind.String,
[AiPrompt] = SettingKind.String,
[AiFilterPrompt] = SettingKind.String,
[CardPrompt] = SettingKind.String,
@@ -202,7 +197,6 @@ public static class SettingsKeys
[ColState] = SettingKind.Dict,
// special
[MyPrompts] = SettingKind.MyPrompts,
[AiConfigs] = SettingKind.AiConfigs,
};
/// <summary>
@@ -0,0 +1,223 @@
using System.Text.Json;
using Deal.Modules.Settings.Application.Abstractions;
using Deal.Modules.Settings.Application.Models;
namespace Deal.Modules.Settings.Application.Services;
/// <summary>
/// Читает, расшифровывает и сохраняет глобальную конфигурацию ИИ-провайдера (общую для всех
/// тенантов) из хранилища глобальных настроек. Провайдера, модель и ключ задаёт оператор —
/// пользователи тенантов модели не настраивают.
/// </summary>
/// <param name="store">KV-хранилище глобальных настроек оператора (таблица public.global_settings).</param>
/// <param name="cipher">Шифр секретов (AES-256-GCM, формат <c>enc:</c>).</param>
public sealed class AiGlobalConfigService(IGlobalSettingsStore store, ISecretCipher cipher)
{
/// <summary>
/// Минимальная длина API-ключа провайдера ИИ
/// </summary>
public const int ApiKeyMinLength = 8;
// Символ-заполнитель маски секрета (U+2026, «1234…5678»).
private const string MaskEllipsis = "…";
private const string EncryptedPrefix = "enc:";
// Id провайдера «Другой (OpenAI-совместимый)» — единственный не-local, чей baseUrl
// задаёт оператор (остальным облачным адрес фиксирован каталогом, SSRF-гейт).
private const string CustomProviderId = "custom";
// Ключ JSON значения aiConfig с id провайдера (остальные поля — SettingsFieldKeys).
private const string ProviderIdProperty = "providerId";
// Геометрия маски секрета: короткий (≤8) → «x…»; иначе «1234…5678».
private const int MaskShortMaxLength = 8;
private const int MaskShortVisibleChars = 1;
private const int MaskEdgeVisibleChars = 4;
// Каталог провайдеров для выбора оператором (не зависит от сохранённой конфигурации).
private static readonly IReadOnlyList<AiProviderPublicDto> ProviderCatalog =
AiProviders.All
.Select(provider => new AiProviderPublicDto(
provider.Id, provider.Name, provider.Base, provider.Local, provider.Models))
.ToList();
// Опции JSON значения aiConfig: camelCase (как пишет SaveAsync) + терпимость регистра.
private static readonly JsonSerializerOptions ConfigJsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true,
};
/// <summary>
/// Читает текущую конфигурацию с эффективными значениями
/// </summary>
/// <returns>Снимок; повреждённая строка или неизвестный провайдер — конфигурация не задана.</returns>
public async Task<AiGlobalConfigSnapshot> GetSnapshotAsync(CancellationToken ct)
{
SettingValue? row = await store.GetAsync(GlobalSettingsKeys.AiConfig, ct).ConfigureAwait(false);
if (row is null)
{
return EmptySnapshot();
}
try
{
using JsonDocument document = JsonDocument.Parse(row.ValueJson);
JsonElement root = document.RootElement;
string providerId = ReadString(root, ProviderIdProperty);
if (providerId.Length == 0)
{
return EmptySnapshot();
}
AiProviderDefinition? meta = AiProviders.All.FirstOrDefault(provider => provider.Id == providerId);
if (meta is null)
{
// Неизвестный id (ручное вмешательство в БД) — считаем конфигурацию незаданной.
return EmptySnapshot();
}
string baseUrl = ReadString(root, SettingsFieldKeys.BaseUrl);
if (baseUrl.Length == 0 || !IsBaseUrlMutable(meta))
{
baseUrl = meta.Base;
}
string model = ReadString(root, SettingsFieldKeys.Model);
if (model.Length == 0)
{
model = meta.Models.FirstOrDefault() ?? string.Empty;
}
string apiKey = cipher.Decrypt(ReadString(root, SettingsFieldKeys.ApiKey));
return new AiGlobalConfigSnapshot(providerId, baseUrl, model, apiKey);
}
catch (JsonException)
{
// Повреждённая строка aiConfig — конфигурация не задана (мягкая семантика, как SettingsService).
return EmptySnapshot();
}
}
/// <summary>
/// Маскированная форма конфигурации для операторской ручки
/// </summary>
/// <returns>DTO с флагом keySet и маской ключа.</returns>
public async Task<AiGlobalConfigMaskedDto> GetMaskedAsync(CancellationToken ct)
{
AiGlobalConfigSnapshot snapshot = await GetSnapshotAsync(ct).ConfigureAwait(false);
return new AiGlobalConfigMaskedDto(
ProviderId: snapshot.ProviderId,
BaseUrl: snapshot.BaseUrl,
Model: snapshot.Model,
KeySet: snapshot.ApiKey.Length > 0,
KeyMasked: MaskSecret(snapshot.ApiKey),
Providers: ProviderCatalog);
}
/// <summary>
/// Сохраняет глобальную конфигурацию ИИ (эффективные значения; ключ шифруется)
/// </summary>
/// <param name="providerId">Id провайдера из каталога <see cref="AiProviders"/>.</param>
/// <param name="baseUrl">Базовый URL (учитывается только для local/custom провайдеров).</param>
/// <param name="model">Модель (у каталогных провайдеров пустая заменяется первой из каталога).</param>
/// <param name="apiKey">API-ключ открытым текстом (не нужен локальным провайдерам).</param>
/// <exception cref="ArgumentException">Значения не прошли валидацию (см. <see cref="IsValidApiKey"/>).</exception>
public async Task SaveAsync(
string providerId,
string baseUrl,
string model,
string apiKey,
CancellationToken ct)
{
string trimmedProviderId = (providerId ?? string.Empty).Trim();
string trimmedBaseUrl = (baseUrl ?? string.Empty).Trim();
string trimmedModel = (model ?? string.Empty).Trim();
string trimmedApiKey = (apiKey ?? string.Empty).Trim();
AiProviderDefinition? meta = AiProviders.All.FirstOrDefault(provider => provider.Id == trimmedProviderId);
if (meta is null)
{
throw new ArgumentException("Провайдер не из списка разрешённых.", nameof(providerId));
}
if (!IsBaseUrlMutable(meta))
{
// Адрес каталогных облачных провайдеров фиксирован каталогом (SSRF-гейт).
trimmedBaseUrl = meta.Base;
}
if (trimmedModel.Length == 0)
{
trimmedModel = meta.Models.FirstOrDefault() ?? string.Empty;
}
if (trimmedModel.Length == 0)
{
throw new ArgumentException("Модель обязательна (в каталоге провайдера нет моделей).", nameof(model));
}
if (trimmedApiKey.Length > 0 && !IsValidApiKey(trimmedApiKey))
{
throw new ArgumentException("API-ключ непустой, ≥8 симв., без маски и без префикса enc:.", nameof(apiKey));
}
var value = new AiGlobalConfigValue(
ProviderId: trimmedProviderId,
ApiKey: cipher.Encrypt(trimmedApiKey),
BaseUrl: trimmedBaseUrl,
Model: trimmedModel);
string valueJson = JsonSerializer.Serialize(value, ConfigJsonOptions);
await store.SetAsync(GlobalSettingsKeys.AiConfig, valueJson, ct).ConfigureAwait(false);
}
/// <summary>
/// Валиден ли API-ключ к шифрованию и сохранению
/// </summary>
/// <param name="apiKey">Проверяемое значение (уже без пробелов).</param>
/// <returns>True — значение допустимо.</returns>
public static bool IsValidApiKey(string apiKey) =>
apiKey.Length >= ApiKeyMinLength
&& !apiKey.Contains(MaskEllipsis, StringComparison.Ordinal)
&& !apiKey.StartsWith(EncryptedPrefix, StringComparison.Ordinal);
// Пустой снимок «конфигурация не задана».
private static AiGlobalConfigSnapshot EmptySnapshot() =>
new(string.Empty, string.Empty, string.Empty, string.Empty);
// Адрес мутабелен только для локальных провайдеров и custom (остальным — фиксирован каталогом).
private static bool IsBaseUrlMutable(AiProviderDefinition meta) => meta.Local || meta.Id == CustomProviderId;
// Читает строковое поле JSON-объекта (пусто при отсутствии/не-строке).
// root: Корень JSON значения aiConfig.
// propertyName: Имя поля (camelCase).
// Возвращает: Значение поля (trim) или пустая строка.
private static string ReadString(JsonElement root, string propertyName)
{
if (root.ValueKind != JsonValueKind.Object
|| !root.TryGetProperty(propertyName, out JsonElement element)
|| element.ValueKind != JsonValueKind.String)
{
return string.Empty;
}
return (element.GetString() ?? string.Empty).Trim();
}
// Маска СЕКРЕТА (apiKey): всегда скрывает, кроме пустого. Длина ≤ 8 → «x…», иначе «1234…5678».
// value: Открытый секрет (не null).
// Возвращает: Маскированная строка.
private static string MaskSecret(string value)
{
return value.Length switch
{
0 => string.Empty,
<= MaskShortMaxLength => string.Concat(value.AsSpan(0, MaskShortVisibleChars), MaskEllipsis),
_ => string.Concat(
value.AsSpan(0, MaskEdgeVisibleChars),
MaskEllipsis,
value.AsSpan(value.Length - MaskEdgeVisibleChars)),
};
}
}
@@ -92,7 +92,7 @@ public sealed partial class SettingsService
writes[key] = JsonSerializer.Serialize(number);
}
// Применяет строковый ключ: targetCurrency — Upper; aiProvider — только из каталога провайдеров.
// Применяет строковый ключ: targetCurrency — Upper.
// key: Имя ключа.
// value: JSON-значение из тела PATCH.
// writes: Накопитель записей (key → valueJson).
@@ -111,11 +111,6 @@ public sealed partial class SettingsService
text = text.ToUpperInvariant();
}
if (key == SettingsKeys.AiProvider && !ProviderIds.Contains(text))
{
return; // aiProvider вне списка провайдеров — пропуск (L134–135)
}
writes[key] = JsonSerializer.Serialize(text);
}
@@ -1,102 +0,0 @@
using System.Text.Json;
using Deal.Modules.Settings.Application.Models;
namespace Deal.Modules.Settings.Application.Services;
// Часть SettingsService: PATCH-ключ секрета aiConfigs (ApplyAiConfigsKey, SSRF-гейт baseUrl, шифрование
// apiKey), включая удаление переопределения, вернувшегося к дефолту (EqualsDefault).
public sealed partial class SettingsService
{
private void ApplyAiConfigsKey(
JsonElement value,
Dictionary<string, JsonElement> overrides,
Dictionary<string, string> writes,
List<string> removals)
{
if (value.ValueKind != JsonValueKind.Object)
{
return;
}
// База — эффективные конфиги (дефолты + сохранённые переопределения): PATCH дописывает поля.
var effective = new Dictionary<string, AiConfigSetting>(
MergeAiConfigs(SettingsDefaults.AiConfigs, overrides),
StringComparer.Ordinal);
foreach (JsonProperty provider in value.EnumerateObject())
{
if (!effective.ContainsKey(provider.Name) || provider.Value.ValueKind != JsonValueKind.Object)
{
continue;
}
AiConfigSetting config = effective[provider.Name];
string providerId = provider.Name;
string apiKey = config.ApiKey;
string baseUrl = config.BaseUrl;
string model = config.Model;
JsonElement entry = provider.Value;
// Каталог провайдеров: для baseUrl-гейта нужен признак local и фиксированный адрес каталога.
AiProviderDefinition? meta = AiProviders.All.FirstOrDefault(p => p.Id == providerId);
// baseUrl: переопределение принимается ТОЛЬКО для локальных провайдеров (Ollama/LM Studio — свой
// адрес на машине пользователя) и custom (OpenAI-совместимый — адрес задаёт сам пользователь).
// У каталоговых облачных провайдеров (deepseek/openai/openrouter/anthropic) адрес фиксирован
// каталогом — иначе тенант мог бы перенаправить ключ/запрос на произвольный внутренний адрес
// (SSRF, Security review) и проверка подключения ушла бы на него.
bool baseUrlMutable = meta is { Local: true } || providerId == CustomProviderId;
if (baseUrlMutable && TryReadFieldText(entry, SettingsFieldKeys.BaseUrl, out string newBaseUrl))
{
baseUrl = newBaseUrl;
}
if (TryReadFieldText(entry, SettingsFieldKeys.Model, out string newModel))
{
model = newModel;
}
// apiKey: непустой, ≥8 симв., без префикса enc: и без маски (содержит "…") → шифруется;
// иначе (пустой/маска) ключ не меняется.
if (TryReadFieldText(entry, SettingsFieldKeys.ApiKey, out string newKey)
&& newKey.Length >= ApiKeyMinLength
&& !newKey.StartsWith(EncryptedValuePrefix, StringComparison.Ordinal)
&& !newKey.Contains(MaskEllipsis, StringComparison.Ordinal))
{
apiKey = secretCipher.Encrypt(newKey);
}
effective[provider.Name] = new AiConfigSetting(apiKey, baseUrl, model);
}
if (EqualsDefault(effective))
{
removals.Add(SettingsKeys.AiConfigs);
}
else
{
writes[SettingsKeys.AiConfigs] = JsonSerializer.Serialize(effective, JsonOptions);
}
}
// True — эффективный aiConfigs совпадает с дефолтами (переопределение можно не хранить).
// configs: Эффективные конфиги.
// Возвращает: True — хранить переопределение не нужно.
private static bool EqualsDefault(IReadOnlyDictionary<string, AiConfigSetting> configs)
{
if (configs.Count != SettingsDefaults.AiConfigs.Count)
{
return false;
}
foreach ((string providerId, AiConfigSetting config) in configs)
{
if (!SettingsDefaults.AiConfigs.ContainsKey(providerId) || config != SettingsDefaults.AiConfigs[providerId])
{
return false;
}
}
return true;
}
}
@@ -1,26 +0,0 @@
using Deal.Modules.Settings.Application.Models;
namespace Deal.Modules.Settings.Application.Services;
public sealed partial class SettingsService
{
private AiConfigPublicDto ToPublic(AiConfigSetting config)
{
string plainKey = secretCipher.Decrypt(config.ApiKey);
return new AiConfigPublicDto(
BaseUrl: config.BaseUrl,
Model: config.Model,
KeySet: !string.IsNullOrEmpty(plainKey),
KeyMasked: MaskSecret(plainKey));
}
private static string MaskSecret(string value)
{
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)),
};
}
}
@@ -201,34 +201,6 @@ public sealed partial class SettingsService
return new Dictionary<string, object?>(StringComparer.Ordinal);
}
// Объединяет дефолты aiConfigs и сохранённое переопределение (переопределение — на провайдера).
// defaults: Дефолтные конфиги на всех провайдеров каталога.
// overrides: Сохранённые переопределения.
// Возвращает: Эффективные конфиги: провайдеры дефолтов + перекрытия из хранилища (неизвестные пропускаются).
private static IReadOnlyDictionary<string, AiConfigSetting> MergeAiConfigs(IReadOnlyDictionary<string, AiConfigSetting> defaults, Dictionary<string, JsonElement> overrides)
{
var merged = defaults.ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.Ordinal);
if (overrides.TryGetValue(SettingsKeys.AiConfigs, out JsonElement element) && element.ValueKind == JsonValueKind.Object)
{
foreach (JsonProperty provider in element.EnumerateObject())
{
// «Только существующие провайдеры»: неизвестные id из хранилища не подмешиваются.
if (!merged.ContainsKey(provider.Name) || provider.Value.ValueKind != JsonValueKind.Object)
{
continue;
}
string apiKey = TryReadFieldText(provider.Value, SettingsFieldKeys.ApiKey, out string storedKey) ? storedKey : string.Empty;
string baseUrl = TryReadFieldText(provider.Value, SettingsFieldKeys.BaseUrl, out string storedBase) ? storedBase : string.Empty;
string model = TryReadFieldText(provider.Value, SettingsFieldKeys.Model, out string storedModel) ? storedModel : string.Empty;
merged[provider.Name] = new AiConfigSetting(apiKey, baseUrl, model);
}
}
return merged;
}
// Читает сохранённые «Мои промпты»; повреждённое значение → пустой список (дефолт).
// overrides: Сохранённые переопределения.
// Возвращает: Список промптов из хранилища или дефолт (пустой).
@@ -8,30 +8,14 @@ namespace Deal.Modules.Settings.Application.Services;
/// Сервис настроек тенанта
/// </summary>
/// <param name="store">KV-хранилище настроек тенанта (таблица settings).</param>
/// <param name="secretCipher">Шифр секретов.</param>
/// <param name="listeners">Слушатели смены настроек конверсии; пусто — no-op.</param>
public sealed partial class SettingsService(
ISettingsStore store,
ISecretCipher secretCipher,
IEnumerable<IRatesChangedListener> listeners)
{
private const string EncryptedValuePrefix = "enc:";
// Символ-заполнитель маски секрета (U+2026, «1234…5678»): строка, содержащая его,
// — это маска из public-снимка, а не новый секрет (Security review: не шифровать маску).
private const string MaskEllipsis = "…";
// Id провайдера «Другой (OpenAI-совместимый)» — единственный не-local, чей baseUrl
// задаёт пользователь (остальным адрес фиксирован каталогом, SSRF-гейт).
private const string CustomProviderId = "custom";
// Максимум элементов списка-настройки при записи из PATCH (срез 200).
private const int MaxListItems = 200;
// Минимальная длина нового API-ключа, который принимает PATCH (≥8 симв.).
private const int ApiKeyMinLength = 8;
// Максимум элементов «Моих промптов» при записи из PATCH (≤100).
private const int MaxMyPrompts = 100;
@@ -65,16 +49,6 @@ public sealed partial class SettingsService(
[SettingsKeys.DiscEvalThreshold] = (1, 100),
};
// Идентификаторы известных провайдеров (валидация aiProvider/aiConfigs).
private static readonly IReadOnlySet<string> ProviderIds =
AiProviders.All.Select(provider => provider.Id).ToHashSet(StringComparer.Ordinal);
private static readonly IReadOnlyList<ProviderPublicDto> PublicProviders =
AiProviders.All
.Select(provider => new ProviderPublicDto(
provider.Id, provider.Name, provider.Base, provider.Local, provider.Models))
.ToList();
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
@@ -105,7 +79,6 @@ public sealed partial class SettingsService(
Dictionary<string, int> preparedDelays = PrepareDelayClamps(body, overrides);
var writes = new Dictionary<string, string>(StringComparer.Ordinal);
var removals = new List<string>();
foreach ((string key, JsonElement value) in body)
{
@@ -162,10 +135,6 @@ public sealed partial class SettingsService(
}
break;
case SettingKind.AiConfigs:
ApplyAiConfigsKey(value, overrides, writes, removals);
break;
}
}
@@ -174,11 +143,6 @@ public sealed partial class SettingsService(
await store.SetAsync(key, valueJson, ct);
}
foreach (string key in removals)
{
await store.RemoveAsync(key, ct);
}
if (AffectsConversion(body))
{
await NotifyRatesChangedAsync(ct);
@@ -214,8 +178,6 @@ public sealed partial class SettingsService(
// Возвращает: DTO public-снимка.
private PublicSettingsDto BuildSnapshot(Dictionary<string, JsonElement> overrides)
{
IReadOnlyDictionary<string, AiConfigSetting> aiConfigs = MergeAiConfigs(SettingsDefaults.AiConfigs, overrides);
var result = new PublicSettingsDto
{
// Хранилище / обработка
@@ -269,11 +231,6 @@ public sealed partial class SettingsService(
DiscEvalThreshold = MergeInt(overrides, SettingsKeys.DiscEvalThreshold, SettingsDefaults.DiscEvalThreshold),
DiscPaused = MergeBool(overrides, SettingsKeys.DiscPaused, SettingsDefaults.DiscPaused),
ColState = MergeDict(overrides, SettingsKeys.ColState),
// ИИ-провайдеры (публичная форма)
AiProvider = MergeString(overrides, SettingsKeys.AiProvider, SettingsDefaults.AiProvider),
AiConfigs = aiConfigs.ToDictionary(pair => pair.Key, pair => ToPublic(pair.Value), StringComparer.Ordinal),
Providers = PublicProviders,
};
return result;
@@ -22,6 +22,14 @@ public static class AuditRecordDtoExtensions
private const string OldPrefix = "old";
private const string NewPrefix = "new";
// Соответствие суффикса «old<X>» имени ключа нового значения в устаревшем формате (имена не совпадают).
private static readonly IReadOnlyDictionary<string, string> LegacyNewNameByOldSuffix =
new Dictionary<string, string>(StringComparer.Ordinal)
{
["Budget"] = "budgetTokens",
["Period"] = "period",
};
// События аудита «неудачный вход» (тенант/оператор).
private static readonly string[] FailedLoginEvents =
{
@@ -140,10 +148,23 @@ public static class AuditRecordDtoExtensions
continue;
}
string? pairedNewName = property.Name.StartsWith(OldPrefix, StringComparison.Ordinal)
&& property.Name.Length > OldPrefix.Length
? NewPrefix + property.Name[OldPrefix.Length..]
: null;
string? pairedNewName = null;
if (property.Name.StartsWith(OldPrefix, StringComparison.Ordinal)
&& property.Name.Length > OldPrefix.Length)
{
string suffix = property.Name[OldPrefix.Length..];
string defaultNewName = NewPrefix + suffix;
if (LegacyNewNameByOldSuffix.TryGetValue(suffix, out string? mapped)
&& root.TryGetProperty(mapped, out JsonElement mappedValue))
{
consumed.Add(mapped);
result.Add(new AuditChangeDto(LowerFirst(suffix), Stringify(property.Value), Stringify(mappedValue)));
continue;
}
pairedNewName = root.TryGetProperty(defaultNewName, out _) ? defaultNewName : null;
}
if (pairedNewName is not null && root.TryGetProperty(pairedNewName, out JsonElement newValue))
{
consumed.Add(pairedNewName);
@@ -162,11 +183,12 @@ public static class AuditRecordDtoExtensions
// Приводит значение JSON к строке отображения.
// element: Значение JSON.
// Возвращает: Строковое представление либо null для JSON null.
// Возвращает: Строковое представление либо null для JSON null; массивы — списком через запятую.
private static string? Stringify(JsonElement element) => element.ValueKind switch
{
JsonValueKind.Null or JsonValueKind.Undefined => null,
JsonValueKind.String => element.GetString(),
JsonValueKind.Array => string.Join(", ", element.EnumerateArray().Select(Stringify)),
_ => element.GetRawText(),
};
@@ -154,4 +154,9 @@ public static class AuditEvents
/// Оператор изменил глобальные ключи Telegram api_id/api_hash.
/// </summary>
public const string TelegramKeysChanged = "telegram_keys_changed";
/// <summary>
/// Оператор изменил глобальную конфигурацию ИИ-провайдера.
/// </summary>
public const string AiConfigChanged = "ai_config_changed";
}
@@ -119,4 +119,24 @@ public static class AuditFields
/// Диалог Telegram
/// </summary>
public const string DialogId = "dialogId";
/// <summary>
/// Id ИИ-провайдера
/// </summary>
public const string ProviderId = "providerId";
/// <summary>
/// Базовый URL ИИ-провайдера
/// </summary>
public const string BaseUrl = "baseUrl";
/// <summary>
/// Модель ИИ-провайдера
/// </summary>
public const string Model = "model";
/// <summary>
/// Признак заданного API-ключа ИИ-провайдера
/// </summary>
public const string KeySet = "keySet";
}
@@ -0,0 +1,141 @@
using Deal.Modules.Settings.Application.Abstractions;
using Deal.Modules.Settings.Application.Models;
using Deal.Modules.Settings.Application.Services;
using Deal.Tests.Unit.Modules.Settings;
using Deal.Tests.Unit.Support;
namespace Deal.Tests.Unit.Api;
/// <summary>
/// Тесты сервиса глобальной конфигурации ИИ оператора
/// </summary>
public sealed class AiGlobalConfigServiceTests
{
private const string ApiKey = "sk-operator-key-123";
private const string DeepSeekBase = "https://api.deepseek.com";
private readonly TestGlobalSettingsStore _store = new();
private readonly ISecretCipher _cipher = TestCiphers.New();
private readonly AiGlobalConfigService _service;
public AiGlobalConfigServiceTests()
{
_service = new AiGlobalConfigService(_store.Store, _cipher);
}
[Fact]
public async Task GetMaskedAsync_NoConfig_ReturnsEmptySnapshotWithProviderCatalog()
{
AiGlobalConfigMaskedDto masked = await _service.GetMaskedAsync(CancellationToken.None);
Assert.Equal(string.Empty, masked.ProviderId);
Assert.Equal(string.Empty, masked.KeyMasked);
Assert.False(masked.KeySet);
Assert.Equal(AiProviders.All.Count, masked.Providers.Count);
}
[Fact]
public async Task SaveAsync_EncryptsApiKeyAndMasquesOnRead()
{
await _service.SaveAsync("deepseek", string.Empty, "deepseek-v4-pro", ApiKey, CancellationToken.None);
string storedJson = _store.GetStoredJson(GlobalSettingsKeys.AiConfig)!;
Assert.Contains("enc:", storedJson);
Assert.DoesNotContain(ApiKey, storedJson);
AiGlobalConfigMaskedDto masked = await _service.GetMaskedAsync(CancellationToken.None);
Assert.Equal("deepseek", masked.ProviderId);
Assert.Equal("deepseek-v4-pro", masked.Model);
Assert.True(masked.KeySet);
Assert.Equal("sk-o…-123", masked.KeyMasked);
}
[Fact]
public async Task SaveAsync_CloudProviderBaseUrl_IsFixedByCatalog()
{
await _service.SaveAsync("deepseek", "https://evil.example/v1", string.Empty, ApiKey, CancellationToken.None);
AiGlobalConfigSnapshot snapshot = await _service.GetSnapshotAsync(CancellationToken.None);
Assert.Equal(DeepSeekBase, snapshot.BaseUrl);
Assert.Equal("deepseek-v4-flash", snapshot.Model); // первая модель каталога
Assert.True(snapshot.Configured);
}
[Fact]
public async Task SaveAsync_CustomProvider_KeepsOperatorBaseUrlAndModel()
{
await _service.SaveAsync("custom", "https://llm.local/v1", "my-model", ApiKey, CancellationToken.None);
AiGlobalConfigSnapshot snapshot = await _service.GetSnapshotAsync(CancellationToken.None);
Assert.Equal("https://llm.local/v1", snapshot.BaseUrl);
Assert.Equal("my-model", snapshot.Model);
Assert.True(snapshot.Configured);
}
[Fact]
public async Task SaveAsync_LocalProvider_ConfiguredWithoutApiKey()
{
await _service.SaveAsync("ollama", "http://127.0.0.1:11435/v1", string.Empty, string.Empty, CancellationToken.None);
AiGlobalConfigSnapshot snapshot = await _service.GetSnapshotAsync(CancellationToken.None);
Assert.Equal("http://127.0.0.1:11435/v1", snapshot.BaseUrl);
Assert.True(snapshot.Configured);
}
[Fact]
public async Task SaveAsync_UnknownProvider_Throws()
{
await Assert.ThrowsAsync<ArgumentException>(
() => _service.SaveAsync("несуществующий", string.Empty, string.Empty, ApiKey, CancellationToken.None));
}
[Fact]
public async Task SaveAsync_CustomWithoutModel_Throws()
{
await Assert.ThrowsAsync<ArgumentException>(
() => _service.SaveAsync("custom", "https://llm.local/v1", string.Empty, ApiKey, CancellationToken.None));
}
[Fact]
public async Task SaveAsync_ShortApiKey_Throws()
{
await Assert.ThrowsAsync<ArgumentException>(
() => _service.SaveAsync("deepseek", string.Empty, string.Empty, "short", CancellationToken.None));
}
[Fact]
public async Task GetSnapshotAsync_MalformedStoredJson_IsNotConfigured()
{
_store.Preload(GlobalSettingsKeys.AiConfig, "not-json");
AiGlobalConfigSnapshot snapshot = await _service.GetSnapshotAsync(CancellationToken.None);
Assert.False(snapshot.Configured);
}
[Fact]
public async Task GetSnapshotAsync_BrokenCiphertext_LeavesKeyEmptyWithoutThrowing()
{
_store.Preload(
GlobalSettingsKeys.AiConfig,
"{\"providerId\":\"deepseek\",\"baseUrl\":\"\",\"model\":\"\",\"apiKey\":\"enc:не-base64!\"}");
AiGlobalConfigSnapshot snapshot = await _service.GetSnapshotAsync(CancellationToken.None);
Assert.Equal(string.Empty, snapshot.ApiKey);
Assert.False(snapshot.Configured);
}
[Theory]
[InlineData("sk-operator-key-123", true)]
[InlineData("sk-1…-123", false)] // маска из снимка
[InlineData("enc:sk-operator-key", false)]
[InlineData("short", false)]
public void IsValidApiKey_RejectsMaskEncryptedAndShort(string apiKey, bool expected)
{
Assert.Equal(expected, AiGlobalConfigService.IsValidApiKey(apiKey));
}
}
@@ -0,0 +1,86 @@
using Deal.Api.Services;
using Deal.Infrastructure.Data;
using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Tenants.Application.Models;
using Deal.SharedKernel.Tenants.Abstractions;
using Deal.Tests.Unit.Support;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
namespace Deal.Tests.Unit.Api;
/// <summary>
/// Юнит-тесты резолвера ссылок аудита
/// </summary>
public sealed class AuditReferenceResolverTests
{
private static readonly Guid TenantGuid = Guid.Parse("00000000-0000-0000-0000-000000000001");
[Fact]
public async Task Resolve_ResolvesCardTitleAndContainerName()
{
TestKanjStore kanban = new();
kanban.SeedCard(new CardDto { Id = "c_1", Title = "WPF заказ", Col = "b_1" });
kanban.SeedBoard(new ContainerDto { Id = "b_1", Name = "WPF" });
using ServiceProvider provider = BuildProvider(kanban);
var resolver = new AuditReferenceResolver(
provider.GetRequiredService<IServiceScopeFactory>(),
NullLogger<AuditReferenceResolver>.Instance);
var record = Record("""{"changes":[{"field":"cardId","to":"c_1"},{"field":"to","to":"b_1"}]}""");
IReadOnlyDictionary<string, string> names =
await ((IAuditReferenceResolver)resolver).ResolveAsync([record], CancellationToken.None);
Assert.Equal("WPF заказ", names["c_1"]);
Assert.Equal("WPF", names["b_1"]);
}
[Fact]
public async Task Resolve_UnknownIds_ReturnsOnlyKnownNames()
{
TestKanjStore kanban = new();
using ServiceProvider provider = BuildProvider(kanban);
var resolver = new AuditReferenceResolver(
provider.GetRequiredService<IServiceScopeFactory>(),
NullLogger<AuditReferenceResolver>.Instance);
var record = Record("""{"changes":[{"field":"cardId","to":"c_missing"}]}""");
IReadOnlyDictionary<string, string> names =
await ((IAuditReferenceResolver)resolver).ResolveAsync([record], CancellationToken.None);
Assert.Empty(names);
}
[Fact]
public async Task Resolve_WithoutTenant_ReturnsEmpty()
{
TestKanjStore kanban = new();
using ServiceProvider provider = BuildProvider(kanban);
var resolver = new AuditReferenceResolver(
provider.GetRequiredService<IServiceScopeFactory>(),
NullLogger<AuditReferenceResolver>.Instance);
var record = Record("""{"changes":[{"field":"cardId","to":"c_1"}]}""", tenantId: null);
IReadOnlyDictionary<string, string> names =
await ((IAuditReferenceResolver)resolver).ResolveAsync([record], CancellationToken.None);
Assert.Empty(names);
}
private static ServiceProvider BuildProvider(TestKanjStore kanban)
{
var services = new ServiceCollection();
services.AddSingleton<ITenantContext, TenantContext>();
services.AddSingleton(kanban.Store);
return services.BuildServiceProvider();
}
private static AuditRecordDto Record(string detailJson, Guid? tenantId = null) =>
new(
AuditEvents.CardMoved,
AuditActorTypes.Tenant,
ActorId: null,
TenantId: tenantId ?? TenantGuid,
Ip: null,
DetailJson: detailJson);
}
@@ -0,0 +1,59 @@
using Deal.Api.Services;
using Deal.Modules.Tenants.Application.Models;
using Deal.Tests.Unit.Support;
namespace Deal.Tests.Unit.Api;
/// <summary>
/// Юнит-тесты проекции записей аудита
/// </summary>
public sealed class AuditViewFactoryTests
{
[Fact]
public async Task Project_DropsUnchangedValues()
{
var record = new AuditRecordDto(
AuditEvents.TenantLimitChanged,
AuditActorTypes.Operator,
ActorId: null,
TenantId: null,
Ip: null,
DetailJson: """{"tenantId":"00000000-0000-0000-0000-000000000001","oldBudget":10000000,"oldPeriod":"month","budgetTokens":100000,"period":"month"}""");
IReadOnlyList<AuditRecordViewDto> view = await AuditViewFactory.ProjectAsync(
[record],
new TestTenantRepository().Repository,
new TestAuthStore().Store,
new TestAuditReferenceResolver(),
CancellationToken.None);
AuditRecordViewDto row = Assert.Single(view);
AuditChangeDto budget = Assert.Single(row.Changes);
Assert.Equal("budget", budget.Field);
Assert.Equal("10000000", budget.From);
Assert.Equal("100000", budget.To);
}
[Fact]
public async Task Project_KeepsChangedValues()
{
var record = new AuditRecordDto(
AuditEvents.SettingsUpdated,
AuditActorTypes.Tenant,
ActorId: null,
TenantId: null,
Ip: null,
DetailJson: """{"changes":[{"field":"minLen","from":"24","to":"42"}]}""");
IReadOnlyList<AuditRecordViewDto> view = await AuditViewFactory.ProjectAsync(
[record],
new TestTenantRepository().Repository,
new TestAuthStore().Store,
new TestAuditReferenceResolver(),
CancellationToken.None);
AuditChangeDto change = Assert.Single(Assert.Single(view).Changes);
Assert.Equal("24", change.From);
Assert.Equal("42", change.To);
}
}
@@ -139,7 +139,7 @@ public sealed class GrpcAiToolsTests
return new GrpcAiTools(
tenantContext,
connection,
new AiProviderConfigBuilder(settings.Store, cipher),
new AiProviderConfigBuilder(TestAiConfig.New(new TestGlobalSettingsStore(), cipher)),
new TokenUsageRecorder(settings.Store, limits.Store, tenantContext, new TokenUsageEventService(new TestTokenUsageEventStore().Store)),
NullLogger<GrpcAiTools>.Instance);
}
@@ -9,6 +9,7 @@ using Deal.Modules.Kanban.Application.Abstractions;
using Deal.Modules.Pipeline.Application.Parse;
using Deal.Modules.Pipeline.Application.Services;
using Deal.Modules.Settings.Application.Abstractions;
using Deal.Modules.Settings.Application.Services;
using Deal.Modules.Tenants.Application.Abstractions;
using Deal.Modules.Tenants.Application.Services;
using Deal.SharedKernel.Tenants.Abstractions;
@@ -144,7 +145,9 @@ public sealed class IntegrationsDiTests
tenantContext.SetTenant(new TenantId(Guid.NewGuid().ToString("N")));
services.AddSingleton<ITenantContext>(tenantContext);
services.AddScoped<ISettingsStore>(_ => new TestSettingsStore().Store);
services.AddScoped<IGlobalSettingsStore>(_ => new TestGlobalSettingsStore().Store);
services.AddScoped<ISecretCipher>(_ => TestCiphers.New());
services.AddScoped<AiGlobalConfigService>();
services.AddScoped<ICardStore>(_ => new TestKanjStore().Store);
services.AddScoped<IMlLearningStore>(_ => new TestMlLearningStore().Store);
services.AddScoped<ITenantLimitStore>(_ => new TestTenantLimitStore().Store);
@@ -178,7 +178,7 @@ public sealed class PipelineWorkerGrpcAiTests
var grpcClassifier = new GrpcAiClassifier(
tenantContext,
connection,
new AiProviderConfigBuilder(settings.Store, TestCiphers.New()),
new AiProviderConfigBuilder(TestAiConfig.New(new TestGlobalSettingsStore(), TestCiphers.New())),
new AiClassifyContextBuilder(settings.Store, kanjStore.Store),
new TokenUsageRecorder(settings.Store, limits.Store, tenantContext, new TokenUsageEventService(new TestTokenUsageEventStore().Store)),
NullLogger<GrpcAiClassifier>.Instance);
@@ -38,7 +38,6 @@ public sealed class SettingsCatalogTests
// String
["targetCurrency"] = SettingKind.String,
["rateSource"] = SettingKind.String,
["aiProvider"] = SettingKind.String,
["aiPrompt"] = SettingKind.String,
["aiFilterPrompt"] = SettingKind.String,
["cardPrompt"] = SettingKind.String,
@@ -59,10 +58,9 @@ public sealed class SettingsCatalogTests
["colState"] = SettingKind.Dict,
// special
["myPrompts"] = SettingKind.MyPrompts,
["aiConfigs"] = SettingKind.AiConfigs,
};
private const int ExpectedPublicKeysCount = 43;
private const int ExpectedPublicKeysCount = 41;
[Fact]
public void PublicKeys_CoverAllApiMapKeysWithCorrectCategories()
@@ -169,26 +167,11 @@ public sealed class SettingsCatalogTests
Assert.True(SettingsDefaults.BlockResumes);
Assert.Equal("RUB", SettingsDefaults.TargetCurrency);
Assert.Equal("cbr", SettingsDefaults.RateSource);
Assert.Equal("deepseek", SettingsDefaults.AiProvider);
Assert.False(SettingsDefaults.DiscPaused);
Assert.Empty(SettingsDefaults.ColState);
Assert.Empty(SettingsDefaults.MyPrompts);
}
[Fact]
public void Defaults_AiConfigsSeededForEveryProviderWithEmptyKey()
{
Assert.Equal(AiProviders.All.Count, SettingsDefaults.AiConfigs.Count);
foreach (AiProviderDefinition provider in AiProviders.All)
{
AiConfigSetting config = SettingsDefaults.AiConfigs[provider.Id];
Assert.Equal("", config.ApiKey);
Assert.Equal(provider.Base, config.BaseUrl);
Assert.Equal(provider.Models.FirstOrDefault() ?? string.Empty, config.Model);
}
}
[Fact]
public void DefaultPrompts_ContainKeyMarkersFromDataJs()
{
@@ -57,6 +57,52 @@ public sealed class AuditRecordDtoExtensionsTests
});
}
[Fact]
public void AuditChanges_LegacyLimitChange_PairsBudgetAndPeriod()
{
var record = Record(
"""{"tenantId":"6f3c0d1e-2b4a-4c8d-9e0f-1a2b3c4d5e6f","oldBudget":100000,"oldPeriod":"month","budgetTokens":1000000,"period":"month"}""");
IReadOnlyList<AuditChangeDto> changes = record.AuditChanges();
Assert.Collection(
changes,
budget =>
{
Assert.Equal("budget", budget.Field);
Assert.Equal("100000", budget.From);
Assert.Equal("1000000", budget.To);
},
period =>
{
Assert.Equal("period", period.Field);
Assert.Equal("month", period.From);
Assert.Equal("month", period.To);
});
}
[Fact]
public void AuditChanges_LegacyFieldsArray_RendersAsList()
{
var record = Record("""{"fields":["autoMonitorNew"]}""");
AuditChangeDto change = Assert.Single(record.AuditChanges());
Assert.Equal(AuditFields.Fields, change.Field);
Assert.Null(change.From);
Assert.Equal("autoMonitorNew", change.To);
}
[Fact]
public void AuditChanges_LegacyFieldsArray_JoinsMultipleKeys()
{
var record = Record("""{"fields":["autoMonitorNew","minLen"]}""");
AuditChangeDto change = Assert.Single(record.AuditChanges());
Assert.Equal("autoMonitorNew, minLen", change.To);
}
[Fact]
public void AuditChanges_InvalidJson_ReturnsEmpty()
{
@@ -12,6 +12,7 @@ using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Pipeline.Application.Services;
using Deal.Modules.Settings.Application.Abstractions;
using Deal.Modules.Settings.Application.Models;
using Deal.Modules.Settings.Application.Services;
using Deal.Modules.Tenants.Application.Services;
using Deal.SharedKernel.Tenants.Models;
using Deal.Tests.Unit.Grpc;
@@ -71,7 +72,8 @@ public sealed class GrpcAiClassifierTests
Assert.Equal("deepseek", service.LastFilter.ProviderConfig.ProviderId);
Assert.Equal("https://api.deepseek.com", service.LastFilter.ProviderConfig.BaseUrl);
Assert.Equal("deepseek-v4-flash", service.LastFilter.ProviderConfig.Model);
Assert.False(service.LastFilter.ProviderConfig.HasApiKey);
Assert.True(service.LastFilter.ProviderConfig.HasApiKey);
Assert.Equal(TestAiConfig.DefaultApiKey, service.LastFilter.ProviderConfig.ApiKey);
Assert.False(service.LastFilter.ProviderConfig.HasApiStyle);
Assert.Equal(TenantIdValue, Assert.Single(service.RequestTenantIds));
@@ -220,34 +222,24 @@ public sealed class GrpcAiClassifierTests
}
[Fact]
public async Task ClassifyAsync_ProviderConfig_ReadsDecryptedKeyAndOverridesFromSettings()
public async Task ClassifyAsync_ProviderConfig_UsesGlobalOperatorConfig()
{
await AiGrpcTestHost.RunAsync(AiGrpcTestHost.DefaultToken, new RecordingAiService(), async (port, service) =>
{
service.ClassifyReply = new ClassifyReply { Ok = true, Json = """{"title":"Заголовок","stack":[],"is_spam":false}""" };
(TestSettingsStore settings, ISecretCipher cipher, TestKanjStore kanjStore) = Context(port);
settings.Preload(SettingsKeys.AiProvider, Json("anthropic"));
settings.Preload(
SettingsKeys.AiConfigs,
new JsonObject
{
["anthropic"] = new JsonObject
{
["apiKey"] = cipher.Encrypt("sk-ant-test-secret"),
["baseUrl"] = "https://llm.local",
["model"] = "claude-custom",
},
}.ToJsonString());
AiGlobalConfigService aiConfig = TestAiConfig.New(
new TestGlobalSettingsStore(), cipher, providerId: "anthropic", apiKey: "sk-ant-test-secret");
IAiClassifier classifier = CreateClassifier(port, settings, cipher, kanjStore);
IAiClassifier classifier = CreateClassifier(port, settings, cipher, kanjStore, aiConfig: aiConfig);
await classifier.ClassifyAsync("текст", CancellationToken.None);
// ProviderConfig из настроек: id/base/model — переопределение, apiKey расшифрован, api_style — из каталога.
// ProviderConfig глобальной конфигурации: id/apiKey, адрес каталога фиксирован, api_style — из каталога.
Assert.NotNull(service.LastClassify);
ProviderConfig config = service.LastClassify!.ProviderConfig;
Assert.Equal("anthropic", config.ProviderId);
Assert.Equal("https://llm.local", config.BaseUrl);
Assert.Equal("claude-custom", config.Model);
Assert.Equal("https://api.anthropic.com", config.BaseUrl);
Assert.Equal("claude-sonnet-5", config.Model);
Assert.True(config.HasApiKey);
Assert.Equal("sk-ant-test-secret", config.ApiKey);
Assert.True(config.HasApiStyle);
@@ -255,6 +247,33 @@ public sealed class GrpcAiClassifierTests
});
}
[Fact]
public async Task ClassifyAsync_ProviderConfig_HonorsCustomBaseUrlAndModel()
{
await AiGrpcTestHost.RunAsync(AiGrpcTestHost.DefaultToken, new RecordingAiService(), async (port, service) =>
{
service.ClassifyReply = new ClassifyReply { Ok = true, Json = """{"title":"Заголовок","stack":[],"is_spam":false}""" };
(TestSettingsStore settings, ISecretCipher cipher, TestKanjStore kanjStore) = Context(port);
AiGlobalConfigService aiConfig = TestAiConfig.New(
new TestGlobalSettingsStore(),
cipher,
providerId: "custom",
baseUrl: "https://llm.local/v1",
model: "my-model",
apiKey: "sk-custom-key-123");
IAiClassifier classifier = CreateClassifier(port, settings, cipher, kanjStore, aiConfig: aiConfig);
await classifier.ClassifyAsync("текст", CancellationToken.None);
// OpenAI-совместимый провайдер: адрес и модель задаёт оператор.
ProviderConfig config = service.LastClassify!.ProviderConfig;
Assert.Equal("custom", config.ProviderId);
Assert.Equal("https://llm.local/v1", config.BaseUrl);
Assert.Equal("my-model", config.Model);
Assert.Equal("sk-custom-key-123", config.ApiKey);
});
}
[Fact]
public async Task ClassifyAsync_LongMessage_TruncatesUserContextTo5000CodePoints()
{
@@ -277,25 +296,28 @@ public sealed class GrpcAiClassifierTests
// Создаёт GrpcAiClassifier к хосту-фейку на эфемерном порту в tenant-контексте теста.
// port: Порт хоста-фейка ai-service.
// settings: KV-хранилище тенанта (дефолты при пустом).
// cipher: Шифр секретов (расшифровка apiKey).
// cipher: Шифр секретов.
// kanjStore: Канбан-фейк (доски/примеры контекста классификации).
// limits: Фейк-хранилище лимитов (списание usage; при null — собственный экземпляр).
// aiConfig: Глобальная конфигурация ИИ (при null — провайдер по умолчанию с ключом).
// Возвращает: Экземпляр GrpcAiClassifier.
private static GrpcAiClassifier CreateClassifier(
int port,
TestSettingsStore settings,
ISecretCipher cipher,
TestKanjStore kanjStore,
TestTenantLimitStore? limits = null)
TestTenantLimitStore? limits = null,
AiGlobalConfigService? aiConfig = null)
{
limits ??= new TestTenantLimitStore();
aiConfig ??= TestAiConfig.New(new TestGlobalSettingsStore(), cipher);
ITenantContext tenantContext = new TenantContext();
tenantContext.SetTenant(new TenantId(TenantIdValue));
var connection = new AiGrpcConnection(new AiServiceOptions { UseLocal = false, Endpoint = $"http://127.0.0.1:{port}" });
return new GrpcAiClassifier(
tenantContext,
connection,
new AiProviderConfigBuilder(settings.Store, cipher),
new AiProviderConfigBuilder(aiConfig),
new AiClassifyContextBuilder(settings.Store, kanjStore.Store),
new TokenUsageRecorder(settings.Store, limits.Store, tenantContext, new TokenUsageEventService(new TestTokenUsageEventStore().Store)),
NullLogger<GrpcAiClassifier>.Instance);
@@ -0,0 +1,263 @@
using System.Net;
using System.Text;
using System.Text.Json;
using Deal.Modules.Settings.Application.Models;
using Deal.Modules.Tenants.Application.Models;
using Deal.Tests.Unit.Modules.Settings;
using Deal.Tests.Unit.Modules.Tenants;
using Deal.Tests.Unit.Support;
namespace Deal.Tests.Unit.Support;
/// <summary>
/// HTTP-тесты операторских ручек глобальной конфигурации ИИ
/// </summary>
public sealed class OperatorAiConfigEndpointsHttpTests
{
private const string OperatorLogin = "operator";
private const string OperatorPassword = "operator";
private const string InvalidProviderDetail = "Провайдер не из списка разрешённых";
private const string MissingAiModelDetail = "Укажите model — у выбранного провайдера нет моделей по умолчанию";
private const string AiConfigNotSetDetail = "Сначала сохраните конфигурацию ИИ";
private const string ApiKey = "sk-operator-key-123";
[Fact]
public async Task AiConfig_WithoutOperatorSession_Returns401()
{
await RunAsync(
async (baseAddress, _, _, _, _) =>
{
using HttpClient anonymous = CreateClient(baseAddress);
using HttpResponseMessage get = await anonymous.GetAsync(AiConfigUrl(baseAddress));
Assert.Equal(HttpStatusCode.Unauthorized, get.StatusCode);
using HttpResponseMessage put = await PutJsonAsync(
anonymous, AiConfigUrl(baseAddress), new { providerId = "deepseek", apiKey = ApiKey });
Assert.Equal(HttpStatusCode.Unauthorized, put.StatusCode);
});
}
[Fact]
public async Task GetAiConfig_NoConfig_ReturnsEmptySnapshotWithProviderCatalog()
{
await RunAsync(
async (baseAddress, _, _, _, _) =>
{
HttpClient operatorClient = CreateClient(baseAddress);
await LoginOperatorAsync(operatorClient, baseAddress);
using HttpResponseMessage response = await operatorClient.GetAsync(AiConfigUrl(baseAddress));
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
JsonElement body = await ReadJsonAsync(response);
Assert.Equal(string.Empty, body.GetProperty("providerId").GetString());
Assert.Equal(string.Empty, body.GetProperty("keyMasked").GetString());
Assert.False(body.GetProperty("keySet").GetBoolean());
Assert.Equal(AiProviders.All.Count, body.GetProperty("providers").GetArrayLength());
});
}
[Fact]
public async Task PutAiConfig_EncryptsKeyMasksResponseAndWritesAudit()
{
var auditStore = new TestAuditLogStore();
await RunAsync(
async (baseAddress, operatorStore, _, globalSettings, audit) =>
{
HttpClient operatorClient = CreateClient(baseAddress);
await LoginOperatorAsync(operatorClient, baseAddress);
using HttpResponseMessage put = await PutJsonAsync(
operatorClient, AiConfigUrl(baseAddress), new { providerId = "deepseek", model = "deepseek-v4-pro", apiKey = ApiKey });
Assert.Equal(HttpStatusCode.OK, put.StatusCode);
JsonElement body = await ReadJsonAsync(put);
Assert.Equal("deepseek", body.GetProperty("providerId").GetString());
Assert.Equal("deepseek-v4-pro", body.GetProperty("model").GetString());
Assert.Equal("sk-o…-123", body.GetProperty("keyMasked").GetString());
Assert.True(body.GetProperty("keySet").GetBoolean());
// В хранилище ключ только в enc:-форме, открытого секрета нет.
string storedJson = globalSettings.GetStoredJson(GlobalSettingsKeys.AiConfig)!;
Assert.Contains("enc:", storedJson);
Assert.DoesNotContain(ApiKey, storedJson);
// Аудит: актор-оператор, детали без секрета.
AuditRecordDto record = Assert.Single(audit.Records, r => r.EventType == AuditEvents.AiConfigChanged);
Assert.Equal(AuditActorTypes.Operator, record.ActorType);
Assert.Equal(operatorStore.Operators.Single().Id, record.ActorId);
Assert.Null(record.TenantId);
Assert.DoesNotContain(ApiKey, record.DetailJson);
Assert.Equal("deepseek", record.DetailValue(AuditFields.ProviderId));
Assert.Equal("deepseek-v4-pro", record.DetailValue(AuditFields.Model));
Assert.Equal("true", record.DetailValue(AuditFields.KeySet));
},
auditStore);
}
[Fact]
public async Task PutAiConfig_UnknownProvider_Returns400()
{
await RunAsync(
async (baseAddress, _, _, _, _) =>
{
HttpClient operatorClient = CreateClient(baseAddress);
await LoginOperatorAsync(operatorClient, baseAddress);
using HttpResponseMessage response = await PutJsonAsync(
operatorClient, AiConfigUrl(baseAddress), new { providerId = "несуществующий", apiKey = ApiKey });
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
Assert.Equal(InvalidProviderDetail, (await ReadJsonAsync(response)).GetProperty("detail").GetString());
});
}
[Fact]
public async Task PutAiConfig_CustomWithoutModel_Returns400()
{
await RunAsync(
async (baseAddress, _, _, _, _) =>
{
HttpClient operatorClient = CreateClient(baseAddress);
await LoginOperatorAsync(operatorClient, baseAddress);
using HttpResponseMessage response = await PutJsonAsync(
operatorClient, AiConfigUrl(baseAddress), new { providerId = "custom", baseUrl = "https://llm.local/v1", apiKey = ApiKey });
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
Assert.Equal(MissingAiModelDetail, (await ReadJsonAsync(response)).GetProperty("detail").GetString());
});
}
[Fact]
public async Task PutAiConfig_ShortApiKey_Returns400()
{
await RunAsync(
async (baseAddress, _, _, _, _) =>
{
HttpClient operatorClient = CreateClient(baseAddress);
await LoginOperatorAsync(operatorClient, baseAddress);
using HttpResponseMessage response = await PutJsonAsync(
operatorClient, AiConfigUrl(baseAddress), new { providerId = "deepseek", apiKey = "short" });
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
});
}
[Fact]
public async Task CheckAiConfig_NoConfig_Returns400()
{
await RunAsync(
async (baseAddress, _, _, _, _) =>
{
HttpClient operatorClient = CreateClient(baseAddress);
await LoginOperatorAsync(operatorClient, baseAddress);
using HttpResponseMessage response = await PostJsonAsync(
operatorClient, AiConfigCheckUrl(baseAddress), new { });
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
Assert.Equal(AiConfigNotSetDetail, (await ReadJsonAsync(response)).GetProperty("detail").GetString());
});
}
[Fact]
public async Task CheckAiConfig_LocalProvider_ReturnsOkWithoutHttp()
{
await RunAsync(
async (baseAddress, _, _, _, _) =>
{
HttpClient operatorClient = CreateClient(baseAddress);
await LoginOperatorAsync(operatorClient, baseAddress);
using HttpResponseMessage put = await PutJsonAsync(
operatorClient, AiConfigUrl(baseAddress), new { providerId = "ollama", baseUrl = "http://127.0.0.1:11434/v1" });
Assert.Equal(HttpStatusCode.OK, put.StatusCode);
using HttpResponseMessage check = await PostJsonAsync(
operatorClient, AiConfigCheckUrl(baseAddress), new { });
Assert.Equal(HttpStatusCode.OK, check.StatusCode);
JsonElement body = await ReadJsonAsync(check);
Assert.True(body.GetProperty("ok").GetBoolean());
Assert.Equal("ollama", body.GetProperty("provider").GetString());
});
}
// ─── Хелперы ─────────────────────────────────────────────────────────
// URL глобальной конфигурации ИИ (GET/PUT /api/operator/settings/ai-config).
private static string AiConfigUrl(string baseAddress) =>
$"{baseAddress}/api/operator/settings/ai-config";
// URL проверки связи (POST /api/operator/settings/ai-config/check).
private static string AiConfigCheckUrl(string baseAddress) =>
$"{baseAddress}/api/operator/settings/ai-config/check";
// Прогоняет сценарий на хосте с фейком глобального хранилища и аудита.
private static Task RunAsync(
Func<string, TestOperatorAuthStore, TestAuthStore, TestGlobalSettingsStore, TestAuditLogStore, Task> scenario,
TestAuditLogStore? auditStore = null) =>
OperatorAuthHttpHost.RunWithGlobalSettingsAsync(
NewOperatorStore(),
new TestAuthStore(),
scenario,
auditStore);
// Логинит оператора (ожидается 200).
private static async Task LoginOperatorAsync(HttpClient client, string baseAddress)
{
using HttpResponseMessage login = await PostJsonAsync(
client, $"{baseAddress}/api/operator/auth/login", new { login = OperatorLogin, password = OperatorPassword });
Assert.Equal(HttpStatusCode.OK, login.StatusCode);
}
// Фейк-хранилище оператора с активным оператором operator/operator.
private static TestOperatorAuthStore NewOperatorStore()
{
var passwordHasher = TestHashers.New();
var store = new TestOperatorAuthStore();
store.AddOperator(new StoredOperatorDto(
Guid.NewGuid(),
OperatorLogin,
Status: "active",
PasswordHash: passwordHasher.Hash(OperatorPassword)));
return store;
}
// HTTP-клиент с собственным CookieContainer (изоляция сценариев кук).
private static HttpClient CreateClient(string baseAddress) =>
new(new HttpClientHandler { UseCookies = true, CookieContainer = new CookieContainer() })
{
BaseAddress = new Uri(baseAddress),
};
// POST JSON-тела и возврат ответа.
private static async Task<HttpResponseMessage> PostJsonAsync(
HttpClient client,
string url,
object body)
{
string json = JsonSerializer.Serialize(body);
using var content = new StringContent(json, Encoding.UTF8, "application/json");
return await client.PostAsync(url, content);
}
// PUT JSON-тела и возврат ответа.
private static async Task<HttpResponseMessage> PutJsonAsync(
HttpClient client,
string url,
object body)
{
string json = JsonSerializer.Serialize(body);
using var content = new StringContent(json, Encoding.UTF8, "application/json");
return await client.PutAsync(url, content);
}
// Читает тело ответа как JSON-документ (корень, отвязанный от документа).
private static async Task<JsonElement> ReadJsonAsync(HttpResponseMessage response)
{
await using Stream stream = await response.Content.ReadAsStreamAsync();
using var document = await JsonDocument.ParseAsync(stream);
return document.RootElement.Clone();
}
}
@@ -10,6 +10,7 @@ using Deal.Infrastructure.Integrations.Services;
using Deal.Infrastructure.Persistence;
using Deal.Infrastructure.Tenancy;
using Deal.Modules.Settings.Application.Abstractions;
using Deal.Modules.Settings.Application.Services;
using Deal.Modules.Tenants.Application.Abstractions;
using Deal.Modules.Tenants.Application.Registrars;
using Deal.SharedKernel.Tenants.Abstractions;
@@ -196,6 +197,8 @@ internal static class OperatorAuthHttpHost
builder.Services.AddSingleton<ISecretCipher>(TestCiphers.New());
// Сервис глобальных ключей Telegram (операторские ручки /api/operator/settings/telegram-keys).
builder.Services.AddScoped<TelegramKeysService>();
// Сервис глобальной конфигурации ИИ (операторские ручки /api/operator/settings/ai-config).
builder.Services.AddScoped<AiGlobalConfigService>();
builder.Services.AddSingleton<ITokenUsageEventStore>(effectiveTokenUsageStore.Store);
builder.Services.AddSingleton(_ => new TestTenantProvisioner().Provisioner);
builder.Services.AddScoped<TenantSchemaMigrationService>();
@@ -212,6 +215,10 @@ internal static class OperatorAuthHttpHost
builder.Services.AddSingleton<IRateLimitCounterStore>(new TestRateLimitCounterStore().Store);
builder.Services.AddScoped<LoginAttemptGuard>();
builder.Services.AddScoped<SuspiciousActivityReporter>();
builder.Services.AddSingleton<IAuditReferenceResolver>(new TestAuditReferenceResolver());
// Проверка связи с ИИ-провайдером (операторская ручка ai-config/check): локальные
// провайдеры отвечают без HTTP, поэтому клиента достаточно с таймаутом по умолчанию.
builder.Services.AddSingleton<IAiConnectionChecker>(new AiConnectionChecker(new HttpClient()));
WebApplication app = builder.Build();
app.UseMiddleware<SessionMiddleware>();
@@ -14,12 +14,11 @@ namespace Deal.Tests.Unit.Support;
public sealed class SettingsServiceTests
{
private readonly TestSettingsStore _store = new();
private readonly ISecretCipher _cipher = TestCiphers.New();
private readonly SettingsService _service;
public SettingsServiceTests()
{
_service = new SettingsService(_store.Store, _cipher, Array.Empty<IRatesChangedListener>());
_service = new SettingsService(_store.Store, Array.Empty<IRatesChangedListener>());
}
// ─── GET: снимок ─────────────────────────────────────────────────────────
@@ -39,7 +38,6 @@ public sealed class SettingsServiceTests
Assert.Equal("both", snapshot.WantedType);
Assert.Equal("RUB", snapshot.TargetCurrency);
Assert.Equal("cbr", snapshot.RateSource);
Assert.Equal("deepseek", snapshot.AiProvider);
Assert.True(snapshot.ConversionOn);
Assert.True(snapshot.RemindersEnabled);
Assert.Equal(50, snapshot.DiscJoinLimit);
@@ -47,26 +45,18 @@ public sealed class SettingsServiceTests
Assert.Equal(70, snapshot.DiscJoinDelayMax);
Assert.False(snapshot.DiscPaused);
// Пустые коллекции и секреты.
// Пустые коллекции.
Assert.Empty(snapshot.MyPrompts);
Assert.Empty(snapshot.ColState);
Assert.Equal(7, snapshot.Providers.Count);
Assert.Equal(7, snapshot.AiConfigs.Count);
Assert.All(snapshot.AiConfigs, pair => Assert.False(pair.Value.KeySet));
Assert.All(snapshot.AiConfigs, pair => Assert.Equal(string.Empty, pair.Value.KeyMasked));
}
[Fact]
public async Task GetPublicAsync_StoredOverrides_OverlapDefaults()
{
// Переопределения: int, список, colState, aiConfigs (с секретом).
// Переопределения: int, список, colState.
_store.Preload(SettingsKeys.MinLen, "100");
_store.Preload(SettingsKeys.StopPhrases, "[\"фраза один\",\"фраза два\"]");
_store.Preload(SettingsKeys.ColState, "{\"colA\":{\"collapsed\":true},\"num\":42}");
string aiToken = _cipher.Encrypt("sk-1234567890ab");
_store.Preload(
SettingsKeys.AiConfigs,
JsonSerializer.Serialize(new { deepseek = new { apiKey = aiToken, baseUrl = "https://x.example/v1", model = "m1" } }));
PublicSettingsDto snapshot = await _service.GetPublicAsync(CancellationToken.None);
@@ -79,29 +69,6 @@ public sealed class SettingsServiceTests
Assert.Equal(2, snapshot.ColState.Count);
Assert.True(((JsonElement)snapshot.ColState["colA"]!).GetProperty("collapsed").GetBoolean());
Assert.Equal(42, ((JsonElement)snapshot.ColState["num"]!).GetInt32());
// aiConfigs: только переопределённый провайдер; дефолты остальных не тронуты.
AiConfigPublicDto deepseek = snapshot.AiConfigs["deepseek"];
Assert.True(deepseek.KeySet);
Assert.Equal("sk-1…90ab", deepseek.KeyMasked);
Assert.Equal("https://x.example/v1", deepseek.BaseUrl);
Assert.Equal("m1", deepseek.Model);
Assert.False(snapshot.AiConfigs["openai"].KeySet);
}
[Fact]
public async Task GetPublicAsync_MalformedEncryptedSecret_ReturnsMaskEmpty()
{
_store.Preload(
SettingsKeys.AiConfigs,
JsonSerializer.Serialize(new { deepseek = new { apiKey = "enc:не-base64!", baseUrl = "https://x", model = "m" } }));
PublicSettingsDto snapshot = await _service.GetPublicAsync(CancellationToken.None);
AiConfigPublicDto deepseek = snapshot.AiConfigs["deepseek"];
Assert.False(deepseek.KeySet);
Assert.Equal(string.Empty, deepseek.KeyMasked);
Assert.Equal(7, snapshot.AiConfigs.Count);
}
// ─── PATCH: общее поведение ──────────────────────────────────────────────
@@ -113,7 +80,6 @@ public sealed class SettingsServiceTests
Assert.Equal(100, snapshot.MinLen);
Assert.Equal(14, snapshot.ArchiveAfterDays);
Assert.Equal("deepseek", snapshot.AiProvider);
Assert.Equal("100", _store.GetStoredJson(SettingsKeys.MinLen));
Assert.Single(_store.Keys); // записано только переданное поле
}
@@ -310,17 +276,6 @@ public sealed class SettingsServiceTests
Assert.Equal("\"USD\"", _store.GetStoredJson(SettingsKeys.TargetCurrency));
}
[Fact]
public async Task ApplyPatchAsync_AiProvider_UnknownIsSkippedKnownIsApplied()
{
PublicSettingsDto first = await PatchAsync(new { aiProvider = "неизвестный" });
Assert.Equal("deepseek", first.AiProvider);
Assert.Empty(_store.Keys);
PublicSettingsDto second = await PatchAsync(new { aiProvider = "openai" });
Assert.Equal("openai", second.AiProvider);
}
[Fact]
public async Task ApplyPatchAsync_List_ConvertsScalarsAndSlicesTo200()
{
@@ -402,160 +357,6 @@ public sealed class SettingsServiceTests
Assert.Empty(_store.Keys);
}
// ─── PATCH: aiConfigs (секреты) ──────────────────────────────────────────
[Fact]
public async Task ApplyPatchAsync_AiConfigs_ApiKeyIsEncryptedInStoreAndMaskedOutside()
{
const string apiKey = "sk-1234567890ab";
PublicSettingsDto snapshot = await PatchAsync(new Dictionary<string, object?>
{
["aiConfigs"] = new Dictionary<string, object?> { ["deepseek"] = new { apiKey } },
});
string storedJson = _store.GetStoredJson(SettingsKeys.AiConfigs)!;
Assert.NotNull(storedJson);
Assert.Contains("enc:", storedJson);
Assert.DoesNotContain(apiKey, storedJson);
AiConfigPublicDto deepseek = snapshot.AiConfigs["deepseek"];
Assert.True(deepseek.KeySet);
Assert.Equal("sk-1…90ab", deepseek.KeyMasked);
Assert.Equal(SettingsDefaults.AiConfigs["deepseek"].BaseUrl, deepseek.BaseUrl);
Assert.Equal(SettingsDefaults.AiConfigs["deepseek"].Model, deepseek.Model);
}
[Fact]
public async Task ApplyPatchAsync_AiConfigs_ShortApiKeyIsNotEncrypted()
{
PublicSettingsDto snapshot = await PatchAsync(new Dictionary<string, object?>
{
["aiConfigs"] = new Dictionary<string, object?> { ["deepseek"] = new { apiKey = "short" } },
});
Assert.False(snapshot.AiConfigs["deepseek"].KeySet);
Assert.Null(_store.GetStoredJson(SettingsKeys.AiConfigs)); // дефолт не переопределён — строки нет
}
[Fact]
public async Task ApplyPatchAsync_AiConfigs_UnknownProviderIsIgnored()
{
PublicSettingsDto snapshot = await PatchAsync(new Dictionary<string, object?>
{
["aiConfigs"] = new Dictionary<string, object?>
{
["несуществующий"] = new { apiKey = "sk-1234567890ab" },
},
});
Assert.All(snapshot.AiConfigs.Values, config => Assert.False(config.KeySet));
Assert.Null(_store.GetStoredJson(SettingsKeys.AiConfigs));
}
[Fact]
public async Task ApplyPatchAsync_AiConfigs_BaseUrlMutableOnlyForLocalAndCustomProviders()
{
// Каталоговый облачный провайдер (deepseek): baseUrl фиксирован каталогом — переопределение
// игнорируется (SSRF-гейт, Security review); model при этом применяется.
PublicSettingsDto snapshot = await PatchAsync(new Dictionary<string, object?>
{
["aiConfigs"] = new Dictionary<string, object?>
{
["deepseek"] = new { baseUrl = "http://local.example/v1", model = "my-model" },
},
});
AiConfigPublicDto deepseek = snapshot.AiConfigs["deepseek"];
Assert.Equal(SettingsDefaults.AiConfigs["deepseek"].BaseUrl, deepseek.BaseUrl);
Assert.Equal("my-model", deepseek.Model);
Assert.False(deepseek.KeySet);
// Локальный провайдер (ollama) и custom: свой baseUrl разрешён (это их назначение).
PublicSettingsDto local = await PatchAsync(new Dictionary<string, object?>
{
["aiConfigs"] = new Dictionary<string, object?>
{
["ollama"] = new { baseUrl = "http://127.0.0.1:11435/v1" },
["custom"] = new { baseUrl = "https://my-gw.example/v1" },
},
});
Assert.Equal("http://127.0.0.1:11435/v1", local.AiConfigs["ollama"].BaseUrl);
Assert.Equal("https://my-gw.example/v1", local.AiConfigs["custom"].BaseUrl);
}
[Fact]
public async Task ApplyPatchAsync_AiConfigs_ExistingKeySurvivesFollowUpPatchWithoutApiKey()
{
const string apiKey = "sk-1234567890ab";
await PatchAsync(new Dictionary<string, object?>
{
["aiConfigs"] = new Dictionary<string, object?> { ["deepseek"] = new { apiKey } },
});
string firstStored = _store.GetStoredJson(SettingsKeys.AiConfigs)!;
PublicSettingsDto snapshot = await PatchAsync(new Dictionary<string, object?>
{
["aiConfigs"] = new Dictionary<string, object?>
{
// baseUrl каталогового провайдера не переопределяется (SSRF-гейт) — перезаписи нет.
["deepseek"] = new { baseUrl = "http://other.example/v1" },
},
});
AiConfigPublicDto deepseek = snapshot.AiConfigs["deepseek"];
Assert.True(deepseek.KeySet); // ключ сохранён прежним (apiKey не передан)
Assert.Equal("sk-1…90ab", deepseek.KeyMasked);
Assert.Equal(SettingsDefaults.AiConfigs["deepseek"].BaseUrl, deepseek.BaseUrl);
Assert.Equal(firstStored, _store.GetStoredJson(SettingsKeys.AiConfigs)); // значение не изменилось
}
[Fact]
public async Task ApplyPatchAsync_AiConfigs_MaskedApiKeyIsNotEncryptedAgain()
{
const string apiKey = "sk-1234567890ab";
await PatchAsync(new Dictionary<string, object?>
{
["aiConfigs"] = new Dictionary<string, object?> { ["deepseek"] = new { apiKey } },
});
// Повторный PATCH с маской из public-снимка («sk-1…90ab») не должен зашифровать маску как новый ключ
// (Security review: иначе реальный ключ теряется безвозвратно).
PublicSettingsDto snapshot = await PatchAsync(new Dictionary<string, object?>
{
["aiConfigs"] = new Dictionary<string, object?>
{
["deepseek"] = new { apiKey = "sk-1…90ab" },
},
});
AiConfigPublicDto deepseek = snapshot.AiConfigs["deepseek"];
Assert.True(deepseek.KeySet); // ключ прежний, маска НЕ зашифрована
Assert.Equal("sk-1…90ab", deepseek.KeyMasked);
// Сохранённый ключ всё ещё исходный (не «enc:маска»): расшифровка даёт оригинал.
string storedJson = _store.GetStoredJson(SettingsKeys.AiConfigs)!;
using var document = JsonDocument.Parse(storedJson);
string storedKey = document.RootElement.GetProperty("deepseek").GetProperty("apiKey").GetString()!;
Assert.Equal(apiKey, _cipher.Decrypt(storedKey));
}
[Fact]
public async Task ApplyPatchAsync_AiConfigs_EightCharKeyIsFullyMaskedOnOutput()
{
// Короткий (8 симв.) ключ в public-снимке не раскрывается целиком (Security review: echo-маска).
PublicSettingsDto snapshot = await PatchAsync(new Dictionary<string, object?>
{
["aiConfigs"] = new Dictionary<string, object?> { ["deepseek"] = new { apiKey = "12345678" } },
});
AiConfigPublicDto deepseek = snapshot.AiConfigs["deepseek"];
Assert.True(deepseek.KeySet);
Assert.Equal("1…", deepseek.KeyMasked);
}
[Fact]
public async Task ApplyPatchAsync_ColState_PassedThroughAsIs()
{
@@ -593,7 +394,7 @@ public sealed class SettingsServiceTests
public async Task ApplyPatchAsync_TargetCurrencyInBody_NotifiesListenersAfterSave()
{
var listener = Substitute.For<IRatesChangedListener>();
SettingsService local = new(_store.Store, _cipher, new[] { listener });
SettingsService local = new(_store.Store, new[] { listener });
PublicSettingsDto snapshot = await local.ApplyPatchAsync(JsonBody(new { targetCurrency = "usd" }), CancellationToken.None);
@@ -606,7 +407,7 @@ public sealed class SettingsServiceTests
public async Task ApplyPatchAsync_ConversionOnInBody_NotifiesListeners()
{
var listener = Substitute.For<IRatesChangedListener>();
SettingsService local = new(_store.Store, _cipher, new[] { listener });
SettingsService local = new(_store.Store, new[] { listener });
await local.ApplyPatchAsync(JsonBody(new { conversionOn = false }), CancellationToken.None);
@@ -618,7 +419,7 @@ public sealed class SettingsServiceTests
public async Task ApplyPatchAsync_UnrelatedKeys_DoesNotNotifyListeners()
{
var listener = Substitute.For<IRatesChangedListener>();
SettingsService local = new(_store.Store, _cipher, new[] { listener });
SettingsService local = new(_store.Store, new[] { listener });
await local.ApplyPatchAsync(JsonBody(new { minLen = 100, rateSource = "mock" }), CancellationToken.None);
@@ -629,7 +430,7 @@ public sealed class SettingsServiceTests
public async Task ApplyPatchAsync_TargetCurrencyJsonNull_DoesNotNotifyListeners()
{
var listener = Substitute.For<IRatesChangedListener>();
SettingsService local = new(_store.Store, _cipher, new[] { listener });
SettingsService local = new(_store.Store, new[] { listener });
await local.ApplyPatchAsync(JsonBody(new Dictionary<string, object?> { ["targetCurrency"] = null }), CancellationToken.None);
@@ -0,0 +1,51 @@
using System.Text.Json;
using Deal.Modules.Settings.Application.Abstractions;
using Deal.Modules.Settings.Application.Models;
using Deal.Modules.Settings.Application.Services;
namespace Deal.Tests.Unit.Support;
/// <summary>
/// Глобальная конфигурация ИИ для тестов ИИ-вызовов: провайдер каталога с заданным ключом.
/// </summary>
internal static class TestAiConfig
{
/// <summary>
/// Провайдер по умолчанию для тестов ИИ-вызовов.
/// </summary>
public const string DefaultProviderId = "deepseek";
/// <summary>
/// API-ключ по умолчанию: конфигурация считается заданной.
/// </summary>
public const string DefaultApiKey = "sk-test-deepseek-key";
/// <summary>
/// Кладёт глобальную конфигурацию ИИ и создаёт поверх неё сервис.
/// </summary>
/// <param name="store">Подставка хранилища глобальных настроек.</param>
/// <param name="cipher">Шифр секретов.</param>
/// <param name="providerId">Id провайдера каталога.</param>
/// <param name="baseUrl">Базовый URL (пусто — дефолт каталога).</param>
/// <param name="model">Модель (пусто — первая из каталога).</param>
/// <param name="apiKey">API-ключ открытым текстом (пусто — без ключа).</param>
/// <returns>Сервис глобальной конфигурации ИИ.</returns>
public static AiGlobalConfigService New(
TestGlobalSettingsStore store,
ISecretCipher cipher,
string providerId = DefaultProviderId,
string baseUrl = "",
string model = "",
string apiKey = DefaultApiKey)
{
store.Preload(GlobalSettingsKeys.AiConfig, JsonSerializer.Serialize(new
{
providerId,
baseUrl,
model,
apiKey = apiKey.Length == 0 ? string.Empty : cipher.Encrypt(apiKey),
}));
return new AiGlobalConfigService(store.Store, cipher);
}
}
@@ -0,0 +1,15 @@
using Deal.Api.Services;
using Deal.Modules.Tenants.Application.Models;
namespace Deal.Tests.Unit.Support;
/// <summary>
/// Подставка резолвера ссылок аудита: без чтения данных тенанта
/// </summary>
public sealed class TestAuditReferenceResolver : IAuditReferenceResolver
{
public Task<IReadOnlyDictionary<string, string>> ResolveAsync(
IReadOnlyList<AuditRecordDto> records,
CancellationToken ct) =>
Task.FromResult<IReadOnlyDictionary<string, string>>(new Dictionary<string, string>());
}
@@ -23,6 +23,17 @@ const COLUMNS = [
const ACTOR_TONES = { operator: 'brand', tenant: 'neutral', system: 'warn' }
// Код поля «изменённые настройки» устаревшего формата деталей.
const FIELD_SETTINGS = 'fields'
// Поля-ссылки на контейнер и служебные контейнеры с человекочитаемыми именами.
const CONTAINER_FIELDS = new Set(['to', 'col', 'containerId'])
const CONTAINER_VALUE_KEYS = {
inbox: 'operator.columnValue.inbox',
archive: 'operator.columnValue.archive',
trash: 'operator.columnValue.trash',
}
// Строковые представления булевых значений в деталях события (как в JSON).
const TRUE_VALUE = 'true'
const FALSE_VALUE = 'false'
@@ -53,13 +64,37 @@ function valueLabel(field, raw) {
if (raw === null || raw === undefined || raw === '') return '—'
if (raw === TRUE_VALUE) return t('operator.da')
if (raw === FALSE_VALUE) return t('operator.net')
if (CONTAINER_FIELDS.has(field) && CONTAINER_VALUE_KEYS[raw]) return t(CONTAINER_VALUE_KEYS[raw])
return translate(`operator.value.${field}.${raw}`, raw)
}
// Список ключей настроек из устаревшего формата → русские названия через запятую.
function settingList(raw) {
return String(raw)
.split(',')
.map((key) => key.trim())
.filter(Boolean)
.map((key) => translate(`operator.setting.${key}`, key))
.join(', ')
}
function changesOf(row) {
return Array.isArray(row.changes) ? row.changes : []
}
// Строка изменения: «Поле: было → стало» либо «Поле: значение».
function changeLine(change) {
const field = fieldLabel(change.field)
if (change.field === FIELD_SETTINGS) {
return `${field}: ${settingList(change.to)}`
}
const to = valueLabel(change.field, change.to)
return hasFrom(change)
? `${field}: ${valueLabel(change.field, change.from)} → ${to}`
: `${field}: ${to}`
}
// Вторая строка колонки пользователя: имя пространства, иначе id.
function tenantSubline(row) {
return row.userName && row.tenantName ? row.tenantName : row.tenantId || '—'
@@ -99,14 +134,7 @@ function pretty(json) {
class="mt-1.5 max-w-[460px] rounded-md bg-ink/70 border border-white/8 p-2"
>
<div v-if="changesOf(row).length" class="space-y-0.5">
<div v-for="(change, i) in changesOf(row)" :key="i" class="text-[11px] text-mid">
<span class="text-low">{{ fieldLabel(change.field) }}:</span>
<template v-if="hasFrom(change)">
<span class="text-low">{{ valueLabel(change.field, change.from) }}</span>
<span class="text-low/60"> → </span>
</template>
<span class="text-hi">{{ valueLabel(change.field, change.to) }}</span>
</div>
<div v-for="(change, i) in changesOf(row)" :key="i" class="text-[11px] text-mid">{{ changeLine(change) }}</div>
</div>
<div v-else class="text-[11px] text-low">{{ $t('operator.dopolnitelnyh-parametrov-net') }}</div>
<button
+9 -108
View File
@@ -6,21 +6,14 @@ import {
toast,
saveAiPrompt,
saveAiCardPrompt,
setAiProvider,
checkAiConnection,
saveAiSettings,
addMyPrompt,
removeMyPrompt,
} from '../../store.js'
import { AI_PROVIDERS, DEFAULT_AI_PROMPT, DEFAULT_AI_CARD_PROMPT } from '../../data.js'
import { DEFAULT_AI_PROMPT, DEFAULT_AI_CARD_PROMPT } from '../../data.js'
import Icon from '../Icon.vue'
import UiToggle from '../ui/ToggleSwitch.vue'
import PromptLibraryModal from '../PromptLibraryModal.vue'
// активный AI-провайдер (выбран может быть только один)
const activeAi = computed(() => AI_PROVIDERS.find((p) => p.id === state.aiProvider))
const activeCfg = computed(() => state.aiConfigs[state.aiProvider])
// ── самооценка ML: подсказка на вкладке AI, когда «ML справляется, ИИ можно отключить» ──
const ML_NEED = 50 // окно последних подтверждённых решений
const ADV_STYLE = {
@@ -147,105 +140,19 @@ async function saveCurrentAsMine() {
</div>
</div>
<!-- выбор провайдера (активен только один) -->
<!-- провайдер и модель задаёт оператор системы -->
<div class="rounded-xl border border-white/5 bg-raise/40 p-4">
<div class="flex items-center justify-between mb-1">
<div class="flex items-start gap-3.5">
<span class="w-9 h-9 rounded-lg grid place-items-center shrink-0 bg-brand/10 text-brand">
<Icon name="chip" :size="17" />
</span>
<div class="min-w-0 flex-1">
<div class="text-[12px] font-medium text-hi">{{ $t('settings.provajder-klassifikatora') }}</div>
<span class="text-[11px] text-low/80">{{ $t('settings.aktiven-odin') }}{{ activeAi?.name }}»</span>
</div>
<p class="text-[11.5px] text-low mb-3">
{{ $t('settings.oblachnye-api-ili-lokalnye-openai') }}
<p class="text-[11.5px] text-low mt-1 leading-relaxed">
{{ $t('settings.provajdera-model-i-klyuch-nastraivaet') }}
</p>
<div class="space-y-2">
<button
v-for="p in AI_PROVIDERS"
:key="p.id"
class="w-full flex items-start gap-3 rounded-xl border p-3 text-left transition-colors"
:class="
state.aiProvider === p.id
? 'border-brand/40 bg-brand/5'
: 'border-white/8 bg-transparent hover:bg-white/3'
"
@click="setAiProvider(p.id)"
>
<span
class="mt-0.5 w-4 h-4 rounded-full border-2 grid place-items-center shrink-0"
:class="state.aiProvider === p.id ? 'border-brand' : 'border-low'"
>
<span v-if="state.aiProvider === p.id" class="w-2 h-2 rounded-full bg-brand"></span>
</span>
<span class="min-w-0 flex-1">
<span class="flex items-center gap-2">
<span class="text-[13px] font-medium text-hi">{{ p.name }}</span>
<span
class="text-[9px] uppercase tracking-wider font-semibold rounded px-1.5 py-px shrink-0"
:class="p.local ? 'bg-online/12 text-online' : 'bg-brand/12 text-brand'"
>
{{ p.local ? $t('settings.lokalno') : $t('settings.oblako') }}
</span>
</span>
<span class="block text-[11.5px] text-low mt-0.5 leading-relaxed">{{ p.desc }}</span>
<span class="block text-[10.5px] text-low/70 font-mono mt-1">{{ p.base }}</span>
</span>
</button>
</div>
</div>
<!-- параметры активного провайдера -->
<div v-if="activeAi && activeCfg" class="rounded-xl border border-white/5 bg-raise/40 p-4 space-y-3">
<div class="text-[12px] font-medium text-hi">{{ $t('settings.parametry') }} {{ activeAi.name }}</div>
<div>
<label class="block text-[11.5px] text-low mb-1.5">Base URL / endpoint</label>
<input
v-model="activeCfg.baseUrl"
class="w-full h-9 rounded-lg bg-ink/70 border border-white/10 focus:border-brand/50 outline-none px-3 font-mono text-[12.5px]"
/>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-[11.5px] text-low mb-1.5">{{ $t('settings.model') }}</label>
<input
v-model="activeCfg.model"
list="ai-models-list"
class="w-full h-9 rounded-lg bg-ink/70 border border-white/10 focus:border-brand/50 outline-none px-3 font-mono text-[12.5px]"
:placeholder="$t('settings.vvedite-ili-vyberite-model')"
/>
<datalist id="ai-models-list">
<option v-for="m in activeAi.models" :key="m" :value="m" />
<option v-if="activeCfg.model" :value="activeCfg.model" />
</datalist>
</div>
<div v-if="!activeAi.local">
<label class="block text-[11.5px] text-low mb-1">{{ $t('settings.api-klyuch') }}</label>
<input
v-model="activeCfg.apiKey"
type="password"
autocomplete="off"
autocorrect="off"
autocapitalize="off"
spellcheck="false"
class="w-full h-9 rounded-lg bg-ink/70 border border-white/10 focus:border-brand/50 outline-none px-3 font-mono text-[12.5px]"
:placeholder="activeCfg.keyMasked || 'sk-…'"
/>
</div>
<div v-else class="flex items-end">
<span class="text-[11px] text-online/90 flex items-center gap-1.5 pb-2">
<Icon name="check" :size="13" />
{{ $t('settings.klyuch-ne-trebuetsya') }}
</span>
</div>
</div>
<div class="flex items-center gap-2">
<button
class="h-9 px-4 rounded-lg bg-brand/12 text-brand border border-brand/20 text-[12.5px] font-medium hover:bg-brand/20 transition-colors"
@click="checkAiConnection"
>
{{ $t('settings.proverit-podklyuchenie') }}
</button>
<span class="text-[11px] text-low/80">
{{ activeAi.local ? $t('settings.ping-lokalnogo-servera') : $t('settings.klyuch-nigde-ne-logiruetsya') }}
</span>
</div>
</div>
<!-- системный промпт -->
@@ -287,12 +194,6 @@ async function saveCurrentAsMine() {
>
{{ $t('settings.sohranit-prompt') }}
</button>
<button
class="h-9 px-4 rounded-lg bg-white/5 hover:bg-white/10 text-[12px] text-mid hover:text-hi transition-colors"
@click="saveAiSettings"
>
{{ $t('settings.sohranit-nastrojki-ai') }}
</button>
</div>
<!-- Мои промпты -->
-1
View File
@@ -10,7 +10,6 @@ export const DAY = 86400000
export {
COLUMN_WIDTHS,
CURRENCIES,
AI_PROVIDERS,
DEFAULT_AI_PROMPT,
DEFAULT_AI_CARD_PROMPT,
DEFAULT_AI_FILTER_PROMPT,
+3 -68
View File
@@ -1,7 +1,7 @@
// ─── Языковой контент-каталог (ru) ────────────────────────────────────────
// Список валют, AI-провайдеры, стадии канбана «Выбранных» и дефолтные
// промпты — это пользовательские строки, поэтому живут в ресурсах локали
// (вне компонентов/логики). При добавлении языка новый каталог кладётся рядом
// Список валют, стадии канбана «Выбранных» и дефолтные промпты — это
// пользовательские строки, поэтому живут в ресурсах локали (вне
// компонентов/логики). При добавлении языка новый каталог кладётся рядом
// (например, ru-контент остаётся языком-фолбэком).
// Ширины колонок канбана: cls — класс Tailwind, label — пункт меню, порядок
@@ -23,71 +23,6 @@ export const CURRENCIES = [
{ code: 'GBP', name: 'Британский фунт', symbol: '£' },
]
export const AI_PROVIDERS = [
{
id: 'deepseek',
name: 'DeepSeek',
desc: 'Официальный API DeepSeek (OpenAI-совместимый), 1M контекст, thinking-режим',
base: 'https://api.deepseek.com',
local: false,
models: ['deepseek-v4-flash', 'deepseek-v4-pro', 'deepseek-v4-flash-vision-exp'],
},
{
id: 'openai',
name: 'OpenAI',
desc: 'ChatGPT API (OpenAI-совместимый), флагманская линейка GPT-5.6',
base: 'https://api.openai.com/v1',
local: false,
models: ['gpt-5.6-sol', 'gpt-5.6-terra', 'gpt-5.6-luna'],
},
{
id: 'openrouter',
name: 'OpenRouter',
desc: 'Агрегатор: DeepSeek, Claude, GPT, Gemini и другие модели',
base: 'https://openrouter.ai/api/v1',
local: false,
models: [
'deepseek/deepseek-v4-flash',
'anthropic/claude-sonnet-5',
'openai/gpt-5.6-luna',
'google/gemini-3.8-flash',
'qwen/qwen3.8-flash',
],
},
{
id: 'anthropic',
name: 'Anthropic Claude',
desc: 'Claude (Messages API), актуальная линейка моделей',
base: 'https://api.anthropic.com',
local: false,
models: ['claude-sonnet-5', 'claude-opus-5', 'claude-fable-5-1', 'claude-haiku-4-5-20251001'],
},
{
id: 'ollama',
name: 'Ollama (локально)',
desc: 'Локальный сервер, OpenAI-совместимый, API-ключ не нужен',
base: 'http://localhost:11434/v1',
local: true,
models: ['qwen3-coder:30b', 'qwen3-coder:480b', 'qwen3:32b'],
},
{
id: 'lmstudio',
name: 'LM Studio (локально)',
desc: 'Локальный сервер, OpenAI-совместимый, API-ключ не нужен',
base: 'http://localhost:1234/v1',
local: true,
models: ['qwen3-coder-30b', 'qwen3-coder-480b', 'llama-3.3-70b'],
},
{
id: 'custom',
name: 'Другой (OpenAI-совместимый)',
desc: 'Любой совместимый сервер: vLLM, Together, Groq, самописный',
base: 'https://',
local: false,
models: [],
},
]
export const DEFAULT_AI_PROMPT = `Ты — классификатор входящих сообщений. Сообщение — ЗАЯВКА/ОБЪЯВЛЕНИЕ, только если в нём есть конкретный запрос или предложение по делу: заказ услуги/товара/работы, поиск исполнителя или найм человека. Направление вашей сферы:
{domain}
+86 -4
View File
@@ -525,6 +525,7 @@ export const ru = {
"vklyuchit-ii": "Включить ИИ",
"bez-ii-ml-obuchaetsya-tolko-vruchnuyu": "Без ИИ ML обучается только вручную: переносите карточки по колонкам, отправляйте мусор\n в корзину, возвращайте ошибочный отсев — каждое действие учит модель. Кнопки\n «Предложить колонки» и «Переклассифицировать» требуют включённого ИИ.",
"provajder-klassifikatora": "Провайдер классификатора",
"provajdera-model-i-klyuch-nastraivaet": "Провайдера, модель и API-ключ задаёт оператор системы — они едины для всех пользователей и не настраиваются здесь.",
"aktiven-odin": "активен один — «",
"oblachnye-api-ili-lokalnye-openai": "Облачные API или локальные OpenAI-совместимые серверы (Ollama, LM Studio, vLLM)",
"parametry": "Параметры:",
@@ -864,9 +865,11 @@ export const ru = {
"tenant_status_changed": "Изменён статус пользователя",
"tenant_limit_changed": "Изменён лимит ИИ",
"impersonation_started": "Вход от имени пользователя",
"impersonation_stopped": "Завершён вход от имени пользователя"
"impersonation_stopped": "Завершён вход от имени пользователя",
"ai_config_changed": "Изменена конфигурация ИИ"
},
"field": {
"id": "Идентификатор",
"login": "Логин",
"email": "Email",
"codeHash": "Код (хэш)",
@@ -878,7 +881,7 @@ export const ru = {
"status": "Статус",
"targetLogin": "Вход",
"cardId": "Карточка",
"to": "Куда",
"to": "Колонка",
"col": "Колонка",
"containerId": "Колонка",
"attempted": "Обработано",
@@ -889,7 +892,65 @@ export const ru = {
"all": "Все каналы",
"count": "Каналов",
"phase": "Этап",
"dialogId": "Диалог"
"dialogId": "Диалог",
"providerId": "Провайдер",
"baseUrl": "Базовый URL",
"model": "Модель",
"keySet": "Ключ задан"
},
"setting": {
"archiveAfterDays": "Срок до архива",
"archiveClearDays": "Очистка архива, дней",
"trashClearDays": "Очистка корзины, дней",
"minLen": "Минимальная длина сообщения",
"discJoinLimit": "Суточный лимит вступлений",
"discJoinDelayMin": "Мин. задержка вступления, сек",
"discJoinDelayMax": "Макс. задержка вступления, сек",
"discEvalSample": "Сообщений для оценки",
"discEvalThreshold": "Порог соответствия, %",
"excludeBudgetFrom": "Исключать бюджет от",
"excludeBudgetTo": "Исключать бюджет до",
"autoArchive": "Автоперенос в архив",
"aiEnabled": "Использовать ИИ",
"aiFilterEnabled": "ИИ-фильтр входящих",
"conversionOn": "Конвертация валют",
"remindersEnabled": "Напоминания",
"mlEnabled": "ML в пайплайне",
"blockResumes": "Блокировать резюме",
"budgetRequiredHire": "Требовать бюджет для вакансий",
"budgetRequiredOrder": "Требовать бюджет для фриланса",
"autoMonitorNew": "Проверять новые каналы автоматически",
"discPaused": "Поиск каналов на паузе",
"targetCurrency": "Целевая валюта",
"rateSource": "Источник курсов",
"aiProvider": "Провайдер ИИ",
"aiPrompt": "Промпт классификатора",
"aiFilterPrompt": "Промпт фильтра",
"cardPrompt": "Промпт структуры карточки",
"domainDescription": "Описание домена",
"wantedType": "Тип заявок",
"hireLabel": "Метка вакансии",
"orderLabel": "Метка фриланса",
"stopPhrases": "Стоп-фразы",
"domainKeywords": "Ключевые слова домена",
"hireMarkers": "Маркеры найма",
"levelTerms": "Термины уровней",
"resumeMarkers": "Маркеры резюме",
"excludeKeywords": "Исключающие слова",
"excludeLocations": "Исключающие локации",
"excludeTypes": "Исключающие типы",
"colState": "Состояние колонок",
"myPrompts": "Мои промпты",
"aiConfigs": "Провайдеры ИИ",
"providerId": "Провайдер ИИ",
"baseUrl": "Базовый URL ИИ",
"model": "Модель ИИ",
"keySet": "Ключ ИИ задан"
},
"columnValue": {
"inbox": "Неразобранное",
"archive": "Архив",
"trash": "Корзина"
},
"value": {
"status": {
@@ -1025,7 +1086,28 @@ export const ru = {
"ukazhite-nepustoj-api-hash": "Укажите непустой api_hash",
"sohranit-klyuchi": "Сохранить ключи",
"klyuchi-telegram-sohraneny": "Ключи Telegram сохранены",
"smena-api-id-trebuet-api-hash": "Смена api_id требует повторного ввода api_hash"
"smena-api-id-trebuet-api-hash": "Смена api_id требует повторного ввода api_hash",
"ii": "ИИ",
"ii-desc": "Глобальный провайдер ИИ общий для всех пользователей: они не выбирают модель и не вводят ключи",
"globalnyj-provajder-ii": "Провайдер ИИ",
"globalnyj-provajder-ii-desc": "Классификация, фильтр, ключи поиска и карточки всех пользователей работают на этой конфигурации",
"konfiguraciya-dejstvuet-dlya-vseh": "Конфигурация едина для всех пользователей. Пользователю остаются его промпты и ниша — без выбора провайдера и модели.",
"provajder": "Провайдер",
"vyberite-provajdera": "Выберите провайдера",
"lokalno": "локально",
"model": "Модель",
"vvedite-model": "Введите модель",
"model-podskazka": "Пусто — первая модель каталога провайдера",
"model-obyazatelna-dlya-drugogo": "Для провайдера «Другой» модель обязательна",
"base-url-podskazka": "Адрес API; пусто — адрес каталога",
"api-klyuch": "API-ключ",
"api-klyuch-hint": "Ключ хранится зашифрованным; наружу отдаётся только маска",
"klyuch-zadan": "Ключ задан",
"klyuch-ne-zadan": "Ключ не задан",
"sohranit-konfiguraciyu": "Сохранить конфигурацию",
"proverit-svyaz": "Проверить связь",
"proverka-vypolnena": "Проверка выполнена",
"ii-config-sohranena": "Конфигурация ИИ сохранена"
},
"auth": {
"ne-udalos-smenit-parol": "Не удалось сменить пароль",
-5
View File
@@ -55,11 +55,6 @@ export const state = reactive({
tgKeysSet: false,
// ai
aiProvider: 'deepseek',
aiConfigs: {},
aiKey: '',
aiKeyChecked: false,
aiKeyOk: false,
aiPrompt: DEFAULT_AI_PROMPT,
aiCardPrompt: DEFAULT_AI_CARD_PROMPT,
aiEnabled: true,
+44 -1
View File
@@ -29,8 +29,9 @@ export const op = reactive({
audit: { items: [], total: 0 },
activity: { items: [], total: 0 },
tgKeys: null, // глобальные ключи Telegram (маскированный снимок)
aiConfig: null, // глобальная конфигурация ИИ (маскированный снимок с каталогом провайдеров)
loading: {}, // ключи: tenants|invites|limits|health|overview|tokens|suspicious|audit|activity|tgKeys|detail|limit
loading: {}, // ключи: tenants|invites|limits|health|overview|tokens|suspicious|audit|activity|tgKeys|aiConfig|detail|limit
})
/** Каталог типов событий аудита (контракт этапа 10 + события этапа 7). */
@@ -56,6 +57,7 @@ export const AUDIT_EVENT_TYPES = [
'channel_created',
'telegram_linked',
'telegram_keys_changed',
'ai_config_changed',
'tenant_created',
'tenant_status_changed',
'tenant_limit_changed',
@@ -392,6 +394,47 @@ export async function saveTelegramKeys(payload) {
}
}
// ─── Глобальная конфигурация ИИ (оператор) ──────────────────────────────
/** Маскированный снимок глобальной конфигурации ИИ вместе с каталогом провайдеров. */
export async function loadAiConfig() {
setLoading('aiConfig', true)
try {
op.aiConfig = await api.get('/api/operator/settings/ai-config')
} catch (e) {
toast(errMsg(e), { icon: 'x' })
} finally {
setLoading('aiConfig', false)
}
}
/** Сохранение конфигурации ИИ. Возвращает снимок или null при ошибке. */
export async function saveAiConfig(payload) {
try {
op.aiConfig = await api.put('/api/operator/settings/ai-config', payload)
toast(t('operator.ii-config-sohranena'), { icon: 'check' })
return op.aiConfig
} catch (e) {
toast(errMsg(e), { icon: 'x' })
return null
}
}
/** Проверка связи с сохранённым провайдером. Возвращает результат или null при ошибке. */
export async function checkAiConfig() {
setLoading('aiConfigCheck', true)
try {
const result = await api.post('/api/operator/settings/ai-config/check')
toast(result?.message || t('operator.proverka-vypolnena'), { icon: result?.ok ? 'check' : 'x' })
return result
} catch (e) {
toast(errMsg(e), { icon: 'x' })
return null
} finally {
setLoading('aiConfigCheck', false)
}
}
// ─── Формат ────────────────────────────────────────────────────────────────
/** Число с разделителями разрядов («1 570 000»). */
+1 -91
View File
@@ -4,7 +4,6 @@ import { t } from '@/i18n/index.js'
import { watch } from 'vue'
import {
CURRENCIES,
AI_PROVIDERS,
DEFAULT_AI_PROMPT,
DEFAULT_AI_FILTER_PROMPT,
DEFAULT_AI_CARD_PROMPT,
@@ -27,7 +26,6 @@ export function applySettings(pub, { keys = null } = {}) {
if (!pub) return
_hydrating = true
const has = (k) => keys === null || keys.includes(k)
if (has('aiProvider')) state.aiProvider = pub.aiProvider || state.aiProvider
if (has('aiPrompt')) state.aiPrompt = pub.aiPrompt || DEFAULT_AI_PROMPT
if (has('cardPrompt')) state.aiCardPrompt = pub.cardPrompt || DEFAULT_AI_CARD_PROMPT
if (has('aiFilterEnabled')) state.aiFilterEnabled = !!pub.aiFilterEnabled
@@ -63,98 +61,10 @@ export function applySettings(pub, { keys = null } = {}) {
if (has('targetCurrency')) state.targetCurrency = pub.targetCurrency || 'RUB'
if (has('rateSource')) state.rateSource = pub.rateSource || 'cbr'
if (has('remindersEnabled')) state.remindersEnabled = !!pub.remindersEnabled
// конфиги провайдеров пересобираем только при полном применении или когда
// ключ aiConfigs был именно в этом запросе (ответ на PATCH конфига)
if (keys === null || keys.includes('aiConfigs')) {
const providers = keys === null ? (pub.providers?.length ? pub.providers : AI_PROVIDERS) : AI_PROVIDERS
const cfg = pub.aiConfigs || {}
state.aiConfigs = {}
for (const p of providers) {
const c = cfg[p.id] || {}
state.aiConfigs[p.id] = {
apiKey: '',
keyMasked: c.keyMasked || '',
keySet: !!c.keySet,
baseUrl: c.baseUrl || p.base,
model: c.model || p.models?.[0] || '',
}
}
}
_hydrating = false
}
// ─── AI: провайдеры, конфиги, промпты ────────────────────────────────────
export function checkAiKey() {
state.aiKeyChecked = true
state.aiKeyOk = state.aiKey.trim().length >= 24
toast(state.aiKeyOk ? t('settings.klyuch-dejstvitelen') : t('settings.klyuch-pohozh-na-nepolnyj'), {
icon: state.aiKeyOk ? 'check' : 'x',
})
}
export function aiProviderMeta(id) {
return AI_PROVIDERS.find((p) => p.id === id) || null
}
export async function setAiProvider(id) {
if (!state.aiConfigs[id]) return
if (state.aiProvider === id) return
state.aiProvider = id
const p = aiProviderMeta(id)
try {
await api.patch('/api/settings', { aiProvider: id })
toast(t('settings.classifier', { name: p?.name || id }), { icon: 'sparkles' })
} catch (e) {
toast(errMsg(e), { icon: 'x' })
}
}
// Патч активного AI-конфига: base/model всегда, apiKey — только если ввели новый
function aiCfgPatch() {
const cfg = state.aiConfigs[state.aiProvider]
if (!cfg) return null
const patch = { aiConfigs: { [state.aiProvider]: { baseUrl: cfg.baseUrl, model: cfg.model } } }
const key = String(cfg.apiKey || '').trim()
if (key && key.length >= 8 && !key.startsWith('enc:')) patch.aiConfigs[state.aiProvider].apiKey = key
return patch
}
export async function checkAiConnection() {
// ключ вводится в UI и живёт в state; перед проверкой его нужно сохранить,
// иначе сервер ответит «Не задан API-ключ»
const patch = aiCfgPatch()
if (!patch) return
try {
await api.patch('/api/settings', patch)
} catch (e) {
toast(errMsg(e), { icon: 'x' })
return
}
try {
const r = await api.post('/api/ai/check')
toast(r.message || (r.ok ? t('settings.podklyuchenie-uspeshno') : t('settings.ne-udalos-podklyuchitsya')), {
icon: r.ok ? 'check' : 'x',
})
} catch (e) {
toast(errMsg(e), { icon: 'x' })
}
}
export async function saveAiSettings() {
const p = aiProviderMeta(state.aiProvider)
const patch = aiCfgPatch()
if (!patch) return
try {
await api.patch('/api/settings', patch)
const pub = await api.get('/api/settings')
applySettings(pub, { keys: Object.keys(patch) })
toast(t('settings.aiSaved', { name: p?.name }), { icon: 'check' })
} catch (e) {
toast(errMsg(e), { icon: 'x' })
}
}
// ─── AI: промпты ─────────────────────────────────────────────────────────
export async function saveAiPrompt() {
try {
@@ -0,0 +1,151 @@
<script setup>
import { t } from '@/i18n/index.js'
// Раздел «ИИ» консоли: глобальная конфигурация провайдера (GET/PUT
// /api/operator/settings/ai-config, POST .../check). Провайдер, модель и ключ —
// общие для всех пользователей; ключ хранится зашифрованным и наружу приходит
// только маской (в поле ввода не подставляется).
import { computed, onMounted, ref, watch } from 'vue'
import { checkAiConfig, loadAiConfig, op, saveAiConfig } from '../../store/operator.js'
import Icon from '../../components/Icon.vue'
import Badge from '../../components/ui/Badge.vue'
import Button from '../../components/ui/Button.vue'
import Card from '../../components/ui/Card.vue'
import SelectInput from '../../components/ui/SelectInput.vue'
import TextInput from '../../components/ui/TextInput.vue'
import SectionLayout from '../../components/operator/SectionLayout.vue'
// Id провайдера «Другой (OpenAI-совместимый)»: и адрес, и модель задаёт оператор.
const CUSTOM_PROVIDER_ID = 'custom'
const form = ref({ providerId: '', baseUrl: '', model: '', apiKey: '' })
const saving = ref(false)
const checking = ref(false)
const providers = computed(() => op.aiConfig?.providers || [])
const providerOptions = computed(() =>
providers.value.map((p) => ({ value: p.id, label: p.local ? `${p.name} (${t('operator.lokalno')})` : p.name })),
)
const provider = computed(() => providers.value.find((p) => p.id === form.value.providerId) || null)
// Адрес API вводится только для локальных провайдеров и «Другого» — у каталогных он фиксирован.
const baseUrlEditable = computed(() => !!provider.value && (provider.value.local || provider.value.id === CUSTOM_PROVIDER_ID))
// Модель обязательна, когда у провайдера нет моделей каталога.
const modelRequired = computed(() => provider.value?.id === CUSTOM_PROVIDER_ID)
onMounted(loadAiConfig)
watch(
() => op.aiConfig,
(snapshot) => {
if (!snapshot) return
form.value = {
providerId: snapshot.providerId || '',
baseUrl: snapshot.baseUrl || '',
model: snapshot.model || '',
apiKey: '',
}
},
{ immediate: true },
)
// Сборка PATCH-тела: непереданные поля сервер оставляет без изменений.
function payload() {
const body = { providerId: form.value.providerId }
if (baseUrlEditable.value) body.baseUrl = form.value.baseUrl.trim()
const model = form.value.model.trim()
if (model) body.model = model
const apiKey = form.value.apiKey.trim()
if (apiKey) body.apiKey = apiKey
return body
}
async function submit() {
if (saving.value || !form.value.providerId) return
saving.value = true
const saved = await saveAiConfig(payload())
saving.value = false
if (saved) form.value.apiKey = ''
}
async function check() {
if (checking.value) return
checking.value = true
await checkAiConfig()
checking.value = false
}
</script>
<template>
<SectionLayout :title="$t('operator.ii')" :desc="$t('operator.ii-desc')">
<template #actions>
<Badge :tone="op.aiConfig?.keySet ? 'ok' : 'danger'" dot>
{{ op.aiConfig?.keySet ? $t('operator.klyuch-zadan') : $t('operator.klyuch-ne-zadan') }}
</Badge>
<Button variant="subtle" size="sm" :loading="op.loading.aiConfig" @click="loadAiConfig">
<Icon name="refresh" :size="14" />
{{ $t('common.obnovit') }}
</Button>
</template>
<div class="max-w-xl">
<Card :title="$t('operator.globalnyj-provajder-ii')" :desc="$t('operator.globalnyj-provajder-ii-desc')">
<div
v-if="op.aiConfig"
class="rounded-lg border border-white/8 bg-ink/40 px-3.5 py-3 mb-4 space-y-1.5"
>
<div class="flex items-center gap-3 flex-wrap">
<Badge :tone="op.aiConfig.keySet ? 'ok' : 'danger'" dot>
{{ op.aiConfig.keySet ? $t('operator.klyuch-zadan') : $t('operator.klyuch-ne-zadan') }}
</Badge>
<span v-if="op.aiConfig.providerId" class="font-mono text-[11.5px] text-mid">
{{ op.aiConfig.providerId }}
</span>
<span v-if="op.aiConfig.model" class="font-mono text-[11.5px] text-mid">{{ op.aiConfig.model }}</span>
<span v-if="op.aiConfig.keySet" class="font-mono text-[11.5px] text-mid">
{{ op.aiConfig.keyMasked }}
</span>
</div>
<p class="text-[11.5px] text-low leading-relaxed">{{ $t('operator.konfiguraciya-dejstvuet-dlya-vseh') }}</p>
</div>
<form class="space-y-3.5" @submit.prevent="submit">
<SelectInput
v-model="form.providerId"
:label="$t('operator.provajder')"
:options="providerOptions"
:placeholder="$t('operator.vyberite-provajdera')"
/>
<TextInput
v-if="baseUrlEditable"
v-model="form.baseUrl"
label="Base URL"
mono
:placeholder="provider?.base || 'https://'"
:hint="$t('operator.base-url-podskazka')"
/>
<TextInput
v-model="form.model"
:label="$t('operator.model')"
mono
:placeholder="provider?.models?.[0] || $t('operator.vvedite-model')"
:hint="modelRequired ? $t('operator.model-obyazatelna-dlya-drugogo') : $t('operator.model-podskazka')"
/>
<TextInput
v-model="form.apiKey"
:label="$t('operator.api-klyuch')"
type="password"
autocomplete="new-password"
:placeholder="op.aiConfig?.keyMasked || 'sk-…'"
:hint="$t('operator.api-klyuch-hint')"
/>
<div class="flex items-center gap-2 pt-1 flex-wrap">
<Button type="submit" variant="primary" :loading="saving">{{ $t('operator.sohranit-konfiguraciyu') }}</Button>
<Button variant="subtle" :loading="checking" @click="check">
<Icon name="check" :size="14" />
{{ $t('operator.proverit-svyaz') }}
</Button>
</div>
</form>
</Card>
</div>
</SectionLayout>
</template>
@@ -18,6 +18,7 @@ import AuditSection from './AuditSection.vue'
import AnalyticsSection from './AnalyticsSection.vue'
import HealthSection from './HealthSection.vue'
import TelegramSection from './TelegramSection.vue'
import AiSection from './AiSection.vue'
const SECTIONS = [
{ id: 'tenants', name: t('operator.tenanty'), icon: 'users', component: TenantsSection },
@@ -27,6 +28,7 @@ const SECTIONS = [
{ id: 'analytics', name: t('operator.analitika'), icon: 'activity', component: AnalyticsSection },
{ id: 'health', name: t('operator.sostoyanie-sistemy'), icon: 'check', component: HealthSection },
{ id: 'telegram', name: t('operator.telegram'), icon: 'key', component: TelegramSection },
{ id: 'ai', name: t('operator.ii'), icon: 'sparkles', component: AiSection },
]
const activeId = computed(() => {