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

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