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; /// /// Операторские эндпоинты лимитов ИИ-бюджета: сводка по всем тенантам и просмотр/смена лимита тенанта /// (план Task 10, Ruling 3/11). /// /// /// Все ручки — только оператору: без операторской сессии 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 «Тенант не найден»). Ответы деталей — единая форма /// (см. ) — статус тенанта, флаги порогов и процент расхода. /// 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(); /// /// Регистрирует ручки лимитов: GET /api/operator/limits (сводка) и GET/PATCH /// /api/operator/tenants/{id}/limit (детали/смена). /// /// Построитель маршрутов приложения. /// Построитель маршрутов для цепочки вызовов. 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 ListSummaryAsync( HttpContext context, ITenantRepository tenantRepository, ITenantLimitStore limitStore, CancellationToken ct) { var operatorIdentity = context.GetCurrentOperator(); if (operatorIdentity is null) { return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail); } IReadOnlyList tenants = await tenantRepository.ListAsync(ct); var items = new List(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 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 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)); } /// /// Процент расхода бюджета для операторской сводки/деталей (0..100, floor). /// /// /// Бюджет ≤0 трактуется как исчерпанный (лимит 0 запрещает ИИ, Ruling 3) → 100%; расход ≥ бюджета также /// показывается как 100 (потолок индикатора). Расчёт — в double: диапазон long (до ~9.2·10¹⁸ токенов) /// не переполняет double, floor-ошибка возможна только на границе целого при масштабах, нереальных для /// бюджета токенов (целочисленный used·100/budget переполнялся бы при used > ~9.2·10¹⁶). /// /// Использовано токенов с начала периода. /// Бюджет периода. /// Процент в диапазоне 0..100 (расход сверх бюджета показывается как 100). 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(); }