Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
09358f07f2 | ||
|
|
ea4ed73327 | ||
|
|
6b7bfa5e4d | ||
|
|
78fe64f531 | ||
|
|
dda314cddc | ||
|
|
16f4270995 | ||
|
|
ac9d92d196 | ||
|
|
fb964f9b4f | ||
|
|
f7459069d1 | ||
|
|
37b7524665 | ||
|
|
d0e202fbc6 | ||
|
|
905129effa | ||
|
|
f31989a133 |
+9
-10
@@ -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))
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-6
@@ -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
|
||||||
|
|||||||
@@ -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,104 +140,18 @@ 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">
|
||||||
<div class="text-[12px] font-medium text-hi">{{ $t('settings.provajder-klassifikatora') }}</div>
|
<span class="w-9 h-9 rounded-lg grid place-items-center shrink-0 bg-brand/10 text-brand">
|
||||||
<span class="text-[11px] text-low/80">{{ $t('settings.aktiven-odin') }}{{ activeAi?.name }}»</span>
|
<Icon name="chip" :size="17" />
|
||||||
</div>
|
|
||||||
<p class="text-[11.5px] text-low mb-3">
|
|
||||||
{{ $t('settings.oblachnye-api-ili-lokalnye-openai') }}
|
|
||||||
</p>
|
|
||||||
<div class="space-y-2">
|
|
||||||
<button
|
|
||||||
v-for="p in AI_PROVIDERS"
|
|
||||||
:key="p.id"
|
|
||||||
class="w-full flex items-start gap-3 rounded-xl border p-3 text-left transition-colors"
|
|
||||||
:class="
|
|
||||||
state.aiProvider === p.id
|
|
||||||
? 'border-brand/40 bg-brand/5'
|
|
||||||
: 'border-white/8 bg-transparent hover:bg-white/3'
|
|
||||||
"
|
|
||||||
@click="setAiProvider(p.id)"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
class="mt-0.5 w-4 h-4 rounded-full border-2 grid place-items-center shrink-0"
|
|
||||||
:class="state.aiProvider === p.id ? 'border-brand' : 'border-low'"
|
|
||||||
>
|
|
||||||
<span v-if="state.aiProvider === p.id" class="w-2 h-2 rounded-full bg-brand"></span>
|
|
||||||
</span>
|
|
||||||
<span class="min-w-0 flex-1">
|
|
||||||
<span class="flex items-center gap-2">
|
|
||||||
<span class="text-[13px] font-medium text-hi">{{ p.name }}</span>
|
|
||||||
<span
|
|
||||||
class="text-[9px] uppercase tracking-wider font-semibold rounded px-1.5 py-px shrink-0"
|
|
||||||
:class="p.local ? 'bg-online/12 text-online' : 'bg-brand/12 text-brand'"
|
|
||||||
>
|
|
||||||
{{ p.local ? $t('settings.lokalno') : $t('settings.oblako') }}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
<span class="block text-[11.5px] text-low mt-0.5 leading-relaxed">{{ p.desc }}</span>
|
|
||||||
<span class="block text-[10.5px] text-low/70 font-mono mt-1">{{ p.base }}</span>
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- параметры активного провайдера -->
|
|
||||||
<div v-if="activeAi && activeCfg" class="rounded-xl border border-white/5 bg-raise/40 p-4 space-y-3">
|
|
||||||
<div class="text-[12px] font-medium text-hi">{{ $t('settings.parametry') }} {{ activeAi.name }}</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-[11.5px] text-low mb-1.5">Base URL / endpoint</label>
|
|
||||||
<input
|
|
||||||
v-model="activeCfg.baseUrl"
|
|
||||||
class="w-full h-9 rounded-lg bg-ink/70 border border-white/10 focus:border-brand/50 outline-none px-3 font-mono text-[12.5px]"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="grid grid-cols-2 gap-3">
|
|
||||||
<div>
|
|
||||||
<label class="block text-[11.5px] text-low mb-1.5">{{ $t('settings.model') }}</label>
|
|
||||||
<input
|
|
||||||
v-model="activeCfg.model"
|
|
||||||
list="ai-models-list"
|
|
||||||
class="w-full h-9 rounded-lg bg-ink/70 border border-white/10 focus:border-brand/50 outline-none px-3 font-mono text-[12.5px]"
|
|
||||||
:placeholder="$t('settings.vvedite-ili-vyberite-model')"
|
|
||||||
/>
|
|
||||||
<datalist id="ai-models-list">
|
|
||||||
<option v-for="m in activeAi.models" :key="m" :value="m" />
|
|
||||||
<option v-if="activeCfg.model" :value="activeCfg.model" />
|
|
||||||
</datalist>
|
|
||||||
</div>
|
|
||||||
<div v-if="!activeAi.local">
|
|
||||||
<label class="block text-[11.5px] text-low mb-1">{{ $t('settings.api-klyuch') }}</label>
|
|
||||||
<input
|
|
||||||
v-model="activeCfg.apiKey"
|
|
||||||
type="password"
|
|
||||||
autocomplete="off"
|
|
||||||
autocorrect="off"
|
|
||||||
autocapitalize="off"
|
|
||||||
spellcheck="false"
|
|
||||||
class="w-full h-9 rounded-lg bg-ink/70 border border-white/10 focus:border-brand/50 outline-none px-3 font-mono text-[12.5px]"
|
|
||||||
:placeholder="activeCfg.keyMasked || 'sk-…'"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div v-else class="flex items-end">
|
|
||||||
<span class="text-[11px] text-online/90 flex items-center gap-1.5 pb-2">
|
|
||||||
<Icon name="check" :size="13" />
|
|
||||||
{{ $t('settings.klyuch-ne-trebuetsya') }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<button
|
|
||||||
class="h-9 px-4 rounded-lg bg-brand/12 text-brand border border-brand/20 text-[12.5px] font-medium hover:bg-brand/20 transition-colors"
|
|
||||||
@click="checkAiConnection"
|
|
||||||
>
|
|
||||||
{{ $t('settings.proverit-podklyuchenie') }}
|
|
||||||
</button>
|
|
||||||
<span class="text-[11px] text-low/80">
|
|
||||||
{{ activeAi.local ? $t('settings.ping-lokalnogo-servera') : $t('settings.klyuch-nigde-ne-logiruetsya') }}
|
|
||||||
</span>
|
</span>
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<div class="text-[12px] font-medium text-hi">{{ $t('settings.provajder-klassifikatora') }}</div>
|
||||||
|
<p class="text-[11.5px] text-low mt-1 leading-relaxed">
|
||||||
|
{{ $t('settings.provajdera-model-i-klyuch-nastraivaet') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<!-- Мои промпты -->
|
<!-- Мои промпты -->
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|
||||||
|
|||||||
@@ -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": "Не удалось сменить пароль",
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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»). */
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
Reference in New Issue
Block a user