using System.Text.Json; using Deal.Api.Extensions; using Deal.Api.Services; using Deal.Modules.Settings.Application.Abstractions; using Deal.Modules.Settings.Application.Models; namespace Deal.Api.Endpoints; /// /// Эндпоинт проверки подключения AI-провайдера /// public static class AiCheckEndpoint { private const string ApiGroupPrefix = "/api"; // Путь проверки подключения AI-провайдера. private const string AiCheckPath = "/ai/check"; private const string OpenApiTag = "settings"; /// /// Регистрирует POST /api/ai/check. /// /// Построитель маршрутов приложения. /// Построитель маршрутов для цепочки вызовов. public static IEndpointRouteBuilder MapAiCheckEndpoint(this IEndpointRouteBuilder app) { var group = app.MapGroup(ApiGroupPrefix).WithTags(OpenApiTag); group.MapPost(AiCheckPath, CheckAsync); return app; } // POST /api/ai/check: проверка соединения с активным AI-провайдером тенанта. private static async Task CheckAsync(HttpContext context, CancellationToken ct) { if (context.GetCurrentUser() is null) { return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); } // Резолв после 401-гейта: ISettingsStore — scoped на TenantDbContext (tenant-контекст запроса). ISettingsStore store = context.RequestServices.GetRequiredService(); ISecretCipher secretCipher = context.RequestServices.GetRequiredService(); IAiConnectionChecker checker = context.RequestServices.GetRequiredService(); AiCheckRequest checkRequest = await BuildActiveCheckRequestAsync(store, secretCipher, ct); AiCheckResultDto result = await checker.CheckAsync(checkRequest, ct); return Results.Ok(result); } // store: KV-хранилище настроек тенанта. // secretCipher: Шифр секретов (расшифровка apiKey). // ct: Токен отмены. // Возвращает: Запрос проверки: id провайдера + эффективные base/model + расшифрованный ключ. // Эффективные значения = дефолты SettingsDefaults, перекрытые сохранёнными private static async Task BuildActiveCheckRequestAsync( ISettingsStore store, ISecretCipher secretCipher, CancellationToken ct) { string providerId = await ReadActiveProviderIdAsync(store, ct); AiProviderDefinition? meta = AiProviders.All.FirstOrDefault(provider => provider.Id == providerId); // Неизвестный id (ручное вмешательство в БД — PATCH-гейт SettingsService не даёт сохранить): // HTTP не выполняется — ответит SSRF-гейт checker (allowlist). if (meta is null) { return new AiCheckRequest(providerId, string.Empty, string.Empty, string.Empty, IsLocal: false, ApiStyle: null); } AiConfigSetting config = await ReadEffectiveConfigAsync(store, providerId, ct); string apiKey = secretCipher.Decrypt(config.ApiKey); string baseUrl = string.IsNullOrEmpty(config.BaseUrl) ? meta.Base : config.BaseUrl; string model = string.IsNullOrEmpty(config.Model) ? meta.Models.FirstOrDefault() ?? string.Empty : config.Model; return new AiCheckRequest(providerId, baseUrl, model, apiKey, meta.Local, meta.ApiStyle); } // Читает активный провайдер: сохранённый aiProvider или дефолт (повреждённое значение — дефолт). // store: KV-хранилище настроек тенанта. // ct: Токен отмены. // Возвращает: id провайдера. private static async Task ReadActiveProviderIdAsync(ISettingsStore store, CancellationToken ct) { SettingValue? row = await store.GetAsync(SettingsKeys.AiProvider, ct); if (row is null) { return SettingsDefaults.AiProvider; } try { using JsonDocument document = JsonDocument.Parse(row.ValueJson); if (document.RootElement.ValueKind == JsonValueKind.String) { return document.RootElement.GetString() ?? SettingsDefaults.AiProvider; } } catch (JsonException) { // Повреждённая строка — дефолт (мягкая семантика, как в SettingsService). } return SettingsDefaults.AiProvider; } // Эффективный конфиг провайдера: дефолт SettingsDefaults, перекрытый сохранённым aiConfigs. // store: KV-хранилище настроек тенанта. // providerId: Активный провайдер (id из каталога). // ct: Токен отмены. // Возвращает: Конфиг {apiKey, baseUrl, model}; повреждённая строка aiConfigs — дефолт. private static async Task ReadEffectiveConfigAsync( ISettingsStore store, string providerId, CancellationToken ct) { AiConfigSetting defaults = SettingsDefaults.AiConfigs[providerId]; SettingValue? row = await store.GetAsync(SettingsKeys.AiConfigs, ct); if (row is null) { return defaults; } try { using JsonDocument document = JsonDocument.Parse(row.ValueJson); JsonElement root = document.RootElement; if (root.ValueKind == JsonValueKind.Object && root.TryGetProperty(providerId, out JsonElement entry) && entry.ValueKind == JsonValueKind.Object) { return new AiConfigSetting( ApiKey: ReadField(entry, "apiKey") ?? defaults.ApiKey, BaseUrl: ReadField(entry, "baseUrl") ?? defaults.BaseUrl, Model: ReadField(entry, "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; } }