Integrations -> Abstractions/Exceptions/Extensions/Models/Options/ Services (включая Storage); Persistence-конфигурации -> Configurations; корень Deal.Api (оркестратор/планировщики/DTO) -> Services/Dtos. namespace приведён к путям, using добавлены/дедуплицированы, FQN обновлены.
173 lines
8.9 KiB
C#
173 lines
8.9 KiB
C#
using System.Text.Json;
|
||
using Deal.Api.Http;
|
||
using Deal.Modules.Settings.Application.Abstractions;
|
||
using Deal.Modules.Settings.Application.Models;
|
||
using Deal.Modules.Settings.Application.Registrars;
|
||
using Deal.Modules.Settings.Application.Services;
|
||
using Deal.Api.Services;
|
||
using Deal.Api.Dtos;
|
||
|
||
namespace Deal.Api.Endpoints;
|
||
|
||
/// <summary>
|
||
/// Эндпоинт проверки подключения AI-провайдера: POST /api/ai/check (Ruling 7/8, api-map §4.10).
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// «Только для Settings-экрана» (Ruling 8): фронт жмёт «Проверить подключение» (store.js
|
||
/// checkAiConnection) БЕЗ тела — сервер читает АКТИВНУЮ конфигурацию провайдера тенанта
|
||
/// (настройки <c>aiProvider</c> + <c>aiConfigs</c> с расшифровкой ключа через <see cref="ISecretCipher"/>;
|
||
/// 1:1 с ai_svc._cfg(), ai.py L25–33), вызывает порт <see cref="IAiConnectionChecker"/> и отдаёт
|
||
/// {ok, message} + статус провайдера. Требует сессию: 401 {detail} (формат прототипа).
|
||
/// Резолв scoped-зависимостей — через RequestServices ПОСЛЕ проверки сессии (как SettingsEndpoints:
|
||
/// DI-биндинг параметров выполняется до тела, а ISettingsStore требует tenant-контекст запроса).
|
||
/// </remarks>
|
||
public static class AiCheckEndpoint
|
||
{
|
||
// Префикс группы API (общий для эндпоинтов этапа, Ruling 8).
|
||
private const string ApiGroupPrefix = "/api";
|
||
|
||
// Путь проверки подключения AI-провайдера.
|
||
private const string AiCheckPath = "/ai/check";
|
||
|
||
// OpenAPI-тег группы (в прототипе роутер settings — settings_routes.py).
|
||
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);
|
||
}
|
||
|
||
// Собирает запрос проверки из активной конфигурации провайдера (1:1 с ai_svc._cfg()).
|
||
// store: KV-хранилище настроек тенанта.
|
||
// secretCipher: Шифр секретов (расшифровка apiKey).
|
||
// ct: Токен отмены.
|
||
// Возвращает: Запрос проверки: id провайдера + эффективные base/model + расшифрованный ключ.
|
||
// Эффективные значения = дефолты SettingsDefaults, перекрытые сохранёнными
|
||
// переопределениями (Ruling 1); пустое переопределение base/model → дефолт каталога
|
||
// (семантика «cfg.get(...) or meta[...]» прототипа).
|
||
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, "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;
|
||
}
|
||
}
|