Инициализировать репозиторий «Дейл»
Первый коммит: модульный монолит ядра (.NET 10) и gRPC-сервисы ai/ml/telegram, фронтенд Vue 3/Vite/Tailwind, документация (ТЗ, инструкция пользователя, техдокументация, код-стайл), бэклог, скрипты развёртывания и архив прототипа LeadRadar.
This commit is contained in:
@@ -0,0 +1,245 @@
|
||||
using Deal.Api.Http;
|
||||
using Deal.Modules.Tenants.Application;
|
||||
using Deal.Modules.Tenants.Application.Models;
|
||||
|
||||
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();
|
||||
}
|
||||
Reference in New Issue
Block a user