diff --git a/src/core/Deal.Api/Endpoints/AiCheckEndpoint.cs b/src/core/Deal.Api/Endpoints/AiCheckEndpoint.cs
deleted file mode 100644
index c459027..0000000
--- a/src/core/Deal.Api/Endpoints/AiCheckEndpoint.cs
+++ /dev/null
@@ -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;
-
-///
-/// Эндпоинт проверки подключения AI-провайдера
-///
-public static class AiCheckEndpoint
-{
- private const string ApiGroupPrefix = "/api";
-
- // Путь проверки подключения AI-провайдера.
- private const string AiCheckPath = "/ai/check";
-
- private const string OpenApiTag = "settings";
-
- ///
- /// Регистрирует POST /api/ai/check.
- ///
- /// Построитель маршрутов приложения.
- /// Построитель маршрутов для цепочки вызовов.
- 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 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();
- ISecretCipher secretCipher = context.RequestServices.GetRequiredService();
- IAiConnectionChecker checker = context.RequestServices.GetRequiredService();
-
- AiCheckRequest checkRequest = await BuildActiveCheckRequestAsync(store, secretCipher, ct);
- AiCheckResultDto result = await checker.CheckAsync(checkRequest, ct);
- return Results.Ok(result);
- }
-
- private static async Task 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 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 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;
- }
-}
diff --git a/src/core/Deal.Api/Endpoints/OperatorSettingsEndpoints.cs b/src/core/Deal.Api/Endpoints/OperatorSettingsEndpoints.cs
index 8a324f4..7d19481 100644
--- a/src/core/Deal.Api/Endpoints/OperatorSettingsEndpoints.cs
+++ b/src/core/Deal.Api/Endpoints/OperatorSettingsEndpoints.cs
@@ -2,6 +2,9 @@ using Deal.Api.Endpoints.RequestModels;
using Deal.Api.Extensions;
using Deal.Api.Services;
using Deal.Api.Telegram;
+using Deal.Modules.Settings.Application.Abstractions;
+using Deal.Modules.Settings.Application.Models;
+using Deal.Modules.Settings.Application.Services;
using Deal.Modules.Tenants.Application.Models;
using Deal.Modules.Tenants.Application.Services;
@@ -21,6 +24,9 @@ public static class OperatorSettingsEndpoints
// Относительный путь глобальных ключей Telegram (GET/PUT).
private const string TelegramKeysPath = "/telegram-keys";
+ // Относительный путь глобальной конфигурации ИИ (GET/PUT/POST-check).
+ private const string AiConfigPath = "/ai-config";
+
// Текст 400: пустое тело PUT (ни одного поля).
private const string EmptyBodyDetail = "Укажите api_id и api_hash";
@@ -33,6 +39,24 @@ public static class OperatorSettingsEndpoints
// Текст 400: api_hash пустой/маска/с префиксом enc:.
private const string InvalidApiHashDetail = "Укажите непустой api_hash";
+ // Текст 400: пустое тело PUT конфига ИИ (ни одного поля).
+ private const string EmptyAiConfigBodyDetail = "Укажите хотя бы одно поле (providerId, baseUrl, model, apiKey)";
+
+ // Текст 400: провайдер не из каталога AiProviders.
+ private const string InvalidProviderDetail = "Провайдер не из списка разрешённых";
+
+ // Текст 400: api-ключ короче 8 символов/маска/с префиксом enc:.
+ private const string InvalidAiKeyDetail = "API-ключ должен быть не короче 8 символов, без маски";
+
+ // Текст 400: конфигурации ИИ ещё нет, а провайдер в PUT не передан.
+ private const string MissingProviderDetail = "Конфигурация ИИ ещё не задана — укажите providerId";
+
+ // Текст 400: custom-провайдер без модели (в каталоге нет моделей-дефолтов).
+ private const string MissingAiModelDetail = "Укажите model — у выбранного провайдера нет моделей по умолчанию";
+
+ // Текст 400: проверка связи при незаданной конфигурации ИИ.
+ private const string AiConfigNotSetDetail = "Сначала сохраните конфигурацию ИИ";
+
///
/// Регистрирует группу /api/operator/settings
///
@@ -43,6 +67,9 @@ public static class OperatorSettingsEndpoints
var group = app.MapGroup(SettingsGroupPrefix).WithTags(SettingsOpenApiTag);
group.MapGet(TelegramKeysPath, GetTelegramKeysAsync);
group.MapPut(TelegramKeysPath, PutTelegramKeysAsync);
+ group.MapGet(AiConfigPath, GetAiConfigAsync);
+ group.MapPut(AiConfigPath, PutAiConfigAsync);
+ group.MapPost(AiConfigPath + "/check", CheckAiConfigAsync);
return app;
}
@@ -135,6 +162,152 @@ public static class OperatorSettingsEndpoints
return Results.Ok(snapshot);
}
+ // GET /api/operator/settings/ai-config: маскированная глобальная конфигурация ИИ.
+ // context: Контекст запроса.
+ // config: Сервис глобальной конфигурации ИИ (scoped).
+ // ct: Токен отмены.
+ // Возвращает: 200 маскированный снимок или 401 без операторской сессии.
+ private static async Task 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 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 CheckAiConfigAsync(
+ HttpContext context,
+ AiGlobalConfigService config,
+ IAiConnectionChecker checker,
+ CancellationToken ct)
+ {
+ if (context.GetCurrentOperator() is null)
+ {
+ return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail);
+ }
+
+ AiGlobalConfigSnapshot snapshot = await config.GetSnapshotAsync(ct);
+ if (snapshot.ProviderId.Length == 0 || snapshot.Meta is null)
+ {
+ return EndpointResults.BadRequest(AiConfigNotSetDetail);
+ }
+
+ var request = new AiCheckRequest(
+ snapshot.ProviderId,
+ snapshot.BaseUrl,
+ snapshot.Model,
+ snapshot.ApiKey,
+ snapshot.Meta.Local,
+ snapshot.Meta.ApiStyle);
+ AiCheckResultDto result = await checker.CheckAsync(request, ct);
+ return Results.Ok(result);
+ }
+
// IP-адрес клиента для аудита (без порта; null, если недоступен).
// context: Контекст запроса.
// Возвращает: Строковое представление IP или null.
diff --git a/src/core/Deal.Api/Endpoints/RequestModels/OperatorAiConfigRequest.cs b/src/core/Deal.Api/Endpoints/RequestModels/OperatorAiConfigRequest.cs
new file mode 100644
index 0000000..41947a0
--- /dev/null
+++ b/src/core/Deal.Api/Endpoints/RequestModels/OperatorAiConfigRequest.cs
@@ -0,0 +1,14 @@
+namespace Deal.Api.Endpoints.RequestModels;
+
+///
+/// Тело PUT /api/operator/settings/ai-config
+///
+/// Id провайдера из каталога AiProviders; null — не менялся.
+/// Базовый URL API (учитывается для local/custom провайдеров); null — не менялся.
+/// Активная модель; null — не менялась (у каталогных провайдеров пустая = первая из каталога).
+/// API-ключ открытым текстом (хранится зашифрованным); null — не менялся (маска не принимается).
+public sealed record OperatorAiConfigRequest(
+ string? ProviderId,
+ string? BaseUrl,
+ string? Model,
+ string? ApiKey);
diff --git a/src/core/Deal.Api/Endpoints/SettingsEndpoints.cs b/src/core/Deal.Api/Endpoints/SettingsEndpoints.cs
index 3270eea..d936b12 100644
--- a/src/core/Deal.Api/Endpoints/SettingsEndpoints.cs
+++ b/src/core/Deal.Api/Endpoints/SettingsEndpoints.cs
@@ -113,7 +113,7 @@ public static class SettingsEndpoints
{
keyList.Add(key);
SettingKind? kind = SettingsKeys.FindPublicKind(key);
- if (kind is SettingKind.Dict or SettingKind.MyPrompts or SettingKind.AiConfigs)
+ if (kind is SettingKind.Dict or SettingKind.MyPrompts)
{
changes.Add(AuditDetails.Set(key, ChangedMarker));
continue;
diff --git a/src/core/Deal.Api/Program.cs b/src/core/Deal.Api/Program.cs
index f683551..018127f 100644
--- a/src/core/Deal.Api/Program.cs
+++ b/src/core/Deal.Api/Program.cs
@@ -25,6 +25,7 @@ using Deal.Modules.Kanban.Application.Registrars;
using Deal.Modules.Pipeline.Application.Registrars;
using Deal.Modules.Settings.Application.Abstractions;
using Deal.Modules.Settings.Application.Registrars;
+using Deal.Modules.Settings.Application.Services;
using Deal.Modules.Telegram.Application;
using Deal.Modules.Tenants.Application.Models;
using Deal.Modules.Tenants.Application.Registrars;
@@ -160,6 +161,7 @@ builder.Services.AddDiscoveryModule();
builder.Services.AddScoped();
builder.Services.AddScoped();
+builder.Services.AddScoped();
builder.Services.AddSingleton();
@@ -356,7 +358,6 @@ app.MapOperatorSettingsEndpoints();
app.MapOperatorMaintenanceEndpoints();
app.MapJoinEndpoint();
app.MapSettingsEndpoints();
-app.MapAiCheckEndpoint();
app.MapRatesEndpoints();
app.MapMlEndpoints();
app.MapFilterTesterEndpoints();
diff --git a/src/core/Deal.Infrastructure/Integrations/Services/AiConnectionChecker.cs b/src/core/Deal.Infrastructure/Integrations/Services/AiConnectionChecker.cs
index 5af4c4f..7714f57 100644
--- a/src/core/Deal.Infrastructure/Integrations/Services/AiConnectionChecker.cs
+++ b/src/core/Deal.Infrastructure/Integrations/Services/AiConnectionChecker.cs
@@ -83,8 +83,8 @@ public sealed class AiConnectionChecker : IAiConnectionChecker
string name = meta?.Name ?? request.ProviderId;
// SSRF-гейт (allowlist, preflight): проверка возможна только для провайдера фиксированного
- // каталога AiProviders. В штатном потоке недостижимо (PATCH-гейт aiProvider/aiConfigs в
- // SettingsService) — защита от ручного изменения БД/повреждённого хранилища.
+ // каталога AiProviders. В штатном потоке недостижимо (конфигурацию задаёт оператор из
+ // каталога через OperatorSettingsEndpoints) — защита от ручного изменения БД.
if (meta is null)
{
return BuildResult(request, name, ok: false, message: ProviderNotAllowedMessage);
diff --git a/src/core/Deal.Infrastructure/Integrations/Services/AiProviderConfigBuilder.cs b/src/core/Deal.Infrastructure/Integrations/Services/AiProviderConfigBuilder.cs
index 0c450e6..827fd9e 100644
--- a/src/core/Deal.Infrastructure/Integrations/Services/AiProviderConfigBuilder.cs
+++ b/src/core/Deal.Infrastructure/Integrations/Services/AiProviderConfigBuilder.cs
@@ -1,150 +1,49 @@
-using System.Text.Json.Nodes;
using Deal.Grpc.Ai;
-using Deal.Modules.Settings.Application.Abstractions;
+using Deal.Infrastructure.Integrations.Exceptions;
using Deal.Modules.Settings.Application.Models;
+using Deal.Modules.Settings.Application.Services;
namespace Deal.Infrastructure.Integrations.Services;
///
-/// Собирает конфиг активного ИИ-провайдера для запросов ai-service.
+/// Собирает конфиг ИИ-провайдера для запросов ai-service из глобальной конфигурации
+/// оператора (общая для всех тенантов; провайдера, модель и ключ задаёт оператор).
///
-public sealed class AiProviderConfigBuilder
+/// Сервис глобальной конфигурации ИИ (ключ aiConfig).
+public sealed class AiProviderConfigBuilder(AiGlobalConfigService configService)
{
- // Ключ aiConfigs: поле apiKey переопределения провайдера.
- private const string ApiKeyField = "apiKey";
-
- // Ключ aiConfigs: поле baseUrl переопределения провайдера.
- private const string BaseUrlField = "baseUrl";
-
- // Ключ aiConfigs: поле model переопределения провайдера.
- private const string ModelField = "model";
-
- private const string EncryptedPrefix = "enc:";
-
- private readonly ISettingsStore _store;
- private readonly ISecretCipher _secretCipher;
+ // Текст AiUnavailableException, когда оператор ещё не сохранил конфигурацию ИИ.
+ private const string AiNotConfiguredMessage = "ИИ не настроен оператором системы";
///
- /// Создаёт сборщик конфига провайдера.
+ /// Собирает ProviderConfig для тела запроса ai-service
///
- /// KV-хранилище настроек тенанта (aiProvider/aiConfigs).
- /// Расшифровка секрета aiConfigs.apiKey.
- public AiProviderConfigBuilder(ISettingsStore store, ISecretCipher secretCipher)
- {
- ArgumentNullException.ThrowIfNull(store);
- ArgumentNullException.ThrowIfNull(secretCipher);
- _store = store;
- _secretCipher = secretCipher;
- }
-
- ///
- /// Собирает ProviderConfig активного провайдера для тела запроса ai-service.
- ///
- /// Конфиг: provider_id/base/model/api_key (расшифрованный)/api_style (см. ai.proto).
+ /// Конфиг: provider_id/base/model/api_key/api_style (см. ai.proto).
+ /// Конфигурация ИИ не задана оператором.
public async Task BuildAsync(CancellationToken ct)
{
- string providerId = await ReadProviderIdAsync(ct);
- AiProviderDefinition meta = AiProviders.All.FirstOrDefault(provider => provider.Id == providerId)
- ?? AiProviders.All[0]; // неизвестный id — дефолтный провайдер (python L28)
-
- JsonObject? overrides = await ReadAiConfigsOverrideAsync(ct);
- JsonObject? raw = overrides?[meta.Id] as JsonObject;
-
- string apiKey = ReadField(raw, ApiKeyField);
- if (apiKey.StartsWith(EncryptedPrefix, StringComparison.Ordinal))
+ AiGlobalConfigSnapshot snapshot = await configService.GetSnapshotAsync(ct);
+ if (!snapshot.Configured)
{
- apiKey = _secretCipher.Decrypt(apiKey);
- }
-
- string baseUrl = ReadField(raw, BaseUrlField);
- if (baseUrl.Length == 0)
- {
- baseUrl = meta.Base; // python L90: cfg.baseUrl or meta.base
- }
-
- string model = ReadField(raw, ModelField);
- if (model.Length == 0)
- {
- model = meta.Models.FirstOrDefault() ?? string.Empty; // python L91: cfg.model or models[0]
+ throw new AiUnavailableException(AiNotConfiguredMessage);
}
var config = new ProviderConfig
{
- ProviderId = meta.Id,
- BaseUrl = baseUrl,
- Model = model,
+ ProviderId = snapshot.ProviderId,
+ BaseUrl = snapshot.BaseUrl,
+ Model = snapshot.Model,
};
- if (apiKey.Length > 0)
+ if (snapshot.ApiKey.Length > 0)
{
- config.ApiKey = apiKey;
+ config.ApiKey = snapshot.ApiKey;
}
- if (meta.ApiStyle is { Length: > 0 } apiStyle)
+ if (snapshot.Meta?.ApiStyle is { Length: > 0 } apiStyle)
{
config.ApiStyle = apiStyle;
}
return config;
}
-
- private async Task 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(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 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(out string? text) ? text ?? string.Empty : string.Empty;
- }
}
diff --git a/src/core/Deal.Modules.Settings/Application/Models/AiCheckRequest.cs b/src/core/Deal.Modules.Settings/Application/Models/AiCheckRequest.cs
index e970229..60dcd5b 100644
--- a/src/core/Deal.Modules.Settings/Application/Models/AiCheckRequest.cs
+++ b/src/core/Deal.Modules.Settings/Application/Models/AiCheckRequest.cs
@@ -3,9 +3,9 @@ namespace Deal.Modules.Settings.Application.Models;
///
/// Данные проверки подключения AI-провайдера.
///
-/// Идентификатор провайдера (id из каталога AiProviders, ключ в aiConfigs).
-/// Эффективный базовый URL: переопределение aiConfigs или дефолт каталога.
-/// Активная модель: переопределение aiConfigs или первая модель каталога.
+/// Идентификатор провайдера (id из каталога AiProviders).
+/// Эффективный базовый URL провайдера.
+/// Активная модель.
/// API-ключ открытым текстом (пустая строка — ключ не задан).
/// True — локальный сервер (Ollama/LM Studio): ключ не требуется, HTTP не выполняется.
/// Стиль API: null — OpenAI-совместимый, "anthropic" — Messages API.
diff --git a/src/core/Deal.Modules.Settings/Application/Models/AiCheckResultDto.cs b/src/core/Deal.Modules.Settings/Application/Models/AiCheckResultDto.cs
index ede2c56..87a1847 100644
--- a/src/core/Deal.Modules.Settings/Application/Models/AiCheckResultDto.cs
+++ b/src/core/Deal.Modules.Settings/Application/Models/AiCheckResultDto.cs
@@ -1,7 +1,7 @@
namespace Deal.Modules.Settings.Application.Models;
///
-/// Результат проверки подключения AI-провайдера — тело POST /api/ai/check.
+/// Результат проверки подключения AI-провайдера — тело проверки связи операторской конфигурации ИИ.
///
/// True — подключение успешно (включая локальные серверы).
/// Сообщение для UI.
diff --git a/src/core/Deal.Modules.Settings/Application/Models/AiConfigPublicDto.cs b/src/core/Deal.Modules.Settings/Application/Models/AiConfigPublicDto.cs
deleted file mode 100644
index 829a820..0000000
--- a/src/core/Deal.Modules.Settings/Application/Models/AiConfigPublicDto.cs
+++ /dev/null
@@ -1,10 +0,0 @@
-namespace Deal.Modules.Settings.Application.Models;
-
-///
-/// Публичная форма конфигурации AI-провайдера в aiConfigs.
-///
-/// Базовый URL API провайдера.
-/// Активная модель.
-/// True — API-ключ задан (после расшифровки не пустой).
-/// Маскированный ключ: пусто, как есть (len ≤ 8) или «1234…5678».
-public sealed record AiConfigPublicDto(string BaseUrl, string Model, bool KeySet, string KeyMasked);
diff --git a/src/core/Deal.Modules.Settings/Application/Models/AiConfigSetting.cs b/src/core/Deal.Modules.Settings/Application/Models/AiConfigSetting.cs
deleted file mode 100644
index 507aded..0000000
--- a/src/core/Deal.Modules.Settings/Application/Models/AiConfigSetting.cs
+++ /dev/null
@@ -1,9 +0,0 @@
-namespace Deal.Modules.Settings.Application.Models;
-
-///
-/// Сохранённая конфигурация AI-провайдера в настройке aiConfigs
-///
-/// API-ключ: пустая строка или зашифрованное значение с префиксом enc:.
-/// Базовый URL API (переопределение дефолта провайдера).
-/// Активная модель.
-public sealed record AiConfigSetting(string ApiKey, string BaseUrl, string Model);
diff --git a/src/core/Deal.Modules.Settings/Application/Models/AiGlobalConfigMaskedDto.cs b/src/core/Deal.Modules.Settings/Application/Models/AiGlobalConfigMaskedDto.cs
new file mode 100644
index 0000000..3bacb40
--- /dev/null
+++ b/src/core/Deal.Modules.Settings/Application/Models/AiGlobalConfigMaskedDto.cs
@@ -0,0 +1,18 @@
+namespace Deal.Modules.Settings.Application.Models;
+
+///
+/// Маскированная форма глобальной конфигурации ИИ — тело ответа операторской ручки GET/PUT ai-config.
+///
+/// Id провайдера из каталога ; пусто — конфигурация не задана.
+/// Базовый URL API.
+/// Активная модель.
+/// True — API-ключ задан (после расшифровки не пустой).
+/// Маскированный ключ: пусто, как есть (len ≤ 8) или «1234…5678».
+/// Каталог провайдеров для выбора оператором.
+public sealed record AiGlobalConfigMaskedDto(
+ string ProviderId,
+ string BaseUrl,
+ string Model,
+ bool KeySet,
+ string KeyMasked,
+ IReadOnlyList Providers);
diff --git a/src/core/Deal.Modules.Settings/Application/Models/AiGlobalConfigSnapshot.cs b/src/core/Deal.Modules.Settings/Application/Models/AiGlobalConfigSnapshot.cs
new file mode 100644
index 0000000..d99122c
--- /dev/null
+++ b/src/core/Deal.Modules.Settings/Application/Models/AiGlobalConfigSnapshot.cs
@@ -0,0 +1,29 @@
+namespace Deal.Modules.Settings.Application.Models;
+
+///
+/// Снимок глобальной конфигурации ИИ-провайдера с расшифрованным ключом (только для внутреннего использования).
+///
+/// Id провайдера из каталога ; пусто — конфигурация не задана.
+/// Базовый URL API (эффективный: сохранённый или дефолт каталога).
+/// Активная модель (эффективная: сохранённая или первая из каталога).
+/// API-ключ открытым текстом (пусто — не задан; локальным провайдерам не нужен).
+public sealed record AiGlobalConfigSnapshot(
+ string ProviderId,
+ string BaseUrl,
+ string Model,
+ string ApiKey)
+{
+ ///
+ /// Провайдер каталога по или null для неизвестного/незаданного id
+ ///
+ public AiProviderDefinition? Meta => AiProviders.All.FirstOrDefault(provider => provider.Id == ProviderId);
+
+ ///
+ /// Конфигурация готова к вызовам ИИ: провайдер известен, адрес и модель непустые,
+ /// ключ задан (или не нужен локальному провайдеру)
+ ///
+ public bool Configured => Meta is not null
+ && BaseUrl.Length > 0
+ && Model.Length > 0
+ && (Meta.Local || ApiKey.Length > 0);
+}
diff --git a/src/core/Deal.Modules.Settings/Application/Models/AiGlobalConfigValue.cs b/src/core/Deal.Modules.Settings/Application/Models/AiGlobalConfigValue.cs
new file mode 100644
index 0000000..d8ea11c
--- /dev/null
+++ b/src/core/Deal.Modules.Settings/Application/Models/AiGlobalConfigValue.cs
@@ -0,0 +1,14 @@
+namespace Deal.Modules.Settings.Application.Models;
+
+///
+/// Сериализуемая форма значения глобальной конфигурации ИИ (ключ — зашифрованный).
+///
+/// Id провайдера.
+/// Зашифрованный ключ (префикс enc:) или пусто.
+/// Базовый URL.
+/// Модель.
+public sealed record AiGlobalConfigValue(
+ string ProviderId,
+ string ApiKey,
+ string BaseUrl,
+ string Model);
diff --git a/src/core/Deal.Modules.Settings/Application/Models/AiProviderPublicDto.cs b/src/core/Deal.Modules.Settings/Application/Models/AiProviderPublicDto.cs
new file mode 100644
index 0000000..d0e6ba0
--- /dev/null
+++ b/src/core/Deal.Modules.Settings/Application/Models/AiProviderPublicDto.cs
@@ -0,0 +1,16 @@
+namespace Deal.Modules.Settings.Application.Models;
+
+///
+/// Провайдер ИИ для выбора оператором
+///
+/// Идентификатор провайдера из каталога.
+/// Человекочитаемое имя.
+/// Базовый URL API по умолчанию.
+/// True — локальный сервер (ключ не нужен).
+/// Доступные модели каталога; пусто — оператор задаёт модель вручную.
+public sealed record AiProviderPublicDto(
+ string Id,
+ string Name,
+ string Base,
+ bool Local,
+ IReadOnlyList Models);
diff --git a/src/core/Deal.Modules.Settings/Application/Models/GlobalSettingsKeys.cs b/src/core/Deal.Modules.Settings/Application/Models/GlobalSettingsKeys.cs
index 3185601..6ce64ed 100644
--- a/src/core/Deal.Modules.Settings/Application/Models/GlobalSettingsKeys.cs
+++ b/src/core/Deal.Modules.Settings/Application/Models/GlobalSettingsKeys.cs
@@ -6,4 +6,9 @@ namespace Deal.Modules.Settings.Application.Models;
public static class GlobalSettingsKeys
{
public const string TelegramKeys = "telegramKeys";
+
+ ///
+ /// Глобальная конфигурация ИИ-провайдера (задаётся оператором, общая для всех тенантов)
+ ///
+ public const string AiConfig = "aiConfig";
}
diff --git a/src/core/Deal.Modules.Settings/Application/Models/ProviderPublicDto.cs b/src/core/Deal.Modules.Settings/Application/Models/ProviderPublicDto.cs
deleted file mode 100644
index a1326cb..0000000
--- a/src/core/Deal.Modules.Settings/Application/Models/ProviderPublicDto.cs
+++ /dev/null
@@ -1,11 +0,0 @@
-namespace Deal.Modules.Settings.Application.Models;
-
-///
-/// Провайдер ИИ в public-снимке настроек.
-///
-/// Идентификатор провайдера.
-/// Человекочитаемое имя.
-/// Базовый URL API.
-/// True — локальный сервер (ключ не нужен).
-/// Доступные модели.
-public sealed record ProviderPublicDto(string Id, string Name, string Base, bool Local, IReadOnlyList Models);
diff --git a/src/core/Deal.Modules.Settings/Application/Models/PublicSettingsDto.cs b/src/core/Deal.Modules.Settings/Application/Models/PublicSettingsDto.cs
index 435b8ae..39c1181 100644
--- a/src/core/Deal.Modules.Settings/Application/Models/PublicSettingsDto.cs
+++ b/src/core/Deal.Modules.Settings/Application/Models/PublicSettingsDto.cs
@@ -210,20 +210,4 @@ public sealed record PublicSettingsDto
///
public IReadOnlyDictionary ColState { get; init; } =
new Dictionary();
-
- ///
- /// Активный AI-провайдер
- ///
- public string AiProvider { get; init; } = string.Empty;
-
- ///
- /// Публичные конфигурации AI-провайдеров
- ///
- public IReadOnlyDictionary AiConfigs { get; init; } =
- new Dictionary();
-
- ///
- /// Статический список AI-провайдеров.
- ///
- public IReadOnlyList Providers { get; init; } = Array.Empty();
}
diff --git a/src/core/Deal.Modules.Settings/Application/Models/SettingKind.cs b/src/core/Deal.Modules.Settings/Application/Models/SettingKind.cs
index 24c365c..afa84da 100644
--- a/src/core/Deal.Modules.Settings/Application/Models/SettingKind.cs
+++ b/src/core/Deal.Modules.Settings/Application/Models/SettingKind.cs
@@ -35,11 +35,6 @@ public enum SettingKind
///
MyPrompts,
- ///
- /// Конфигурации AI-провайдеров с ключами
- ///
- AiConfigs,
-
///
/// Внутренний (непубличный) ключ
///
diff --git a/src/core/Deal.Modules.Settings/Application/Models/SettingsDefaults.cs b/src/core/Deal.Modules.Settings/Application/Models/SettingsDefaults.cs
index d9b7648..d79d13a 100644
--- a/src/core/Deal.Modules.Settings/Application/Models/SettingsDefaults.cs
+++ b/src/core/Deal.Modules.Settings/Application/Models/SettingsDefaults.cs
@@ -207,18 +207,6 @@ public static class SettingsDefaults
///
public static readonly IReadOnlyDictionary ColState = new Dictionary();
- // ── ИИ ──
-
- ///
- /// Дефолт «aiProvider»
- ///
- public const string AiProvider = "deepseek";
-
- ///
- /// Дефолт «aiConfigs»
- ///
- public static readonly IReadOnlyDictionary AiConfigs = BuildDefaultAiConfigs();
-
// ── Telegram / Discovery ──
///
@@ -255,16 +243,4 @@ public static class SettingsDefaults
/// Дефолт «discPaused»
///
public const bool DiscPaused = false;
-
- private static IReadOnlyDictionary BuildDefaultAiConfigs()
- {
- var result = new Dictionary(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;
- }
}
diff --git a/src/core/Deal.Modules.Settings/Application/Models/SettingsKeys.cs b/src/core/Deal.Modules.Settings/Application/Models/SettingsKeys.cs
index 35ca284..ba2a7a2 100644
--- a/src/core/Deal.Modules.Settings/Application/Models/SettingsKeys.cs
+++ b/src/core/Deal.Modules.Settings/Application/Models/SettingsKeys.cs
@@ -104,8 +104,6 @@ public static class SettingsKeys
public const string RateSource = "rateSource";
- public const string AiProvider = "aiProvider";
-
public const string AiPrompt = "aiPrompt";
public const string AiFilterPrompt = "aiFilterPrompt";
@@ -147,8 +145,6 @@ public static class SettingsKeys
public const string MyPrompts = "myPrompts";
- public const string AiConfigs = "aiConfigs";
-
///
/// Каталог публичных ключей
///
@@ -181,7 +177,6 @@ public static class SettingsKeys
// String
[TargetCurrency] = SettingKind.String,
[RateSource] = SettingKind.String,
- [AiProvider] = SettingKind.String,
[AiPrompt] = SettingKind.String,
[AiFilterPrompt] = SettingKind.String,
[CardPrompt] = SettingKind.String,
@@ -202,7 +197,6 @@ public static class SettingsKeys
[ColState] = SettingKind.Dict,
// special
[MyPrompts] = SettingKind.MyPrompts,
- [AiConfigs] = SettingKind.AiConfigs,
};
///
diff --git a/src/core/Deal.Modules.Settings/Application/Services/AiGlobalConfigService.cs b/src/core/Deal.Modules.Settings/Application/Services/AiGlobalConfigService.cs
new file mode 100644
index 0000000..28c029e
--- /dev/null
+++ b/src/core/Deal.Modules.Settings/Application/Services/AiGlobalConfigService.cs
@@ -0,0 +1,226 @@
+using System.Text.Json;
+using Deal.Modules.Settings.Application.Abstractions;
+using Deal.Modules.Settings.Application.Models;
+
+namespace Deal.Modules.Settings.Application.Services;
+
+///
+/// Читает, расшифровывает и сохраняет глобальную конфигурацию ИИ-провайдера (общую для всех
+/// тенантов) из хранилища глобальных настроек. Провайдера, модель и ключ задаёт оператор —
+/// пользователи тенантов модели не настраивают.
+///
+/// KV-хранилище глобальных настроек оператора (таблица public.global_settings).
+/// Шифр секретов (AES-256-GCM, формат enc:).
+public sealed class AiGlobalConfigService(IGlobalSettingsStore store, ISecretCipher cipher)
+{
+ ///
+ /// Минимальная длина API-ключа провайдера ИИ
+ ///
+ 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 (camelCase, как пишет SaveAsync).
+ private const string ProviderIdProperty = "providerId";
+ private const string ApiKeyProperty = "apiKey";
+ private const string BaseUrlProperty = "baseUrl";
+ private const string ModelProperty = "model";
+
+ // Геометрия маски секрета: короткий (≤8) → «x…»; иначе «1234…5678».
+ private const int MaskShortMaxLength = 8;
+ private const int MaskShortVisibleChars = 1;
+ private const int MaskEdgeVisibleChars = 4;
+
+ // Каталог провайдеров для выбора оператором (не зависит от сохранённой конфигурации).
+ private static readonly IReadOnlyList 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,
+ };
+
+ ///
+ /// Читает текущую конфигурацию с эффективными значениями
+ ///
+ /// Снимок; повреждённая строка или неизвестный провайдер — конфигурация не задана.
+ public async Task 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, BaseUrlProperty);
+ if (baseUrl.Length == 0 || !IsBaseUrlMutable(meta))
+ {
+ baseUrl = meta.Base;
+ }
+
+ string model = ReadString(root, ModelProperty);
+ if (model.Length == 0)
+ {
+ model = meta.Models.FirstOrDefault() ?? string.Empty;
+ }
+
+ string apiKey = cipher.Decrypt(ReadString(root, ApiKeyProperty));
+ return new AiGlobalConfigSnapshot(providerId, baseUrl, model, apiKey);
+ }
+ catch (JsonException)
+ {
+ // Повреждённая строка aiConfig — конфигурация не задана (мягкая семантика, как SettingsService).
+ return EmptySnapshot();
+ }
+ }
+
+ ///
+ /// Маскированная форма конфигурации для операторской ручки
+ ///
+ /// DTO с флагом keySet и маской ключа.
+ public async Task 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);
+ }
+
+ ///
+ /// Сохраняет глобальную конфигурацию ИИ (эффективные значения; ключ шифруется)
+ ///
+ /// Id провайдера из каталога .
+ /// Базовый URL (учитывается только для local/custom провайдеров).
+ /// Модель (у каталогных провайдеров пустая заменяется первой из каталога).
+ /// API-ключ открытым текстом (не нужен локальным провайдерам).
+ /// Значения не прошли валидацию (см. ).
+ 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);
+ }
+
+ ///
+ /// Валиден ли API-ключ к шифрованию и сохранению
+ ///
+ /// Проверяемое значение (уже без пробелов).
+ /// True — значение допустимо.
+ 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)),
+ };
+ }
+}
diff --git a/src/core/Deal.Modules.Settings/Application/Services/SettingsService.PatchScalarKeys.cs b/src/core/Deal.Modules.Settings/Application/Services/SettingsService.PatchScalarKeys.cs
index 37dfd35..38e38af 100644
--- a/src/core/Deal.Modules.Settings/Application/Services/SettingsService.PatchScalarKeys.cs
+++ b/src/core/Deal.Modules.Settings/Application/Services/SettingsService.PatchScalarKeys.cs
@@ -92,7 +92,7 @@ public sealed partial class SettingsService
writes[key] = JsonSerializer.Serialize(number);
}
- // Применяет строковый ключ: targetCurrency — Upper; aiProvider — только из каталога провайдеров.
+ // Применяет строковый ключ: targetCurrency — Upper.
// key: Имя ключа.
// value: JSON-значение из тела PATCH.
// writes: Накопитель записей (key → valueJson).
@@ -111,11 +111,6 @@ public sealed partial class SettingsService
text = text.ToUpperInvariant();
}
- if (key == SettingsKeys.AiProvider && !ProviderIds.Contains(text))
- {
- return; // aiProvider вне списка провайдеров — пропуск (L134–135)
- }
-
writes[key] = JsonSerializer.Serialize(text);
}
diff --git a/src/core/Deal.Modules.Settings/Application/Services/SettingsService.PatchSecrets.cs b/src/core/Deal.Modules.Settings/Application/Services/SettingsService.PatchSecrets.cs
deleted file mode 100644
index 975eb36..0000000
--- a/src/core/Deal.Modules.Settings/Application/Services/SettingsService.PatchSecrets.cs
+++ /dev/null
@@ -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 overrides,
- Dictionary writes,
- List removals)
- {
- if (value.ValueKind != JsonValueKind.Object)
- {
- return;
- }
-
- // База — эффективные конфиги (дефолты + сохранённые переопределения): PATCH дописывает поля.
- var effective = new Dictionary(
- 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 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;
- }
-}
diff --git a/src/core/Deal.Modules.Settings/Application/Services/SettingsService.PublicForms.cs b/src/core/Deal.Modules.Settings/Application/Services/SettingsService.PublicForms.cs
deleted file mode 100644
index 5df5241..0000000
--- a/src/core/Deal.Modules.Settings/Application/Services/SettingsService.PublicForms.cs
+++ /dev/null
@@ -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)),
- };
- }
-}
diff --git a/src/core/Deal.Modules.Settings/Application/Services/SettingsService.ReadMerge.cs b/src/core/Deal.Modules.Settings/Application/Services/SettingsService.ReadMerge.cs
index 76f5e0f..d9063db 100644
--- a/src/core/Deal.Modules.Settings/Application/Services/SettingsService.ReadMerge.cs
+++ b/src/core/Deal.Modules.Settings/Application/Services/SettingsService.ReadMerge.cs
@@ -201,34 +201,6 @@ public sealed partial class SettingsService
return new Dictionary(StringComparer.Ordinal);
}
- // Объединяет дефолты aiConfigs и сохранённое переопределение (переопределение — на провайдера).
- // defaults: Дефолтные конфиги на всех провайдеров каталога.
- // overrides: Сохранённые переопределения.
- // Возвращает: Эффективные конфиги: провайдеры дефолтов + перекрытия из хранилища (неизвестные пропускаются).
- private static IReadOnlyDictionary MergeAiConfigs(IReadOnlyDictionary defaults, Dictionary 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: Сохранённые переопределения.
// Возвращает: Список промптов из хранилища или дефолт (пустой).
diff --git a/src/core/Deal.Modules.Settings/Application/Services/SettingsService.cs b/src/core/Deal.Modules.Settings/Application/Services/SettingsService.cs
index eb8a3ee..b8b08f0 100644
--- a/src/core/Deal.Modules.Settings/Application/Services/SettingsService.cs
+++ b/src/core/Deal.Modules.Settings/Application/Services/SettingsService.cs
@@ -8,30 +8,14 @@ namespace Deal.Modules.Settings.Application.Services;
/// Сервис настроек тенанта
///
/// KV-хранилище настроек тенанта (таблица settings).
-/// Шифр секретов.
/// Слушатели смены настроек конверсии; пусто — no-op.
public sealed partial class SettingsService(
ISettingsStore store,
- ISecretCipher secretCipher,
IEnumerable listeners)
{
-
- private const string EncryptedValuePrefix = "enc:";
-
- // Символ-заполнитель маски секрета (U+2026, «1234…5678»): строка, содержащая его,
- // — это маска из public-снимка, а не новый секрет (Security review: не шифровать маску).
- private const string MaskEllipsis = "…";
-
- // Id провайдера «Другой (OpenAI-совместимый)» — единственный не-local, чей baseUrl
- // задаёт пользователь (остальным адрес фиксирован каталогом, SSRF-гейт).
- private const string CustomProviderId = "custom";
-
// Максимум элементов списка-настройки при записи из PATCH (срез 200).
private const int MaxListItems = 200;
- // Минимальная длина нового API-ключа, который принимает PATCH (≥8 симв.).
- private const int ApiKeyMinLength = 8;
-
// Максимум элементов «Моих промптов» при записи из PATCH (≤100).
private const int MaxMyPrompts = 100;
@@ -65,16 +49,6 @@ public sealed partial class SettingsService(
[SettingsKeys.DiscEvalThreshold] = (1, 100),
};
- // Идентификаторы известных провайдеров (валидация aiProvider/aiConfigs).
- private static readonly IReadOnlySet ProviderIds =
- AiProviders.All.Select(provider => provider.Id).ToHashSet(StringComparer.Ordinal);
-
- private static readonly IReadOnlyList PublicProviders =
- AiProviders.All
- .Select(provider => new ProviderPublicDto(
- provider.Id, provider.Name, provider.Base, provider.Local, provider.Models))
- .ToList();
-
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
@@ -105,7 +79,6 @@ public sealed partial class SettingsService(
Dictionary preparedDelays = PrepareDelayClamps(body, overrides);
var writes = new Dictionary(StringComparer.Ordinal);
- var removals = new List();
foreach ((string key, JsonElement value) in body)
{
@@ -162,10 +135,6 @@ public sealed partial class SettingsService(
}
break;
-
- case SettingKind.AiConfigs:
- ApplyAiConfigsKey(value, overrides, writes, removals);
- break;
}
}
@@ -174,11 +143,6 @@ public sealed partial class SettingsService(
await store.SetAsync(key, valueJson, ct);
}
- foreach (string key in removals)
- {
- await store.RemoveAsync(key, ct);
- }
-
if (AffectsConversion(body))
{
await NotifyRatesChangedAsync(ct);
@@ -214,8 +178,6 @@ public sealed partial class SettingsService(
// Возвращает: DTO public-снимка.
private PublicSettingsDto BuildSnapshot(Dictionary overrides)
{
- IReadOnlyDictionary aiConfigs = MergeAiConfigs(SettingsDefaults.AiConfigs, overrides);
-
var result = new PublicSettingsDto
{
// Хранилище / обработка
@@ -269,11 +231,6 @@ public sealed partial class SettingsService(
DiscEvalThreshold = MergeInt(overrides, SettingsKeys.DiscEvalThreshold, SettingsDefaults.DiscEvalThreshold),
DiscPaused = MergeBool(overrides, SettingsKeys.DiscPaused, SettingsDefaults.DiscPaused),
ColState = MergeDict(overrides, SettingsKeys.ColState),
-
- // ИИ-провайдеры (публичная форма)
- AiProvider = MergeString(overrides, SettingsKeys.AiProvider, SettingsDefaults.AiProvider),
- AiConfigs = aiConfigs.ToDictionary(pair => pair.Key, pair => ToPublic(pair.Value), StringComparer.Ordinal),
- Providers = PublicProviders,
};
return result;
diff --git a/src/core/Deal.Modules.Tenants/Application/Models/AuditEvents.cs b/src/core/Deal.Modules.Tenants/Application/Models/AuditEvents.cs
index 518e87c..571d3eb 100644
--- a/src/core/Deal.Modules.Tenants/Application/Models/AuditEvents.cs
+++ b/src/core/Deal.Modules.Tenants/Application/Models/AuditEvents.cs
@@ -154,4 +154,9 @@ public static class AuditEvents
/// Оператор изменил глобальные ключи Telegram api_id/api_hash.
///
public const string TelegramKeysChanged = "telegram_keys_changed";
+
+ ///
+ /// Оператор изменил глобальную конфигурацию ИИ-провайдера.
+ ///
+ public const string AiConfigChanged = "ai_config_changed";
}
diff --git a/src/core/Deal.Modules.Tenants/Application/Models/AuditFields.cs b/src/core/Deal.Modules.Tenants/Application/Models/AuditFields.cs
index 8503abb..e25b25b 100644
--- a/src/core/Deal.Modules.Tenants/Application/Models/AuditFields.cs
+++ b/src/core/Deal.Modules.Tenants/Application/Models/AuditFields.cs
@@ -119,4 +119,24 @@ public static class AuditFields
/// Диалог Telegram
///
public const string DialogId = "dialogId";
+
+ ///
+ /// Id ИИ-провайдера
+ ///
+ public const string ProviderId = "providerId";
+
+ ///
+ /// Базовый URL ИИ-провайдера
+ ///
+ public const string BaseUrl = "baseUrl";
+
+ ///
+ /// Модель ИИ-провайдера
+ ///
+ public const string Model = "model";
+
+ ///
+ /// Признак заданного API-ключа ИИ-провайдера
+ ///
+ public const string KeySet = "keySet";
}
diff --git a/src/core/tests/Deal.Tests.Unit/Api/AiGlobalConfigServiceTests.cs b/src/core/tests/Deal.Tests.Unit/Api/AiGlobalConfigServiceTests.cs
new file mode 100644
index 0000000..a7c3a76
--- /dev/null
+++ b/src/core/tests/Deal.Tests.Unit/Api/AiGlobalConfigServiceTests.cs
@@ -0,0 +1,128 @@
+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;
+
+///
+/// Тесты сервиса глобальной конфигурации ИИ оператора
+///
+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(
+ () => _service.SaveAsync("несуществующий", string.Empty, string.Empty, ApiKey, CancellationToken.None));
+ }
+
+ [Fact]
+ public async Task SaveAsync_CustomWithoutModel_Throws()
+ {
+ await Assert.ThrowsAsync(
+ () => _service.SaveAsync("custom", "https://llm.local/v1", string.Empty, ApiKey, CancellationToken.None));
+ }
+
+ [Fact]
+ public async Task SaveAsync_ShortApiKey_Throws()
+ {
+ await Assert.ThrowsAsync(
+ () => _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);
+ }
+
+ [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));
+ }
+}
diff --git a/src/core/tests/Deal.Tests.Unit/Contracts/GrpcAiToolsTests.cs b/src/core/tests/Deal.Tests.Unit/Contracts/GrpcAiToolsTests.cs
index 437011a..b81a3e0 100644
--- a/src/core/tests/Deal.Tests.Unit/Contracts/GrpcAiToolsTests.cs
+++ b/src/core/tests/Deal.Tests.Unit/Contracts/GrpcAiToolsTests.cs
@@ -139,7 +139,7 @@ public sealed class GrpcAiToolsTests
return new GrpcAiTools(
tenantContext,
connection,
- new AiProviderConfigBuilder(settings.Store, cipher),
+ new AiProviderConfigBuilder(TestAiConfig.New(new TestGlobalSettingsStore(), cipher)),
new TokenUsageRecorder(settings.Store, limits.Store, tenantContext, new TokenUsageEventService(new TestTokenUsageEventStore().Store)),
NullLogger.Instance);
}
diff --git a/src/core/tests/Deal.Tests.Unit/Contracts/IntegrationsDiTests.cs b/src/core/tests/Deal.Tests.Unit/Contracts/IntegrationsDiTests.cs
index e30468d..9594547 100644
--- a/src/core/tests/Deal.Tests.Unit/Contracts/IntegrationsDiTests.cs
+++ b/src/core/tests/Deal.Tests.Unit/Contracts/IntegrationsDiTests.cs
@@ -9,6 +9,7 @@ using Deal.Modules.Kanban.Application.Abstractions;
using Deal.Modules.Pipeline.Application.Parse;
using Deal.Modules.Pipeline.Application.Services;
using Deal.Modules.Settings.Application.Abstractions;
+using Deal.Modules.Settings.Application.Services;
using Deal.Modules.Tenants.Application.Abstractions;
using Deal.Modules.Tenants.Application.Services;
using Deal.SharedKernel.Tenants.Abstractions;
@@ -144,7 +145,9 @@ public sealed class IntegrationsDiTests
tenantContext.SetTenant(new TenantId(Guid.NewGuid().ToString("N")));
services.AddSingleton(tenantContext);
services.AddScoped(_ => new TestSettingsStore().Store);
+ services.AddScoped(_ => new TestGlobalSettingsStore().Store);
services.AddScoped(_ => TestCiphers.New());
+ services.AddScoped();
services.AddScoped(_ => new TestKanjStore().Store);
services.AddScoped(_ => new TestMlLearningStore().Store);
services.AddScoped(_ => new TestTenantLimitStore().Store);
diff --git a/src/core/tests/Deal.Tests.Unit/Contracts/PipelineWorkerGrpcAiTests.cs b/src/core/tests/Deal.Tests.Unit/Contracts/PipelineWorkerGrpcAiTests.cs
index 4cea455..15bc9c7 100644
--- a/src/core/tests/Deal.Tests.Unit/Contracts/PipelineWorkerGrpcAiTests.cs
+++ b/src/core/tests/Deal.Tests.Unit/Contracts/PipelineWorkerGrpcAiTests.cs
@@ -178,7 +178,7 @@ public sealed class PipelineWorkerGrpcAiTests
var grpcClassifier = new GrpcAiClassifier(
tenantContext,
connection,
- new AiProviderConfigBuilder(settings.Store, TestCiphers.New()),
+ new AiProviderConfigBuilder(TestAiConfig.New(new TestGlobalSettingsStore(), TestCiphers.New())),
new AiClassifyContextBuilder(settings.Store, kanjStore.Store),
new TokenUsageRecorder(settings.Store, limits.Store, tenantContext, new TokenUsageEventService(new TestTokenUsageEventStore().Store)),
NullLogger.Instance);
diff --git a/src/core/tests/Deal.Tests.Unit/Modules/Settings/SettingsCatalogTests.cs b/src/core/tests/Deal.Tests.Unit/Modules/Settings/SettingsCatalogTests.cs
index 9158bec..db00a19 100644
--- a/src/core/tests/Deal.Tests.Unit/Modules/Settings/SettingsCatalogTests.cs
+++ b/src/core/tests/Deal.Tests.Unit/Modules/Settings/SettingsCatalogTests.cs
@@ -38,7 +38,6 @@ public sealed class SettingsCatalogTests
// String
["targetCurrency"] = SettingKind.String,
["rateSource"] = SettingKind.String,
- ["aiProvider"] = SettingKind.String,
["aiPrompt"] = SettingKind.String,
["aiFilterPrompt"] = SettingKind.String,
["cardPrompt"] = SettingKind.String,
@@ -59,10 +58,9 @@ public sealed class SettingsCatalogTests
["colState"] = SettingKind.Dict,
// special
["myPrompts"] = SettingKind.MyPrompts,
- ["aiConfigs"] = SettingKind.AiConfigs,
};
- private const int ExpectedPublicKeysCount = 43;
+ private const int ExpectedPublicKeysCount = 41;
[Fact]
public void PublicKeys_CoverAllApiMapKeysWithCorrectCategories()
@@ -169,26 +167,11 @@ public sealed class SettingsCatalogTests
Assert.True(SettingsDefaults.BlockResumes);
Assert.Equal("RUB", SettingsDefaults.TargetCurrency);
Assert.Equal("cbr", SettingsDefaults.RateSource);
- Assert.Equal("deepseek", SettingsDefaults.AiProvider);
Assert.False(SettingsDefaults.DiscPaused);
Assert.Empty(SettingsDefaults.ColState);
Assert.Empty(SettingsDefaults.MyPrompts);
}
- [Fact]
- public void Defaults_AiConfigsSeededForEveryProviderWithEmptyKey()
- {
- Assert.Equal(AiProviders.All.Count, SettingsDefaults.AiConfigs.Count);
-
- foreach (AiProviderDefinition provider in AiProviders.All)
- {
- AiConfigSetting config = SettingsDefaults.AiConfigs[provider.Id];
- Assert.Equal("", config.ApiKey);
- Assert.Equal(provider.Base, config.BaseUrl);
- Assert.Equal(provider.Models.FirstOrDefault() ?? string.Empty, config.Model);
- }
- }
-
[Fact]
public void DefaultPrompts_ContainKeyMarkersFromDataJs()
{
diff --git a/src/core/tests/Deal.Tests.Unit/Support/GrpcAiClassifierTests.cs b/src/core/tests/Deal.Tests.Unit/Support/GrpcAiClassifierTests.cs
index 5457266..834c1a5 100644
--- a/src/core/tests/Deal.Tests.Unit/Support/GrpcAiClassifierTests.cs
+++ b/src/core/tests/Deal.Tests.Unit/Support/GrpcAiClassifierTests.cs
@@ -12,6 +12,7 @@ using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Pipeline.Application.Services;
using Deal.Modules.Settings.Application.Abstractions;
using Deal.Modules.Settings.Application.Models;
+using Deal.Modules.Settings.Application.Services;
using Deal.Modules.Tenants.Application.Services;
using Deal.SharedKernel.Tenants.Models;
using Deal.Tests.Unit.Grpc;
@@ -71,7 +72,8 @@ public sealed class GrpcAiClassifierTests
Assert.Equal("deepseek", service.LastFilter.ProviderConfig.ProviderId);
Assert.Equal("https://api.deepseek.com", service.LastFilter.ProviderConfig.BaseUrl);
Assert.Equal("deepseek-v4-flash", service.LastFilter.ProviderConfig.Model);
- Assert.False(service.LastFilter.ProviderConfig.HasApiKey);
+ Assert.True(service.LastFilter.ProviderConfig.HasApiKey);
+ Assert.Equal(TestAiConfig.DefaultApiKey, service.LastFilter.ProviderConfig.ApiKey);
Assert.False(service.LastFilter.ProviderConfig.HasApiStyle);
Assert.Equal(TenantIdValue, Assert.Single(service.RequestTenantIds));
@@ -220,34 +222,24 @@ public sealed class GrpcAiClassifierTests
}
[Fact]
- public async Task ClassifyAsync_ProviderConfig_ReadsDecryptedKeyAndOverridesFromSettings()
+ public async Task ClassifyAsync_ProviderConfig_UsesGlobalOperatorConfig()
{
await AiGrpcTestHost.RunAsync(AiGrpcTestHost.DefaultToken, new RecordingAiService(), async (port, service) =>
{
service.ClassifyReply = new ClassifyReply { Ok = true, Json = """{"title":"Заголовок","stack":[],"is_spam":false}""" };
(TestSettingsStore settings, ISecretCipher cipher, TestKanjStore kanjStore) = Context(port);
- settings.Preload(SettingsKeys.AiProvider, Json("anthropic"));
- settings.Preload(
- SettingsKeys.AiConfigs,
- new JsonObject
- {
- ["anthropic"] = new JsonObject
- {
- ["apiKey"] = cipher.Encrypt("sk-ant-test-secret"),
- ["baseUrl"] = "https://llm.local",
- ["model"] = "claude-custom",
- },
- }.ToJsonString());
+ AiGlobalConfigService aiConfig = TestAiConfig.New(
+ new TestGlobalSettingsStore(), cipher, providerId: "anthropic", apiKey: "sk-ant-test-secret");
- IAiClassifier classifier = CreateClassifier(port, settings, cipher, kanjStore);
+ IAiClassifier classifier = CreateClassifier(port, settings, cipher, kanjStore, aiConfig: aiConfig);
await classifier.ClassifyAsync("текст", CancellationToken.None);
- // ProviderConfig из настроек: id/base/model — переопределение, apiKey расшифрован, api_style — из каталога.
+ // ProviderConfig глобальной конфигурации: id/apiKey, адрес каталога фиксирован, api_style — из каталога.
Assert.NotNull(service.LastClassify);
ProviderConfig config = service.LastClassify!.ProviderConfig;
Assert.Equal("anthropic", config.ProviderId);
- Assert.Equal("https://llm.local", config.BaseUrl);
- Assert.Equal("claude-custom", config.Model);
+ Assert.Equal("https://api.anthropic.com", config.BaseUrl);
+ Assert.Equal("claude-sonnet-5", config.Model);
Assert.True(config.HasApiKey);
Assert.Equal("sk-ant-test-secret", config.ApiKey);
Assert.True(config.HasApiStyle);
@@ -255,6 +247,33 @@ public sealed class GrpcAiClassifierTests
});
}
+ [Fact]
+ public async Task ClassifyAsync_ProviderConfig_HonorsCustomBaseUrlAndModel()
+ {
+ await AiGrpcTestHost.RunAsync(AiGrpcTestHost.DefaultToken, new RecordingAiService(), async (port, service) =>
+ {
+ service.ClassifyReply = new ClassifyReply { Ok = true, Json = """{"title":"Заголовок","stack":[],"is_spam":false}""" };
+ (TestSettingsStore settings, ISecretCipher cipher, TestKanjStore kanjStore) = Context(port);
+ AiGlobalConfigService aiConfig = TestAiConfig.New(
+ new TestGlobalSettingsStore(),
+ cipher,
+ providerId: "custom",
+ baseUrl: "https://llm.local/v1",
+ model: "my-model",
+ apiKey: "sk-custom-key-123");
+
+ IAiClassifier classifier = CreateClassifier(port, settings, cipher, kanjStore, aiConfig: aiConfig);
+ await classifier.ClassifyAsync("текст", CancellationToken.None);
+
+ // OpenAI-совместимый провайдер: адрес и модель задаёт оператор.
+ ProviderConfig config = service.LastClassify!.ProviderConfig;
+ Assert.Equal("custom", config.ProviderId);
+ Assert.Equal("https://llm.local/v1", config.BaseUrl);
+ Assert.Equal("my-model", config.Model);
+ Assert.Equal("sk-custom-key-123", config.ApiKey);
+ });
+ }
+
[Fact]
public async Task ClassifyAsync_LongMessage_TruncatesUserContextTo5000CodePoints()
{
@@ -277,25 +296,28 @@ public sealed class GrpcAiClassifierTests
// Создаёт GrpcAiClassifier к хосту-фейку на эфемерном порту в tenant-контексте теста.
// port: Порт хоста-фейка ai-service.
// settings: KV-хранилище тенанта (дефолты при пустом).
- // cipher: Шифр секретов (расшифровка apiKey).
+ // cipher: Шифр секретов.
// kanjStore: Канбан-фейк (доски/примеры контекста классификации).
// limits: Фейк-хранилище лимитов (списание usage; при null — собственный экземпляр).
+ // aiConfig: Глобальная конфигурация ИИ (при null — провайдер по умолчанию с ключом).
// Возвращает: Экземпляр GrpcAiClassifier.
private static GrpcAiClassifier CreateClassifier(
int port,
TestSettingsStore settings,
ISecretCipher cipher,
TestKanjStore kanjStore,
- TestTenantLimitStore? limits = null)
+ TestTenantLimitStore? limits = null,
+ AiGlobalConfigService? aiConfig = null)
{
limits ??= new TestTenantLimitStore();
+ aiConfig ??= TestAiConfig.New(new TestGlobalSettingsStore(), cipher);
ITenantContext tenantContext = new TenantContext();
tenantContext.SetTenant(new TenantId(TenantIdValue));
var connection = new AiGrpcConnection(new AiServiceOptions { UseLocal = false, Endpoint = $"http://127.0.0.1:{port}" });
return new GrpcAiClassifier(
tenantContext,
connection,
- new AiProviderConfigBuilder(settings.Store, cipher),
+ new AiProviderConfigBuilder(aiConfig),
new AiClassifyContextBuilder(settings.Store, kanjStore.Store),
new TokenUsageRecorder(settings.Store, limits.Store, tenantContext, new TokenUsageEventService(new TestTokenUsageEventStore().Store)),
NullLogger.Instance);
diff --git a/src/core/tests/Deal.Tests.Unit/Support/OperatorAiConfigEndpointsHttpTests.cs b/src/core/tests/Deal.Tests.Unit/Support/OperatorAiConfigEndpointsHttpTests.cs
new file mode 100644
index 0000000..a4b0ece
--- /dev/null
+++ b/src/core/tests/Deal.Tests.Unit/Support/OperatorAiConfigEndpointsHttpTests.cs
@@ -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;
+
+///
+/// HTTP-тесты операторских ручек глобальной конфигурации ИИ
+///
+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 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 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 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 ReadJsonAsync(HttpResponseMessage response)
+ {
+ await using Stream stream = await response.Content.ReadAsStreamAsync();
+ using var document = await JsonDocument.ParseAsync(stream);
+ return document.RootElement.Clone();
+ }
+}
diff --git a/src/core/tests/Deal.Tests.Unit/Support/OperatorAuthHttpHost.cs b/src/core/tests/Deal.Tests.Unit/Support/OperatorAuthHttpHost.cs
index d603551..beaafff 100644
--- a/src/core/tests/Deal.Tests.Unit/Support/OperatorAuthHttpHost.cs
+++ b/src/core/tests/Deal.Tests.Unit/Support/OperatorAuthHttpHost.cs
@@ -10,6 +10,7 @@ using Deal.Infrastructure.Integrations.Services;
using Deal.Infrastructure.Persistence;
using Deal.Infrastructure.Tenancy;
using Deal.Modules.Settings.Application.Abstractions;
+using Deal.Modules.Settings.Application.Services;
using Deal.Modules.Tenants.Application.Abstractions;
using Deal.Modules.Tenants.Application.Registrars;
using Deal.SharedKernel.Tenants.Abstractions;
@@ -196,6 +197,8 @@ internal static class OperatorAuthHttpHost
builder.Services.AddSingleton(TestCiphers.New());
// Сервис глобальных ключей Telegram (операторские ручки /api/operator/settings/telegram-keys).
builder.Services.AddScoped();
+ // Сервис глобальной конфигурации ИИ (операторские ручки /api/operator/settings/ai-config).
+ builder.Services.AddScoped();
builder.Services.AddSingleton(effectiveTokenUsageStore.Store);
builder.Services.AddSingleton(_ => new TestTenantProvisioner().Provisioner);
builder.Services.AddScoped();
@@ -213,6 +216,9 @@ internal static class OperatorAuthHttpHost
builder.Services.AddScoped();
builder.Services.AddScoped();
builder.Services.AddSingleton(new TestAuditReferenceResolver());
+ // Проверка связи с ИИ-провайдером (операторская ручка ai-config/check): локальные
+ // провайдеры отвечают без HTTP, поэтому клиента достаточно с таймаутом по умолчанию.
+ builder.Services.AddSingleton(new AiConnectionChecker(new HttpClient()));
WebApplication app = builder.Build();
app.UseMiddleware();
diff --git a/src/core/tests/Deal.Tests.Unit/Support/SettingsServiceTests.cs b/src/core/tests/Deal.Tests.Unit/Support/SettingsServiceTests.cs
index d0c8c13..23c81cd 100644
--- a/src/core/tests/Deal.Tests.Unit/Support/SettingsServiceTests.cs
+++ b/src/core/tests/Deal.Tests.Unit/Support/SettingsServiceTests.cs
@@ -14,12 +14,11 @@ namespace Deal.Tests.Unit.Support;
public sealed class SettingsServiceTests
{
private readonly TestSettingsStore _store = new();
- private readonly ISecretCipher _cipher = TestCiphers.New();
private readonly SettingsService _service;
public SettingsServiceTests()
{
- _service = new SettingsService(_store.Store, _cipher, Array.Empty());
+ _service = new SettingsService(_store.Store, Array.Empty());
}
// ─── GET: снимок ─────────────────────────────────────────────────────────
@@ -39,7 +38,6 @@ public sealed class SettingsServiceTests
Assert.Equal("both", snapshot.WantedType);
Assert.Equal("RUB", snapshot.TargetCurrency);
Assert.Equal("cbr", snapshot.RateSource);
- Assert.Equal("deepseek", snapshot.AiProvider);
Assert.True(snapshot.ConversionOn);
Assert.True(snapshot.RemindersEnabled);
Assert.Equal(50, snapshot.DiscJoinLimit);
@@ -47,26 +45,18 @@ public sealed class SettingsServiceTests
Assert.Equal(70, snapshot.DiscJoinDelayMax);
Assert.False(snapshot.DiscPaused);
- // Пустые коллекции и секреты.
+ // Пустые коллекции.
Assert.Empty(snapshot.MyPrompts);
Assert.Empty(snapshot.ColState);
- Assert.Equal(7, snapshot.Providers.Count);
- Assert.Equal(7, snapshot.AiConfigs.Count);
- Assert.All(snapshot.AiConfigs, pair => Assert.False(pair.Value.KeySet));
- Assert.All(snapshot.AiConfigs, pair => Assert.Equal(string.Empty, pair.Value.KeyMasked));
}
[Fact]
public async Task GetPublicAsync_StoredOverrides_OverlapDefaults()
{
- // Переопределения: int, список, colState, aiConfigs (с секретом).
+ // Переопределения: int, список, colState.
_store.Preload(SettingsKeys.MinLen, "100");
_store.Preload(SettingsKeys.StopPhrases, "[\"фраза один\",\"фраза два\"]");
_store.Preload(SettingsKeys.ColState, "{\"colA\":{\"collapsed\":true},\"num\":42}");
- string aiToken = _cipher.Encrypt("sk-1234567890ab");
- _store.Preload(
- SettingsKeys.AiConfigs,
- JsonSerializer.Serialize(new { deepseek = new { apiKey = aiToken, baseUrl = "https://x.example/v1", model = "m1" } }));
PublicSettingsDto snapshot = await _service.GetPublicAsync(CancellationToken.None);
@@ -79,29 +69,6 @@ public sealed class SettingsServiceTests
Assert.Equal(2, snapshot.ColState.Count);
Assert.True(((JsonElement)snapshot.ColState["colA"]!).GetProperty("collapsed").GetBoolean());
Assert.Equal(42, ((JsonElement)snapshot.ColState["num"]!).GetInt32());
-
- // aiConfigs: только переопределённый провайдер; дефолты остальных не тронуты.
- AiConfigPublicDto deepseek = snapshot.AiConfigs["deepseek"];
- Assert.True(deepseek.KeySet);
- Assert.Equal("sk-1…90ab", deepseek.KeyMasked);
- Assert.Equal("https://x.example/v1", deepseek.BaseUrl);
- Assert.Equal("m1", deepseek.Model);
- Assert.False(snapshot.AiConfigs["openai"].KeySet);
- }
-
- [Fact]
- public async Task GetPublicAsync_MalformedEncryptedSecret_ReturnsMaskEmpty()
- {
- _store.Preload(
- SettingsKeys.AiConfigs,
- JsonSerializer.Serialize(new { deepseek = new { apiKey = "enc:не-base64!", baseUrl = "https://x", model = "m" } }));
-
- PublicSettingsDto snapshot = await _service.GetPublicAsync(CancellationToken.None);
-
- AiConfigPublicDto deepseek = snapshot.AiConfigs["deepseek"];
- Assert.False(deepseek.KeySet);
- Assert.Equal(string.Empty, deepseek.KeyMasked);
- Assert.Equal(7, snapshot.AiConfigs.Count);
}
// ─── PATCH: общее поведение ──────────────────────────────────────────────
@@ -113,7 +80,6 @@ public sealed class SettingsServiceTests
Assert.Equal(100, snapshot.MinLen);
Assert.Equal(14, snapshot.ArchiveAfterDays);
- Assert.Equal("deepseek", snapshot.AiProvider);
Assert.Equal("100", _store.GetStoredJson(SettingsKeys.MinLen));
Assert.Single(_store.Keys); // записано только переданное поле
}
@@ -310,17 +276,6 @@ public sealed class SettingsServiceTests
Assert.Equal("\"USD\"", _store.GetStoredJson(SettingsKeys.TargetCurrency));
}
- [Fact]
- public async Task ApplyPatchAsync_AiProvider_UnknownIsSkippedKnownIsApplied()
- {
- PublicSettingsDto first = await PatchAsync(new { aiProvider = "неизвестный" });
- Assert.Equal("deepseek", first.AiProvider);
- Assert.Empty(_store.Keys);
-
- PublicSettingsDto second = await PatchAsync(new { aiProvider = "openai" });
- Assert.Equal("openai", second.AiProvider);
- }
-
[Fact]
public async Task ApplyPatchAsync_List_ConvertsScalarsAndSlicesTo200()
{
@@ -402,160 +357,6 @@ public sealed class SettingsServiceTests
Assert.Empty(_store.Keys);
}
- // ─── PATCH: aiConfigs (секреты) ──────────────────────────────────────────
-
- [Fact]
- public async Task ApplyPatchAsync_AiConfigs_ApiKeyIsEncryptedInStoreAndMaskedOutside()
- {
- const string apiKey = "sk-1234567890ab";
-
- PublicSettingsDto snapshot = await PatchAsync(new Dictionary
- {
- ["aiConfigs"] = new Dictionary { ["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
- {
- ["aiConfigs"] = new Dictionary { ["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
- {
- ["aiConfigs"] = new Dictionary
- {
- ["несуществующий"] = 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
- {
- ["aiConfigs"] = new Dictionary
- {
- ["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
- {
- ["aiConfigs"] = new Dictionary
- {
- ["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
- {
- ["aiConfigs"] = new Dictionary { ["deepseek"] = new { apiKey } },
- });
- string firstStored = _store.GetStoredJson(SettingsKeys.AiConfigs)!;
-
- PublicSettingsDto snapshot = await PatchAsync(new Dictionary
- {
- ["aiConfigs"] = new Dictionary
- {
- // 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
- {
- ["aiConfigs"] = new Dictionary { ["deepseek"] = new { apiKey } },
- });
-
- // Повторный PATCH с маской из public-снимка («sk-1…90ab») не должен зашифровать маску как новый ключ
- // (Security review: иначе реальный ключ теряется безвозвратно).
- PublicSettingsDto snapshot = await PatchAsync(new Dictionary
- {
- ["aiConfigs"] = new Dictionary
- {
- ["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
- {
- ["aiConfigs"] = new Dictionary { ["deepseek"] = new { apiKey = "12345678" } },
- });
-
- AiConfigPublicDto deepseek = snapshot.AiConfigs["deepseek"];
- Assert.True(deepseek.KeySet);
- Assert.Equal("1…", deepseek.KeyMasked);
- }
-
-
[Fact]
public async Task ApplyPatchAsync_ColState_PassedThroughAsIs()
{
@@ -593,7 +394,7 @@ public sealed class SettingsServiceTests
public async Task ApplyPatchAsync_TargetCurrencyInBody_NotifiesListenersAfterSave()
{
var listener = Substitute.For();
- SettingsService local = new(_store.Store, _cipher, new[] { listener });
+ SettingsService local = new(_store.Store, new[] { listener });
PublicSettingsDto snapshot = await local.ApplyPatchAsync(JsonBody(new { targetCurrency = "usd" }), CancellationToken.None);
@@ -606,7 +407,7 @@ public sealed class SettingsServiceTests
public async Task ApplyPatchAsync_ConversionOnInBody_NotifiesListeners()
{
var listener = Substitute.For();
- SettingsService local = new(_store.Store, _cipher, new[] { listener });
+ SettingsService local = new(_store.Store, new[] { listener });
await local.ApplyPatchAsync(JsonBody(new { conversionOn = false }), CancellationToken.None);
@@ -618,7 +419,7 @@ public sealed class SettingsServiceTests
public async Task ApplyPatchAsync_UnrelatedKeys_DoesNotNotifyListeners()
{
var listener = Substitute.For();
- SettingsService local = new(_store.Store, _cipher, new[] { listener });
+ SettingsService local = new(_store.Store, new[] { listener });
await local.ApplyPatchAsync(JsonBody(new { minLen = 100, rateSource = "mock" }), CancellationToken.None);
@@ -629,7 +430,7 @@ public sealed class SettingsServiceTests
public async Task ApplyPatchAsync_TargetCurrencyJsonNull_DoesNotNotifyListeners()
{
var listener = Substitute.For();
- SettingsService local = new(_store.Store, _cipher, new[] { listener });
+ SettingsService local = new(_store.Store, new[] { listener });
await local.ApplyPatchAsync(JsonBody(new Dictionary { ["targetCurrency"] = null }), CancellationToken.None);
diff --git a/src/core/tests/Deal.Tests.Unit/Support/TestAiConfig.cs b/src/core/tests/Deal.Tests.Unit/Support/TestAiConfig.cs
new file mode 100644
index 0000000..a9139dc
--- /dev/null
+++ b/src/core/tests/Deal.Tests.Unit/Support/TestAiConfig.cs
@@ -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;
+
+///
+/// Глобальная конфигурация ИИ для тестов ИИ-вызовов: провайдер каталога с заданным ключом.
+///
+internal static class TestAiConfig
+{
+ ///
+ /// Провайдер по умолчанию для тестов ИИ-вызовов.
+ ///
+ public const string DefaultProviderId = "deepseek";
+
+ ///
+ /// API-ключ по умолчанию: конфигурация считается заданной.
+ ///
+ public const string DefaultApiKey = "sk-test-deepseek-key";
+
+ ///
+ /// Кладёт глобальную конфигурацию ИИ и создаёт поверх неё сервис.
+ ///
+ /// Подставка хранилища глобальных настроек.
+ /// Шифр секретов.
+ /// Id провайдера каталога.
+ /// Базовый URL (пусто — дефолт каталога).
+ /// Модель (пусто — первая из каталога).
+ /// API-ключ открытым текстом (пусто — без ключа).
+ /// Сервис глобальной конфигурации ИИ.
+ 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);
+ }
+}