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