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/{dialog_id}/backfill` | Догнать сообщения одного диалога | — | `{ok: true, processed: int}` *(фронт не вызывает — только сервер)* |
| `POST /dialogs/preview` | Последние сообщения диалога (свежие из TG, старые из БД) | `{dialogId, limit?=24 (clamp 1..50)}` | `{items: [§4.11 сообщение]}` | | `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 | | METHOD /api/… | Назначение | Request body | Response |
|---|---|---|---| |---|---|---|---|
| `GET /settings` | Публичные настройки (секреты замаскированы) | — | §4.6 (полный settings) | | `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 | | `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 |
| `POST /ai/check` | Проверка подключения AI-провайдера | — | `{ok: bool, message: str}` + поля статуса провайдера |
| `GET /rates` | Курсы валют | — | `{base: "RUB", rates: {CODE: num}, source: "cbr"\|"mock", updatedAt: ms\|null}` | | `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` | | `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)* | | `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 ### 3.5 Детальные операции карточки (бывший Projects, projects_routes.py) — 15
Все операции — над ресурсом `/api/cards/{cardId}` (см. §4.1); отдельного `/api/projects` больше нет. Все операции — над ресурсом `/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, "discJoinLimit": 50, "discJoinDelayMin": 50, "discJoinDelayMax": 70,
"discEvalSample": 10, "discEvalThreshold": 40, // (не используется фронтом) "discEvalSample": 10, "discEvalThreshold": 40, // (не используется фронтом)
"discPaused": false, "colState": {}, // colState — то же, что GET /columns/state "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 (по одному/группами): `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 — **полный** 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`). ⚠ **Изменение (решение владельца, вариант 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 Промпты ### 4.7 Промпты
- `aiPrompt`, `aiFilterPrompt`, `cardPrompt` — plain string, редактируются на вкладке ИИ; содержат плейсхолдеры `{domain}`/`{keywords}`. - `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}]}`. `{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` — этап выключен Коды `stage`: `length|stop|resume|type|exclude|ml|ai|spam_ai|budget`; `skipped=true` — этап выключен
настройкой. `parsed` — разбор текста (поля карточки) либо null. Запись в систему не производится. настройкой. `parsed` — разбор текста (поля карточки) либо null. Запись в систему не производится.
- **`POST /api/ai/check`**: `{ok: bool, message: string, local?, keySet?}`.
- Комментарии карточки: `{id, by: string, text, time: string}` — `by` всегда «Вы», `time` «только что». - Комментарии карточки: `{id, by: string, text, time: string}` — `by` всегда «Вы», `time` «только что».
--- ---
@@ -400,7 +399,7 @@ links/files/history/tzText/reminder` — модули; `isNew/prevCol/isVacancy/
|---|---:| |---|---:|
| Auth `/api/auth` | 4 | | Auth `/api/auth` | 4 |
| Telegram `/api/tg` | 14 | | 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 | | Processing `/api/pipeline` (+ `/api/admin/check-message`) | 7 |
| ML `/api/ml` | 5 | | ML `/api/ml` | 5 |
| Discovery `/api/discovery` | 13 | | Discovery `/api/discovery` | 13 |
@@ -268,3 +268,73 @@
> Примечание для вкладки Telegram у тенанта: `GET /api/tg/status` остаётся (подключение аккаунта), > Примечание для вкладки Telegram у тенанта: `GET /api/tg/status` остаётся (подключение аккаунта),
> поле `keysSet` отражает глобальные ключи; команды `start-phone`/`start-qr` без ключей отвечают > поле `keysSet` отражает глобальные ключи; команды `start-phone`/`start-qr` без ключей отвечают
> `400 { "detail": "Ключи Telegram не заданы оператором" }`. > `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. Настройки тенанта ## 8. Настройки тенанта
- Telegram: ключи приложения (оператор), подключение аккаунта, авто-мониторинг новых. - Telegram: ключи приложения (оператор), подключение аккаунта, авто-мониторинг новых.
- ИИ: провайдер (один; включая локальные), модель, ключ (хранится зашифрованно), - ИИ: провайдер (один; включая локальные), модель и ключ задаёт **оператор** глобально (едины для всех
промпты (базовый + свой), библиотека готовых промптов по сферам + «мои промпты», тенантов; в консоли оператора раздел «ИИ», ключ хранится зашифрованно); пользователю — промпты
вкл/выкл ИИ, вкл/выкл ИИ-фильтр. (базовый + свой), библиотека готовых промптов по сферам + «мои промпты», вкл/выкл ИИ, вкл/выкл ИИ-фильтр.
- ML: вкл/выкл, обучение на действиях, проверка на сообщении/канале, сброс, самооценка - ML: вкл/выкл, обучение на действиях, проверка на сообщении/канале, сброс, самооценка
(«ML справляется с последними N сообщениями — ИИ можно отключить»). («ML справляется с последними N сообщениями — ИИ можно отключить»).
- Обработка: стоп-фразы, длина, резюме, тип заявки, домен/ключи, маркеры найма/заказа. - Обработка: стоп-фразы, длина, резюме, тип заявки, домен/ключи, маркеры найма/заказа.
@@ -219,6 +219,8 @@ Telegram-аккаунт, выбирает каналы/группы для мо
## 10. Админка оператора ## 10. Админка оператора
- Тенанты: создание, инвайты, статус, лимиты/бюджеты, приостановка. - Тенанты: создание, инвайты, статус, лимиты/бюджеты, приостановка.
- Глобальные настройки сервиса: ключи приложения Telegram и конфигурация ИИ-провайдера
(провайдер/модель/baseUrl/ключ; ключ зашифрован, наружу — маска) с проверкой связи.
- Health всех сервисов и очередей. - Health всех сервисов и очередей.
- Аудит: входы/выходы, инвайты, impersonation, действия оператора и пользователей тенанта - Аудит: входы/выходы, инвайты, impersonation, действия оператора и пользователей тенанта
(создание/перенос/удаление карточек, комментарии, контейнеры, настройки, каналы). (создание/перенос/удаление карточек, комментарии, контейнеры, настройки, каналы).
@@ -119,8 +119,10 @@ global_settings(key, value, updated_at)
> `token_usage_events` — история расхода токенов (этап 10, T2; подробнее — §13.10). > `token_usage_events` — история расхода токенов (этап 10, T2; подробнее — §13.10).
> `global_settings` — глобальные настройки уровня сервиса; сейчас хранит ключи приложения Telegram > `global_settings` — глобальные настройки уровня сервиса; сейчас хранит ключи приложения Telegram
> (`telegramKeys`: `api_id`/`api_hash`, hash — в `enc:`), которые задаёт **оператор** глобально > (`telegramKeys`: `api_id`/`api_hash`, hash — в `enc:`) и конфигурацию ИИ-провайдера (`aiConfig`:
> (ручки `GET/PUT /api/operator/settings/telegram-keys`); тенант ключи не видит/не задаёт. > `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`) и их > Операторские таблицы этапа 7 (`operators`, `operator_sessions`, `tenant_limits`, `audit_log`) и их
> контур описаны в §13.8. С этапа 12 счётчики распределённого rate-limit и попыток входа — > контур описаны в §13.8. С этапа 12 счётчики распределённого rate-limit и попыток входа —
> `public.rate_limit_counters` (см. §10). > `public.rate_limit_counters` (см. §10).
@@ -496,7 +498,8 @@ DEAL_MTLS_ENABLED=0|1 DEAL_MTLS_CERT_PASSWORD=... DEAL_DEFAULT_AI_BU
ответ — полный снимок; секреты наружу только масками `keyMasked`/`apiId`; внутренние ключи ответ — полный снимок; секреты наружу только масками `keyMasked`/`apiId`; внутренние ключи
`ratesCache`/`mlDecisions`/`aiDecisions` не публикуются); `ratesCache`/`mlDecisions`/`aiDecisions` не публикуются);
- шифрование секретов AI/Telegram: AES-256-GCM, в БД — `enc:` + Base64 (ключ — env/file, см. §13.4a); - шифрование секретов 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`/ЦБ); - курсы валют: `GET /api/rates`, `POST /api/rates/refresh` (кэш `ratesCache` в settings; `mock`/ЦБ);
- ML-панель на детерминированной заглушке: `GET /api/ml/status`, `POST /api/ml/reset|predict` - ML-панель на детерминированной заглушке: `GET /api/ml/status`, `POST /api/ml/reset|predict`
(candidates → `{items:[]}`, apply → 404 — нет telegram-данных до этапа 6); (candidates → `{items:[]}`, apply → 404 — нет telegram-данных до этапа 6);
@@ -774,28 +777,30 @@ health — `GET /api/health` → `{"ok":true,"service":"deal"}`.
### 4a. Шифрование секретов настроек (ключи AI/Telegram) ### 4a. Шифрование секретов настроек (ключи AI/Telegram)
Секреты (`aiConfigs[].apiKey`) хранятся в `settings.ValueJson` шифротекстом: Секреты хранятся шифротекстом `enc:` + Base64(nonce‖ct‖tag), AES-256-GCM (nonce 12 Б, tag 16 Б).
`enc:` + Base64(nonce‖ct‖tag), AES-256-GCM (nonce 12 Б, tag 16 Б). Ключ шифрования — env Ключ шифрования — env `DEAL_ENCRYPTION_KEY` (32 байта в urlsafe-Base64); при отсутствии в dev
`DEAL_ENCRYPTION_KEY` (32 байта в urlsafe-Base64); при отсутствии в dev берётся/создаётся файл берётся/создаётся файл `<ContentRoot>/data/encryption.key` (путь переопределяется env
`<ContentRoot>/data/encryption.key` (путь переопределяется env `DEAL_ENCRYPTION_KEY_FILE`) — `DEAL_ENCRYPTION_KEY_FILE`) — при генерации лог-warning. Невалидный env-ключ — ошибка при старте.
при генерации лог-warning. Невалидный env-ключ — ошибка при старте. Наружу секреты не отдаются: Наружу секреты не отдаются: только маски `keyMasked` (первые 4 + «…» + последние 4, len≤8 — как есть)
в GET/PATCH `/api/settings` только маски `keyMasked` (первые 4 + «…» + последние 4, len≤8 — как есть)
и `keySet`. и `keySet`.
> Исторический раздел (этап 2). С этапа 12 ключей Telegram (`tgKeys`/`apiId`/`apiHash`) в настройках > Исторический раздел (этап 2). С этапа 12 ключей Telegram (`tgKeys`/`apiId`/`apiHash`) в настройках
> тенанта нет — они задаются **оператором** глобально (таблица `public.global_settings`, > тенанта нет — они задаются **оператором** глобально (таблица `public.global_settings`,
> `GET/PUT /api/operator/settings/telegram-keys`; hash шифруется тем же AES-256-GCM). > `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) ### 4b. Эндпоинты этапа 2 (настройки тенанта; сессия `deal_session` обязательна, иначе 401)
- `GET /api/settings` — публичный снимок дерева настроек: дефолты модуля, перекрытые - `GET /api/settings` — публичный снимок дерева настроек: дефолты модуля, перекрытые
переопределениями из `settings` тенанта; включает списки `providers`/`aiConfigs`/`tgKeys`/`myPrompts`. переопределениями из `settings` тенанта; включает `myPrompts` и колонки, но не настройки
ИИ-провайдера (они операторские).
`PATCH /api/settings` — частичное обновление (невалидное поле мягко пропускается, ответ — полный `PATCH /api/settings` — частичное обновление (невалидное поле мягко пропускается, ответ — полный
снимок). Побочные эффекты: при `rateSource` — фоновый refresh курсов. Внутренние ключи снимок). Побочные эффекты: при `rateSource` — фоновый refresh курсов. Внутренние ключи
(`ratesCache`, `mlDecisions`, `aiDecisions`) в GET/PATCH не участвуют. (`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` - `GET /api/rates` / `POST /api/rates/refresh` — курсы к RUB (`base` = `RUB`); источник по `rateSource`
(`mock` — константа, `cbr` — ЦБ РФ, ≤4 запроса/сутки, интервал 6 ч; `USDT`=`USD`); кэш — внутренняя (`mock` — константа, `cbr` — ЦБ РФ, ≤4 запроса/сутки, интервал 6 ч; `USDT`=`USD`); кэш — внутренняя
настройка `ratesCache` `{rates, source, updatedAtMs}`. настройка `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-скан → (`PUT /api/operator/settings/telegram-keys`, hash шифруется) → `POST /api/tg/start-qr` → QR-скан →
фаза `ready` («Telegram подключён, сессия сохранена»), затем реальные диалоги/мониторинг/«Перечитать»/ фаза `ready` («Telegram подключён, сессия сохранена»), затем реальные диалоги/мониторинг/«Перечитать»/
discovery-поиск и вступления. В настройках тенанта ключей нет (решение владельца, вариант A). discovery-поиск и вступления. В настройках тенанта ключей нет (решение владельца, вариант A).
- LLM: `PATCH /api/settings` `aiConfigs`/`aiProvider` (напр. DeepSeek или локальный OpenAI-совместимый) → - LLM: оператор задаёт провайдера и модель в консоли (`PUT /api/operator/settings/ai-config`, напр. DeepSeek
`POST /api/ai/check`; реальная классификация/фильтр/генерация ключей при `Services__Ai__UseLocal=false`. или локальный OpenAI-совместимый) → `POST /api/operator/settings/ai-config/check`; реальная
классификация/фильтр/генерация ключей при `Services__Ai__UseLocal=false`.
- Сквозной smoke стека — `scripts/dev-smoke.sh` (одна команда; Docker Desktop должен быть поднят). - Сквозной smoke стека — `scripts/dev-smoke.sh` (одна команда; Docker Desktop должен быть поднят).
### 8. Этап 7 — SaaS-контур (Tasks 1–14; бэкапы — §13.9; финальные доки — Task 16): оператор/инвайты/лимиты/аудит/rate-limit/mTLS/логи/compose-prod ### 8. Этап 7 — SaaS-контур (Tasks 1–14; бэкапы — §13.9; финальные доки — Task 16): оператор/инвайты/лимиты/аудит/rate-limit/mTLS/логи/compose-prod
@@ -184,12 +184,13 @@
**Настройки → Telegram:** подключение аккаунта, авто-мониторинг новых чатов. **Настройки → Telegram:** подключение аккаунта, авто-мониторинг новых чатов.
**Настройки → ИИ:** **Настройки → ИИ:**
- провайдер и модель (можно выбрать один, включая локальные OpenAI-совместимые);
- ключ API (хранится зашифрованно);
- **промпты**: базовый (не меняется) + свой промпт; библиотека готовых промптов по сферам
с поиском и категориями; сохранённые свои промпты («Мои промпты»);
- вкл/выкл ИИ и ИИ-фильтр. Если ML уже уверенно обрабатывает поток — система подскажет, - вкл/выкл ИИ и ИИ-фильтр. Если ML уже уверенно обрабатывает поток — система подскажет,
что ИИ можно отключить. что ИИ можно отключить;
- **промпты**: базовый (не меняется) + свой промпт; библиотека готовых промптов по сферам
с поиском и категориями; сохранённые свои промпты («Мои промпты»).
Провайдера, модель и API-ключ задаёт оператор сервиса — они едины для всех пользователей
и в кабинете не настраиваются.
**Настройки → ML:** включение, обучение на ваших действиях, проверка модели на сообщении/канале, **Настройки → ML:** включение, обучение на ваших действиях, проверка модели на сообщении/канале,
сброс обучения, показатели самооценки. сброс обучения, показатели самооценки.
@@ -257,6 +258,9 @@
пространствам и лимитам) с фильтрами по типу события, актору, пространству и периоду; есть пагинация. пространствам и лимитам) с фильтрами по типу события, актору, пространству и периоду; есть пагинация.
- **Аналитика** — обзор за период (число пространств, расход токенов, входы/выходы/неудачные входы), - **Аналитика** — обзор за период (число пространств, расход токенов, входы/выходы/неудачные входы),
расход токенов с группировкой по дням/пространствам/провайдерам/моделям и лента действий. расход токенов с группировкой по дням/пространствам/провайдерам/моделям и лента действий.
- **ИИ** — глобальный провайдер ИИ: выбор провайдера из каталога, модель, адрес API (для локальных
и «Другого») и API-ключ, а также проверка связи. Конфигурация единая для всех пользователей;
ключ хранится зашифрованным и показывается только маской.
- **Состояние системы** — доступность ядра, базы данных и сервисов (Telegram, ИИ, ML). - **Состояние системы** — доступность ядра, базы данных и сервисов (Telegram, ИИ, ML).
> **Dev-окружение:** вход в консоль — `operator`/`operator`. В обычной (прод) сборке учётные > **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); 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>(); CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
CardDto unified = await cardsService.GetCardAsync(cardId, ct); CardDto unified = await cardsService.GetCardAsync(cardId, ct);
@@ -140,6 +140,7 @@ public static class ContainersEndpoints
} }
ContainersService containers = context.RequestServices.GetRequiredService<ContainersService>(); ContainersService containers = context.RequestServices.GetRequiredService<ContainersService>();
ContainerDto before = await containers.GetAsync(containerId, ct);
ContainerDto updated = await containers.PatchAsync( ContainerDto updated = await containers.PatchAsync(
containerId, containerId,
new ContainerPatchDto( new ContainerPatchDto(
@@ -153,7 +154,13 @@ public static class ContainersEndpoints
patchBody.Policy), patchBody.Policy),
ct); 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 }); return Results.Ok(new { id = updated.Id });
} }
@@ -135,6 +135,7 @@ public static class OperatorAnalyticsEndpoints
AnalyticsService analyticsService, AnalyticsService analyticsService,
ITenantRepository tenantRepository, ITenantRepository tenantRepository,
IAuthStore authStore, IAuthStore authStore,
IAuditReferenceResolver referenceResolver,
CancellationToken ct) CancellationToken ct)
{ {
if (context.GetCurrentOperator() is null) if (context.GetCurrentOperator() is null)
@@ -144,7 +145,7 @@ public static class OperatorAnalyticsEndpoints
AnalyticsActivityDto activity = await analyticsService.ActivityAsync( AnalyticsActivityDto activity = await analyticsService.ActivityAsync(
eventType, actorType, actorId, tenantId, from, to, limit, offset, ct); 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 }); 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, AuditService auditService,
ITenantRepository tenantRepository, ITenantRepository tenantRepository,
IAuthStore authStore, IAuthStore authStore,
IAuditReferenceResolver referenceResolver,
CancellationToken ct) CancellationToken ct)
{ {
var operatorIdentity = context.GetCurrentOperator(); var operatorIdentity = context.GetCurrentOperator();
@@ -54,7 +55,7 @@ public static class OperatorAuditEndpoints
eventType, actorType, tenantId, from, to, NormalizeLimit(limit), actorId, NormalizeOffset(offset)); eventType, actorType, tenantId, from, to, NormalizeLimit(limit), actorId, NormalizeOffset(offset));
IReadOnlyList<AuditRecordDto> items = await auditService.QueryAsync(filter, ct); IReadOnlyList<AuditRecordDto> items = await auditService.QueryAsync(filter, ct);
int total = await auditService.CountAsync(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 }); return Results.Ok(new { items = view, total });
} }
@@ -2,6 +2,9 @@ using Deal.Api.Endpoints.RequestModels;
using Deal.Api.Extensions; using Deal.Api.Extensions;
using Deal.Api.Services; using Deal.Api.Services;
using Deal.Api.Telegram; 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.Models;
using Deal.Modules.Tenants.Application.Services; using Deal.Modules.Tenants.Application.Services;
@@ -21,6 +24,9 @@ public static class OperatorSettingsEndpoints
// Относительный путь глобальных ключей Telegram (GET/PUT). // Относительный путь глобальных ключей Telegram (GET/PUT).
private const string TelegramKeysPath = "/telegram-keys"; private const string TelegramKeysPath = "/telegram-keys";
// Относительный путь глобальной конфигурации ИИ (GET/PUT/POST-check).
private const string AiConfigPath = "/ai-config";
// Текст 400: пустое тело PUT (ни одного поля). // Текст 400: пустое тело PUT (ни одного поля).
private const string EmptyBodyDetail = "Укажите api_id и api_hash"; private const string EmptyBodyDetail = "Укажите api_id и api_hash";
@@ -33,6 +39,24 @@ public static class OperatorSettingsEndpoints
// Текст 400: api_hash пустой/маска/с префиксом enc:. // Текст 400: api_hash пустой/маска/с префиксом enc:.
private const string InvalidApiHashDetail = "Укажите непустой api_hash"; 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> /// <summary>
/// Регистрирует группу /api/operator/settings /// Регистрирует группу /api/operator/settings
/// </summary> /// </summary>
@@ -43,6 +67,9 @@ public static class OperatorSettingsEndpoints
var group = app.MapGroup(SettingsGroupPrefix).WithTags(SettingsOpenApiTag); var group = app.MapGroup(SettingsGroupPrefix).WithTags(SettingsOpenApiTag);
group.MapGet(TelegramKeysPath, GetTelegramKeysAsync); group.MapGet(TelegramKeysPath, GetTelegramKeysAsync);
group.MapPut(TelegramKeysPath, PutTelegramKeysAsync); group.MapPut(TelegramKeysPath, PutTelegramKeysAsync);
group.MapGet(AiConfigPath, GetAiConfigAsync);
group.MapPut(AiConfigPath, PutAiConfigAsync);
group.MapPost(AiConfigPath + "/check", CheckAiConfigAsync);
return app; return app;
} }
@@ -135,6 +162,152 @@ public static class OperatorSettingsEndpoints
return Results.Ok(snapshot); 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, если недоступен). // IP-адрес клиента для аудита (без порта; null, если недоступен).
// context: Контекст запроса. // context: Контекст запроса.
// Возвращает: Строковое представление IP или null. // Возвращает: Строковое представление 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.Models;
using Deal.Modules.Settings.Application.Services; using Deal.Modules.Settings.Application.Services;
using Deal.Modules.Tenants.Application.Models; using Deal.Modules.Tenants.Application.Models;
using Deal.SharedKernel.Utilities;
namespace Deal.Api.Endpoints; namespace Deal.Api.Endpoints;
@@ -18,6 +19,15 @@ public static class SettingsEndpoints
private const string SettingsOpenApiTag = "settings"; private const string SettingsOpenApiTag = "settings";
private const string InvalidBodyDetail = "Тело запроса должно быть JSON-объектом"; 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> /// <summary>
/// Регистрирует GET/PATCH /api/settings. /// Регистрирует GET/PATCH /api/settings.
/// </summary> /// </summary>
@@ -72,9 +82,10 @@ public static class SettingsEndpoints
} }
SettingsService settingsService = context.RequestServices.GetRequiredService<SettingsService>(); SettingsService settingsService = context.RequestServices.GetRequiredService<SettingsService>();
PublicSettingsDto before = await settingsService.GetPublicAsync(ct);
PublicSettingsDto result = await settingsService.ApplyPatchAsync(body, 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)) if (ShouldScheduleRatesRefresh(body))
{ {
@@ -84,6 +95,60 @@ public static class SettingsEndpoints
return Results.Ok(result); 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) private static bool ShouldScheduleRatesRefresh(Dictionary<string, JsonElement> body)
{ {
if (!body.TryGetValue(SettingsKeys.RateSource, out JsonElement element)) 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.Pipeline.Application.Registrars;
using Deal.Modules.Settings.Application.Abstractions; using Deal.Modules.Settings.Application.Abstractions;
using Deal.Modules.Settings.Application.Registrars; using Deal.Modules.Settings.Application.Registrars;
using Deal.Modules.Settings.Application.Services;
using Deal.Modules.Telegram.Application; using Deal.Modules.Telegram.Application;
using Deal.Modules.Tenants.Application.Models; using Deal.Modules.Tenants.Application.Models;
using Deal.Modules.Tenants.Application.Registrars; using Deal.Modules.Tenants.Application.Registrars;
@@ -160,11 +161,14 @@ builder.Services.AddDiscoveryModule();
builder.Services.AddScoped<TgStatusService>(); builder.Services.AddScoped<TgStatusService>();
builder.Services.AddScoped<TelegramKeysService>(); builder.Services.AddScoped<TelegramKeysService>();
builder.Services.AddScoped<AiGlobalConfigService>();
builder.Services.AddSingleton<TelegramBackfillScheduler>(); builder.Services.AddSingleton<TelegramBackfillScheduler>();
builder.Services.AddScoped<AdminTickOrchestrator>(); builder.Services.AddScoped<AdminTickOrchestrator>();
builder.Services.AddScoped<IAuditReferenceResolver, AuditReferenceResolver>();
builder.Services.AddScoped<FtsMaintenance>(); builder.Services.AddScoped<FtsMaintenance>();
builder.Services.AddSingleton<SseBroker>(); builder.Services.AddSingleton<SseBroker>();
@@ -354,7 +358,6 @@ app.MapOperatorSettingsEndpoints();
app.MapOperatorMaintenanceEndpoints(); app.MapOperatorMaintenanceEndpoints();
app.MapJoinEndpoint(); app.MapJoinEndpoint();
app.MapSettingsEndpoints(); app.MapSettingsEndpoints();
app.MapAiCheckEndpoint();
app.MapRatesEndpoints(); app.MapRatesEndpoints();
app.MapMlEndpoints(); app.MapMlEndpoints();
app.MapFilterTesterEndpoints(); 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="records">Записи аудита.</param>
/// <param name="tenantRepository">Реестр пространств для разрешения имён.</param> /// <param name="tenantRepository">Реестр пространств для разрешения имён.</param>
/// <param name="authStore">Хранилище пользователей для разрешения логинов владельцев.</param> /// <param name="authStore">Хранилище пользователей для разрешения логинов владельцев.</param>
/// <param name="referenceResolver">Резолвер ссылок на карточки и колонки.</param>
/// <param name="ct">Токен отмены.</param> /// <param name="ct">Токен отмены.</param>
/// <returns>Записи для чтения оператором.</returns> /// <returns>Записи для чтения оператором.</returns>
public static async Task<IReadOnlyList<AuditRecordViewDto>> ProjectAsync( public static async Task<IReadOnlyList<AuditRecordViewDto>> ProjectAsync(
IReadOnlyList<AuditRecordDto> records, IReadOnlyList<AuditRecordDto> records,
ITenantRepository tenantRepository, ITenantRepository tenantRepository,
IAuthStore authStore, IAuthStore authStore,
IAuditReferenceResolver referenceResolver,
CancellationToken ct) CancellationToken ct)
{ {
Guid[] tenantIds = DistinctTenantIds(records); Guid[] tenantIds = DistinctTenantIds(records);
IReadOnlyDictionary<Guid, string> names = await ResolveNamesAsync(tenantIds, tenantRepository, ct); IReadOnlyDictionary<Guid, string> names = await ResolveNamesAsync(tenantIds, tenantRepository, ct);
IReadOnlyDictionary<Guid, string> owners = await ResolveOwnerLoginsAsync(tenantIds, authStore, 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); var view = new List<AuditRecordViewDto>(records.Count);
foreach (AuditRecordDto record in records) foreach (AuditRecordDto record in records)
@@ -40,7 +43,7 @@ public static class AuditViewFactory
ResolveUserName(record, owners), ResolveUserName(record, owners),
tenantName, tenantName,
record.Ip, record.Ip,
record.AuditChanges(), ResolveChanges(record, references),
record.DetailJson, record.DetailJson,
record.At, record.At,
record.Id)); record.Id));
@@ -49,6 +52,47 @@ public static class AuditViewFactory
return view; 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 из деталей события. // Логин реального пользователя: владелец пространства, иначе логин/email из деталей события.
// record: Запись аудита. // record: Запись аудита.
// owners: Логины владельцев по идентификаторам пространств. // 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; string name = meta?.Name ?? request.ProviderId;
// SSRF-гейт (allowlist, preflight): проверка возможна только для провайдера фиксированного // SSRF-гейт (allowlist, preflight): проверка возможна только для провайдера фиксированного
// каталога AiProviders. В штатном потоке недостижимо (PATCH-гейт aiProvider/aiConfigs в // каталога AiProviders. В штатном потоке недостижимо (конфигурацию задаёт оператор из
// SettingsService) — защита от ручного изменения БД/повреждённого хранилища. // каталога через OperatorSettingsEndpoints) — защита от ручного изменения БД.
if (meta is null) if (meta is null)
{ {
return BuildResult(request, name, ok: false, message: ProviderNotAllowedMessage); return BuildResult(request, name, ok: false, message: ProviderNotAllowedMessage);
@@ -1,150 +1,49 @@
using System.Text.Json.Nodes;
using Deal.Grpc.Ai; 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.Models;
using Deal.Modules.Settings.Application.Services;
namespace Deal.Infrastructure.Integrations.Services; namespace Deal.Infrastructure.Integrations.Services;
/// <summary> /// <summary>
/// Собирает конфиг активного ИИ-провайдера для запросов ai-service. /// Собирает конфиг ИИ-провайдера для запросов ai-service из глобальной конфигурации
/// оператора (общая для всех тенантов; провайдера, модель и ключ задаёт оператор).
/// </summary> /// </summary>
public sealed class AiProviderConfigBuilder /// <param name="configService">Сервис глобальной конфигурации ИИ (ключ aiConfig).</param>
public sealed class AiProviderConfigBuilder(AiGlobalConfigService configService)
{ {
// Ключ aiConfigs: поле apiKey переопределения провайдера. // Текст AiUnavailableException, когда оператор ещё не сохранил конфигурацию ИИ.
private const string ApiKeyField = "apiKey"; private const string AiNotConfiguredMessage = "ИИ не настроен оператором системы";
// Ключ 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;
/// <summary> /// <summary>
/// Создаёт сборщик конфига провайдера. /// Собирает ProviderConfig для тела запроса ai-service
/// </summary> /// </summary>
/// <param name="store">KV-хранилище настроек тенанта (aiProvider/aiConfigs).</param> /// <returns>Конфиг: provider_id/base/model/api_key/api_style (см. ai.proto).</returns>
/// <param name="secretCipher">Расшифровка секрета aiConfigs.apiKey.</param> /// <exception cref="AiUnavailableException">Конфигурация ИИ не задана оператором.</exception>
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>
public async Task<ProviderConfig> BuildAsync(CancellationToken ct) public async Task<ProviderConfig> BuildAsync(CancellationToken ct)
{ {
string providerId = await ReadProviderIdAsync(ct); AiGlobalConfigSnapshot snapshot = await configService.GetSnapshotAsync(ct);
AiProviderDefinition meta = AiProviders.All.FirstOrDefault(provider => provider.Id == providerId) if (!snapshot.Configured)
?? 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))
{ {
apiKey = _secretCipher.Decrypt(apiKey); throw new AiUnavailableException(AiNotConfiguredMessage);
}
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]
} }
var config = new ProviderConfig var config = new ProviderConfig
{ {
ProviderId = meta.Id, ProviderId = snapshot.ProviderId,
BaseUrl = baseUrl, BaseUrl = snapshot.BaseUrl,
Model = model, 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; config.ApiStyle = apiStyle;
} }
return config; 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) CardResultDto result = CardsDefaultContainers.Contains(toContainerId)
? await cardsService.MoveStageCardAsync(cardId, toContainerId, ct) ? await cardsService.MoveStageCardAsync(cardId, toContainerId, ct)
: await cardsService.MoveDashboardCardAsync(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"/>. /// Результат перехода карточки единым механизмом <see cref="ICardMover"/>.
/// </summary> /// </summary>
/// <param name="Error">Текст 400-ошибки либо null (успех).</param> /// <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> /// <summary>
/// Данные проверки подключения AI-провайдера. /// Данные проверки подключения AI-провайдера.
/// </summary> /// </summary>
/// <param name="ProviderId">Идентификатор провайдера (id из каталога <c>AiProviders</c>, ключ в aiConfigs).</param> /// <param name="ProviderId">Идентификатор провайдера (id из каталога <c>AiProviders</c>).</param>
/// <param name="BaseUrl">Эффективный базовый URL: переопределение aiConfigs или дефолт каталога.</param> /// <param name="BaseUrl">Эффективный базовый URL провайдера.</param>
/// <param name="Model">Активная модель: переопределение aiConfigs или первая модель каталога.</param> /// <param name="Model">Активная модель.</param>
/// <param name="ApiKey">API-ключ открытым текстом (пустая строка — ключ не задан).</param> /// <param name="ApiKey">API-ключ открытым текстом (пустая строка — ключ не задан).</param>
/// <param name="IsLocal">True — локальный сервер (Ollama/LM Studio): ключ не требуется, HTTP не выполняется.</param> /// <param name="IsLocal">True — локальный сервер (Ollama/LM Studio): ключ не требуется, HTTP не выполняется.</param>
/// <param name="ApiStyle">Стиль API: null — OpenAI-совместимый, <c>"anthropic"</c> — Messages API.</param> /// <param name="ApiStyle">Стиль API: null — OpenAI-совместимый, <c>"anthropic"</c> — Messages API.</param>
@@ -1,7 +1,7 @@
namespace Deal.Modules.Settings.Application.Models; namespace Deal.Modules.Settings.Application.Models;
/// <summary> /// <summary>
/// Результат проверки подключения AI-провайдера — тело POST /api/ai/check. /// Результат проверки подключения AI-провайдера — тело проверки связи операторской конфигурации ИИ.
/// </summary> /// </summary>
/// <param name="Ok">True — подключение успешно (включая локальные серверы).</param> /// <param name="Ok">True — подключение успешно (включая локальные серверы).</param>
/// <param name="Message">Сообщение для UI.</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 static class GlobalSettingsKeys
{ {
public const string TelegramKeys = "telegramKeys"; 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> /// </summary>
public IReadOnlyDictionary<string, object?> ColState { get; init; } = public IReadOnlyDictionary<string, object?> ColState { get; init; } =
new Dictionary<string, object?>(); 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> /// </summary>
MyPrompts, MyPrompts,
/// <summary>
/// Конфигурации AI-провайдеров с ключами
/// </summary>
AiConfigs,
/// <summary> /// <summary>
/// Внутренний (непубличный) ключ /// Внутренний (непубличный) ключ
/// </summary> /// </summary>
@@ -207,18 +207,6 @@ public static class SettingsDefaults
/// </summary> /// </summary>
public static readonly IReadOnlyDictionary<string, object?> ColState = new Dictionary<string, object?>(); 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 ── // ── Telegram / Discovery ──
/// <summary> /// <summary>
@@ -255,16 +243,4 @@ public static class SettingsDefaults
/// Дефолт «discPaused» /// Дефолт «discPaused»
/// </summary> /// </summary>
public const bool DiscPaused = false; 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 RateSource = "rateSource";
public const string AiProvider = "aiProvider";
public const string AiPrompt = "aiPrompt"; public const string AiPrompt = "aiPrompt";
public const string AiFilterPrompt = "aiFilterPrompt"; public const string AiFilterPrompt = "aiFilterPrompt";
@@ -147,8 +145,6 @@ public static class SettingsKeys
public const string MyPrompts = "myPrompts"; public const string MyPrompts = "myPrompts";
public const string AiConfigs = "aiConfigs";
/// <summary> /// <summary>
/// Каталог публичных ключей /// Каталог публичных ключей
/// </summary> /// </summary>
@@ -181,7 +177,6 @@ public static class SettingsKeys
// String // String
[TargetCurrency] = SettingKind.String, [TargetCurrency] = SettingKind.String,
[RateSource] = SettingKind.String, [RateSource] = SettingKind.String,
[AiProvider] = SettingKind.String,
[AiPrompt] = SettingKind.String, [AiPrompt] = SettingKind.String,
[AiFilterPrompt] = SettingKind.String, [AiFilterPrompt] = SettingKind.String,
[CardPrompt] = SettingKind.String, [CardPrompt] = SettingKind.String,
@@ -202,7 +197,6 @@ public static class SettingsKeys
[ColState] = SettingKind.Dict, [ColState] = SettingKind.Dict,
// special // special
[MyPrompts] = SettingKind.MyPrompts, [MyPrompts] = SettingKind.MyPrompts,
[AiConfigs] = SettingKind.AiConfigs,
}; };
/// <summary> /// <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); writes[key] = JsonSerializer.Serialize(number);
} }
// Применяет строковый ключ: targetCurrency — Upper; aiProvider — только из каталога провайдеров. // Применяет строковый ключ: targetCurrency — Upper.
// key: Имя ключа. // key: Имя ключа.
// value: JSON-значение из тела PATCH. // value: JSON-значение из тела PATCH.
// writes: Накопитель записей (key → valueJson). // writes: Накопитель записей (key → valueJson).
@@ -111,11 +111,6 @@ public sealed partial class SettingsService
text = text.ToUpperInvariant(); text = text.ToUpperInvariant();
} }
if (key == SettingsKeys.AiProvider && !ProviderIds.Contains(text))
{
return; // aiProvider вне списка провайдеров — пропуск (L134–135)
}
writes[key] = JsonSerializer.Serialize(text); 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); 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: Сохранённые переопределения. // overrides: Сохранённые переопределения.
// Возвращает: Список промптов из хранилища или дефолт (пустой). // Возвращает: Список промптов из хранилища или дефолт (пустой).
@@ -8,30 +8,14 @@ namespace Deal.Modules.Settings.Application.Services;
/// Сервис настроек тенанта /// Сервис настроек тенанта
/// </summary> /// </summary>
/// <param name="store">KV-хранилище настроек тенанта (таблица settings).</param> /// <param name="store">KV-хранилище настроек тенанта (таблица settings).</param>
/// <param name="secretCipher">Шифр секретов.</param>
/// <param name="listeners">Слушатели смены настроек конверсии; пусто — no-op.</param> /// <param name="listeners">Слушатели смены настроек конверсии; пусто — no-op.</param>
public sealed partial class SettingsService( public sealed partial class SettingsService(
ISettingsStore store, ISettingsStore store,
ISecretCipher secretCipher,
IEnumerable<IRatesChangedListener> listeners) 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). // Максимум элементов списка-настройки при записи из PATCH (срез 200).
private const int MaxListItems = 200; private const int MaxListItems = 200;
// Минимальная длина нового API-ключа, который принимает PATCH (≥8 симв.).
private const int ApiKeyMinLength = 8;
// Максимум элементов «Моих промптов» при записи из PATCH (≤100). // Максимум элементов «Моих промптов» при записи из PATCH (≤100).
private const int MaxMyPrompts = 100; private const int MaxMyPrompts = 100;
@@ -65,16 +49,6 @@ public sealed partial class SettingsService(
[SettingsKeys.DiscEvalThreshold] = (1, 100), [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() private static readonly JsonSerializerOptions JsonOptions = new()
{ {
PropertyNamingPolicy = JsonNamingPolicy.CamelCase, PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
@@ -105,7 +79,6 @@ public sealed partial class SettingsService(
Dictionary<string, int> preparedDelays = PrepareDelayClamps(body, overrides); Dictionary<string, int> preparedDelays = PrepareDelayClamps(body, overrides);
var writes = new Dictionary<string, string>(StringComparer.Ordinal); var writes = new Dictionary<string, string>(StringComparer.Ordinal);
var removals = new List<string>();
foreach ((string key, JsonElement value) in body) foreach ((string key, JsonElement value) in body)
{ {
@@ -162,10 +135,6 @@ public sealed partial class SettingsService(
} }
break; 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); await store.SetAsync(key, valueJson, ct);
} }
foreach (string key in removals)
{
await store.RemoveAsync(key, ct);
}
if (AffectsConversion(body)) if (AffectsConversion(body))
{ {
await NotifyRatesChangedAsync(ct); await NotifyRatesChangedAsync(ct);
@@ -214,8 +178,6 @@ public sealed partial class SettingsService(
// Возвращает: DTO public-снимка. // Возвращает: DTO public-снимка.
private PublicSettingsDto BuildSnapshot(Dictionary<string, JsonElement> overrides) private PublicSettingsDto BuildSnapshot(Dictionary<string, JsonElement> overrides)
{ {
IReadOnlyDictionary<string, AiConfigSetting> aiConfigs = MergeAiConfigs(SettingsDefaults.AiConfigs, overrides);
var result = new PublicSettingsDto var result = new PublicSettingsDto
{ {
// Хранилище / обработка // Хранилище / обработка
@@ -269,11 +231,6 @@ public sealed partial class SettingsService(
DiscEvalThreshold = MergeInt(overrides, SettingsKeys.DiscEvalThreshold, SettingsDefaults.DiscEvalThreshold), DiscEvalThreshold = MergeInt(overrides, SettingsKeys.DiscEvalThreshold, SettingsDefaults.DiscEvalThreshold),
DiscPaused = MergeBool(overrides, SettingsKeys.DiscPaused, SettingsDefaults.DiscPaused), DiscPaused = MergeBool(overrides, SettingsKeys.DiscPaused, SettingsDefaults.DiscPaused),
ColState = MergeDict(overrides, SettingsKeys.ColState), 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; return result;
@@ -22,6 +22,14 @@ public static class AuditRecordDtoExtensions
private const string OldPrefix = "old"; private const string OldPrefix = "old";
private const string NewPrefix = "new"; 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 = private static readonly string[] FailedLoginEvents =
{ {
@@ -140,10 +148,23 @@ public static class AuditRecordDtoExtensions
continue; continue;
} }
string? pairedNewName = property.Name.StartsWith(OldPrefix, StringComparison.Ordinal) string? pairedNewName = null;
&& property.Name.Length > OldPrefix.Length if (property.Name.StartsWith(OldPrefix, StringComparison.Ordinal)
? NewPrefix + property.Name[OldPrefix.Length..] && property.Name.Length > OldPrefix.Length)
: null; {
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)) if (pairedNewName is not null && root.TryGetProperty(pairedNewName, out JsonElement newValue))
{ {
consumed.Add(pairedNewName); consumed.Add(pairedNewName);
@@ -162,11 +183,12 @@ public static class AuditRecordDtoExtensions
// Приводит значение JSON к строке отображения. // Приводит значение JSON к строке отображения.
// element: Значение JSON. // element: Значение JSON.
// Возвращает: Строковое представление либо null для JSON null. // Возвращает: Строковое представление либо null для JSON null; массивы — списком через запятую.
private static string? Stringify(JsonElement element) => element.ValueKind switch private static string? Stringify(JsonElement element) => element.ValueKind switch
{ {
JsonValueKind.Null or JsonValueKind.Undefined => null, JsonValueKind.Null or JsonValueKind.Undefined => null,
JsonValueKind.String => element.GetString(), JsonValueKind.String => element.GetString(),
JsonValueKind.Array => string.Join(", ", element.EnumerateArray().Select(Stringify)),
_ => element.GetRawText(), _ => element.GetRawText(),
}; };
@@ -154,4 +154,9 @@ public static class AuditEvents
/// Оператор изменил глобальные ключи Telegram api_id/api_hash. /// Оператор изменил глобальные ключи Telegram api_id/api_hash.
/// </summary> /// </summary>
public const string TelegramKeysChanged = "telegram_keys_changed"; 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 /// Диалог Telegram
/// </summary> /// </summary>
public const string DialogId = "dialogId"; 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( return new GrpcAiTools(
tenantContext, tenantContext,
connection, 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)), new TokenUsageRecorder(settings.Store, limits.Store, tenantContext, new TokenUsageEventService(new TestTokenUsageEventStore().Store)),
NullLogger<GrpcAiTools>.Instance); NullLogger<GrpcAiTools>.Instance);
} }
@@ -9,6 +9,7 @@ using Deal.Modules.Kanban.Application.Abstractions;
using Deal.Modules.Pipeline.Application.Parse; using Deal.Modules.Pipeline.Application.Parse;
using Deal.Modules.Pipeline.Application.Services; using Deal.Modules.Pipeline.Application.Services;
using Deal.Modules.Settings.Application.Abstractions; using Deal.Modules.Settings.Application.Abstractions;
using Deal.Modules.Settings.Application.Services;
using Deal.Modules.Tenants.Application.Abstractions; using Deal.Modules.Tenants.Application.Abstractions;
using Deal.Modules.Tenants.Application.Services; using Deal.Modules.Tenants.Application.Services;
using Deal.SharedKernel.Tenants.Abstractions; using Deal.SharedKernel.Tenants.Abstractions;
@@ -144,7 +145,9 @@ public sealed class IntegrationsDiTests
tenantContext.SetTenant(new TenantId(Guid.NewGuid().ToString("N"))); tenantContext.SetTenant(new TenantId(Guid.NewGuid().ToString("N")));
services.AddSingleton<ITenantContext>(tenantContext); services.AddSingleton<ITenantContext>(tenantContext);
services.AddScoped<ISettingsStore>(_ => new TestSettingsStore().Store); services.AddScoped<ISettingsStore>(_ => new TestSettingsStore().Store);
services.AddScoped<IGlobalSettingsStore>(_ => new TestGlobalSettingsStore().Store);
services.AddScoped<ISecretCipher>(_ => TestCiphers.New()); services.AddScoped<ISecretCipher>(_ => TestCiphers.New());
services.AddScoped<AiGlobalConfigService>();
services.AddScoped<ICardStore>(_ => new TestKanjStore().Store); services.AddScoped<ICardStore>(_ => new TestKanjStore().Store);
services.AddScoped<IMlLearningStore>(_ => new TestMlLearningStore().Store); services.AddScoped<IMlLearningStore>(_ => new TestMlLearningStore().Store);
services.AddScoped<ITenantLimitStore>(_ => new TestTenantLimitStore().Store); services.AddScoped<ITenantLimitStore>(_ => new TestTenantLimitStore().Store);
@@ -178,7 +178,7 @@ public sealed class PipelineWorkerGrpcAiTests
var grpcClassifier = new GrpcAiClassifier( var grpcClassifier = new GrpcAiClassifier(
tenantContext, tenantContext,
connection, connection,
new AiProviderConfigBuilder(settings.Store, TestCiphers.New()), new AiProviderConfigBuilder(TestAiConfig.New(new TestGlobalSettingsStore(), TestCiphers.New())),
new AiClassifyContextBuilder(settings.Store, kanjStore.Store), new AiClassifyContextBuilder(settings.Store, kanjStore.Store),
new TokenUsageRecorder(settings.Store, limits.Store, tenantContext, new TokenUsageEventService(new TestTokenUsageEventStore().Store)), new TokenUsageRecorder(settings.Store, limits.Store, tenantContext, new TokenUsageEventService(new TestTokenUsageEventStore().Store)),
NullLogger<GrpcAiClassifier>.Instance); NullLogger<GrpcAiClassifier>.Instance);
@@ -38,7 +38,6 @@ public sealed class SettingsCatalogTests
// String // String
["targetCurrency"] = SettingKind.String, ["targetCurrency"] = SettingKind.String,
["rateSource"] = SettingKind.String, ["rateSource"] = SettingKind.String,
["aiProvider"] = SettingKind.String,
["aiPrompt"] = SettingKind.String, ["aiPrompt"] = SettingKind.String,
["aiFilterPrompt"] = SettingKind.String, ["aiFilterPrompt"] = SettingKind.String,
["cardPrompt"] = SettingKind.String, ["cardPrompt"] = SettingKind.String,
@@ -59,10 +58,9 @@ public sealed class SettingsCatalogTests
["colState"] = SettingKind.Dict, ["colState"] = SettingKind.Dict,
// special // special
["myPrompts"] = SettingKind.MyPrompts, ["myPrompts"] = SettingKind.MyPrompts,
["aiConfigs"] = SettingKind.AiConfigs,
}; };
private const int ExpectedPublicKeysCount = 43; private const int ExpectedPublicKeysCount = 41;
[Fact] [Fact]
public void PublicKeys_CoverAllApiMapKeysWithCorrectCategories() public void PublicKeys_CoverAllApiMapKeysWithCorrectCategories()
@@ -169,26 +167,11 @@ public sealed class SettingsCatalogTests
Assert.True(SettingsDefaults.BlockResumes); Assert.True(SettingsDefaults.BlockResumes);
Assert.Equal("RUB", SettingsDefaults.TargetCurrency); Assert.Equal("RUB", SettingsDefaults.TargetCurrency);
Assert.Equal("cbr", SettingsDefaults.RateSource); Assert.Equal("cbr", SettingsDefaults.RateSource);
Assert.Equal("deepseek", SettingsDefaults.AiProvider);
Assert.False(SettingsDefaults.DiscPaused); Assert.False(SettingsDefaults.DiscPaused);
Assert.Empty(SettingsDefaults.ColState); Assert.Empty(SettingsDefaults.ColState);
Assert.Empty(SettingsDefaults.MyPrompts); 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] [Fact]
public void DefaultPrompts_ContainKeyMarkersFromDataJs() 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] [Fact]
public void AuditChanges_InvalidJson_ReturnsEmpty() public void AuditChanges_InvalidJson_ReturnsEmpty()
{ {
@@ -12,6 +12,7 @@ using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Pipeline.Application.Services; using Deal.Modules.Pipeline.Application.Services;
using Deal.Modules.Settings.Application.Abstractions; using Deal.Modules.Settings.Application.Abstractions;
using Deal.Modules.Settings.Application.Models; using Deal.Modules.Settings.Application.Models;
using Deal.Modules.Settings.Application.Services;
using Deal.Modules.Tenants.Application.Services; using Deal.Modules.Tenants.Application.Services;
using Deal.SharedKernel.Tenants.Models; using Deal.SharedKernel.Tenants.Models;
using Deal.Tests.Unit.Grpc; using Deal.Tests.Unit.Grpc;
@@ -71,7 +72,8 @@ public sealed class GrpcAiClassifierTests
Assert.Equal("deepseek", service.LastFilter.ProviderConfig.ProviderId); Assert.Equal("deepseek", service.LastFilter.ProviderConfig.ProviderId);
Assert.Equal("https://api.deepseek.com", service.LastFilter.ProviderConfig.BaseUrl); Assert.Equal("https://api.deepseek.com", service.LastFilter.ProviderConfig.BaseUrl);
Assert.Equal("deepseek-v4-flash", service.LastFilter.ProviderConfig.Model); 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.False(service.LastFilter.ProviderConfig.HasApiStyle);
Assert.Equal(TenantIdValue, Assert.Single(service.RequestTenantIds)); Assert.Equal(TenantIdValue, Assert.Single(service.RequestTenantIds));
@@ -220,34 +222,24 @@ public sealed class GrpcAiClassifierTests
} }
[Fact] [Fact]
public async Task ClassifyAsync_ProviderConfig_ReadsDecryptedKeyAndOverridesFromSettings() public async Task ClassifyAsync_ProviderConfig_UsesGlobalOperatorConfig()
{ {
await AiGrpcTestHost.RunAsync(AiGrpcTestHost.DefaultToken, new RecordingAiService(), async (port, service) => await AiGrpcTestHost.RunAsync(AiGrpcTestHost.DefaultToken, new RecordingAiService(), async (port, service) =>
{ {
service.ClassifyReply = new ClassifyReply { Ok = true, Json = """{"title":"Заголовок","stack":[],"is_spam":false}""" }; service.ClassifyReply = new ClassifyReply { Ok = true, Json = """{"title":"Заголовок","stack":[],"is_spam":false}""" };
(TestSettingsStore settings, ISecretCipher cipher, TestKanjStore kanjStore) = Context(port); (TestSettingsStore settings, ISecretCipher cipher, TestKanjStore kanjStore) = Context(port);
settings.Preload(SettingsKeys.AiProvider, Json("anthropic")); AiGlobalConfigService aiConfig = TestAiConfig.New(
settings.Preload( new TestGlobalSettingsStore(), cipher, providerId: "anthropic", apiKey: "sk-ant-test-secret");
SettingsKeys.AiConfigs,
new JsonObject
{
["anthropic"] = new JsonObject
{
["apiKey"] = cipher.Encrypt("sk-ant-test-secret"),
["baseUrl"] = "https://llm.local",
["model"] = "claude-custom",
},
}.ToJsonString());
IAiClassifier classifier = CreateClassifier(port, settings, cipher, kanjStore); IAiClassifier classifier = CreateClassifier(port, settings, cipher, kanjStore, aiConfig: aiConfig);
await classifier.ClassifyAsync("текст", CancellationToken.None); await classifier.ClassifyAsync("текст", CancellationToken.None);
// ProviderConfig из настроек: id/base/model — переопределение, apiKey расшифрован, api_style — из каталога. // ProviderConfig глобальной конфигурации: id/apiKey, адрес каталога фиксирован, api_style — из каталога.
Assert.NotNull(service.LastClassify); Assert.NotNull(service.LastClassify);
ProviderConfig config = service.LastClassify!.ProviderConfig; ProviderConfig config = service.LastClassify!.ProviderConfig;
Assert.Equal("anthropic", config.ProviderId); Assert.Equal("anthropic", config.ProviderId);
Assert.Equal("https://llm.local", config.BaseUrl); Assert.Equal("https://api.anthropic.com", config.BaseUrl);
Assert.Equal("claude-custom", config.Model); Assert.Equal("claude-sonnet-5", config.Model);
Assert.True(config.HasApiKey); Assert.True(config.HasApiKey);
Assert.Equal("sk-ant-test-secret", config.ApiKey); Assert.Equal("sk-ant-test-secret", config.ApiKey);
Assert.True(config.HasApiStyle); 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] [Fact]
public async Task ClassifyAsync_LongMessage_TruncatesUserContextTo5000CodePoints() public async Task ClassifyAsync_LongMessage_TruncatesUserContextTo5000CodePoints()
{ {
@@ -277,25 +296,28 @@ public sealed class GrpcAiClassifierTests
// Создаёт GrpcAiClassifier к хосту-фейку на эфемерном порту в tenant-контексте теста. // Создаёт GrpcAiClassifier к хосту-фейку на эфемерном порту в tenant-контексте теста.
// port: Порт хоста-фейка ai-service. // port: Порт хоста-фейка ai-service.
// settings: KV-хранилище тенанта (дефолты при пустом). // settings: KV-хранилище тенанта (дефолты при пустом).
// cipher: Шифр секретов (расшифровка apiKey). // cipher: Шифр секретов.
// kanjStore: Канбан-фейк (доски/примеры контекста классификации). // kanjStore: Канбан-фейк (доски/примеры контекста классификации).
// limits: Фейк-хранилище лимитов (списание usage; при null — собственный экземпляр). // limits: Фейк-хранилище лимитов (списание usage; при null — собственный экземпляр).
// aiConfig: Глобальная конфигурация ИИ (при null — провайдер по умолчанию с ключом).
// Возвращает: Экземпляр GrpcAiClassifier. // Возвращает: Экземпляр GrpcAiClassifier.
private static GrpcAiClassifier CreateClassifier( private static GrpcAiClassifier CreateClassifier(
int port, int port,
TestSettingsStore settings, TestSettingsStore settings,
ISecretCipher cipher, ISecretCipher cipher,
TestKanjStore kanjStore, TestKanjStore kanjStore,
TestTenantLimitStore? limits = null) TestTenantLimitStore? limits = null,
AiGlobalConfigService? aiConfig = null)
{ {
limits ??= new TestTenantLimitStore(); limits ??= new TestTenantLimitStore();
aiConfig ??= TestAiConfig.New(new TestGlobalSettingsStore(), cipher);
ITenantContext tenantContext = new TenantContext(); ITenantContext tenantContext = new TenantContext();
tenantContext.SetTenant(new TenantId(TenantIdValue)); tenantContext.SetTenant(new TenantId(TenantIdValue));
var connection = new AiGrpcConnection(new AiServiceOptions { UseLocal = false, Endpoint = $"http://127.0.0.1:{port}" }); var connection = new AiGrpcConnection(new AiServiceOptions { UseLocal = false, Endpoint = $"http://127.0.0.1:{port}" });
return new GrpcAiClassifier( return new GrpcAiClassifier(
tenantContext, tenantContext,
connection, connection,
new AiProviderConfigBuilder(settings.Store, cipher), new AiProviderConfigBuilder(aiConfig),
new AiClassifyContextBuilder(settings.Store, kanjStore.Store), new AiClassifyContextBuilder(settings.Store, kanjStore.Store),
new TokenUsageRecorder(settings.Store, limits.Store, tenantContext, new TokenUsageEventService(new TestTokenUsageEventStore().Store)), new TokenUsageRecorder(settings.Store, limits.Store, tenantContext, new TokenUsageEventService(new TestTokenUsageEventStore().Store)),
NullLogger<GrpcAiClassifier>.Instance); 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.Persistence;
using Deal.Infrastructure.Tenancy; using Deal.Infrastructure.Tenancy;
using Deal.Modules.Settings.Application.Abstractions; using Deal.Modules.Settings.Application.Abstractions;
using Deal.Modules.Settings.Application.Services;
using Deal.Modules.Tenants.Application.Abstractions; using Deal.Modules.Tenants.Application.Abstractions;
using Deal.Modules.Tenants.Application.Registrars; using Deal.Modules.Tenants.Application.Registrars;
using Deal.SharedKernel.Tenants.Abstractions; using Deal.SharedKernel.Tenants.Abstractions;
@@ -196,6 +197,8 @@ internal static class OperatorAuthHttpHost
builder.Services.AddSingleton<ISecretCipher>(TestCiphers.New()); builder.Services.AddSingleton<ISecretCipher>(TestCiphers.New());
// Сервис глобальных ключей Telegram (операторские ручки /api/operator/settings/telegram-keys). // Сервис глобальных ключей Telegram (операторские ручки /api/operator/settings/telegram-keys).
builder.Services.AddScoped<TelegramKeysService>(); builder.Services.AddScoped<TelegramKeysService>();
// Сервис глобальной конфигурации ИИ (операторские ручки /api/operator/settings/ai-config).
builder.Services.AddScoped<AiGlobalConfigService>();
builder.Services.AddSingleton<ITokenUsageEventStore>(effectiveTokenUsageStore.Store); builder.Services.AddSingleton<ITokenUsageEventStore>(effectiveTokenUsageStore.Store);
builder.Services.AddSingleton(_ => new TestTenantProvisioner().Provisioner); builder.Services.AddSingleton(_ => new TestTenantProvisioner().Provisioner);
builder.Services.AddScoped<TenantSchemaMigrationService>(); builder.Services.AddScoped<TenantSchemaMigrationService>();
@@ -212,6 +215,10 @@ internal static class OperatorAuthHttpHost
builder.Services.AddSingleton<IRateLimitCounterStore>(new TestRateLimitCounterStore().Store); builder.Services.AddSingleton<IRateLimitCounterStore>(new TestRateLimitCounterStore().Store);
builder.Services.AddScoped<LoginAttemptGuard>(); builder.Services.AddScoped<LoginAttemptGuard>();
builder.Services.AddScoped<SuspiciousActivityReporter>(); 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(); WebApplication app = builder.Build();
app.UseMiddleware<SessionMiddleware>(); app.UseMiddleware<SessionMiddleware>();
@@ -14,12 +14,11 @@ namespace Deal.Tests.Unit.Support;
public sealed class SettingsServiceTests public sealed class SettingsServiceTests
{ {
private readonly TestSettingsStore _store = new(); private readonly TestSettingsStore _store = new();
private readonly ISecretCipher _cipher = TestCiphers.New();
private readonly SettingsService _service; private readonly SettingsService _service;
public SettingsServiceTests() public SettingsServiceTests()
{ {
_service = new SettingsService(_store.Store, _cipher, Array.Empty<IRatesChangedListener>()); _service = new SettingsService(_store.Store, Array.Empty<IRatesChangedListener>());
} }
// ─── GET: снимок ───────────────────────────────────────────────────────── // ─── GET: снимок ─────────────────────────────────────────────────────────
@@ -39,7 +38,6 @@ public sealed class SettingsServiceTests
Assert.Equal("both", snapshot.WantedType); Assert.Equal("both", snapshot.WantedType);
Assert.Equal("RUB", snapshot.TargetCurrency); Assert.Equal("RUB", snapshot.TargetCurrency);
Assert.Equal("cbr", snapshot.RateSource); Assert.Equal("cbr", snapshot.RateSource);
Assert.Equal("deepseek", snapshot.AiProvider);
Assert.True(snapshot.ConversionOn); Assert.True(snapshot.ConversionOn);
Assert.True(snapshot.RemindersEnabled); Assert.True(snapshot.RemindersEnabled);
Assert.Equal(50, snapshot.DiscJoinLimit); Assert.Equal(50, snapshot.DiscJoinLimit);
@@ -47,26 +45,18 @@ public sealed class SettingsServiceTests
Assert.Equal(70, snapshot.DiscJoinDelayMax); Assert.Equal(70, snapshot.DiscJoinDelayMax);
Assert.False(snapshot.DiscPaused); Assert.False(snapshot.DiscPaused);
// Пустые коллекции и секреты. // Пустые коллекции.
Assert.Empty(snapshot.MyPrompts); Assert.Empty(snapshot.MyPrompts);
Assert.Empty(snapshot.ColState); 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] [Fact]
public async Task GetPublicAsync_StoredOverrides_OverlapDefaults() public async Task GetPublicAsync_StoredOverrides_OverlapDefaults()
{ {
// Переопределения: int, список, colState, aiConfigs (с секретом). // Переопределения: int, список, colState.
_store.Preload(SettingsKeys.MinLen, "100"); _store.Preload(SettingsKeys.MinLen, "100");
_store.Preload(SettingsKeys.StopPhrases, "[\"фраза один\",\"фраза два\"]"); _store.Preload(SettingsKeys.StopPhrases, "[\"фраза один\",\"фраза два\"]");
_store.Preload(SettingsKeys.ColState, "{\"colA\":{\"collapsed\":true},\"num\":42}"); _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); PublicSettingsDto snapshot = await _service.GetPublicAsync(CancellationToken.None);
@@ -79,29 +69,6 @@ public sealed class SettingsServiceTests
Assert.Equal(2, snapshot.ColState.Count); Assert.Equal(2, snapshot.ColState.Count);
Assert.True(((JsonElement)snapshot.ColState["colA"]!).GetProperty("collapsed").GetBoolean()); Assert.True(((JsonElement)snapshot.ColState["colA"]!).GetProperty("collapsed").GetBoolean());
Assert.Equal(42, ((JsonElement)snapshot.ColState["num"]!).GetInt32()); 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: общее поведение ────────────────────────────────────────────── // ─── PATCH: общее поведение ──────────────────────────────────────────────
@@ -113,7 +80,6 @@ public sealed class SettingsServiceTests
Assert.Equal(100, snapshot.MinLen); Assert.Equal(100, snapshot.MinLen);
Assert.Equal(14, snapshot.ArchiveAfterDays); Assert.Equal(14, snapshot.ArchiveAfterDays);
Assert.Equal("deepseek", snapshot.AiProvider);
Assert.Equal("100", _store.GetStoredJson(SettingsKeys.MinLen)); Assert.Equal("100", _store.GetStoredJson(SettingsKeys.MinLen));
Assert.Single(_store.Keys); // записано только переданное поле Assert.Single(_store.Keys); // записано только переданное поле
} }
@@ -310,17 +276,6 @@ public sealed class SettingsServiceTests
Assert.Equal("\"USD\"", _store.GetStoredJson(SettingsKeys.TargetCurrency)); 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] [Fact]
public async Task ApplyPatchAsync_List_ConvertsScalarsAndSlicesTo200() public async Task ApplyPatchAsync_List_ConvertsScalarsAndSlicesTo200()
{ {
@@ -402,160 +357,6 @@ public sealed class SettingsServiceTests
Assert.Empty(_store.Keys); 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] [Fact]
public async Task ApplyPatchAsync_ColState_PassedThroughAsIs() public async Task ApplyPatchAsync_ColState_PassedThroughAsIs()
{ {
@@ -593,7 +394,7 @@ public sealed class SettingsServiceTests
public async Task ApplyPatchAsync_TargetCurrencyInBody_NotifiesListenersAfterSave() public async Task ApplyPatchAsync_TargetCurrencyInBody_NotifiesListenersAfterSave()
{ {
var listener = Substitute.For<IRatesChangedListener>(); 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); 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() public async Task ApplyPatchAsync_ConversionOnInBody_NotifiesListeners()
{ {
var listener = Substitute.For<IRatesChangedListener>(); 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); await local.ApplyPatchAsync(JsonBody(new { conversionOn = false }), CancellationToken.None);
@@ -618,7 +419,7 @@ public sealed class SettingsServiceTests
public async Task ApplyPatchAsync_UnrelatedKeys_DoesNotNotifyListeners() public async Task ApplyPatchAsync_UnrelatedKeys_DoesNotNotifyListeners()
{ {
var listener = Substitute.For<IRatesChangedListener>(); 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); 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() public async Task ApplyPatchAsync_TargetCurrencyJsonNull_DoesNotNotifyListeners()
{ {
var listener = Substitute.For<IRatesChangedListener>(); 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); 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 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). // Строковые представления булевых значений в деталях события (как в JSON).
const TRUE_VALUE = 'true' const TRUE_VALUE = 'true'
const FALSE_VALUE = 'false' const FALSE_VALUE = 'false'
@@ -53,13 +64,37 @@ function valueLabel(field, raw) {
if (raw === null || raw === undefined || raw === '') return '—' if (raw === null || raw === undefined || raw === '') return '—'
if (raw === TRUE_VALUE) return t('operator.da') if (raw === TRUE_VALUE) return t('operator.da')
if (raw === FALSE_VALUE) return t('operator.net') 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) 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) { function changesOf(row) {
return Array.isArray(row.changes) ? row.changes : [] 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. // Вторая строка колонки пользователя: имя пространства, иначе id.
function tenantSubline(row) { function tenantSubline(row) {
return row.userName && row.tenantName ? row.tenantName : row.tenantId || '—' 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" 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-if="changesOf(row).length" class="space-y-0.5">
<div v-for="(change, i) in changesOf(row)" :key="i" class="text-[11px] text-mid"> <div v-for="(change, i) in changesOf(row)" :key="i" class="text-[11px] text-mid">{{ changeLine(change) }}</div>
<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> </div>
<div v-else class="text-[11px] text-low">{{ $t('operator.dopolnitelnyh-parametrov-net') }}</div> <div v-else class="text-[11px] text-low">{{ $t('operator.dopolnitelnyh-parametrov-net') }}</div>
<button <button
+9 -108
View File
@@ -6,21 +6,14 @@ import {
toast, toast,
saveAiPrompt, saveAiPrompt,
saveAiCardPrompt, saveAiCardPrompt,
setAiProvider,
checkAiConnection,
saveAiSettings,
addMyPrompt, addMyPrompt,
removeMyPrompt, removeMyPrompt,
} from '../../store.js' } 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 Icon from '../Icon.vue'
import UiToggle from '../ui/ToggleSwitch.vue' import UiToggle from '../ui/ToggleSwitch.vue'
import PromptLibraryModal from '../PromptLibraryModal.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 справляется, ИИ можно отключить» ── // ── самооценка ML: подсказка на вкладке AI, когда «ML справляется, ИИ можно отключить» ──
const ML_NEED = 50 // окно последних подтверждённых решений const ML_NEED = 50 // окно последних подтверждённых решений
const ADV_STYLE = { const ADV_STYLE = {
@@ -147,105 +140,19 @@ async function saveCurrentAsMine() {
</div> </div>
</div> </div>
<!-- выбор провайдера (активен только один) --> <!-- провайдер и модель задаёт оператор системы -->
<div class="rounded-xl border border-white/5 bg-raise/40 p-4"> <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> <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> <p class="text-[11.5px] text-low mt-1 leading-relaxed">
</div> {{ $t('settings.provajdera-model-i-klyuch-nastraivaet') }}
<p class="text-[11.5px] text-low mb-3">
{{ $t('settings.oblachnye-api-ili-lokalnye-openai') }}
</p> </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> </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> </div>
<!-- системный промпт --> <!-- системный промпт -->
@@ -287,12 +194,6 @@ async function saveCurrentAsMine() {
> >
{{ $t('settings.sohranit-prompt') }} {{ $t('settings.sohranit-prompt') }}
</button> </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> </div>
<!-- Мои промпты --> <!-- Мои промпты -->
-1
View File
@@ -10,7 +10,6 @@ export const DAY = 86400000
export { export {
COLUMN_WIDTHS, COLUMN_WIDTHS,
CURRENCIES, CURRENCIES,
AI_PROVIDERS,
DEFAULT_AI_PROMPT, DEFAULT_AI_PROMPT,
DEFAULT_AI_CARD_PROMPT, DEFAULT_AI_CARD_PROMPT,
DEFAULT_AI_FILTER_PROMPT, DEFAULT_AI_FILTER_PROMPT,
+3 -68
View File
@@ -1,7 +1,7 @@
// ─── Языковой контент-каталог (ru) ──────────────────────────────────────── // ─── Языковой контент-каталог (ru) ────────────────────────────────────────
// Список валют, AI-провайдеры, стадии канбана «Выбранных» и дефолтные // Список валют, стадии канбана «Выбранных» и дефолтные промпты — это
// промпты — это пользовательские строки, поэтому живут в ресурсах локали // пользовательские строки, поэтому живут в ресурсах локали (вне
// (вне компонентов/логики). При добавлении языка новый каталог кладётся рядом // компонентов/логики). При добавлении языка новый каталог кладётся рядом
// (например, ru-контент остаётся языком-фолбэком). // (например, ru-контент остаётся языком-фолбэком).
// Ширины колонок канбана: cls — класс Tailwind, label — пункт меню, порядок // Ширины колонок канбана: cls — класс Tailwind, label — пункт меню, порядок
@@ -23,71 +23,6 @@ export const CURRENCIES = [
{ code: 'GBP', name: 'Британский фунт', symbol: '£' }, { 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 = `Ты — классификатор входящих сообщений. Сообщение — ЗАЯВКА/ОБЪЯВЛЕНИЕ, только если в нём есть конкретный запрос или предложение по делу: заказ услуги/товара/работы, поиск исполнителя или найм человека. Направление вашей сферы: export const DEFAULT_AI_PROMPT = `Ты — классификатор входящих сообщений. Сообщение — ЗАЯВКА/ОБЪЯВЛЕНИЕ, только если в нём есть конкретный запрос или предложение по делу: заказ услуги/товара/работы, поиск исполнителя или найм человека. Направление вашей сферы:
{domain} {domain}
+86 -4
View File
@@ -525,6 +525,7 @@ export const ru = {
"vklyuchit-ii": "Включить ИИ", "vklyuchit-ii": "Включить ИИ",
"bez-ii-ml-obuchaetsya-tolko-vruchnuyu": "Без ИИ ML обучается только вручную: переносите карточки по колонкам, отправляйте мусор\n в корзину, возвращайте ошибочный отсев — каждое действие учит модель. Кнопки\n «Предложить колонки» и «Переклассифицировать» требуют включённого ИИ.", "bez-ii-ml-obuchaetsya-tolko-vruchnuyu": "Без ИИ ML обучается только вручную: переносите карточки по колонкам, отправляйте мусор\n в корзину, возвращайте ошибочный отсев — каждое действие учит модель. Кнопки\n «Предложить колонки» и «Переклассифицировать» требуют включённого ИИ.",
"provajder-klassifikatora": "Провайдер классификатора", "provajder-klassifikatora": "Провайдер классификатора",
"provajdera-model-i-klyuch-nastraivaet": "Провайдера, модель и API-ключ задаёт оператор системы — они едины для всех пользователей и не настраиваются здесь.",
"aktiven-odin": "активен один — «", "aktiven-odin": "активен один — «",
"oblachnye-api-ili-lokalnye-openai": "Облачные API или локальные OpenAI-совместимые серверы (Ollama, LM Studio, vLLM)", "oblachnye-api-ili-lokalnye-openai": "Облачные API или локальные OpenAI-совместимые серверы (Ollama, LM Studio, vLLM)",
"parametry": "Параметры:", "parametry": "Параметры:",
@@ -864,9 +865,11 @@ export const ru = {
"tenant_status_changed": "Изменён статус пользователя", "tenant_status_changed": "Изменён статус пользователя",
"tenant_limit_changed": "Изменён лимит ИИ", "tenant_limit_changed": "Изменён лимит ИИ",
"impersonation_started": "Вход от имени пользователя", "impersonation_started": "Вход от имени пользователя",
"impersonation_stopped": "Завершён вход от имени пользователя" "impersonation_stopped": "Завершён вход от имени пользователя",
"ai_config_changed": "Изменена конфигурация ИИ"
}, },
"field": { "field": {
"id": "Идентификатор",
"login": "Логин", "login": "Логин",
"email": "Email", "email": "Email",
"codeHash": "Код (хэш)", "codeHash": "Код (хэш)",
@@ -878,7 +881,7 @@ export const ru = {
"status": "Статус", "status": "Статус",
"targetLogin": "Вход", "targetLogin": "Вход",
"cardId": "Карточка", "cardId": "Карточка",
"to": "Куда", "to": "Колонка",
"col": "Колонка", "col": "Колонка",
"containerId": "Колонка", "containerId": "Колонка",
"attempted": "Обработано", "attempted": "Обработано",
@@ -889,7 +892,65 @@ export const ru = {
"all": "Все каналы", "all": "Все каналы",
"count": "Каналов", "count": "Каналов",
"phase": "Этап", "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": { "value": {
"status": { "status": {
@@ -1025,7 +1086,28 @@ export const ru = {
"ukazhite-nepustoj-api-hash": "Укажите непустой api_hash", "ukazhite-nepustoj-api-hash": "Укажите непустой api_hash",
"sohranit-klyuchi": "Сохранить ключи", "sohranit-klyuchi": "Сохранить ключи",
"klyuchi-telegram-sohraneny": "Ключи Telegram сохранены", "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": { "auth": {
"ne-udalos-smenit-parol": "Не удалось сменить пароль", "ne-udalos-smenit-parol": "Не удалось сменить пароль",
-5
View File
@@ -55,11 +55,6 @@ export const state = reactive({
tgKeysSet: false, tgKeysSet: false,
// ai // ai
aiProvider: 'deepseek',
aiConfigs: {},
aiKey: '',
aiKeyChecked: false,
aiKeyOk: false,
aiPrompt: DEFAULT_AI_PROMPT, aiPrompt: DEFAULT_AI_PROMPT,
aiCardPrompt: DEFAULT_AI_CARD_PROMPT, aiCardPrompt: DEFAULT_AI_CARD_PROMPT,
aiEnabled: true, aiEnabled: true,
+44 -1
View File
@@ -29,8 +29,9 @@ export const op = reactive({
audit: { items: [], total: 0 }, audit: { items: [], total: 0 },
activity: { items: [], total: 0 }, activity: { items: [], total: 0 },
tgKeys: null, // глобальные ключи Telegram (маскированный снимок) 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). */ /** Каталог типов событий аудита (контракт этапа 10 + события этапа 7). */
@@ -56,6 +57,7 @@ export const AUDIT_EVENT_TYPES = [
'channel_created', 'channel_created',
'telegram_linked', 'telegram_linked',
'telegram_keys_changed', 'telegram_keys_changed',
'ai_config_changed',
'tenant_created', 'tenant_created',
'tenant_status_changed', 'tenant_status_changed',
'tenant_limit_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 570 000»). */
+1 -91
View File
@@ -4,7 +4,6 @@ import { t } from '@/i18n/index.js'
import { watch } from 'vue' import { watch } from 'vue'
import { import {
CURRENCIES, CURRENCIES,
AI_PROVIDERS,
DEFAULT_AI_PROMPT, DEFAULT_AI_PROMPT,
DEFAULT_AI_FILTER_PROMPT, DEFAULT_AI_FILTER_PROMPT,
DEFAULT_AI_CARD_PROMPT, DEFAULT_AI_CARD_PROMPT,
@@ -27,7 +26,6 @@ export function applySettings(pub, { keys = null } = {}) {
if (!pub) return if (!pub) return
_hydrating = true _hydrating = true
const has = (k) => keys === null || keys.includes(k) 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('aiPrompt')) state.aiPrompt = pub.aiPrompt || DEFAULT_AI_PROMPT
if (has('cardPrompt')) state.aiCardPrompt = pub.cardPrompt || DEFAULT_AI_CARD_PROMPT if (has('cardPrompt')) state.aiCardPrompt = pub.cardPrompt || DEFAULT_AI_CARD_PROMPT
if (has('aiFilterEnabled')) state.aiFilterEnabled = !!pub.aiFilterEnabled 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('targetCurrency')) state.targetCurrency = pub.targetCurrency || 'RUB'
if (has('rateSource')) state.rateSource = pub.rateSource || 'cbr' if (has('rateSource')) state.rateSource = pub.rateSource || 'cbr'
if (has('remindersEnabled')) state.remindersEnabled = !!pub.remindersEnabled 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 _hydrating = false
} }
// ─── AI: провайдеры, конфиги, промпты ──────────────────────────────────── // ─── 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' })
}
}
export async function saveAiPrompt() { export async function saveAiPrompt() {
try { 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 AnalyticsSection from './AnalyticsSection.vue'
import HealthSection from './HealthSection.vue' import HealthSection from './HealthSection.vue'
import TelegramSection from './TelegramSection.vue' import TelegramSection from './TelegramSection.vue'
import AiSection from './AiSection.vue'
const SECTIONS = [ const SECTIONS = [
{ id: 'tenants', name: t('operator.tenanty'), icon: 'users', component: TenantsSection }, { 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: 'analytics', name: t('operator.analitika'), icon: 'activity', component: AnalyticsSection },
{ id: 'health', name: t('operator.sostoyanie-sistemy'), icon: 'check', component: HealthSection }, { id: 'health', name: t('operator.sostoyanie-sistemy'), icon: 'check', component: HealthSection },
{ id: 'telegram', name: t('operator.telegram'), icon: 'key', component: TelegramSection }, { id: 'telegram', name: t('operator.telegram'), icon: 'key', component: TelegramSection },
{ id: 'ai', name: t('operator.ii'), icon: 'sparkles', component: AiSection },
] ]
const activeId = computed(() => { const activeId = computed(() => {