Files
Deal/src/core/Deal.Api/Endpoints/OperatorLimitsEndpoints.cs
T
Rustam Khalimov 410194b0cb Разбить Infrastructure и корень Deal.Api по назначению
Integrations -> Abstractions/Exceptions/Extensions/Models/Options/
Services (включая Storage); Persistence-конфигурации -> Configurations;
корень Deal.Api (оркестратор/планировщики/DTO) -> Services/Dtos.
namespace приведён к путям, using добавлены/дедуплицированы, FQN
обновлены.
2026-09-11 13:20:10 +03:00

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 &gt; ~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();
}