Перенести настройку ИИ-провайдера в глобальные настройки оператора

Провайдера, модель, baseUrl и API-ключ задаёт оператор: конфигурация хранится в public.global_settings (ключ aiConfig, шифрование enc:), читается общей для всех тенантов и используется AiProviderConfigBuilder во всех ИИ-вызовах. Настройки тенанта больше не содержат aiProvider/aiConfigs, пользовательский POST /api/ai/check удалён. Добавлены операторские ручки GET/PUT /api/operator/settings/ai-config и POST .../check с аудитом ai_config_changed.
This commit is contained in:
2026-09-15 21:56:11 +03:00
parent dda314cddc
commit 78fe64f531
39 changed files with 1054 additions and 815 deletions
@@ -1,153 +0,0 @@
using System.Text.Json;
using Deal.Api.Extensions;
using Deal.Api.Services;
using Deal.Modules.Settings.Application.Abstractions;
using Deal.Modules.Settings.Application.Models;
namespace Deal.Api.Endpoints;
/// <summary>
/// Эндпоинт проверки подключения AI-провайдера
/// </summary>
public static class AiCheckEndpoint
{
private const string ApiGroupPrefix = "/api";
// Путь проверки подключения AI-провайдера.
private const string AiCheckPath = "/ai/check";
private const string OpenApiTag = "settings";
/// <summary>
/// Регистрирует POST /api/ai/check.
/// </summary>
/// <param name="app">Построитель маршрутов приложения.</param>
/// <returns>Построитель маршрутов для цепочки вызовов.</returns>
public static IEndpointRouteBuilder MapAiCheckEndpoint(this IEndpointRouteBuilder app)
{
var group = app.MapGroup(ApiGroupPrefix).WithTags(OpenApiTag);
group.MapPost(AiCheckPath, CheckAsync);
return app;
}
// POST /api/ai/check: проверка соединения с активным AI-провайдером тенанта.
private static async Task<IResult> CheckAsync(HttpContext context, CancellationToken ct)
{
if (context.GetCurrentUser() is null)
{
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
}
// Резолв после 401-гейта: ISettingsStore — scoped на TenantDbContext (tenant-контекст запроса).
ISettingsStore store = context.RequestServices.GetRequiredService<ISettingsStore>();
ISecretCipher secretCipher = context.RequestServices.GetRequiredService<ISecretCipher>();
IAiConnectionChecker checker = context.RequestServices.GetRequiredService<IAiConnectionChecker>();
AiCheckRequest checkRequest = await BuildActiveCheckRequestAsync(store, secretCipher, ct);
AiCheckResultDto result = await checker.CheckAsync(checkRequest, ct);
return Results.Ok(result);
}
private static async Task<AiCheckRequest> BuildActiveCheckRequestAsync(
ISettingsStore store,
ISecretCipher secretCipher,
CancellationToken ct)
{
string providerId = await ReadActiveProviderIdAsync(store, ct);
AiProviderDefinition? meta = AiProviders.All.FirstOrDefault(provider => provider.Id == providerId);
// Неизвестный id (ручное вмешательство в БД — PATCH-гейт SettingsService не даёт сохранить):
// HTTP не выполняется — ответит SSRF-гейт checker (allowlist).
if (meta is null)
{
return new AiCheckRequest(providerId, string.Empty, string.Empty, string.Empty, IsLocal: false, ApiStyle: null);
}
AiConfigSetting config = await ReadEffectiveConfigAsync(store, providerId, ct);
string apiKey = secretCipher.Decrypt(config.ApiKey);
string baseUrl = string.IsNullOrEmpty(config.BaseUrl) ? meta.Base : config.BaseUrl;
string model = string.IsNullOrEmpty(config.Model)
? meta.Models.FirstOrDefault() ?? string.Empty
: config.Model;
return new AiCheckRequest(providerId, baseUrl, model, apiKey, meta.Local, meta.ApiStyle);
}
// Читает активный провайдер: сохранённый aiProvider или дефолт (повреждённое значение — дефолт).
// store: KV-хранилище настроек тенанта.
// ct: Токен отмены.
// Возвращает: id провайдера.
private static async Task<string> ReadActiveProviderIdAsync(ISettingsStore store, CancellationToken ct)
{
SettingValue? row = await store.GetAsync(SettingsKeys.AiProvider, ct);
if (row is null)
{
return SettingsDefaults.AiProvider;
}
try
{
using JsonDocument document = JsonDocument.Parse(row.ValueJson);
if (document.RootElement.ValueKind == JsonValueKind.String)
{
return document.RootElement.GetString() ?? SettingsDefaults.AiProvider;
}
}
catch (JsonException)
{
// Повреждённая строка — дефолт (мягкая семантика, как в SettingsService).
}
return SettingsDefaults.AiProvider;
}
// Эффективный конфиг провайдера: дефолт SettingsDefaults, перекрытый сохранённым aiConfigs.
// store: KV-хранилище настроек тенанта.
// providerId: Активный провайдер (id из каталога).
// ct: Токен отмены.
// Возвращает: Конфиг {apiKey, baseUrl, model}; повреждённая строка aiConfigs — дефолт.
private static async Task<AiConfigSetting> ReadEffectiveConfigAsync(
ISettingsStore store,
string providerId,
CancellationToken ct)
{
AiConfigSetting defaults = SettingsDefaults.AiConfigs[providerId];
SettingValue? row = await store.GetAsync(SettingsKeys.AiConfigs, ct);
if (row is null)
{
return defaults;
}
try
{
using JsonDocument document = JsonDocument.Parse(row.ValueJson);
JsonElement root = document.RootElement;
if (root.ValueKind == JsonValueKind.Object
&& root.TryGetProperty(providerId, out JsonElement entry)
&& entry.ValueKind == JsonValueKind.Object)
{
return new AiConfigSetting(
ApiKey: ReadField(entry, SettingsFieldKeys.ApiKey) ?? defaults.ApiKey,
BaseUrl: ReadField(entry, SettingsFieldKeys.BaseUrl) ?? defaults.BaseUrl,
Model: ReadField(entry, SettingsFieldKeys.Model) ?? defaults.Model);
}
}
catch (JsonException)
{
// Повреждённая строка — дефолт (не роняем проверку).
}
return defaults;
}
// Читает строковое поле объекта конфигурации (имена полей camelCase, как пишет SettingsService).
// entry: JSON-объект конфигурации провайдера.
// field: Имя поля (apiKey/baseUrl/model).
// Возвращает: Значение или null, если поле отсутствует/не строка.
private static string? ReadField(JsonElement entry, string field)
{
return entry.TryGetProperty(field, out JsonElement value) && value.ValueKind == JsonValueKind.String
? value.GetString()
: null;
}
}
@@ -2,6 +2,9 @@ using Deal.Api.Endpoints.RequestModels;
using Deal.Api.Extensions;
using Deal.Api.Services;
using Deal.Api.Telegram;
using Deal.Modules.Settings.Application.Abstractions;
using Deal.Modules.Settings.Application.Models;
using Deal.Modules.Settings.Application.Services;
using Deal.Modules.Tenants.Application.Models;
using Deal.Modules.Tenants.Application.Services;
@@ -21,6 +24,9 @@ public static class OperatorSettingsEndpoints
// Относительный путь глобальных ключей Telegram (GET/PUT).
private const string TelegramKeysPath = "/telegram-keys";
// Относительный путь глобальной конфигурации ИИ (GET/PUT/POST-check).
private const string AiConfigPath = "/ai-config";
// Текст 400: пустое тело PUT (ни одного поля).
private const string EmptyBodyDetail = "Укажите api_id и api_hash";
@@ -33,6 +39,24 @@ public static class OperatorSettingsEndpoints
// Текст 400: api_hash пустой/маска/с префиксом enc:.
private const string InvalidApiHashDetail = "Укажите непустой api_hash";
// Текст 400: пустое тело PUT конфига ИИ (ни одного поля).
private const string EmptyAiConfigBodyDetail = "Укажите хотя бы одно поле (providerId, baseUrl, model, apiKey)";
// Текст 400: провайдер не из каталога AiProviders.
private const string InvalidProviderDetail = "Провайдер не из списка разрешённых";
// Текст 400: api-ключ короче 8 символов/маска/с префиксом enc:.
private const string InvalidAiKeyDetail = "API-ключ должен быть не короче 8 символов, без маски";
// Текст 400: конфигурации ИИ ещё нет, а провайдер в PUT не передан.
private const string MissingProviderDetail = "Конфигурация ИИ ещё не задана — укажите providerId";
// Текст 400: custom-провайдер без модели (в каталоге нет моделей-дефолтов).
private const string MissingAiModelDetail = "Укажите model — у выбранного провайдера нет моделей по умолчанию";
// Текст 400: проверка связи при незаданной конфигурации ИИ.
private const string AiConfigNotSetDetail = "Сначала сохраните конфигурацию ИИ";
/// <summary>
/// Регистрирует группу /api/operator/settings
/// </summary>
@@ -43,6 +67,9 @@ public static class OperatorSettingsEndpoints
var group = app.MapGroup(SettingsGroupPrefix).WithTags(SettingsOpenApiTag);
group.MapGet(TelegramKeysPath, GetTelegramKeysAsync);
group.MapPut(TelegramKeysPath, PutTelegramKeysAsync);
group.MapGet(AiConfigPath, GetAiConfigAsync);
group.MapPut(AiConfigPath, PutAiConfigAsync);
group.MapPost(AiConfigPath + "/check", CheckAiConfigAsync);
return app;
}
@@ -135,6 +162,152 @@ public static class OperatorSettingsEndpoints
return Results.Ok(snapshot);
}
// GET /api/operator/settings/ai-config: маскированная глобальная конфигурация ИИ.
// context: Контекст запроса.
// config: Сервис глобальной конфигурации ИИ (scoped).
// ct: Токен отмены.
// Возвращает: 200 маскированный снимок или 401 без операторской сессии.
private static async Task<IResult> GetAiConfigAsync(
HttpContext context,
AiGlobalConfigService config,
CancellationToken ct)
{
if (context.GetCurrentOperator() is null)
{
return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail);
}
AiGlobalConfigMaskedDto snapshot = await config.GetMaskedAsync(ct);
return Results.Ok(snapshot);
}
// PUT /api/operator/settings/ai-config: частичное сохранение глобальной конфигурации ИИ.
// Поля передаются по отдельности: непереданное (null) сохраняет текущее значение. Если
// конфигурации ещё нет, providerId обязателен. Ключ передаётся только при смене (маска не
// принимается). Смена провайдера сохраняет ключ, только если передан явно.
// body: Тело {providerId?, baseUrl?, model?, apiKey?} (хотя бы одно поле).
// context: Контекст запроса.
// config: Сервис глобальной конфигурации ИИ (scoped).
// auditService: Сервис аудита (событие ai_config_changed).
// ct: Токен отмены.
// Возвращает: 200 маскированный снимок, 400 при невалидных/недостающих полях или 401 без операторской сессии.
private static async Task<IResult> PutAiConfigAsync(
OperatorAiConfigRequest? body,
HttpContext context,
AiGlobalConfigService config,
AuditService auditService,
CancellationToken ct)
{
var operatorIdentity = context.GetCurrentOperator();
if (operatorIdentity is null)
{
return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail);
}
if (body is null)
{
return EndpointResults.BadRequest(EmptyAiConfigBodyDetail);
}
// null — поле не передано (сохраняем текущее); явное значение валидируется ниже.
string? providerId = body.ProviderId?.Trim();
string? baseUrl = body.BaseUrl?.Trim();
string? model = body.Model?.Trim();
string? apiKey = body.ApiKey?.Trim();
if (providerId is null && baseUrl is null && model is null && apiKey is null)
{
return EndpointResults.BadRequest(EmptyAiConfigBodyDetail);
}
if (providerId is not null
&& AiProviders.All.All(provider => provider.Id != providerId))
{
return EndpointResults.BadRequest(InvalidProviderDetail);
}
if (apiKey is not null && apiKey.Length > 0 && !AiGlobalConfigService.IsValidApiKey(apiKey))
{
return EndpointResults.BadRequest(InvalidAiKeyDetail);
}
// Частичное обновление: недостающие поля берём из текущей конфигурации. При смене
// провайдера поля не переносятся от старого (дефолты каталога), ключ — только если
// передан явно.
AiGlobalConfigSnapshot current = await config.GetSnapshotAsync(ct);
string effectiveProviderId = providerId ?? current.ProviderId;
if (effectiveProviderId.Length == 0)
{
return EndpointResults.BadRequest(MissingProviderDetail);
}
bool providerChanged = providerId is not null && providerId != current.ProviderId;
string effectiveBaseUrl = baseUrl ?? (providerChanged ? string.Empty : current.BaseUrl);
string effectiveModel = model ?? (providerChanged ? string.Empty : current.Model);
string effectiveApiKey = apiKey ?? (providerChanged ? string.Empty : current.ApiKey);
try
{
await config.SaveAsync(effectiveProviderId, effectiveBaseUrl, effectiveModel, effectiveApiKey, ct);
}
catch (ArgumentException)
{
// Остаточный случай валидации: custom-провайдер без модели (провайдер и ключ проверены выше).
return EndpointResults.BadRequest(MissingAiModelDetail);
}
AiGlobalConfigSnapshot saved = await config.GetSnapshotAsync(ct);
await auditService.AppendAsync(new AuditRecordDto(
AuditEvents.AiConfigChanged,
AuditActorTypes.Operator,
ActorId: operatorIdentity.OperatorId,
TenantId: null,
Ip: ClientIp(context),
DetailJson: AuditService.ToDetailJson(
[
AuditDetails.Set(AuditFields.ProviderId, saved.ProviderId),
AuditDetails.Set(AuditFields.BaseUrl, saved.BaseUrl),
AuditDetails.Set(AuditFields.Model, saved.Model),
AuditDetails.Set(AuditFields.KeySet, saved.ApiKey.Length > 0),
])), ct);
AiGlobalConfigMaskedDto snapshot = await config.GetMaskedAsync(ct);
return Results.Ok(snapshot);
}
// POST /api/operator/settings/ai-config/check: проверка связи с сохранённым ИИ-провайдером.
// context: Контекст запроса.
// config: Сервис глобальной конфигурации ИИ (scoped).
// checker: Проверка подключения провайдера (HTTP к списку моделей провайдера).
// ct: Токен отмены.
// Возвращает: 200 результат проверки, 400 без сохранённой конфигурации или 401 без операторской сессии.
private static async Task<IResult> CheckAiConfigAsync(
HttpContext context,
AiGlobalConfigService config,
IAiConnectionChecker checker,
CancellationToken ct)
{
if (context.GetCurrentOperator() is null)
{
return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail);
}
AiGlobalConfigSnapshot snapshot = await config.GetSnapshotAsync(ct);
if (snapshot.ProviderId.Length == 0 || snapshot.Meta is null)
{
return EndpointResults.BadRequest(AiConfigNotSetDetail);
}
var request = new AiCheckRequest(
snapshot.ProviderId,
snapshot.BaseUrl,
snapshot.Model,
snapshot.ApiKey,
snapshot.Meta.Local,
snapshot.Meta.ApiStyle);
AiCheckResultDto result = await checker.CheckAsync(request, ct);
return Results.Ok(result);
}
// IP-адрес клиента для аудита (без порта; null, если недоступен).
// context: Контекст запроса.
// Возвращает: Строковое представление IP или null.
@@ -0,0 +1,14 @@
namespace Deal.Api.Endpoints.RequestModels;
/// <summary>
/// Тело PUT /api/operator/settings/ai-config
/// </summary>
/// <param name="ProviderId">Id провайдера из каталога AiProviders; null — не менялся.</param>
/// <param name="BaseUrl">Базовый URL API (учитывается для local/custom провайдеров); null — не менялся.</param>
/// <param name="Model">Активная модель; null — не менялась (у каталогных провайдеров пустая = первая из каталога).</param>
/// <param name="ApiKey">API-ключ открытым текстом (хранится зашифрованным); null — не менялся (маска не принимается).</param>
public sealed record OperatorAiConfigRequest(
string? ProviderId,
string? BaseUrl,
string? Model,
string? ApiKey);
@@ -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;
+2 -1
View File
@@ -25,6 +25,7 @@ using Deal.Modules.Kanban.Application.Registrars;
using Deal.Modules.Pipeline.Application.Registrars;
using Deal.Modules.Settings.Application.Abstractions;
using Deal.Modules.Settings.Application.Registrars;
using Deal.Modules.Settings.Application.Services;
using Deal.Modules.Telegram.Application;
using Deal.Modules.Tenants.Application.Models;
using Deal.Modules.Tenants.Application.Registrars;
@@ -160,6 +161,7 @@ builder.Services.AddDiscoveryModule();
builder.Services.AddScoped<TgStatusService>();
builder.Services.AddScoped<TelegramKeysService>();
builder.Services.AddScoped<AiGlobalConfigService>();
builder.Services.AddSingleton<TelegramBackfillScheduler>();
@@ -356,7 +358,6 @@ app.MapOperatorSettingsEndpoints();
app.MapOperatorMaintenanceEndpoints();
app.MapJoinEndpoint();
app.MapSettingsEndpoints();
app.MapAiCheckEndpoint();
app.MapRatesEndpoints();
app.MapMlEndpoints();
app.MapFilterTesterEndpoints();
@@ -83,8 +83,8 @@ public sealed class AiConnectionChecker : IAiConnectionChecker
string name = meta?.Name ?? request.ProviderId;
// SSRF-гейт (allowlist, preflight): проверка возможна только для провайдера фиксированного
// каталога AiProviders. В штатном потоке недостижимо (PATCH-гейт aiProvider/aiConfigs в
// SettingsService) — защита от ручного изменения БД/повреждённого хранилища.
// каталога AiProviders. В штатном потоке недостижимо (конфигурацию задаёт оператор из
// каталога через OperatorSettingsEndpoints) — защита от ручного изменения БД.
if (meta is null)
{
return BuildResult(request, name, ok: false, message: ProviderNotAllowedMessage);
@@ -1,150 +1,49 @@
using System.Text.Json.Nodes;
using Deal.Grpc.Ai;
using Deal.Modules.Settings.Application.Abstractions;
using Deal.Infrastructure.Integrations.Exceptions;
using Deal.Modules.Settings.Application.Models;
using Deal.Modules.Settings.Application.Services;
namespace Deal.Infrastructure.Integrations.Services;
/// <summary>
/// Собирает конфиг активного ИИ-провайдера для запросов ai-service.
/// Собирает конфиг ИИ-провайдера для запросов ai-service из глобальной конфигурации
/// оператора (общая для всех тенантов; провайдера, модель и ключ задаёт оператор).
/// </summary>
public sealed class AiProviderConfigBuilder
/// <param name="configService">Сервис глобальной конфигурации ИИ (ключ aiConfig).</param>
public sealed class AiProviderConfigBuilder(AiGlobalConfigService configService)
{
// Ключ aiConfigs: поле apiKey переопределения провайдера.
private const string ApiKeyField = "apiKey";
// Ключ aiConfigs: поле baseUrl переопределения провайдера.
private const string BaseUrlField = "baseUrl";
// Ключ aiConfigs: поле model переопределения провайдера.
private const string ModelField = "model";
private const string EncryptedPrefix = "enc:";
private readonly ISettingsStore _store;
private readonly ISecretCipher _secretCipher;
// Текст AiUnavailableException, когда оператор ещё не сохранил конфигурацию ИИ.
private const string AiNotConfiguredMessage = "ИИ не настроен оператором системы";
/// <summary>
/// Создаёт сборщик конфига провайдера.
/// Собирает ProviderConfig для тела запроса ai-service
/// </summary>
/// <param name="store">KV-хранилище настроек тенанта (aiProvider/aiConfigs).</param>
/// <param name="secretCipher">Расшифровка секрета aiConfigs.apiKey.</param>
public AiProviderConfigBuilder(ISettingsStore store, ISecretCipher secretCipher)
{
ArgumentNullException.ThrowIfNull(store);
ArgumentNullException.ThrowIfNull(secretCipher);
_store = store;
_secretCipher = secretCipher;
}
/// <summary>
/// Собирает ProviderConfig активного провайдера для тела запроса ai-service.
/// </summary>
/// <returns>Конфиг: provider_id/base/model/api_key (расшифрованный)/api_style (см. ai.proto).</returns>
/// <returns>Конфиг: provider_id/base/model/api_key/api_style (см. ai.proto).</returns>
/// <exception cref="AiUnavailableException">Конфигурация ИИ не задана оператором.</exception>
public async Task<ProviderConfig> BuildAsync(CancellationToken ct)
{
string providerId = await ReadProviderIdAsync(ct);
AiProviderDefinition meta = AiProviders.All.FirstOrDefault(provider => provider.Id == providerId)
?? AiProviders.All[0]; // неизвестный id — дефолтный провайдер (python L28)
JsonObject? overrides = await ReadAiConfigsOverrideAsync(ct);
JsonObject? raw = overrides?[meta.Id] as JsonObject;
string apiKey = ReadField(raw, ApiKeyField);
if (apiKey.StartsWith(EncryptedPrefix, StringComparison.Ordinal))
AiGlobalConfigSnapshot snapshot = await configService.GetSnapshotAsync(ct);
if (!snapshot.Configured)
{
apiKey = _secretCipher.Decrypt(apiKey);
}
string baseUrl = ReadField(raw, BaseUrlField);
if (baseUrl.Length == 0)
{
baseUrl = meta.Base; // python L90: cfg.baseUrl or meta.base
}
string model = ReadField(raw, ModelField);
if (model.Length == 0)
{
model = meta.Models.FirstOrDefault() ?? string.Empty; // python L91: cfg.model or models[0]
throw new AiUnavailableException(AiNotConfiguredMessage);
}
var config = new ProviderConfig
{
ProviderId = meta.Id,
BaseUrl = baseUrl,
Model = model,
ProviderId = snapshot.ProviderId,
BaseUrl = snapshot.BaseUrl,
Model = snapshot.Model,
};
if (apiKey.Length > 0)
if (snapshot.ApiKey.Length > 0)
{
config.ApiKey = apiKey;
config.ApiKey = snapshot.ApiKey;
}
if (meta.ApiStyle is { Length: > 0 } apiStyle)
if (snapshot.Meta?.ApiStyle is { Length: > 0 } apiStyle)
{
config.ApiStyle = apiStyle;
}
return config;
}
private async Task<string> ReadProviderIdAsync(CancellationToken ct)
{
SettingValue? row = await _store.GetAsync(SettingsKeys.AiProvider, ct);
if (row is null)
{
return SettingsDefaults.AiProvider;
}
try
{
JsonNode? value = JsonNode.Parse(row.ValueJson);
if (value is JsonValue scalar && scalar.TryGetValue<string>(out string? providerId)
&& !string.IsNullOrWhiteSpace(providerId))
{
return providerId;
}
}
catch (System.Text.Json.JsonException)
{
// Повреждённая строка — дефолт (мягкая семантика, как в SettingsService).
}
return SettingsDefaults.AiProvider;
}
// Переопределение aiConfigs тенанта (JSON-объект «id провайдера → конфиг»); null — дефолты.
// ct: Токен отмены.
// Возвращает: Объект переопределения или null.
private async Task<JsonObject?> ReadAiConfigsOverrideAsync(CancellationToken ct)
{
SettingValue? row = await _store.GetAsync(SettingsKeys.AiConfigs, ct);
if (row is null)
{
return null;
}
try
{
return JsonNode.Parse(row.ValueJson) as JsonObject;
}
catch (System.Text.Json.JsonException)
{
// Повреждённая строка — дефолты (мягкая семантика, как в SettingsService).
return null;
}
}
// Строковое поле конфига провайдера (отсутствие/null/не-строка → пустая строка).
// config: Объект конфига провайдера (может быть null — дефолты).
// field: Имя поля (apiKey/baseUrl/model).
// Возвращает: Значение строкой или пустая строка.
private static string ReadField(JsonObject? config, string field)
{
if (config is null || !config.TryGetPropertyValue(field, out JsonNode? node) || node is not JsonValue value)
{
return string.Empty;
}
return value.TryGetValue<string>(out string? text) ? text ?? string.Empty : string.Empty;
}
}
@@ -3,9 +3,9 @@ namespace Deal.Modules.Settings.Application.Models;
/// <summary>
/// Данные проверки подключения AI-провайдера.
/// </summary>
/// <param name="ProviderId">Идентификатор провайдера (id из каталога <c>AiProviders</c>, ключ в aiConfigs).</param>
/// <param name="BaseUrl">Эффективный базовый URL: переопределение aiConfigs или дефолт каталога.</param>
/// <param name="Model">Активная модель: переопределение aiConfigs или первая модель каталога.</param>
/// <param name="ProviderId">Идентификатор провайдера (id из каталога <c>AiProviders</c>).</param>
/// <param name="BaseUrl">Эффективный базовый URL провайдера.</param>
/// <param name="Model">Активная модель.</param>
/// <param name="ApiKey">API-ключ открытым текстом (пустая строка — ключ не задан).</param>
/// <param name="IsLocal">True — локальный сервер (Ollama/LM Studio): ключ не требуется, HTTP не выполняется.</param>
/// <param name="ApiStyle">Стиль API: null — OpenAI-совместимый, <c>"anthropic"</c> — Messages API.</param>
@@ -1,7 +1,7 @@
namespace Deal.Modules.Settings.Application.Models;
/// <summary>
/// Результат проверки подключения AI-провайдера — тело POST /api/ai/check.
/// Результат проверки подключения AI-провайдера — тело проверки связи операторской конфигурации ИИ.
/// </summary>
/// <param name="Ok">True — подключение успешно (включая локальные серверы).</param>
/// <param name="Message">Сообщение для UI.</param>
@@ -1,10 +0,0 @@
namespace Deal.Modules.Settings.Application.Models;
/// <summary>
/// Публичная форма конфигурации AI-провайдера в <c>aiConfigs</c>.
/// </summary>
/// <param name="BaseUrl">Базовый URL API провайдера.</param>
/// <param name="Model">Активная модель.</param>
/// <param name="KeySet">True — API-ключ задан (после расшифровки не пустой).</param>
/// <param name="KeyMasked">Маскированный ключ: пусто, как есть (len ≤ 8) или «1234…5678».</param>
public sealed record AiConfigPublicDto(string BaseUrl, string Model, bool KeySet, string KeyMasked);
@@ -1,9 +0,0 @@
namespace Deal.Modules.Settings.Application.Models;
/// <summary>
/// Сохранённая конфигурация AI-провайдера в настройке <c>aiConfigs</c>
/// </summary>
/// <param name="ApiKey">API-ключ: пустая строка или зашифрованное значение с префиксом <c>enc:</c>.</param>
/// <param name="BaseUrl">Базовый URL API (переопределение дефолта провайдера).</param>
/// <param name="Model">Активная модель.</param>
public sealed record AiConfigSetting(string ApiKey, string BaseUrl, string Model);
@@ -0,0 +1,18 @@
namespace Deal.Modules.Settings.Application.Models;
/// <summary>
/// Маскированная форма глобальной конфигурации ИИ — тело ответа операторской ручки GET/PUT ai-config.
/// </summary>
/// <param name="ProviderId">Id провайдера из каталога <see cref="AiProviders"/>; пусто — конфигурация не задана.</param>
/// <param name="BaseUrl">Базовый URL API.</param>
/// <param name="Model">Активная модель.</param>
/// <param name="KeySet">True — API-ключ задан (после расшифровки не пустой).</param>
/// <param name="KeyMasked">Маскированный ключ: пусто, как есть (len ≤ 8) или «1234…5678».</param>
/// <param name="Providers">Каталог провайдеров для выбора оператором.</param>
public sealed record AiGlobalConfigMaskedDto(
string ProviderId,
string BaseUrl,
string Model,
bool KeySet,
string KeyMasked,
IReadOnlyList<AiProviderPublicDto> Providers);
@@ -0,0 +1,29 @@
namespace Deal.Modules.Settings.Application.Models;
/// <summary>
/// Снимок глобальной конфигурации ИИ-провайдера с расшифрованным ключом (только для внутреннего использования).
/// </summary>
/// <param name="ProviderId">Id провайдера из каталога <see cref="AiProviders"/>; пусто — конфигурация не задана.</param>
/// <param name="BaseUrl">Базовый URL API (эффективный: сохранённый или дефолт каталога).</param>
/// <param name="Model">Активная модель (эффективная: сохранённая или первая из каталога).</param>
/// <param name="ApiKey">API-ключ открытым текстом (пусто — не задан; локальным провайдерам не нужен).</param>
public sealed record AiGlobalConfigSnapshot(
string ProviderId,
string BaseUrl,
string Model,
string ApiKey)
{
/// <summary>
/// Провайдер каталога по <see cref="ProviderId"/> или null для неизвестного/незаданного id
/// </summary>
public AiProviderDefinition? Meta => AiProviders.All.FirstOrDefault(provider => provider.Id == ProviderId);
/// <summary>
/// Конфигурация готова к вызовам ИИ: провайдер известен, адрес и модель непустые,
/// ключ задан (или не нужен локальному провайдеру)
/// </summary>
public bool Configured => Meta is not null
&& BaseUrl.Length > 0
&& Model.Length > 0
&& (Meta.Local || ApiKey.Length > 0);
}
@@ -0,0 +1,14 @@
namespace Deal.Modules.Settings.Application.Models;
/// <summary>
/// Сериализуемая форма значения глобальной конфигурации ИИ (ключ — зашифрованный).
/// </summary>
/// <param name="ProviderId">Id провайдера.</param>
/// <param name="ApiKey">Зашифрованный ключ (префикс <c>enc:</c>) или пусто.</param>
/// <param name="BaseUrl">Базовый URL.</param>
/// <param name="Model">Модель.</param>
public sealed record AiGlobalConfigValue(
string ProviderId,
string ApiKey,
string BaseUrl,
string Model);
@@ -0,0 +1,16 @@
namespace Deal.Modules.Settings.Application.Models;
/// <summary>
/// Провайдер ИИ для выбора оператором
/// </summary>
/// <param name="Id">Идентификатор провайдера из каталога.</param>
/// <param name="Name">Человекочитаемое имя.</param>
/// <param name="Base">Базовый URL API по умолчанию.</param>
/// <param name="Local">True — локальный сервер (ключ не нужен).</param>
/// <param name="Models">Доступные модели каталога; пусто — оператор задаёт модель вручную.</param>
public sealed record AiProviderPublicDto(
string Id,
string Name,
string Base,
bool Local,
IReadOnlyList<string> Models);
@@ -6,4 +6,9 @@ namespace Deal.Modules.Settings.Application.Models;
public static class GlobalSettingsKeys
{
public const string TelegramKeys = "telegramKeys";
/// <summary>
/// Глобальная конфигурация ИИ-провайдера (задаётся оператором, общая для всех тенантов)
/// </summary>
public const string AiConfig = "aiConfig";
}
@@ -1,11 +0,0 @@
namespace Deal.Modules.Settings.Application.Models;
/// <summary>
/// Провайдер ИИ в public-снимке настроек.
/// </summary>
/// <param name="Id">Идентификатор провайдера.</param>
/// <param name="Name">Человекочитаемое имя.</param>
/// <param name="Base">Базовый URL API.</param>
/// <param name="Local">True — локальный сервер (ключ не нужен).</param>
/// <param name="Models">Доступные модели.</param>
public sealed record ProviderPublicDto(string Id, string Name, string Base, bool Local, IReadOnlyList<string> Models);
@@ -210,20 +210,4 @@ public sealed record PublicSettingsDto
/// </summary>
public IReadOnlyDictionary<string, object?> ColState { get; init; } =
new Dictionary<string, object?>();
/// <summary>
/// Активный AI-провайдер
/// </summary>
public string AiProvider { get; init; } = string.Empty;
/// <summary>
/// Публичные конфигурации AI-провайдеров
/// </summary>
public IReadOnlyDictionary<string, AiConfigPublicDto> AiConfigs { get; init; } =
new Dictionary<string, AiConfigPublicDto>();
/// <summary>
/// Статический список AI-провайдеров.
/// </summary>
public IReadOnlyList<ProviderPublicDto> Providers { get; init; } = Array.Empty<ProviderPublicDto>();
}
@@ -35,11 +35,6 @@ public enum SettingKind
/// </summary>
MyPrompts,
/// <summary>
/// Конфигурации AI-провайдеров с ключами
/// </summary>
AiConfigs,
/// <summary>
/// Внутренний (непубличный) ключ
/// </summary>
@@ -207,18 +207,6 @@ public static class SettingsDefaults
/// </summary>
public static readonly IReadOnlyDictionary<string, object?> ColState = new Dictionary<string, object?>();
// ── ИИ ──
/// <summary>
/// Дефолт «aiProvider»
/// </summary>
public const string AiProvider = "deepseek";
/// <summary>
/// Дефолт «aiConfigs»
/// </summary>
public static readonly IReadOnlyDictionary<string, AiConfigSetting> AiConfigs = BuildDefaultAiConfigs();
// ── Telegram / Discovery ──
/// <summary>
@@ -255,16 +243,4 @@ public static class SettingsDefaults
/// Дефолт «discPaused»
/// </summary>
public const bool DiscPaused = false;
private static IReadOnlyDictionary<string, AiConfigSetting> BuildDefaultAiConfigs()
{
var result = new Dictionary<string, AiConfigSetting>(AiProviders.All.Count);
foreach (AiProviderDefinition provider in AiProviders.All)
{
string firstModel = provider.Models.FirstOrDefault() ?? string.Empty;
result.Add(provider.Id, new AiConfigSetting(ApiKey: "", BaseUrl: provider.Base, Model: firstModel));
}
return result;
}
}
@@ -104,8 +104,6 @@ public static class SettingsKeys
public const string RateSource = "rateSource";
public const string AiProvider = "aiProvider";
public const string AiPrompt = "aiPrompt";
public const string AiFilterPrompt = "aiFilterPrompt";
@@ -147,8 +145,6 @@ public static class SettingsKeys
public const string MyPrompts = "myPrompts";
public const string AiConfigs = "aiConfigs";
/// <summary>
/// Каталог публичных ключей
/// </summary>
@@ -181,7 +177,6 @@ public static class SettingsKeys
// String
[TargetCurrency] = SettingKind.String,
[RateSource] = SettingKind.String,
[AiProvider] = SettingKind.String,
[AiPrompt] = SettingKind.String,
[AiFilterPrompt] = SettingKind.String,
[CardPrompt] = SettingKind.String,
@@ -202,7 +197,6 @@ public static class SettingsKeys
[ColState] = SettingKind.Dict,
// special
[MyPrompts] = SettingKind.MyPrompts,
[AiConfigs] = SettingKind.AiConfigs,
};
/// <summary>
@@ -0,0 +1,226 @@
using System.Text.Json;
using Deal.Modules.Settings.Application.Abstractions;
using Deal.Modules.Settings.Application.Models;
namespace Deal.Modules.Settings.Application.Services;
/// <summary>
/// Читает, расшифровывает и сохраняет глобальную конфигурацию ИИ-провайдера (общую для всех
/// тенантов) из хранилища глобальных настроек. Провайдера, модель и ключ задаёт оператор —
/// пользователи тенантов модели не настраивают.
/// </summary>
/// <param name="store">KV-хранилище глобальных настроек оператора (таблица public.global_settings).</param>
/// <param name="cipher">Шифр секретов (AES-256-GCM, формат <c>enc:</c>).</param>
public sealed class AiGlobalConfigService(IGlobalSettingsStore store, ISecretCipher cipher)
{
/// <summary>
/// Минимальная длина API-ключа провайдера ИИ
/// </summary>
public const int ApiKeyMinLength = 8;
// Символ-заполнитель маски секрета (U+2026, «1234…5678»).
private const string MaskEllipsis = "…";
private const string EncryptedPrefix = "enc:";
// Id провайдера «Другой (OpenAI-совместимый)» — единственный не-local, чей baseUrl
// задаёт оператор (остальным облачным адрес фиксирован каталогом, SSRF-гейт).
private const string CustomProviderId = "custom";
// Ключи JSON значения aiConfig (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<AiProviderPublicDto> ProviderCatalog =
AiProviders.All
.Select(provider => new AiProviderPublicDto(
provider.Id, provider.Name, provider.Base, provider.Local, provider.Models))
.ToList();
// Опции JSON значения aiConfig: camelCase (как пишет SaveAsync) + терпимость регистра.
private static readonly JsonSerializerOptions ConfigJsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true,
};
/// <summary>
/// Читает текущую конфигурацию с эффективными значениями
/// </summary>
/// <returns>Снимок; повреждённая строка или неизвестный провайдер — конфигурация не задана.</returns>
public async Task<AiGlobalConfigSnapshot> GetSnapshotAsync(CancellationToken ct)
{
SettingValue? row = await store.GetAsync(GlobalSettingsKeys.AiConfig, ct).ConfigureAwait(false);
if (row is null)
{
return EmptySnapshot();
}
try
{
using JsonDocument document = JsonDocument.Parse(row.ValueJson);
JsonElement root = document.RootElement;
string providerId = ReadString(root, ProviderIdProperty);
if (providerId.Length == 0)
{
return EmptySnapshot();
}
AiProviderDefinition? meta = AiProviders.All.FirstOrDefault(provider => provider.Id == providerId);
if (meta is null)
{
// Неизвестный id (ручное вмешательство в БД) — считаем конфигурацию незаданной.
return EmptySnapshot();
}
string baseUrl = ReadString(root, 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();
}
}
/// <summary>
/// Маскированная форма конфигурации для операторской ручки
/// </summary>
/// <returns>DTO с флагом keySet и маской ключа.</returns>
public async Task<AiGlobalConfigMaskedDto> GetMaskedAsync(CancellationToken ct)
{
AiGlobalConfigSnapshot snapshot = await GetSnapshotAsync(ct).ConfigureAwait(false);
return new AiGlobalConfigMaskedDto(
ProviderId: snapshot.ProviderId,
BaseUrl: snapshot.BaseUrl,
Model: snapshot.Model,
KeySet: snapshot.ApiKey.Length > 0,
KeyMasked: MaskSecret(snapshot.ApiKey),
Providers: ProviderCatalog);
}
/// <summary>
/// Сохраняет глобальную конфигурацию ИИ (эффективные значения; ключ шифруется)
/// </summary>
/// <param name="providerId">Id провайдера из каталога <see cref="AiProviders"/>.</param>
/// <param name="baseUrl">Базовый URL (учитывается только для local/custom провайдеров).</param>
/// <param name="model">Модель (у каталогных провайдеров пустая заменяется первой из каталога).</param>
/// <param name="apiKey">API-ключ открытым текстом (не нужен локальным провайдерам).</param>
/// <exception cref="ArgumentException">Значения не прошли валидацию (см. <see cref="IsValidApiKey"/>).</exception>
public async Task SaveAsync(
string providerId,
string baseUrl,
string model,
string apiKey,
CancellationToken ct)
{
string trimmedProviderId = (providerId ?? string.Empty).Trim();
string trimmedBaseUrl = (baseUrl ?? string.Empty).Trim();
string trimmedModel = (model ?? string.Empty).Trim();
string trimmedApiKey = (apiKey ?? string.Empty).Trim();
AiProviderDefinition? meta = AiProviders.All.FirstOrDefault(provider => provider.Id == trimmedProviderId);
if (meta is null)
{
throw new ArgumentException("Провайдер не из списка разрешённых.", nameof(providerId));
}
if (!IsBaseUrlMutable(meta))
{
// Адрес каталогных облачных провайдеров фиксирован каталогом (SSRF-гейт).
trimmedBaseUrl = meta.Base;
}
if (trimmedModel.Length == 0)
{
trimmedModel = meta.Models.FirstOrDefault() ?? string.Empty;
}
if (trimmedModel.Length == 0)
{
throw new ArgumentException("Модель обязательна (в каталоге провайдера нет моделей).", nameof(model));
}
if (trimmedApiKey.Length > 0 && !IsValidApiKey(trimmedApiKey))
{
throw new ArgumentException("API-ключ непустой, ≥8 симв., без маски и без префикса enc:.", nameof(apiKey));
}
var value = new AiGlobalConfigValue(
ProviderId: trimmedProviderId,
ApiKey: cipher.Encrypt(trimmedApiKey),
BaseUrl: trimmedBaseUrl,
Model: trimmedModel);
string valueJson = JsonSerializer.Serialize(value, ConfigJsonOptions);
await store.SetAsync(GlobalSettingsKeys.AiConfig, valueJson, ct).ConfigureAwait(false);
}
/// <summary>
/// Валиден ли API-ключ к шифрованию и сохранению
/// </summary>
/// <param name="apiKey">Проверяемое значение (уже без пробелов).</param>
/// <returns>True — значение допустимо.</returns>
public static bool IsValidApiKey(string apiKey) =>
apiKey.Length >= ApiKeyMinLength
&& !apiKey.Contains(MaskEllipsis, StringComparison.Ordinal)
&& !apiKey.StartsWith(EncryptedPrefix, StringComparison.Ordinal);
// Пустой снимок «конфигурация не задана».
private static AiGlobalConfigSnapshot EmptySnapshot() =>
new(string.Empty, string.Empty, string.Empty, string.Empty);
// Адрес мутабелен только для локальных провайдеров и custom (остальным — фиксирован каталогом).
private static bool IsBaseUrlMutable(AiProviderDefinition meta) => meta.Local || meta.Id == CustomProviderId;
// Читает строковое поле JSON-объекта (пусто при отсутствии/не-строке).
// root: Корень JSON значения aiConfig.
// propertyName: Имя поля (camelCase).
// Возвращает: Значение поля (trim) или пустая строка.
private static string ReadString(JsonElement root, string propertyName)
{
if (root.ValueKind != JsonValueKind.Object
|| !root.TryGetProperty(propertyName, out JsonElement element)
|| element.ValueKind != JsonValueKind.String)
{
return string.Empty;
}
return (element.GetString() ?? string.Empty).Trim();
}
// Маска СЕКРЕТА (apiKey): всегда скрывает, кроме пустого. Длина ≤ 8 → «x…», иначе «1234…5678».
// value: Открытый секрет (не null).
// Возвращает: Маскированная строка.
private static string MaskSecret(string value)
{
return value.Length switch
{
0 => string.Empty,
<= MaskShortMaxLength => string.Concat(value.AsSpan(0, MaskShortVisibleChars), MaskEllipsis),
_ => string.Concat(
value.AsSpan(0, MaskEdgeVisibleChars),
MaskEllipsis,
value.AsSpan(value.Length - MaskEdgeVisibleChars)),
};
}
}
@@ -92,7 +92,7 @@ public sealed partial class SettingsService
writes[key] = JsonSerializer.Serialize(number);
}
// Применяет строковый ключ: targetCurrency — Upper; aiProvider — только из каталога провайдеров.
// Применяет строковый ключ: targetCurrency — Upper.
// key: Имя ключа.
// value: JSON-значение из тела PATCH.
// writes: Накопитель записей (key → valueJson).
@@ -111,11 +111,6 @@ public sealed partial class SettingsService
text = text.ToUpperInvariant();
}
if (key == SettingsKeys.AiProvider && !ProviderIds.Contains(text))
{
return; // aiProvider вне списка провайдеров — пропуск (L134–135)
}
writes[key] = JsonSerializer.Serialize(text);
}
@@ -1,102 +0,0 @@
using System.Text.Json;
using Deal.Modules.Settings.Application.Models;
namespace Deal.Modules.Settings.Application.Services;
// Часть SettingsService: PATCH-ключ секрета aiConfigs (ApplyAiConfigsKey, SSRF-гейт baseUrl, шифрование
// apiKey), включая удаление переопределения, вернувшегося к дефолту (EqualsDefault).
public sealed partial class SettingsService
{
private void ApplyAiConfigsKey(
JsonElement value,
Dictionary<string, JsonElement> overrides,
Dictionary<string, string> writes,
List<string> removals)
{
if (value.ValueKind != JsonValueKind.Object)
{
return;
}
// База — эффективные конфиги (дефолты + сохранённые переопределения): PATCH дописывает поля.
var effective = new Dictionary<string, AiConfigSetting>(
MergeAiConfigs(SettingsDefaults.AiConfigs, overrides),
StringComparer.Ordinal);
foreach (JsonProperty provider in value.EnumerateObject())
{
if (!effective.ContainsKey(provider.Name) || provider.Value.ValueKind != JsonValueKind.Object)
{
continue;
}
AiConfigSetting config = effective[provider.Name];
string providerId = provider.Name;
string apiKey = config.ApiKey;
string baseUrl = config.BaseUrl;
string model = config.Model;
JsonElement entry = provider.Value;
// Каталог провайдеров: для baseUrl-гейта нужен признак local и фиксированный адрес каталога.
AiProviderDefinition? meta = AiProviders.All.FirstOrDefault(p => p.Id == providerId);
// baseUrl: переопределение принимается ТОЛЬКО для локальных провайдеров (Ollama/LM Studio — свой
// адрес на машине пользователя) и custom (OpenAI-совместимый — адрес задаёт сам пользователь).
// У каталоговых облачных провайдеров (deepseek/openai/openrouter/anthropic) адрес фиксирован
// каталогом — иначе тенант мог бы перенаправить ключ/запрос на произвольный внутренний адрес
// (SSRF, Security review) и проверка подключения ушла бы на него.
bool baseUrlMutable = meta is { Local: true } || providerId == CustomProviderId;
if (baseUrlMutable && TryReadFieldText(entry, SettingsFieldKeys.BaseUrl, out string newBaseUrl))
{
baseUrl = newBaseUrl;
}
if (TryReadFieldText(entry, SettingsFieldKeys.Model, out string newModel))
{
model = newModel;
}
// apiKey: непустой, ≥8 симв., без префикса enc: и без маски (содержит "…") → шифруется;
// иначе (пустой/маска) ключ не меняется.
if (TryReadFieldText(entry, SettingsFieldKeys.ApiKey, out string newKey)
&& newKey.Length >= ApiKeyMinLength
&& !newKey.StartsWith(EncryptedValuePrefix, StringComparison.Ordinal)
&& !newKey.Contains(MaskEllipsis, StringComparison.Ordinal))
{
apiKey = secretCipher.Encrypt(newKey);
}
effective[provider.Name] = new AiConfigSetting(apiKey, baseUrl, model);
}
if (EqualsDefault(effective))
{
removals.Add(SettingsKeys.AiConfigs);
}
else
{
writes[SettingsKeys.AiConfigs] = JsonSerializer.Serialize(effective, JsonOptions);
}
}
// True — эффективный aiConfigs совпадает с дефолтами (переопределение можно не хранить).
// configs: Эффективные конфиги.
// Возвращает: True — хранить переопределение не нужно.
private static bool EqualsDefault(IReadOnlyDictionary<string, AiConfigSetting> configs)
{
if (configs.Count != SettingsDefaults.AiConfigs.Count)
{
return false;
}
foreach ((string providerId, AiConfigSetting config) in configs)
{
if (!SettingsDefaults.AiConfigs.ContainsKey(providerId) || config != SettingsDefaults.AiConfigs[providerId])
{
return false;
}
}
return true;
}
}
@@ -1,26 +0,0 @@
using Deal.Modules.Settings.Application.Models;
namespace Deal.Modules.Settings.Application.Services;
public sealed partial class SettingsService
{
private AiConfigPublicDto ToPublic(AiConfigSetting config)
{
string plainKey = secretCipher.Decrypt(config.ApiKey);
return new AiConfigPublicDto(
BaseUrl: config.BaseUrl,
Model: config.Model,
KeySet: !string.IsNullOrEmpty(plainKey),
KeyMasked: MaskSecret(plainKey));
}
private static string MaskSecret(string value)
{
return value.Length switch
{
0 => string.Empty,
<= 8 => string.Concat(value.AsSpan(0, 1), MaskEllipsis),
_ => string.Concat(value.AsSpan(0, 4), MaskEllipsis, value.AsSpan(value.Length - 4)),
};
}
}
@@ -201,34 +201,6 @@ public sealed partial class SettingsService
return new Dictionary<string, object?>(StringComparer.Ordinal);
}
// Объединяет дефолты aiConfigs и сохранённое переопределение (переопределение — на провайдера).
// defaults: Дефолтные конфиги на всех провайдеров каталога.
// overrides: Сохранённые переопределения.
// Возвращает: Эффективные конфиги: провайдеры дефолтов + перекрытия из хранилища (неизвестные пропускаются).
private static IReadOnlyDictionary<string, AiConfigSetting> MergeAiConfigs(IReadOnlyDictionary<string, AiConfigSetting> defaults, Dictionary<string, JsonElement> overrides)
{
var merged = defaults.ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.Ordinal);
if (overrides.TryGetValue(SettingsKeys.AiConfigs, out JsonElement element) && element.ValueKind == JsonValueKind.Object)
{
foreach (JsonProperty provider in element.EnumerateObject())
{
// «Только существующие провайдеры»: неизвестные id из хранилища не подмешиваются.
if (!merged.ContainsKey(provider.Name) || provider.Value.ValueKind != JsonValueKind.Object)
{
continue;
}
string apiKey = TryReadFieldText(provider.Value, SettingsFieldKeys.ApiKey, out string storedKey) ? storedKey : string.Empty;
string baseUrl = TryReadFieldText(provider.Value, SettingsFieldKeys.BaseUrl, out string storedBase) ? storedBase : string.Empty;
string model = TryReadFieldText(provider.Value, SettingsFieldKeys.Model, out string storedModel) ? storedModel : string.Empty;
merged[provider.Name] = new AiConfigSetting(apiKey, baseUrl, model);
}
}
return merged;
}
// Читает сохранённые «Мои промпты»; повреждённое значение → пустой список (дефолт).
// overrides: Сохранённые переопределения.
// Возвращает: Список промптов из хранилища или дефолт (пустой).
@@ -8,30 +8,14 @@ namespace Deal.Modules.Settings.Application.Services;
/// Сервис настроек тенанта
/// </summary>
/// <param name="store">KV-хранилище настроек тенанта (таблица settings).</param>
/// <param name="secretCipher">Шифр секретов.</param>
/// <param name="listeners">Слушатели смены настроек конверсии; пусто — no-op.</param>
public sealed partial class SettingsService(
ISettingsStore store,
ISecretCipher secretCipher,
IEnumerable<IRatesChangedListener> listeners)
{
private const string EncryptedValuePrefix = "enc:";
// Символ-заполнитель маски секрета (U+2026, «1234…5678»): строка, содержащая его,
// — это маска из public-снимка, а не новый секрет (Security review: не шифровать маску).
private const string MaskEllipsis = "…";
// Id провайдера «Другой (OpenAI-совместимый)» — единственный не-local, чей baseUrl
// задаёт пользователь (остальным адрес фиксирован каталогом, SSRF-гейт).
private const string CustomProviderId = "custom";
// Максимум элементов списка-настройки при записи из PATCH (срез 200).
private const int MaxListItems = 200;
// Минимальная длина нового API-ключа, который принимает PATCH (≥8 симв.).
private const int ApiKeyMinLength = 8;
// Максимум элементов «Моих промптов» при записи из PATCH (≤100).
private const int MaxMyPrompts = 100;
@@ -65,16 +49,6 @@ public sealed partial class SettingsService(
[SettingsKeys.DiscEvalThreshold] = (1, 100),
};
// Идентификаторы известных провайдеров (валидация aiProvider/aiConfigs).
private static readonly IReadOnlySet<string> ProviderIds =
AiProviders.All.Select(provider => provider.Id).ToHashSet(StringComparer.Ordinal);
private static readonly IReadOnlyList<ProviderPublicDto> PublicProviders =
AiProviders.All
.Select(provider => new ProviderPublicDto(
provider.Id, provider.Name, provider.Base, provider.Local, provider.Models))
.ToList();
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
@@ -105,7 +79,6 @@ public sealed partial class SettingsService(
Dictionary<string, int> preparedDelays = PrepareDelayClamps(body, overrides);
var writes = new Dictionary<string, string>(StringComparer.Ordinal);
var removals = new List<string>();
foreach ((string key, JsonElement value) in body)
{
@@ -162,10 +135,6 @@ public sealed partial class SettingsService(
}
break;
case SettingKind.AiConfigs:
ApplyAiConfigsKey(value, overrides, writes, removals);
break;
}
}
@@ -174,11 +143,6 @@ public sealed partial class SettingsService(
await store.SetAsync(key, valueJson, ct);
}
foreach (string key in removals)
{
await store.RemoveAsync(key, ct);
}
if (AffectsConversion(body))
{
await NotifyRatesChangedAsync(ct);
@@ -214,8 +178,6 @@ public sealed partial class SettingsService(
// Возвращает: DTO public-снимка.
private PublicSettingsDto BuildSnapshot(Dictionary<string, JsonElement> overrides)
{
IReadOnlyDictionary<string, AiConfigSetting> aiConfigs = MergeAiConfigs(SettingsDefaults.AiConfigs, overrides);
var result = new PublicSettingsDto
{
// Хранилище / обработка
@@ -269,11 +231,6 @@ public sealed partial class SettingsService(
DiscEvalThreshold = MergeInt(overrides, SettingsKeys.DiscEvalThreshold, SettingsDefaults.DiscEvalThreshold),
DiscPaused = MergeBool(overrides, SettingsKeys.DiscPaused, SettingsDefaults.DiscPaused),
ColState = MergeDict(overrides, SettingsKeys.ColState),
// ИИ-провайдеры (публичная форма)
AiProvider = MergeString(overrides, SettingsKeys.AiProvider, SettingsDefaults.AiProvider),
AiConfigs = aiConfigs.ToDictionary(pair => pair.Key, pair => ToPublic(pair.Value), StringComparer.Ordinal),
Providers = PublicProviders,
};
return result;
@@ -154,4 +154,9 @@ public static class AuditEvents
/// Оператор изменил глобальные ключи Telegram api_id/api_hash.
/// </summary>
public const string TelegramKeysChanged = "telegram_keys_changed";
/// <summary>
/// Оператор изменил глобальную конфигурацию ИИ-провайдера.
/// </summary>
public const string AiConfigChanged = "ai_config_changed";
}
@@ -119,4 +119,24 @@ public static class AuditFields
/// Диалог Telegram
/// </summary>
public const string DialogId = "dialogId";
/// <summary>
/// Id ИИ-провайдера
/// </summary>
public const string ProviderId = "providerId";
/// <summary>
/// Базовый URL ИИ-провайдера
/// </summary>
public const string BaseUrl = "baseUrl";
/// <summary>
/// Модель ИИ-провайдера
/// </summary>
public const string Model = "model";
/// <summary>
/// Признак заданного API-ключа ИИ-провайдера
/// </summary>
public const string KeySet = "keySet";
}
@@ -0,0 +1,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;
/// <summary>
/// Тесты сервиса глобальной конфигурации ИИ оператора
/// </summary>
public sealed class AiGlobalConfigServiceTests
{
private const string ApiKey = "sk-operator-key-123";
private const string DeepSeekBase = "https://api.deepseek.com";
private readonly TestGlobalSettingsStore _store = new();
private readonly ISecretCipher _cipher = TestCiphers.New();
private readonly AiGlobalConfigService _service;
public AiGlobalConfigServiceTests()
{
_service = new AiGlobalConfigService(_store.Store, _cipher);
}
[Fact]
public async Task GetMaskedAsync_NoConfig_ReturnsEmptySnapshotWithProviderCatalog()
{
AiGlobalConfigMaskedDto masked = await _service.GetMaskedAsync(CancellationToken.None);
Assert.Equal(string.Empty, masked.ProviderId);
Assert.Equal(string.Empty, masked.KeyMasked);
Assert.False(masked.KeySet);
Assert.Equal(AiProviders.All.Count, masked.Providers.Count);
}
[Fact]
public async Task SaveAsync_EncryptsApiKeyAndMasquesOnRead()
{
await _service.SaveAsync("deepseek", string.Empty, "deepseek-v4-pro", ApiKey, CancellationToken.None);
string storedJson = _store.GetStoredJson(GlobalSettingsKeys.AiConfig)!;
Assert.Contains("enc:", storedJson);
Assert.DoesNotContain(ApiKey, storedJson);
AiGlobalConfigMaskedDto masked = await _service.GetMaskedAsync(CancellationToken.None);
Assert.Equal("deepseek", masked.ProviderId);
Assert.Equal("deepseek-v4-pro", masked.Model);
Assert.True(masked.KeySet);
Assert.Equal("sk-o…-123", masked.KeyMasked);
}
[Fact]
public async Task SaveAsync_CloudProviderBaseUrl_IsFixedByCatalog()
{
await _service.SaveAsync("deepseek", "https://evil.example/v1", string.Empty, ApiKey, CancellationToken.None);
AiGlobalConfigSnapshot snapshot = await _service.GetSnapshotAsync(CancellationToken.None);
Assert.Equal(DeepSeekBase, snapshot.BaseUrl);
Assert.Equal("deepseek-v4-flash", snapshot.Model); // первая модель каталога
Assert.True(snapshot.Configured);
}
[Fact]
public async Task SaveAsync_CustomProvider_KeepsOperatorBaseUrlAndModel()
{
await _service.SaveAsync("custom", "https://llm.local/v1", "my-model", ApiKey, CancellationToken.None);
AiGlobalConfigSnapshot snapshot = await _service.GetSnapshotAsync(CancellationToken.None);
Assert.Equal("https://llm.local/v1", snapshot.BaseUrl);
Assert.Equal("my-model", snapshot.Model);
Assert.True(snapshot.Configured);
}
[Fact]
public async Task SaveAsync_LocalProvider_ConfiguredWithoutApiKey()
{
await _service.SaveAsync("ollama", "http://127.0.0.1:11435/v1", string.Empty, string.Empty, CancellationToken.None);
AiGlobalConfigSnapshot snapshot = await _service.GetSnapshotAsync(CancellationToken.None);
Assert.Equal("http://127.0.0.1:11435/v1", snapshot.BaseUrl);
Assert.True(snapshot.Configured);
}
[Fact]
public async Task SaveAsync_UnknownProvider_Throws()
{
await Assert.ThrowsAsync<ArgumentException>(
() => _service.SaveAsync("несуществующий", string.Empty, string.Empty, ApiKey, CancellationToken.None));
}
[Fact]
public async Task SaveAsync_CustomWithoutModel_Throws()
{
await Assert.ThrowsAsync<ArgumentException>(
() => _service.SaveAsync("custom", "https://llm.local/v1", string.Empty, ApiKey, CancellationToken.None));
}
[Fact]
public async Task SaveAsync_ShortApiKey_Throws()
{
await Assert.ThrowsAsync<ArgumentException>(
() => _service.SaveAsync("deepseek", string.Empty, string.Empty, "short", CancellationToken.None));
}
[Fact]
public async Task GetSnapshotAsync_MalformedStoredJson_IsNotConfigured()
{
_store.Preload(GlobalSettingsKeys.AiConfig, "not-json");
AiGlobalConfigSnapshot snapshot = await _service.GetSnapshotAsync(CancellationToken.None);
Assert.False(snapshot.Configured);
}
[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));
}
}
@@ -139,7 +139,7 @@ public sealed class GrpcAiToolsTests
return new GrpcAiTools(
tenantContext,
connection,
new AiProviderConfigBuilder(settings.Store, cipher),
new AiProviderConfigBuilder(TestAiConfig.New(new TestGlobalSettingsStore(), cipher)),
new TokenUsageRecorder(settings.Store, limits.Store, tenantContext, new TokenUsageEventService(new TestTokenUsageEventStore().Store)),
NullLogger<GrpcAiTools>.Instance);
}
@@ -9,6 +9,7 @@ using Deal.Modules.Kanban.Application.Abstractions;
using Deal.Modules.Pipeline.Application.Parse;
using Deal.Modules.Pipeline.Application.Services;
using Deal.Modules.Settings.Application.Abstractions;
using Deal.Modules.Settings.Application.Services;
using Deal.Modules.Tenants.Application.Abstractions;
using Deal.Modules.Tenants.Application.Services;
using Deal.SharedKernel.Tenants.Abstractions;
@@ -144,7 +145,9 @@ public sealed class IntegrationsDiTests
tenantContext.SetTenant(new TenantId(Guid.NewGuid().ToString("N")));
services.AddSingleton<ITenantContext>(tenantContext);
services.AddScoped<ISettingsStore>(_ => new TestSettingsStore().Store);
services.AddScoped<IGlobalSettingsStore>(_ => new TestGlobalSettingsStore().Store);
services.AddScoped<ISecretCipher>(_ => TestCiphers.New());
services.AddScoped<AiGlobalConfigService>();
services.AddScoped<ICardStore>(_ => new TestKanjStore().Store);
services.AddScoped<IMlLearningStore>(_ => new TestMlLearningStore().Store);
services.AddScoped<ITenantLimitStore>(_ => new TestTenantLimitStore().Store);
@@ -178,7 +178,7 @@ public sealed class PipelineWorkerGrpcAiTests
var grpcClassifier = new GrpcAiClassifier(
tenantContext,
connection,
new AiProviderConfigBuilder(settings.Store, TestCiphers.New()),
new AiProviderConfigBuilder(TestAiConfig.New(new TestGlobalSettingsStore(), TestCiphers.New())),
new AiClassifyContextBuilder(settings.Store, kanjStore.Store),
new TokenUsageRecorder(settings.Store, limits.Store, tenantContext, new TokenUsageEventService(new TestTokenUsageEventStore().Store)),
NullLogger<GrpcAiClassifier>.Instance);
@@ -38,7 +38,6 @@ public sealed class SettingsCatalogTests
// String
["targetCurrency"] = SettingKind.String,
["rateSource"] = SettingKind.String,
["aiProvider"] = SettingKind.String,
["aiPrompt"] = SettingKind.String,
["aiFilterPrompt"] = SettingKind.String,
["cardPrompt"] = SettingKind.String,
@@ -59,10 +58,9 @@ public sealed class SettingsCatalogTests
["colState"] = SettingKind.Dict,
// special
["myPrompts"] = SettingKind.MyPrompts,
["aiConfigs"] = SettingKind.AiConfigs,
};
private const int ExpectedPublicKeysCount = 43;
private const int ExpectedPublicKeysCount = 41;
[Fact]
public void PublicKeys_CoverAllApiMapKeysWithCorrectCategories()
@@ -169,26 +167,11 @@ public sealed class SettingsCatalogTests
Assert.True(SettingsDefaults.BlockResumes);
Assert.Equal("RUB", SettingsDefaults.TargetCurrency);
Assert.Equal("cbr", SettingsDefaults.RateSource);
Assert.Equal("deepseek", SettingsDefaults.AiProvider);
Assert.False(SettingsDefaults.DiscPaused);
Assert.Empty(SettingsDefaults.ColState);
Assert.Empty(SettingsDefaults.MyPrompts);
}
[Fact]
public void Defaults_AiConfigsSeededForEveryProviderWithEmptyKey()
{
Assert.Equal(AiProviders.All.Count, SettingsDefaults.AiConfigs.Count);
foreach (AiProviderDefinition provider in AiProviders.All)
{
AiConfigSetting config = SettingsDefaults.AiConfigs[provider.Id];
Assert.Equal("", config.ApiKey);
Assert.Equal(provider.Base, config.BaseUrl);
Assert.Equal(provider.Models.FirstOrDefault() ?? string.Empty, config.Model);
}
}
[Fact]
public void DefaultPrompts_ContainKeyMarkersFromDataJs()
{
@@ -12,6 +12,7 @@ using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Pipeline.Application.Services;
using Deal.Modules.Settings.Application.Abstractions;
using Deal.Modules.Settings.Application.Models;
using Deal.Modules.Settings.Application.Services;
using Deal.Modules.Tenants.Application.Services;
using Deal.SharedKernel.Tenants.Models;
using Deal.Tests.Unit.Grpc;
@@ -71,7 +72,8 @@ public sealed class GrpcAiClassifierTests
Assert.Equal("deepseek", service.LastFilter.ProviderConfig.ProviderId);
Assert.Equal("https://api.deepseek.com", service.LastFilter.ProviderConfig.BaseUrl);
Assert.Equal("deepseek-v4-flash", service.LastFilter.ProviderConfig.Model);
Assert.False(service.LastFilter.ProviderConfig.HasApiKey);
Assert.True(service.LastFilter.ProviderConfig.HasApiKey);
Assert.Equal(TestAiConfig.DefaultApiKey, service.LastFilter.ProviderConfig.ApiKey);
Assert.False(service.LastFilter.ProviderConfig.HasApiStyle);
Assert.Equal(TenantIdValue, Assert.Single(service.RequestTenantIds));
@@ -220,34 +222,24 @@ public sealed class GrpcAiClassifierTests
}
[Fact]
public async Task ClassifyAsync_ProviderConfig_ReadsDecryptedKeyAndOverridesFromSettings()
public async Task ClassifyAsync_ProviderConfig_UsesGlobalOperatorConfig()
{
await AiGrpcTestHost.RunAsync(AiGrpcTestHost.DefaultToken, new RecordingAiService(), async (port, service) =>
{
service.ClassifyReply = new ClassifyReply { Ok = true, Json = """{"title":"Заголовок","stack":[],"is_spam":false}""" };
(TestSettingsStore settings, ISecretCipher cipher, TestKanjStore kanjStore) = Context(port);
settings.Preload(SettingsKeys.AiProvider, Json("anthropic"));
settings.Preload(
SettingsKeys.AiConfigs,
new JsonObject
{
["anthropic"] = new JsonObject
{
["apiKey"] = cipher.Encrypt("sk-ant-test-secret"),
["baseUrl"] = "https://llm.local",
["model"] = "claude-custom",
},
}.ToJsonString());
AiGlobalConfigService aiConfig = TestAiConfig.New(
new TestGlobalSettingsStore(), cipher, providerId: "anthropic", apiKey: "sk-ant-test-secret");
IAiClassifier classifier = CreateClassifier(port, settings, cipher, kanjStore);
IAiClassifier classifier = CreateClassifier(port, settings, cipher, kanjStore, aiConfig: aiConfig);
await classifier.ClassifyAsync("текст", CancellationToken.None);
// ProviderConfig из настроек: id/base/model — переопределение, apiKey расшифрован, api_style — из каталога.
// ProviderConfig глобальной конфигурации: id/apiKey, адрес каталога фиксирован, api_style — из каталога.
Assert.NotNull(service.LastClassify);
ProviderConfig config = service.LastClassify!.ProviderConfig;
Assert.Equal("anthropic", config.ProviderId);
Assert.Equal("https://llm.local", config.BaseUrl);
Assert.Equal("claude-custom", config.Model);
Assert.Equal("https://api.anthropic.com", config.BaseUrl);
Assert.Equal("claude-sonnet-5", config.Model);
Assert.True(config.HasApiKey);
Assert.Equal("sk-ant-test-secret", config.ApiKey);
Assert.True(config.HasApiStyle);
@@ -255,6 +247,33 @@ public sealed class GrpcAiClassifierTests
});
}
[Fact]
public async Task ClassifyAsync_ProviderConfig_HonorsCustomBaseUrlAndModel()
{
await AiGrpcTestHost.RunAsync(AiGrpcTestHost.DefaultToken, new RecordingAiService(), async (port, service) =>
{
service.ClassifyReply = new ClassifyReply { Ok = true, Json = """{"title":"Заголовок","stack":[],"is_spam":false}""" };
(TestSettingsStore settings, ISecretCipher cipher, TestKanjStore kanjStore) = Context(port);
AiGlobalConfigService aiConfig = TestAiConfig.New(
new TestGlobalSettingsStore(),
cipher,
providerId: "custom",
baseUrl: "https://llm.local/v1",
model: "my-model",
apiKey: "sk-custom-key-123");
IAiClassifier classifier = CreateClassifier(port, settings, cipher, kanjStore, aiConfig: aiConfig);
await classifier.ClassifyAsync("текст", CancellationToken.None);
// OpenAI-совместимый провайдер: адрес и модель задаёт оператор.
ProviderConfig config = service.LastClassify!.ProviderConfig;
Assert.Equal("custom", config.ProviderId);
Assert.Equal("https://llm.local/v1", config.BaseUrl);
Assert.Equal("my-model", config.Model);
Assert.Equal("sk-custom-key-123", config.ApiKey);
});
}
[Fact]
public async Task ClassifyAsync_LongMessage_TruncatesUserContextTo5000CodePoints()
{
@@ -277,25 +296,28 @@ public sealed class GrpcAiClassifierTests
// Создаёт GrpcAiClassifier к хосту-фейку на эфемерном порту в tenant-контексте теста.
// port: Порт хоста-фейка ai-service.
// settings: KV-хранилище тенанта (дефолты при пустом).
// cipher: Шифр секретов (расшифровка apiKey).
// cipher: Шифр секретов.
// kanjStore: Канбан-фейк (доски/примеры контекста классификации).
// limits: Фейк-хранилище лимитов (списание usage; при null — собственный экземпляр).
// aiConfig: Глобальная конфигурация ИИ (при null — провайдер по умолчанию с ключом).
// Возвращает: Экземпляр GrpcAiClassifier.
private static GrpcAiClassifier CreateClassifier(
int port,
TestSettingsStore settings,
ISecretCipher cipher,
TestKanjStore kanjStore,
TestTenantLimitStore? limits = null)
TestTenantLimitStore? limits = null,
AiGlobalConfigService? aiConfig = null)
{
limits ??= new TestTenantLimitStore();
aiConfig ??= TestAiConfig.New(new TestGlobalSettingsStore(), cipher);
ITenantContext tenantContext = new TenantContext();
tenantContext.SetTenant(new TenantId(TenantIdValue));
var connection = new AiGrpcConnection(new AiServiceOptions { UseLocal = false, Endpoint = $"http://127.0.0.1:{port}" });
return new GrpcAiClassifier(
tenantContext,
connection,
new AiProviderConfigBuilder(settings.Store, cipher),
new AiProviderConfigBuilder(aiConfig),
new AiClassifyContextBuilder(settings.Store, kanjStore.Store),
new TokenUsageRecorder(settings.Store, limits.Store, tenantContext, new TokenUsageEventService(new TestTokenUsageEventStore().Store)),
NullLogger<GrpcAiClassifier>.Instance);
@@ -0,0 +1,263 @@
using System.Net;
using System.Text;
using System.Text.Json;
using Deal.Modules.Settings.Application.Models;
using Deal.Modules.Tenants.Application.Models;
using Deal.Tests.Unit.Modules.Settings;
using Deal.Tests.Unit.Modules.Tenants;
using Deal.Tests.Unit.Support;
namespace Deal.Tests.Unit.Support;
/// <summary>
/// HTTP-тесты операторских ручек глобальной конфигурации ИИ
/// </summary>
public sealed class OperatorAiConfigEndpointsHttpTests
{
private const string OperatorLogin = "operator";
private const string OperatorPassword = "operator";
private const string InvalidProviderDetail = "Провайдер не из списка разрешённых";
private const string MissingAiModelDetail = "Укажите model — у выбранного провайдера нет моделей по умолчанию";
private const string AiConfigNotSetDetail = "Сначала сохраните конфигурацию ИИ";
private const string ApiKey = "sk-operator-key-123";
[Fact]
public async Task AiConfig_WithoutOperatorSession_Returns401()
{
await RunAsync(
async (baseAddress, _, _, _, _) =>
{
using HttpClient anonymous = CreateClient(baseAddress);
using HttpResponseMessage get = await anonymous.GetAsync(AiConfigUrl(baseAddress));
Assert.Equal(HttpStatusCode.Unauthorized, get.StatusCode);
using HttpResponseMessage put = await PutJsonAsync(
anonymous, AiConfigUrl(baseAddress), new { providerId = "deepseek", apiKey = ApiKey });
Assert.Equal(HttpStatusCode.Unauthorized, put.StatusCode);
});
}
[Fact]
public async Task GetAiConfig_NoConfig_ReturnsEmptySnapshotWithProviderCatalog()
{
await RunAsync(
async (baseAddress, _, _, _, _) =>
{
HttpClient operatorClient = CreateClient(baseAddress);
await LoginOperatorAsync(operatorClient, baseAddress);
using HttpResponseMessage response = await operatorClient.GetAsync(AiConfigUrl(baseAddress));
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
JsonElement body = await ReadJsonAsync(response);
Assert.Equal(string.Empty, body.GetProperty("providerId").GetString());
Assert.Equal(string.Empty, body.GetProperty("keyMasked").GetString());
Assert.False(body.GetProperty("keySet").GetBoolean());
Assert.Equal(AiProviders.All.Count, body.GetProperty("providers").GetArrayLength());
});
}
[Fact]
public async Task PutAiConfig_EncryptsKeyMasksResponseAndWritesAudit()
{
var auditStore = new TestAuditLogStore();
await RunAsync(
async (baseAddress, operatorStore, _, globalSettings, audit) =>
{
HttpClient operatorClient = CreateClient(baseAddress);
await LoginOperatorAsync(operatorClient, baseAddress);
using HttpResponseMessage put = await PutJsonAsync(
operatorClient, AiConfigUrl(baseAddress), new { providerId = "deepseek", model = "deepseek-v4-pro", apiKey = ApiKey });
Assert.Equal(HttpStatusCode.OK, put.StatusCode);
JsonElement body = await ReadJsonAsync(put);
Assert.Equal("deepseek", body.GetProperty("providerId").GetString());
Assert.Equal("deepseek-v4-pro", body.GetProperty("model").GetString());
Assert.Equal("sk-o…-123", body.GetProperty("keyMasked").GetString());
Assert.True(body.GetProperty("keySet").GetBoolean());
// В хранилище ключ только в enc:-форме, открытого секрета нет.
string storedJson = globalSettings.GetStoredJson(GlobalSettingsKeys.AiConfig)!;
Assert.Contains("enc:", storedJson);
Assert.DoesNotContain(ApiKey, storedJson);
// Аудит: актор-оператор, детали без секрета.
AuditRecordDto record = Assert.Single(audit.Records, r => r.EventType == AuditEvents.AiConfigChanged);
Assert.Equal(AuditActorTypes.Operator, record.ActorType);
Assert.Equal(operatorStore.Operators.Single().Id, record.ActorId);
Assert.Null(record.TenantId);
Assert.DoesNotContain(ApiKey, record.DetailJson);
Assert.Equal("deepseek", record.DetailValue(AuditFields.ProviderId));
Assert.Equal("deepseek-v4-pro", record.DetailValue(AuditFields.Model));
Assert.Equal("true", record.DetailValue(AuditFields.KeySet));
},
auditStore);
}
[Fact]
public async Task PutAiConfig_UnknownProvider_Returns400()
{
await RunAsync(
async (baseAddress, _, _, _, _) =>
{
HttpClient operatorClient = CreateClient(baseAddress);
await LoginOperatorAsync(operatorClient, baseAddress);
using HttpResponseMessage response = await PutJsonAsync(
operatorClient, AiConfigUrl(baseAddress), new { providerId = "несуществующий", apiKey = ApiKey });
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
Assert.Equal(InvalidProviderDetail, (await ReadJsonAsync(response)).GetProperty("detail").GetString());
});
}
[Fact]
public async Task PutAiConfig_CustomWithoutModel_Returns400()
{
await RunAsync(
async (baseAddress, _, _, _, _) =>
{
HttpClient operatorClient = CreateClient(baseAddress);
await LoginOperatorAsync(operatorClient, baseAddress);
using HttpResponseMessage response = await PutJsonAsync(
operatorClient, AiConfigUrl(baseAddress), new { providerId = "custom", baseUrl = "https://llm.local/v1", apiKey = ApiKey });
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
Assert.Equal(MissingAiModelDetail, (await ReadJsonAsync(response)).GetProperty("detail").GetString());
});
}
[Fact]
public async Task PutAiConfig_ShortApiKey_Returns400()
{
await RunAsync(
async (baseAddress, _, _, _, _) =>
{
HttpClient operatorClient = CreateClient(baseAddress);
await LoginOperatorAsync(operatorClient, baseAddress);
using HttpResponseMessage response = await PutJsonAsync(
operatorClient, AiConfigUrl(baseAddress), new { providerId = "deepseek", apiKey = "short" });
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
});
}
[Fact]
public async Task CheckAiConfig_NoConfig_Returns400()
{
await RunAsync(
async (baseAddress, _, _, _, _) =>
{
HttpClient operatorClient = CreateClient(baseAddress);
await LoginOperatorAsync(operatorClient, baseAddress);
using HttpResponseMessage response = await PostJsonAsync(
operatorClient, AiConfigCheckUrl(baseAddress), new { });
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
Assert.Equal(AiConfigNotSetDetail, (await ReadJsonAsync(response)).GetProperty("detail").GetString());
});
}
[Fact]
public async Task CheckAiConfig_LocalProvider_ReturnsOkWithoutHttp()
{
await RunAsync(
async (baseAddress, _, _, _, _) =>
{
HttpClient operatorClient = CreateClient(baseAddress);
await LoginOperatorAsync(operatorClient, baseAddress);
using HttpResponseMessage put = await PutJsonAsync(
operatorClient, AiConfigUrl(baseAddress), new { providerId = "ollama", baseUrl = "http://127.0.0.1:11434/v1" });
Assert.Equal(HttpStatusCode.OK, put.StatusCode);
using HttpResponseMessage check = await PostJsonAsync(
operatorClient, AiConfigCheckUrl(baseAddress), new { });
Assert.Equal(HttpStatusCode.OK, check.StatusCode);
JsonElement body = await ReadJsonAsync(check);
Assert.True(body.GetProperty("ok").GetBoolean());
Assert.Equal("ollama", body.GetProperty("provider").GetString());
});
}
// ─── Хелперы ─────────────────────────────────────────────────────────
// URL глобальной конфигурации ИИ (GET/PUT /api/operator/settings/ai-config).
private static string AiConfigUrl(string baseAddress) =>
$"{baseAddress}/api/operator/settings/ai-config";
// URL проверки связи (POST /api/operator/settings/ai-config/check).
private static string AiConfigCheckUrl(string baseAddress) =>
$"{baseAddress}/api/operator/settings/ai-config/check";
// Прогоняет сценарий на хосте с фейком глобального хранилища и аудита.
private static Task RunAsync(
Func<string, TestOperatorAuthStore, TestAuthStore, TestGlobalSettingsStore, TestAuditLogStore, Task> scenario,
TestAuditLogStore? auditStore = null) =>
OperatorAuthHttpHost.RunWithGlobalSettingsAsync(
NewOperatorStore(),
new TestAuthStore(),
scenario,
auditStore);
// Логинит оператора (ожидается 200).
private static async Task LoginOperatorAsync(HttpClient client, string baseAddress)
{
using HttpResponseMessage login = await PostJsonAsync(
client, $"{baseAddress}/api/operator/auth/login", new { login = OperatorLogin, password = OperatorPassword });
Assert.Equal(HttpStatusCode.OK, login.StatusCode);
}
// Фейк-хранилище оператора с активным оператором operator/operator.
private static TestOperatorAuthStore NewOperatorStore()
{
var passwordHasher = TestHashers.New();
var store = new TestOperatorAuthStore();
store.AddOperator(new StoredOperatorDto(
Guid.NewGuid(),
OperatorLogin,
Status: "active",
PasswordHash: passwordHasher.Hash(OperatorPassword)));
return store;
}
// HTTP-клиент с собственным CookieContainer (изоляция сценариев кук).
private static HttpClient CreateClient(string baseAddress) =>
new(new HttpClientHandler { UseCookies = true, CookieContainer = new CookieContainer() })
{
BaseAddress = new Uri(baseAddress),
};
// POST JSON-тела и возврат ответа.
private static async Task<HttpResponseMessage> PostJsonAsync(
HttpClient client,
string url,
object body)
{
string json = JsonSerializer.Serialize(body);
using var content = new StringContent(json, Encoding.UTF8, "application/json");
return await client.PostAsync(url, content);
}
// PUT JSON-тела и возврат ответа.
private static async Task<HttpResponseMessage> PutJsonAsync(
HttpClient client,
string url,
object body)
{
string json = JsonSerializer.Serialize(body);
using var content = new StringContent(json, Encoding.UTF8, "application/json");
return await client.PutAsync(url, content);
}
// Читает тело ответа как JSON-документ (корень, отвязанный от документа).
private static async Task<JsonElement> ReadJsonAsync(HttpResponseMessage response)
{
await using Stream stream = await response.Content.ReadAsStreamAsync();
using var document = await JsonDocument.ParseAsync(stream);
return document.RootElement.Clone();
}
}
@@ -10,6 +10,7 @@ using Deal.Infrastructure.Integrations.Services;
using Deal.Infrastructure.Persistence;
using Deal.Infrastructure.Tenancy;
using Deal.Modules.Settings.Application.Abstractions;
using Deal.Modules.Settings.Application.Services;
using Deal.Modules.Tenants.Application.Abstractions;
using Deal.Modules.Tenants.Application.Registrars;
using Deal.SharedKernel.Tenants.Abstractions;
@@ -196,6 +197,8 @@ internal static class OperatorAuthHttpHost
builder.Services.AddSingleton<ISecretCipher>(TestCiphers.New());
// Сервис глобальных ключей Telegram (операторские ручки /api/operator/settings/telegram-keys).
builder.Services.AddScoped<TelegramKeysService>();
// Сервис глобальной конфигурации ИИ (операторские ручки /api/operator/settings/ai-config).
builder.Services.AddScoped<AiGlobalConfigService>();
builder.Services.AddSingleton<ITokenUsageEventStore>(effectiveTokenUsageStore.Store);
builder.Services.AddSingleton(_ => new TestTenantProvisioner().Provisioner);
builder.Services.AddScoped<TenantSchemaMigrationService>();
@@ -213,6 +216,9 @@ internal static class OperatorAuthHttpHost
builder.Services.AddScoped<LoginAttemptGuard>();
builder.Services.AddScoped<SuspiciousActivityReporter>();
builder.Services.AddSingleton<IAuditReferenceResolver>(new TestAuditReferenceResolver());
// Проверка связи с ИИ-провайдером (операторская ручка ai-config/check): локальные
// провайдеры отвечают без HTTP, поэтому клиента достаточно с таймаутом по умолчанию.
builder.Services.AddSingleton<IAiConnectionChecker>(new AiConnectionChecker(new HttpClient()));
WebApplication app = builder.Build();
app.UseMiddleware<SessionMiddleware>();
@@ -14,12 +14,11 @@ namespace Deal.Tests.Unit.Support;
public sealed class SettingsServiceTests
{
private readonly TestSettingsStore _store = new();
private readonly ISecretCipher _cipher = TestCiphers.New();
private readonly SettingsService _service;
public SettingsServiceTests()
{
_service = new SettingsService(_store.Store, _cipher, Array.Empty<IRatesChangedListener>());
_service = new SettingsService(_store.Store, Array.Empty<IRatesChangedListener>());
}
// ─── GET: снимок ─────────────────────────────────────────────────────────
@@ -39,7 +38,6 @@ public sealed class SettingsServiceTests
Assert.Equal("both", snapshot.WantedType);
Assert.Equal("RUB", snapshot.TargetCurrency);
Assert.Equal("cbr", snapshot.RateSource);
Assert.Equal("deepseek", snapshot.AiProvider);
Assert.True(snapshot.ConversionOn);
Assert.True(snapshot.RemindersEnabled);
Assert.Equal(50, snapshot.DiscJoinLimit);
@@ -47,26 +45,18 @@ public sealed class SettingsServiceTests
Assert.Equal(70, snapshot.DiscJoinDelayMax);
Assert.False(snapshot.DiscPaused);
// Пустые коллекции и секреты.
// Пустые коллекции.
Assert.Empty(snapshot.MyPrompts);
Assert.Empty(snapshot.ColState);
Assert.Equal(7, snapshot.Providers.Count);
Assert.Equal(7, snapshot.AiConfigs.Count);
Assert.All(snapshot.AiConfigs, pair => Assert.False(pair.Value.KeySet));
Assert.All(snapshot.AiConfigs, pair => Assert.Equal(string.Empty, pair.Value.KeyMasked));
}
[Fact]
public async Task GetPublicAsync_StoredOverrides_OverlapDefaults()
{
// Переопределения: int, список, colState, aiConfigs (с секретом).
// Переопределения: int, список, colState.
_store.Preload(SettingsKeys.MinLen, "100");
_store.Preload(SettingsKeys.StopPhrases, "[\"фраза один\",\"фраза два\"]");
_store.Preload(SettingsKeys.ColState, "{\"colA\":{\"collapsed\":true},\"num\":42}");
string aiToken = _cipher.Encrypt("sk-1234567890ab");
_store.Preload(
SettingsKeys.AiConfigs,
JsonSerializer.Serialize(new { deepseek = new { apiKey = aiToken, baseUrl = "https://x.example/v1", model = "m1" } }));
PublicSettingsDto snapshot = await _service.GetPublicAsync(CancellationToken.None);
@@ -79,29 +69,6 @@ public sealed class SettingsServiceTests
Assert.Equal(2, snapshot.ColState.Count);
Assert.True(((JsonElement)snapshot.ColState["colA"]!).GetProperty("collapsed").GetBoolean());
Assert.Equal(42, ((JsonElement)snapshot.ColState["num"]!).GetInt32());
// aiConfigs: только переопределённый провайдер; дефолты остальных не тронуты.
AiConfigPublicDto deepseek = snapshot.AiConfigs["deepseek"];
Assert.True(deepseek.KeySet);
Assert.Equal("sk-1…90ab", deepseek.KeyMasked);
Assert.Equal("https://x.example/v1", deepseek.BaseUrl);
Assert.Equal("m1", deepseek.Model);
Assert.False(snapshot.AiConfigs["openai"].KeySet);
}
[Fact]
public async Task GetPublicAsync_MalformedEncryptedSecret_ReturnsMaskEmpty()
{
_store.Preload(
SettingsKeys.AiConfigs,
JsonSerializer.Serialize(new { deepseek = new { apiKey = "enc:не-base64!", baseUrl = "https://x", model = "m" } }));
PublicSettingsDto snapshot = await _service.GetPublicAsync(CancellationToken.None);
AiConfigPublicDto deepseek = snapshot.AiConfigs["deepseek"];
Assert.False(deepseek.KeySet);
Assert.Equal(string.Empty, deepseek.KeyMasked);
Assert.Equal(7, snapshot.AiConfigs.Count);
}
// ─── PATCH: общее поведение ──────────────────────────────────────────────
@@ -113,7 +80,6 @@ public sealed class SettingsServiceTests
Assert.Equal(100, snapshot.MinLen);
Assert.Equal(14, snapshot.ArchiveAfterDays);
Assert.Equal("deepseek", snapshot.AiProvider);
Assert.Equal("100", _store.GetStoredJson(SettingsKeys.MinLen));
Assert.Single(_store.Keys); // записано только переданное поле
}
@@ -310,17 +276,6 @@ public sealed class SettingsServiceTests
Assert.Equal("\"USD\"", _store.GetStoredJson(SettingsKeys.TargetCurrency));
}
[Fact]
public async Task ApplyPatchAsync_AiProvider_UnknownIsSkippedKnownIsApplied()
{
PublicSettingsDto first = await PatchAsync(new { aiProvider = "неизвестный" });
Assert.Equal("deepseek", first.AiProvider);
Assert.Empty(_store.Keys);
PublicSettingsDto second = await PatchAsync(new { aiProvider = "openai" });
Assert.Equal("openai", second.AiProvider);
}
[Fact]
public async Task ApplyPatchAsync_List_ConvertsScalarsAndSlicesTo200()
{
@@ -402,160 +357,6 @@ public sealed class SettingsServiceTests
Assert.Empty(_store.Keys);
}
// ─── PATCH: aiConfigs (секреты) ──────────────────────────────────────────
[Fact]
public async Task ApplyPatchAsync_AiConfigs_ApiKeyIsEncryptedInStoreAndMaskedOutside()
{
const string apiKey = "sk-1234567890ab";
PublicSettingsDto snapshot = await PatchAsync(new Dictionary<string, object?>
{
["aiConfigs"] = new Dictionary<string, object?> { ["deepseek"] = new { apiKey } },
});
string storedJson = _store.GetStoredJson(SettingsKeys.AiConfigs)!;
Assert.NotNull(storedJson);
Assert.Contains("enc:", storedJson);
Assert.DoesNotContain(apiKey, storedJson);
AiConfigPublicDto deepseek = snapshot.AiConfigs["deepseek"];
Assert.True(deepseek.KeySet);
Assert.Equal("sk-1…90ab", deepseek.KeyMasked);
Assert.Equal(SettingsDefaults.AiConfigs["deepseek"].BaseUrl, deepseek.BaseUrl);
Assert.Equal(SettingsDefaults.AiConfigs["deepseek"].Model, deepseek.Model);
}
[Fact]
public async Task ApplyPatchAsync_AiConfigs_ShortApiKeyIsNotEncrypted()
{
PublicSettingsDto snapshot = await PatchAsync(new Dictionary<string, object?>
{
["aiConfigs"] = new Dictionary<string, object?> { ["deepseek"] = new { apiKey = "short" } },
});
Assert.False(snapshot.AiConfigs["deepseek"].KeySet);
Assert.Null(_store.GetStoredJson(SettingsKeys.AiConfigs)); // дефолт не переопределён — строки нет
}
[Fact]
public async Task ApplyPatchAsync_AiConfigs_UnknownProviderIsIgnored()
{
PublicSettingsDto snapshot = await PatchAsync(new Dictionary<string, object?>
{
["aiConfigs"] = new Dictionary<string, object?>
{
["несуществующий"] = new { apiKey = "sk-1234567890ab" },
},
});
Assert.All(snapshot.AiConfigs.Values, config => Assert.False(config.KeySet));
Assert.Null(_store.GetStoredJson(SettingsKeys.AiConfigs));
}
[Fact]
public async Task ApplyPatchAsync_AiConfigs_BaseUrlMutableOnlyForLocalAndCustomProviders()
{
// Каталоговый облачный провайдер (deepseek): baseUrl фиксирован каталогом — переопределение
// игнорируется (SSRF-гейт, Security review); model при этом применяется.
PublicSettingsDto snapshot = await PatchAsync(new Dictionary<string, object?>
{
["aiConfigs"] = new Dictionary<string, object?>
{
["deepseek"] = new { baseUrl = "http://local.example/v1", model = "my-model" },
},
});
AiConfigPublicDto deepseek = snapshot.AiConfigs["deepseek"];
Assert.Equal(SettingsDefaults.AiConfigs["deepseek"].BaseUrl, deepseek.BaseUrl);
Assert.Equal("my-model", deepseek.Model);
Assert.False(deepseek.KeySet);
// Локальный провайдер (ollama) и custom: свой baseUrl разрешён (это их назначение).
PublicSettingsDto local = await PatchAsync(new Dictionary<string, object?>
{
["aiConfigs"] = new Dictionary<string, object?>
{
["ollama"] = new { baseUrl = "http://127.0.0.1:11435/v1" },
["custom"] = new { baseUrl = "https://my-gw.example/v1" },
},
});
Assert.Equal("http://127.0.0.1:11435/v1", local.AiConfigs["ollama"].BaseUrl);
Assert.Equal("https://my-gw.example/v1", local.AiConfigs["custom"].BaseUrl);
}
[Fact]
public async Task ApplyPatchAsync_AiConfigs_ExistingKeySurvivesFollowUpPatchWithoutApiKey()
{
const string apiKey = "sk-1234567890ab";
await PatchAsync(new Dictionary<string, object?>
{
["aiConfigs"] = new Dictionary<string, object?> { ["deepseek"] = new { apiKey } },
});
string firstStored = _store.GetStoredJson(SettingsKeys.AiConfigs)!;
PublicSettingsDto snapshot = await PatchAsync(new Dictionary<string, object?>
{
["aiConfigs"] = new Dictionary<string, object?>
{
// baseUrl каталогового провайдера не переопределяется (SSRF-гейт) — перезаписи нет.
["deepseek"] = new { baseUrl = "http://other.example/v1" },
},
});
AiConfigPublicDto deepseek = snapshot.AiConfigs["deepseek"];
Assert.True(deepseek.KeySet); // ключ сохранён прежним (apiKey не передан)
Assert.Equal("sk-1…90ab", deepseek.KeyMasked);
Assert.Equal(SettingsDefaults.AiConfigs["deepseek"].BaseUrl, deepseek.BaseUrl);
Assert.Equal(firstStored, _store.GetStoredJson(SettingsKeys.AiConfigs)); // значение не изменилось
}
[Fact]
public async Task ApplyPatchAsync_AiConfigs_MaskedApiKeyIsNotEncryptedAgain()
{
const string apiKey = "sk-1234567890ab";
await PatchAsync(new Dictionary<string, object?>
{
["aiConfigs"] = new Dictionary<string, object?> { ["deepseek"] = new { apiKey } },
});
// Повторный PATCH с маской из public-снимка («sk-1…90ab») не должен зашифровать маску как новый ключ
// (Security review: иначе реальный ключ теряется безвозвратно).
PublicSettingsDto snapshot = await PatchAsync(new Dictionary<string, object?>
{
["aiConfigs"] = new Dictionary<string, object?>
{
["deepseek"] = new { apiKey = "sk-1…90ab" },
},
});
AiConfigPublicDto deepseek = snapshot.AiConfigs["deepseek"];
Assert.True(deepseek.KeySet); // ключ прежний, маска НЕ зашифрована
Assert.Equal("sk-1…90ab", deepseek.KeyMasked);
// Сохранённый ключ всё ещё исходный (не «enc:маска»): расшифровка даёт оригинал.
string storedJson = _store.GetStoredJson(SettingsKeys.AiConfigs)!;
using var document = JsonDocument.Parse(storedJson);
string storedKey = document.RootElement.GetProperty("deepseek").GetProperty("apiKey").GetString()!;
Assert.Equal(apiKey, _cipher.Decrypt(storedKey));
}
[Fact]
public async Task ApplyPatchAsync_AiConfigs_EightCharKeyIsFullyMaskedOnOutput()
{
// Короткий (8 симв.) ключ в public-снимке не раскрывается целиком (Security review: echo-маска).
PublicSettingsDto snapshot = await PatchAsync(new Dictionary<string, object?>
{
["aiConfigs"] = new Dictionary<string, object?> { ["deepseek"] = new { apiKey = "12345678" } },
});
AiConfigPublicDto deepseek = snapshot.AiConfigs["deepseek"];
Assert.True(deepseek.KeySet);
Assert.Equal("1…", deepseek.KeyMasked);
}
[Fact]
public async Task ApplyPatchAsync_ColState_PassedThroughAsIs()
{
@@ -593,7 +394,7 @@ public sealed class SettingsServiceTests
public async Task ApplyPatchAsync_TargetCurrencyInBody_NotifiesListenersAfterSave()
{
var listener = Substitute.For<IRatesChangedListener>();
SettingsService local = new(_store.Store, _cipher, new[] { listener });
SettingsService local = new(_store.Store, new[] { listener });
PublicSettingsDto snapshot = await local.ApplyPatchAsync(JsonBody(new { targetCurrency = "usd" }), CancellationToken.None);
@@ -606,7 +407,7 @@ public sealed class SettingsServiceTests
public async Task ApplyPatchAsync_ConversionOnInBody_NotifiesListeners()
{
var listener = Substitute.For<IRatesChangedListener>();
SettingsService local = new(_store.Store, _cipher, new[] { listener });
SettingsService local = new(_store.Store, new[] { listener });
await local.ApplyPatchAsync(JsonBody(new { conversionOn = false }), CancellationToken.None);
@@ -618,7 +419,7 @@ public sealed class SettingsServiceTests
public async Task ApplyPatchAsync_UnrelatedKeys_DoesNotNotifyListeners()
{
var listener = Substitute.For<IRatesChangedListener>();
SettingsService local = new(_store.Store, _cipher, new[] { listener });
SettingsService local = new(_store.Store, new[] { listener });
await local.ApplyPatchAsync(JsonBody(new { minLen = 100, rateSource = "mock" }), CancellationToken.None);
@@ -629,7 +430,7 @@ public sealed class SettingsServiceTests
public async Task ApplyPatchAsync_TargetCurrencyJsonNull_DoesNotNotifyListeners()
{
var listener = Substitute.For<IRatesChangedListener>();
SettingsService local = new(_store.Store, _cipher, new[] { listener });
SettingsService local = new(_store.Store, new[] { listener });
await local.ApplyPatchAsync(JsonBody(new Dictionary<string, object?> { ["targetCurrency"] = null }), CancellationToken.None);
@@ -0,0 +1,51 @@
using System.Text.Json;
using Deal.Modules.Settings.Application.Abstractions;
using Deal.Modules.Settings.Application.Models;
using Deal.Modules.Settings.Application.Services;
namespace Deal.Tests.Unit.Support;
/// <summary>
/// Глобальная конфигурация ИИ для тестов ИИ-вызовов: провайдер каталога с заданным ключом.
/// </summary>
internal static class TestAiConfig
{
/// <summary>
/// Провайдер по умолчанию для тестов ИИ-вызовов.
/// </summary>
public const string DefaultProviderId = "deepseek";
/// <summary>
/// API-ключ по умолчанию: конфигурация считается заданной.
/// </summary>
public const string DefaultApiKey = "sk-test-deepseek-key";
/// <summary>
/// Кладёт глобальную конфигурацию ИИ и создаёт поверх неё сервис.
/// </summary>
/// <param name="store">Подставка хранилища глобальных настроек.</param>
/// <param name="cipher">Шифр секретов.</param>
/// <param name="providerId">Id провайдера каталога.</param>
/// <param name="baseUrl">Базовый URL (пусто — дефолт каталога).</param>
/// <param name="model">Модель (пусто — первая из каталога).</param>
/// <param name="apiKey">API-ключ открытым текстом (пусто — без ключа).</param>
/// <returns>Сервис глобальной конфигурации ИИ.</returns>
public static AiGlobalConfigService New(
TestGlobalSettingsStore store,
ISecretCipher cipher,
string providerId = DefaultProviderId,
string baseUrl = "",
string model = "",
string apiKey = DefaultApiKey)
{
store.Preload(GlobalSettingsKeys.AiConfig, JsonSerializer.Serialize(new
{
providerId,
baseUrl,
model,
apiKey = apiKey.Length == 0 ? string.Empty : cipher.Encrypt(apiKey),
}));
return new AiGlobalConfigService(store.Store, cipher);
}
}