Integrations -> Abstractions/Exceptions/Extensions/Models/Options/ Services (включая Storage); Persistence-конфигурации -> Configurations; корень Deal.Api (оркестратор/планировщики/DTO) -> Services/Dtos. namespace приведён к путям, using добавлены/дедуплицированы, FQN обновлены.
251 lines
13 KiB
C#
251 lines
13 KiB
C#
using Deal.Api.Http;
|
|
using Deal.Modules.Tenants.Application.Abstractions;
|
|
using Deal.Modules.Tenants.Application.Extensions;
|
|
using Deal.Modules.Tenants.Application.Models;
|
|
using Deal.Modules.Tenants.Application.Registrars;
|
|
using Deal.Modules.Tenants.Application.Services;
|
|
using Deal.Api.Services;
|
|
using Deal.Api.Dtos;
|
|
|
|
namespace Deal.Api.Endpoints;
|
|
|
|
/// <summary>
|
|
/// Операторские эндпоинты лимитов ИИ-бюджета: сводка по всем тенантам и просмотр/смена лимита тенанта
|
|
/// (план Task 10, Ruling 3/11).
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Все ручки — только оператору: без операторской сессии 401 «Требуется вход оператора» (как остальные
|
|
/// /api/operator/*). GET /api/operator/limits — сводка {items:[{tenantId, name, budget, period, used, percent,
|
|
/// status}]} по реестру тенантов (Ruling 3: строка лимита на путь чтения заводится лениво с дефолт-бюджетом —
|
|
/// тенант без расхода виден как «дефолт, 0»). GET/PATCH /api/operator/tenants/{id}/limit — детали/смена лимита:
|
|
/// PATCH принимает {budget?, period?} (оба опциональны — меняется только заданное; null-тело/без полей → 400),
|
|
/// сбрасывает Warned80/NotifiedExhausted через UpdateBudgetAsync (Ruling 3: смена бюджета открывает пороги
|
|
/// тостов заново) и пишет аудит tenant_limit_changed (только при реальном изменении — повторный PATCH с теми же
|
|
/// значениями идемпотентен, аудит не дублируется). Отрицательный бюджет/чужой период отсекаются 400 до вызова
|
|
/// хранилища; тенант проверяется по реестру (404 «Тенант не найден»). Ответы деталей — единая форма
|
|
/// (см. <see cref="BuildDetailDto"/>) — статус тенанта, флаги порогов и процент расхода.
|
|
/// </remarks>
|
|
public static class OperatorLimitsEndpoints
|
|
{
|
|
// Текст 400: PATCH без полей (null-тело/пустой объект).
|
|
private const string EmptyUpdateDetail = "Укажите новый бюджет или период";
|
|
|
|
// Текст 400: бюджет отрицательный (порог лимита не позволяет).
|
|
private const string NegativeBudgetDetail = "Бюджет должен быть неотрицательным";
|
|
|
|
// Текст 400: период не month и не day (константы TenantLimitPeriods).
|
|
private const string InvalidPeriodDetail = "Период должен быть month или day";
|
|
|
|
// Текст 404: тенант с таким id не найден в реестре.
|
|
private const string TenantNotFoundDetail = "Тенант не найден";
|
|
|
|
// Верхняя граница процента расхода (диапазон 0..100) — константа расчёта CalculatePercent.
|
|
private const int PercentMax = 100;
|
|
|
|
// Префикс сводки лимитов (Ruling 11: /api/operator/*).
|
|
private const string OperatorGroupPrefix = "/api/operator";
|
|
|
|
// Префикс группы операторских ручек тенантов (общий с Task 7).
|
|
private const string TenantsGroupPrefix = "/api/operator/tenants";
|
|
|
|
// Путь сводки лимитов по всем тенантам.
|
|
private const string SummaryPath = "/limits";
|
|
|
|
// Относительный путь лимита тенанта (просмотр/смена).
|
|
private const string TenantLimitPath = "/{id:guid}/limit";
|
|
|
|
// OpenAPI-тег группы сводки лимитов.
|
|
private const string LimitsOpenApiTag = "operator-limits";
|
|
|
|
// Без состояния, поэтому безопасен как статический экземпляр (период-математика Task 8).
|
|
private static readonly TokenBudgetService BudgetService = new();
|
|
|
|
/// <summary>
|
|
/// Регистрирует ручки лимитов: GET /api/operator/limits (сводка) и GET/PATCH
|
|
/// /api/operator/tenants/{id}/limit (детали/смена).
|
|
/// </summary>
|
|
/// <param name="app">Построитель маршрутов приложения.</param>
|
|
/// <returns>Построитель маршрутов для цепочки вызовов.</returns>
|
|
public static IEndpointRouteBuilder MapOperatorLimitsEndpoints(this IEndpointRouteBuilder app)
|
|
{
|
|
app.MapGroup(OperatorGroupPrefix).WithTags(LimitsOpenApiTag).MapGet(SummaryPath, ListSummaryAsync);
|
|
var tenantsGroup = app.MapGroup(TenantsGroupPrefix).WithTags(LimitsOpenApiTag);
|
|
tenantsGroup.MapGet(TenantLimitPath, GetLimitAsync);
|
|
tenantsGroup.MapPatch(TenantLimitPath, PatchLimitAsync);
|
|
return app;
|
|
}
|
|
|
|
// GET /api/operator/limits: сводка бюджета/расхода по всем тенантам (план Task 10).
|
|
private static async Task<IResult> ListSummaryAsync(
|
|
HttpContext context,
|
|
ITenantRepository tenantRepository,
|
|
ITenantLimitStore limitStore,
|
|
CancellationToken ct)
|
|
{
|
|
var operatorIdentity = context.GetCurrentOperator();
|
|
if (operatorIdentity is null)
|
|
{
|
|
return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail);
|
|
}
|
|
|
|
IReadOnlyList<TenantRecordDto> tenants = await tenantRepository.ListAsync(ct);
|
|
var items = new List<object>(tenants.Count);
|
|
foreach (TenantRecordDto tenant in tenants)
|
|
{
|
|
// Ленивый reset периода внутри GetStateAsync (Ruling 3): сводка всегда про текущий период.
|
|
BudgetStateDto state = await limitStore.GetStateAsync(tenant.Id, ct);
|
|
items.Add(new
|
|
{
|
|
tenantId = tenant.Id,
|
|
name = tenant.Name,
|
|
budget = state.BudgetTokens,
|
|
period = state.Period,
|
|
used = state.UsedTokens,
|
|
percent = CalculatePercent(state.UsedTokens, state.BudgetTokens),
|
|
status = state.Status,
|
|
});
|
|
}
|
|
|
|
return Results.Ok(new { items });
|
|
}
|
|
|
|
// GET /api/operator/tenants/{id}/limit: детали лимита тенанта (форма BuildDetailDto).
|
|
private static async Task<IResult> GetLimitAsync(
|
|
Guid id,
|
|
HttpContext context,
|
|
ITenantRepository tenantRepository,
|
|
ITenantLimitStore limitStore,
|
|
CancellationToken ct)
|
|
{
|
|
var operatorIdentity = context.GetCurrentOperator();
|
|
if (operatorIdentity is null)
|
|
{
|
|
return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail);
|
|
}
|
|
|
|
TenantRecordDto? tenant = await tenantRepository.FindByIdAsync(id, ct);
|
|
if (tenant is null)
|
|
{
|
|
return EndpointResults.NotFound(TenantNotFoundDetail);
|
|
}
|
|
|
|
BudgetStateDto state = await limitStore.GetStateAsync(id, ct);
|
|
return Results.Ok(BuildDetailDto(tenant.Name, state));
|
|
}
|
|
|
|
// PATCH /api/operator/tenants/{id}/limit: смена бюджета/периода (сброс флагов + аудит tenant_limit_changed).
|
|
private static async Task<IResult> PatchLimitAsync(
|
|
Guid id,
|
|
OperatorLimitUpdateRequest? body,
|
|
HttpContext context,
|
|
ITenantRepository tenantRepository,
|
|
ITenantLimitStore limitStore,
|
|
AuditService auditService,
|
|
CancellationToken ct)
|
|
{
|
|
var operatorIdentity = context.GetCurrentOperator();
|
|
if (operatorIdentity is null)
|
|
{
|
|
return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail);
|
|
}
|
|
|
|
if (body is null || (body.Budget is null && string.IsNullOrWhiteSpace(body.Period)))
|
|
{
|
|
return EndpointResults.BadRequest(EmptyUpdateDetail);
|
|
}
|
|
|
|
if (body.Budget is < 0)
|
|
{
|
|
return EndpointResults.BadRequest(NegativeBudgetDetail);
|
|
}
|
|
|
|
if (body.Period is not null
|
|
&& body.Period != TenantLimitPeriods.Month
|
|
&& body.Period != TenantLimitPeriods.Day)
|
|
{
|
|
return EndpointResults.BadRequest(InvalidPeriodDetail);
|
|
}
|
|
|
|
TenantRecordDto? tenant = await tenantRepository.FindByIdAsync(id, ct);
|
|
if (tenant is null)
|
|
{
|
|
return EndpointResults.NotFound(TenantNotFoundDetail);
|
|
}
|
|
|
|
// Текущее состояние — источник значений не заданных в PATCH полей (период/бюджет меняются по отдельности).
|
|
BudgetStateDto current = await limitStore.GetStateAsync(id, ct);
|
|
long newBudget = body.Budget ?? current.BudgetTokens;
|
|
string newPeriod = body.Period ?? current.Period;
|
|
if (newBudget == current.BudgetTokens && newPeriod == current.Period)
|
|
{
|
|
// Идемпотентный повторный PATCH: без изменения хранилища и без дубля аудита.
|
|
return Results.Ok(BuildDetailDto(tenant.Name, current));
|
|
}
|
|
|
|
BudgetStateDto updated = await limitStore.UpdateBudgetAsync(id, newBudget, newPeriod, ct);
|
|
await auditService.AppendAsync(new AuditRecordDto(
|
|
AuditEvents.TenantLimitChanged,
|
|
AuditActorTypes.Operator,
|
|
ActorId: operatorIdentity.OperatorId,
|
|
TenantId: id,
|
|
Ip: ClientIp(context),
|
|
DetailJson: AuditService.ToDetailJson(new
|
|
{
|
|
tenantId = id,
|
|
oldBudget = current.BudgetTokens,
|
|
oldPeriod = current.Period,
|
|
budgetTokens = newBudget,
|
|
period = newPeriod,
|
|
})), ct);
|
|
|
|
return Results.Ok(BuildDetailDto(tenant.Name, updated));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Процент расхода бюджета для операторской сводки/деталей (0..100, floor).
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Бюджет ≤0 трактуется как исчерпанный (лимит 0 запрещает ИИ, Ruling 3) → 100%; расход ≥ бюджета также
|
|
/// показывается как 100 (потолок индикатора). Расчёт — в double: диапазон long (до ~9.2·10¹⁸ токенов)
|
|
/// не переполняет double, floor-ошибка возможна только на границе целого при масштабах, нереальных для
|
|
/// бюджета токенов (целочисленный used·100/budget переполнялся бы при used > ~9.2·10¹⁶).
|
|
/// </remarks>
|
|
/// <param name="usedTokens">Использовано токенов с начала периода.</param>
|
|
/// <param name="budgetTokens">Бюджет периода.</param>
|
|
/// <returns>Процент в диапазоне 0..100 (расход сверх бюджета показывается как 100).</returns>
|
|
public static int CalculatePercent(long usedTokens, long budgetTokens)
|
|
{
|
|
if (budgetTokens <= 0 || usedTokens >= budgetTokens)
|
|
{
|
|
return PercentMax;
|
|
}
|
|
|
|
return (int)(usedTokens * (double)PercentMax / budgetTokens);
|
|
}
|
|
|
|
// Форма деталей лимита тенанта (GET и ответ PATCH — единая).
|
|
// name: Имя тенанта (реестр).
|
|
// state: Состояние бюджета (после ленивого reset).
|
|
// Возвращает: Объект ответа: лимит + расход + флаги порогов + статус тенанта.
|
|
private static object BuildDetailDto(string name, BudgetStateDto state) => new
|
|
{
|
|
tenantId = state.TenantId,
|
|
name,
|
|
status = state.Status,
|
|
allowed = state.Allowed,
|
|
budget = state.BudgetTokens,
|
|
period = state.Period,
|
|
periodStart = state.PeriodStart,
|
|
used = state.UsedTokens,
|
|
remaining = BudgetService.RemainingTokens(state.UsedTokens, state.BudgetTokens),
|
|
percent = CalculatePercent(state.UsedTokens, state.BudgetTokens),
|
|
warned80 = state.Warned80,
|
|
notifiedExhausted = state.NotifiedExhausted,
|
|
};
|
|
|
|
// IP-адрес клиента для аудита (без порта; null, если недоступен).
|
|
// context: Контекст запроса.
|
|
// Возвращает: Строковое представление IP или null.
|
|
private static string? ClientIp(HttpContext context) => context.Connection.RemoteIpAddress?.ToString();
|
|
}
|