Удалены <remarks>, <summary> сжаты до короткой фразы, вырезаны ссылки на Task/Ruling/этап/python/прототип; //-комментарии со ссылками на процесс удалены; то же в .proto. Правила обновлены в docs/spec/Код-стайл-Дейл.md. Строк комментариев 27210 -> ~19100.
159 lines
7.4 KiB
C#
159 lines
7.4 KiB
C#
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);
|
|
}
|
|
|
|
// store: KV-хранилище настроек тенанта.
|
|
// secretCipher: Шифр секретов (расшифровка apiKey).
|
|
// ct: Токен отмены.
|
|
// Возвращает: Запрос проверки: id провайдера + эффективные base/model + расшифрованный ключ.
|
|
// Эффективные значения = дефолты SettingsDefaults, перекрытые сохранёнными
|
|
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;
|
|
}
|
|
}
|