Провайдера, модель, 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.
167 lines
7.1 KiB
C#
167 lines
7.1 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;
|
|
using Deal.Modules.Settings.Application.Services;
|
|
using Deal.Modules.Tenants.Application.Models;
|
|
using Deal.SharedKernel.Utilities;
|
|
|
|
namespace Deal.Api.Endpoints;
|
|
|
|
/// <summary>
|
|
/// HTTP-эндпоинты настроек тенанта
|
|
/// </summary>
|
|
public static class SettingsEndpoints
|
|
{
|
|
private const string ApiGroupPrefix = "/api";
|
|
private const string SettingsPath = "/settings";
|
|
private const string SettingsOpenApiTag = "settings";
|
|
private const string InvalidBodyDetail = "Тело запроса должно быть JSON-объектом";
|
|
|
|
// Максимальная длина значения в деталях аудита (промпты/списки бывают длинными).
|
|
private const int MaxAuditValueLength = 120;
|
|
|
|
// Пометка для составных настроек, чьи значения в аудит не пишутся (секреты, снимки состояния).
|
|
private const string ChangedMarker = "изменено";
|
|
|
|
// Опции сериализации снимка настроек в деталях аудита (camelCase, как на wire).
|
|
private static readonly JsonSerializerOptions AuditJsonOptions = new(JsonSerializerDefaults.Web);
|
|
|
|
/// <summary>
|
|
/// Регистрирует GET/PATCH /api/settings.
|
|
/// </summary>
|
|
/// <param name="app">Построитель маршрутов приложения.</param>
|
|
/// <returns>Построитель маршрутов для цепочки вызовов.</returns>
|
|
public static IEndpointRouteBuilder MapSettingsEndpoints(this IEndpointRouteBuilder app)
|
|
{
|
|
var group = app.MapGroup(ApiGroupPrefix).WithTags(SettingsOpenApiTag);
|
|
|
|
group.MapGet(SettingsPath, GetSettingsAsync);
|
|
group.MapPatch(SettingsPath, PatchSettingsAsync);
|
|
|
|
return app;
|
|
}
|
|
|
|
// GET /api/settings: публичный снимок настроек текущего тенанта.
|
|
private static async Task<IResult> GetSettingsAsync(HttpContext context, CancellationToken ct)
|
|
{
|
|
if (!context.HasUser())
|
|
{
|
|
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
|
}
|
|
|
|
SettingsService settingsService = context.RequestServices.GetRequiredService<SettingsService>();
|
|
return Results.Ok(await settingsService.GetPublicAsync(ct));
|
|
}
|
|
|
|
// PATCH /api/settings: частичное обновление настроек; ответ — полный снимок после применения.
|
|
private static async Task<IResult> PatchSettingsAsync(HttpContext context, CancellationToken ct)
|
|
{
|
|
if (!context.HasUser())
|
|
{
|
|
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
|
}
|
|
|
|
Dictionary<string, JsonElement>? body;
|
|
try
|
|
{
|
|
body = await JsonSerializer.DeserializeAsync<Dictionary<string, JsonElement>>(
|
|
context.Request.Body,
|
|
options: null,
|
|
cancellationToken: ct);
|
|
}
|
|
catch (JsonException)
|
|
{
|
|
return EndpointResults.BadRequest(InvalidBodyDetail);
|
|
}
|
|
|
|
if (body is null)
|
|
{
|
|
return EndpointResults.BadRequest(InvalidBodyDetail);
|
|
}
|
|
|
|
SettingsService settingsService = context.RequestServices.GetRequiredService<SettingsService>();
|
|
PublicSettingsDto before = await settingsService.GetPublicAsync(ct);
|
|
PublicSettingsDto result = await settingsService.ApplyPatchAsync(body, ct);
|
|
|
|
await AuditAppender.AppendTenantAsync(context, AuditEvents.SettingsUpdated, BuildSettingsChanges(before, result, body.Keys), ct);
|
|
|
|
if (ShouldScheduleRatesRefresh(body))
|
|
{
|
|
context.RequestServices.GetRequiredService<RatesRefreshScheduler>().Schedule();
|
|
}
|
|
|
|
return Results.Ok(result);
|
|
}
|
|
|
|
// Строит изменения настроек «поле: было → стало» по применённому PATCH.
|
|
// before: Снимок настроек до применения.
|
|
// after: Снимок настроек после применения.
|
|
// keys: Ключи PATCH-тела (изменённые).
|
|
// Возвращает: Изменения по каждому ключу (составные — пометкой «изменено»).
|
|
private static IReadOnlyList<AuditChangeDto> BuildSettingsChanges(
|
|
PublicSettingsDto before,
|
|
PublicSettingsDto after,
|
|
IEnumerable<string> keys)
|
|
{
|
|
using JsonDocument beforeDoc = JsonDocument.Parse(JsonSerializer.Serialize(before, AuditJsonOptions));
|
|
using JsonDocument afterDoc = JsonDocument.Parse(JsonSerializer.Serialize(after, AuditJsonOptions));
|
|
var changes = new List<AuditChangeDto>();
|
|
var keyList = new List<string>();
|
|
foreach (string key in keys)
|
|
{
|
|
keyList.Add(key);
|
|
SettingKind? kind = SettingsKeys.FindPublicKind(key);
|
|
if (kind is SettingKind.Dict or SettingKind.MyPrompts)
|
|
{
|
|
changes.Add(AuditDetails.Set(key, ChangedMarker));
|
|
continue;
|
|
}
|
|
|
|
changes.Add(AuditDetails.Change(
|
|
key,
|
|
ReadSettingText(beforeDoc.RootElement, key),
|
|
ReadSettingText(afterDoc.RootElement, key)));
|
|
}
|
|
|
|
return changes.Count > 0
|
|
? changes
|
|
: [AuditDetails.Set(AuditFields.Fields, string.Join(", ", keyList))];
|
|
}
|
|
|
|
// Читает значение ключа снимка настроек как текст для деталей аудита.
|
|
private static string? ReadSettingText(JsonElement root, string key) =>
|
|
root.TryGetProperty(key, out JsonElement value) ? ToText(value) : null;
|
|
|
|
// Приводит значение JSON к строке отображения (длинные значения обрезаются).
|
|
private static string? ToText(JsonElement value) => value.ValueKind switch
|
|
{
|
|
JsonValueKind.Null or JsonValueKind.Undefined => null,
|
|
JsonValueKind.String => Truncate(value.GetString()),
|
|
JsonValueKind.True => BoolText.True,
|
|
JsonValueKind.False => BoolText.False,
|
|
JsonValueKind.Array => Truncate(string.Join(", ", value.EnumerateArray().Select(ToText))),
|
|
_ => Truncate(value.GetRawText()),
|
|
};
|
|
|
|
// Обрезает длинное значение деталей аудита.
|
|
private static string? Truncate(string? value) =>
|
|
value is not null && value.Length > MaxAuditValueLength ? value[..MaxAuditValueLength] + "…" : value;
|
|
|
|
private static bool ShouldScheduleRatesRefresh(Dictionary<string, JsonElement> body)
|
|
{
|
|
if (!body.TryGetValue(SettingsKeys.RateSource, out JsonElement element))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
return element.ValueKind switch
|
|
{
|
|
JsonValueKind.String => !string.IsNullOrEmpty(element.GetString()),
|
|
JsonValueKind.Null => false,
|
|
_ => true,
|
|
};
|
|
}
|
|
}
|