Убрать неиспользуемые using по код-стайлу
Прогон dotnet format (IDE0005) по 4 решениям: удалены лишние using, оставшиеся после миграции namespace (676 файлов).
This commit is contained in:
@@ -1,10 +1,8 @@
|
||||
using System.Text.Json.Nodes;
|
||||
using Deal.Ai.Llm;
|
||||
using Deal.Grpc.Ai;
|
||||
using Grpc.Core;
|
||||
using Grpc.Net.Client;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using System.Text.Json.Nodes;
|
||||
using Deal.Ai.Tests.Support;
|
||||
|
||||
namespace Deal.Ai.Tests.Ai;
|
||||
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
using Deal.Ai;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using Deal.Ai.Llm;
|
||||
using Deal.Grpc.Hosting;
|
||||
using Grpc.Core;
|
||||
using Grpc.Net.Client;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using Deal.Ai.Tests.Support;
|
||||
|
||||
namespace Deal.Ai.Tests.Ai;
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using Deal.Ai.Llm;
|
||||
using Deal.Ai.Tests.Support;
|
||||
|
||||
namespace Deal.Ai.Tests.Ai;
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
using Deal.Ai.Llm;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Deal.Ai.Tests.Support;
|
||||
|
||||
namespace Deal.Ai.Tests.Ai;
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using Deal.Ai.Llm;
|
||||
using Deal.Ai.Tests.Support;
|
||||
|
||||
namespace Deal.Ai.Tests.Ai;
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
using Xunit;
|
||||
|
||||
// In-proc gRPC-тесты ai-service поднимают реальные Kestrel-хосты и меняют процесс-глобальную
|
||||
// env-переменную DEAL_SERVICE_TOKEN на время сценария (AiTestHost и AiServiceHostTests).
|
||||
// Параллельный прогон классов дал бы гонки на env — тесты сериализованы (тот же шаблон, что
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using Deal.Grpc.Ai;
|
||||
using Deal.Ai;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Grpc.Core;
|
||||
using Grpc.Health.V1;
|
||||
using Grpc.Net.Client;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using Xunit;
|
||||
using Deal.Ai.Tests.Support;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
|
||||
namespace Deal.Ai.Tests.Grpc;
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using System.Text.Json.Nodes;
|
||||
using Deal.Ai.Llm;
|
||||
|
||||
namespace Deal.Ai.Tests.Support;
|
||||
|
||||
|
||||
@@ -1,39 +1,33 @@
|
||||
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.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Настройки httpOnly-куки сессии. Привязываются из секции "Cookies" конфигурации (IOptions).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Источник значений — конфигурация: секция <c>Cookies</c> в appsettings.json /
|
||||
/// appsettings.Development.json и переменные окружения <c>Cookies__*</c> (имя, срок, Secure).
|
||||
/// <para>
|
||||
/// Срок жизни по умолчанию — единый источник числа «30 дней»: константа модуля
|
||||
/// <see cref="AuthService.SessionLifetimeDays"/>, на которую ссылается код-дефолт свойства
|
||||
/// <see cref="Days"/>. Значение из конфигурации (<c>Cookies__Days</c>) при необходимости перекрывает его.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class CookieOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Имя куки (Ruling 6: <c>deal_session</c>).
|
||||
/// </summary>
|
||||
public string Name { get; set; } = "deal_session";
|
||||
|
||||
/// <summary>
|
||||
/// Срок жизни куки в днях; совпадает со сроком жизни сессии (Ruling 6).
|
||||
/// </summary>
|
||||
public int Days { get; set; } = AuthService.SessionLifetimeDays;
|
||||
|
||||
/// <summary>
|
||||
/// Флаг Secure куки (dev=false; включается при HTTPS-проксировании, Ruling 6).
|
||||
/// </summary>
|
||||
public bool Secure { get; set; }
|
||||
}
|
||||
using Deal.Modules.Tenants.Application.Services;
|
||||
|
||||
namespace Deal.Api.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Настройки httpOnly-куки сессии. Привязываются из секции "Cookies" конфигурации (IOptions).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Источник значений — конфигурация: секция <c>Cookies</c> в appsettings.json /
|
||||
/// appsettings.Development.json и переменные окружения <c>Cookies__*</c> (имя, срок, Secure).
|
||||
/// <para>
|
||||
/// Срок жизни по умолчанию — единый источник числа «30 дней»: константа модуля
|
||||
/// <see cref="AuthService.SessionLifetimeDays"/>, на которую ссылается код-дефолт свойства
|
||||
/// <see cref="Days"/>. Значение из конфигурации (<c>Cookies__Days</c>) при необходимости перекрывает его.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class CookieOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Имя куки (Ruling 6: <c>deal_session</c>).
|
||||
/// </summary>
|
||||
public string Name { get; set; } = "deal_session";
|
||||
|
||||
/// <summary>
|
||||
/// Срок жизни куки в днях; совпадает со сроком жизни сессии (Ruling 6).
|
||||
/// </summary>
|
||||
public int Days { get; set; } = AuthService.SessionLifetimeDays;
|
||||
|
||||
/// <summary>
|
||||
/// Флаг Secure куки (dev=false; включается при HTTPS-проксировании, Ruling 6).
|
||||
/// </summary>
|
||||
public bool Secure { get; set; }
|
||||
}
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
using Deal.Api.Services;
|
||||
using Deal.Api.Dtos;
|
||||
|
||||
namespace Deal.Api.Configuration;
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -1,42 +1,39 @@
|
||||
using Deal.Api.Services;
|
||||
using Deal.Api.Dtos;
|
||||
|
||||
namespace Deal.Api.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Настройки доверия прокси-заголовкам (план Task 12; замечание ревью T4/T11 к Ruling 5/10): секция
|
||||
/// <c>ForwardedHeaders</c> конфигурации (appsettings.json + переменные окружения <c>ForwardedHeaders__*</c>).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// В PROD наружу смотрит только Caddy (compose-prod, Task 14), core принимает соединения от него:
|
||||
/// без обработки X-Forwarded-For/X-Forwarded-Proto RemoteIpAddress (HttpContext.Connection)
|
||||
/// всех запросов — адрес Caddy, и audit-IP (ClientIp эндпоинтов, Ruling 4) и rate-limit-по-IP
|
||||
/// (политики Ruling 5, LoginAttemptGuard) схлопываются в один бакет прокси. UseForwardedHeaders
|
||||
/// доверяет заголовкам только клиентов из <see cref="KnownProxies"/> (IP-адреса) и
|
||||
/// <see cref="KnownNetworks"/> (подсети CIDR).
|
||||
/// <para>
|
||||
/// <c>Enabled=false</c> — код-дефолт и значение dev/тестов: прокси в dev-стеке нет (compose.dev —
|
||||
/// core наружу напрямую :5080), curl-приёмки от заголовков не зависят. PROD включает env
|
||||
/// (<c>ForwardedHeaders__Enabled=true</c>) и перечисляет Caddy: KnownProxies (его IP) либо KnownNetworks
|
||||
/// (узкий CIDR compose-сети). Пустые KnownProxies/KnownNetworks у ForwardedHeadersMiddleware означают
|
||||
/// «доверять любому клиенту» — Program.BuildForwardedHeadersOptions не допускает пустоту и добавляет
|
||||
/// loopback-фолбэк (dev-прокси на хосте: vite/локальный Caddy); явное перечисление в конфиге замещает его.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class ForwardedHeadersConfig
|
||||
{
|
||||
/// <summary>
|
||||
/// Включена ли обработка прокси-заголовков (dev/тесты — false; PROD за Caddy — true).
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Доверенные прокси-адреса: IP клиентов, которым можно верить в X-Forwarded-For/Proto.
|
||||
/// </summary>
|
||||
public string[] KnownProxies { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Доверенные подсети прокси в CIDR (например "172.16.0.0/12" — compose-сеть PROD).
|
||||
/// </summary>
|
||||
public string[] KnownNetworks { get; set; } = [];
|
||||
}
|
||||
namespace Deal.Api.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Настройки доверия прокси-заголовкам (план Task 12; замечание ревью T4/T11 к Ruling 5/10): секция
|
||||
/// <c>ForwardedHeaders</c> конфигурации (appsettings.json + переменные окружения <c>ForwardedHeaders__*</c>).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// В PROD наружу смотрит только Caddy (compose-prod, Task 14), core принимает соединения от него:
|
||||
/// без обработки X-Forwarded-For/X-Forwarded-Proto RemoteIpAddress (HttpContext.Connection)
|
||||
/// всех запросов — адрес Caddy, и audit-IP (ClientIp эндпоинтов, Ruling 4) и rate-limit-по-IP
|
||||
/// (политики Ruling 5, LoginAttemptGuard) схлопываются в один бакет прокси. UseForwardedHeaders
|
||||
/// доверяет заголовкам только клиентов из <see cref="KnownProxies"/> (IP-адреса) и
|
||||
/// <see cref="KnownNetworks"/> (подсети CIDR).
|
||||
/// <para>
|
||||
/// <c>Enabled=false</c> — код-дефолт и значение dev/тестов: прокси в dev-стеке нет (compose.dev —
|
||||
/// core наружу напрямую :5080), curl-приёмки от заголовков не зависят. PROD включает env
|
||||
/// (<c>ForwardedHeaders__Enabled=true</c>) и перечисляет Caddy: KnownProxies (его IP) либо KnownNetworks
|
||||
/// (узкий CIDR compose-сети). Пустые KnownProxies/KnownNetworks у ForwardedHeadersMiddleware означают
|
||||
/// «доверять любому клиенту» — Program.BuildForwardedHeadersOptions не допускает пустоту и добавляет
|
||||
/// loopback-фолбэк (dev-прокси на хосте: vite/локальный Caddy); явное перечисление в конфиге замещает его.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class ForwardedHeadersConfig
|
||||
{
|
||||
/// <summary>
|
||||
/// Включена ли обработка прокси-заголовков (dev/тесты — false; PROD за Caddy — true).
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Доверенные прокси-адреса: IP клиентов, которым можно верить в X-Forwarded-For/Proto.
|
||||
/// </summary>
|
||||
public string[] KnownProxies { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Доверенные подсети прокси в CIDR (например "172.16.0.0/12" — compose-сеть PROD).
|
||||
/// </summary>
|
||||
public string[] KnownNetworks { get; set; } = [];
|
||||
}
|
||||
|
||||
@@ -1,41 +1,35 @@
|
||||
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.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Настройки httpOnly-куки сессии оператора. Привязываются из секции "OperatorCookies" конфигурации (IOptions).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Источник значений — конфигурация: секция <c>OperatorCookies</c> в appsettings.json и переменные
|
||||
/// окружения <c>OperatorCookies__*</c> (имя, срок, Secure). Имя по умолчанию — <c>deal_operator_session</c>:
|
||||
/// отдельная от тенантной <c>deal_session</c> кука (Ruling 1 этапа 7) — операторская сессия не может быть
|
||||
/// подменена тенантной и наоборот (сессии разрешаются разными middleware).
|
||||
/// <para>
|
||||
/// Срок жизни по умолчанию — единый источник числа «12 часов»: константа модуля
|
||||
/// <see cref="OperatorAuthService.SessionLifetimeHours"/>, на которую ссылается код-дефолт свойства
|
||||
/// <see cref="Hours"/>. Значение из конфигурации (<c>OperatorCookies__Hours</c>) при необходимости перекрывает его.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class OperatorCookieOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Имя куки (Ruling 1: <c>deal_operator_session</c>).
|
||||
/// </summary>
|
||||
public string Name { get; set; } = "deal_operator_session";
|
||||
|
||||
/// <summary>
|
||||
/// Срок жизни куки в часах; совпадает со сроком жизни сессии оператора (Ruling 1: 12).
|
||||
/// </summary>
|
||||
public int Hours { get; set; } = OperatorAuthService.SessionLifetimeHours;
|
||||
|
||||
/// <summary>
|
||||
/// Флаг Secure куки (dev=false; включается при HTTPS-проксировании, Ruling 1).
|
||||
/// </summary>
|
||||
public bool Secure { get; set; }
|
||||
}
|
||||
using Deal.Modules.Tenants.Application.Services;
|
||||
|
||||
namespace Deal.Api.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Настройки httpOnly-куки сессии оператора. Привязываются из секции "OperatorCookies" конфигурации (IOptions).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Источник значений — конфигурация: секция <c>OperatorCookies</c> в appsettings.json и переменные
|
||||
/// окружения <c>OperatorCookies__*</c> (имя, срок, Secure). Имя по умолчанию — <c>deal_operator_session</c>:
|
||||
/// отдельная от тенантной <c>deal_session</c> кука (Ruling 1 этапа 7) — операторская сессия не может быть
|
||||
/// подменена тенантной и наоборот (сессии разрешаются разными middleware).
|
||||
/// <para>
|
||||
/// Срок жизни по умолчанию — единый источник числа «12 часов»: константа модуля
|
||||
/// <see cref="OperatorAuthService.SessionLifetimeHours"/>, на которую ссылается код-дефолт свойства
|
||||
/// <see cref="Hours"/>. Значение из конфигурации (<c>OperatorCookies__Hours</c>) при необходимости перекрывает его.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class OperatorCookieOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Имя куки (Ruling 1: <c>deal_operator_session</c>).
|
||||
/// </summary>
|
||||
public string Name { get; set; } = "deal_operator_session";
|
||||
|
||||
/// <summary>
|
||||
/// Срок жизни куки в часах; совпадает со сроком жизни сессии оператора (Ruling 1: 12).
|
||||
/// </summary>
|
||||
public int Hours { get; set; } = OperatorAuthService.SessionLifetimeHours;
|
||||
|
||||
/// <summary>
|
||||
/// Флаг Secure куки (dev=false; включается при HTTPS-проксировании, Ruling 1).
|
||||
/// </summary>
|
||||
public bool Secure { get; set; }
|
||||
}
|
||||
|
||||
@@ -1,46 +1,43 @@
|
||||
using Deal.Api.Services;
|
||||
using Deal.Api.Dtos;
|
||||
|
||||
namespace Deal.Api.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Настройки rate limiting и защиты входа (план Task 11, Ruling 5): секция <c>RateLimit</c> конфигурации
|
||||
/// (appsettings.json + переменные окружения <c>RateLimit__*</c>).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <c>Enabled=false</c> — код-дефолт и значение dev/тестов: политики и middleware не регистрируются вовсе,
|
||||
/// curl-приёмки и unit-хосты не режутся. PROD включает env-переопределением (<c>RateLimit__Enabled=true</c>
|
||||
/// в compose-prod, Task 14). Все окна политик — фиксированные, 1 минута (имена свойств — «PerMinute»).
|
||||
/// </remarks>
|
||||
public sealed class RateLimitOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Включены ли rate limiting и LoginAttemptGuard (dev/тесты — false, Ruling 5).
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Лимит политики "auth" (фиксированное окно в минуту на IP) для /api/auth/login и /api/operator/auth/login.
|
||||
/// </summary>
|
||||
public int AuthPerMinute { get; set; } = 10;
|
||||
|
||||
/// <summary>
|
||||
/// Лимит политики "api" (в минуту на тенанта либо IP анонима) для остальных /api-эндпоинтов.
|
||||
/// </summary>
|
||||
public int ApiPerMinute { get; set; } = 600;
|
||||
|
||||
/// <summary>
|
||||
/// Лимит gRPC-ингресса (в минуту на tenant-id из metadata; интерцептор IngressRateLimitInterceptor).
|
||||
/// </summary>
|
||||
public int GrpcIngressPerMinute { get; set; } = 600;
|
||||
|
||||
/// <summary>
|
||||
/// Порог неудачных попыток входа ключа ip|login до блокировки (LoginAttemptGuard).
|
||||
/// </summary>
|
||||
public int LoginAttemptsMax { get; set; } = 5;
|
||||
|
||||
/// <summary>
|
||||
/// Окно учёта неудачных попыток входа в минутах (LoginAttemptGuard; текст 429 — фиксированный «15 минут»).
|
||||
/// </summary>
|
||||
public int LoginAttemptWindowMin { get; set; } = 15;
|
||||
}
|
||||
namespace Deal.Api.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Настройки rate limiting и защиты входа (план Task 11, Ruling 5): секция <c>RateLimit</c> конфигурации
|
||||
/// (appsettings.json + переменные окружения <c>RateLimit__*</c>).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <c>Enabled=false</c> — код-дефолт и значение dev/тестов: политики и middleware не регистрируются вовсе,
|
||||
/// curl-приёмки и unit-хосты не режутся. PROD включает env-переопределением (<c>RateLimit__Enabled=true</c>
|
||||
/// в compose-prod, Task 14). Все окна политик — фиксированные, 1 минута (имена свойств — «PerMinute»).
|
||||
/// </remarks>
|
||||
public sealed class RateLimitOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Включены ли rate limiting и LoginAttemptGuard (dev/тесты — false, Ruling 5).
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Лимит политики "auth" (фиксированное окно в минуту на IP) для /api/auth/login и /api/operator/auth/login.
|
||||
/// </summary>
|
||||
public int AuthPerMinute { get; set; } = 10;
|
||||
|
||||
/// <summary>
|
||||
/// Лимит политики "api" (в минуту на тенанта либо IP анонима) для остальных /api-эндпоинтов.
|
||||
/// </summary>
|
||||
public int ApiPerMinute { get; set; } = 600;
|
||||
|
||||
/// <summary>
|
||||
/// Лимит gRPC-ингресса (в минуту на tenant-id из metadata; интерцептор IngressRateLimitInterceptor).
|
||||
/// </summary>
|
||||
public int GrpcIngressPerMinute { get; set; } = 600;
|
||||
|
||||
/// <summary>
|
||||
/// Порог неудачных попыток входа ключа ip|login до блокировки (LoginAttemptGuard).
|
||||
/// </summary>
|
||||
public int LoginAttemptsMax { get; set; } = 5;
|
||||
|
||||
/// <summary>
|
||||
/// Окно учёта неудачных попыток входа в минутах (LoginAttemptGuard; текст 429 — фиксированный «15 минут»).
|
||||
/// </summary>
|
||||
public int LoginAttemptWindowMin { get; set; } = 15;
|
||||
}
|
||||
|
||||
@@ -1,28 +1,25 @@
|
||||
using Deal.Api.Services;
|
||||
using Deal.Api.Dtos;
|
||||
|
||||
namespace Deal.Api.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Настройки безопасности HTTP (план Task 12, Ruling 10(2)/9): секция <c>Security</c> конфигурации
|
||||
/// (appsettings.json + переменные окружения <c>Security__*</c>).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <see cref="AllowedOrigins"/> — единый явный allowlist для Origin-проверки мутаций
|
||||
/// (<see cref="Deal.Api.Middleware.OriginGuardMiddleware"/>) и CORS-политики. Пустой список — dev-режим:
|
||||
/// OriginGuard принимает только «свой» origin запроса (схема + Host, для не-GET запросов /api),
|
||||
/// CORS разрешает любой origin (текущее поведение «как в прототипе»). Непустой список (PROD,
|
||||
/// compose-prod, Ruling 9) — CORS становится строгим allowlist + credentials; OriginGuard дополнительно
|
||||
/// принимает перечисленные origin'ы (в т.ч. когда запрос идёт не от «своего» Host — фронт за прокси).
|
||||
/// <para>
|
||||
/// Записи — полные origin'ы в том виде, в каком их шлёт браузер: схема://хост[:порт], без завершающего
|
||||
/// слэша (например <c>https://deal.example</c>, <c>http://localhost:5173</c>). Сравнение регистронезависимо.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class SecurityOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Явный allowlist Origin/CORS (схема://хост[:порт]); пусто — dev-режим «любой origin + свой Host».
|
||||
/// </summary>
|
||||
public string[] AllowedOrigins { get; set; } = [];
|
||||
}
|
||||
namespace Deal.Api.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Настройки безопасности HTTP (план Task 12, Ruling 10(2)/9): секция <c>Security</c> конфигурации
|
||||
/// (appsettings.json + переменные окружения <c>Security__*</c>).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <see cref="AllowedOrigins"/> — единый явный allowlist для Origin-проверки мутаций
|
||||
/// (<see cref="Deal.Api.Middleware.OriginGuardMiddleware"/>) и CORS-политики. Пустой список — dev-режим:
|
||||
/// OriginGuard принимает только «свой» origin запроса (схема + Host, для не-GET запросов /api),
|
||||
/// CORS разрешает любой origin (текущее поведение «как в прототипе»). Непустой список (PROD,
|
||||
/// compose-prod, Ruling 9) — CORS становится строгим allowlist + credentials; OriginGuard дополнительно
|
||||
/// принимает перечисленные origin'ы (в т.ч. когда запрос идёт не от «своего» Host — фронт за прокси).
|
||||
/// <para>
|
||||
/// Записи — полные origin'ы в том виде, в каком их шлёт браузер: схема://хост[:порт], без завершающего
|
||||
/// слэша (например <c>https://deal.example</c>, <c>http://localhost:5173</c>). Сравнение регистронезависимо.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class SecurityOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Явный allowlist Origin/CORS (схема://хост[:порт]); пусто — dev-режим «любой origin + свой Host».
|
||||
/// </summary>
|
||||
public string[] AllowedOrigins { get; set; } = [];
|
||||
}
|
||||
|
||||
@@ -1,29 +1,26 @@
|
||||
using Deal.Modules.Kanban.Application.Models;
|
||||
using Deal.Api.Services;
|
||||
using Deal.Api;
|
||||
using Deal.Api.Dtos;
|
||||
|
||||
namespace Deal.Api.Dtos;
|
||||
|
||||
/// <summary>
|
||||
/// Ответ POST /api/admin/tick — форма {storage, reminders, pipeline, queue} (dashboard_routes.py admin_tick L327–337, план Task 10).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Поля 1:1 с прототипом: storage — статистика тика правил хранения с очисткой отсева
|
||||
/// (<see cref="StorageTickStatsDto"/>, purgedRejected объединяет purge пайплайна — Ruling 9, tick_storage
|
||||
/// L485–493); reminders — «выстрелившие» напоминания «Отложено» этапа 5 (план Task 11, Ruling 3/8): те же
|
||||
/// записи {id,title,containerId}, что ушли SSE-событиями reminder_due (check_reminders admin_tick L334/L337),
|
||||
/// пусто — сработавших нет либо проверка недоступна; pipeline — счётчики одного прохода pump (ключи словаря
|
||||
/// python L921: staged/rulesStored/mlStored/mlDrop/typeDrop/aiStored/aiDrop/aiFail/noBudget; пусто — pump не
|
||||
/// выполнялся/сбой, как {} при занятом локе прототипа); queue — число строк очереди входящих после pump
|
||||
/// (queue_len L337). Наружу сериализуется в camelCase (storage/reminders/pipeline/queue).
|
||||
/// </remarks>
|
||||
/// <param name="Storage">Статистика тика правил хранения (включая purgedRejected — очистку отсева 3 суток).</param>
|
||||
/// <param name="Reminders">«Выстрелившие» напоминания {id,title,containerId} — список SSE reminder_due тика.</param>
|
||||
/// <param name="Pipeline">Счётчики pump: словарь ключей прототипа; пуст, если pump не дал результата.</param>
|
||||
/// <param name="Queue">Строк очереди входящих после прохода pump (queue_len).</param>
|
||||
public sealed record AdminTickResultDto(
|
||||
StorageTickStatsDto Storage,
|
||||
IReadOnlyList<CardReminderDueDto> Reminders,
|
||||
IReadOnlyDictionary<string, int> Pipeline,
|
||||
int Queue);
|
||||
using Deal.Modules.Kanban.Application.Models;
|
||||
|
||||
namespace Deal.Api.Dtos;
|
||||
|
||||
/// <summary>
|
||||
/// Ответ POST /api/admin/tick — форма {storage, reminders, pipeline, queue} (dashboard_routes.py admin_tick L327–337, план Task 10).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Поля 1:1 с прототипом: storage — статистика тика правил хранения с очисткой отсева
|
||||
/// (<see cref="StorageTickStatsDto"/>, purgedRejected объединяет purge пайплайна — Ruling 9, tick_storage
|
||||
/// L485–493); reminders — «выстрелившие» напоминания «Отложено» этапа 5 (план Task 11, Ruling 3/8): те же
|
||||
/// записи {id,title,containerId}, что ушли SSE-событиями reminder_due (check_reminders admin_tick L334/L337),
|
||||
/// пусто — сработавших нет либо проверка недоступна; pipeline — счётчики одного прохода pump (ключи словаря
|
||||
/// python L921: staged/rulesStored/mlStored/mlDrop/typeDrop/aiStored/aiDrop/aiFail/noBudget; пусто — pump не
|
||||
/// выполнялся/сбой, как {} при занятом локе прототипа); queue — число строк очереди входящих после pump
|
||||
/// (queue_len L337). Наружу сериализуется в camelCase (storage/reminders/pipeline/queue).
|
||||
/// </remarks>
|
||||
/// <param name="Storage">Статистика тика правил хранения (включая purgedRejected — очистку отсева 3 суток).</param>
|
||||
/// <param name="Reminders">«Выстрелившие» напоминания {id,title,containerId} — список SSE reminder_due тика.</param>
|
||||
/// <param name="Pipeline">Счётчики pump: словарь ключей прототипа; пуст, если pump не дал результата.</param>
|
||||
/// <param name="Queue">Строк очереди входящих после прохода pump (queue_len).</param>
|
||||
public sealed record AdminTickResultDto(
|
||||
StorageTickStatsDto Storage,
|
||||
IReadOnlyList<CardReminderDueDto> Reminders,
|
||||
IReadOnlyDictionary<string, int> Pipeline,
|
||||
int Queue);
|
||||
|
||||
@@ -1,176 +1,172 @@
|
||||
using System.Text.Json;
|
||||
using Deal.Api.Extensions;
|
||||
using Deal.Api.Models;
|
||||
using Deal.Api.Services;
|
||||
using Deal.Modules.Settings.Application.Abstractions;
|
||||
using Deal.Modules.Settings.Application.Models;
|
||||
using Deal.Modules.Settings.Application.Registrars;
|
||||
using Deal.Modules.Settings.Application.Services;
|
||||
using Deal.Api.Dtos;
|
||||
|
||||
namespace Deal.Api.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Эндпоинт проверки подключения AI-провайдера: POST /api/ai/check (Ruling 7/8, api-map §4.10).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// «Только для Settings-экрана» (Ruling 8): фронт жмёт «Проверить подключение» (store.js
|
||||
/// checkAiConnection) БЕЗ тела — сервер читает АКТИВНУЮ конфигурацию провайдера тенанта
|
||||
/// (настройки <c>aiProvider</c> + <c>aiConfigs</c> с расшифровкой ключа через <see cref="ISecretCipher"/>;
|
||||
/// 1:1 с ai_svc._cfg(), ai.py L25–33), вызывает порт <see cref="IAiConnectionChecker"/> и отдаёт
|
||||
/// {ok, message} + статус провайдера. Требует сессию: 401 {detail} (формат прототипа).
|
||||
/// Резолв scoped-зависимостей — через RequestServices ПОСЛЕ проверки сессии (как SettingsEndpoints:
|
||||
/// DI-биндинг параметров выполняется до тела, а ISettingsStore требует tenant-контекст запроса).
|
||||
/// </remarks>
|
||||
public static class AiCheckEndpoint
|
||||
{
|
||||
// Префикс группы API (общий для эндпоинтов этапа, Ruling 8).
|
||||
private const string ApiGroupPrefix = "/api";
|
||||
|
||||
// Путь проверки подключения AI-провайдера.
|
||||
private const string AiCheckPath = "/ai/check";
|
||||
|
||||
// OpenAPI-тег группы (в прототипе роутер settings — settings_routes.py).
|
||||
private const string OpenApiTag = "settings";
|
||||
|
||||
/// <summary>
|
||||
/// Регистрирует POST /api/ai/check.
|
||||
/// </summary>
|
||||
/// <param name="app">Построитель маршрутов приложения.</param>
|
||||
/// <returns>Построитель маршрутов для цепочки вызовов.</returns>
|
||||
public static IEndpointRouteBuilder MapAiCheckEndpoint(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup(ApiGroupPrefix).WithTags(OpenApiTag);
|
||||
group.MapPost(AiCheckPath, CheckAsync);
|
||||
return app;
|
||||
}
|
||||
|
||||
// POST /api/ai/check: проверка соединения с активным AI-провайдером тенанта.
|
||||
private static async Task<IResult> CheckAsync(HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (context.GetCurrentUser() is null)
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
// Резолв после 401-гейта: ISettingsStore — scoped на TenantDbContext (tenant-контекст запроса).
|
||||
ISettingsStore store = context.RequestServices.GetRequiredService<ISettingsStore>();
|
||||
ISecretCipher secretCipher = context.RequestServices.GetRequiredService<ISecretCipher>();
|
||||
IAiConnectionChecker checker = context.RequestServices.GetRequiredService<IAiConnectionChecker>();
|
||||
|
||||
AiCheckRequest checkRequest = await BuildActiveCheckRequestAsync(store, secretCipher, ct);
|
||||
AiCheckResultDto result = await checker.CheckAsync(checkRequest, ct);
|
||||
return Results.Ok(result);
|
||||
}
|
||||
|
||||
// Собирает запрос проверки из активной конфигурации провайдера (1:1 с ai_svc._cfg()).
|
||||
// store: KV-хранилище настроек тенанта.
|
||||
// secretCipher: Шифр секретов (расшифровка apiKey).
|
||||
// ct: Токен отмены.
|
||||
// Возвращает: Запрос проверки: id провайдера + эффективные base/model + расшифрованный ключ.
|
||||
// Эффективные значения = дефолты SettingsDefaults, перекрытые сохранёнными
|
||||
// переопределениями (Ruling 1); пустое переопределение base/model → дефолт каталога
|
||||
// (семантика «cfg.get(...) or meta[...]» прототипа).
|
||||
private static async Task<AiCheckRequest> BuildActiveCheckRequestAsync(
|
||||
ISettingsStore store,
|
||||
ISecretCipher secretCipher,
|
||||
CancellationToken ct)
|
||||
{
|
||||
string providerId = await ReadActiveProviderIdAsync(store, ct);
|
||||
AiProviderDefinition? meta = AiProviders.All.FirstOrDefault(provider => provider.Id == providerId);
|
||||
|
||||
// Неизвестный id (ручное вмешательство в БД — PATCH-гейт SettingsService не даёт сохранить):
|
||||
// HTTP не выполняется — ответит SSRF-гейт checker (allowlist).
|
||||
if (meta is null)
|
||||
{
|
||||
return new AiCheckRequest(providerId, string.Empty, string.Empty, string.Empty, IsLocal: false, ApiStyle: null);
|
||||
}
|
||||
|
||||
AiConfigSetting config = await ReadEffectiveConfigAsync(store, providerId, ct);
|
||||
string apiKey = secretCipher.Decrypt(config.ApiKey);
|
||||
string baseUrl = string.IsNullOrEmpty(config.BaseUrl) ? meta.Base : config.BaseUrl;
|
||||
string model = string.IsNullOrEmpty(config.Model)
|
||||
? meta.Models.FirstOrDefault() ?? string.Empty
|
||||
: config.Model;
|
||||
|
||||
return new AiCheckRequest(providerId, baseUrl, model, apiKey, meta.Local, meta.ApiStyle);
|
||||
}
|
||||
|
||||
// Читает активный провайдер: сохранённый aiProvider или дефолт (повреждённое значение — дефолт).
|
||||
// store: KV-хранилище настроек тенанта.
|
||||
// ct: Токен отмены.
|
||||
// Возвращает: id провайдера.
|
||||
private static async Task<string> ReadActiveProviderIdAsync(ISettingsStore store, CancellationToken ct)
|
||||
{
|
||||
SettingValue? row = await store.GetAsync(SettingsKeys.AiProvider, ct);
|
||||
if (row is null)
|
||||
{
|
||||
return SettingsDefaults.AiProvider;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using JsonDocument document = JsonDocument.Parse(row.ValueJson);
|
||||
if (document.RootElement.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
return document.RootElement.GetString() ?? SettingsDefaults.AiProvider;
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// Повреждённая строка — дефолт (мягкая семантика, как в SettingsService).
|
||||
}
|
||||
|
||||
return SettingsDefaults.AiProvider;
|
||||
}
|
||||
|
||||
// Эффективный конфиг провайдера: дефолт SettingsDefaults, перекрытый сохранённым aiConfigs.
|
||||
// store: KV-хранилище настроек тенанта.
|
||||
// providerId: Активный провайдер (id из каталога).
|
||||
// ct: Токен отмены.
|
||||
// Возвращает: Конфиг {apiKey, baseUrl, model}; повреждённая строка aiConfigs — дефолт.
|
||||
private static async Task<AiConfigSetting> ReadEffectiveConfigAsync(
|
||||
ISettingsStore store,
|
||||
string providerId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
AiConfigSetting defaults = SettingsDefaults.AiConfigs[providerId];
|
||||
SettingValue? row = await store.GetAsync(SettingsKeys.AiConfigs, ct);
|
||||
if (row is null)
|
||||
{
|
||||
return defaults;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using JsonDocument document = JsonDocument.Parse(row.ValueJson);
|
||||
JsonElement root = document.RootElement;
|
||||
if (root.ValueKind == JsonValueKind.Object
|
||||
&& root.TryGetProperty(providerId, out JsonElement entry)
|
||||
&& entry.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
return new AiConfigSetting(
|
||||
ApiKey: ReadField(entry, "apiKey") ?? defaults.ApiKey,
|
||||
BaseUrl: ReadField(entry, "baseUrl") ?? defaults.BaseUrl,
|
||||
Model: ReadField(entry, "model") ?? defaults.Model);
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// Повреждённая строка — дефолт (не роняем проверку).
|
||||
}
|
||||
|
||||
return defaults;
|
||||
}
|
||||
|
||||
// Читает строковое поле объекта конфигурации (имена полей camelCase, как пишет SettingsService).
|
||||
// entry: JSON-объект конфигурации провайдера.
|
||||
// field: Имя поля (apiKey/baseUrl/model).
|
||||
// Возвращает: Значение или null, если поле отсутствует/не строка.
|
||||
private static string? ReadField(JsonElement entry, string field)
|
||||
{
|
||||
return entry.TryGetProperty(field, out JsonElement value) && value.ValueKind == JsonValueKind.String
|
||||
? value.GetString()
|
||||
: null;
|
||||
}
|
||||
}
|
||||
using System.Text.Json;
|
||||
using Deal.Api.Extensions;
|
||||
using Deal.Api.Services;
|
||||
using Deal.Modules.Settings.Application.Abstractions;
|
||||
using Deal.Modules.Settings.Application.Models;
|
||||
|
||||
namespace Deal.Api.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Эндпоинт проверки подключения AI-провайдера: POST /api/ai/check (Ruling 7/8, api-map §4.10).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// «Только для Settings-экрана» (Ruling 8): фронт жмёт «Проверить подключение» (store.js
|
||||
/// checkAiConnection) БЕЗ тела — сервер читает АКТИВНУЮ конфигурацию провайдера тенанта
|
||||
/// (настройки <c>aiProvider</c> + <c>aiConfigs</c> с расшифровкой ключа через <see cref="ISecretCipher"/>;
|
||||
/// 1:1 с ai_svc._cfg(), ai.py L25–33), вызывает порт <see cref="IAiConnectionChecker"/> и отдаёт
|
||||
/// {ok, message} + статус провайдера. Требует сессию: 401 {detail} (формат прототипа).
|
||||
/// Резолв scoped-зависимостей — через RequestServices ПОСЛЕ проверки сессии (как SettingsEndpoints:
|
||||
/// DI-биндинг параметров выполняется до тела, а ISettingsStore требует tenant-контекст запроса).
|
||||
/// </remarks>
|
||||
public static class AiCheckEndpoint
|
||||
{
|
||||
// Префикс группы API (общий для эндпоинтов этапа, Ruling 8).
|
||||
private const string ApiGroupPrefix = "/api";
|
||||
|
||||
// Путь проверки подключения AI-провайдера.
|
||||
private const string AiCheckPath = "/ai/check";
|
||||
|
||||
// OpenAPI-тег группы (в прототипе роутер settings — settings_routes.py).
|
||||
private const string OpenApiTag = "settings";
|
||||
|
||||
/// <summary>
|
||||
/// Регистрирует POST /api/ai/check.
|
||||
/// </summary>
|
||||
/// <param name="app">Построитель маршрутов приложения.</param>
|
||||
/// <returns>Построитель маршрутов для цепочки вызовов.</returns>
|
||||
public static IEndpointRouteBuilder MapAiCheckEndpoint(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup(ApiGroupPrefix).WithTags(OpenApiTag);
|
||||
group.MapPost(AiCheckPath, CheckAsync);
|
||||
return app;
|
||||
}
|
||||
|
||||
// POST /api/ai/check: проверка соединения с активным AI-провайдером тенанта.
|
||||
private static async Task<IResult> CheckAsync(HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (context.GetCurrentUser() is null)
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
// Резолв после 401-гейта: ISettingsStore — scoped на TenantDbContext (tenant-контекст запроса).
|
||||
ISettingsStore store = context.RequestServices.GetRequiredService<ISettingsStore>();
|
||||
ISecretCipher secretCipher = context.RequestServices.GetRequiredService<ISecretCipher>();
|
||||
IAiConnectionChecker checker = context.RequestServices.GetRequiredService<IAiConnectionChecker>();
|
||||
|
||||
AiCheckRequest checkRequest = await BuildActiveCheckRequestAsync(store, secretCipher, ct);
|
||||
AiCheckResultDto result = await checker.CheckAsync(checkRequest, ct);
|
||||
return Results.Ok(result);
|
||||
}
|
||||
|
||||
// Собирает запрос проверки из активной конфигурации провайдера (1:1 с ai_svc._cfg()).
|
||||
// store: KV-хранилище настроек тенанта.
|
||||
// secretCipher: Шифр секретов (расшифровка apiKey).
|
||||
// ct: Токен отмены.
|
||||
// Возвращает: Запрос проверки: id провайдера + эффективные base/model + расшифрованный ключ.
|
||||
// Эффективные значения = дефолты SettingsDefaults, перекрытые сохранёнными
|
||||
// переопределениями (Ruling 1); пустое переопределение base/model → дефолт каталога
|
||||
// (семантика «cfg.get(...) or meta[...]» прототипа).
|
||||
private static async Task<AiCheckRequest> BuildActiveCheckRequestAsync(
|
||||
ISettingsStore store,
|
||||
ISecretCipher secretCipher,
|
||||
CancellationToken ct)
|
||||
{
|
||||
string providerId = await ReadActiveProviderIdAsync(store, ct);
|
||||
AiProviderDefinition? meta = AiProviders.All.FirstOrDefault(provider => provider.Id == providerId);
|
||||
|
||||
// Неизвестный id (ручное вмешательство в БД — PATCH-гейт SettingsService не даёт сохранить):
|
||||
// HTTP не выполняется — ответит SSRF-гейт checker (allowlist).
|
||||
if (meta is null)
|
||||
{
|
||||
return new AiCheckRequest(providerId, string.Empty, string.Empty, string.Empty, IsLocal: false, ApiStyle: null);
|
||||
}
|
||||
|
||||
AiConfigSetting config = await ReadEffectiveConfigAsync(store, providerId, ct);
|
||||
string apiKey = secretCipher.Decrypt(config.ApiKey);
|
||||
string baseUrl = string.IsNullOrEmpty(config.BaseUrl) ? meta.Base : config.BaseUrl;
|
||||
string model = string.IsNullOrEmpty(config.Model)
|
||||
? meta.Models.FirstOrDefault() ?? string.Empty
|
||||
: config.Model;
|
||||
|
||||
return new AiCheckRequest(providerId, baseUrl, model, apiKey, meta.Local, meta.ApiStyle);
|
||||
}
|
||||
|
||||
// Читает активный провайдер: сохранённый aiProvider или дефолт (повреждённое значение — дефолт).
|
||||
// store: KV-хранилище настроек тенанта.
|
||||
// ct: Токен отмены.
|
||||
// Возвращает: id провайдера.
|
||||
private static async Task<string> ReadActiveProviderIdAsync(ISettingsStore store, CancellationToken ct)
|
||||
{
|
||||
SettingValue? row = await store.GetAsync(SettingsKeys.AiProvider, ct);
|
||||
if (row is null)
|
||||
{
|
||||
return SettingsDefaults.AiProvider;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using JsonDocument document = JsonDocument.Parse(row.ValueJson);
|
||||
if (document.RootElement.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
return document.RootElement.GetString() ?? SettingsDefaults.AiProvider;
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// Повреждённая строка — дефолт (мягкая семантика, как в SettingsService).
|
||||
}
|
||||
|
||||
return SettingsDefaults.AiProvider;
|
||||
}
|
||||
|
||||
// Эффективный конфиг провайдера: дефолт SettingsDefaults, перекрытый сохранённым aiConfigs.
|
||||
// store: KV-хранилище настроек тенанта.
|
||||
// providerId: Активный провайдер (id из каталога).
|
||||
// ct: Токен отмены.
|
||||
// Возвращает: Конфиг {apiKey, baseUrl, model}; повреждённая строка aiConfigs — дефолт.
|
||||
private static async Task<AiConfigSetting> ReadEffectiveConfigAsync(
|
||||
ISettingsStore store,
|
||||
string providerId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
AiConfigSetting defaults = SettingsDefaults.AiConfigs[providerId];
|
||||
SettingValue? row = await store.GetAsync(SettingsKeys.AiConfigs, ct);
|
||||
if (row is null)
|
||||
{
|
||||
return defaults;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using JsonDocument document = JsonDocument.Parse(row.ValueJson);
|
||||
JsonElement root = document.RootElement;
|
||||
if (root.ValueKind == JsonValueKind.Object
|
||||
&& root.TryGetProperty(providerId, out JsonElement entry)
|
||||
&& entry.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
return new AiConfigSetting(
|
||||
ApiKey: ReadField(entry, "apiKey") ?? defaults.ApiKey,
|
||||
BaseUrl: ReadField(entry, "baseUrl") ?? defaults.BaseUrl,
|
||||
Model: ReadField(entry, "model") ?? defaults.Model);
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// Повреждённая строка — дефолт (не роняем проверку).
|
||||
}
|
||||
|
||||
return defaults;
|
||||
}
|
||||
|
||||
// Читает строковое поле объекта конфигурации (имена полей camelCase, как пишет SettingsService).
|
||||
// entry: JSON-объект конфигурации провайдера.
|
||||
// field: Имя поля (apiKey/baseUrl/model).
|
||||
// Возвращает: Значение или null, если поле отсутствует/не строка.
|
||||
private static string? ReadField(JsonElement entry, string field)
|
||||
{
|
||||
return entry.TryGetProperty(field, out JsonElement value) && value.ValueKind == JsonValueKind.String
|
||||
? value.GetString()
|
||||
: null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,99 +1,97 @@
|
||||
using Deal.Api.Events;
|
||||
using Deal.Api.Extensions;
|
||||
using Deal.Api.Models;
|
||||
using Deal.Api.Services;
|
||||
using Deal.Contracts.Integrations.Abstractions;
|
||||
using Deal.Contracts.Integrations.Models;
|
||||
using Deal.Api.Dtos;
|
||||
|
||||
namespace Deal.Api.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Эндпоинты ИИ-предложений: POST /api/ai/suggest-columns и POST /api/ai/suggest-keywords
|
||||
/// (план Task 14 L476–479; прототип dashboard_routes.py L395–409).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Контракт 1:1 с прототипом и api-map §3.2 L120–121: suggest-columns → <c>{ok:true, created:N}</c>
|
||||
/// либо <c>{ok:false, reason, cooldown?}</c>; suggest-keywords → <c>{ok:true, keywords:[…]}</c> либо
|
||||
/// <c>{ok:false, reason}</c>. Причины — мягкие ошибки (HTTP 200 с ok:false + reason), статусы 4xx/5xx
|
||||
/// не мапятся. Оба требуют сессию: 401 {detail} без куки (как остальные эндпоинты контейнеров); порт
|
||||
/// IColumnSuggester резолвится из RequestServices ПОСЛЕ проверки сессии (scoped на tenant-контекст
|
||||
/// запроса). При успехе suggest-columns публикуется SSE-toast «ИИ предложил колонок: N — откройте и
|
||||
/// решите» (sparkles, 1:1 с suggest.py L162); boards_changed НЕ шлём (Ruling 5: фронт перечитывает
|
||||
/// доски сам после ok). Публикации — из эндпоинта (Ruling 5): без подписчиков — no-op.
|
||||
/// </remarks>
|
||||
public static class AiSuggestEndpoints
|
||||
{
|
||||
// Префикс группы AI-эндпоинтов этапа (роутер dashboard, prefix="/api"; пути L395/L406).
|
||||
private const string AiGroupPrefix = "/api/ai";
|
||||
|
||||
// Путь предложения колонок (dashboard_routes.py L395).
|
||||
private const string SuggestColumnsPath = "/suggest-columns";
|
||||
|
||||
// Путь предложения ключевых слов (dashboard_routes.py L404).
|
||||
private const string SuggestKeywordsPath = "/suggest-keywords";
|
||||
|
||||
// OpenAPI-тег группы (в прототипе роутер dashboard — dashboard_routes.py).
|
||||
private const string OpenApiTag = "dashboard";
|
||||
|
||||
// SSE-тип события тоста (Ruling 5; api.js слушает 'toast').
|
||||
private const string ToastEventType = "toast";
|
||||
|
||||
// Текст тоста после успешных предложений колонок (suggest.py L162, 1:1).
|
||||
private const string SuggestToastTextFormat = "ИИ предложил колонок: {0} — откройте и решите";
|
||||
|
||||
// Иконка тоста предложений колонок (sparkles, 1:1 с прототипом).
|
||||
private const string SparklesIcon = "sparkles";
|
||||
|
||||
/// <summary>
|
||||
/// Регистрирует POST /api/ai/suggest-columns и POST /api/ai/suggest-keywords.
|
||||
/// </summary>
|
||||
/// <param name="app">Построитель маршрутов приложения.</param>
|
||||
/// <returns>Построитель маршрутов для цепочки вызовов.</returns>
|
||||
public static IEndpointRouteBuilder MapAiSuggestEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup(AiGroupPrefix).WithTags(OpenApiTag);
|
||||
group.MapPost(SuggestColumnsPath, SuggestColumnsAsync);
|
||||
group.MapPost(SuggestKeywordsPath, SuggestKeywordsAsync);
|
||||
return app;
|
||||
}
|
||||
|
||||
// POST /api/ai/suggest-columns: анализ «Неразобранного» и создание колонок-предложений.
|
||||
// Ответ — результат порта 1:1: {ok:true, created:N} — доски suggested=true созданы (эндпоинт шлёт
|
||||
// SSE-toast), {ok:false, reason} (+ cooldown) — мягкая причина (HTTP 200). Кулдаун/«мало карточек»/
|
||||
// «похожие колонки уже есть» — за адаптером LocalColumnSuggester (Ruling 3).
|
||||
private static async Task<IResult> SuggestColumnsAsync(HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
IColumnSuggester suggester = context.RequestServices.GetRequiredService<IColumnSuggester>();
|
||||
SuggestColumnsResultDto result = await suggester.SuggestColumnsAsync(ct);
|
||||
if (result.Ok)
|
||||
{
|
||||
SseBroker broker = context.RequestServices.GetRequiredService<SseBroker>();
|
||||
broker.Publish(
|
||||
context.GetCurrentUser()!.TenantId,
|
||||
ToastEventType,
|
||||
new { text = string.Format(SuggestToastTextFormat, result.Created), icon = SparklesIcon });
|
||||
}
|
||||
|
||||
return Results.Ok(result);
|
||||
}
|
||||
|
||||
// POST /api/ai/suggest-keywords: слова-маркеры сферы по карточкам (настройки «Сфера и ключи»).
|
||||
// Ответ — результат порта 1:1: {ok:true, keywords:[…]} (≤60) либо {ok:false, reason} (HTTP 200).
|
||||
private static async Task<IResult> SuggestKeywordsAsync(HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
IColumnSuggester suggester = context.RequestServices.GetRequiredService<IColumnSuggester>();
|
||||
SuggestKeywordsResultDto result = await suggester.SuggestKeywordsAsync(ct);
|
||||
return Results.Ok(result);
|
||||
}
|
||||
}
|
||||
using Deal.Api.Events;
|
||||
using Deal.Api.Extensions;
|
||||
using Deal.Api.Services;
|
||||
using Deal.Contracts.Integrations.Abstractions;
|
||||
using Deal.Contracts.Integrations.Models;
|
||||
|
||||
namespace Deal.Api.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Эндпоинты ИИ-предложений: POST /api/ai/suggest-columns и POST /api/ai/suggest-keywords
|
||||
/// (план Task 14 L476–479; прототип dashboard_routes.py L395–409).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Контракт 1:1 с прототипом и api-map §3.2 L120–121: suggest-columns → <c>{ok:true, created:N}</c>
|
||||
/// либо <c>{ok:false, reason, cooldown?}</c>; suggest-keywords → <c>{ok:true, keywords:[…]}</c> либо
|
||||
/// <c>{ok:false, reason}</c>. Причины — мягкие ошибки (HTTP 200 с ok:false + reason), статусы 4xx/5xx
|
||||
/// не мапятся. Оба требуют сессию: 401 {detail} без куки (как остальные эндпоинты контейнеров); порт
|
||||
/// IColumnSuggester резолвится из RequestServices ПОСЛЕ проверки сессии (scoped на tenant-контекст
|
||||
/// запроса). При успехе suggest-columns публикуется SSE-toast «ИИ предложил колонок: N — откройте и
|
||||
/// решите» (sparkles, 1:1 с suggest.py L162); boards_changed НЕ шлём (Ruling 5: фронт перечитывает
|
||||
/// доски сам после ok). Публикации — из эндпоинта (Ruling 5): без подписчиков — no-op.
|
||||
/// </remarks>
|
||||
public static class AiSuggestEndpoints
|
||||
{
|
||||
// Префикс группы AI-эндпоинтов этапа (роутер dashboard, prefix="/api"; пути L395/L406).
|
||||
private const string AiGroupPrefix = "/api/ai";
|
||||
|
||||
// Путь предложения колонок (dashboard_routes.py L395).
|
||||
private const string SuggestColumnsPath = "/suggest-columns";
|
||||
|
||||
// Путь предложения ключевых слов (dashboard_routes.py L404).
|
||||
private const string SuggestKeywordsPath = "/suggest-keywords";
|
||||
|
||||
// OpenAPI-тег группы (в прототипе роутер dashboard — dashboard_routes.py).
|
||||
private const string OpenApiTag = "dashboard";
|
||||
|
||||
// SSE-тип события тоста (Ruling 5; api.js слушает 'toast').
|
||||
private const string ToastEventType = "toast";
|
||||
|
||||
// Текст тоста после успешных предложений колонок (suggest.py L162, 1:1).
|
||||
private const string SuggestToastTextFormat = "ИИ предложил колонок: {0} — откройте и решите";
|
||||
|
||||
// Иконка тоста предложений колонок (sparkles, 1:1 с прототипом).
|
||||
private const string SparklesIcon = "sparkles";
|
||||
|
||||
/// <summary>
|
||||
/// Регистрирует POST /api/ai/suggest-columns и POST /api/ai/suggest-keywords.
|
||||
/// </summary>
|
||||
/// <param name="app">Построитель маршрутов приложения.</param>
|
||||
/// <returns>Построитель маршрутов для цепочки вызовов.</returns>
|
||||
public static IEndpointRouteBuilder MapAiSuggestEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup(AiGroupPrefix).WithTags(OpenApiTag);
|
||||
group.MapPost(SuggestColumnsPath, SuggestColumnsAsync);
|
||||
group.MapPost(SuggestKeywordsPath, SuggestKeywordsAsync);
|
||||
return app;
|
||||
}
|
||||
|
||||
// POST /api/ai/suggest-columns: анализ «Неразобранного» и создание колонок-предложений.
|
||||
// Ответ — результат порта 1:1: {ok:true, created:N} — доски suggested=true созданы (эндпоинт шлёт
|
||||
// SSE-toast), {ok:false, reason} (+ cooldown) — мягкая причина (HTTP 200). Кулдаун/«мало карточек»/
|
||||
// «похожие колонки уже есть» — за адаптером LocalColumnSuggester (Ruling 3).
|
||||
private static async Task<IResult> SuggestColumnsAsync(HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
IColumnSuggester suggester = context.RequestServices.GetRequiredService<IColumnSuggester>();
|
||||
SuggestColumnsResultDto result = await suggester.SuggestColumnsAsync(ct);
|
||||
if (result.Ok)
|
||||
{
|
||||
SseBroker broker = context.RequestServices.GetRequiredService<SseBroker>();
|
||||
broker.Publish(
|
||||
context.GetCurrentUser()!.TenantId,
|
||||
ToastEventType,
|
||||
new { text = string.Format(SuggestToastTextFormat, result.Created), icon = SparklesIcon });
|
||||
}
|
||||
|
||||
return Results.Ok(result);
|
||||
}
|
||||
|
||||
// POST /api/ai/suggest-keywords: слова-маркеры сферы по карточкам (настройки «Сфера и ключи»).
|
||||
// Ответ — результат порта 1:1: {ok:true, keywords:[…]} (≤60) либо {ok:false, reason} (HTTP 200).
|
||||
private static async Task<IResult> SuggestKeywordsAsync(HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
IColumnSuggester suggester = context.RequestServices.GetRequiredService<IColumnSuggester>();
|
||||
SuggestKeywordsResultDto result = await suggester.SuggestKeywordsAsync(ct);
|
||||
return Results.Ok(result);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,217 +1,213 @@
|
||||
using Deal.Api.Extensions;
|
||||
using Deal.Api.Models;
|
||||
using Deal.Api.Services;
|
||||
using Deal.Api.Middleware;
|
||||
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 Microsoft.Extensions.Options;
|
||||
using Deal.Api.Dtos;
|
||||
// Имя конфигурационного типа совпадает с Microsoft.AspNetCore.Http.CookieOptions — фиксируем алиасом.
|
||||
using CookieOptions = Deal.Api.Configuration.CookieOptions;
|
||||
|
||||
namespace Deal.Api.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// HTTP-эндпоинты аутентификации (группа /api/auth). Контракт 1:1 с прототипом auth_routes.py.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Успех-ответы — <c>{ok:true,...}</c>, ошибки — HTTP-код + <c>{"detail":"..."}</c> (Ruling 10).
|
||||
/// Кука сессии выставляется на login и change-password (свежий токен). Сообщения об ошибках —
|
||||
/// фиксированные строки прототипа.
|
||||
/// </remarks>
|
||||
public static class AuthEndpoints
|
||||
{
|
||||
private const string InvalidCredentialsDetail = "Неверный логин или пароль";
|
||||
private const string WrongOldPasswordDetail = "Текущий пароль неверен";
|
||||
private const string PasswordTooShortDetail = "Пароль слишком короткий (минимум 8 символов)";
|
||||
private const string TenantSuspendedDetail = "Учётная запись приостановлена. Обратитесь к оператору";
|
||||
private const string AuthGroupPrefix = "/api/auth";
|
||||
private const string AuthOpenApiTag = "auth";
|
||||
|
||||
/// <summary>
|
||||
/// Регистрирует группу /api/auth: login, logout, me, change-password.
|
||||
/// </summary>
|
||||
/// <param name="app">Построитель маршрутов приложения.</param>
|
||||
/// <returns>Построитель маршрутов для цепочки вызовов.</returns>
|
||||
public static IEndpointRouteBuilder MapAuthEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup(AuthGroupPrefix).WithTags(AuthOpenApiTag);
|
||||
|
||||
// Политика "auth" rate limiter (план Task 11, Ruling 5): фиксированное окно 10/мин на IP
|
||||
// ручки входа; остальные ручки группы — под глобальной API-политикой (по тенанту/IP).
|
||||
group.MapPost("/login", LoginAsync).RequireRateLimiting(RateLimitPolicies.AuthPolicy);
|
||||
group.MapPost("/logout", LogoutAsync);
|
||||
group.MapGet("/me", MeAsync);
|
||||
group.MapPost("/change-password", ChangePasswordAsync);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
// POST /api/auth/login: проверка учётных данных, выдача куки сессии; результат пишется в аудит (Task 4/7).
|
||||
// До AuthService отрабатывает LoginAttemptGuard (5 неудач ip|login за 15 мин → 429, Ruling 5).
|
||||
private static async Task<IResult> LoginAsync(
|
||||
LoginRequest body,
|
||||
AuthService authService,
|
||||
AuditService auditService,
|
||||
IOptions<CookieOptions> cookieOptions,
|
||||
HttpContext context,
|
||||
CancellationToken ct,
|
||||
LoginAttemptGuard loginAttemptGuard)
|
||||
{
|
||||
string? attemptedLogin = NormalizeLogin(body.Login);
|
||||
|
||||
// Защита входа (план Task 11, Ruling 5): блокировка ключа ip|login до проверки учётных данных —
|
||||
// 429 «Слишком много попыток входа…» (в dev при RateLimit:Enabled=false гвард выключен).
|
||||
if (await loginAttemptGuard.IsBlockedAsync(ClientIp(context), attemptedLogin, ct))
|
||||
{
|
||||
return EndpointResults.TooManyRequests(LoginAttemptGuard.BlockedDetail);
|
||||
}
|
||||
|
||||
var result = await authService.LoginAsync(body.Login, body.Password, ct);
|
||||
|
||||
// Приостановленный тенант: вход заблокирован (Ruling 10(5)). Отдельный текст от «неверных учётных
|
||||
// данных»; tenant_login_failed пишется с tenantId и ActorId (ревью Task 4: failed-логины suspended-тенанта).
|
||||
// Решение Task 7: HTTP-код 403 (а не 401) — учётка существует, доступ запрещён; приёмочный текст плана
|
||||
// Task 16 формулирует «login 401» — отклонение зафиксировано для api-map/техдок в task-7-report.md.
|
||||
if (result.Error == LoginResultDto.ErrorTenantSuspended)
|
||||
{
|
||||
await auditService.AppendAsync(new AuditRecordDto(
|
||||
AuditEvents.TenantLoginFailed,
|
||||
AuditActorTypes.Tenant,
|
||||
ActorId: result.UserId,
|
||||
TenantId: result.TenantId,
|
||||
Ip: ClientIp(context),
|
||||
DetailJson: AuditService.ToDetailJson(new { login = NormalizeLogin(body.Login) })), ct);
|
||||
|
||||
return EndpointResults.Forbidden(TenantSuspendedDetail);
|
||||
}
|
||||
|
||||
if (result.Login is null || result.Token is null)
|
||||
{
|
||||
// Пустой/пробельный login и неверные учётные данные — одно сообщение (семантика прототипа).
|
||||
// Аудит tenant_login_failed пишем только для реальной попытки (непустой логин), без пароля (Ruling 4);
|
||||
// счётчик неудач гварда растёт там же — пустые логины ключа не имеют (блокирует только auth-политика).
|
||||
if (attemptedLogin is not null)
|
||||
{
|
||||
await loginAttemptGuard.RecordFailureAsync(ClientIp(context), attemptedLogin, ct);
|
||||
await auditService.AppendAsync(new AuditRecordDto(
|
||||
AuditEvents.TenantLoginFailed,
|
||||
AuditActorTypes.Tenant,
|
||||
ActorId: null,
|
||||
TenantId: null,
|
||||
Ip: ClientIp(context),
|
||||
DetailJson: AuditService.ToDetailJson(new { login = attemptedLogin })), ct);
|
||||
}
|
||||
|
||||
return EndpointResults.Unauthorized(InvalidCredentialsDetail);
|
||||
}
|
||||
|
||||
// Успешный вход сбрасывает счётчик неудач ключа ip|login (Ruling 5).
|
||||
await loginAttemptGuard.ResetAsync(ClientIp(context), result.Login, ct);
|
||||
|
||||
await auditService.AppendAsync(new AuditRecordDto(
|
||||
AuditEvents.TenantLoginOk,
|
||||
AuditActorTypes.Tenant,
|
||||
ActorId: result.UserId,
|
||||
TenantId: result.TenantId,
|
||||
Ip: ClientIp(context),
|
||||
DetailJson: AuditService.ToDetailJson(new { login = result.Login })), ct);
|
||||
|
||||
SessionCookieWriter.Append(context, cookieOptions.Value, result.Token);
|
||||
return Results.Ok(new { ok = true, login = result.Login });
|
||||
}
|
||||
|
||||
// POST /api/auth/logout: удаление сессии по токену из куки и очистка куки (всегда ok).
|
||||
// Если удалённая сессия была impersonation — пишется аудит impersonation_stopped (Task 7, ревью: полный аудит).
|
||||
private static async Task<IResult> LogoutAsync(
|
||||
AuthService authService,
|
||||
AuditService auditService,
|
||||
IOptions<CookieOptions> cookieOptions,
|
||||
HttpContext context,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var cookieName = cookieOptions.Value.Name;
|
||||
var rawToken = context.Request.Cookies[cookieName];
|
||||
// Пользователь разрешённой сессии — до её удаления (SessionMiddleware наполнил Items на старте запроса).
|
||||
CurrentUser? user = context.GetCurrentUser();
|
||||
var logout = await authService.LogoutAsync(rawToken, ct);
|
||||
if (logout is not null)
|
||||
{
|
||||
// Актор — оператор, начавший impersonation (маркер сессии); тенант — для фильтра TenantId.
|
||||
await auditService.AppendAsync(new AuditRecordDto(
|
||||
AuditEvents.ImpersonationStopped,
|
||||
AuditActorTypes.Operator,
|
||||
ActorId: logout.OperatorId,
|
||||
TenantId: logout.TenantId,
|
||||
Ip: ClientIp(context),
|
||||
DetailJson: AuditService.ToDetailJson(new { login = logout.Login })), ct);
|
||||
}
|
||||
|
||||
// Выход пользователя тенанта (этап 10, T1): событие пишется при живой разрешённой сессии.
|
||||
if (user is not null)
|
||||
{
|
||||
await AuditAppender.AppendTenantAsync(context, AuditEvents.TenantLogout, new { login = user.Login }, ct);
|
||||
}
|
||||
|
||||
context.Response.Cookies.Delete(cookieName);
|
||||
return Results.Ok(new { ok = true });
|
||||
}
|
||||
|
||||
// GET /api/auth/me: проверка живой сессии.
|
||||
private static IResult MeAsync(HttpContext context)
|
||||
{
|
||||
var user = context.GetCurrentUser();
|
||||
if (user is null)
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
return Results.Ok(new { login = user.Login, ok = true });
|
||||
}
|
||||
|
||||
// POST /api/auth/change-password: смена пароля и перевыпуск куки (свежая сессия).
|
||||
private static async Task<IResult> ChangePasswordAsync(
|
||||
ChangePasswordRequest body,
|
||||
AuthService authService,
|
||||
IOptions<CookieOptions> cookieOptions,
|
||||
HttpContext context,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var user = context.GetCurrentUser();
|
||||
if (user is null)
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
var result = await authService.ChangePasswordAsync(user.Login, body.OldPassword, body.NewPassword, ct);
|
||||
if (!result.Ok || result.NewToken is null)
|
||||
{
|
||||
// Семантика прототипа: код ошибки различает «старый пароль неверен» и «слишком короткий».
|
||||
var detail = result.Error == ChangePasswordResultDto.ErrorTooShort
|
||||
? PasswordTooShortDetail
|
||||
: WrongOldPasswordDetail;
|
||||
return EndpointResults.BadRequest(detail);
|
||||
}
|
||||
|
||||
// Старые сессии удалены внутри сервиса; выдаём клиенту свежую куку.
|
||||
SessionCookieWriter.Append(context, cookieOptions.Value, result.NewToken);
|
||||
return Results.Ok(new { ok = true });
|
||||
}
|
||||
|
||||
// Нормализованная попытка логина для аудита (нижний регистр/обрезка, как AuthService); null — писать нечего.
|
||||
// login: Логин из тела запроса.
|
||||
// Возвращает: Нормализованный логин или null при пустом/пробельном входе.
|
||||
private static string? NormalizeLogin(string? login)
|
||||
{
|
||||
string? normalized = login?.Trim().ToLowerInvariant();
|
||||
return string.IsNullOrEmpty(normalized) ? null : normalized;
|
||||
}
|
||||
|
||||
// IP-адрес клиента для аудита (без порта; null, если недоступен).
|
||||
// context: Контекст запроса.
|
||||
// Возвращает: Строковое представление IP или null.
|
||||
private static string? ClientIp(HttpContext context) => context.Connection.RemoteIpAddress?.ToString();
|
||||
}
|
||||
using Deal.Api.Extensions;
|
||||
using Deal.Api.Middleware;
|
||||
using Deal.Api.Models;
|
||||
using Deal.Api.Services;
|
||||
using Deal.Modules.Tenants.Application.Models;
|
||||
using Deal.Modules.Tenants.Application.Services;
|
||||
using Microsoft.Extensions.Options;
|
||||
// Имя конфигурационного типа совпадает с Microsoft.AspNetCore.Http.CookieOptions — фиксируем алиасом.
|
||||
using CookieOptions = Deal.Api.Configuration.CookieOptions;
|
||||
|
||||
namespace Deal.Api.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// HTTP-эндпоинты аутентификации (группа /api/auth). Контракт 1:1 с прототипом auth_routes.py.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Успех-ответы — <c>{ok:true,...}</c>, ошибки — HTTP-код + <c>{"detail":"..."}</c> (Ruling 10).
|
||||
/// Кука сессии выставляется на login и change-password (свежий токен). Сообщения об ошибках —
|
||||
/// фиксированные строки прототипа.
|
||||
/// </remarks>
|
||||
public static class AuthEndpoints
|
||||
{
|
||||
private const string InvalidCredentialsDetail = "Неверный логин или пароль";
|
||||
private const string WrongOldPasswordDetail = "Текущий пароль неверен";
|
||||
private const string PasswordTooShortDetail = "Пароль слишком короткий (минимум 8 символов)";
|
||||
private const string TenantSuspendedDetail = "Учётная запись приостановлена. Обратитесь к оператору";
|
||||
private const string AuthGroupPrefix = "/api/auth";
|
||||
private const string AuthOpenApiTag = "auth";
|
||||
|
||||
/// <summary>
|
||||
/// Регистрирует группу /api/auth: login, logout, me, change-password.
|
||||
/// </summary>
|
||||
/// <param name="app">Построитель маршрутов приложения.</param>
|
||||
/// <returns>Построитель маршрутов для цепочки вызовов.</returns>
|
||||
public static IEndpointRouteBuilder MapAuthEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup(AuthGroupPrefix).WithTags(AuthOpenApiTag);
|
||||
|
||||
// Политика "auth" rate limiter (план Task 11, Ruling 5): фиксированное окно 10/мин на IP
|
||||
// ручки входа; остальные ручки группы — под глобальной API-политикой (по тенанту/IP).
|
||||
group.MapPost("/login", LoginAsync).RequireRateLimiting(RateLimitPolicies.AuthPolicy);
|
||||
group.MapPost("/logout", LogoutAsync);
|
||||
group.MapGet("/me", MeAsync);
|
||||
group.MapPost("/change-password", ChangePasswordAsync);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
// POST /api/auth/login: проверка учётных данных, выдача куки сессии; результат пишется в аудит (Task 4/7).
|
||||
// До AuthService отрабатывает LoginAttemptGuard (5 неудач ip|login за 15 мин → 429, Ruling 5).
|
||||
private static async Task<IResult> LoginAsync(
|
||||
LoginRequest body,
|
||||
AuthService authService,
|
||||
AuditService auditService,
|
||||
IOptions<CookieOptions> cookieOptions,
|
||||
HttpContext context,
|
||||
CancellationToken ct,
|
||||
LoginAttemptGuard loginAttemptGuard)
|
||||
{
|
||||
string? attemptedLogin = NormalizeLogin(body.Login);
|
||||
|
||||
// Защита входа (план Task 11, Ruling 5): блокировка ключа ip|login до проверки учётных данных —
|
||||
// 429 «Слишком много попыток входа…» (в dev при RateLimit:Enabled=false гвард выключен).
|
||||
if (await loginAttemptGuard.IsBlockedAsync(ClientIp(context), attemptedLogin, ct))
|
||||
{
|
||||
return EndpointResults.TooManyRequests(LoginAttemptGuard.BlockedDetail);
|
||||
}
|
||||
|
||||
var result = await authService.LoginAsync(body.Login, body.Password, ct);
|
||||
|
||||
// Приостановленный тенант: вход заблокирован (Ruling 10(5)). Отдельный текст от «неверных учётных
|
||||
// данных»; tenant_login_failed пишется с tenantId и ActorId (ревью Task 4: failed-логины suspended-тенанта).
|
||||
// Решение Task 7: HTTP-код 403 (а не 401) — учётка существует, доступ запрещён; приёмочный текст плана
|
||||
// Task 16 формулирует «login 401» — отклонение зафиксировано для api-map/техдок в task-7-report.md.
|
||||
if (result.Error == LoginResultDto.ErrorTenantSuspended)
|
||||
{
|
||||
await auditService.AppendAsync(new AuditRecordDto(
|
||||
AuditEvents.TenantLoginFailed,
|
||||
AuditActorTypes.Tenant,
|
||||
ActorId: result.UserId,
|
||||
TenantId: result.TenantId,
|
||||
Ip: ClientIp(context),
|
||||
DetailJson: AuditService.ToDetailJson(new { login = NormalizeLogin(body.Login) })), ct);
|
||||
|
||||
return EndpointResults.Forbidden(TenantSuspendedDetail);
|
||||
}
|
||||
|
||||
if (result.Login is null || result.Token is null)
|
||||
{
|
||||
// Пустой/пробельный login и неверные учётные данные — одно сообщение (семантика прототипа).
|
||||
// Аудит tenant_login_failed пишем только для реальной попытки (непустой логин), без пароля (Ruling 4);
|
||||
// счётчик неудач гварда растёт там же — пустые логины ключа не имеют (блокирует только auth-политика).
|
||||
if (attemptedLogin is not null)
|
||||
{
|
||||
await loginAttemptGuard.RecordFailureAsync(ClientIp(context), attemptedLogin, ct);
|
||||
await auditService.AppendAsync(new AuditRecordDto(
|
||||
AuditEvents.TenantLoginFailed,
|
||||
AuditActorTypes.Tenant,
|
||||
ActorId: null,
|
||||
TenantId: null,
|
||||
Ip: ClientIp(context),
|
||||
DetailJson: AuditService.ToDetailJson(new { login = attemptedLogin })), ct);
|
||||
}
|
||||
|
||||
return EndpointResults.Unauthorized(InvalidCredentialsDetail);
|
||||
}
|
||||
|
||||
// Успешный вход сбрасывает счётчик неудач ключа ip|login (Ruling 5).
|
||||
await loginAttemptGuard.ResetAsync(ClientIp(context), result.Login, ct);
|
||||
|
||||
await auditService.AppendAsync(new AuditRecordDto(
|
||||
AuditEvents.TenantLoginOk,
|
||||
AuditActorTypes.Tenant,
|
||||
ActorId: result.UserId,
|
||||
TenantId: result.TenantId,
|
||||
Ip: ClientIp(context),
|
||||
DetailJson: AuditService.ToDetailJson(new { login = result.Login })), ct);
|
||||
|
||||
SessionCookieWriter.Append(context, cookieOptions.Value, result.Token);
|
||||
return Results.Ok(new { ok = true, login = result.Login });
|
||||
}
|
||||
|
||||
// POST /api/auth/logout: удаление сессии по токену из куки и очистка куки (всегда ok).
|
||||
// Если удалённая сессия была impersonation — пишется аудит impersonation_stopped (Task 7, ревью: полный аудит).
|
||||
private static async Task<IResult> LogoutAsync(
|
||||
AuthService authService,
|
||||
AuditService auditService,
|
||||
IOptions<CookieOptions> cookieOptions,
|
||||
HttpContext context,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var cookieName = cookieOptions.Value.Name;
|
||||
var rawToken = context.Request.Cookies[cookieName];
|
||||
// Пользователь разрешённой сессии — до её удаления (SessionMiddleware наполнил Items на старте запроса).
|
||||
CurrentUser? user = context.GetCurrentUser();
|
||||
var logout = await authService.LogoutAsync(rawToken, ct);
|
||||
if (logout is not null)
|
||||
{
|
||||
// Актор — оператор, начавший impersonation (маркер сессии); тенант — для фильтра TenantId.
|
||||
await auditService.AppendAsync(new AuditRecordDto(
|
||||
AuditEvents.ImpersonationStopped,
|
||||
AuditActorTypes.Operator,
|
||||
ActorId: logout.OperatorId,
|
||||
TenantId: logout.TenantId,
|
||||
Ip: ClientIp(context),
|
||||
DetailJson: AuditService.ToDetailJson(new { login = logout.Login })), ct);
|
||||
}
|
||||
|
||||
// Выход пользователя тенанта (этап 10, T1): событие пишется при живой разрешённой сессии.
|
||||
if (user is not null)
|
||||
{
|
||||
await AuditAppender.AppendTenantAsync(context, AuditEvents.TenantLogout, new { login = user.Login }, ct);
|
||||
}
|
||||
|
||||
context.Response.Cookies.Delete(cookieName);
|
||||
return Results.Ok(new { ok = true });
|
||||
}
|
||||
|
||||
// GET /api/auth/me: проверка живой сессии.
|
||||
private static IResult MeAsync(HttpContext context)
|
||||
{
|
||||
var user = context.GetCurrentUser();
|
||||
if (user is null)
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
return Results.Ok(new { login = user.Login, ok = true });
|
||||
}
|
||||
|
||||
// POST /api/auth/change-password: смена пароля и перевыпуск куки (свежая сессия).
|
||||
private static async Task<IResult> ChangePasswordAsync(
|
||||
ChangePasswordRequest body,
|
||||
AuthService authService,
|
||||
IOptions<CookieOptions> cookieOptions,
|
||||
HttpContext context,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var user = context.GetCurrentUser();
|
||||
if (user is null)
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
var result = await authService.ChangePasswordAsync(user.Login, body.OldPassword, body.NewPassword, ct);
|
||||
if (!result.Ok || result.NewToken is null)
|
||||
{
|
||||
// Семантика прототипа: код ошибки различает «старый пароль неверен» и «слишком короткий».
|
||||
var detail = result.Error == ChangePasswordResultDto.ErrorTooShort
|
||||
? PasswordTooShortDetail
|
||||
: WrongOldPasswordDetail;
|
||||
return EndpointResults.BadRequest(detail);
|
||||
}
|
||||
|
||||
// Старые сессии удалены внутри сервиса; выдаём клиенту свежую куку.
|
||||
SessionCookieWriter.Append(context, cookieOptions.Value, result.NewToken);
|
||||
return Results.Ok(new { ok = true });
|
||||
}
|
||||
|
||||
// Нормализованная попытка логина для аудита (нижний регистр/обрезка, как AuthService); null — писать нечего.
|
||||
// login: Логин из тела запроса.
|
||||
// Возвращает: Нормализованный логин или null при пустом/пробельном входе.
|
||||
private static string? NormalizeLogin(string? login)
|
||||
{
|
||||
string? normalized = login?.Trim().ToLowerInvariant();
|
||||
return string.IsNullOrEmpty(normalized) ? null : normalized;
|
||||
}
|
||||
|
||||
// IP-адрес клиента для аудита (без порта; null, если недоступен).
|
||||
// context: Контекст запроса.
|
||||
// Возвращает: Строковое представление IP или null.
|
||||
private static string? ClientIp(HttpContext context) => context.Connection.RemoteIpAddress?.ToString();
|
||||
}
|
||||
|
||||
@@ -1,478 +1,469 @@
|
||||
using System.Text.Json;
|
||||
using Deal.Api.Endpoints.RequestModels;
|
||||
using Deal.Api.Extensions;
|
||||
using Deal.Api.Models;
|
||||
using Deal.Api.Services;
|
||||
using Deal.Contracts.Integrations.Abstractions;
|
||||
using Deal.Contracts.Integrations.Models;
|
||||
using Deal.Modules.Kanban.Application.Abstractions;
|
||||
using Deal.Modules.Kanban.Application.Extensions;
|
||||
using Deal.Modules.Kanban.Application.Models;
|
||||
using Deal.Modules.Kanban.Application.Registrars;
|
||||
using Deal.Modules.Kanban.Application.Services;
|
||||
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.Dtos;
|
||||
|
||||
namespace Deal.Api.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Детальные операции карточки: создание локальной, «взять в работу», патч, ссылки, файлы,
|
||||
/// напоминания, очистка «Отклонено» — продолжение группы /api/cards (этап 9, T6).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Единый контракт /api/cards (R5): операции проектной карточки (патч полей, ссылки, файлы, напоминания)
|
||||
/// теперь живут на том же ресурсе карточки. Список/чтение/перенос/комментарии/корзина — в
|
||||
/// <see cref="CardsEndpoints"/>; здесь — уникальные подпути. Все мутации возвращают обновлённую
|
||||
/// единую карточку (чтение после записи через <see cref="CardsService"/>). Все эндпоинты требуют сессию.
|
||||
/// </remarks>
|
||||
public static class CardDetailsEndpoints
|
||||
{
|
||||
// Префикс группы (общий с CardsEndpoints).
|
||||
private const string CardsGroupPrefix = "/api/cards";
|
||||
|
||||
// Статический сегмент «взять в работу» (регистрируется до /{cardId}).
|
||||
private const string TakePath = "/take";
|
||||
|
||||
// Статический сегмент очистки «Отклонено» (регистрируется до /{cardId}).
|
||||
private const string ClearRejectedPath = "/clear-rejected";
|
||||
|
||||
// Параметрический сегмент карточки (PATCH /{cardId}).
|
||||
private const string CardIdPath = "/{cardId}";
|
||||
|
||||
// Вложенный путь добавления ссылки.
|
||||
private const string LinksPath = "/{cardId}/links";
|
||||
|
||||
// Вложенный путь удаления ссылки.
|
||||
private const string LinkItemPath = "/{cardId}/links/{linkId}";
|
||||
|
||||
// Вложенный путь загрузки вложений (multipart, поле files).
|
||||
private const string FilesPath = "/{cardId}/files";
|
||||
|
||||
// Вложенный путь скачивания вложения (поток + attachment).
|
||||
private const string FileDownloadPath = "/{cardId}/files/{fileId}/download";
|
||||
|
||||
// Вложенный путь удаления вложения.
|
||||
private const string FileItemPath = "/{cardId}/files/{fileId}";
|
||||
|
||||
// Вложенный путь установки/снятия напоминания.
|
||||
private const string ReminderPath = "/{cardId}/reminder";
|
||||
|
||||
// Вложенный путь «напомнить позже».
|
||||
private const string ReminderSnoozePath = "/{cardId}/reminder/snooze";
|
||||
|
||||
// OpenAPI-тег группы.
|
||||
private const string OpenApiTag = "cards";
|
||||
|
||||
// 404: карточка не найдена.
|
||||
private const string CardNotFoundDetail = "Карточка не найдена";
|
||||
|
||||
// 400: тело PATCH не JSON-объект.
|
||||
private const string InvalidBodyDetail = "Тело запроса должно быть JSON-объектом";
|
||||
|
||||
// 400: POST файлов без multipart/form-data.
|
||||
private const string FormExpectedDetail = "Ожидается multipart/form-data";
|
||||
|
||||
// 400: POST напоминания без поля at.
|
||||
private const string ReminderAtMissingDetail = "Поле at (epoch-ms) обязательно";
|
||||
|
||||
// 404 download: объекта нет в хранилище.
|
||||
private const string FileNotFoundInStorageDetail = "Файл не найден в MinIO";
|
||||
|
||||
// 410 download: у записи файла нет objectKey.
|
||||
private const string FileNotSavedDetail = "Файл не сохранён в объектном хранилище";
|
||||
|
||||
// Content-Type скачивания по умолчанию.
|
||||
private const string DownloadContentTypeFallback = "application/octet-stream";
|
||||
|
||||
// Символ, убираемый из имени файла для Content-Disposition.
|
||||
private const string FileNameQuoteCharacter = "\"";
|
||||
|
||||
/// <summary>
|
||||
/// Регистрирует уникальные подпути /api/cards (создание, take, патч, ссылки, файлы, напоминания).
|
||||
/// </summary>
|
||||
/// <param name="app">Построитель маршрутов приложения.</param>
|
||||
/// <returns>Построитель маршрутов для цепочки вызовов.</returns>
|
||||
public static IEndpointRouteBuilder MapCardDetailsEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var cards = app.MapGroup(CardsGroupPrefix).WithTags(OpenApiTag);
|
||||
|
||||
// Статические сегменты (/take, /clear-rejected) ДО /{cardId}; вложенные — за /{cardId}.
|
||||
cards.MapPost("", CreateCardAsync);
|
||||
cards.MapPost(TakePath, TakeCardAsync);
|
||||
cards.MapPost(ClearRejectedPath, ClearRejectedAsync);
|
||||
cards.MapPatch(CardIdPath, PatchCardAsync);
|
||||
cards.MapPost(LinksPath, AddLinkAsync);
|
||||
cards.MapDelete(LinkItemPath, RemoveLinkAsync);
|
||||
cards.MapPost(FilesPath, UploadFilesAsync);
|
||||
cards.MapGet(FileDownloadPath, DownloadFileAsync);
|
||||
cards.MapDelete(FileItemPath, RemoveFileAsync);
|
||||
cards.MapPost(ReminderPath, SetReminderAsync);
|
||||
cards.MapDelete(ReminderPath, ClearReminderAsync);
|
||||
cards.MapPost(ReminderSnoozePath, SnoozeReminderAsync);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
// POST /api/cards: ручное создание «локальной» карточки. Ответ — созданная карточка.
|
||||
private static async Task<IResult> CreateCardAsync(
|
||||
CreateCardRequest body,
|
||||
HttpContext context,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
CardsService service = context.RequestServices.GetRequiredService<CardsService>();
|
||||
CardDto created = await service.CreateLocalCardAsync(ToCreateLocalDto(body), ct);
|
||||
|
||||
// Аудит создания карточки (этап 10, T1).
|
||||
await AuditAppender.AppendTenantAsync(context, AuditEvents.CardCreated, new { cardId = created.Id }, ct);
|
||||
return await ReadCardAsync(context, created.Id, ct);
|
||||
}
|
||||
|
||||
// POST /api/cards/take {cardId}: «взять в работу» — перенос карточки в planned. Ответ — карточка.
|
||||
private static async Task<IResult> TakeCardAsync(
|
||||
TakeCardRequest body,
|
||||
HttpContext context,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
CardsService service = context.RequestServices.GetRequiredService<CardsService>();
|
||||
CardDto? card = await service.TakeCardAsync(body.CardId ?? body.LeadId ?? string.Empty, ct);
|
||||
return card is null
|
||||
? EndpointResults.NotFound(CardNotFoundDetail)
|
||||
: await ReadCardAsync(context, card.Id, ct);
|
||||
}
|
||||
|
||||
// POST /api/cards/clear-rejected: полная очистка терминальной стадии «Отклонено».
|
||||
private static async Task<IResult> ClearRejectedAsync(HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
CardsService service = context.RequestServices.GetRequiredService<CardsService>();
|
||||
int cleared = await service.ClearRejectedAsync(ct);
|
||||
return Results.Ok(new { ok = true, cleared });
|
||||
}
|
||||
|
||||
// PATCH /api/cards/{cardId}: точечная правка полей (title/summary/contact/tzText/stack/budget).
|
||||
// Тело читается как произвольный JSON-объект (presence-aware): явный null чистящих полей
|
||||
// (budget:null, stack:null) не теряется типизированным биндингом. Ответ — обновлённая карточка.
|
||||
private static async Task<IResult> PatchCardAsync(
|
||||
string cardId,
|
||||
HttpContext context,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
IReadOnlyDictionary<string, JsonElement>? body = await ReadPatchBodyAsync(context, ct);
|
||||
if (body is null)
|
||||
{
|
||||
return EndpointResults.BadRequest(InvalidBodyDetail);
|
||||
}
|
||||
|
||||
CardsService service = context.RequestServices.GetRequiredService<CardsService>();
|
||||
CardDto? card = await service.PatchCardAsync(cardId, body, ct);
|
||||
return card is null
|
||||
? EndpointResults.NotFound(CardNotFoundDetail)
|
||||
: await ReadCardAsync(context, cardId, ct);
|
||||
}
|
||||
|
||||
// POST /api/cards/{cardId}/links {name?,url}: добавить ссылку. Ответ — карточка.
|
||||
private static async Task<IResult> AddLinkAsync(
|
||||
string cardId,
|
||||
CardLinkRequest body,
|
||||
HttpContext context,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
CardsService service = context.RequestServices.GetRequiredService<CardsService>();
|
||||
CardResultDto result = await service.AddLinkAsync(
|
||||
cardId,
|
||||
body.Name ?? string.Empty,
|
||||
body.Url ?? string.Empty,
|
||||
ct);
|
||||
if (result.Error is not null)
|
||||
{
|
||||
return EndpointResults.BadRequest(result.Error);
|
||||
}
|
||||
|
||||
return result.Card is null
|
||||
? EndpointResults.NotFound(CardNotFoundDetail)
|
||||
: await ReadCardAsync(context, cardId, ct);
|
||||
}
|
||||
|
||||
// DELETE /api/cards/{cardId}/links/{linkId}: удалить ссылку. Ответ — карточка.
|
||||
private static async Task<IResult> RemoveLinkAsync(
|
||||
string cardId,
|
||||
string linkId,
|
||||
HttpContext context,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
CardsService service = context.RequestServices.GetRequiredService<CardsService>();
|
||||
CardResultDto result = await service.RemoveLinkAsync(cardId, linkId, ct);
|
||||
return result.Card is null
|
||||
? EndpointResults.NotFound(CardNotFoundDetail)
|
||||
: await ReadCardAsync(context, cardId, ct);
|
||||
}
|
||||
|
||||
// POST /api/cards/{cardId}/files: загрузка вложений (multipart/form-data, поле files).
|
||||
// Ответ — обновлённая карточка (с новыми files). Карточки нет → 404 до записи объектов. Каждый файл:
|
||||
// имя/ContentType/поток/длина → CardsService.AddFileAsync. Ранний null — гонка (404).
|
||||
private static async Task<IResult> UploadFilesAsync(
|
||||
string cardId,
|
||||
HttpContext context,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
|
||||
if (await cardsService.GetCardAsync(cardId, ct) is null)
|
||||
{
|
||||
return EndpointResults.NotFound(CardNotFoundDetail);
|
||||
}
|
||||
|
||||
IFormCollection form;
|
||||
try
|
||||
{
|
||||
form = await context.Request.ReadFormAsync(ct);
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
// Тело не multipart/form-data — ReadFormAsync бросает; фронт так не шлёт.
|
||||
return EndpointResults.BadRequest(FormExpectedDetail);
|
||||
}
|
||||
|
||||
foreach (IFormFile file in form.Files)
|
||||
{
|
||||
await using Stream content = file.OpenReadStream();
|
||||
CardFileDto? entry = await cardsService.AddFileAsync(cardId, file.FileName, file.ContentType, content, file.Length, ct);
|
||||
if (entry is null)
|
||||
{
|
||||
return EndpointResults.NotFound(CardNotFoundDetail);
|
||||
}
|
||||
}
|
||||
|
||||
return await ReadCardAsync(context, cardId, ct);
|
||||
}
|
||||
|
||||
// GET /api/cards/{cardId}/files/{fileId}/download: поток содержимого вложения.
|
||||
// Карточки/записи нет → 404; пустой objectKey → 410; объекта нет в хранилище/сбой → 404. Ответ — поток
|
||||
// с Content-Length/Content-Type из дескриптора; Content-Disposition attachment, имя без кавычек.
|
||||
private static async Task<IResult> DownloadFileAsync(
|
||||
string cardId,
|
||||
string fileId,
|
||||
HttpContext context,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
|
||||
CardFileDto? entry = await cardsService.GetFileEntryAsync(cardId, fileId, ct);
|
||||
if (entry is null)
|
||||
{
|
||||
return EndpointResults.NotFound(CardNotFoundDetail);
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(entry.ObjectKey))
|
||||
{
|
||||
return EndpointResults.Gone(FileNotSavedDetail);
|
||||
}
|
||||
|
||||
IFileStorage storage = context.RequestServices.GetRequiredService<IFileStorage>();
|
||||
FileMeta? meta;
|
||||
Stream? stream;
|
||||
try
|
||||
{
|
||||
meta = await storage.StatAsync(entry.ObjectKey, ct);
|
||||
stream = meta is null ? null : await storage.GetAsync(entry.ObjectKey, ct);
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return EndpointResults.NotFound(FileNotFoundInStorageDetail);
|
||||
}
|
||||
|
||||
if (meta is null || stream is null)
|
||||
{
|
||||
return EndpointResults.NotFound(FileNotFoundInStorageDetail);
|
||||
}
|
||||
|
||||
context.Response.ContentLength = meta.Size;
|
||||
string contentType = string.IsNullOrWhiteSpace(meta.ContentType)
|
||||
? DownloadContentTypeFallback
|
||||
: meta.ContentType;
|
||||
return Results.Stream(stream, contentType, fileDownloadName: ToDownloadFileName(entry.Name));
|
||||
}
|
||||
|
||||
// DELETE /api/cards/{cardId}/files/{fileId}: открепить файл. Ответ — карточка.
|
||||
private static async Task<IResult> RemoveFileAsync(
|
||||
string cardId,
|
||||
string fileId,
|
||||
HttpContext context,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
|
||||
CardDto? card = await cardsService.RemoveFileAsync(cardId, fileId, ct);
|
||||
return card is null
|
||||
? EndpointResults.NotFound(CardNotFoundDetail)
|
||||
: await ReadCardAsync(context, cardId, ct);
|
||||
}
|
||||
|
||||
// POST /api/cards/{cardId}/reminder {at: epoch-ms}: установить напоминание. Ответ — карточка.
|
||||
private static async Task<IResult> SetReminderAsync(
|
||||
string cardId,
|
||||
ReminderSetRequest body,
|
||||
HttpContext context,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
if (body.At is null)
|
||||
{
|
||||
return EndpointResults.BadRequest(ReminderAtMissingDetail);
|
||||
}
|
||||
|
||||
CardsService service = context.RequestServices.GetRequiredService<CardsService>();
|
||||
CardResultDto result = await service.SetReminderAsync(cardId, body.At.Value, ct);
|
||||
if (result.Error is not null)
|
||||
{
|
||||
return EndpointResults.BadRequest(result.Error);
|
||||
}
|
||||
|
||||
return result.Card is null
|
||||
? EndpointResults.NotFound(CardNotFoundDetail)
|
||||
: await ReadCardAsync(context, cardId, ct);
|
||||
}
|
||||
|
||||
// DELETE /api/cards/{cardId}/reminder: снять напоминание. Ответ — карточка.
|
||||
private static async Task<IResult> ClearReminderAsync(
|
||||
string cardId,
|
||||
HttpContext context,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
CardsService service = context.RequestServices.GetRequiredService<CardsService>();
|
||||
return await service.ClearReminderAsync(cardId, ct)
|
||||
? await ReadCardAsync(context, cardId, ct)
|
||||
: EndpointResults.NotFound(CardNotFoundDetail);
|
||||
}
|
||||
|
||||
// POST /api/cards/{cardId}/reminder/snooze: «напомнить позже» (now + 24 ч). Ответ — карточка.
|
||||
private static async Task<IResult> SnoozeReminderAsync(
|
||||
string cardId,
|
||||
HttpContext context,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
CardsService service = context.RequestServices.GetRequiredService<CardsService>();
|
||||
return await service.SnoozeReminderAsync(cardId, ct)
|
||||
? await ReadCardAsync(context, cardId, ct)
|
||||
: EndpointResults.NotFound(CardNotFoundDetail);
|
||||
}
|
||||
|
||||
// Читает карточку через единый сервис и возвращает её как ответ (404 — карточки нет).
|
||||
// context: Контекст запроса (для резолва CardsService).
|
||||
// cardId: Id карточки.
|
||||
// ct: Токен отмены.
|
||||
// Возвращает: 200 с единой карточкой либо 404.
|
||||
private static async Task<IResult> ReadCardAsync(
|
||||
HttpContext context,
|
||||
string cardId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
|
||||
CardDto? card = await cardsService.GetCardAsync(cardId, ct);
|
||||
return card is null
|
||||
? EndpointResults.NotFound(CardNotFoundDetail)
|
||||
: Results.Ok(card);
|
||||
}
|
||||
|
||||
// Имя файла для Content-Disposition без кавычек «"».
|
||||
// name: Имя файла как в метаданных записи.
|
||||
// Возвращает: Имя, безопасное для заголовка.
|
||||
private static string ToDownloadFileName(string name) => name.Replace(FileNameQuoteCharacter, string.Empty);
|
||||
|
||||
// Переводит тело POST /api/cards в начальные поля сервиса (поля 1:1 с CardLocalCreateDto).
|
||||
// body: Тело запроса.
|
||||
// Возвращает: DTO модуля для CardsService.CreateLocalCardAsync.
|
||||
private static CardLocalCreateDto ToCreateLocalDto(CreateCardRequest body)
|
||||
{
|
||||
return new CardLocalCreateDto(
|
||||
Title: body.Title,
|
||||
Summary: body.Summary,
|
||||
Stack: body.Stack,
|
||||
Budget: body.Budget,
|
||||
Contact: body.Contact,
|
||||
TzText: body.TzText,
|
||||
ContainerId: body.ContainerId ?? body.Stage);
|
||||
}
|
||||
|
||||
// Читает тело PATCH как произвольный JSON-объект: ключ → JsonElement (presence-aware).
|
||||
// context: Контекст запроса.
|
||||
// ct: Токен отмены.
|
||||
// Возвращает: Словарь ключей тела либо null — тело не JSON-объект.
|
||||
private static async Task<IReadOnlyDictionary<string, JsonElement>?> ReadPatchBodyAsync(HttpContext context, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await JsonSerializer.DeserializeAsync<Dictionary<string, JsonElement>>(
|
||||
context.Request.Body,
|
||||
options: null,
|
||||
cancellationToken: ct);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
using System.Text.Json;
|
||||
using Deal.Api.Endpoints.RequestModels;
|
||||
using Deal.Api.Extensions;
|
||||
using Deal.Api.Services;
|
||||
using Deal.Contracts.Integrations.Abstractions;
|
||||
using Deal.Contracts.Integrations.Models;
|
||||
using Deal.Modules.Kanban.Application.Models;
|
||||
using Deal.Modules.Kanban.Application.Services;
|
||||
using Deal.Modules.Tenants.Application.Models;
|
||||
|
||||
namespace Deal.Api.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Детальные операции карточки: создание локальной, «взять в работу», патч, ссылки, файлы,
|
||||
/// напоминания, очистка «Отклонено» — продолжение группы /api/cards (этап 9, T6).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Единый контракт /api/cards (R5): операции проектной карточки (патч полей, ссылки, файлы, напоминания)
|
||||
/// теперь живут на том же ресурсе карточки. Список/чтение/перенос/комментарии/корзина — в
|
||||
/// <see cref="CardsEndpoints"/>; здесь — уникальные подпути. Все мутации возвращают обновлённую
|
||||
/// единую карточку (чтение после записи через <see cref="CardsService"/>). Все эндпоинты требуют сессию.
|
||||
/// </remarks>
|
||||
public static class CardDetailsEndpoints
|
||||
{
|
||||
// Префикс группы (общий с CardsEndpoints).
|
||||
private const string CardsGroupPrefix = "/api/cards";
|
||||
|
||||
// Статический сегмент «взять в работу» (регистрируется до /{cardId}).
|
||||
private const string TakePath = "/take";
|
||||
|
||||
// Статический сегмент очистки «Отклонено» (регистрируется до /{cardId}).
|
||||
private const string ClearRejectedPath = "/clear-rejected";
|
||||
|
||||
// Параметрический сегмент карточки (PATCH /{cardId}).
|
||||
private const string CardIdPath = "/{cardId}";
|
||||
|
||||
// Вложенный путь добавления ссылки.
|
||||
private const string LinksPath = "/{cardId}/links";
|
||||
|
||||
// Вложенный путь удаления ссылки.
|
||||
private const string LinkItemPath = "/{cardId}/links/{linkId}";
|
||||
|
||||
// Вложенный путь загрузки вложений (multipart, поле files).
|
||||
private const string FilesPath = "/{cardId}/files";
|
||||
|
||||
// Вложенный путь скачивания вложения (поток + attachment).
|
||||
private const string FileDownloadPath = "/{cardId}/files/{fileId}/download";
|
||||
|
||||
// Вложенный путь удаления вложения.
|
||||
private const string FileItemPath = "/{cardId}/files/{fileId}";
|
||||
|
||||
// Вложенный путь установки/снятия напоминания.
|
||||
private const string ReminderPath = "/{cardId}/reminder";
|
||||
|
||||
// Вложенный путь «напомнить позже».
|
||||
private const string ReminderSnoozePath = "/{cardId}/reminder/snooze";
|
||||
|
||||
// OpenAPI-тег группы.
|
||||
private const string OpenApiTag = "cards";
|
||||
|
||||
// 404: карточка не найдена.
|
||||
private const string CardNotFoundDetail = "Карточка не найдена";
|
||||
|
||||
// 400: тело PATCH не JSON-объект.
|
||||
private const string InvalidBodyDetail = "Тело запроса должно быть JSON-объектом";
|
||||
|
||||
// 400: POST файлов без multipart/form-data.
|
||||
private const string FormExpectedDetail = "Ожидается multipart/form-data";
|
||||
|
||||
// 400: POST напоминания без поля at.
|
||||
private const string ReminderAtMissingDetail = "Поле at (epoch-ms) обязательно";
|
||||
|
||||
// 404 download: объекта нет в хранилище.
|
||||
private const string FileNotFoundInStorageDetail = "Файл не найден в MinIO";
|
||||
|
||||
// 410 download: у записи файла нет objectKey.
|
||||
private const string FileNotSavedDetail = "Файл не сохранён в объектном хранилище";
|
||||
|
||||
// Content-Type скачивания по умолчанию.
|
||||
private const string DownloadContentTypeFallback = "application/octet-stream";
|
||||
|
||||
// Символ, убираемый из имени файла для Content-Disposition.
|
||||
private const string FileNameQuoteCharacter = "\"";
|
||||
|
||||
/// <summary>
|
||||
/// Регистрирует уникальные подпути /api/cards (создание, take, патч, ссылки, файлы, напоминания).
|
||||
/// </summary>
|
||||
/// <param name="app">Построитель маршрутов приложения.</param>
|
||||
/// <returns>Построитель маршрутов для цепочки вызовов.</returns>
|
||||
public static IEndpointRouteBuilder MapCardDetailsEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var cards = app.MapGroup(CardsGroupPrefix).WithTags(OpenApiTag);
|
||||
|
||||
// Статические сегменты (/take, /clear-rejected) ДО /{cardId}; вложенные — за /{cardId}.
|
||||
cards.MapPost("", CreateCardAsync);
|
||||
cards.MapPost(TakePath, TakeCardAsync);
|
||||
cards.MapPost(ClearRejectedPath, ClearRejectedAsync);
|
||||
cards.MapPatch(CardIdPath, PatchCardAsync);
|
||||
cards.MapPost(LinksPath, AddLinkAsync);
|
||||
cards.MapDelete(LinkItemPath, RemoveLinkAsync);
|
||||
cards.MapPost(FilesPath, UploadFilesAsync);
|
||||
cards.MapGet(FileDownloadPath, DownloadFileAsync);
|
||||
cards.MapDelete(FileItemPath, RemoveFileAsync);
|
||||
cards.MapPost(ReminderPath, SetReminderAsync);
|
||||
cards.MapDelete(ReminderPath, ClearReminderAsync);
|
||||
cards.MapPost(ReminderSnoozePath, SnoozeReminderAsync);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
// POST /api/cards: ручное создание «локальной» карточки. Ответ — созданная карточка.
|
||||
private static async Task<IResult> CreateCardAsync(
|
||||
CreateCardRequest body,
|
||||
HttpContext context,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
CardsService service = context.RequestServices.GetRequiredService<CardsService>();
|
||||
CardDto created = await service.CreateLocalCardAsync(ToCreateLocalDto(body), ct);
|
||||
|
||||
// Аудит создания карточки (этап 10, T1).
|
||||
await AuditAppender.AppendTenantAsync(context, AuditEvents.CardCreated, new { cardId = created.Id }, ct);
|
||||
return await ReadCardAsync(context, created.Id, ct);
|
||||
}
|
||||
|
||||
// POST /api/cards/take {cardId}: «взять в работу» — перенос карточки в planned. Ответ — карточка.
|
||||
private static async Task<IResult> TakeCardAsync(
|
||||
TakeCardRequest body,
|
||||
HttpContext context,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
CardsService service = context.RequestServices.GetRequiredService<CardsService>();
|
||||
CardDto? card = await service.TakeCardAsync(body.CardId ?? body.LeadId ?? string.Empty, ct);
|
||||
return card is null
|
||||
? EndpointResults.NotFound(CardNotFoundDetail)
|
||||
: await ReadCardAsync(context, card.Id, ct);
|
||||
}
|
||||
|
||||
// POST /api/cards/clear-rejected: полная очистка терминальной стадии «Отклонено».
|
||||
private static async Task<IResult> ClearRejectedAsync(HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
CardsService service = context.RequestServices.GetRequiredService<CardsService>();
|
||||
int cleared = await service.ClearRejectedAsync(ct);
|
||||
return Results.Ok(new { ok = true, cleared });
|
||||
}
|
||||
|
||||
// PATCH /api/cards/{cardId}: точечная правка полей (title/summary/contact/tzText/stack/budget).
|
||||
// Тело читается как произвольный JSON-объект (presence-aware): явный null чистящих полей
|
||||
// (budget:null, stack:null) не теряется типизированным биндингом. Ответ — обновлённая карточка.
|
||||
private static async Task<IResult> PatchCardAsync(
|
||||
string cardId,
|
||||
HttpContext context,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
IReadOnlyDictionary<string, JsonElement>? body = await ReadPatchBodyAsync(context, ct);
|
||||
if (body is null)
|
||||
{
|
||||
return EndpointResults.BadRequest(InvalidBodyDetail);
|
||||
}
|
||||
|
||||
CardsService service = context.RequestServices.GetRequiredService<CardsService>();
|
||||
CardDto? card = await service.PatchCardAsync(cardId, body, ct);
|
||||
return card is null
|
||||
? EndpointResults.NotFound(CardNotFoundDetail)
|
||||
: await ReadCardAsync(context, cardId, ct);
|
||||
}
|
||||
|
||||
// POST /api/cards/{cardId}/links {name?,url}: добавить ссылку. Ответ — карточка.
|
||||
private static async Task<IResult> AddLinkAsync(
|
||||
string cardId,
|
||||
CardLinkRequest body,
|
||||
HttpContext context,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
CardsService service = context.RequestServices.GetRequiredService<CardsService>();
|
||||
CardResultDto result = await service.AddLinkAsync(
|
||||
cardId,
|
||||
body.Name ?? string.Empty,
|
||||
body.Url ?? string.Empty,
|
||||
ct);
|
||||
if (result.Error is not null)
|
||||
{
|
||||
return EndpointResults.BadRequest(result.Error);
|
||||
}
|
||||
|
||||
return result.Card is null
|
||||
? EndpointResults.NotFound(CardNotFoundDetail)
|
||||
: await ReadCardAsync(context, cardId, ct);
|
||||
}
|
||||
|
||||
// DELETE /api/cards/{cardId}/links/{linkId}: удалить ссылку. Ответ — карточка.
|
||||
private static async Task<IResult> RemoveLinkAsync(
|
||||
string cardId,
|
||||
string linkId,
|
||||
HttpContext context,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
CardsService service = context.RequestServices.GetRequiredService<CardsService>();
|
||||
CardResultDto result = await service.RemoveLinkAsync(cardId, linkId, ct);
|
||||
return result.Card is null
|
||||
? EndpointResults.NotFound(CardNotFoundDetail)
|
||||
: await ReadCardAsync(context, cardId, ct);
|
||||
}
|
||||
|
||||
// POST /api/cards/{cardId}/files: загрузка вложений (multipart/form-data, поле files).
|
||||
// Ответ — обновлённая карточка (с новыми files). Карточки нет → 404 до записи объектов. Каждый файл:
|
||||
// имя/ContentType/поток/длина → CardsService.AddFileAsync. Ранний null — гонка (404).
|
||||
private static async Task<IResult> UploadFilesAsync(
|
||||
string cardId,
|
||||
HttpContext context,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
|
||||
if (await cardsService.GetCardAsync(cardId, ct) is null)
|
||||
{
|
||||
return EndpointResults.NotFound(CardNotFoundDetail);
|
||||
}
|
||||
|
||||
IFormCollection form;
|
||||
try
|
||||
{
|
||||
form = await context.Request.ReadFormAsync(ct);
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
// Тело не multipart/form-data — ReadFormAsync бросает; фронт так не шлёт.
|
||||
return EndpointResults.BadRequest(FormExpectedDetail);
|
||||
}
|
||||
|
||||
foreach (IFormFile file in form.Files)
|
||||
{
|
||||
await using Stream content = file.OpenReadStream();
|
||||
CardFileDto? entry = await cardsService.AddFileAsync(cardId, file.FileName, file.ContentType, content, file.Length, ct);
|
||||
if (entry is null)
|
||||
{
|
||||
return EndpointResults.NotFound(CardNotFoundDetail);
|
||||
}
|
||||
}
|
||||
|
||||
return await ReadCardAsync(context, cardId, ct);
|
||||
}
|
||||
|
||||
// GET /api/cards/{cardId}/files/{fileId}/download: поток содержимого вложения.
|
||||
// Карточки/записи нет → 404; пустой objectKey → 410; объекта нет в хранилище/сбой → 404. Ответ — поток
|
||||
// с Content-Length/Content-Type из дескриптора; Content-Disposition attachment, имя без кавычек.
|
||||
private static async Task<IResult> DownloadFileAsync(
|
||||
string cardId,
|
||||
string fileId,
|
||||
HttpContext context,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
|
||||
CardFileDto? entry = await cardsService.GetFileEntryAsync(cardId, fileId, ct);
|
||||
if (entry is null)
|
||||
{
|
||||
return EndpointResults.NotFound(CardNotFoundDetail);
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(entry.ObjectKey))
|
||||
{
|
||||
return EndpointResults.Gone(FileNotSavedDetail);
|
||||
}
|
||||
|
||||
IFileStorage storage = context.RequestServices.GetRequiredService<IFileStorage>();
|
||||
FileMeta? meta;
|
||||
Stream? stream;
|
||||
try
|
||||
{
|
||||
meta = await storage.StatAsync(entry.ObjectKey, ct);
|
||||
stream = meta is null ? null : await storage.GetAsync(entry.ObjectKey, ct);
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return EndpointResults.NotFound(FileNotFoundInStorageDetail);
|
||||
}
|
||||
|
||||
if (meta is null || stream is null)
|
||||
{
|
||||
return EndpointResults.NotFound(FileNotFoundInStorageDetail);
|
||||
}
|
||||
|
||||
context.Response.ContentLength = meta.Size;
|
||||
string contentType = string.IsNullOrWhiteSpace(meta.ContentType)
|
||||
? DownloadContentTypeFallback
|
||||
: meta.ContentType;
|
||||
return Results.Stream(stream, contentType, fileDownloadName: ToDownloadFileName(entry.Name));
|
||||
}
|
||||
|
||||
// DELETE /api/cards/{cardId}/files/{fileId}: открепить файл. Ответ — карточка.
|
||||
private static async Task<IResult> RemoveFileAsync(
|
||||
string cardId,
|
||||
string fileId,
|
||||
HttpContext context,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
|
||||
CardDto? card = await cardsService.RemoveFileAsync(cardId, fileId, ct);
|
||||
return card is null
|
||||
? EndpointResults.NotFound(CardNotFoundDetail)
|
||||
: await ReadCardAsync(context, cardId, ct);
|
||||
}
|
||||
|
||||
// POST /api/cards/{cardId}/reminder {at: epoch-ms}: установить напоминание. Ответ — карточка.
|
||||
private static async Task<IResult> SetReminderAsync(
|
||||
string cardId,
|
||||
ReminderSetRequest body,
|
||||
HttpContext context,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
if (body.At is null)
|
||||
{
|
||||
return EndpointResults.BadRequest(ReminderAtMissingDetail);
|
||||
}
|
||||
|
||||
CardsService service = context.RequestServices.GetRequiredService<CardsService>();
|
||||
CardResultDto result = await service.SetReminderAsync(cardId, body.At.Value, ct);
|
||||
if (result.Error is not null)
|
||||
{
|
||||
return EndpointResults.BadRequest(result.Error);
|
||||
}
|
||||
|
||||
return result.Card is null
|
||||
? EndpointResults.NotFound(CardNotFoundDetail)
|
||||
: await ReadCardAsync(context, cardId, ct);
|
||||
}
|
||||
|
||||
// DELETE /api/cards/{cardId}/reminder: снять напоминание. Ответ — карточка.
|
||||
private static async Task<IResult> ClearReminderAsync(
|
||||
string cardId,
|
||||
HttpContext context,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
CardsService service = context.RequestServices.GetRequiredService<CardsService>();
|
||||
return await service.ClearReminderAsync(cardId, ct)
|
||||
? await ReadCardAsync(context, cardId, ct)
|
||||
: EndpointResults.NotFound(CardNotFoundDetail);
|
||||
}
|
||||
|
||||
// POST /api/cards/{cardId}/reminder/snooze: «напомнить позже» (now + 24 ч). Ответ — карточка.
|
||||
private static async Task<IResult> SnoozeReminderAsync(
|
||||
string cardId,
|
||||
HttpContext context,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
CardsService service = context.RequestServices.GetRequiredService<CardsService>();
|
||||
return await service.SnoozeReminderAsync(cardId, ct)
|
||||
? await ReadCardAsync(context, cardId, ct)
|
||||
: EndpointResults.NotFound(CardNotFoundDetail);
|
||||
}
|
||||
|
||||
// Читает карточку через единый сервис и возвращает её как ответ (404 — карточки нет).
|
||||
// context: Контекст запроса (для резолва CardsService).
|
||||
// cardId: Id карточки.
|
||||
// ct: Токен отмены.
|
||||
// Возвращает: 200 с единой карточкой либо 404.
|
||||
private static async Task<IResult> ReadCardAsync(
|
||||
HttpContext context,
|
||||
string cardId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
|
||||
CardDto? card = await cardsService.GetCardAsync(cardId, ct);
|
||||
return card is null
|
||||
? EndpointResults.NotFound(CardNotFoundDetail)
|
||||
: Results.Ok(card);
|
||||
}
|
||||
|
||||
// Имя файла для Content-Disposition без кавычек «"».
|
||||
// name: Имя файла как в метаданных записи.
|
||||
// Возвращает: Имя, безопасное для заголовка.
|
||||
private static string ToDownloadFileName(string name) => name.Replace(FileNameQuoteCharacter, string.Empty);
|
||||
|
||||
// Переводит тело POST /api/cards в начальные поля сервиса (поля 1:1 с CardLocalCreateDto).
|
||||
// body: Тело запроса.
|
||||
// Возвращает: DTO модуля для CardsService.CreateLocalCardAsync.
|
||||
private static CardLocalCreateDto ToCreateLocalDto(CreateCardRequest body)
|
||||
{
|
||||
return new CardLocalCreateDto(
|
||||
Title: body.Title,
|
||||
Summary: body.Summary,
|
||||
Stack: body.Stack,
|
||||
Budget: body.Budget,
|
||||
Contact: body.Contact,
|
||||
TzText: body.TzText,
|
||||
ContainerId: body.ContainerId ?? body.Stage);
|
||||
}
|
||||
|
||||
// Читает тело PATCH как произвольный JSON-объект: ключ → JsonElement (presence-aware).
|
||||
// context: Контекст запроса.
|
||||
// ct: Токен отмены.
|
||||
// Возвращает: Словарь ключей тела либо null — тело не JSON-объект.
|
||||
private static async Task<IReadOnlyDictionary<string, JsonElement>?> ReadPatchBodyAsync(HttpContext context, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await JsonSerializer.DeserializeAsync<Dictionary<string, JsonElement>>(
|
||||
context.Request.Body,
|
||||
options: null,
|
||||
cancellationToken: ct);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,502 +1,491 @@
|
||||
using Deal.Api.Endpoints.RequestModels;
|
||||
using Deal.Api.Events;
|
||||
using Deal.Api.Extensions;
|
||||
using Deal.Api.Models;
|
||||
using Deal.Api.Services;
|
||||
using Deal.Modules.Cards.Application.Abstractions;
|
||||
using Deal.Modules.Cards.Application.Dtos;
|
||||
using Deal.Modules.Cards.Application.Models;
|
||||
using Deal.Modules.Kanban.Application.Abstractions;
|
||||
using Deal.Modules.Kanban.Application.Extensions;
|
||||
using Deal.Modules.Kanban.Application.Models;
|
||||
using Deal.Modules.Kanban.Application.Registrars;
|
||||
using Deal.Modules.Kanban.Application.Services;
|
||||
using Deal.Modules.Pipeline.Application.Abstractions;
|
||||
using Deal.Modules.Pipeline.Application.Models;
|
||||
using Deal.Modules.Pipeline.Application.Registrars;
|
||||
using Deal.Modules.Pipeline.Application.Services;
|
||||
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.Dtos;
|
||||
|
||||
namespace Deal.Api.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Эндпоинты карточек и поиска: GET /api/cards[?containerId=], /cards/counts, /cards/{id},
|
||||
/// mark-all-seen/mark-col-seen, move/trash/restore/DELETE, clear-col, comments, reclassify (batch + {id}),
|
||||
/// GET /api/search (этап 9, T6).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Единый контракт /api/cards (R5): старые ручки /api/leads, /api/projects, /api/boards и /api/columns
|
||||
/// упразднены. GET /cards →
|
||||
/// {items}; counts — плоская wire-форма {new, <col>: {count, new}, learning, ml, ai}; move → обновлённая
|
||||
/// карточка; restore → {ok, col}; clear-col → {ok, cleared}; comments → {comments}; search →
|
||||
/// {cards, messages: []}. 400 «Неизвестный контейнер» при несуществующем containerId; 404 «Карточка не
|
||||
/// найдена» — null-результаты сервисов, 400-тексты — константы CardsService.
|
||||
/// ⚠ Статические сегменты (counts, mark-all-seen, mark-col-seen, clear-col, reclassify) регистрируются ДО
|
||||
/// /cards/{cardId}. Все эндпоинты требуют сессию: 401 {detail}; сервисы резолвятся из RequestServices
|
||||
/// ПОСЛЕ проверки сессии.
|
||||
/// </remarks>
|
||||
public static class CardsEndpoints
|
||||
{
|
||||
// Префикс группы карточек.
|
||||
private const string CardsGroupPrefix = "/api/cards";
|
||||
|
||||
// Префикс группы поиска (единственный эндпоинт группы — /api/search).
|
||||
private const string ApiGroupPrefix = "/api";
|
||||
|
||||
// Путь поиска (GET).
|
||||
private const string SearchPath = "/search";
|
||||
|
||||
// OpenAPI-тег группы (в прототипе роутер dashboard — dashboard_routes.py).
|
||||
private const string OpenApiTag = "dashboard";
|
||||
|
||||
// 404: карточка не найдена (dashboard_routes.py _lead_or_404 L92–96).
|
||||
private const string CardNotFoundDetail = "Карточка не найдена";
|
||||
|
||||
// 400 GET /cards: containerId не существует.
|
||||
private const string UnknownColumnDetail = "Неизвестный контейнер";
|
||||
|
||||
// Инициатор перехода при ручном переносе — действие пользователя (R4 этапа 9).
|
||||
private const string UserActor = "user";
|
||||
|
||||
// SSE-тип события завершения переклассификации (этап 12, остаток 2; api.js слушает 'cards_reclassified').
|
||||
private const string ReclassifiedEventType = "cards_reclassified";
|
||||
|
||||
// Контекст ручного перехода карточки: пользователь, обучение ML по цели переноса.
|
||||
private static readonly TransitionContext UserMoveContext = new() { Actor = UserActor, Learn = true };
|
||||
|
||||
/// <summary>
|
||||
/// Регистрирует группы /api/cards и /api (карточки + поиск). Статические сегменты — до /cards/{cardId}.
|
||||
/// </summary>
|
||||
/// <param name="app">Построитель маршрутов приложения.</param>
|
||||
/// <returns>Построитель маршрутов для цепочки вызовов.</returns>
|
||||
public static IEndpointRouteBuilder MapCardsEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var leads = app.MapGroup(CardsGroupPrefix).WithTags(OpenApiTag);
|
||||
|
||||
// Статические сегменты ДО /cards/{cardId}: ASP.NET Core отдаёт приоритет литералам, порядок регистрации
|
||||
// сохранён для читаемости.
|
||||
leads.MapGet("", ListCardsAsync);
|
||||
leads.MapGet("/counts", CountsAsync);
|
||||
leads.MapPost("/mark-all-seen", MarkAllSeenAsync);
|
||||
leads.MapPost("/mark-col-seen", MarkColSeenAsync);
|
||||
leads.MapPost("/clear-col", ClearColAsync);
|
||||
leads.MapPost("/reclassify", ReclassifyAsync);
|
||||
leads.MapGet("/{cardId}", GetCardAsync);
|
||||
leads.MapPost("/{cardId}/reclassify", ReclassifyOneAsync);
|
||||
leads.MapPost("/{cardId}/move", MoveAsync);
|
||||
leads.MapPost("/{cardId}/trash", TrashAsync);
|
||||
leads.MapPost("/{cardId}/restore", RestoreAsync);
|
||||
leads.MapDelete("/{cardId}", DeleteAsync);
|
||||
leads.MapPost("/{cardId}/comments", AddCommentAsync);
|
||||
|
||||
app.MapGroup(ApiGroupPrefix).WithTags(OpenApiTag).MapGet(SearchPath, SearchAsync);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
// GET /api/cards?containerId=: карточки контейнера (или все карточки дашборда); 400 «Неизвестный контейнер».
|
||||
// Параметр col принят как алиас containerId (совместимость со старым фронтом).
|
||||
private static async Task<IResult> ListCardsAsync(
|
||||
string? containerId,
|
||||
string? col,
|
||||
HttpContext context,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
string? target = string.IsNullOrEmpty(containerId) ? col : containerId;
|
||||
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
|
||||
if (!string.IsNullOrEmpty(target) && !await IsKnownContainerAsync(target, context, ct))
|
||||
{
|
||||
return EndpointResults.BadRequest(UnknownColumnDetail);
|
||||
}
|
||||
|
||||
IReadOnlyList<CardDto> cards = await cardsService.ListCardsAsync(target, ct);
|
||||
return Results.Ok(new { items = cards });
|
||||
}
|
||||
|
||||
// GET /api/cards/counts: плоская wire-форма счётчиков {new, <col>:{count,new}, learning, ml, ai} (L161–163, §4.1 L257).
|
||||
private static async Task<IResult> CountsAsync(HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
|
||||
CardCountsDto counts = await cardsService.CountsAsync(ct);
|
||||
|
||||
// Разворачивание CardCountsDto: колонки — корневые ключи (counts L268–279), служебные — фиксированные.
|
||||
var wire = new Dictionary<string, object> { ["new"] = counts.New };
|
||||
foreach ((string col, CardColumnCountDto column) in counts.Columns)
|
||||
{
|
||||
wire[col] = column;
|
||||
}
|
||||
|
||||
wire["learning"] = counts.Learning;
|
||||
wire["ml"] = counts.Ml;
|
||||
wire["ai"] = counts.Ai;
|
||||
return Results.Ok(wire);
|
||||
}
|
||||
|
||||
// GET /api/cards/{cardId}: одна карточка; 404 «Карточка не найдена» (L166–168).
|
||||
private static async Task<IResult> GetCardAsync(
|
||||
string cardId,
|
||||
HttpContext context,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
|
||||
CardDto? card = await cardsService.GetCardAsync(cardId, ct);
|
||||
return card is null
|
||||
? EndpointResults.NotFound(CardNotFoundDetail)
|
||||
: Results.Ok(card);
|
||||
}
|
||||
|
||||
// POST /api/cards/mark-all-seen: снять «новое» со всех карточек (L177–180); ответ {ok:true}.
|
||||
private static async Task<IResult> MarkAllSeenAsync(HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
|
||||
await cardsService.MarkSeenAsync(cardId: null, col: null, ct);
|
||||
return Results.Ok(new { ok = true });
|
||||
}
|
||||
|
||||
// POST /api/cards/mark-col-seen: снять «новое» с колонки (L187–191); ответ {ok:true}.
|
||||
private static async Task<IResult> MarkColSeenAsync(
|
||||
MarkColBody body,
|
||||
HttpContext context,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
if (body.Col is null)
|
||||
{
|
||||
// Пустая/отсутствующая col попала бы в mark_seen как «не задана» и сняла бы «новое» со ВСЕХ
|
||||
// карточек (truthiness python, L250–256) — эндпоинт защищает от вызова с null (прототип: 422).
|
||||
return EndpointResults.BadRequest(UnknownColumnDetail);
|
||||
}
|
||||
|
||||
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
|
||||
await cardsService.MarkSeenAsync(cardId: null, col: body.Col, ct);
|
||||
return Results.Ok(new { ok = true });
|
||||
}
|
||||
|
||||
// POST /api/cards/{cardId}/move {to}: перенос карточки между контейнерами (этап 9, R4).
|
||||
// Маршрутизацию цели (стадия «Выбранных» vs дашборд-контейнер) и побочные эффекты выполняет единый
|
||||
// доменный механизм перехода ICardMover: стадия — запись истории и сброс напоминания
|
||||
// (move_stage), дашборд-контейнер — журнал/обучение ML. Ответ — обновлённая карточка; 400 при
|
||||
// несуществующем контейнере, 404 — карточки нет.
|
||||
private static async Task<IResult> MoveAsync(
|
||||
string cardId,
|
||||
MoveBody body,
|
||||
HttpContext context,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
ICardMover mover = context.RequestServices.GetRequiredService<ICardMover>();
|
||||
CardMoveResultDto outcome = await mover.MoveAsync(cardId, body.To ?? string.Empty, UserMoveContext, ct);
|
||||
if (outcome.Error is not null)
|
||||
{
|
||||
return EndpointResults.BadRequest(outcome.Error);
|
||||
}
|
||||
|
||||
if (!outcome.Exists)
|
||||
{
|
||||
return EndpointResults.NotFound(CardNotFoundDetail);
|
||||
}
|
||||
|
||||
// Аудит переноса карточки (этап 10, T1): цель — минимальный безопасный идентификатор.
|
||||
await AuditAppender.AppendTenantAsync(context, AuditEvents.CardMoved, new { cardId, to = body.To }, ct);
|
||||
|
||||
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
|
||||
CardDto? unified = await cardsService.GetCardAsync(cardId, ct);
|
||||
return unified is null
|
||||
? EndpointResults.NotFound(CardNotFoundDetail)
|
||||
: Results.Ok(unified);
|
||||
}
|
||||
|
||||
// POST /api/cards/{cardId}/trash: в корзину + обучение ML spam (L203–207); ответ {ok:true}; 404.
|
||||
private static async Task<IResult> TrashAsync(
|
||||
string cardId,
|
||||
HttpContext context,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
|
||||
CardDto? card = await cardsService.TrashCardAsync(cardId, ct);
|
||||
if (card is null)
|
||||
{
|
||||
return EndpointResults.NotFound(CardNotFoundDetail);
|
||||
}
|
||||
|
||||
// Аудит отправки карточки в корзину (этап 10, T1).
|
||||
await AuditAppender.AppendTenantAsync(context, AuditEvents.CardTrashed, new { cardId }, ct);
|
||||
return Results.Ok(new { ok = true });
|
||||
}
|
||||
|
||||
// POST /api/cards/{cardId}/restore: возврат из архив/корзины на канбан (L210–214); ответ {ok, col}; 404.
|
||||
private static async Task<IResult> RestoreAsync(
|
||||
string cardId,
|
||||
HttpContext context,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
|
||||
string? col = await cardsService.RestoreCardAsync(cardId, ct);
|
||||
if (col is null)
|
||||
{
|
||||
return EndpointResults.NotFound(CardNotFoundDetail);
|
||||
}
|
||||
|
||||
// Аудит возврата карточки из корзины/архива (этап 10, T1).
|
||||
await AuditAppender.AppendTenantAsync(context, AuditEvents.CardRestored, new { cardId, col }, ct);
|
||||
return Results.Ok(new { ok = true, col });
|
||||
}
|
||||
|
||||
// DELETE /api/cards/{cardId}: удалить навсегда (Cards + комментарии; L217–221); ответ {ok:true}; 404.
|
||||
private static async Task<IResult> DeleteAsync(
|
||||
string cardId,
|
||||
HttpContext context,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
|
||||
bool deleted = await cardsService.DeleteForeverAsync(cardId, ct);
|
||||
if (!deleted)
|
||||
{
|
||||
return EndpointResults.NotFound(CardNotFoundDetail);
|
||||
}
|
||||
|
||||
// Аудит удаления карточки навсегда (этап 10, T1).
|
||||
await AuditAppender.AppendTenantAsync(context, AuditEvents.CardDeleted, new { cardId }, ct);
|
||||
return Results.Ok(new { ok = true });
|
||||
}
|
||||
|
||||
// POST /api/cards/clear-col {col}: очистить корзину/архив (L228–235); ответ {ok, cleared}; 400.
|
||||
private static async Task<IResult> ClearColAsync(
|
||||
ClearColBody body,
|
||||
HttpContext context,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
|
||||
ClearColResultDto result = await cardsService.ClearColAsync(body.Col ?? string.Empty, ct);
|
||||
return result.Error is not null
|
||||
? EndpointResults.BadRequest(result.Error)
|
||||
: Results.Ok(new { ok = true, cleared = result.Cleared });
|
||||
}
|
||||
|
||||
// POST /api/cards/{cardId}/comments {text}: добавить комментарий (L238–242); ответ {comments}; 400 «Пустой комментарий»; 404.
|
||||
private static async Task<IResult> AddCommentAsync(
|
||||
string cardId,
|
||||
CommentBody body,
|
||||
HttpContext context,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
|
||||
AddCommentResultDto result = await cardsService.AddCommentAsync(cardId, body.Text ?? string.Empty, ct);
|
||||
if (result.Error is not null)
|
||||
{
|
||||
return EndpointResults.BadRequest(result.Error);
|
||||
}
|
||||
|
||||
if (result.Comments is null)
|
||||
{
|
||||
return EndpointResults.NotFound(CardNotFoundDetail);
|
||||
}
|
||||
|
||||
// Аудит добавления комментария (этап 10, T1): текст комментария в детали не пишется.
|
||||
await AuditAppender.AppendTenantAsync(context, AuditEvents.CardCommentAdded, new { cardId }, ct);
|
||||
return Results.Ok(new { comments = result.Comments });
|
||||
}
|
||||
|
||||
// POST /api/cards/reclassify: переклассификация «Неразобранного» (все карточки либо ids).
|
||||
// Тело ids опционально (фронт шлёт запрос без тела — все карточки inbox). Проход синхронный; при занятом
|
||||
// проходе ответ {started:false, busy:true}. Поля started/busy/attempted сохранены ради совместимости,
|
||||
// добавлены reclassified/moved/kept/trashed/skipped/usedAi/reason. Аудит — card_reclassified; после
|
||||
// успешного прохода (reclassified > 0) публикуется SSE cards_reclassified {reclassified,moved}.
|
||||
// body: Тело запроса (ids — опционально).
|
||||
// context: Контекст запроса.
|
||||
// ct: Токен отмены.
|
||||
private static async Task<IResult> ReclassifyAsync(
|
||||
ReclassifyBody? body,
|
||||
HttpContext context,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
CardReclassifier reclassifier = context.RequestServices.GetRequiredService<CardReclassifier>();
|
||||
ReclassifyResultDto result = await reclassifier.ReclassifyInboxAsync(body?.Ids, ct);
|
||||
await AppendReclassifyAuditAsync(context, result, ct);
|
||||
PublishReclassified(context, result);
|
||||
return Results.Ok(ToReclassifyWire(result));
|
||||
}
|
||||
|
||||
// POST /api/cards/{cardId}/reclassify: переклассификация одной карточки; 404 «Карточка не найдена».
|
||||
// cardId: Id карточки (c_...).
|
||||
// context: Контекст запроса.
|
||||
// ct: Токен отмены.
|
||||
private static async Task<IResult> ReclassifyOneAsync(
|
||||
string cardId,
|
||||
HttpContext context,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
|
||||
CardDto? card = await cardsService.GetCardAsync(cardId, ct);
|
||||
if (card is null)
|
||||
{
|
||||
return EndpointResults.NotFound(CardNotFoundDetail);
|
||||
}
|
||||
|
||||
CardReclassifier reclassifier = context.RequestServices.GetRequiredService<CardReclassifier>();
|
||||
ReclassifyResultDto result = await reclassifier.ReclassifyCardAsync(card, ct);
|
||||
await AppendReclassifyAuditAsync(context, result, ct);
|
||||
PublishReclassified(context, result);
|
||||
return Results.Ok(ToReclassifyWire(result));
|
||||
}
|
||||
|
||||
// Wire-форма итога переклассификации (camelCase; сохранены started/busy/attempted).
|
||||
// result: Итог прохода.
|
||||
// Возвращает: Объект ответа эндпоинта.
|
||||
private static object ToReclassifyWire(ReclassifyResultDto result) => new
|
||||
{
|
||||
started = result.Started,
|
||||
busy = result.Busy,
|
||||
attempted = result.Attempted,
|
||||
reclassified = result.Reclassified,
|
||||
moved = result.Moved,
|
||||
kept = result.Kept,
|
||||
trashed = result.Trashed,
|
||||
skipped = result.Skipped,
|
||||
usedAi = result.UsedAi,
|
||||
reason = result.Reason,
|
||||
};
|
||||
|
||||
// Публикует SSE cards_reclassified после успешного прохода (Ruling 5: публикации — из Api).
|
||||
// Публикуется только когда проход реально выполнен и что-то изменил (started и
|
||||
// reclassified > 0): пустой inbox/всё пропущено не меняют доску — событие не шлём. Нагрузка
|
||||
// минимальная: сколько обработано и перемещено (фронт перечитывает доску). Без подписчиков — no-op.
|
||||
// context: Контекст запроса (тенант-канал сессии).
|
||||
// result: Итог прохода.
|
||||
private static void PublishReclassified(HttpContext context, ReclassifyResultDto result)
|
||||
{
|
||||
if (!result.Started || result.Reclassified == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
SseBroker broker = context.RequestServices.GetRequiredService<SseBroker>();
|
||||
broker.Publish(
|
||||
context.GetCurrentUser()!.TenantId,
|
||||
ReclassifiedEventType,
|
||||
new { reclassified = result.Reclassified, moved = result.Moved });
|
||||
}
|
||||
|
||||
// Аудит переклассификации: пишется только когда проход реально что-то изменил.
|
||||
// context: Контекст запроса.
|
||||
// result: Итог прохода.
|
||||
// ct: Токен отмены.
|
||||
private static Task AppendReclassifyAuditAsync(
|
||||
HttpContext context,
|
||||
ReclassifyResultDto result,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (result.Reclassified == 0)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
return AuditAppender.AppendTenantAsync(
|
||||
context,
|
||||
AuditEvents.CardReclassified,
|
||||
new { attempted = result.Attempted, reclassified = result.Reclassified, moved = result.Moved, trashed = result.Trashed },
|
||||
ct);
|
||||
}
|
||||
|
||||
// GET /api/search?q=: поиск карточек (FTS + LIKE, Ruling 6/Task 12; dashboard_routes L254–256). Ответ {leads, messages: []}.
|
||||
private static async Task<IResult> SearchAsync(
|
||||
string? q,
|
||||
HttpContext context,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
|
||||
IReadOnlyList<CardDto> leads = await cardsService.SearchCardsAsync(q, ct);
|
||||
|
||||
// messages всегда []: telegram-сообщений здесь нет, фронт их не читает.
|
||||
return Results.Ok(new { cards = leads, messages = Array.Empty<object>() });
|
||||
}
|
||||
|
||||
// ── Внутреннее ─────────────────────────────────────────────────────────
|
||||
|
||||
// Существует ли контейнер с таким id (служебная зона/стадия/доска).
|
||||
// col: Значение query-параметра containerId (непустое).
|
||||
// context: Контекст запроса (для резолва ContainersService).
|
||||
// ct: Токен отмены.
|
||||
// Возвращает: True — контейнер допустим для фильтра.
|
||||
private static async Task<bool> IsKnownContainerAsync(
|
||||
string col,
|
||||
HttpContext context,
|
||||
CancellationToken ct)
|
||||
{
|
||||
ContainersService containers = context.RequestServices.GetRequiredService<ContainersService>();
|
||||
return await containers.GetAsync(col, ct) is not null;
|
||||
}
|
||||
}
|
||||
using Deal.Api.Endpoints.RequestModels;
|
||||
using Deal.Api.Events;
|
||||
using Deal.Api.Extensions;
|
||||
using Deal.Api.Services;
|
||||
using Deal.Modules.Cards.Application.Abstractions;
|
||||
using Deal.Modules.Cards.Application.Dtos;
|
||||
using Deal.Modules.Cards.Application.Models;
|
||||
using Deal.Modules.Kanban.Application.Models;
|
||||
using Deal.Modules.Kanban.Application.Services;
|
||||
using Deal.Modules.Pipeline.Application.Models;
|
||||
using Deal.Modules.Pipeline.Application.Services;
|
||||
using Deal.Modules.Tenants.Application.Models;
|
||||
|
||||
namespace Deal.Api.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Эндпоинты карточек и поиска: GET /api/cards[?containerId=], /cards/counts, /cards/{id},
|
||||
/// mark-all-seen/mark-col-seen, move/trash/restore/DELETE, clear-col, comments, reclassify (batch + {id}),
|
||||
/// GET /api/search (этап 9, T6).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Единый контракт /api/cards (R5): старые ручки /api/leads, /api/projects, /api/boards и /api/columns
|
||||
/// упразднены. GET /cards →
|
||||
/// {items}; counts — плоская wire-форма {new, <col>: {count, new}, learning, ml, ai}; move → обновлённая
|
||||
/// карточка; restore → {ok, col}; clear-col → {ok, cleared}; comments → {comments}; search →
|
||||
/// {cards, messages: []}. 400 «Неизвестный контейнер» при несуществующем containerId; 404 «Карточка не
|
||||
/// найдена» — null-результаты сервисов, 400-тексты — константы CardsService.
|
||||
/// ⚠ Статические сегменты (counts, mark-all-seen, mark-col-seen, clear-col, reclassify) регистрируются ДО
|
||||
/// /cards/{cardId}. Все эндпоинты требуют сессию: 401 {detail}; сервисы резолвятся из RequestServices
|
||||
/// ПОСЛЕ проверки сессии.
|
||||
/// </remarks>
|
||||
public static class CardsEndpoints
|
||||
{
|
||||
// Префикс группы карточек.
|
||||
private const string CardsGroupPrefix = "/api/cards";
|
||||
|
||||
// Префикс группы поиска (единственный эндпоинт группы — /api/search).
|
||||
private const string ApiGroupPrefix = "/api";
|
||||
|
||||
// Путь поиска (GET).
|
||||
private const string SearchPath = "/search";
|
||||
|
||||
// OpenAPI-тег группы (в прототипе роутер dashboard — dashboard_routes.py).
|
||||
private const string OpenApiTag = "dashboard";
|
||||
|
||||
// 404: карточка не найдена (dashboard_routes.py _lead_or_404 L92–96).
|
||||
private const string CardNotFoundDetail = "Карточка не найдена";
|
||||
|
||||
// 400 GET /cards: containerId не существует.
|
||||
private const string UnknownColumnDetail = "Неизвестный контейнер";
|
||||
|
||||
// Инициатор перехода при ручном переносе — действие пользователя (R4 этапа 9).
|
||||
private const string UserActor = "user";
|
||||
|
||||
// SSE-тип события завершения переклассификации (этап 12, остаток 2; api.js слушает 'cards_reclassified').
|
||||
private const string ReclassifiedEventType = "cards_reclassified";
|
||||
|
||||
// Контекст ручного перехода карточки: пользователь, обучение ML по цели переноса.
|
||||
private static readonly TransitionContext UserMoveContext = new() { Actor = UserActor, Learn = true };
|
||||
|
||||
/// <summary>
|
||||
/// Регистрирует группы /api/cards и /api (карточки + поиск). Статические сегменты — до /cards/{cardId}.
|
||||
/// </summary>
|
||||
/// <param name="app">Построитель маршрутов приложения.</param>
|
||||
/// <returns>Построитель маршрутов для цепочки вызовов.</returns>
|
||||
public static IEndpointRouteBuilder MapCardsEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var leads = app.MapGroup(CardsGroupPrefix).WithTags(OpenApiTag);
|
||||
|
||||
// Статические сегменты ДО /cards/{cardId}: ASP.NET Core отдаёт приоритет литералам, порядок регистрации
|
||||
// сохранён для читаемости.
|
||||
leads.MapGet("", ListCardsAsync);
|
||||
leads.MapGet("/counts", CountsAsync);
|
||||
leads.MapPost("/mark-all-seen", MarkAllSeenAsync);
|
||||
leads.MapPost("/mark-col-seen", MarkColSeenAsync);
|
||||
leads.MapPost("/clear-col", ClearColAsync);
|
||||
leads.MapPost("/reclassify", ReclassifyAsync);
|
||||
leads.MapGet("/{cardId}", GetCardAsync);
|
||||
leads.MapPost("/{cardId}/reclassify", ReclassifyOneAsync);
|
||||
leads.MapPost("/{cardId}/move", MoveAsync);
|
||||
leads.MapPost("/{cardId}/trash", TrashAsync);
|
||||
leads.MapPost("/{cardId}/restore", RestoreAsync);
|
||||
leads.MapDelete("/{cardId}", DeleteAsync);
|
||||
leads.MapPost("/{cardId}/comments", AddCommentAsync);
|
||||
|
||||
app.MapGroup(ApiGroupPrefix).WithTags(OpenApiTag).MapGet(SearchPath, SearchAsync);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
// GET /api/cards?containerId=: карточки контейнера (или все карточки дашборда); 400 «Неизвестный контейнер».
|
||||
// Параметр col принят как алиас containerId (совместимость со старым фронтом).
|
||||
private static async Task<IResult> ListCardsAsync(
|
||||
string? containerId,
|
||||
string? col,
|
||||
HttpContext context,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
string? target = string.IsNullOrEmpty(containerId) ? col : containerId;
|
||||
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
|
||||
if (!string.IsNullOrEmpty(target) && !await IsKnownContainerAsync(target, context, ct))
|
||||
{
|
||||
return EndpointResults.BadRequest(UnknownColumnDetail);
|
||||
}
|
||||
|
||||
IReadOnlyList<CardDto> cards = await cardsService.ListCardsAsync(target, ct);
|
||||
return Results.Ok(new { items = cards });
|
||||
}
|
||||
|
||||
// GET /api/cards/counts: плоская wire-форма счётчиков {new, <col>:{count,new}, learning, ml, ai} (L161–163, §4.1 L257).
|
||||
private static async Task<IResult> CountsAsync(HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
|
||||
CardCountsDto counts = await cardsService.CountsAsync(ct);
|
||||
|
||||
// Разворачивание CardCountsDto: колонки — корневые ключи (counts L268–279), служебные — фиксированные.
|
||||
var wire = new Dictionary<string, object> { ["new"] = counts.New };
|
||||
foreach ((string col, CardColumnCountDto column) in counts.Columns)
|
||||
{
|
||||
wire[col] = column;
|
||||
}
|
||||
|
||||
wire["learning"] = counts.Learning;
|
||||
wire["ml"] = counts.Ml;
|
||||
wire["ai"] = counts.Ai;
|
||||
return Results.Ok(wire);
|
||||
}
|
||||
|
||||
// GET /api/cards/{cardId}: одна карточка; 404 «Карточка не найдена» (L166–168).
|
||||
private static async Task<IResult> GetCardAsync(
|
||||
string cardId,
|
||||
HttpContext context,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
|
||||
CardDto? card = await cardsService.GetCardAsync(cardId, ct);
|
||||
return card is null
|
||||
? EndpointResults.NotFound(CardNotFoundDetail)
|
||||
: Results.Ok(card);
|
||||
}
|
||||
|
||||
// POST /api/cards/mark-all-seen: снять «новое» со всех карточек (L177–180); ответ {ok:true}.
|
||||
private static async Task<IResult> MarkAllSeenAsync(HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
|
||||
await cardsService.MarkSeenAsync(cardId: null, col: null, ct);
|
||||
return Results.Ok(new { ok = true });
|
||||
}
|
||||
|
||||
// POST /api/cards/mark-col-seen: снять «новое» с колонки (L187–191); ответ {ok:true}.
|
||||
private static async Task<IResult> MarkColSeenAsync(
|
||||
MarkColBody body,
|
||||
HttpContext context,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
if (body.Col is null)
|
||||
{
|
||||
// Пустая/отсутствующая col попала бы в mark_seen как «не задана» и сняла бы «новое» со ВСЕХ
|
||||
// карточек (truthiness python, L250–256) — эндпоинт защищает от вызова с null (прототип: 422).
|
||||
return EndpointResults.BadRequest(UnknownColumnDetail);
|
||||
}
|
||||
|
||||
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
|
||||
await cardsService.MarkSeenAsync(cardId: null, col: body.Col, ct);
|
||||
return Results.Ok(new { ok = true });
|
||||
}
|
||||
|
||||
// POST /api/cards/{cardId}/move {to}: перенос карточки между контейнерами (этап 9, R4).
|
||||
// Маршрутизацию цели (стадия «Выбранных» vs дашборд-контейнер) и побочные эффекты выполняет единый
|
||||
// доменный механизм перехода ICardMover: стадия — запись истории и сброс напоминания
|
||||
// (move_stage), дашборд-контейнер — журнал/обучение ML. Ответ — обновлённая карточка; 400 при
|
||||
// несуществующем контейнере, 404 — карточки нет.
|
||||
private static async Task<IResult> MoveAsync(
|
||||
string cardId,
|
||||
MoveBody body,
|
||||
HttpContext context,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
ICardMover mover = context.RequestServices.GetRequiredService<ICardMover>();
|
||||
CardMoveResultDto outcome = await mover.MoveAsync(cardId, body.To ?? string.Empty, UserMoveContext, ct);
|
||||
if (outcome.Error is not null)
|
||||
{
|
||||
return EndpointResults.BadRequest(outcome.Error);
|
||||
}
|
||||
|
||||
if (!outcome.Exists)
|
||||
{
|
||||
return EndpointResults.NotFound(CardNotFoundDetail);
|
||||
}
|
||||
|
||||
// Аудит переноса карточки (этап 10, T1): цель — минимальный безопасный идентификатор.
|
||||
await AuditAppender.AppendTenantAsync(context, AuditEvents.CardMoved, new { cardId, to = body.To }, ct);
|
||||
|
||||
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
|
||||
CardDto? unified = await cardsService.GetCardAsync(cardId, ct);
|
||||
return unified is null
|
||||
? EndpointResults.NotFound(CardNotFoundDetail)
|
||||
: Results.Ok(unified);
|
||||
}
|
||||
|
||||
// POST /api/cards/{cardId}/trash: в корзину + обучение ML spam (L203–207); ответ {ok:true}; 404.
|
||||
private static async Task<IResult> TrashAsync(
|
||||
string cardId,
|
||||
HttpContext context,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
|
||||
CardDto? card = await cardsService.TrashCardAsync(cardId, ct);
|
||||
if (card is null)
|
||||
{
|
||||
return EndpointResults.NotFound(CardNotFoundDetail);
|
||||
}
|
||||
|
||||
// Аудит отправки карточки в корзину (этап 10, T1).
|
||||
await AuditAppender.AppendTenantAsync(context, AuditEvents.CardTrashed, new { cardId }, ct);
|
||||
return Results.Ok(new { ok = true });
|
||||
}
|
||||
|
||||
// POST /api/cards/{cardId}/restore: возврат из архив/корзины на канбан (L210–214); ответ {ok, col}; 404.
|
||||
private static async Task<IResult> RestoreAsync(
|
||||
string cardId,
|
||||
HttpContext context,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
|
||||
string? col = await cardsService.RestoreCardAsync(cardId, ct);
|
||||
if (col is null)
|
||||
{
|
||||
return EndpointResults.NotFound(CardNotFoundDetail);
|
||||
}
|
||||
|
||||
// Аудит возврата карточки из корзины/архива (этап 10, T1).
|
||||
await AuditAppender.AppendTenantAsync(context, AuditEvents.CardRestored, new { cardId, col }, ct);
|
||||
return Results.Ok(new { ok = true, col });
|
||||
}
|
||||
|
||||
// DELETE /api/cards/{cardId}: удалить навсегда (Cards + комментарии; L217–221); ответ {ok:true}; 404.
|
||||
private static async Task<IResult> DeleteAsync(
|
||||
string cardId,
|
||||
HttpContext context,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
|
||||
bool deleted = await cardsService.DeleteForeverAsync(cardId, ct);
|
||||
if (!deleted)
|
||||
{
|
||||
return EndpointResults.NotFound(CardNotFoundDetail);
|
||||
}
|
||||
|
||||
// Аудит удаления карточки навсегда (этап 10, T1).
|
||||
await AuditAppender.AppendTenantAsync(context, AuditEvents.CardDeleted, new { cardId }, ct);
|
||||
return Results.Ok(new { ok = true });
|
||||
}
|
||||
|
||||
// POST /api/cards/clear-col {col}: очистить корзину/архив (L228–235); ответ {ok, cleared}; 400.
|
||||
private static async Task<IResult> ClearColAsync(
|
||||
ClearColBody body,
|
||||
HttpContext context,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
|
||||
ClearColResultDto result = await cardsService.ClearColAsync(body.Col ?? string.Empty, ct);
|
||||
return result.Error is not null
|
||||
? EndpointResults.BadRequest(result.Error)
|
||||
: Results.Ok(new { ok = true, cleared = result.Cleared });
|
||||
}
|
||||
|
||||
// POST /api/cards/{cardId}/comments {text}: добавить комментарий (L238–242); ответ {comments}; 400 «Пустой комментарий»; 404.
|
||||
private static async Task<IResult> AddCommentAsync(
|
||||
string cardId,
|
||||
CommentBody body,
|
||||
HttpContext context,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
|
||||
AddCommentResultDto result = await cardsService.AddCommentAsync(cardId, body.Text ?? string.Empty, ct);
|
||||
if (result.Error is not null)
|
||||
{
|
||||
return EndpointResults.BadRequest(result.Error);
|
||||
}
|
||||
|
||||
if (result.Comments is null)
|
||||
{
|
||||
return EndpointResults.NotFound(CardNotFoundDetail);
|
||||
}
|
||||
|
||||
// Аудит добавления комментария (этап 10, T1): текст комментария в детали не пишется.
|
||||
await AuditAppender.AppendTenantAsync(context, AuditEvents.CardCommentAdded, new { cardId }, ct);
|
||||
return Results.Ok(new { comments = result.Comments });
|
||||
}
|
||||
|
||||
// POST /api/cards/reclassify: переклассификация «Неразобранного» (все карточки либо ids).
|
||||
// Тело ids опционально (фронт шлёт запрос без тела — все карточки inbox). Проход синхронный; при занятом
|
||||
// проходе ответ {started:false, busy:true}. Поля started/busy/attempted сохранены ради совместимости,
|
||||
// добавлены reclassified/moved/kept/trashed/skipped/usedAi/reason. Аудит — card_reclassified; после
|
||||
// успешного прохода (reclassified > 0) публикуется SSE cards_reclassified {reclassified,moved}.
|
||||
// body: Тело запроса (ids — опционально).
|
||||
// context: Контекст запроса.
|
||||
// ct: Токен отмены.
|
||||
private static async Task<IResult> ReclassifyAsync(
|
||||
ReclassifyBody? body,
|
||||
HttpContext context,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
CardReclassifier reclassifier = context.RequestServices.GetRequiredService<CardReclassifier>();
|
||||
ReclassifyResultDto result = await reclassifier.ReclassifyInboxAsync(body?.Ids, ct);
|
||||
await AppendReclassifyAuditAsync(context, result, ct);
|
||||
PublishReclassified(context, result);
|
||||
return Results.Ok(ToReclassifyWire(result));
|
||||
}
|
||||
|
||||
// POST /api/cards/{cardId}/reclassify: переклассификация одной карточки; 404 «Карточка не найдена».
|
||||
// cardId: Id карточки (c_...).
|
||||
// context: Контекст запроса.
|
||||
// ct: Токен отмены.
|
||||
private static async Task<IResult> ReclassifyOneAsync(
|
||||
string cardId,
|
||||
HttpContext context,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
|
||||
CardDto? card = await cardsService.GetCardAsync(cardId, ct);
|
||||
if (card is null)
|
||||
{
|
||||
return EndpointResults.NotFound(CardNotFoundDetail);
|
||||
}
|
||||
|
||||
CardReclassifier reclassifier = context.RequestServices.GetRequiredService<CardReclassifier>();
|
||||
ReclassifyResultDto result = await reclassifier.ReclassifyCardAsync(card, ct);
|
||||
await AppendReclassifyAuditAsync(context, result, ct);
|
||||
PublishReclassified(context, result);
|
||||
return Results.Ok(ToReclassifyWire(result));
|
||||
}
|
||||
|
||||
// Wire-форма итога переклассификации (camelCase; сохранены started/busy/attempted).
|
||||
// result: Итог прохода.
|
||||
// Возвращает: Объект ответа эндпоинта.
|
||||
private static object ToReclassifyWire(ReclassifyResultDto result) => new
|
||||
{
|
||||
started = result.Started,
|
||||
busy = result.Busy,
|
||||
attempted = result.Attempted,
|
||||
reclassified = result.Reclassified,
|
||||
moved = result.Moved,
|
||||
kept = result.Kept,
|
||||
trashed = result.Trashed,
|
||||
skipped = result.Skipped,
|
||||
usedAi = result.UsedAi,
|
||||
reason = result.Reason,
|
||||
};
|
||||
|
||||
// Публикует SSE cards_reclassified после успешного прохода (Ruling 5: публикации — из Api).
|
||||
// Публикуется только когда проход реально выполнен и что-то изменил (started и
|
||||
// reclassified > 0): пустой inbox/всё пропущено не меняют доску — событие не шлём. Нагрузка
|
||||
// минимальная: сколько обработано и перемещено (фронт перечитывает доску). Без подписчиков — no-op.
|
||||
// context: Контекст запроса (тенант-канал сессии).
|
||||
// result: Итог прохода.
|
||||
private static void PublishReclassified(HttpContext context, ReclassifyResultDto result)
|
||||
{
|
||||
if (!result.Started || result.Reclassified == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
SseBroker broker = context.RequestServices.GetRequiredService<SseBroker>();
|
||||
broker.Publish(
|
||||
context.GetCurrentUser()!.TenantId,
|
||||
ReclassifiedEventType,
|
||||
new { reclassified = result.Reclassified, moved = result.Moved });
|
||||
}
|
||||
|
||||
// Аудит переклассификации: пишется только когда проход реально что-то изменил.
|
||||
// context: Контекст запроса.
|
||||
// result: Итог прохода.
|
||||
// ct: Токен отмены.
|
||||
private static Task AppendReclassifyAuditAsync(
|
||||
HttpContext context,
|
||||
ReclassifyResultDto result,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (result.Reclassified == 0)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
return AuditAppender.AppendTenantAsync(
|
||||
context,
|
||||
AuditEvents.CardReclassified,
|
||||
new { attempted = result.Attempted, reclassified = result.Reclassified, moved = result.Moved, trashed = result.Trashed },
|
||||
ct);
|
||||
}
|
||||
|
||||
// GET /api/search?q=: поиск карточек (FTS + LIKE, Ruling 6/Task 12; dashboard_routes L254–256). Ответ {leads, messages: []}.
|
||||
private static async Task<IResult> SearchAsync(
|
||||
string? q,
|
||||
HttpContext context,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
|
||||
IReadOnlyList<CardDto> leads = await cardsService.SearchCardsAsync(q, ct);
|
||||
|
||||
// messages всегда []: telegram-сообщений здесь нет, фронт их не читает.
|
||||
return Results.Ok(new { cards = leads, messages = Array.Empty<object>() });
|
||||
}
|
||||
|
||||
// ── Внутреннее ─────────────────────────────────────────────────────────
|
||||
|
||||
// Существует ли контейнер с таким id (служебная зона/стадия/доска).
|
||||
// col: Значение query-параметра containerId (непустое).
|
||||
// context: Контекст запроса (для резолва ContainersService).
|
||||
// ct: Токен отмены.
|
||||
// Возвращает: True — контейнер допустим для фильтра.
|
||||
private static async Task<bool> IsKnownContainerAsync(
|
||||
string col,
|
||||
HttpContext context,
|
||||
CancellationToken ct)
|
||||
{
|
||||
ContainersService containers = context.RequestServices.GetRequiredService<ContainersService>();
|
||||
return await containers.GetAsync(col, ct) is not null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
using Deal.Api.Services;
|
||||
using Deal.Api.Dtos;
|
||||
|
||||
namespace Deal.Api.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Тело POST /api/auth/change-password. Входящий JSON — camelCase (oldPassword, newPassword).
|
||||
/// </summary>
|
||||
/// <param name="OldPassword">Текущий пароль.</param>
|
||||
/// <param name="NewPassword">Новый пароль (минимум 8 символов).</param>
|
||||
public sealed record ChangePasswordRequest(string OldPassword, string NewPassword);
|
||||
namespace Deal.Api.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Тело POST /api/auth/change-password. Входящий JSON — camelCase (oldPassword, newPassword).
|
||||
/// </summary>
|
||||
/// <param name="OldPassword">Текущий пароль.</param>
|
||||
/// <param name="NewPassword">Новый пароль (минимум 8 символов).</param>
|
||||
public sealed record ChangePasswordRequest(string OldPassword, string NewPassword);
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
using Deal.Api.Services;
|
||||
using Deal.Api.Dtos;
|
||||
|
||||
namespace Deal.Api.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Тело POST /api/admin/check-message (1:1 с CheckMessageBody, dashboard_routes.py L72–73).
|
||||
/// </summary>
|
||||
/// <param name="Text">Текст сообщения для проверки фильтром (этап 1 + этап 2 тестера).</param>
|
||||
public sealed record CheckMessageRequest(string Text);
|
||||
namespace Deal.Api.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Тело POST /api/admin/check-message (1:1 с CheckMessageBody, dashboard_routes.py L72–73).
|
||||
/// </summary>
|
||||
/// <param name="Text">Текст сообщения для проверки фильтром (этап 1 + этап 2 тестера).</param>
|
||||
public sealed record CheckMessageRequest(string Text);
|
||||
|
||||
@@ -1,332 +1,323 @@
|
||||
using System.Text.Json;
|
||||
using Deal.Api.Endpoints.RequestModels;
|
||||
using Deal.Api.Extensions;
|
||||
using Deal.Api.Models;
|
||||
using Deal.Api.Services;
|
||||
using Deal.Modules.Kanban.Application.Abstractions;
|
||||
using Deal.Modules.Kanban.Application.Extensions;
|
||||
using Deal.Modules.Kanban.Application.Models;
|
||||
using Deal.Modules.Kanban.Application.Registrars;
|
||||
using Deal.Modules.Kanban.Application.Services;
|
||||
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.Dtos;
|
||||
|
||||
namespace Deal.Api.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Эндпоинты контейнеров (колонок/стадий/зон) и состояния колонок: GET/POST /api/containers,
|
||||
/// PATCH /{id}/accept, PATCH/DELETE /{id}, POST /reorder, GET/PATCH state (этап 9, T4/T6).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Единый реестр контейнеров приходит на смену /api/boards + /api/columns (R5): список, создание,
|
||||
/// частичное обновление, принятие ИИ-предложения, удаление с переносом карточек в inbox, reorder и
|
||||
/// состояние колонок (colState). Все эндпоинты требуют сессию: 401 {detail}. ContainersService
|
||||
/// резолвится из RequestServices ПОСЛЕ проверки сессии.
|
||||
/// </remarks>
|
||||
public static class ContainersEndpoints
|
||||
{
|
||||
// Префикс группы контейнеров.
|
||||
private const string ContainersGroupPrefix = "/api/containers";
|
||||
|
||||
// OpenAPI-тег группы.
|
||||
private const string OpenApiTag = "containers";
|
||||
|
||||
// 404 PATCH/accept: контейнер не найден.
|
||||
private const string ContainerNotFoundDetail = "Контейнер не найден";
|
||||
|
||||
// 400: отсутствующий/явный null name контейнера.
|
||||
private const string ContainerNameRequiredDetail = "Укажите название колонки";
|
||||
|
||||
// 400 reorder: отсутствующий/явный null order.
|
||||
private const string ContainerOrderRequiredDetail = "Не указан порядок колонок";
|
||||
|
||||
// 400 PATCH: тело не JSON-объект.
|
||||
private const string InvalidBodyDetail = "Тело запроса должно быть JSON-объектом";
|
||||
|
||||
// Опции разбора PATCH-тела: web-дефолты (camelCase + регистронезависимость).
|
||||
private static readonly JsonSerializerOptions RequestJsonOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
/// <summary>
|
||||
/// Регистрирует группы /api/containers (контейнеры + состояние колонок).
|
||||
/// </summary>
|
||||
/// <param name="app">Построитель маршрутов приложения.</param>
|
||||
/// <returns>Построитель маршрутов для цепочки вызовов.</returns>
|
||||
public static IEndpointRouteBuilder MapContainersEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var containers = app.MapGroup(ContainersGroupPrefix).WithTags(OpenApiTag);
|
||||
containers.MapGet("", ListContainersAsync);
|
||||
containers.MapPost("", CreateContainerAsync);
|
||||
containers.MapPost("/reorder", ReorderContainersAsync);
|
||||
containers.MapGet("/state", GetColumnsStateAsync);
|
||||
containers.MapPatch("/{containerId}/state", PatchColumnStateAsync);
|
||||
containers.MapPost("/{containerId}/accept", AcceptSuggestedAsync);
|
||||
containers.MapPatch("/{containerId}", PatchContainerAsync);
|
||||
containers.MapDelete("/{containerId}", DeleteContainerAsync);
|
||||
return app;
|
||||
}
|
||||
|
||||
// GET /api/containers?space=: список контейнеров пространства (или всех) со счётчиками.
|
||||
private static async Task<IResult> ListContainersAsync(
|
||||
string? space,
|
||||
HttpContext context,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
ContainersService containers = context.RequestServices.GetRequiredService<ContainersService>();
|
||||
return Results.Ok(new { items = await containers.ListAsync(space, ct) });
|
||||
}
|
||||
|
||||
// POST /api/containers: создать контейнер; ответ {id}.
|
||||
private static async Task<IResult> CreateContainerAsync(
|
||||
ContainerCreateRequest body,
|
||||
HttpContext context,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
if (body.Name is null)
|
||||
{
|
||||
return EndpointResults.BadRequest(ContainerNameRequiredDetail);
|
||||
}
|
||||
|
||||
ContainersService containers = context.RequestServices.GetRequiredService<ContainersService>();
|
||||
ContainerDto created = await containers.CreateAsync(
|
||||
new ContainerCreateDto(
|
||||
Name: body.Name,
|
||||
Description: body.Description ?? string.Empty,
|
||||
Color: body.Color,
|
||||
Space: body.Space ?? ContainerSpaces.Dashboard,
|
||||
Kind: body.Kind ?? ContainerKinds.Board,
|
||||
Suggested: body.Suggested ?? false,
|
||||
Rules: NormalizeWireRules(body.Rules),
|
||||
Note: body.Note ?? string.Empty),
|
||||
ct);
|
||||
|
||||
// Аудит создания контейнера (этап 10, T1).
|
||||
await AuditAppender.AppendTenantAsync(context, AuditEvents.ContainerCreated, new { id = created.Id, name = created.Name }, ct);
|
||||
return Results.Ok(new { id = created.Id });
|
||||
}
|
||||
|
||||
// PATCH /api/containers/{id}: частичное обновление; ответ {id}; 404 «Контейнер не найден».
|
||||
private static async Task<IResult> PatchContainerAsync(
|
||||
string containerId,
|
||||
JsonElement body,
|
||||
HttpContext context,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
if (body.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
return EndpointResults.BadRequest(InvalidBodyDetail);
|
||||
}
|
||||
|
||||
foreach (JsonProperty property in body.EnumerateObject())
|
||||
{
|
||||
if (string.Equals(property.Name, "name", StringComparison.OrdinalIgnoreCase)
|
||||
&& property.Value.ValueKind == JsonValueKind.Null)
|
||||
{
|
||||
return EndpointResults.BadRequest(ContainerNameRequiredDetail);
|
||||
}
|
||||
}
|
||||
|
||||
ContainerPatchRequest? patchBody;
|
||||
try
|
||||
{
|
||||
patchBody = body.Deserialize<ContainerPatchRequest>(RequestJsonOptions);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return EndpointResults.BadRequest(InvalidBodyDetail);
|
||||
}
|
||||
|
||||
if (patchBody is null)
|
||||
{
|
||||
return EndpointResults.BadRequest(InvalidBodyDetail);
|
||||
}
|
||||
|
||||
ContainersService containers = context.RequestServices.GetRequiredService<ContainersService>();
|
||||
ContainerDto? updated = await containers.PatchAsync(
|
||||
containerId,
|
||||
new ContainerPatchDto(
|
||||
patchBody.Name,
|
||||
patchBody.Description,
|
||||
patchBody.Color,
|
||||
patchBody.Collapsed,
|
||||
patchBody.Suggested,
|
||||
patchBody.Note,
|
||||
NormalizeWireRules(patchBody.Rules),
|
||||
patchBody.Policy),
|
||||
ct);
|
||||
if (updated is null)
|
||||
{
|
||||
return EndpointResults.NotFound(ContainerNotFoundDetail);
|
||||
}
|
||||
|
||||
// Аудит изменения контейнера (этап 10, T1).
|
||||
await AuditAppender.AppendTenantAsync(context, AuditEvents.ContainerUpdated, new { id = updated.Id }, ct);
|
||||
return Results.Ok(new { id = updated.Id });
|
||||
}
|
||||
|
||||
// POST /api/containers/{id}/accept: принять ИИ-предложение (suggested=false).
|
||||
private static async Task<IResult> AcceptSuggestedAsync(
|
||||
string containerId,
|
||||
HttpContext context,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
ContainersService containers = context.RequestServices.GetRequiredService<ContainersService>();
|
||||
ContainerDto? accepted = await containers.AcceptSuggestedAsync(containerId, ct);
|
||||
if (accepted is null)
|
||||
{
|
||||
return EndpointResults.NotFound(ContainerNotFoundDetail);
|
||||
}
|
||||
|
||||
// Аудит изменения контейнера (принятие ИИ-предложения) — этап 10, T1.
|
||||
await AuditAppender.AppendTenantAsync(context, AuditEvents.ContainerUpdated, new { id = accepted.Id }, ct);
|
||||
return Results.Ok(accepted);
|
||||
}
|
||||
|
||||
// DELETE /api/containers/{id}: удалить контейнер; карточки → «Неразобранное» новыми.
|
||||
private static async Task<IResult> DeleteContainerAsync(
|
||||
string containerId,
|
||||
HttpContext context,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
ContainersService containers = context.RequestServices.GetRequiredService<ContainersService>();
|
||||
int moved = await containers.DeleteAsync(containerId, ct);
|
||||
|
||||
// Аудит удаления контейнера (этап 10, T1).
|
||||
await AuditAppender.AppendTenantAsync(context, AuditEvents.ContainerDeleted, new { id = containerId }, ct);
|
||||
return Results.Ok(new { ok = true, movedToInbox = moved });
|
||||
}
|
||||
|
||||
// POST /api/containers/reorder: порядок контейнеров пространства; ответ {ok:true}.
|
||||
private static async Task<IResult> ReorderContainersAsync(
|
||||
OrderBody body,
|
||||
HttpContext context,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
if (body.Order is null)
|
||||
{
|
||||
return EndpointResults.BadRequest(ContainerOrderRequiredDetail);
|
||||
}
|
||||
|
||||
ContainersService containers = context.RequestServices.GetRequiredService<ContainersService>();
|
||||
await containers.ReorderAsync(body.Space ?? ContainerSpaces.Dashboard, body.Order, ct);
|
||||
return Results.Ok(new { ok = true });
|
||||
}
|
||||
|
||||
// GET /api/containers/state: свёрнутость/ширина всех колонок (colState).
|
||||
private static async Task<IResult> GetColumnsStateAsync(HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
ContainersService containers = context.RequestServices.GetRequiredService<ContainersService>();
|
||||
IReadOnlyDictionary<string, ColumnStateDto> state = await containers.GetColStateAsync(ct);
|
||||
|
||||
var wire = new Dictionary<string, object>(StringComparer.Ordinal);
|
||||
foreach ((string colId, ColumnStateDto colState) in state)
|
||||
{
|
||||
wire[colId] = ToWireState(colState);
|
||||
}
|
||||
|
||||
return Results.Ok(wire);
|
||||
}
|
||||
|
||||
// PATCH /api/containers/{id}/state: merge патча в состояние колонки; ответ — состояние этой колонки.
|
||||
private static async Task<IResult> PatchColumnStateAsync(
|
||||
string containerId,
|
||||
ColStateBody body,
|
||||
HttpContext context,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
ContainersService containers = context.RequestServices.GetRequiredService<ContainersService>();
|
||||
ColumnStateDto merged = await containers.PatchColStateAsync(
|
||||
containerId,
|
||||
new ColumnStateDto(body.Collapsed, body.Width),
|
||||
ct);
|
||||
return Results.Ok(ToWireState(merged));
|
||||
}
|
||||
|
||||
// Правила из wire → каноничный ContainerRulesDto: отсутствующие группы становятся пустыми списками.
|
||||
// rules: Правила из тела запроса (null — «не меняются/нет правил»).
|
||||
// Возвращает: Каноничные правила с не-null группами; null — правил в теле нет.
|
||||
private static ContainerRulesDto? NormalizeWireRules(ContainerRulesDto? rules)
|
||||
{
|
||||
if (rules is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return rules with
|
||||
{
|
||||
Mode = rules.Mode ?? string.Empty,
|
||||
Direction = rules.Direction ?? Array.Empty<string>(),
|
||||
Keywords = rules.Keywords ?? Array.Empty<string>(),
|
||||
Stack = rules.Stack ?? Array.Empty<string>(),
|
||||
Grade = rules.Grade ?? Array.Empty<string>(),
|
||||
Exclude = rules.Exclude ?? Array.Empty<string>(),
|
||||
Budget = rules.Budget is null ? null : rules.Budget with { Cur = rules.Budget.Cur ?? string.Empty },
|
||||
Levels = rules.Levels ?? Array.Empty<string>(),
|
||||
Locations = rules.Locations ?? Array.Empty<string>(),
|
||||
Types = rules.Types ?? Array.Empty<string>(),
|
||||
Prices = rules.Prices is null ? null : rules.Prices with { Cur = rules.Prices.Cur ?? string.Empty },
|
||||
};
|
||||
}
|
||||
|
||||
// Состояние колонки → wire-объект только с заданными полями (collapsed/width), без null.
|
||||
// state: Состояние колонки (могут быть null-поля).
|
||||
// Возвращает: Словарь из не-null полей состояния.
|
||||
private static Dictionary<string, object> ToWireState(ColumnStateDto state)
|
||||
{
|
||||
var wire = new Dictionary<string, object>();
|
||||
if (state.Collapsed is { } collapsed)
|
||||
{
|
||||
wire["collapsed"] = collapsed;
|
||||
}
|
||||
|
||||
if (state.Width is not null)
|
||||
{
|
||||
wire["width"] = state.Width;
|
||||
}
|
||||
|
||||
return wire;
|
||||
}
|
||||
}
|
||||
using System.Text.Json;
|
||||
using Deal.Api.Endpoints.RequestModels;
|
||||
using Deal.Api.Extensions;
|
||||
using Deal.Api.Services;
|
||||
using Deal.Modules.Kanban.Application.Models;
|
||||
using Deal.Modules.Kanban.Application.Services;
|
||||
using Deal.Modules.Tenants.Application.Models;
|
||||
|
||||
namespace Deal.Api.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Эндпоинты контейнеров (колонок/стадий/зон) и состояния колонок: GET/POST /api/containers,
|
||||
/// PATCH /{id}/accept, PATCH/DELETE /{id}, POST /reorder, GET/PATCH state (этап 9, T4/T6).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Единый реестр контейнеров приходит на смену /api/boards + /api/columns (R5): список, создание,
|
||||
/// частичное обновление, принятие ИИ-предложения, удаление с переносом карточек в inbox, reorder и
|
||||
/// состояние колонок (colState). Все эндпоинты требуют сессию: 401 {detail}. ContainersService
|
||||
/// резолвится из RequestServices ПОСЛЕ проверки сессии.
|
||||
/// </remarks>
|
||||
public static class ContainersEndpoints
|
||||
{
|
||||
// Префикс группы контейнеров.
|
||||
private const string ContainersGroupPrefix = "/api/containers";
|
||||
|
||||
// OpenAPI-тег группы.
|
||||
private const string OpenApiTag = "containers";
|
||||
|
||||
// 404 PATCH/accept: контейнер не найден.
|
||||
private const string ContainerNotFoundDetail = "Контейнер не найден";
|
||||
|
||||
// 400: отсутствующий/явный null name контейнера.
|
||||
private const string ContainerNameRequiredDetail = "Укажите название колонки";
|
||||
|
||||
// 400 reorder: отсутствующий/явный null order.
|
||||
private const string ContainerOrderRequiredDetail = "Не указан порядок колонок";
|
||||
|
||||
// 400 PATCH: тело не JSON-объект.
|
||||
private const string InvalidBodyDetail = "Тело запроса должно быть JSON-объектом";
|
||||
|
||||
// Опции разбора PATCH-тела: web-дефолты (camelCase + регистронезависимость).
|
||||
private static readonly JsonSerializerOptions RequestJsonOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
/// <summary>
|
||||
/// Регистрирует группы /api/containers (контейнеры + состояние колонок).
|
||||
/// </summary>
|
||||
/// <param name="app">Построитель маршрутов приложения.</param>
|
||||
/// <returns>Построитель маршрутов для цепочки вызовов.</returns>
|
||||
public static IEndpointRouteBuilder MapContainersEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var containers = app.MapGroup(ContainersGroupPrefix).WithTags(OpenApiTag);
|
||||
containers.MapGet("", ListContainersAsync);
|
||||
containers.MapPost("", CreateContainerAsync);
|
||||
containers.MapPost("/reorder", ReorderContainersAsync);
|
||||
containers.MapGet("/state", GetColumnsStateAsync);
|
||||
containers.MapPatch("/{containerId}/state", PatchColumnStateAsync);
|
||||
containers.MapPost("/{containerId}/accept", AcceptSuggestedAsync);
|
||||
containers.MapPatch("/{containerId}", PatchContainerAsync);
|
||||
containers.MapDelete("/{containerId}", DeleteContainerAsync);
|
||||
return app;
|
||||
}
|
||||
|
||||
// GET /api/containers?space=: список контейнеров пространства (или всех) со счётчиками.
|
||||
private static async Task<IResult> ListContainersAsync(
|
||||
string? space,
|
||||
HttpContext context,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
ContainersService containers = context.RequestServices.GetRequiredService<ContainersService>();
|
||||
return Results.Ok(new { items = await containers.ListAsync(space, ct) });
|
||||
}
|
||||
|
||||
// POST /api/containers: создать контейнер; ответ {id}.
|
||||
private static async Task<IResult> CreateContainerAsync(
|
||||
ContainerCreateRequest body,
|
||||
HttpContext context,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
if (body.Name is null)
|
||||
{
|
||||
return EndpointResults.BadRequest(ContainerNameRequiredDetail);
|
||||
}
|
||||
|
||||
ContainersService containers = context.RequestServices.GetRequiredService<ContainersService>();
|
||||
ContainerDto created = await containers.CreateAsync(
|
||||
new ContainerCreateDto(
|
||||
Name: body.Name,
|
||||
Description: body.Description ?? string.Empty,
|
||||
Color: body.Color,
|
||||
Space: body.Space ?? ContainerSpaces.Dashboard,
|
||||
Kind: body.Kind ?? ContainerKinds.Board,
|
||||
Suggested: body.Suggested ?? false,
|
||||
Rules: NormalizeWireRules(body.Rules),
|
||||
Note: body.Note ?? string.Empty),
|
||||
ct);
|
||||
|
||||
// Аудит создания контейнера (этап 10, T1).
|
||||
await AuditAppender.AppendTenantAsync(context, AuditEvents.ContainerCreated, new { id = created.Id, name = created.Name }, ct);
|
||||
return Results.Ok(new { id = created.Id });
|
||||
}
|
||||
|
||||
// PATCH /api/containers/{id}: частичное обновление; ответ {id}; 404 «Контейнер не найден».
|
||||
private static async Task<IResult> PatchContainerAsync(
|
||||
string containerId,
|
||||
JsonElement body,
|
||||
HttpContext context,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
if (body.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
return EndpointResults.BadRequest(InvalidBodyDetail);
|
||||
}
|
||||
|
||||
foreach (JsonProperty property in body.EnumerateObject())
|
||||
{
|
||||
if (string.Equals(property.Name, "name", StringComparison.OrdinalIgnoreCase)
|
||||
&& property.Value.ValueKind == JsonValueKind.Null)
|
||||
{
|
||||
return EndpointResults.BadRequest(ContainerNameRequiredDetail);
|
||||
}
|
||||
}
|
||||
|
||||
ContainerPatchRequest? patchBody;
|
||||
try
|
||||
{
|
||||
patchBody = body.Deserialize<ContainerPatchRequest>(RequestJsonOptions);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return EndpointResults.BadRequest(InvalidBodyDetail);
|
||||
}
|
||||
|
||||
if (patchBody is null)
|
||||
{
|
||||
return EndpointResults.BadRequest(InvalidBodyDetail);
|
||||
}
|
||||
|
||||
ContainersService containers = context.RequestServices.GetRequiredService<ContainersService>();
|
||||
ContainerDto? updated = await containers.PatchAsync(
|
||||
containerId,
|
||||
new ContainerPatchDto(
|
||||
patchBody.Name,
|
||||
patchBody.Description,
|
||||
patchBody.Color,
|
||||
patchBody.Collapsed,
|
||||
patchBody.Suggested,
|
||||
patchBody.Note,
|
||||
NormalizeWireRules(patchBody.Rules),
|
||||
patchBody.Policy),
|
||||
ct);
|
||||
if (updated is null)
|
||||
{
|
||||
return EndpointResults.NotFound(ContainerNotFoundDetail);
|
||||
}
|
||||
|
||||
// Аудит изменения контейнера (этап 10, T1).
|
||||
await AuditAppender.AppendTenantAsync(context, AuditEvents.ContainerUpdated, new { id = updated.Id }, ct);
|
||||
return Results.Ok(new { id = updated.Id });
|
||||
}
|
||||
|
||||
// POST /api/containers/{id}/accept: принять ИИ-предложение (suggested=false).
|
||||
private static async Task<IResult> AcceptSuggestedAsync(
|
||||
string containerId,
|
||||
HttpContext context,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
ContainersService containers = context.RequestServices.GetRequiredService<ContainersService>();
|
||||
ContainerDto? accepted = await containers.AcceptSuggestedAsync(containerId, ct);
|
||||
if (accepted is null)
|
||||
{
|
||||
return EndpointResults.NotFound(ContainerNotFoundDetail);
|
||||
}
|
||||
|
||||
// Аудит изменения контейнера (принятие ИИ-предложения) — этап 10, T1.
|
||||
await AuditAppender.AppendTenantAsync(context, AuditEvents.ContainerUpdated, new { id = accepted.Id }, ct);
|
||||
return Results.Ok(accepted);
|
||||
}
|
||||
|
||||
// DELETE /api/containers/{id}: удалить контейнер; карточки → «Неразобранное» новыми.
|
||||
private static async Task<IResult> DeleteContainerAsync(
|
||||
string containerId,
|
||||
HttpContext context,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
ContainersService containers = context.RequestServices.GetRequiredService<ContainersService>();
|
||||
int moved = await containers.DeleteAsync(containerId, ct);
|
||||
|
||||
// Аудит удаления контейнера (этап 10, T1).
|
||||
await AuditAppender.AppendTenantAsync(context, AuditEvents.ContainerDeleted, new { id = containerId }, ct);
|
||||
return Results.Ok(new { ok = true, movedToInbox = moved });
|
||||
}
|
||||
|
||||
// POST /api/containers/reorder: порядок контейнеров пространства; ответ {ok:true}.
|
||||
private static async Task<IResult> ReorderContainersAsync(
|
||||
OrderBody body,
|
||||
HttpContext context,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
if (body.Order is null)
|
||||
{
|
||||
return EndpointResults.BadRequest(ContainerOrderRequiredDetail);
|
||||
}
|
||||
|
||||
ContainersService containers = context.RequestServices.GetRequiredService<ContainersService>();
|
||||
await containers.ReorderAsync(body.Space ?? ContainerSpaces.Dashboard, body.Order, ct);
|
||||
return Results.Ok(new { ok = true });
|
||||
}
|
||||
|
||||
// GET /api/containers/state: свёрнутость/ширина всех колонок (colState).
|
||||
private static async Task<IResult> GetColumnsStateAsync(HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
ContainersService containers = context.RequestServices.GetRequiredService<ContainersService>();
|
||||
IReadOnlyDictionary<string, ColumnStateDto> state = await containers.GetColStateAsync(ct);
|
||||
|
||||
var wire = new Dictionary<string, object>(StringComparer.Ordinal);
|
||||
foreach ((string colId, ColumnStateDto colState) in state)
|
||||
{
|
||||
wire[colId] = ToWireState(colState);
|
||||
}
|
||||
|
||||
return Results.Ok(wire);
|
||||
}
|
||||
|
||||
// PATCH /api/containers/{id}/state: merge патча в состояние колонки; ответ — состояние этой колонки.
|
||||
private static async Task<IResult> PatchColumnStateAsync(
|
||||
string containerId,
|
||||
ColStateBody body,
|
||||
HttpContext context,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
ContainersService containers = context.RequestServices.GetRequiredService<ContainersService>();
|
||||
ColumnStateDto merged = await containers.PatchColStateAsync(
|
||||
containerId,
|
||||
new ColumnStateDto(body.Collapsed, body.Width),
|
||||
ct);
|
||||
return Results.Ok(ToWireState(merged));
|
||||
}
|
||||
|
||||
// Правила из wire → каноничный ContainerRulesDto: отсутствующие группы становятся пустыми списками.
|
||||
// rules: Правила из тела запроса (null — «не меняются/нет правил»).
|
||||
// Возвращает: Каноничные правила с не-null группами; null — правил в теле нет.
|
||||
private static ContainerRulesDto? NormalizeWireRules(ContainerRulesDto? rules)
|
||||
{
|
||||
if (rules is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return rules with
|
||||
{
|
||||
Mode = rules.Mode ?? string.Empty,
|
||||
Direction = rules.Direction ?? Array.Empty<string>(),
|
||||
Keywords = rules.Keywords ?? Array.Empty<string>(),
|
||||
Stack = rules.Stack ?? Array.Empty<string>(),
|
||||
Grade = rules.Grade ?? Array.Empty<string>(),
|
||||
Exclude = rules.Exclude ?? Array.Empty<string>(),
|
||||
Budget = rules.Budget is null ? null : rules.Budget with { Cur = rules.Budget.Cur ?? string.Empty },
|
||||
Levels = rules.Levels ?? Array.Empty<string>(),
|
||||
Locations = rules.Locations ?? Array.Empty<string>(),
|
||||
Types = rules.Types ?? Array.Empty<string>(),
|
||||
Prices = rules.Prices is null ? null : rules.Prices with { Cur = rules.Prices.Cur ?? string.Empty },
|
||||
};
|
||||
}
|
||||
|
||||
// Состояние колонки → wire-объект только с заданными полями (collapsed/width), без null.
|
||||
// state: Состояние колонки (могут быть null-поля).
|
||||
// Возвращает: Словарь из не-null полей состояния.
|
||||
private static Dictionary<string, object> ToWireState(ColumnStateDto state)
|
||||
{
|
||||
var wire = new Dictionary<string, object>();
|
||||
if (state.Collapsed is { } collapsed)
|
||||
{
|
||||
wire["collapsed"] = collapsed;
|
||||
}
|
||||
|
||||
if (state.Width is not null)
|
||||
{
|
||||
wire["width"] = state.Width;
|
||||
}
|
||||
|
||||
return wire;
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,122 +1,121 @@
|
||||
using Deal.Api.Events;
|
||||
using Deal.Api.Extensions;
|
||||
using Deal.Api.Models;
|
||||
using Deal.Api.Services;
|
||||
using Deal.Api.Dtos;
|
||||
|
||||
namespace Deal.Api.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// SSE-поток событий канбана: GET /api/events (Ruling 5; прототип events_routes.py L15–38).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Открывает <c>text/event-stream</c> с подпиской на канал тенанта сессии (singleton SseBroker).
|
||||
/// События пишутся по мере поступления; при тишине 15 с отправляется ping-комментарий ": ping" —
|
||||
/// соединение держится (переподключение EventSource, api.js L62–104). Завершение — по отвалу клиента
|
||||
/// (CancellationToken = RequestAborted); отписка — в finally. Без сессии — 401 {detail} (Ruling 10,
|
||||
/// паттерн остальных эндпоинтов). Заголовки: Content-Type text/event-stream, Cache-Control: no-cache,
|
||||
/// X-Accel-Buffering: no (запрет буферизации прокси, иначе ping/события задерживаются).
|
||||
/// </remarks>
|
||||
public static class EventsEndpoint
|
||||
{
|
||||
// Путь потока (роутер events, events_routes.py L12: prefix="/api").
|
||||
private const string EventsPath = "/api/events";
|
||||
|
||||
// OpenAPI-тег группы (в прототипе — роутер events_routes.py).
|
||||
private const string OpenApiTag = "events";
|
||||
|
||||
// Тип контента потока (events_routes.py L32).
|
||||
private const string EventStreamContentType = "text/event-stream";
|
||||
|
||||
// Директива кеширования: поток не кешируется (events_routes.py L34).
|
||||
private const string NoCacheHeaderValue = "no-cache";
|
||||
|
||||
// Отключение буферизации ответа nginx-прокси (events_routes.py L35).
|
||||
private const string NoBufferingHeaderValue = "no";
|
||||
|
||||
// Ping-комментарий: строки протокола SSE, начинающиеся с ':', клиент игнорирует.
|
||||
private const string PingComment = ": ping\n\n";
|
||||
|
||||
// Интервал ping при тишине: держим соединение (events_routes.py L24: timeout=15).
|
||||
private static readonly TimeSpan PingInterval = TimeSpan.FromSeconds(15);
|
||||
|
||||
/// <summary>
|
||||
/// Регистрирует GET /api/events.
|
||||
/// </summary>
|
||||
/// <param name="app">Построитель маршрутов приложения.</param>
|
||||
/// <returns>Построитель маршрутов для цепочки вызовов.</returns>
|
||||
public static IEndpointRouteBuilder MapEventsEndpoint(this IEndpointRouteBuilder app)
|
||||
{
|
||||
app.MapGet(EventsPath, StreamEventsAsync).WithTags(OpenApiTag);
|
||||
return app;
|
||||
}
|
||||
|
||||
// GET /api/events: поток text/event-stream канала тенанта сессии.
|
||||
// context: Контекст запроса (сессия — HttpContext.Items).
|
||||
// ct: Отмена запроса: клиент отвалился — завершаем поток и отписываемся.
|
||||
private static async Task StreamEventsAsync(HttpContext context, CancellationToken ct)
|
||||
{
|
||||
CurrentUser? user = context.GetCurrentUser();
|
||||
if (user is null)
|
||||
{
|
||||
await EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail).ExecuteAsync(context);
|
||||
return;
|
||||
}
|
||||
|
||||
SseBroker broker = context.RequestServices.GetRequiredService<SseBroker>();
|
||||
SseSubscription subscription = broker.Subscribe(user.TenantId);
|
||||
try
|
||||
{
|
||||
HttpResponse response = context.Response;
|
||||
response.ContentType = EventStreamContentType;
|
||||
response.Headers["Cache-Control"] = NoCacheHeaderValue;
|
||||
response.Headers["X-Accel-Buffering"] = NoBufferingHeaderValue;
|
||||
|
||||
while (true)
|
||||
{
|
||||
using var pingTimeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
pingTimeout.CancelAfter(PingInterval);
|
||||
try
|
||||
{
|
||||
await subscription.Events.WaitToReadAsync(pingTimeout.Token);
|
||||
}
|
||||
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
|
||||
{
|
||||
// Тишина 15 с — ping держит соединение; отмену клиента ловит внешний catch.
|
||||
await WriteFrameAsync(response, PingComment, ct);
|
||||
continue;
|
||||
}
|
||||
|
||||
while (subscription.Events.TryRead(out SseEvent? sseEvent))
|
||||
{
|
||||
await WriteFrameAsync(response, sseEvent.RenderFrame(), ct);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||
{
|
||||
// Клиент закрыл соединение — штатное завершение потока.
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// Сброс соединения клиентом (закрытая вкладка/обрыв сети): ответ уже не доставить.
|
||||
}
|
||||
finally
|
||||
{
|
||||
broker.Unsubscribe(user.TenantId, subscription.Id);
|
||||
}
|
||||
}
|
||||
|
||||
// Пишет frame в поток ответа и сбрасывает буфер — события уходят сразу (не пачкой).
|
||||
// response: Ответ (stream уже начат).
|
||||
// frame: Frame протокола SSE.
|
||||
// ct: Токен отмены запроса.
|
||||
private static async Task WriteFrameAsync(
|
||||
HttpResponse response,
|
||||
string frame,
|
||||
CancellationToken ct)
|
||||
{
|
||||
await response.WriteAsync(frame, ct);
|
||||
await response.Body.FlushAsync(ct);
|
||||
}
|
||||
}
|
||||
using Deal.Api.Events;
|
||||
using Deal.Api.Extensions;
|
||||
using Deal.Api.Models;
|
||||
using Deal.Api.Services;
|
||||
|
||||
namespace Deal.Api.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// SSE-поток событий канбана: GET /api/events (Ruling 5; прототип events_routes.py L15–38).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Открывает <c>text/event-stream</c> с подпиской на канал тенанта сессии (singleton SseBroker).
|
||||
/// События пишутся по мере поступления; при тишине 15 с отправляется ping-комментарий ": ping" —
|
||||
/// соединение держится (переподключение EventSource, api.js L62–104). Завершение — по отвалу клиента
|
||||
/// (CancellationToken = RequestAborted); отписка — в finally. Без сессии — 401 {detail} (Ruling 10,
|
||||
/// паттерн остальных эндпоинтов). Заголовки: Content-Type text/event-stream, Cache-Control: no-cache,
|
||||
/// X-Accel-Buffering: no (запрет буферизации прокси, иначе ping/события задерживаются).
|
||||
/// </remarks>
|
||||
public static class EventsEndpoint
|
||||
{
|
||||
// Путь потока (роутер events, events_routes.py L12: prefix="/api").
|
||||
private const string EventsPath = "/api/events";
|
||||
|
||||
// OpenAPI-тег группы (в прототипе — роутер events_routes.py).
|
||||
private const string OpenApiTag = "events";
|
||||
|
||||
// Тип контента потока (events_routes.py L32).
|
||||
private const string EventStreamContentType = "text/event-stream";
|
||||
|
||||
// Директива кеширования: поток не кешируется (events_routes.py L34).
|
||||
private const string NoCacheHeaderValue = "no-cache";
|
||||
|
||||
// Отключение буферизации ответа nginx-прокси (events_routes.py L35).
|
||||
private const string NoBufferingHeaderValue = "no";
|
||||
|
||||
// Ping-комментарий: строки протокола SSE, начинающиеся с ':', клиент игнорирует.
|
||||
private const string PingComment = ": ping\n\n";
|
||||
|
||||
// Интервал ping при тишине: держим соединение (events_routes.py L24: timeout=15).
|
||||
private static readonly TimeSpan PingInterval = TimeSpan.FromSeconds(15);
|
||||
|
||||
/// <summary>
|
||||
/// Регистрирует GET /api/events.
|
||||
/// </summary>
|
||||
/// <param name="app">Построитель маршрутов приложения.</param>
|
||||
/// <returns>Построитель маршрутов для цепочки вызовов.</returns>
|
||||
public static IEndpointRouteBuilder MapEventsEndpoint(this IEndpointRouteBuilder app)
|
||||
{
|
||||
app.MapGet(EventsPath, StreamEventsAsync).WithTags(OpenApiTag);
|
||||
return app;
|
||||
}
|
||||
|
||||
// GET /api/events: поток text/event-stream канала тенанта сессии.
|
||||
// context: Контекст запроса (сессия — HttpContext.Items).
|
||||
// ct: Отмена запроса: клиент отвалился — завершаем поток и отписываемся.
|
||||
private static async Task StreamEventsAsync(HttpContext context, CancellationToken ct)
|
||||
{
|
||||
CurrentUser? user = context.GetCurrentUser();
|
||||
if (user is null)
|
||||
{
|
||||
await EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail).ExecuteAsync(context);
|
||||
return;
|
||||
}
|
||||
|
||||
SseBroker broker = context.RequestServices.GetRequiredService<SseBroker>();
|
||||
SseSubscription subscription = broker.Subscribe(user.TenantId);
|
||||
try
|
||||
{
|
||||
HttpResponse response = context.Response;
|
||||
response.ContentType = EventStreamContentType;
|
||||
response.Headers["Cache-Control"] = NoCacheHeaderValue;
|
||||
response.Headers["X-Accel-Buffering"] = NoBufferingHeaderValue;
|
||||
|
||||
while (true)
|
||||
{
|
||||
using var pingTimeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
pingTimeout.CancelAfter(PingInterval);
|
||||
try
|
||||
{
|
||||
await subscription.Events.WaitToReadAsync(pingTimeout.Token);
|
||||
}
|
||||
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
|
||||
{
|
||||
// Тишина 15 с — ping держит соединение; отмену клиента ловит внешний catch.
|
||||
await WriteFrameAsync(response, PingComment, ct);
|
||||
continue;
|
||||
}
|
||||
|
||||
while (subscription.Events.TryRead(out SseEvent? sseEvent))
|
||||
{
|
||||
await WriteFrameAsync(response, sseEvent.RenderFrame(), ct);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||
{
|
||||
// Клиент закрыл соединение — штатное завершение потока.
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// Сброс соединения клиентом (закрытая вкладка/обрыв сети): ответ уже не доставить.
|
||||
}
|
||||
finally
|
||||
{
|
||||
broker.Unsubscribe(user.TenantId, subscription.Id);
|
||||
}
|
||||
}
|
||||
|
||||
// Пишет frame в поток ответа и сбрасывает буфер — события уходят сразу (не пачкой).
|
||||
// response: Ответ (stream уже начат).
|
||||
// frame: Frame протокола SSE.
|
||||
// ct: Токен отмены запроса.
|
||||
private static async Task WriteFrameAsync(
|
||||
HttpResponse response,
|
||||
string frame,
|
||||
CancellationToken ct)
|
||||
{
|
||||
await response.WriteAsync(frame, ct);
|
||||
await response.Body.FlushAsync(ct);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,83 +1,78 @@
|
||||
using Deal.Api.Extensions;
|
||||
using Deal.Api.Models;
|
||||
using Deal.Api.Services;
|
||||
using Deal.Modules.Settings.Application.Abstractions;
|
||||
using Deal.Modules.Settings.Application.Models;
|
||||
using Deal.Modules.Settings.Application.Registrars;
|
||||
using Deal.Modules.Settings.Application.Services;
|
||||
using Deal.Api.Dtos;
|
||||
|
||||
namespace Deal.Api.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Тестер фильтра входящих: POST /api/admin/check-message (Ruling 8, api-map §3.2 L109, §4.10 L364).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Имитация этапов пайплайна для тестера в настройках — 1:1 с <c>dashboard_routes.py</c> L267–284:
|
||||
/// этап 1 считают детерминированные правила <see cref="IncomingRules"/> (<c>stage1_plain</c>, pipeline.py
|
||||
/// L94–124) по настройкам тенанта; ответ — <c>{stage1:{pass,reason}, stage2:{pass,reason,skipped}, passed}</c>.
|
||||
/// ИИ-фильтр этапа 2 на этапе 2 ВСЕГДА skipped (Ruling 4/8, план Task 10 L377–380): если этап-1 не прошёл —
|
||||
/// <c>stage2={pass:false,reason:null,skipped:true}, passed:false</c>; иначе — <c>stage2={pass:true,reason:null,
|
||||
/// skipped:true}, passed:true</c> (реальный ИИ-фильтр — этап 6, ветка ошибки ИИ прототипа L281 к skipped
|
||||
/// не относится — там ИИ реально зовётся). kind/kw результата правил наружу НЕ отдаются (в ответе только
|
||||
/// pass/reason — как в прототипе); они нужны мониторингу отсева этапа 4.
|
||||
/// Эндпоинт требует сессию: 401 {detail} (Ruling 10). IncomingRules резолвится из RequestServices ПОСЛЕ
|
||||
/// проверки сессии (scoped на TenantDbContext — паттерн SettingsEndpoints/MlEndpoints).
|
||||
/// </remarks>
|
||||
public static class FilterTesterEndpoints
|
||||
{
|
||||
// Префикс группы API (общий для эндпоинтов этапа, Ruling 8).
|
||||
private const string ApiGroupPrefix = "/api";
|
||||
|
||||
// Путь тестера фильтра входящих (dashboard_routes.py L267).
|
||||
private const string CheckMessagePath = "/admin/check-message";
|
||||
|
||||
// OpenAPI-тег группы (эндпоинт Settings-экрана, Ruling 8).
|
||||
private const string OpenApiTag = "settings";
|
||||
|
||||
/// <summary>
|
||||
/// Регистрирует POST /api/admin/check-message.
|
||||
/// </summary>
|
||||
/// <param name="app">Построитель маршрутов приложения.</param>
|
||||
/// <returns>Построитель маршрутов для цепочки вызовов.</returns>
|
||||
public static IEndpointRouteBuilder MapFilterTesterEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup(ApiGroupPrefix).WithTags(OpenApiTag);
|
||||
group.MapPost(CheckMessagePath, CheckAsync);
|
||||
return app;
|
||||
}
|
||||
|
||||
// POST /api/admin/check-message: этап-1 правила + этап-2 (skipped) для тестера (dashboard_routes.py L267–284).
|
||||
private static async Task<IResult> CheckAsync(
|
||||
CheckMessageRequest body,
|
||||
HttpContext context,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
// Резолв после 401-гейта: IncomingRules — scoped на TenantDbContext (tenant-контекст запроса).
|
||||
IncomingRules incomingRules = context.RequestServices.GetRequiredService<IncomingRules>();
|
||||
IncomingRulesResult stage1 = await incomingRules.CheckAsync(body.Text, ct);
|
||||
|
||||
// Ответ 1:1 с прототипом: stage2 на этапе 2 всегда skipped (Ruling 4/8, план L377–380).
|
||||
if (!stage1.Pass)
|
||||
{
|
||||
return Results.Ok(new
|
||||
{
|
||||
stage1 = new { pass = stage1.Pass, reason = stage1.Reason },
|
||||
stage2 = new { pass = false, reason = (string?)null, skipped = true },
|
||||
passed = false,
|
||||
});
|
||||
}
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
stage1 = new { pass = stage1.Pass, reason = stage1.Reason },
|
||||
stage2 = new { pass = true, reason = (string?)null, skipped = true },
|
||||
passed = true,
|
||||
});
|
||||
}
|
||||
}
|
||||
using Deal.Api.Extensions;
|
||||
using Deal.Api.Services;
|
||||
using Deal.Modules.Settings.Application.Models;
|
||||
|
||||
namespace Deal.Api.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Тестер фильтра входящих: POST /api/admin/check-message (Ruling 8, api-map §3.2 L109, §4.10 L364).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Имитация этапов пайплайна для тестера в настройках — 1:1 с <c>dashboard_routes.py</c> L267–284:
|
||||
/// этап 1 считают детерминированные правила <see cref="IncomingRules"/> (<c>stage1_plain</c>, pipeline.py
|
||||
/// L94–124) по настройкам тенанта; ответ — <c>{stage1:{pass,reason}, stage2:{pass,reason,skipped}, passed}</c>.
|
||||
/// ИИ-фильтр этапа 2 на этапе 2 ВСЕГДА skipped (Ruling 4/8, план Task 10 L377–380): если этап-1 не прошёл —
|
||||
/// <c>stage2={pass:false,reason:null,skipped:true}, passed:false</c>; иначе — <c>stage2={pass:true,reason:null,
|
||||
/// skipped:true}, passed:true</c> (реальный ИИ-фильтр — этап 6, ветка ошибки ИИ прототипа L281 к skipped
|
||||
/// не относится — там ИИ реально зовётся). kind/kw результата правил наружу НЕ отдаются (в ответе только
|
||||
/// pass/reason — как в прототипе); они нужны мониторингу отсева этапа 4.
|
||||
/// Эндпоинт требует сессию: 401 {detail} (Ruling 10). IncomingRules резолвится из RequestServices ПОСЛЕ
|
||||
/// проверки сессии (scoped на TenantDbContext — паттерн SettingsEndpoints/MlEndpoints).
|
||||
/// </remarks>
|
||||
public static class FilterTesterEndpoints
|
||||
{
|
||||
// Префикс группы API (общий для эндпоинтов этапа, Ruling 8).
|
||||
private const string ApiGroupPrefix = "/api";
|
||||
|
||||
// Путь тестера фильтра входящих (dashboard_routes.py L267).
|
||||
private const string CheckMessagePath = "/admin/check-message";
|
||||
|
||||
// OpenAPI-тег группы (эндпоинт Settings-экрана, Ruling 8).
|
||||
private const string OpenApiTag = "settings";
|
||||
|
||||
/// <summary>
|
||||
/// Регистрирует POST /api/admin/check-message.
|
||||
/// </summary>
|
||||
/// <param name="app">Построитель маршрутов приложения.</param>
|
||||
/// <returns>Построитель маршрутов для цепочки вызовов.</returns>
|
||||
public static IEndpointRouteBuilder MapFilterTesterEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup(ApiGroupPrefix).WithTags(OpenApiTag);
|
||||
group.MapPost(CheckMessagePath, CheckAsync);
|
||||
return app;
|
||||
}
|
||||
|
||||
// POST /api/admin/check-message: этап-1 правила + этап-2 (skipped) для тестера (dashboard_routes.py L267–284).
|
||||
private static async Task<IResult> CheckAsync(
|
||||
CheckMessageRequest body,
|
||||
HttpContext context,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
// Резолв после 401-гейта: IncomingRules — scoped на TenantDbContext (tenant-контекст запроса).
|
||||
IncomingRules incomingRules = context.RequestServices.GetRequiredService<IncomingRules>();
|
||||
IncomingRulesResult stage1 = await incomingRules.CheckAsync(body.Text, ct);
|
||||
|
||||
// Ответ 1:1 с прототипом: stage2 на этапе 2 всегда skipped (Ruling 4/8, план L377–380).
|
||||
if (!stage1.Pass)
|
||||
{
|
||||
return Results.Ok(new
|
||||
{
|
||||
stage1 = new { pass = stage1.Pass, reason = stage1.Reason },
|
||||
stage2 = new { pass = false, reason = (string?)null, skipped = true },
|
||||
passed = false,
|
||||
});
|
||||
}
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
stage1 = new { pass = stage1.Pass, reason = stage1.Reason },
|
||||
stage2 = new { pass = true, reason = (string?)null, skipped = true },
|
||||
passed = true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,118 +1,112 @@
|
||||
using Deal.Api.Extensions;
|
||||
using Deal.Api.Models;
|
||||
using Deal.Api.Services;
|
||||
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.Dtos;
|
||||
|
||||
namespace Deal.Api.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Публичный эндпоинт активации инвайта: POST /api/join (Ruling 2/11 этапа 7).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Ручка не требует сессии (публичная; фронт её не вызывает — API-only, curl/будущий UI). Тело
|
||||
/// {code, email, name?, password} → JoinService: валидация кода/email/пароля, CAS-резервирование инвайта,
|
||||
/// создание тенанта (при пустом TenantId — с провижинингом схемы) и пользователя. Кука НЕ ставится: после
|
||||
/// активации клиент входит обычным /api/auth/login (план Task 6). Успех — {ok:true, login}; ошибки — 400
|
||||
/// {detail} с фиксированным текстом причины (все отказы активации — 400, включая истёкший инвайт: слой
|
||||
/// эндпоинта, см. Task 6; Ruling 2 называет это «410-семантикой» — ресурс больше недоступен). Результат
|
||||
/// пишется в аудит — invite_joined (актор — новый пользователь тенанта, детали email+codeHash).
|
||||
/// </remarks>
|
||||
public static class JoinEndpoint
|
||||
{
|
||||
// Путь ручки (вне группы /api/operator — публичная).
|
||||
private const string JoinPath = "/api/join";
|
||||
|
||||
// OpenAPI-тег.
|
||||
private const string JoinOpenApiTag = "join";
|
||||
|
||||
// Текст 400: приглашение с таким кодом не найдено.
|
||||
private const string InviteNotFoundDetail = "Приглашение не найдено";
|
||||
|
||||
// Текст 400: срок действия приглашения истёк (план Task 6, Ruling 2).
|
||||
private const string InviteExpiredDetail = "Срок действия приглашения истёк";
|
||||
|
||||
// Текст 400: приглашение уже активировано (повторная активация тем же кодом).
|
||||
private const string InviteUsedDetail = "Приглашение уже использовано";
|
||||
|
||||
// Текст 400: приглашение отозвано оператором.
|
||||
private const string InviteRevokedDetail = "Приглашение отозвано";
|
||||
|
||||
// Текст 400: email запроса не совпадает с email приглашения (Ruling 2).
|
||||
private const string EmailMismatchDetail = "Email не совпадает с приглашением";
|
||||
|
||||
// Текст 400: пользователь с таким email уже зарегистрирован (users.login unique, Ruling 2).
|
||||
private const string EmailTakenDetail = "Этот email уже зарегистрирован";
|
||||
|
||||
// Текст 400: пароль короче минимума (текст как в AuthEndpoints, план Task 6).
|
||||
private const string PasswordTooShortDetail = "Пароль слишком короткий (минимум 8 символов)";
|
||||
|
||||
// Текст 400: целевой тенант инвайта не существует (Security review).
|
||||
private const string TenantNotFoundDetail = "Тенант приглашения не найден";
|
||||
|
||||
// Текст 400: целевой тенант инвайта приостановлен (Security review).
|
||||
private const string TenantSuspendedDetail = "Тенант приглашения приостановлен";
|
||||
|
||||
/// <summary>
|
||||
/// Регистрирует POST /api/join.
|
||||
/// </summary>
|
||||
/// <param name="app">Построитель маршрутов приложения.</param>
|
||||
/// <returns>Построитель маршрутов для цепочки вызовов.</returns>
|
||||
public static IEndpointRouteBuilder MapJoinEndpoint(this IEndpointRouteBuilder app)
|
||||
{
|
||||
app.MapPost(JoinPath, JoinAsync).WithTags(JoinOpenApiTag);
|
||||
return app;
|
||||
}
|
||||
|
||||
// POST /api/join: активация инвайта; успех пишется в аудит (invite_activated, Task 6/Ruling 4).
|
||||
private static async Task<IResult> JoinAsync(
|
||||
JoinRequest body,
|
||||
JoinService joinService,
|
||||
AuditService auditService,
|
||||
HttpContext context,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var result = await joinService.ActivateAsync(body.Code, body.Email, body.Name, body.Password, ct);
|
||||
if (!result.Ok || result.Login is null || result.UserId is null || result.TenantId is null)
|
||||
{
|
||||
return EndpointResults.BadRequest(DetailFor(result.Error));
|
||||
}
|
||||
|
||||
await auditService.AppendAsync(new AuditRecordDto(
|
||||
AuditEvents.InviteJoined,
|
||||
AuditActorTypes.Tenant,
|
||||
ActorId: result.UserId,
|
||||
TenantId: result.TenantId,
|
||||
Ip: ClientIp(context),
|
||||
// Код инвайта — capability-токен: в аудит пишется только SHA-256-хэш (Security review).
|
||||
DetailJson: AuditService.ToDetailJson(new { email = result.Login, codeHash = SessionTokens.HashToken(body.Code?.Trim() ?? string.Empty) })), ct);
|
||||
|
||||
return Results.Ok(new { ok = true, login = result.Login });
|
||||
}
|
||||
|
||||
// Фиксированный текст 400 по коду ошибки JoinService (все отказы активации — 400).
|
||||
// error: Код ошибки JoinResultDto.
|
||||
// Возвращает: Текст детали ошибки.
|
||||
private static string DetailFor(string? error) =>
|
||||
error switch
|
||||
{
|
||||
JoinResultDto.ErrorExpired => InviteExpiredDetail,
|
||||
JoinResultDto.ErrorUsed => InviteUsedDetail,
|
||||
JoinResultDto.ErrorRevoked => InviteRevokedDetail,
|
||||
JoinResultDto.ErrorEmailMismatch => EmailMismatchDetail,
|
||||
JoinResultDto.ErrorEmailTaken => EmailTakenDetail,
|
||||
JoinResultDto.ErrorPasswordTooShort => PasswordTooShortDetail,
|
||||
JoinResultDto.ErrorTenantNotFound => TenantNotFoundDetail,
|
||||
JoinResultDto.ErrorTenantSuspended => TenantSuspendedDetail,
|
||||
_ => InviteNotFoundDetail,
|
||||
};
|
||||
|
||||
// IP-адрес клиента для аудита (без порта; null, если недоступен).
|
||||
// context: Контекст запроса.
|
||||
// Возвращает: Строковое представление IP или null.
|
||||
private static string? ClientIp(HttpContext context) => context.Connection.RemoteIpAddress?.ToString();
|
||||
}
|
||||
using Deal.Api.Services;
|
||||
using Deal.Modules.Tenants.Application.Models;
|
||||
using Deal.Modules.Tenants.Application.Services;
|
||||
|
||||
namespace Deal.Api.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Публичный эндпоинт активации инвайта: POST /api/join (Ruling 2/11 этапа 7).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Ручка не требует сессии (публичная; фронт её не вызывает — API-only, curl/будущий UI). Тело
|
||||
/// {code, email, name?, password} → JoinService: валидация кода/email/пароля, CAS-резервирование инвайта,
|
||||
/// создание тенанта (при пустом TenantId — с провижинингом схемы) и пользователя. Кука НЕ ставится: после
|
||||
/// активации клиент входит обычным /api/auth/login (план Task 6). Успех — {ok:true, login}; ошибки — 400
|
||||
/// {detail} с фиксированным текстом причины (все отказы активации — 400, включая истёкший инвайт: слой
|
||||
/// эндпоинта, см. Task 6; Ruling 2 называет это «410-семантикой» — ресурс больше недоступен). Результат
|
||||
/// пишется в аудит — invite_joined (актор — новый пользователь тенанта, детали email+codeHash).
|
||||
/// </remarks>
|
||||
public static class JoinEndpoint
|
||||
{
|
||||
// Путь ручки (вне группы /api/operator — публичная).
|
||||
private const string JoinPath = "/api/join";
|
||||
|
||||
// OpenAPI-тег.
|
||||
private const string JoinOpenApiTag = "join";
|
||||
|
||||
// Текст 400: приглашение с таким кодом не найдено.
|
||||
private const string InviteNotFoundDetail = "Приглашение не найдено";
|
||||
|
||||
// Текст 400: срок действия приглашения истёк (план Task 6, Ruling 2).
|
||||
private const string InviteExpiredDetail = "Срок действия приглашения истёк";
|
||||
|
||||
// Текст 400: приглашение уже активировано (повторная активация тем же кодом).
|
||||
private const string InviteUsedDetail = "Приглашение уже использовано";
|
||||
|
||||
// Текст 400: приглашение отозвано оператором.
|
||||
private const string InviteRevokedDetail = "Приглашение отозвано";
|
||||
|
||||
// Текст 400: email запроса не совпадает с email приглашения (Ruling 2).
|
||||
private const string EmailMismatchDetail = "Email не совпадает с приглашением";
|
||||
|
||||
// Текст 400: пользователь с таким email уже зарегистрирован (users.login unique, Ruling 2).
|
||||
private const string EmailTakenDetail = "Этот email уже зарегистрирован";
|
||||
|
||||
// Текст 400: пароль короче минимума (текст как в AuthEndpoints, план Task 6).
|
||||
private const string PasswordTooShortDetail = "Пароль слишком короткий (минимум 8 символов)";
|
||||
|
||||
// Текст 400: целевой тенант инвайта не существует (Security review).
|
||||
private const string TenantNotFoundDetail = "Тенант приглашения не найден";
|
||||
|
||||
// Текст 400: целевой тенант инвайта приостановлен (Security review).
|
||||
private const string TenantSuspendedDetail = "Тенант приглашения приостановлен";
|
||||
|
||||
/// <summary>
|
||||
/// Регистрирует POST /api/join.
|
||||
/// </summary>
|
||||
/// <param name="app">Построитель маршрутов приложения.</param>
|
||||
/// <returns>Построитель маршрутов для цепочки вызовов.</returns>
|
||||
public static IEndpointRouteBuilder MapJoinEndpoint(this IEndpointRouteBuilder app)
|
||||
{
|
||||
app.MapPost(JoinPath, JoinAsync).WithTags(JoinOpenApiTag);
|
||||
return app;
|
||||
}
|
||||
|
||||
// POST /api/join: активация инвайта; успех пишется в аудит (invite_activated, Task 6/Ruling 4).
|
||||
private static async Task<IResult> JoinAsync(
|
||||
JoinRequest body,
|
||||
JoinService joinService,
|
||||
AuditService auditService,
|
||||
HttpContext context,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var result = await joinService.ActivateAsync(body.Code, body.Email, body.Name, body.Password, ct);
|
||||
if (!result.Ok || result.Login is null || result.UserId is null || result.TenantId is null)
|
||||
{
|
||||
return EndpointResults.BadRequest(DetailFor(result.Error));
|
||||
}
|
||||
|
||||
await auditService.AppendAsync(new AuditRecordDto(
|
||||
AuditEvents.InviteJoined,
|
||||
AuditActorTypes.Tenant,
|
||||
ActorId: result.UserId,
|
||||
TenantId: result.TenantId,
|
||||
Ip: ClientIp(context),
|
||||
// Код инвайта — capability-токен: в аудит пишется только SHA-256-хэш (Security review).
|
||||
DetailJson: AuditService.ToDetailJson(new { email = result.Login, codeHash = SessionTokens.HashToken(body.Code?.Trim() ?? string.Empty) })), ct);
|
||||
|
||||
return Results.Ok(new { ok = true, login = result.Login });
|
||||
}
|
||||
|
||||
// Фиксированный текст 400 по коду ошибки JoinService (все отказы активации — 400).
|
||||
// error: Код ошибки JoinResultDto.
|
||||
// Возвращает: Текст детали ошибки.
|
||||
private static string DetailFor(string? error) =>
|
||||
error switch
|
||||
{
|
||||
JoinResultDto.ErrorExpired => InviteExpiredDetail,
|
||||
JoinResultDto.ErrorUsed => InviteUsedDetail,
|
||||
JoinResultDto.ErrorRevoked => InviteRevokedDetail,
|
||||
JoinResultDto.ErrorEmailMismatch => EmailMismatchDetail,
|
||||
JoinResultDto.ErrorEmailTaken => EmailTakenDetail,
|
||||
JoinResultDto.ErrorPasswordTooShort => PasswordTooShortDetail,
|
||||
JoinResultDto.ErrorTenantNotFound => TenantNotFoundDetail,
|
||||
JoinResultDto.ErrorTenantSuspended => TenantSuspendedDetail,
|
||||
_ => InviteNotFoundDetail,
|
||||
};
|
||||
|
||||
// IP-адрес клиента для аудита (без порта; null, если недоступен).
|
||||
// context: Контекст запроса.
|
||||
// Возвращает: Строковое представление IP или null.
|
||||
private static string? ClientIp(HttpContext context) => context.Connection.RemoteIpAddress?.ToString();
|
||||
}
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
using Deal.Api.Services;
|
||||
using Deal.Api.Dtos;
|
||||
|
||||
namespace Deal.Api.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Тело POST /api/join — активация инвайта (Ruling 2, Task 6 этапа 7). Входящий JSON — camelCase (code, email, name?, password).
|
||||
/// </summary>
|
||||
/// <param name="Code">Код приглашения (16 url-safe символов).</param>
|
||||
/// <param name="Email">Email активирующего; обязан совпасть с email приглашения (нормализует JoinService).</param>
|
||||
/// <param name="Name">Имя нового тенанта (только когда у инвайта нет целевого тенанта); null — имя = email.</param>
|
||||
/// <param name="Password">Пароль пользователя (минимум 8 символов).</param>
|
||||
public sealed record JoinRequest(string? Code, string? Email, string? Name, string? Password);
|
||||
namespace Deal.Api.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Тело POST /api/join — активация инвайта (Ruling 2, Task 6 этапа 7). Входящий JSON — camelCase (code, email, name?, password).
|
||||
/// </summary>
|
||||
/// <param name="Code">Код приглашения (16 url-safe символов).</param>
|
||||
/// <param name="Email">Email активирующего; обязан совпасть с email приглашения (нормализует JoinService).</param>
|
||||
/// <param name="Name">Имя нового тенанта (только когда у инвайта нет целевого тенанта); null — имя = email.</param>
|
||||
/// <param name="Password">Пароль пользователя (минимум 8 символов).</param>
|
||||
public sealed record JoinRequest(string? Code, string? Email, string? Name, string? Password);
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
using Deal.Api.Services;
|
||||
using Deal.Api.Dtos;
|
||||
|
||||
namespace Deal.Api.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Тело POST /api/auth/login. Входящий JSON — camelCase (login, password).
|
||||
/// </summary>
|
||||
/// <param name="Login">Логин пользователя.</param>
|
||||
/// <param name="Password">Пароль в открытом виде.</param>
|
||||
public sealed record LoginRequest(string Login, string Password);
|
||||
namespace Deal.Api.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Тело POST /api/auth/login. Входящий JSON — camelCase (login, password).
|
||||
/// </summary>
|
||||
/// <param name="Login">Логин пользователя.</param>
|
||||
/// <param name="Password">Пароль в открытом виде.</param>
|
||||
public sealed record LoginRequest(string Login, string Password);
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
using Deal.Api.Services;
|
||||
using Deal.Api.Dtos;
|
||||
|
||||
namespace Deal.Api.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Тело POST /api/ml/apply. Входящий JSON — camelCase (dialogId, msgId, action).
|
||||
/// </summary>
|
||||
/// <param name="DialogId">Id диалога/канала Telegram, где лежит исходное сообщение.</param>
|
||||
/// <param name="MsgId">Id сообщения внутри диалога.</param>
|
||||
/// <param name="Action">Ручное решение: spam | board:<id> | skip (api-map §3.7 L197).</param>
|
||||
public sealed record MlApplyRequest(string DialogId, int MsgId, string Action);
|
||||
namespace Deal.Api.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Тело POST /api/ml/apply. Входящий JSON — camelCase (dialogId, msgId, action).
|
||||
/// </summary>
|
||||
/// <param name="DialogId">Id диалога/канала Telegram, где лежит исходное сообщение.</param>
|
||||
/// <param name="MsgId">Id сообщения внутри диалога.</param>
|
||||
/// <param name="Action">Ручное решение: spam | board:<id> | skip (api-map §3.7 L197).</param>
|
||||
public sealed record MlApplyRequest(string DialogId, int MsgId, string Action);
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
using Deal.Api.Services;
|
||||
using Deal.Api.Dtos;
|
||||
|
||||
namespace Deal.Api.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Тело POST /api/ml/candidates. Входящий JSON — camelCase (dialogId, limit).
|
||||
/// </summary>
|
||||
/// <param name="DialogId">Id диалога/канала Telegram; пусто — выборка по всем источникам тенанта (§8).</param>
|
||||
/// <param name="Limit">Сколько последних сообщений вернуть (кламп 1..60, дефолт 10).</param>
|
||||
public sealed record MlCandidatesRequest(string DialogId, int Limit);
|
||||
namespace Deal.Api.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Тело POST /api/ml/candidates. Входящий JSON — camelCase (dialogId, limit).
|
||||
/// </summary>
|
||||
/// <param name="DialogId">Id диалога/канала Telegram; пусто — выборка по всем источникам тенанта (§8).</param>
|
||||
/// <param name="Limit">Сколько последних сообщений вернуть (кламп 1..60, дефолт 10).</param>
|
||||
public sealed record MlCandidatesRequest(string DialogId, int Limit);
|
||||
|
||||
@@ -1,192 +1,188 @@
|
||||
using Deal.Api.Extensions;
|
||||
using Deal.Api.Models;
|
||||
using Deal.Api.Services;
|
||||
using Deal.Contracts.Integrations.Abstractions;
|
||||
using Deal.Contracts.Integrations.Models;
|
||||
using Deal.Modules.Pipeline.Application.Abstractions;
|
||||
using Deal.Modules.Pipeline.Application.Models;
|
||||
using Deal.Modules.Pipeline.Application.Registrars;
|
||||
using Deal.Modules.Pipeline.Application.Services;
|
||||
using Deal.Api.Dtos;
|
||||
|
||||
namespace Deal.Api.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Эндпоинты ML-панели: GET /api/ml/status, POST /api/ml/reset, /predict, /candidates, /apply (Ruling 8, api-map §3.7).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Тела ответов 1:1 с прототипом <c>backend/app/routers/ml_routes.py</c> (L66–91, L112–171):
|
||||
/// <c>status</c> — MlStatusResponseDto (enabled/service/reachable/stats, §4.10 L363); <c>reset</c> —
|
||||
/// <c>{ok:true}</c> (мягкая ошибка {ok:false,error} зарезервирована — заглушка всегда успешна);
|
||||
/// <c>predict</c> — <c>{text: первые 200, take, label, scores, hits, ready, margin, terms, type}</c>
|
||||
/// (текст короче 2 символов после trim → 400 «Введите текст»); <c>candidates</c> — <c>{items}</c> реальных
|
||||
/// сообщений-кандидатов канала/выборки (очередь/отсев/карточки + мнение ML, §8; MlReviewService);
|
||||
/// <c>apply</c> — 404 «Исходное сообщение не найдено» либо результат ручной разметки
|
||||
/// <c>{ok, learned, moved, leadId}</c> (обучение ML + перенос/корзина/отсев). ml/learn и ml/flush
|
||||
/// НЕ реализуются (фронт не вызывает, api-map п.9 L399). Все эндпоинты требуют сессию: 401 {detail}
|
||||
/// (Ruling 10). IMlClient/MlReviewService резолвятся из RequestServices ПОСЛЕ проверки сессии (scoped
|
||||
/// на tenant-запрос — вне него не разрешимы, паттерн SettingsEndpoints/AiCheckEndpoint).
|
||||
/// </remarks>
|
||||
public static class MlEndpoints
|
||||
{
|
||||
// Префикс группы /api/ml (Ruling 8: MapMlEndpoints).
|
||||
private const string MlGroupPrefix = "/api/ml";
|
||||
|
||||
// OpenAPI-тег группы (в прототипе роутер ml — ml_routes.py).
|
||||
private const string MlOpenApiTag = "ml";
|
||||
|
||||
// Путь статуса ML (GET).
|
||||
private const string StatusPath = "/status";
|
||||
|
||||
// Путь сброса модели (POST).
|
||||
private const string ResetPath = "/reset";
|
||||
|
||||
// Путь проверки ML на тексте (POST).
|
||||
private const string PredictPath = "/predict";
|
||||
|
||||
// Путь разбора сообщений канала (POST).
|
||||
private const string CandidatesPath = "/candidates";
|
||||
|
||||
// Путь ручного решения по сообщению (POST).
|
||||
private const string ApplyPath = "/apply";
|
||||
|
||||
// Минимальная длина текста для проверки (ml_routes.py L87: len(text) < 2 → 400).
|
||||
private const int MinPredictTextLength = 2;
|
||||
|
||||
// Длина текста в ответе predict: первые 200 символов (ml_routes.py L90 text[:200]).
|
||||
private const int PredictTextPreviewLength = 200;
|
||||
|
||||
// Сообщение 400 для слишком короткого текста (ml_routes.py L88, план Task 9 L348).
|
||||
private const string EnterTextDetail = "Введите текст";
|
||||
|
||||
// Сообщение 404 apply: исходное сообщение не найдено (ml_routes.py L142, план Task 9 L352).
|
||||
private const string MessageNotFoundDetail = "Исходное сообщение не найдено";
|
||||
|
||||
/// <summary>
|
||||
/// Регистрирует группу /api/ml: status/reset/predict/candidates/apply.
|
||||
/// </summary>
|
||||
/// <param name="app">Построитель маршрутов приложения.</param>
|
||||
/// <returns>Построитель маршрутов для цепочки вызовов.</returns>
|
||||
public static IEndpointRouteBuilder MapMlEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup(MlGroupPrefix).WithTags(MlOpenApiTag);
|
||||
|
||||
group.MapGet(StatusPath, StatusAsync);
|
||||
group.MapPost(ResetPath, ResetAsync);
|
||||
group.MapPost(PredictPath, PredictAsync);
|
||||
group.MapPost(CandidatesPath, CandidatesAsync);
|
||||
group.MapPost(ApplyPath, ApplyAsync);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
// GET /api/ml/status: статус ML-сервиса + локальная статистика (ml_routes.py L66–75).
|
||||
private static async Task<IResult> StatusAsync(HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
IMlClient mlClient = context.RequestServices.GetRequiredService<IMlClient>();
|
||||
return Results.Ok(await mlClient.StatusAsync(ct));
|
||||
}
|
||||
|
||||
// POST /api/ml/reset: полный сброс модели + очистка очереди обучения (ml_routes.py L78–81).
|
||||
private static async Task<IResult> ResetAsync(HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
IMlClient mlClient = context.RequestServices.GetRequiredService<IMlClient>();
|
||||
return Results.Ok(await mlClient.ResetAsync(ct));
|
||||
}
|
||||
|
||||
// POST /api/ml/predict: проверка ML на тексте (ml_routes.py L84–90).
|
||||
private static async Task<IResult> PredictAsync(
|
||||
MlPredictRequest body,
|
||||
HttpContext context,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
string text = (body.Text ?? string.Empty).Trim();
|
||||
if (text.Length < MinPredictTextLength)
|
||||
{
|
||||
return EndpointResults.BadRequest(EnterTextDetail);
|
||||
}
|
||||
|
||||
IMlClient mlClient = context.RequestServices.GetRequiredService<IMlClient>();
|
||||
MlPredictResultDto result = await mlClient.PredictAsync(text, ct);
|
||||
|
||||
// Ответ 1:1 с ml_routes.py L90: {"text": <первые 200>, **результат предсказания}.
|
||||
string preview = text.Length <= PredictTextPreviewLength
|
||||
? text
|
||||
: text[..PredictTextPreviewLength];
|
||||
return Results.Ok(new
|
||||
{
|
||||
text = preview,
|
||||
take = result.Take,
|
||||
label = result.Label,
|
||||
scores = result.Scores,
|
||||
hits = result.Hits,
|
||||
ready = result.Ready,
|
||||
margin = result.Margin,
|
||||
terms = result.Terms,
|
||||
type = result.Type,
|
||||
});
|
||||
}
|
||||
|
||||
// POST /api/ml/candidates: последние сообщения канала + мнение ML (ml_routes.py L112–134).
|
||||
private static async Task<IResult> CandidatesAsync(
|
||||
MlCandidatesRequest body,
|
||||
HttpContext context,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
MlReviewService review = context.RequestServices.GetRequiredService<MlReviewService>();
|
||||
IReadOnlyList<MlCandidateDto> items = await review.CandidatesAsync(body.DialogId, body.Limit, ct);
|
||||
return Results.Ok(new { items });
|
||||
}
|
||||
|
||||
// POST /api/ml/apply: ручное решение по сообщению (ml_routes.py L137–171).
|
||||
private static async Task<IResult> ApplyAsync(
|
||||
MlApplyRequest body,
|
||||
HttpContext context,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
MlReviewService review = context.RequestServices.GetRequiredService<MlReviewService>();
|
||||
MlApplyResult? result = await review.ApplyAsync(body.DialogId, body.MsgId, body.Action, ct);
|
||||
if (result is null)
|
||||
{
|
||||
return EndpointResults.NotFound(MessageNotFoundDetail);
|
||||
}
|
||||
|
||||
if (result.Error is not null)
|
||||
{
|
||||
return EndpointResults.BadRequest(result.Error);
|
||||
}
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
ok = result.Ok,
|
||||
learned = result.Learned,
|
||||
moved = result.Moved,
|
||||
leadId = result.LeadId,
|
||||
});
|
||||
}
|
||||
}
|
||||
using Deal.Api.Extensions;
|
||||
using Deal.Api.Services;
|
||||
using Deal.Contracts.Integrations.Abstractions;
|
||||
using Deal.Contracts.Integrations.Models;
|
||||
using Deal.Modules.Pipeline.Application.Models;
|
||||
using Deal.Modules.Pipeline.Application.Services;
|
||||
|
||||
namespace Deal.Api.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Эндпоинты ML-панели: GET /api/ml/status, POST /api/ml/reset, /predict, /candidates, /apply (Ruling 8, api-map §3.7).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Тела ответов 1:1 с прототипом <c>backend/app/routers/ml_routes.py</c> (L66–91, L112–171):
|
||||
/// <c>status</c> — MlStatusResponseDto (enabled/service/reachable/stats, §4.10 L363); <c>reset</c> —
|
||||
/// <c>{ok:true}</c> (мягкая ошибка {ok:false,error} зарезервирована — заглушка всегда успешна);
|
||||
/// <c>predict</c> — <c>{text: первые 200, take, label, scores, hits, ready, margin, terms, type}</c>
|
||||
/// (текст короче 2 символов после trim → 400 «Введите текст»); <c>candidates</c> — <c>{items}</c> реальных
|
||||
/// сообщений-кандидатов канала/выборки (очередь/отсев/карточки + мнение ML, §8; MlReviewService);
|
||||
/// <c>apply</c> — 404 «Исходное сообщение не найдено» либо результат ручной разметки
|
||||
/// <c>{ok, learned, moved, leadId}</c> (обучение ML + перенос/корзина/отсев). ml/learn и ml/flush
|
||||
/// НЕ реализуются (фронт не вызывает, api-map п.9 L399). Все эндпоинты требуют сессию: 401 {detail}
|
||||
/// (Ruling 10). IMlClient/MlReviewService резолвятся из RequestServices ПОСЛЕ проверки сессии (scoped
|
||||
/// на tenant-запрос — вне него не разрешимы, паттерн SettingsEndpoints/AiCheckEndpoint).
|
||||
/// </remarks>
|
||||
public static class MlEndpoints
|
||||
{
|
||||
// Префикс группы /api/ml (Ruling 8: MapMlEndpoints).
|
||||
private const string MlGroupPrefix = "/api/ml";
|
||||
|
||||
// OpenAPI-тег группы (в прототипе роутер ml — ml_routes.py).
|
||||
private const string MlOpenApiTag = "ml";
|
||||
|
||||
// Путь статуса ML (GET).
|
||||
private const string StatusPath = "/status";
|
||||
|
||||
// Путь сброса модели (POST).
|
||||
private const string ResetPath = "/reset";
|
||||
|
||||
// Путь проверки ML на тексте (POST).
|
||||
private const string PredictPath = "/predict";
|
||||
|
||||
// Путь разбора сообщений канала (POST).
|
||||
private const string CandidatesPath = "/candidates";
|
||||
|
||||
// Путь ручного решения по сообщению (POST).
|
||||
private const string ApplyPath = "/apply";
|
||||
|
||||
// Минимальная длина текста для проверки (ml_routes.py L87: len(text) < 2 → 400).
|
||||
private const int MinPredictTextLength = 2;
|
||||
|
||||
// Длина текста в ответе predict: первые 200 символов (ml_routes.py L90 text[:200]).
|
||||
private const int PredictTextPreviewLength = 200;
|
||||
|
||||
// Сообщение 400 для слишком короткого текста (ml_routes.py L88, план Task 9 L348).
|
||||
private const string EnterTextDetail = "Введите текст";
|
||||
|
||||
// Сообщение 404 apply: исходное сообщение не найдено (ml_routes.py L142, план Task 9 L352).
|
||||
private const string MessageNotFoundDetail = "Исходное сообщение не найдено";
|
||||
|
||||
/// <summary>
|
||||
/// Регистрирует группу /api/ml: status/reset/predict/candidates/apply.
|
||||
/// </summary>
|
||||
/// <param name="app">Построитель маршрутов приложения.</param>
|
||||
/// <returns>Построитель маршрутов для цепочки вызовов.</returns>
|
||||
public static IEndpointRouteBuilder MapMlEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup(MlGroupPrefix).WithTags(MlOpenApiTag);
|
||||
|
||||
group.MapGet(StatusPath, StatusAsync);
|
||||
group.MapPost(ResetPath, ResetAsync);
|
||||
group.MapPost(PredictPath, PredictAsync);
|
||||
group.MapPost(CandidatesPath, CandidatesAsync);
|
||||
group.MapPost(ApplyPath, ApplyAsync);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
// GET /api/ml/status: статус ML-сервиса + локальная статистика (ml_routes.py L66–75).
|
||||
private static async Task<IResult> StatusAsync(HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
IMlClient mlClient = context.RequestServices.GetRequiredService<IMlClient>();
|
||||
return Results.Ok(await mlClient.StatusAsync(ct));
|
||||
}
|
||||
|
||||
// POST /api/ml/reset: полный сброс модели + очистка очереди обучения (ml_routes.py L78–81).
|
||||
private static async Task<IResult> ResetAsync(HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
IMlClient mlClient = context.RequestServices.GetRequiredService<IMlClient>();
|
||||
return Results.Ok(await mlClient.ResetAsync(ct));
|
||||
}
|
||||
|
||||
// POST /api/ml/predict: проверка ML на тексте (ml_routes.py L84–90).
|
||||
private static async Task<IResult> PredictAsync(
|
||||
MlPredictRequest body,
|
||||
HttpContext context,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
string text = (body.Text ?? string.Empty).Trim();
|
||||
if (text.Length < MinPredictTextLength)
|
||||
{
|
||||
return EndpointResults.BadRequest(EnterTextDetail);
|
||||
}
|
||||
|
||||
IMlClient mlClient = context.RequestServices.GetRequiredService<IMlClient>();
|
||||
MlPredictResultDto result = await mlClient.PredictAsync(text, ct);
|
||||
|
||||
// Ответ 1:1 с ml_routes.py L90: {"text": <первые 200>, **результат предсказания}.
|
||||
string preview = text.Length <= PredictTextPreviewLength
|
||||
? text
|
||||
: text[..PredictTextPreviewLength];
|
||||
return Results.Ok(new
|
||||
{
|
||||
text = preview,
|
||||
take = result.Take,
|
||||
label = result.Label,
|
||||
scores = result.Scores,
|
||||
hits = result.Hits,
|
||||
ready = result.Ready,
|
||||
margin = result.Margin,
|
||||
terms = result.Terms,
|
||||
type = result.Type,
|
||||
});
|
||||
}
|
||||
|
||||
// POST /api/ml/candidates: последние сообщения канала + мнение ML (ml_routes.py L112–134).
|
||||
private static async Task<IResult> CandidatesAsync(
|
||||
MlCandidatesRequest body,
|
||||
HttpContext context,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
MlReviewService review = context.RequestServices.GetRequiredService<MlReviewService>();
|
||||
IReadOnlyList<MlCandidateDto> items = await review.CandidatesAsync(body.DialogId, body.Limit, ct);
|
||||
return Results.Ok(new { items });
|
||||
}
|
||||
|
||||
// POST /api/ml/apply: ручное решение по сообщению (ml_routes.py L137–171).
|
||||
private static async Task<IResult> ApplyAsync(
|
||||
MlApplyRequest body,
|
||||
HttpContext context,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
MlReviewService review = context.RequestServices.GetRequiredService<MlReviewService>();
|
||||
MlApplyResult? result = await review.ApplyAsync(body.DialogId, body.MsgId, body.Action, ct);
|
||||
if (result is null)
|
||||
{
|
||||
return EndpointResults.NotFound(MessageNotFoundDetail);
|
||||
}
|
||||
|
||||
if (result.Error is not null)
|
||||
{
|
||||
return EndpointResults.BadRequest(result.Error);
|
||||
}
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
ok = result.Ok,
|
||||
learned = result.Learned,
|
||||
moved = result.Moved,
|
||||
leadId = result.LeadId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
using Deal.Api.Services;
|
||||
using Deal.Api.Dtos;
|
||||
|
||||
namespace Deal.Api.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Тело POST /api/ml/predict. Входящий JSON — camelCase (text).
|
||||
/// </summary>
|
||||
/// <param name="Text">Текст сообщения для проверки ML (обрезается/тримится обработчиком, как ml_routes.py L86).</param>
|
||||
public sealed record MlPredictRequest(string Text);
|
||||
namespace Deal.Api.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Тело POST /api/ml/predict. Входящий JSON — camelCase (text).
|
||||
/// </summary>
|
||||
/// <param name="Text">Текст сообщения для проверки ML (обрезается/тримится обработчиком, как ml_routes.py L86).</param>
|
||||
public sealed record MlPredictRequest(string Text);
|
||||
|
||||
@@ -1,174 +1,169 @@
|
||||
using Deal.Api.Extensions;
|
||||
using Deal.Api.Models;
|
||||
using Deal.Api.Services;
|
||||
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.Dtos;
|
||||
|
||||
namespace Deal.Api.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Операторские read-only эндпоинты аналитики: /api/operator/analytics/{overview,tokens,activity} (этап 10, T3).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Только под операторской сессией: без неё 401 «Требуется вход оператора» (как прочие /api/operator/*).
|
||||
/// Ничего не меняет (read-only). groupBy — day|tenant|provider|model (неизвестное — 400 {detail}); from/to —
|
||||
/// ISO-8601 (включительно), как у аудита; activity поддерживает фильтры eventType/actorType/actorId/tenantId,
|
||||
/// limit (1..500) и offset. Все ответы — camelCase (контракт: docs/architecture/2026-09-10-operator-analytics-contract.md).
|
||||
/// </remarks>
|
||||
public static class OperatorAnalyticsEndpoints
|
||||
{
|
||||
// Префикс группы аналитики (Ruling 4 этапа 10).
|
||||
private const string AnalyticsGroupPrefix = "/api/operator/analytics";
|
||||
|
||||
// OpenAPI-тег группы.
|
||||
private const string OperatorOpenApiTag = "operator";
|
||||
|
||||
// Группировка расхода токенов по умолчанию (сутки).
|
||||
private const string DefaultGroupBy = TokenUsageGroupBys.Day;
|
||||
|
||||
// 400 tokens: неизвестная группировка.
|
||||
private const string InvalidGroupByDetail = "Неизвестная группировка (day|tenant|provider|model)";
|
||||
|
||||
/// <summary>
|
||||
/// Регистрирует группу /api/operator/analytics: overview/tokens/activity.
|
||||
/// </summary>
|
||||
/// <param name="app">Построитель маршрутов приложения.</param>
|
||||
/// <returns>Построитель маршрутов для цепочки вызовов.</returns>
|
||||
public static IEndpointRouteBuilder MapOperatorAnalyticsEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup(AnalyticsGroupPrefix).WithTags(OperatorOpenApiTag);
|
||||
group.MapGet("/overview", OverviewAsync);
|
||||
group.MapGet("/tokens", TokensAsync);
|
||||
group.MapGet("/activity", ActivityAsync);
|
||||
group.MapGet("/suspicious", SuspiciousAsync);
|
||||
return app;
|
||||
}
|
||||
|
||||
// GET /api/operator/analytics/suspicious?from=&to=: находки детектора подозрительной активности (§10.5).
|
||||
// from: Начало окна анализа (включительно; ISO-8601); null — последние 24 часа.
|
||||
// to: Конец окна анализа (включительно; ISO-8601); null — «сейчас».
|
||||
// context: Контекст запроса.
|
||||
// suspiciousService: Детектор подозрительной активности (scoped).
|
||||
// ct: Токен отмены.
|
||||
// Возвращает: 200 сводка находок или 401 без операторской сессии.
|
||||
private static async Task<IResult> SuspiciousAsync(
|
||||
DateTimeOffset? from,
|
||||
DateTimeOffset? to,
|
||||
HttpContext context,
|
||||
SuspiciousActivityService suspiciousService,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (context.GetCurrentOperator() is null)
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail);
|
||||
}
|
||||
|
||||
SuspiciousActivityDto report = await suspiciousService.AnalyzeAsync(from, to, ct);
|
||||
return Results.Ok(report);
|
||||
}
|
||||
|
||||
// GET /api/operator/analytics/overview?from=&to=: сводка (тенанты, токены, события, входы/выходы).
|
||||
// from: Начало периода (включительно; ISO-8601).
|
||||
// to: Конец периода (включительно; ISO-8601).
|
||||
// context: Контекст запроса.
|
||||
// analyticsService: Сервис аналитики (scoped).
|
||||
// ct: Токен отмены.
|
||||
// Возвращает: 200 сводка или 401 без операторской сессии.
|
||||
private static async Task<IResult> OverviewAsync(
|
||||
DateTimeOffset? from,
|
||||
DateTimeOffset? to,
|
||||
HttpContext context,
|
||||
AnalyticsService analyticsService,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (context.GetCurrentOperator() is null)
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail);
|
||||
}
|
||||
|
||||
AnalyticsOverviewDto overview = await analyticsService.OverviewAsync(from, to, ct);
|
||||
return Results.Ok(overview);
|
||||
}
|
||||
|
||||
// GET /api/operator/analytics/tokens?groupBy=&tenantId=&from=&to=: агрегаты расхода токенов.
|
||||
// groupBy: Группировка day|tenant|provider|model (дефолт day).
|
||||
// tenantId: Тенант (равенство; пусто — все тенанты).
|
||||
// from: Начало периода (включительно; ISO-8601).
|
||||
// to: Конец периода (включительно; ISO-8601).
|
||||
// context: Контекст запроса.
|
||||
// analyticsService: Сервис аналитики (scoped).
|
||||
// ct: Токен отмены.
|
||||
// Возвращает: 200 агрегаты, 400 неизвестная группировка или 401 без операторской сессии.
|
||||
private static async Task<IResult> TokensAsync(
|
||||
string? groupBy,
|
||||
Guid? tenantId,
|
||||
DateTimeOffset? from,
|
||||
DateTimeOffset? to,
|
||||
HttpContext context,
|
||||
AnalyticsService analyticsService,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (context.GetCurrentOperator() is null)
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail);
|
||||
}
|
||||
|
||||
string normalizedGroupBy = string.IsNullOrWhiteSpace(groupBy) ? DefaultGroupBy : groupBy;
|
||||
if (!IsKnownGroupBy(normalizedGroupBy))
|
||||
{
|
||||
return EndpointResults.BadRequest(InvalidGroupByDetail);
|
||||
}
|
||||
|
||||
AnalyticsTokensDto tokens = await analyticsService.TokensAsync(normalizedGroupBy, tenantId, from, to, ct);
|
||||
return Results.Ok(tokens);
|
||||
}
|
||||
|
||||
// GET /api/operator/analytics/activity?eventType=&actorType=&actorId=&tenantId=&from=&to=&limit=&offset=: лента действий.
|
||||
// eventType: Тип события (равенство; пусто — без фильтра).
|
||||
// actorType: Тип актора operator|tenant|system (равенство).
|
||||
// actorId: Идентификатор актора (равенство).
|
||||
// tenantId: Тенант (равенство).
|
||||
// from: Нижняя граница At (включительно; ISO-8601).
|
||||
// to: Верхняя граница At (включительно; ISO-8601).
|
||||
// limit: Размер страницы (дефолт 100, кламп 1..500).
|
||||
// offset: Смещение страницы (≥0).
|
||||
// context: Контекст запроса.
|
||||
// analyticsService: Сервис аналитики (scoped).
|
||||
// ct: Токен отмены.
|
||||
// Возвращает: 200 {items, total, limit, offset} или 401 без операторской сессии.
|
||||
private static async Task<IResult> ActivityAsync(
|
||||
string? eventType,
|
||||
string? actorType,
|
||||
Guid? actorId,
|
||||
Guid? tenantId,
|
||||
DateTimeOffset? from,
|
||||
DateTimeOffset? to,
|
||||
int? limit,
|
||||
int? offset,
|
||||
HttpContext context,
|
||||
AnalyticsService analyticsService,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (context.GetCurrentOperator() is null)
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail);
|
||||
}
|
||||
|
||||
AnalyticsActivityDto activity = await analyticsService.ActivityAsync(
|
||||
eventType, actorType, actorId, tenantId, from, to, limit, offset, ct);
|
||||
return Results.Ok(activity);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Известна ли группировка расхода токенов (day|tenant|provider|model).
|
||||
/// </summary>
|
||||
/// <param name="groupBy">Значение группировки.</param>
|
||||
/// <returns>True — поддерживаемая группировка.</returns>
|
||||
public static bool IsKnownGroupBy(string groupBy) =>
|
||||
groupBy is TokenUsageGroupBys.Day or TokenUsageGroupBys.Tenant
|
||||
or TokenUsageGroupBys.Provider or TokenUsageGroupBys.Model;
|
||||
}
|
||||
using Deal.Api.Extensions;
|
||||
using Deal.Api.Services;
|
||||
using Deal.Modules.Tenants.Application.Models;
|
||||
using Deal.Modules.Tenants.Application.Services;
|
||||
|
||||
namespace Deal.Api.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Операторские read-only эндпоинты аналитики: /api/operator/analytics/{overview,tokens,activity} (этап 10, T3).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Только под операторской сессией: без неё 401 «Требуется вход оператора» (как прочие /api/operator/*).
|
||||
/// Ничего не меняет (read-only). groupBy — day|tenant|provider|model (неизвестное — 400 {detail}); from/to —
|
||||
/// ISO-8601 (включительно), как у аудита; activity поддерживает фильтры eventType/actorType/actorId/tenantId,
|
||||
/// limit (1..500) и offset. Все ответы — camelCase (контракт: docs/architecture/2026-09-10-operator-analytics-contract.md).
|
||||
/// </remarks>
|
||||
public static class OperatorAnalyticsEndpoints
|
||||
{
|
||||
// Префикс группы аналитики (Ruling 4 этапа 10).
|
||||
private const string AnalyticsGroupPrefix = "/api/operator/analytics";
|
||||
|
||||
// OpenAPI-тег группы.
|
||||
private const string OperatorOpenApiTag = "operator";
|
||||
|
||||
// Группировка расхода токенов по умолчанию (сутки).
|
||||
private const string DefaultGroupBy = TokenUsageGroupBys.Day;
|
||||
|
||||
// 400 tokens: неизвестная группировка.
|
||||
private const string InvalidGroupByDetail = "Неизвестная группировка (day|tenant|provider|model)";
|
||||
|
||||
/// <summary>
|
||||
/// Регистрирует группу /api/operator/analytics: overview/tokens/activity.
|
||||
/// </summary>
|
||||
/// <param name="app">Построитель маршрутов приложения.</param>
|
||||
/// <returns>Построитель маршрутов для цепочки вызовов.</returns>
|
||||
public static IEndpointRouteBuilder MapOperatorAnalyticsEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup(AnalyticsGroupPrefix).WithTags(OperatorOpenApiTag);
|
||||
group.MapGet("/overview", OverviewAsync);
|
||||
group.MapGet("/tokens", TokensAsync);
|
||||
group.MapGet("/activity", ActivityAsync);
|
||||
group.MapGet("/suspicious", SuspiciousAsync);
|
||||
return app;
|
||||
}
|
||||
|
||||
// GET /api/operator/analytics/suspicious?from=&to=: находки детектора подозрительной активности (§10.5).
|
||||
// from: Начало окна анализа (включительно; ISO-8601); null — последние 24 часа.
|
||||
// to: Конец окна анализа (включительно; ISO-8601); null — «сейчас».
|
||||
// context: Контекст запроса.
|
||||
// suspiciousService: Детектор подозрительной активности (scoped).
|
||||
// ct: Токен отмены.
|
||||
// Возвращает: 200 сводка находок или 401 без операторской сессии.
|
||||
private static async Task<IResult> SuspiciousAsync(
|
||||
DateTimeOffset? from,
|
||||
DateTimeOffset? to,
|
||||
HttpContext context,
|
||||
SuspiciousActivityService suspiciousService,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (context.GetCurrentOperator() is null)
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail);
|
||||
}
|
||||
|
||||
SuspiciousActivityDto report = await suspiciousService.AnalyzeAsync(from, to, ct);
|
||||
return Results.Ok(report);
|
||||
}
|
||||
|
||||
// GET /api/operator/analytics/overview?from=&to=: сводка (тенанты, токены, события, входы/выходы).
|
||||
// from: Начало периода (включительно; ISO-8601).
|
||||
// to: Конец периода (включительно; ISO-8601).
|
||||
// context: Контекст запроса.
|
||||
// analyticsService: Сервис аналитики (scoped).
|
||||
// ct: Токен отмены.
|
||||
// Возвращает: 200 сводка или 401 без операторской сессии.
|
||||
private static async Task<IResult> OverviewAsync(
|
||||
DateTimeOffset? from,
|
||||
DateTimeOffset? to,
|
||||
HttpContext context,
|
||||
AnalyticsService analyticsService,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (context.GetCurrentOperator() is null)
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail);
|
||||
}
|
||||
|
||||
AnalyticsOverviewDto overview = await analyticsService.OverviewAsync(from, to, ct);
|
||||
return Results.Ok(overview);
|
||||
}
|
||||
|
||||
// GET /api/operator/analytics/tokens?groupBy=&tenantId=&from=&to=: агрегаты расхода токенов.
|
||||
// groupBy: Группировка day|tenant|provider|model (дефолт day).
|
||||
// tenantId: Тенант (равенство; пусто — все тенанты).
|
||||
// from: Начало периода (включительно; ISO-8601).
|
||||
// to: Конец периода (включительно; ISO-8601).
|
||||
// context: Контекст запроса.
|
||||
// analyticsService: Сервис аналитики (scoped).
|
||||
// ct: Токен отмены.
|
||||
// Возвращает: 200 агрегаты, 400 неизвестная группировка или 401 без операторской сессии.
|
||||
private static async Task<IResult> TokensAsync(
|
||||
string? groupBy,
|
||||
Guid? tenantId,
|
||||
DateTimeOffset? from,
|
||||
DateTimeOffset? to,
|
||||
HttpContext context,
|
||||
AnalyticsService analyticsService,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (context.GetCurrentOperator() is null)
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail);
|
||||
}
|
||||
|
||||
string normalizedGroupBy = string.IsNullOrWhiteSpace(groupBy) ? DefaultGroupBy : groupBy;
|
||||
if (!IsKnownGroupBy(normalizedGroupBy))
|
||||
{
|
||||
return EndpointResults.BadRequest(InvalidGroupByDetail);
|
||||
}
|
||||
|
||||
AnalyticsTokensDto tokens = await analyticsService.TokensAsync(normalizedGroupBy, tenantId, from, to, ct);
|
||||
return Results.Ok(tokens);
|
||||
}
|
||||
|
||||
// GET /api/operator/analytics/activity?eventType=&actorType=&actorId=&tenantId=&from=&to=&limit=&offset=: лента действий.
|
||||
// eventType: Тип события (равенство; пусто — без фильтра).
|
||||
// actorType: Тип актора operator|tenant|system (равенство).
|
||||
// actorId: Идентификатор актора (равенство).
|
||||
// tenantId: Тенант (равенство).
|
||||
// from: Нижняя граница At (включительно; ISO-8601).
|
||||
// to: Верхняя граница At (включительно; ISO-8601).
|
||||
// limit: Размер страницы (дефолт 100, кламп 1..500).
|
||||
// offset: Смещение страницы (≥0).
|
||||
// context: Контекст запроса.
|
||||
// analyticsService: Сервис аналитики (scoped).
|
||||
// ct: Токен отмены.
|
||||
// Возвращает: 200 {items, total, limit, offset} или 401 без операторской сессии.
|
||||
private static async Task<IResult> ActivityAsync(
|
||||
string? eventType,
|
||||
string? actorType,
|
||||
Guid? actorId,
|
||||
Guid? tenantId,
|
||||
DateTimeOffset? from,
|
||||
DateTimeOffset? to,
|
||||
int? limit,
|
||||
int? offset,
|
||||
HttpContext context,
|
||||
AnalyticsService analyticsService,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (context.GetCurrentOperator() is null)
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail);
|
||||
}
|
||||
|
||||
AnalyticsActivityDto activity = await analyticsService.ActivityAsync(
|
||||
eventType, actorType, actorId, tenantId, from, to, limit, offset, ct);
|
||||
return Results.Ok(activity);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Известна ли группировка расхода токенов (day|tenant|provider|model).
|
||||
/// </summary>
|
||||
/// <param name="groupBy">Значение группировки.</param>
|
||||
/// <returns>True — поддерживаемая группировка.</returns>
|
||||
public static bool IsKnownGroupBy(string groupBy) =>
|
||||
groupBy is TokenUsageGroupBys.Day or TokenUsageGroupBys.Tenant
|
||||
or TokenUsageGroupBys.Provider or TokenUsageGroupBys.Model;
|
||||
}
|
||||
|
||||
@@ -1,100 +1,95 @@
|
||||
using Deal.Api.Extensions;
|
||||
using Deal.Api.Models;
|
||||
using Deal.Api.Services;
|
||||
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.Dtos;
|
||||
|
||||
namespace Deal.Api.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Операторский эндпоинт чтения аудита: GET /api/operator/audit (Ruling 4 этапа 7).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Чтение — только оператору: без операторской сессии 401 «Требуется вход оператора» (как /api/operator/auth/me).
|
||||
/// Фильтры-query: eventType, actorType, tenantId, from, to, limit (At DESC, limit клампится в
|
||||
/// 1..<see cref="AuditService.MaxQueryLimit"/>, дефолт — <see cref="AuditService.DefaultQueryLimit"/>).
|
||||
/// Ответ — {items: [...], total}: total — полное число записей по фильтру (без учёта limit). Запись событий —
|
||||
/// только через <see cref="AuditService"/> (append-only); этот эндпоинт лишь читает.
|
||||
/// </remarks>
|
||||
public static class OperatorAuditEndpoints
|
||||
{
|
||||
// Префикс группы операторских ручек /api/operator (Ruling 11).
|
||||
private const string OperatorGroupPrefix = "/api/operator";
|
||||
|
||||
// Путь ленты аудита относительно группы.
|
||||
private const string AuditPath = "/audit";
|
||||
|
||||
// OpenAPI-тег группы (Ruling 11: операторская админка — API-only).
|
||||
private const string OperatorOpenApiTag = "operator";
|
||||
|
||||
/// <summary>
|
||||
/// Регистрирует группу /api/operator: GET /audit (лента аудита; другие ручки — задачи 5/7/10).
|
||||
/// </summary>
|
||||
/// <param name="app">Построитель маршрутов приложения.</param>
|
||||
/// <returns>Построитель маршрутов для цепочки вызовов.</returns>
|
||||
public static IEndpointRouteBuilder MapOperatorAuditEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
app.MapGroup(OperatorGroupPrefix).WithTags(OperatorOpenApiTag).MapGet(AuditPath, ListAsync);
|
||||
return app;
|
||||
}
|
||||
|
||||
// GET /api/operator/audit?eventType=&actorType=&actorId=&tenantId=&from=&to=&limit=&offset=: лента аудита.
|
||||
// eventType: Фильтр по типу события (равенство; пусто — без фильтра).
|
||||
// actorType: Фильтр по типу актора operator|tenant|system (равенство).
|
||||
// actorId: Фильтр по идентификатору актора (равенство).
|
||||
// tenantId: Фильтр по тенанту (равенство).
|
||||
// from: Нижняя граница At (включительно; ISO-8601).
|
||||
// to: Верхняя граница At (включительно; ISO-8601).
|
||||
// limit: Размер выборки (дефолт 100, клампится 1..500).
|
||||
// offset: Смещение страницы (≥0; этап 10, T3).
|
||||
// context: Контекст запроса.
|
||||
// auditService: Сервис аудита (scoped).
|
||||
// ct: Токен отмены.
|
||||
// Возвращает: 200 {items:[...], total} или 401 без операторской сессии.
|
||||
private static async Task<IResult> ListAsync(
|
||||
string? eventType,
|
||||
string? actorType,
|
||||
Guid? actorId,
|
||||
Guid? tenantId,
|
||||
DateTimeOffset? from,
|
||||
DateTimeOffset? to,
|
||||
int? limit,
|
||||
int? offset,
|
||||
HttpContext context,
|
||||
AuditService auditService,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var operatorIdentity = context.GetCurrentOperator();
|
||||
if (operatorIdentity is null)
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail);
|
||||
}
|
||||
|
||||
var filter = new AuditQueryDto(
|
||||
eventType, actorType, tenantId, from, to, NormalizeLimit(limit), actorId, NormalizeOffset(offset));
|
||||
IReadOnlyList<AuditRecordDto> items = await auditService.QueryAsync(filter, ct);
|
||||
int total = await auditService.CountAsync(filter, ct);
|
||||
return Results.Ok(new { items, total });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Нормализует limit запроса: дефолт <see cref="AuditService.DefaultQueryLimit"/>, кламп 1..500 (Ruling 4).
|
||||
/// </summary>
|
||||
/// <param name="limit">Запрошенный размер выборки (null — не задан).</param>
|
||||
/// <returns>Значение для фильтра.</returns>
|
||||
public static int NormalizeLimit(int? limit) =>
|
||||
limit is null
|
||||
? AuditService.DefaultQueryLimit
|
||||
: Math.Max(1, Math.Min(AuditService.MaxQueryLimit, limit.Value));
|
||||
|
||||
/// <summary>
|
||||
/// Нормализует offset запроса: отрицательное/отсутствующее — 0 (этап 10, T3).
|
||||
/// </summary>
|
||||
/// <param name="offset">Запрошенное смещение (null — не задано).</param>
|
||||
/// <returns>Неотрицательное смещение.</returns>
|
||||
public static int NormalizeOffset(int? offset) => Math.Max(0, offset ?? 0);
|
||||
}
|
||||
using Deal.Api.Extensions;
|
||||
using Deal.Api.Services;
|
||||
using Deal.Modules.Tenants.Application.Models;
|
||||
using Deal.Modules.Tenants.Application.Services;
|
||||
|
||||
namespace Deal.Api.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Операторский эндпоинт чтения аудита: GET /api/operator/audit (Ruling 4 этапа 7).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Чтение — только оператору: без операторской сессии 401 «Требуется вход оператора» (как /api/operator/auth/me).
|
||||
/// Фильтры-query: eventType, actorType, tenantId, from, to, limit (At DESC, limit клампится в
|
||||
/// 1..<see cref="AuditService.MaxQueryLimit"/>, дефолт — <see cref="AuditService.DefaultQueryLimit"/>).
|
||||
/// Ответ — {items: [...], total}: total — полное число записей по фильтру (без учёта limit). Запись событий —
|
||||
/// только через <see cref="AuditService"/> (append-only); этот эндпоинт лишь читает.
|
||||
/// </remarks>
|
||||
public static class OperatorAuditEndpoints
|
||||
{
|
||||
// Префикс группы операторских ручек /api/operator (Ruling 11).
|
||||
private const string OperatorGroupPrefix = "/api/operator";
|
||||
|
||||
// Путь ленты аудита относительно группы.
|
||||
private const string AuditPath = "/audit";
|
||||
|
||||
// OpenAPI-тег группы (Ruling 11: операторская админка — API-only).
|
||||
private const string OperatorOpenApiTag = "operator";
|
||||
|
||||
/// <summary>
|
||||
/// Регистрирует группу /api/operator: GET /audit (лента аудита; другие ручки — задачи 5/7/10).
|
||||
/// </summary>
|
||||
/// <param name="app">Построитель маршрутов приложения.</param>
|
||||
/// <returns>Построитель маршрутов для цепочки вызовов.</returns>
|
||||
public static IEndpointRouteBuilder MapOperatorAuditEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
app.MapGroup(OperatorGroupPrefix).WithTags(OperatorOpenApiTag).MapGet(AuditPath, ListAsync);
|
||||
return app;
|
||||
}
|
||||
|
||||
// GET /api/operator/audit?eventType=&actorType=&actorId=&tenantId=&from=&to=&limit=&offset=: лента аудита.
|
||||
// eventType: Фильтр по типу события (равенство; пусто — без фильтра).
|
||||
// actorType: Фильтр по типу актора operator|tenant|system (равенство).
|
||||
// actorId: Фильтр по идентификатору актора (равенство).
|
||||
// tenantId: Фильтр по тенанту (равенство).
|
||||
// from: Нижняя граница At (включительно; ISO-8601).
|
||||
// to: Верхняя граница At (включительно; ISO-8601).
|
||||
// limit: Размер выборки (дефолт 100, клампится 1..500).
|
||||
// offset: Смещение страницы (≥0; этап 10, T3).
|
||||
// context: Контекст запроса.
|
||||
// auditService: Сервис аудита (scoped).
|
||||
// ct: Токен отмены.
|
||||
// Возвращает: 200 {items:[...], total} или 401 без операторской сессии.
|
||||
private static async Task<IResult> ListAsync(
|
||||
string? eventType,
|
||||
string? actorType,
|
||||
Guid? actorId,
|
||||
Guid? tenantId,
|
||||
DateTimeOffset? from,
|
||||
DateTimeOffset? to,
|
||||
int? limit,
|
||||
int? offset,
|
||||
HttpContext context,
|
||||
AuditService auditService,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var operatorIdentity = context.GetCurrentOperator();
|
||||
if (operatorIdentity is null)
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail);
|
||||
}
|
||||
|
||||
var filter = new AuditQueryDto(
|
||||
eventType, actorType, tenantId, from, to, NormalizeLimit(limit), actorId, NormalizeOffset(offset));
|
||||
IReadOnlyList<AuditRecordDto> items = await auditService.QueryAsync(filter, ct);
|
||||
int total = await auditService.CountAsync(filter, ct);
|
||||
return Results.Ok(new { items, total });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Нормализует limit запроса: дефолт <see cref="AuditService.DefaultQueryLimit"/>, кламп 1..500 (Ruling 4).
|
||||
/// </summary>
|
||||
/// <param name="limit">Запрошенный размер выборки (null — не задан).</param>
|
||||
/// <returns>Значение для фильтра.</returns>
|
||||
public static int NormalizeLimit(int? limit) =>
|
||||
limit is null
|
||||
? AuditService.DefaultQueryLimit
|
||||
: Math.Max(1, Math.Min(AuditService.MaxQueryLimit, limit.Value));
|
||||
|
||||
/// <summary>
|
||||
/// Нормализует offset запроса: отрицательное/отсутствующее — 0 (этап 10, T3).
|
||||
/// </summary>
|
||||
/// <param name="offset">Запрошенное смещение (null — не задано).</param>
|
||||
/// <returns>Неотрицательное смещение.</returns>
|
||||
public static int NormalizeOffset(int? offset) => Math.Max(0, offset ?? 0);
|
||||
}
|
||||
|
||||
@@ -1,180 +1,176 @@
|
||||
using Deal.Api.Extensions;
|
||||
using Deal.Api.Models;
|
||||
using Deal.Api.Services;
|
||||
using Deal.Api.Middleware;
|
||||
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 Microsoft.Extensions.Options;
|
||||
using Deal.Api.Dtos;
|
||||
using AspNetCoreCookieOptions = Microsoft.AspNetCore.Http.CookieOptions;
|
||||
// Имя конфигурационного типа совпадает с Microsoft.AspNetCore.Http.CookieOptions — фиксируем алиасами.
|
||||
using OperatorCookieOptions = Deal.Api.Configuration.OperatorCookieOptions;
|
||||
|
||||
namespace Deal.Api.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// HTTP-эндпоинты аутентификации оператора (группа /api/operator/auth). Зеркало AuthEndpoints для операторов (Ruling 1).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Оператор ≠ пользователь тенанта: вход по отдельным public-таблицам (OperatorAuthService/IOperatorAuthStore),
|
||||
/// сессия — в куке deal_operator_session (отдельная от deal_session; 12 ч, httpOnly, SameSite=Lax).
|
||||
/// Успех-ответы — <c>{ok:true,...}</c>, ошибки — HTTP-код + <c>{"detail":"..."}</c> (Ruling 10). Защищённые
|
||||
/// ручки (me) требуют операторскую сессию (401 «Требуется вход оператора») — тенантная кука не проходит.
|
||||
/// Результаты входа пишутся в аудит (operator_login_ok/failed, Task 4/Ruling 4).
|
||||
/// </remarks>
|
||||
public static class OperatorAuthEndpoints
|
||||
{
|
||||
private const string InvalidCredentialsDetail = "Неверный логин или пароль оператора";
|
||||
private const string OperatorAuthGroupPrefix = "/api/operator/auth";
|
||||
private const string OperatorAuthOpenApiTag = "operator-auth";
|
||||
|
||||
/// <summary>
|
||||
/// Регистрирует группу /api/operator/auth: login, logout, me.
|
||||
/// </summary>
|
||||
/// <param name="app">Построитель маршрутов приложения.</param>
|
||||
/// <returns>Построитель маршрутов для цепочки вызовов.</returns>
|
||||
public static IEndpointRouteBuilder MapOperatorAuthEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup(OperatorAuthGroupPrefix).WithTags(OperatorAuthOpenApiTag);
|
||||
|
||||
// Политика "auth" rate limiter (план Task 11, Ruling 5): фиксированное окно 10/мин на IP ручки
|
||||
// входа оператора; остальные ручки группы — под глобальной API-политикой (по тенанту/IP).
|
||||
group.MapPost("/login", LoginAsync).RequireRateLimiting(RateLimitPolicies.AuthPolicy);
|
||||
group.MapPost("/logout", LogoutAsync);
|
||||
group.MapGet("/me", MeAsync);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
// POST /api/operator/auth/login: проверка учётных данных оператора, выдача куки сессии; результат пишется в аудит (Task 4).
|
||||
// До OperatorAuthService отрабатывает LoginAttemptGuard (5 неудач ip|login за 15 мин → 429, Ruling 5).
|
||||
private static async Task<IResult> LoginAsync(
|
||||
LoginRequest body,
|
||||
OperatorAuthService operatorAuthService,
|
||||
AuditService auditService,
|
||||
IOptions<OperatorCookieOptions> cookieOptions,
|
||||
HttpContext context,
|
||||
CancellationToken ct,
|
||||
LoginAttemptGuard loginAttemptGuard)
|
||||
{
|
||||
string? attemptedLogin = NormalizeLogin(body.Login);
|
||||
|
||||
// Защита входа оператора (план Task 11, Ruling 5): зеркало AuthEndpoints — блокировка ключа
|
||||
// ip|login до проверки учётных данных (в dev при RateLimit:Enabled=false гвард выключен).
|
||||
if (await loginAttemptGuard.IsBlockedAsync(ClientIp(context), attemptedLogin, ct))
|
||||
{
|
||||
return EndpointResults.TooManyRequests(LoginAttemptGuard.BlockedDetail);
|
||||
}
|
||||
|
||||
var result = await operatorAuthService.LoginAsync(body.Login, body.Password, ct);
|
||||
if (result.Login is null || result.Token is null)
|
||||
{
|
||||
// Неверные учётные данные оператора — одно сообщение (зеркало AuthEndpoints).
|
||||
// Аудит operator_login_failed — только для реальной попытки (непустой логин), без пароля (Ruling 4);
|
||||
// счётчик неудач гварда растёт там же (пустые логины ключа не имеют).
|
||||
if (attemptedLogin is not null)
|
||||
{
|
||||
await loginAttemptGuard.RecordFailureAsync(ClientIp(context), attemptedLogin, ct);
|
||||
await auditService.AppendAsync(new AuditRecordDto(
|
||||
AuditEvents.OperatorLoginFailed,
|
||||
AuditActorTypes.Operator,
|
||||
ActorId: null,
|
||||
TenantId: null,
|
||||
Ip: ClientIp(context),
|
||||
DetailJson: AuditService.ToDetailJson(new { login = attemptedLogin })), ct);
|
||||
}
|
||||
|
||||
return EndpointResults.Unauthorized(InvalidCredentialsDetail);
|
||||
}
|
||||
|
||||
// Успешный вход оператора сбрасывает счётчик неудач ключа ip|login (Ruling 5).
|
||||
await loginAttemptGuard.ResetAsync(ClientIp(context), result.Login, ct);
|
||||
|
||||
await auditService.AppendAsync(new AuditRecordDto(
|
||||
AuditEvents.OperatorLoginOk,
|
||||
AuditActorTypes.Operator,
|
||||
ActorId: result.OperatorId,
|
||||
TenantId: null,
|
||||
Ip: ClientIp(context),
|
||||
DetailJson: AuditService.ToDetailJson(new { login = result.Login })), ct);
|
||||
|
||||
SetOperatorSessionCookie(context, cookieOptions.Value, result.Token);
|
||||
return Results.Ok(new { ok = true, login = result.Login });
|
||||
}
|
||||
|
||||
// POST /api/operator/auth/logout: удаление операторской сессии по токену из куки и очистка куки (всегда ok).
|
||||
private static async Task<IResult> LogoutAsync(
|
||||
OperatorAuthService operatorAuthService,
|
||||
IOptions<OperatorCookieOptions> cookieOptions,
|
||||
HttpContext context,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var cookieName = cookieOptions.Value.Name;
|
||||
var rawToken = context.Request.Cookies[cookieName];
|
||||
// Оператор разрешённой сессии — до её удаления (OperatorSessionMiddleware наполнил Items).
|
||||
CurrentOperator? operatorIdentity = context.GetCurrentOperator();
|
||||
await operatorAuthService.LogoutAsync(rawToken, ct);
|
||||
context.Response.Cookies.Delete(cookieName);
|
||||
|
||||
// Выход оператора (этап 10, T1): событие пишется при живой разрешённой сессии.
|
||||
if (operatorIdentity is not null)
|
||||
{
|
||||
await AuditAppender.AppendOperatorAsync(context, AuditEvents.OperatorLogout, new { login = operatorIdentity.Login }, ct);
|
||||
}
|
||||
|
||||
return Results.Ok(new { ok = true });
|
||||
}
|
||||
|
||||
// GET /api/operator/auth/me: проверка живой операторской сессии (401 без неё, Ruling 1).
|
||||
private static IResult MeAsync(HttpContext context)
|
||||
{
|
||||
var operatorIdentity = context.GetCurrentOperator();
|
||||
if (operatorIdentity is null)
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail);
|
||||
}
|
||||
|
||||
return Results.Ok(new { login = operatorIdentity.Login, ok = true });
|
||||
}
|
||||
|
||||
// Выставляет httpOnly-куку сессии оператора: SameSite=Lax, Path=/, MaxAge=Hours, Secure — из конфига.
|
||||
// context: Контекст запроса.
|
||||
// options: Настройки куки из конфигурации (секция OperatorCookies).
|
||||
// rawToken: Raw-токен операторской сессии.
|
||||
private static void SetOperatorSessionCookie(
|
||||
HttpContext context,
|
||||
OperatorCookieOptions options,
|
||||
string rawToken)
|
||||
{
|
||||
// MaxAge — OperatorCookies:Hours; код-дефолт значения ссылается на
|
||||
// OperatorAuthService.SessionLifetimeHours (единый источник «12 часов», см. OperatorCookieOptions).
|
||||
context.Response.Cookies.Append(
|
||||
options.Name,
|
||||
rawToken,
|
||||
new AspNetCoreCookieOptions
|
||||
{
|
||||
HttpOnly = true,
|
||||
SameSite = SameSiteMode.Lax,
|
||||
Path = "/",
|
||||
MaxAge = TimeSpan.FromHours(options.Hours),
|
||||
Secure = options.Secure,
|
||||
});
|
||||
}
|
||||
|
||||
// Нормализованная попытка логина для аудита (нижний регистр/обрезка); null — писать нечего.
|
||||
// login: Логин из тела запроса.
|
||||
// Возвращает: Нормализованный логин или null при пустом/пробельном входе.
|
||||
private static string? NormalizeLogin(string? login)
|
||||
{
|
||||
string? normalized = login?.Trim().ToLowerInvariant();
|
||||
return string.IsNullOrEmpty(normalized) ? null : normalized;
|
||||
}
|
||||
|
||||
// IP-адрес клиента для аудита (без порта; null, если недоступен).
|
||||
// context: Контекст запроса.
|
||||
// Возвращает: Строковое представление IP или null.
|
||||
private static string? ClientIp(HttpContext context) => context.Connection.RemoteIpAddress?.ToString();
|
||||
}
|
||||
using Deal.Api.Extensions;
|
||||
using Deal.Api.Middleware;
|
||||
using Deal.Api.Models;
|
||||
using Deal.Api.Services;
|
||||
using Deal.Modules.Tenants.Application.Models;
|
||||
using Deal.Modules.Tenants.Application.Services;
|
||||
using Microsoft.Extensions.Options;
|
||||
using AspNetCoreCookieOptions = Microsoft.AspNetCore.Http.CookieOptions;
|
||||
// Имя конфигурационного типа совпадает с Microsoft.AspNetCore.Http.CookieOptions — фиксируем алиасами.
|
||||
using OperatorCookieOptions = Deal.Api.Configuration.OperatorCookieOptions;
|
||||
|
||||
namespace Deal.Api.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// HTTP-эндпоинты аутентификации оператора (группа /api/operator/auth). Зеркало AuthEndpoints для операторов (Ruling 1).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Оператор ≠ пользователь тенанта: вход по отдельным public-таблицам (OperatorAuthService/IOperatorAuthStore),
|
||||
/// сессия — в куке deal_operator_session (отдельная от deal_session; 12 ч, httpOnly, SameSite=Lax).
|
||||
/// Успех-ответы — <c>{ok:true,...}</c>, ошибки — HTTP-код + <c>{"detail":"..."}</c> (Ruling 10). Защищённые
|
||||
/// ручки (me) требуют операторскую сессию (401 «Требуется вход оператора») — тенантная кука не проходит.
|
||||
/// Результаты входа пишутся в аудит (operator_login_ok/failed, Task 4/Ruling 4).
|
||||
/// </remarks>
|
||||
public static class OperatorAuthEndpoints
|
||||
{
|
||||
private const string InvalidCredentialsDetail = "Неверный логин или пароль оператора";
|
||||
private const string OperatorAuthGroupPrefix = "/api/operator/auth";
|
||||
private const string OperatorAuthOpenApiTag = "operator-auth";
|
||||
|
||||
/// <summary>
|
||||
/// Регистрирует группу /api/operator/auth: login, logout, me.
|
||||
/// </summary>
|
||||
/// <param name="app">Построитель маршрутов приложения.</param>
|
||||
/// <returns>Построитель маршрутов для цепочки вызовов.</returns>
|
||||
public static IEndpointRouteBuilder MapOperatorAuthEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup(OperatorAuthGroupPrefix).WithTags(OperatorAuthOpenApiTag);
|
||||
|
||||
// Политика "auth" rate limiter (план Task 11, Ruling 5): фиксированное окно 10/мин на IP ручки
|
||||
// входа оператора; остальные ручки группы — под глобальной API-политикой (по тенанту/IP).
|
||||
group.MapPost("/login", LoginAsync).RequireRateLimiting(RateLimitPolicies.AuthPolicy);
|
||||
group.MapPost("/logout", LogoutAsync);
|
||||
group.MapGet("/me", MeAsync);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
// POST /api/operator/auth/login: проверка учётных данных оператора, выдача куки сессии; результат пишется в аудит (Task 4).
|
||||
// До OperatorAuthService отрабатывает LoginAttemptGuard (5 неудач ip|login за 15 мин → 429, Ruling 5).
|
||||
private static async Task<IResult> LoginAsync(
|
||||
LoginRequest body,
|
||||
OperatorAuthService operatorAuthService,
|
||||
AuditService auditService,
|
||||
IOptions<OperatorCookieOptions> cookieOptions,
|
||||
HttpContext context,
|
||||
CancellationToken ct,
|
||||
LoginAttemptGuard loginAttemptGuard)
|
||||
{
|
||||
string? attemptedLogin = NormalizeLogin(body.Login);
|
||||
|
||||
// Защита входа оператора (план Task 11, Ruling 5): зеркало AuthEndpoints — блокировка ключа
|
||||
// ip|login до проверки учётных данных (в dev при RateLimit:Enabled=false гвард выключен).
|
||||
if (await loginAttemptGuard.IsBlockedAsync(ClientIp(context), attemptedLogin, ct))
|
||||
{
|
||||
return EndpointResults.TooManyRequests(LoginAttemptGuard.BlockedDetail);
|
||||
}
|
||||
|
||||
var result = await operatorAuthService.LoginAsync(body.Login, body.Password, ct);
|
||||
if (result.Login is null || result.Token is null)
|
||||
{
|
||||
// Неверные учётные данные оператора — одно сообщение (зеркало AuthEndpoints).
|
||||
// Аудит operator_login_failed — только для реальной попытки (непустой логин), без пароля (Ruling 4);
|
||||
// счётчик неудач гварда растёт там же (пустые логины ключа не имеют).
|
||||
if (attemptedLogin is not null)
|
||||
{
|
||||
await loginAttemptGuard.RecordFailureAsync(ClientIp(context), attemptedLogin, ct);
|
||||
await auditService.AppendAsync(new AuditRecordDto(
|
||||
AuditEvents.OperatorLoginFailed,
|
||||
AuditActorTypes.Operator,
|
||||
ActorId: null,
|
||||
TenantId: null,
|
||||
Ip: ClientIp(context),
|
||||
DetailJson: AuditService.ToDetailJson(new { login = attemptedLogin })), ct);
|
||||
}
|
||||
|
||||
return EndpointResults.Unauthorized(InvalidCredentialsDetail);
|
||||
}
|
||||
|
||||
// Успешный вход оператора сбрасывает счётчик неудач ключа ip|login (Ruling 5).
|
||||
await loginAttemptGuard.ResetAsync(ClientIp(context), result.Login, ct);
|
||||
|
||||
await auditService.AppendAsync(new AuditRecordDto(
|
||||
AuditEvents.OperatorLoginOk,
|
||||
AuditActorTypes.Operator,
|
||||
ActorId: result.OperatorId,
|
||||
TenantId: null,
|
||||
Ip: ClientIp(context),
|
||||
DetailJson: AuditService.ToDetailJson(new { login = result.Login })), ct);
|
||||
|
||||
SetOperatorSessionCookie(context, cookieOptions.Value, result.Token);
|
||||
return Results.Ok(new { ok = true, login = result.Login });
|
||||
}
|
||||
|
||||
// POST /api/operator/auth/logout: удаление операторской сессии по токену из куки и очистка куки (всегда ok).
|
||||
private static async Task<IResult> LogoutAsync(
|
||||
OperatorAuthService operatorAuthService,
|
||||
IOptions<OperatorCookieOptions> cookieOptions,
|
||||
HttpContext context,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var cookieName = cookieOptions.Value.Name;
|
||||
var rawToken = context.Request.Cookies[cookieName];
|
||||
// Оператор разрешённой сессии — до её удаления (OperatorSessionMiddleware наполнил Items).
|
||||
CurrentOperator? operatorIdentity = context.GetCurrentOperator();
|
||||
await operatorAuthService.LogoutAsync(rawToken, ct);
|
||||
context.Response.Cookies.Delete(cookieName);
|
||||
|
||||
// Выход оператора (этап 10, T1): событие пишется при живой разрешённой сессии.
|
||||
if (operatorIdentity is not null)
|
||||
{
|
||||
await AuditAppender.AppendOperatorAsync(context, AuditEvents.OperatorLogout, new { login = operatorIdentity.Login }, ct);
|
||||
}
|
||||
|
||||
return Results.Ok(new { ok = true });
|
||||
}
|
||||
|
||||
// GET /api/operator/auth/me: проверка живой операторской сессии (401 без неё, Ruling 1).
|
||||
private static IResult MeAsync(HttpContext context)
|
||||
{
|
||||
var operatorIdentity = context.GetCurrentOperator();
|
||||
if (operatorIdentity is null)
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail);
|
||||
}
|
||||
|
||||
return Results.Ok(new { login = operatorIdentity.Login, ok = true });
|
||||
}
|
||||
|
||||
// Выставляет httpOnly-куку сессии оператора: SameSite=Lax, Path=/, MaxAge=Hours, Secure — из конфига.
|
||||
// context: Контекст запроса.
|
||||
// options: Настройки куки из конфигурации (секция OperatorCookies).
|
||||
// rawToken: Raw-токен операторской сессии.
|
||||
private static void SetOperatorSessionCookie(
|
||||
HttpContext context,
|
||||
OperatorCookieOptions options,
|
||||
string rawToken)
|
||||
{
|
||||
// MaxAge — OperatorCookies:Hours; код-дефолт значения ссылается на
|
||||
// OperatorAuthService.SessionLifetimeHours (единый источник «12 часов», см. OperatorCookieOptions).
|
||||
context.Response.Cookies.Append(
|
||||
options.Name,
|
||||
rawToken,
|
||||
new AspNetCoreCookieOptions
|
||||
{
|
||||
HttpOnly = true,
|
||||
SameSite = SameSiteMode.Lax,
|
||||
Path = "/",
|
||||
MaxAge = TimeSpan.FromHours(options.Hours),
|
||||
Secure = options.Secure,
|
||||
});
|
||||
}
|
||||
|
||||
// Нормализованная попытка логина для аудита (нижний регистр/обрезка); null — писать нечего.
|
||||
// login: Логин из тела запроса.
|
||||
// Возвращает: Нормализованный логин или null при пустом/пробельном входе.
|
||||
private static string? NormalizeLogin(string? login)
|
||||
{
|
||||
string? normalized = login?.Trim().ToLowerInvariant();
|
||||
return string.IsNullOrEmpty(normalized) ? null : normalized;
|
||||
}
|
||||
|
||||
// IP-адрес клиента для аудита (без порта; null, если недоступен).
|
||||
// context: Контекст запроса.
|
||||
// Возвращает: Строковое представление IP или null.
|
||||
private static string? ClientIp(HttpContext context) => context.Connection.RemoteIpAddress?.ToString();
|
||||
}
|
||||
|
||||
@@ -1,204 +1,198 @@
|
||||
using Deal.Api.Extensions;
|
||||
using Deal.Api.Models;
|
||||
using Deal.Api.Services;
|
||||
using Deal.Api.Observability;
|
||||
using Deal.Infrastructure.Integrations.Abstractions;
|
||||
using Deal.Infrastructure.Integrations.Exceptions;
|
||||
using Deal.Infrastructure.Integrations.Extensions;
|
||||
using Deal.Infrastructure.Integrations.Models;
|
||||
using Deal.Infrastructure.Integrations.Options;
|
||||
using Deal.Infrastructure.Integrations.Services;
|
||||
using Deal.Infrastructure.Persistence;
|
||||
using Deal.Infrastructure.Persistence.Configurations;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Deal.Api.Dtos;
|
||||
|
||||
namespace Deal.Api.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Операторский health: GET /api/operator/health (план Task 10, Ruling 3/6/9/11) — ядро/БД и
|
||||
/// автономные сервисы ml/ai/telegram.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Ручка — только оператору (401 «Требуется вход оператора» без операторской сессии). Ответ всегда 200
|
||||
/// (информационный операторский обзор, как /api/health) с полями состояния:
|
||||
/// <c>{ok, core:{db:"ok"|"down"}, services:[{name, mode:"grpc"|"local", status, reachable}],
|
||||
/// queues:{pipeline, mlOutbox}, sessions:{active}}</c>. Глубины очередей обработки/ML-outbox и число
|
||||
/// активных сессий (§10.2) собирает общий <see cref="RuntimeDepthsCollector"/> (тот же путь, что метрики).
|
||||
/// Проверка БД — <c>SELECT 1</c> через DealDbContext (public-схема) с таймаутом 5 с; сбой (контейнер не поднят/
|
||||
/// сеть) → core.db=down без падения ручки. Сервисы: при <c>Services:*:UseLocal=true</c> — <c>{mode:"local",
|
||||
/// reachable:false, status:"local"}</c> (Local-адаптеры, реальный сервис не поднят — Ruling 6; dev-приёмка);
|
||||
/// в gRPC-режиме — <see cref="ServiceHealthProbe"/> к <c>Services:*:Endpoint</c> (grpc.health.v1, таймаут 3 с):
|
||||
/// SERVING → status=ok, иной статус → unhealthy, недоступен → down. <c>ok</c> сводки — БД доступна и все
|
||||
/// сервисы в порядке (Local-режим не считается сбоем).
|
||||
/// </remarks>
|
||||
public static class OperatorHealthEndpoints
|
||||
{
|
||||
// Префикс группы операторских ручек health (Ruling 11).
|
||||
private const string OperatorGroupPrefix = "/api/operator";
|
||||
|
||||
// Путь health-ручки.
|
||||
private const string HealthPath = "/health";
|
||||
|
||||
// OpenAPI-тег группы.
|
||||
private const string OperatorOpenApiTag = "operator-health";
|
||||
|
||||
// Статус БД/сервиса: доступна/здоров (ok).
|
||||
private const string StatusOk = "ok";
|
||||
|
||||
// Статус сервиса: БД/сервис недоступен (down).
|
||||
private const string StatusDown = "down";
|
||||
|
||||
// Статус сервиса: ответил, но не SERVING (grpc NOT_SERVING/SERVICE_UNKNOWN).
|
||||
private const string StatusUnhealthy = "unhealthy";
|
||||
|
||||
// Статус сервиса в Local-режиме: реальный сервис не подключён (UseLocal=true, Ruling 6).
|
||||
private const string StatusLocal = "local";
|
||||
|
||||
// Режим сервиса: Local-адаптеры (UseLocal=true).
|
||||
private const string ModeLocal = "local";
|
||||
|
||||
// Режим сервиса: gRPC-клиент (UseLocal=false).
|
||||
private const string ModeGrpc = "grpc";
|
||||
|
||||
// Имя ml-service в ответе (порядок секций — как в стартовых логах Program.cs).
|
||||
private const string MlServiceName = "ml";
|
||||
|
||||
// Имя ai-service в ответе.
|
||||
private const string AiServiceName = "ai";
|
||||
|
||||
// Имя telegram-service в ответе.
|
||||
private const string TelegramServiceName = "telegram";
|
||||
|
||||
// Таймаут проверки БД, миллисекунд (health не должен висеть на мёртвом хосте Postgres).
|
||||
private const int DatabaseProbeTimeoutMilliseconds = 5000;
|
||||
|
||||
/// <summary>
|
||||
/// Регистрирует GET /api/operator/health (health ядра/БД и автономных сервисов).
|
||||
/// </summary>
|
||||
/// <param name="app">Построитель маршрутов приложения.</param>
|
||||
/// <returns>Построитель маршрутов для цепочки вызовов.</returns>
|
||||
public static IEndpointRouteBuilder MapOperatorHealthEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
app.MapGroup(OperatorGroupPrefix).WithTags(OperatorOpenApiTag).MapGet(HealthPath, GetAsync);
|
||||
return app;
|
||||
}
|
||||
|
||||
// GET /api/operator/health: {ok, core:{db}, services:[{name,mode,status,reachable}]} (всегда 200).
|
||||
private static async Task<IResult> GetAsync(
|
||||
HttpContext context,
|
||||
DealDbContext dbContext,
|
||||
ServiceHealthProbe healthProbe,
|
||||
RuntimeDepthsCollector depthsCollector,
|
||||
MlServiceOptions mlOptions,
|
||||
AiServiceOptions aiOptions,
|
||||
TelegramServiceOptions telegramOptions,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var operatorIdentity = context.GetCurrentOperator();
|
||||
if (operatorIdentity is null)
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail);
|
||||
}
|
||||
|
||||
// Параллельно: БД (SELECT 1), health-пробы сервисов (до 3 с на сервис) и снимок глубин очередей/сессий.
|
||||
Task<string> databaseTask = ProbeDatabaseAsync(dbContext, ct);
|
||||
Task<List<ServiceEntryDto>> servicesTask = ProbeServicesAsync(healthProbe, mlOptions, aiOptions, telegramOptions, ct);
|
||||
Task<RuntimeDepthsDto> depthsTask = depthsCollector.CollectAsync(ct);
|
||||
await Task.WhenAll(databaseTask, servicesTask, depthsTask);
|
||||
|
||||
string databaseStatus = await databaseTask;
|
||||
List<ServiceEntryDto> services = await servicesTask;
|
||||
RuntimeDepthsDto depths = await depthsTask;
|
||||
bool ok = databaseStatus == StatusOk
|
||||
&& services.All(service => service.Mode == ModeLocal || service.Status == StatusOk);
|
||||
return Results.Ok(new
|
||||
{
|
||||
ok,
|
||||
core = new { db = databaseStatus },
|
||||
services,
|
||||
queues = new { pipeline = depths.PipelineQueue, mlOutbox = depths.MlOutbox },
|
||||
sessions = new { active = depths.ActiveSessions },
|
||||
});
|
||||
}
|
||||
|
||||
// Проверяет доступность БД core: SELECT 1 через DealDbContext (public-схема) с таймаутом 5 с.
|
||||
// dbContext: Системный контекст.
|
||||
// ct: Токен отмены.
|
||||
// Возвращает: Статус БД: ok/down (сбой не роняет ручку).
|
||||
private static async Task<string> ProbeDatabaseAsync(DealDbContext dbContext, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
timeout.CancelAfter(DatabaseProbeTimeoutMilliseconds);
|
||||
await dbContext.Database.ExecuteSqlRawAsync("SELECT 1", timeout.Token);
|
||||
return StatusOk;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Postgres недоступен (контейнер не поднят/сеть) — оператор видит core.db=down, ручка жива.
|
||||
return StatusDown;
|
||||
}
|
||||
}
|
||||
|
||||
// Пробы сервисов ml/ai/telegram по их конфигурации: Local-режим — пометка local без вызова;
|
||||
// gRPC-режим — health-проба к Services:*:Endpoint (ServiceHealthProbe).
|
||||
// healthProbe: Проба grpc.health.v1.
|
||||
// mlOptions: Конфигурация ml-service.
|
||||
// aiOptions: Конфигурация ai-service.
|
||||
// telegramOptions: Конфигурация telegram-service.
|
||||
// ct: Токен отмены.
|
||||
// Возвращает: Записи состояния сервисов в порядке ml → ai → telegram.
|
||||
private static async Task<List<ServiceEntryDto>> ProbeServicesAsync(
|
||||
ServiceHealthProbe healthProbe,
|
||||
MlServiceOptions mlOptions,
|
||||
AiServiceOptions aiOptions,
|
||||
TelegramServiceOptions telegramOptions,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var services = new List<ServiceEntryDto>(capacity: 3);
|
||||
await ProbeServiceAsync(services, healthProbe, MlServiceName, mlOptions.UseLocal, mlOptions.Endpoint, ct);
|
||||
await ProbeServiceAsync(services, healthProbe, AiServiceName, aiOptions.UseLocal, aiOptions.Endpoint, ct);
|
||||
await ProbeServiceAsync(services, healthProbe, TelegramServiceName, telegramOptions.UseLocal, telegramOptions.Endpoint, ct);
|
||||
return services;
|
||||
}
|
||||
|
||||
// Одна запись состояния сервиса в списке services (см. ProbeServicesAsync).
|
||||
// services: Куда добавить запись.
|
||||
// healthProbe: Проба grpc.health.v1.
|
||||
// name: Имя сервиса в ответе.
|
||||
// useLocal: True — Local-режим (UseLocal=true): реальный сервис не подключён.
|
||||
// endpoint: Базовый адрес сервиса (для gRPC-режима).
|
||||
// ct: Токен отмены.
|
||||
private static async Task ProbeServiceAsync(
|
||||
List<ServiceEntryDto> services,
|
||||
ServiceHealthProbe healthProbe,
|
||||
string name,
|
||||
bool useLocal,
|
||||
string endpoint,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (useLocal)
|
||||
{
|
||||
services.Add(new ServiceEntryDto(name, ModeLocal, StatusLocal, Reachable: false));
|
||||
return;
|
||||
}
|
||||
|
||||
ServiceHealthResult result = await healthProbe.ProbeAsync(endpoint, ct);
|
||||
string status = (result.Reachable, result.Serving) switch
|
||||
{
|
||||
(true, true) => StatusOk,
|
||||
(true, false) => StatusUnhealthy,
|
||||
_ => StatusDown,
|
||||
};
|
||||
services.Add(new ServiceEntryDto(name, ModeGrpc, status, result.Reachable));
|
||||
}
|
||||
|
||||
// Запись состояния сервиса в ответе /api/operator/health (приватная форма сериализации).
|
||||
// Name: Имя сервиса (ml/ai/telegram).
|
||||
// Mode: Режим: local (UseLocal=true) | grpc (UseLocal=false).
|
||||
// Status: Состояние: ok | unhealthy | down (в Local-режиме — local).
|
||||
// Reachable: True — сервис ответил на health-пробу (в Local-режиме всегда false).
|
||||
private sealed record ServiceEntryDto(string Name, string Mode, string Status, bool Reachable);
|
||||
}
|
||||
using Deal.Api.Extensions;
|
||||
using Deal.Api.Observability;
|
||||
using Deal.Api.Services;
|
||||
using Deal.Infrastructure.Integrations.Models;
|
||||
using Deal.Infrastructure.Integrations.Options;
|
||||
using Deal.Infrastructure.Integrations.Services;
|
||||
using Deal.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Deal.Api.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Операторский health: GET /api/operator/health (план Task 10, Ruling 3/6/9/11) — ядро/БД и
|
||||
/// автономные сервисы ml/ai/telegram.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Ручка — только оператору (401 «Требуется вход оператора» без операторской сессии). Ответ всегда 200
|
||||
/// (информационный операторский обзор, как /api/health) с полями состояния:
|
||||
/// <c>{ok, core:{db:"ok"|"down"}, services:[{name, mode:"grpc"|"local", status, reachable}],
|
||||
/// queues:{pipeline, mlOutbox}, sessions:{active}}</c>. Глубины очередей обработки/ML-outbox и число
|
||||
/// активных сессий (§10.2) собирает общий <see cref="RuntimeDepthsCollector"/> (тот же путь, что метрики).
|
||||
/// Проверка БД — <c>SELECT 1</c> через DealDbContext (public-схема) с таймаутом 5 с; сбой (контейнер не поднят/
|
||||
/// сеть) → core.db=down без падения ручки. Сервисы: при <c>Services:*:UseLocal=true</c> — <c>{mode:"local",
|
||||
/// reachable:false, status:"local"}</c> (Local-адаптеры, реальный сервис не поднят — Ruling 6; dev-приёмка);
|
||||
/// в gRPC-режиме — <see cref="ServiceHealthProbe"/> к <c>Services:*:Endpoint</c> (grpc.health.v1, таймаут 3 с):
|
||||
/// SERVING → status=ok, иной статус → unhealthy, недоступен → down. <c>ok</c> сводки — БД доступна и все
|
||||
/// сервисы в порядке (Local-режим не считается сбоем).
|
||||
/// </remarks>
|
||||
public static class OperatorHealthEndpoints
|
||||
{
|
||||
// Префикс группы операторских ручек health (Ruling 11).
|
||||
private const string OperatorGroupPrefix = "/api/operator";
|
||||
|
||||
// Путь health-ручки.
|
||||
private const string HealthPath = "/health";
|
||||
|
||||
// OpenAPI-тег группы.
|
||||
private const string OperatorOpenApiTag = "operator-health";
|
||||
|
||||
// Статус БД/сервиса: доступна/здоров (ok).
|
||||
private const string StatusOk = "ok";
|
||||
|
||||
// Статус сервиса: БД/сервис недоступен (down).
|
||||
private const string StatusDown = "down";
|
||||
|
||||
// Статус сервиса: ответил, но не SERVING (grpc NOT_SERVING/SERVICE_UNKNOWN).
|
||||
private const string StatusUnhealthy = "unhealthy";
|
||||
|
||||
// Статус сервиса в Local-режиме: реальный сервис не подключён (UseLocal=true, Ruling 6).
|
||||
private const string StatusLocal = "local";
|
||||
|
||||
// Режим сервиса: Local-адаптеры (UseLocal=true).
|
||||
private const string ModeLocal = "local";
|
||||
|
||||
// Режим сервиса: gRPC-клиент (UseLocal=false).
|
||||
private const string ModeGrpc = "grpc";
|
||||
|
||||
// Имя ml-service в ответе (порядок секций — как в стартовых логах Program.cs).
|
||||
private const string MlServiceName = "ml";
|
||||
|
||||
// Имя ai-service в ответе.
|
||||
private const string AiServiceName = "ai";
|
||||
|
||||
// Имя telegram-service в ответе.
|
||||
private const string TelegramServiceName = "telegram";
|
||||
|
||||
// Таймаут проверки БД, миллисекунд (health не должен висеть на мёртвом хосте Postgres).
|
||||
private const int DatabaseProbeTimeoutMilliseconds = 5000;
|
||||
|
||||
/// <summary>
|
||||
/// Регистрирует GET /api/operator/health (health ядра/БД и автономных сервисов).
|
||||
/// </summary>
|
||||
/// <param name="app">Построитель маршрутов приложения.</param>
|
||||
/// <returns>Построитель маршрутов для цепочки вызовов.</returns>
|
||||
public static IEndpointRouteBuilder MapOperatorHealthEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
app.MapGroup(OperatorGroupPrefix).WithTags(OperatorOpenApiTag).MapGet(HealthPath, GetAsync);
|
||||
return app;
|
||||
}
|
||||
|
||||
// GET /api/operator/health: {ok, core:{db}, services:[{name,mode,status,reachable}]} (всегда 200).
|
||||
private static async Task<IResult> GetAsync(
|
||||
HttpContext context,
|
||||
DealDbContext dbContext,
|
||||
ServiceHealthProbe healthProbe,
|
||||
RuntimeDepthsCollector depthsCollector,
|
||||
MlServiceOptions mlOptions,
|
||||
AiServiceOptions aiOptions,
|
||||
TelegramServiceOptions telegramOptions,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var operatorIdentity = context.GetCurrentOperator();
|
||||
if (operatorIdentity is null)
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail);
|
||||
}
|
||||
|
||||
// Параллельно: БД (SELECT 1), health-пробы сервисов (до 3 с на сервис) и снимок глубин очередей/сессий.
|
||||
Task<string> databaseTask = ProbeDatabaseAsync(dbContext, ct);
|
||||
Task<List<ServiceEntryDto>> servicesTask = ProbeServicesAsync(healthProbe, mlOptions, aiOptions, telegramOptions, ct);
|
||||
Task<RuntimeDepthsDto> depthsTask = depthsCollector.CollectAsync(ct);
|
||||
await Task.WhenAll(databaseTask, servicesTask, depthsTask);
|
||||
|
||||
string databaseStatus = await databaseTask;
|
||||
List<ServiceEntryDto> services = await servicesTask;
|
||||
RuntimeDepthsDto depths = await depthsTask;
|
||||
bool ok = databaseStatus == StatusOk
|
||||
&& services.All(service => service.Mode == ModeLocal || service.Status == StatusOk);
|
||||
return Results.Ok(new
|
||||
{
|
||||
ok,
|
||||
core = new { db = databaseStatus },
|
||||
services,
|
||||
queues = new { pipeline = depths.PipelineQueue, mlOutbox = depths.MlOutbox },
|
||||
sessions = new { active = depths.ActiveSessions },
|
||||
});
|
||||
}
|
||||
|
||||
// Проверяет доступность БД core: SELECT 1 через DealDbContext (public-схема) с таймаутом 5 с.
|
||||
// dbContext: Системный контекст.
|
||||
// ct: Токен отмены.
|
||||
// Возвращает: Статус БД: ok/down (сбой не роняет ручку).
|
||||
private static async Task<string> ProbeDatabaseAsync(DealDbContext dbContext, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
timeout.CancelAfter(DatabaseProbeTimeoutMilliseconds);
|
||||
await dbContext.Database.ExecuteSqlRawAsync("SELECT 1", timeout.Token);
|
||||
return StatusOk;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Postgres недоступен (контейнер не поднят/сеть) — оператор видит core.db=down, ручка жива.
|
||||
return StatusDown;
|
||||
}
|
||||
}
|
||||
|
||||
// Пробы сервисов ml/ai/telegram по их конфигурации: Local-режим — пометка local без вызова;
|
||||
// gRPC-режим — health-проба к Services:*:Endpoint (ServiceHealthProbe).
|
||||
// healthProbe: Проба grpc.health.v1.
|
||||
// mlOptions: Конфигурация ml-service.
|
||||
// aiOptions: Конфигурация ai-service.
|
||||
// telegramOptions: Конфигурация telegram-service.
|
||||
// ct: Токен отмены.
|
||||
// Возвращает: Записи состояния сервисов в порядке ml → ai → telegram.
|
||||
private static async Task<List<ServiceEntryDto>> ProbeServicesAsync(
|
||||
ServiceHealthProbe healthProbe,
|
||||
MlServiceOptions mlOptions,
|
||||
AiServiceOptions aiOptions,
|
||||
TelegramServiceOptions telegramOptions,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var services = new List<ServiceEntryDto>(capacity: 3);
|
||||
await ProbeServiceAsync(services, healthProbe, MlServiceName, mlOptions.UseLocal, mlOptions.Endpoint, ct);
|
||||
await ProbeServiceAsync(services, healthProbe, AiServiceName, aiOptions.UseLocal, aiOptions.Endpoint, ct);
|
||||
await ProbeServiceAsync(services, healthProbe, TelegramServiceName, telegramOptions.UseLocal, telegramOptions.Endpoint, ct);
|
||||
return services;
|
||||
}
|
||||
|
||||
// Одна запись состояния сервиса в списке services (см. ProbeServicesAsync).
|
||||
// services: Куда добавить запись.
|
||||
// healthProbe: Проба grpc.health.v1.
|
||||
// name: Имя сервиса в ответе.
|
||||
// useLocal: True — Local-режим (UseLocal=true): реальный сервис не подключён.
|
||||
// endpoint: Базовый адрес сервиса (для gRPC-режима).
|
||||
// ct: Токен отмены.
|
||||
private static async Task ProbeServiceAsync(
|
||||
List<ServiceEntryDto> services,
|
||||
ServiceHealthProbe healthProbe,
|
||||
string name,
|
||||
bool useLocal,
|
||||
string endpoint,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (useLocal)
|
||||
{
|
||||
services.Add(new ServiceEntryDto(name, ModeLocal, StatusLocal, Reachable: false));
|
||||
return;
|
||||
}
|
||||
|
||||
ServiceHealthResult result = await healthProbe.ProbeAsync(endpoint, ct);
|
||||
string status = (result.Reachable, result.Serving) switch
|
||||
{
|
||||
(true, true) => StatusOk,
|
||||
(true, false) => StatusUnhealthy,
|
||||
_ => StatusDown,
|
||||
};
|
||||
services.Add(new ServiceEntryDto(name, ModeGrpc, status, result.Reachable));
|
||||
}
|
||||
|
||||
// Запись состояния сервиса в ответе /api/operator/health (приватная форма сериализации).
|
||||
// Name: Имя сервиса (ml/ai/telegram).
|
||||
// Mode: Режим: local (UseLocal=true) | grpc (UseLocal=false).
|
||||
// Status: Состояние: ok | unhealthy | down (в Local-режиме — local).
|
||||
// Reachable: True — сервис ответил на health-пробу (в Local-режиме всегда false).
|
||||
private sealed record ServiceEntryDto(string Name, string Mode, string Status, bool Reachable);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
using Deal.Api.Services;
|
||||
using Deal.Api.Dtos;
|
||||
|
||||
namespace Deal.Api.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Тело POST /api/operator/invites: email приглашённого и опциональный целевой тенант (Ruling 2 этапа 7).
|
||||
/// </summary>
|
||||
/// <param name="Email">Email приглашённого (регистр/пробелы не важны — нормализует InvitesService).</param>
|
||||
/// <param name="TenantId">Целевой тенант; null — при активации будет создан новый тенант (Task 6).</param>
|
||||
public sealed record OperatorInviteCreateRequest(string? Email, Guid? TenantId);
|
||||
namespace Deal.Api.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Тело POST /api/operator/invites: email приглашённого и опциональный целевой тенант (Ruling 2 этапа 7).
|
||||
/// </summary>
|
||||
/// <param name="Email">Email приглашённого (регистр/пробелы не важны — нормализует InvitesService).</param>
|
||||
/// <param name="TenantId">Целевой тенант; null — при активации будет создан новый тенант (Task 6).</param>
|
||||
public sealed record OperatorInviteCreateRequest(string? Email, Guid? TenantId);
|
||||
|
||||
@@ -1,152 +1,147 @@
|
||||
using Deal.Api.Extensions;
|
||||
using Deal.Api.Models;
|
||||
using Deal.Api.Services;
|
||||
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.Dtos;
|
||||
|
||||
namespace Deal.Api.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Операторские эндпоинты приглашений: GET /api/operator/invites, POST (создание), POST {code}/revoke (Ruling 2/11 этапа 7).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Создание/отзыв/чтение — только оператор: без операторской сессии 401 «Требуется вход оператора»
|
||||
/// (как /api/operator/auth/me). Создание возвращает {code, email, tenantId, expiresAt, status} (план Task 5),
|
||||
/// список — {items:[...]} (полные строки; форма как у GET /api/operator/audit), отзыв — {ok:true}. Результаты
|
||||
/// пишутся в аудит — invite_created/invite_revoked с email и codeHash в DetailJson (Ruling 4; операторские события,
|
||||
/// TenantId null; хэш кода — Security review). Тексты ошибок — фиксированные строки HTTP-слоя (паттерн AuthEndpoints).
|
||||
/// </remarks>
|
||||
public static class OperatorInvitesEndpoints
|
||||
{
|
||||
// Текст 400: email пустой/некорректного формата.
|
||||
private const string InvalidEmailDetail = "Некорректный email";
|
||||
|
||||
// Текст 400: на email уже есть активное приглашение (план Task 5, Ruling 2).
|
||||
private const string DuplicateActiveDetail = "Для этого email уже есть активное приглашение";
|
||||
|
||||
// Текст 404: приглашение с таким кодом не найдено.
|
||||
private const string InviteNotFoundDetail = "Приглашение не найдено";
|
||||
|
||||
// Текст 400: отзыв приглашения не в статусе pending (уже отозвано/использовано/истекло).
|
||||
private const string InviteNotPendingDetail = "Отозвать можно только ожидающее активации приглашение";
|
||||
|
||||
// Префикс группы операторских ручек приглашений (Ruling 11).
|
||||
private const string InvitesGroupPrefix = "/api/operator/invites";
|
||||
|
||||
// Относительный путь отзыва приглашения.
|
||||
private const string RevokePath = "/{code}/revoke";
|
||||
|
||||
// OpenAPI-тег группы.
|
||||
private const string InvitesOpenApiTag = "operator-invites";
|
||||
|
||||
/// <summary>
|
||||
/// Регистрирует группу /api/operator/invites: GET (список), POST (создание), POST {code}/revoke (отзыв).
|
||||
/// </summary>
|
||||
/// <param name="app">Построитель маршрутов приложения.</param>
|
||||
/// <returns>Построитель маршрутов для цепочки вызовов.</returns>
|
||||
public static IEndpointRouteBuilder MapOperatorInvitesEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup(InvitesGroupPrefix).WithTags(InvitesOpenApiTag);
|
||||
|
||||
group.MapGet("", ListAsync);
|
||||
group.MapPost("", CreateAsync);
|
||||
group.MapPost(RevokePath, RevokeAsync);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
// GET /api/operator/invites: список приглашений (новые сверху, со статусами; expired проставляется лениво).
|
||||
private static async Task<IResult> ListAsync(
|
||||
HttpContext context,
|
||||
InvitesService invitesService,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (context.GetCurrentOperator() is null)
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail);
|
||||
}
|
||||
|
||||
IReadOnlyList<InviteDto> items = await invitesService.ListAsync(ct);
|
||||
return Results.Ok(new { items });
|
||||
}
|
||||
|
||||
// POST /api/operator/invites: создание приглашения; результат пишется в аудит (invite_created).
|
||||
private static async Task<IResult> CreateAsync(
|
||||
OperatorInviteCreateRequest body,
|
||||
HttpContext context,
|
||||
InvitesService invitesService,
|
||||
AuditService auditService,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var operatorIdentity = context.GetCurrentOperator();
|
||||
if (operatorIdentity is null)
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail);
|
||||
}
|
||||
|
||||
InviteCreateResultDto result = await invitesService.CreateInviteAsync(
|
||||
operatorIdentity.OperatorId, body.Email, body.TenantId, ct);
|
||||
if (!result.Ok || result.Invite is null)
|
||||
{
|
||||
string detail = result.Error == InviteCreateResultDto.ErrorDuplicateActive
|
||||
? DuplicateActiveDetail
|
||||
: InvalidEmailDetail;
|
||||
return EndpointResults.BadRequest(detail);
|
||||
}
|
||||
|
||||
await auditService.AppendAsync(new AuditRecordDto(
|
||||
AuditEvents.InviteCreated,
|
||||
AuditActorTypes.Operator,
|
||||
ActorId: operatorIdentity.OperatorId,
|
||||
TenantId: null,
|
||||
Ip: ClientIp(context),
|
||||
// Код инвайта — capability-токен (по нему активируется приглашение): в аудит пишется
|
||||
// только его SHA-256-хэш, чтобы утечка ленты не давала рабочие коды (Security review).
|
||||
DetailJson: AuditService.ToDetailJson(new { email = result.Invite.Email, codeHash = SessionTokens.HashToken(result.Invite.Code) })), ct);
|
||||
|
||||
InviteDto invite = result.Invite;
|
||||
return Results.Ok(new { invite.Code, invite.Email, invite.TenantId, invite.ExpiresAt, invite.Status });
|
||||
}
|
||||
|
||||
// POST /api/operator/invites/{code}/revoke: отзыв ожидающего приглашения; результат пишется в аудит (invite_revoked).
|
||||
private static async Task<IResult> RevokeAsync(
|
||||
string code,
|
||||
HttpContext context,
|
||||
InvitesService invitesService,
|
||||
AuditService auditService,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var operatorIdentity = context.GetCurrentOperator();
|
||||
if (operatorIdentity is null)
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail);
|
||||
}
|
||||
|
||||
InviteRevokeResultDto result = await invitesService.RevokeAsync(code, ct);
|
||||
if (!result.Ok)
|
||||
{
|
||||
return result.Error == InviteRevokeResultDto.ErrorNotFound
|
||||
? EndpointResults.NotFound(InviteNotFoundDetail)
|
||||
: EndpointResults.BadRequest(InviteNotPendingDetail);
|
||||
}
|
||||
|
||||
await auditService.AppendAsync(new AuditRecordDto(
|
||||
AuditEvents.InviteRevoked,
|
||||
AuditActorTypes.Operator,
|
||||
ActorId: operatorIdentity.OperatorId,
|
||||
TenantId: null,
|
||||
Ip: ClientIp(context),
|
||||
DetailJson: AuditService.ToDetailJson(new { email = result.Invite!.Email, codeHash = SessionTokens.HashToken(result.Invite.Code) })), ct);
|
||||
|
||||
return Results.Ok(new { ok = true });
|
||||
}
|
||||
|
||||
// IP-адрес клиента для аудита (без порта; null, если недоступен).
|
||||
// context: Контекст запроса.
|
||||
// Возвращает: Строковое представление IP или null.
|
||||
private static string? ClientIp(HttpContext context) => context.Connection.RemoteIpAddress?.ToString();
|
||||
}
|
||||
using Deal.Api.Extensions;
|
||||
using Deal.Api.Services;
|
||||
using Deal.Modules.Tenants.Application.Models;
|
||||
using Deal.Modules.Tenants.Application.Services;
|
||||
|
||||
namespace Deal.Api.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Операторские эндпоинты приглашений: GET /api/operator/invites, POST (создание), POST {code}/revoke (Ruling 2/11 этапа 7).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Создание/отзыв/чтение — только оператор: без операторской сессии 401 «Требуется вход оператора»
|
||||
/// (как /api/operator/auth/me). Создание возвращает {code, email, tenantId, expiresAt, status} (план Task 5),
|
||||
/// список — {items:[...]} (полные строки; форма как у GET /api/operator/audit), отзыв — {ok:true}. Результаты
|
||||
/// пишутся в аудит — invite_created/invite_revoked с email и codeHash в DetailJson (Ruling 4; операторские события,
|
||||
/// TenantId null; хэш кода — Security review). Тексты ошибок — фиксированные строки HTTP-слоя (паттерн AuthEndpoints).
|
||||
/// </remarks>
|
||||
public static class OperatorInvitesEndpoints
|
||||
{
|
||||
// Текст 400: email пустой/некорректного формата.
|
||||
private const string InvalidEmailDetail = "Некорректный email";
|
||||
|
||||
// Текст 400: на email уже есть активное приглашение (план Task 5, Ruling 2).
|
||||
private const string DuplicateActiveDetail = "Для этого email уже есть активное приглашение";
|
||||
|
||||
// Текст 404: приглашение с таким кодом не найдено.
|
||||
private const string InviteNotFoundDetail = "Приглашение не найдено";
|
||||
|
||||
// Текст 400: отзыв приглашения не в статусе pending (уже отозвано/использовано/истекло).
|
||||
private const string InviteNotPendingDetail = "Отозвать можно только ожидающее активации приглашение";
|
||||
|
||||
// Префикс группы операторских ручек приглашений (Ruling 11).
|
||||
private const string InvitesGroupPrefix = "/api/operator/invites";
|
||||
|
||||
// Относительный путь отзыва приглашения.
|
||||
private const string RevokePath = "/{code}/revoke";
|
||||
|
||||
// OpenAPI-тег группы.
|
||||
private const string InvitesOpenApiTag = "operator-invites";
|
||||
|
||||
/// <summary>
|
||||
/// Регистрирует группу /api/operator/invites: GET (список), POST (создание), POST {code}/revoke (отзыв).
|
||||
/// </summary>
|
||||
/// <param name="app">Построитель маршрутов приложения.</param>
|
||||
/// <returns>Построитель маршрутов для цепочки вызовов.</returns>
|
||||
public static IEndpointRouteBuilder MapOperatorInvitesEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup(InvitesGroupPrefix).WithTags(InvitesOpenApiTag);
|
||||
|
||||
group.MapGet("", ListAsync);
|
||||
group.MapPost("", CreateAsync);
|
||||
group.MapPost(RevokePath, RevokeAsync);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
// GET /api/operator/invites: список приглашений (новые сверху, со статусами; expired проставляется лениво).
|
||||
private static async Task<IResult> ListAsync(
|
||||
HttpContext context,
|
||||
InvitesService invitesService,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (context.GetCurrentOperator() is null)
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail);
|
||||
}
|
||||
|
||||
IReadOnlyList<InviteDto> items = await invitesService.ListAsync(ct);
|
||||
return Results.Ok(new { items });
|
||||
}
|
||||
|
||||
// POST /api/operator/invites: создание приглашения; результат пишется в аудит (invite_created).
|
||||
private static async Task<IResult> CreateAsync(
|
||||
OperatorInviteCreateRequest body,
|
||||
HttpContext context,
|
||||
InvitesService invitesService,
|
||||
AuditService auditService,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var operatorIdentity = context.GetCurrentOperator();
|
||||
if (operatorIdentity is null)
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail);
|
||||
}
|
||||
|
||||
InviteCreateResultDto result = await invitesService.CreateInviteAsync(
|
||||
operatorIdentity.OperatorId, body.Email, body.TenantId, ct);
|
||||
if (!result.Ok || result.Invite is null)
|
||||
{
|
||||
string detail = result.Error == InviteCreateResultDto.ErrorDuplicateActive
|
||||
? DuplicateActiveDetail
|
||||
: InvalidEmailDetail;
|
||||
return EndpointResults.BadRequest(detail);
|
||||
}
|
||||
|
||||
await auditService.AppendAsync(new AuditRecordDto(
|
||||
AuditEvents.InviteCreated,
|
||||
AuditActorTypes.Operator,
|
||||
ActorId: operatorIdentity.OperatorId,
|
||||
TenantId: null,
|
||||
Ip: ClientIp(context),
|
||||
// Код инвайта — capability-токен (по нему активируется приглашение): в аудит пишется
|
||||
// только его SHA-256-хэш, чтобы утечка ленты не давала рабочие коды (Security review).
|
||||
DetailJson: AuditService.ToDetailJson(new { email = result.Invite.Email, codeHash = SessionTokens.HashToken(result.Invite.Code) })), ct);
|
||||
|
||||
InviteDto invite = result.Invite;
|
||||
return Results.Ok(new { invite.Code, invite.Email, invite.TenantId, invite.ExpiresAt, invite.Status });
|
||||
}
|
||||
|
||||
// POST /api/operator/invites/{code}/revoke: отзыв ожидающего приглашения; результат пишется в аудит (invite_revoked).
|
||||
private static async Task<IResult> RevokeAsync(
|
||||
string code,
|
||||
HttpContext context,
|
||||
InvitesService invitesService,
|
||||
AuditService auditService,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var operatorIdentity = context.GetCurrentOperator();
|
||||
if (operatorIdentity is null)
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail);
|
||||
}
|
||||
|
||||
InviteRevokeResultDto result = await invitesService.RevokeAsync(code, ct);
|
||||
if (!result.Ok)
|
||||
{
|
||||
return result.Error == InviteRevokeResultDto.ErrorNotFound
|
||||
? EndpointResults.NotFound(InviteNotFoundDetail)
|
||||
: EndpointResults.BadRequest(InviteNotPendingDetail);
|
||||
}
|
||||
|
||||
await auditService.AppendAsync(new AuditRecordDto(
|
||||
AuditEvents.InviteRevoked,
|
||||
AuditActorTypes.Operator,
|
||||
ActorId: operatorIdentity.OperatorId,
|
||||
TenantId: null,
|
||||
Ip: ClientIp(context),
|
||||
DetailJson: AuditService.ToDetailJson(new { email = result.Invite!.Email, codeHash = SessionTokens.HashToken(result.Invite.Code) })), ct);
|
||||
|
||||
return Results.Ok(new { ok = true });
|
||||
}
|
||||
|
||||
// IP-адрес клиента для аудита (без порта; null, если недоступен).
|
||||
// context: Контекст запроса.
|
||||
// Возвращает: Строковое представление IP или null.
|
||||
private static string? ClientIp(HttpContext context) => context.Connection.RemoteIpAddress?.ToString();
|
||||
}
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
using Deal.Api.Services;
|
||||
using Deal.Api.Dtos;
|
||||
|
||||
namespace Deal.Api.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Тело PATCH /api/operator/tenants/{id}/limit: смена лимитов ИИ-бюджета тенанта (план Task 10, Ruling 3).
|
||||
/// Оба поля опциональны — меняется только заданное; смена бюджета/периода сбрасывает флаги Warned80/
|
||||
/// NotifiedExhausted (новый период открывает пороги тостов, один тост на период на порог, Ruling 3).
|
||||
/// </summary>
|
||||
/// <param name="Budget">Новый бюджет периода в токенах (≥0; 0 — ИИ запрещён); null — оставить текущий.</param>
|
||||
/// <param name="Period">Новый тип периода (константа <c>TenantLimitPeriods</c>: month|day); null — оставить текущий.</param>
|
||||
public sealed record OperatorLimitUpdateRequest(long? Budget, string? Period);
|
||||
namespace Deal.Api.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Тело PATCH /api/operator/tenants/{id}/limit: смена лимитов ИИ-бюджета тенанта (план Task 10, Ruling 3).
|
||||
/// Оба поля опциональны — меняется только заданное; смена бюджета/периода сбрасывает флаги Warned80/
|
||||
/// NotifiedExhausted (новый период открывает пороги тостов, один тост на период на порог, Ruling 3).
|
||||
/// </summary>
|
||||
/// <param name="Budget">Новый бюджет периода в токенах (≥0; 0 — ИИ запрещён); null — оставить текущий.</param>
|
||||
/// <param name="Period">Новый тип периода (константа <c>TenantLimitPeriods</c>: month|day); null — оставить текущий.</param>
|
||||
public sealed record OperatorLimitUpdateRequest(long? Budget, string? Period);
|
||||
|
||||
@@ -1,251 +1,247 @@
|
||||
using Deal.Api.Extensions;
|
||||
using Deal.Api.Models;
|
||||
using Deal.Api.Services;
|
||||
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.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();
|
||||
}
|
||||
using Deal.Api.Extensions;
|
||||
using Deal.Api.Services;
|
||||
using Deal.Modules.Tenants.Application.Abstractions;
|
||||
using Deal.Modules.Tenants.Application.Models;
|
||||
using Deal.Modules.Tenants.Application.Services;
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -1,67 +1,65 @@
|
||||
using Deal.Api.Extensions;
|
||||
using Deal.Api.Models;
|
||||
using Deal.Api.Services;
|
||||
using Deal.Infrastructure.Tenancy;
|
||||
using Deal.Api.Dtos;
|
||||
|
||||
namespace Deal.Api.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Операторские maintenance-ручки (этап 12, пакет C): пакетная миграция схем всех тенантов.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Ручка — только оператору (401 «Требуется вход оператора» без операторской сессии).
|
||||
/// POST /api/operator/maintenance/tenants/migrate — идемпотентно проводит провижининг/миграции схем ВСЕХ
|
||||
/// тенантов реестра (CREATE SCHEMA IF NOT EXISTS + EF Migrate, применяющий только неприменённые миграции)
|
||||
/// с ограниченным параллелизмом и логированием прогресса (<see cref="TenantSchemaMigrationService"/>).
|
||||
/// Ответ <c>{ok, total, migrated, failed, failedSchemas, durationMs}</c>; ok=false, если хотя бы одна схема
|
||||
/// не мигрирована (сбой одной не прерывает остальные — оператор видит список проблемных схем).
|
||||
/// </remarks>
|
||||
public static class OperatorMaintenanceEndpoints
|
||||
{
|
||||
// Префикс группы операторских maintenance-ручек.
|
||||
private const string MaintenanceGroupPrefix = "/api/operator/maintenance";
|
||||
|
||||
// Относительный путь пакетной миграции схем тенантов.
|
||||
private const string MigrateTenantsPath = "/tenants/migrate";
|
||||
|
||||
// OpenAPI-тег группы.
|
||||
private const string MaintenanceOpenApiTag = "operator-maintenance";
|
||||
|
||||
/// <summary>
|
||||
/// Регистрирует группу /api/operator/maintenance: пакетная миграция схем тенантов.
|
||||
/// </summary>
|
||||
/// <param name="app">Построитель маршрутов приложения.</param>
|
||||
/// <returns>Построитель маршрутов для цепочки вызовов.</returns>
|
||||
public static IEndpointRouteBuilder MapOperatorMaintenanceEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
app.MapGroup(MaintenanceGroupPrefix)
|
||||
.WithTags(MaintenanceOpenApiTag)
|
||||
.MapPost(MigrateTenantsPath, MigrateTenantsAsync);
|
||||
return app;
|
||||
}
|
||||
|
||||
// POST /api/operator/maintenance/tenants/migrate: миграция схем всех тенантов (сводка прогресса).
|
||||
private static async Task<IResult> MigrateTenantsAsync(
|
||||
HttpContext context,
|
||||
TenantSchemaMigrationService migrationService,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var operatorIdentity = context.GetCurrentOperator();
|
||||
if (operatorIdentity is null)
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail);
|
||||
}
|
||||
|
||||
TenantMigrationSummary summary = await migrationService.MigrateAllAsync(ct);
|
||||
return Results.Ok(new
|
||||
{
|
||||
ok = summary.Ok,
|
||||
total = summary.Total,
|
||||
migrated = summary.Migrated,
|
||||
failed = summary.Failed,
|
||||
failedSchemas = summary.FailedSchemas,
|
||||
durationMs = summary.DurationMs,
|
||||
});
|
||||
}
|
||||
}
|
||||
using Deal.Api.Extensions;
|
||||
using Deal.Api.Services;
|
||||
using Deal.Infrastructure.Tenancy;
|
||||
|
||||
namespace Deal.Api.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Операторские maintenance-ручки (этап 12, пакет C): пакетная миграция схем всех тенантов.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Ручка — только оператору (401 «Требуется вход оператора» без операторской сессии).
|
||||
/// POST /api/operator/maintenance/tenants/migrate — идемпотентно проводит провижининг/миграции схем ВСЕХ
|
||||
/// тенантов реестра (CREATE SCHEMA IF NOT EXISTS + EF Migrate, применяющий только неприменённые миграции)
|
||||
/// с ограниченным параллелизмом и логированием прогресса (<see cref="TenantSchemaMigrationService"/>).
|
||||
/// Ответ <c>{ok, total, migrated, failed, failedSchemas, durationMs}</c>; ok=false, если хотя бы одна схема
|
||||
/// не мигрирована (сбой одной не прерывает остальные — оператор видит список проблемных схем).
|
||||
/// </remarks>
|
||||
public static class OperatorMaintenanceEndpoints
|
||||
{
|
||||
// Префикс группы операторских maintenance-ручек.
|
||||
private const string MaintenanceGroupPrefix = "/api/operator/maintenance";
|
||||
|
||||
// Относительный путь пакетной миграции схем тенантов.
|
||||
private const string MigrateTenantsPath = "/tenants/migrate";
|
||||
|
||||
// OpenAPI-тег группы.
|
||||
private const string MaintenanceOpenApiTag = "operator-maintenance";
|
||||
|
||||
/// <summary>
|
||||
/// Регистрирует группу /api/operator/maintenance: пакетная миграция схем тенантов.
|
||||
/// </summary>
|
||||
/// <param name="app">Построитель маршрутов приложения.</param>
|
||||
/// <returns>Построитель маршрутов для цепочки вызовов.</returns>
|
||||
public static IEndpointRouteBuilder MapOperatorMaintenanceEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
app.MapGroup(MaintenanceGroupPrefix)
|
||||
.WithTags(MaintenanceOpenApiTag)
|
||||
.MapPost(MigrateTenantsPath, MigrateTenantsAsync);
|
||||
return app;
|
||||
}
|
||||
|
||||
// POST /api/operator/maintenance/tenants/migrate: миграция схем всех тенантов (сводка прогресса).
|
||||
private static async Task<IResult> MigrateTenantsAsync(
|
||||
HttpContext context,
|
||||
TenantSchemaMigrationService migrationService,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var operatorIdentity = context.GetCurrentOperator();
|
||||
if (operatorIdentity is null)
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail);
|
||||
}
|
||||
|
||||
TenantMigrationSummary summary = await migrationService.MigrateAllAsync(ct);
|
||||
return Results.Ok(new
|
||||
{
|
||||
ok = summary.Ok,
|
||||
total = summary.Total,
|
||||
migrated = summary.Migrated,
|
||||
failed = summary.Failed,
|
||||
failedSchemas = summary.FailedSchemas,
|
||||
durationMs = summary.DurationMs,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,160 +1,155 @@
|
||||
using Deal.Api.Endpoints.RequestModels;
|
||||
using Deal.Api.Extensions;
|
||||
using Deal.Api.Models;
|
||||
using Deal.Api.Services;
|
||||
using Deal.Api.Telegram;
|
||||
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.Dtos;
|
||||
|
||||
namespace Deal.Api.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Операторские ручки глобальных (системных) настроек: ключи приложения Telegram
|
||||
/// (ТЗ §4.1/§8.1).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Все ручки — только под операторской сессией: без неё 401 «Требуется вход оператора». Ключи Telegram
|
||||
/// задаёт оператор глобально (едины для всех тенантов), тенант их не видит и не задаёт.
|
||||
/// <list type="bullet">
|
||||
/// <item>GET /api/operator/settings/telegram-keys — маскированный снимок: apiId (не секрет, открыт),
|
||||
/// apiHash (маска) и keysSet;</item>
|
||||
/// <item>PUT /api/operator/settings/telegram-keys {apiId?, apiHash?} — частичное сохранение (можно
|
||||
/// передать только одно поле, второе сохраняется); валидация (api_id 5..9 цифр, api_hash непустой),
|
||||
/// шифрование секрета и аудит telegram_keys_changed (без секретов в деталях).</item>
|
||||
/// </list>
|
||||
/// Ошибки — 400/401 <c>{detail}</c> (формат прототипа, Ruling 10).
|
||||
/// </remarks>
|
||||
public static class OperatorSettingsEndpoints
|
||||
{
|
||||
// Префикс группы операторских настроек.
|
||||
private const string SettingsGroupPrefix = "/api/operator/settings";
|
||||
|
||||
// OpenAPI-тег группы.
|
||||
private const string SettingsOpenApiTag = "operator-settings";
|
||||
|
||||
// Относительный путь глобальных ключей Telegram (GET/PUT).
|
||||
private const string TelegramKeysPath = "/telegram-keys";
|
||||
|
||||
// Текст 400: пустое тело PUT (ни одного поля).
|
||||
private const string EmptyBodyDetail = "Укажите api_id и api_hash";
|
||||
|
||||
// Текст 400: частичное обновление, но ключей ещё нет — нужны оба поля.
|
||||
private const string MissingKeysDetail = "Ключи ещё не заданы — укажите и api_id, и api_hash";
|
||||
|
||||
// Текст 400: api_id не 5..9 цифр.
|
||||
private const string InvalidApiIdDetail = "api_id должен состоять из 5–9 цифр";
|
||||
|
||||
// Текст 400: api_hash пустой/маска/с префиксом enc:.
|
||||
private const string InvalidApiHashDetail = "Укажите непустой api_hash";
|
||||
|
||||
/// <summary>
|
||||
/// Регистрирует группу /api/operator/settings: telegram-keys (GET/PUT).
|
||||
/// </summary>
|
||||
/// <param name="app">Построитель маршрутов приложения.</param>
|
||||
/// <returns>Построитель маршрутов для цепочки вызовов.</returns>
|
||||
public static IEndpointRouteBuilder MapOperatorSettingsEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup(SettingsGroupPrefix).WithTags(SettingsOpenApiTag);
|
||||
group.MapGet(TelegramKeysPath, GetTelegramKeysAsync);
|
||||
group.MapPut(TelegramKeysPath, PutTelegramKeysAsync);
|
||||
return app;
|
||||
}
|
||||
|
||||
// GET /api/operator/settings/telegram-keys: маскированные глобальные ключи Telegram.
|
||||
// context: Контекст запроса.
|
||||
// keys: Сервис глобальных ключей Telegram (scoped).
|
||||
// ct: Токен отмены.
|
||||
// Возвращает: 200 маскированный снимок или 401 без операторской сессии.
|
||||
private static async Task<IResult> GetTelegramKeysAsync(
|
||||
HttpContext context,
|
||||
TelegramKeysService keys,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (context.GetCurrentOperator() is null)
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail);
|
||||
}
|
||||
|
||||
TelegramKeysMaskedDto snapshot = await keys.GetMaskedAsync(ct);
|
||||
return Results.Ok(snapshot);
|
||||
}
|
||||
|
||||
// PUT /api/operator/settings/telegram-keys: частичное сохранение глобальных ключей Telegram
|
||||
// оператором.
|
||||
// Поля можно передавать по отдельности: непереданное поле (null) сохраняет текущее значение,
|
||||
// явное значение (в т.ч. пустая строка) валидируется. Если ключей ещё нет, оба поля обязательны.
|
||||
// body: Тело {apiId?, apiHash?} (хотя бы одно поле).
|
||||
// context: Контекст запроса.
|
||||
// keys: Сервис глобальных ключей Telegram (scoped).
|
||||
// auditService: Сервис аудита (событие telegram_keys_changed).
|
||||
// ct: Токен отмены.
|
||||
// Возвращает: 200 маскированный снимок, 400 при невалидных/недостающих полях или 401 без операторской сессии.
|
||||
private static async Task<IResult> PutTelegramKeysAsync(
|
||||
OperatorTelegramKeysRequest? body,
|
||||
HttpContext context,
|
||||
TelegramKeysService keys,
|
||||
AuditService auditService,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var operatorIdentity = context.GetCurrentOperator();
|
||||
if (operatorIdentity is null)
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail);
|
||||
}
|
||||
|
||||
if (body is null)
|
||||
{
|
||||
return EndpointResults.BadRequest(EmptyBodyDetail);
|
||||
}
|
||||
|
||||
// null — поле не передано (сохраняем текущее); непустая строка/плейсхолдер — валидируем явно.
|
||||
string? apiId = body.ApiId?.Trim();
|
||||
string? apiHash = body.ApiHash?.Trim();
|
||||
if (apiId is null && apiHash is null)
|
||||
{
|
||||
return EndpointResults.BadRequest(EmptyBodyDetail);
|
||||
}
|
||||
|
||||
if (apiId is not null && !TelegramKeysService.IsValidApiId(apiId))
|
||||
{
|
||||
return EndpointResults.BadRequest(InvalidApiIdDetail);
|
||||
}
|
||||
|
||||
if (apiHash is not null && !TelegramKeysService.IsValidApiHash(apiHash))
|
||||
{
|
||||
return EndpointResults.BadRequest(InvalidApiHashDetail);
|
||||
}
|
||||
|
||||
// Частичное обновление: недостающее поле берём из текущих ключей; если ключей ещё нет — нужны оба.
|
||||
TgKeysSnapshot current = await keys.GetAsync(ct);
|
||||
string effectiveApiId = apiId ?? current.ApiId;
|
||||
string effectiveApiHash = apiHash ?? current.ApiHash;
|
||||
if (effectiveApiId.Length == 0 || effectiveApiHash.Length == 0)
|
||||
{
|
||||
return EndpointResults.BadRequest(MissingKeysDetail);
|
||||
}
|
||||
|
||||
await keys.SaveAsync(effectiveApiId, effectiveApiHash, ct);
|
||||
|
||||
// Аудит смены глобальных ключей: apiId — не секрет, apiHash в детали не пишется (Ruling 4).
|
||||
await auditService.AppendAsync(new AuditRecordDto(
|
||||
AuditEvents.TelegramKeysChanged,
|
||||
AuditActorTypes.Operator,
|
||||
ActorId: operatorIdentity.OperatorId,
|
||||
TenantId: null,
|
||||
Ip: ClientIp(context),
|
||||
DetailJson: AuditService.ToDetailJson(new { apiId = effectiveApiId, apiHashSet = true })), ct);
|
||||
|
||||
TelegramKeysMaskedDto snapshot = await keys.GetMaskedAsync(ct);
|
||||
return Results.Ok(snapshot);
|
||||
}
|
||||
|
||||
// IP-адрес клиента для аудита (без порта; null, если недоступен).
|
||||
// context: Контекст запроса.
|
||||
// Возвращает: Строковое представление IP или null.
|
||||
private static string? ClientIp(HttpContext context) => context.Connection.RemoteIpAddress?.ToString();
|
||||
}
|
||||
using Deal.Api.Endpoints.RequestModels;
|
||||
using Deal.Api.Extensions;
|
||||
using Deal.Api.Services;
|
||||
using Deal.Api.Telegram;
|
||||
using Deal.Modules.Tenants.Application.Models;
|
||||
using Deal.Modules.Tenants.Application.Services;
|
||||
|
||||
namespace Deal.Api.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Операторские ручки глобальных (системных) настроек: ключи приложения Telegram
|
||||
/// (ТЗ §4.1/§8.1).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Все ручки — только под операторской сессией: без неё 401 «Требуется вход оператора». Ключи Telegram
|
||||
/// задаёт оператор глобально (едины для всех тенантов), тенант их не видит и не задаёт.
|
||||
/// <list type="bullet">
|
||||
/// <item>GET /api/operator/settings/telegram-keys — маскированный снимок: apiId (не секрет, открыт),
|
||||
/// apiHash (маска) и keysSet;</item>
|
||||
/// <item>PUT /api/operator/settings/telegram-keys {apiId?, apiHash?} — частичное сохранение (можно
|
||||
/// передать только одно поле, второе сохраняется); валидация (api_id 5..9 цифр, api_hash непустой),
|
||||
/// шифрование секрета и аудит telegram_keys_changed (без секретов в деталях).</item>
|
||||
/// </list>
|
||||
/// Ошибки — 400/401 <c>{detail}</c> (формат прототипа, Ruling 10).
|
||||
/// </remarks>
|
||||
public static class OperatorSettingsEndpoints
|
||||
{
|
||||
// Префикс группы операторских настроек.
|
||||
private const string SettingsGroupPrefix = "/api/operator/settings";
|
||||
|
||||
// OpenAPI-тег группы.
|
||||
private const string SettingsOpenApiTag = "operator-settings";
|
||||
|
||||
// Относительный путь глобальных ключей Telegram (GET/PUT).
|
||||
private const string TelegramKeysPath = "/telegram-keys";
|
||||
|
||||
// Текст 400: пустое тело PUT (ни одного поля).
|
||||
private const string EmptyBodyDetail = "Укажите api_id и api_hash";
|
||||
|
||||
// Текст 400: частичное обновление, но ключей ещё нет — нужны оба поля.
|
||||
private const string MissingKeysDetail = "Ключи ещё не заданы — укажите и api_id, и api_hash";
|
||||
|
||||
// Текст 400: api_id не 5..9 цифр.
|
||||
private const string InvalidApiIdDetail = "api_id должен состоять из 5–9 цифр";
|
||||
|
||||
// Текст 400: api_hash пустой/маска/с префиксом enc:.
|
||||
private const string InvalidApiHashDetail = "Укажите непустой api_hash";
|
||||
|
||||
/// <summary>
|
||||
/// Регистрирует группу /api/operator/settings: telegram-keys (GET/PUT).
|
||||
/// </summary>
|
||||
/// <param name="app">Построитель маршрутов приложения.</param>
|
||||
/// <returns>Построитель маршрутов для цепочки вызовов.</returns>
|
||||
public static IEndpointRouteBuilder MapOperatorSettingsEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup(SettingsGroupPrefix).WithTags(SettingsOpenApiTag);
|
||||
group.MapGet(TelegramKeysPath, GetTelegramKeysAsync);
|
||||
group.MapPut(TelegramKeysPath, PutTelegramKeysAsync);
|
||||
return app;
|
||||
}
|
||||
|
||||
// GET /api/operator/settings/telegram-keys: маскированные глобальные ключи Telegram.
|
||||
// context: Контекст запроса.
|
||||
// keys: Сервис глобальных ключей Telegram (scoped).
|
||||
// ct: Токен отмены.
|
||||
// Возвращает: 200 маскированный снимок или 401 без операторской сессии.
|
||||
private static async Task<IResult> GetTelegramKeysAsync(
|
||||
HttpContext context,
|
||||
TelegramKeysService keys,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (context.GetCurrentOperator() is null)
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail);
|
||||
}
|
||||
|
||||
TelegramKeysMaskedDto snapshot = await keys.GetMaskedAsync(ct);
|
||||
return Results.Ok(snapshot);
|
||||
}
|
||||
|
||||
// PUT /api/operator/settings/telegram-keys: частичное сохранение глобальных ключей Telegram
|
||||
// оператором.
|
||||
// Поля можно передавать по отдельности: непереданное поле (null) сохраняет текущее значение,
|
||||
// явное значение (в т.ч. пустая строка) валидируется. Если ключей ещё нет, оба поля обязательны.
|
||||
// body: Тело {apiId?, apiHash?} (хотя бы одно поле).
|
||||
// context: Контекст запроса.
|
||||
// keys: Сервис глобальных ключей Telegram (scoped).
|
||||
// auditService: Сервис аудита (событие telegram_keys_changed).
|
||||
// ct: Токен отмены.
|
||||
// Возвращает: 200 маскированный снимок, 400 при невалидных/недостающих полях или 401 без операторской сессии.
|
||||
private static async Task<IResult> PutTelegramKeysAsync(
|
||||
OperatorTelegramKeysRequest? body,
|
||||
HttpContext context,
|
||||
TelegramKeysService keys,
|
||||
AuditService auditService,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var operatorIdentity = context.GetCurrentOperator();
|
||||
if (operatorIdentity is null)
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail);
|
||||
}
|
||||
|
||||
if (body is null)
|
||||
{
|
||||
return EndpointResults.BadRequest(EmptyBodyDetail);
|
||||
}
|
||||
|
||||
// null — поле не передано (сохраняем текущее); непустая строка/плейсхолдер — валидируем явно.
|
||||
string? apiId = body.ApiId?.Trim();
|
||||
string? apiHash = body.ApiHash?.Trim();
|
||||
if (apiId is null && apiHash is null)
|
||||
{
|
||||
return EndpointResults.BadRequest(EmptyBodyDetail);
|
||||
}
|
||||
|
||||
if (apiId is not null && !TelegramKeysService.IsValidApiId(apiId))
|
||||
{
|
||||
return EndpointResults.BadRequest(InvalidApiIdDetail);
|
||||
}
|
||||
|
||||
if (apiHash is not null && !TelegramKeysService.IsValidApiHash(apiHash))
|
||||
{
|
||||
return EndpointResults.BadRequest(InvalidApiHashDetail);
|
||||
}
|
||||
|
||||
// Частичное обновление: недостающее поле берём из текущих ключей; если ключей ещё нет — нужны оба.
|
||||
TgKeysSnapshot current = await keys.GetAsync(ct);
|
||||
string effectiveApiId = apiId ?? current.ApiId;
|
||||
string effectiveApiHash = apiHash ?? current.ApiHash;
|
||||
if (effectiveApiId.Length == 0 || effectiveApiHash.Length == 0)
|
||||
{
|
||||
return EndpointResults.BadRequest(MissingKeysDetail);
|
||||
}
|
||||
|
||||
await keys.SaveAsync(effectiveApiId, effectiveApiHash, ct);
|
||||
|
||||
// Аудит смены глобальных ключей: apiId — не секрет, apiHash в детали не пишется (Ruling 4).
|
||||
await auditService.AppendAsync(new AuditRecordDto(
|
||||
AuditEvents.TelegramKeysChanged,
|
||||
AuditActorTypes.Operator,
|
||||
ActorId: operatorIdentity.OperatorId,
|
||||
TenantId: null,
|
||||
Ip: ClientIp(context),
|
||||
DetailJson: AuditService.ToDetailJson(new { apiId = effectiveApiId, apiHashSet = true })), ct);
|
||||
|
||||
TelegramKeysMaskedDto snapshot = await keys.GetMaskedAsync(ct);
|
||||
return Results.Ok(snapshot);
|
||||
}
|
||||
|
||||
// IP-адрес клиента для аудита (без порта; null, если недоступен).
|
||||
// context: Контекст запроса.
|
||||
// Возвращает: Строковое представление IP или null.
|
||||
private static string? ClientIp(HttpContext context) => context.Connection.RemoteIpAddress?.ToString();
|
||||
}
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
using Deal.Api.Services;
|
||||
using Deal.Api.Dtos;
|
||||
|
||||
namespace Deal.Api.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Тело POST /api/operator/tenants: создание тенанта оператором (план Task 7, Ruling 11).
|
||||
/// </summary>
|
||||
/// <param name="Name">Имя тенанта (обязательно; пробелы по краям обрезаются).</param>
|
||||
/// <param name="Email">Email владельца (опционально): создаёт сразу пользователя-владельца с одноразовым
|
||||
/// паролем (иначе владелец заводится инвайтом, Ruling 2).</param>
|
||||
public sealed record OperatorTenantCreateRequest(string? Name, string? Email);
|
||||
namespace Deal.Api.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Тело POST /api/operator/tenants: создание тенанта оператором (план Task 7, Ruling 11).
|
||||
/// </summary>
|
||||
/// <param name="Name">Имя тенанта (обязательно; пробелы по краям обрезаются).</param>
|
||||
/// <param name="Email">Email владельца (опционально): создаёт сразу пользователя-владельца с одноразовым
|
||||
/// паролем (иначе владелец заводится инвайтом, Ruling 2).</param>
|
||||
public sealed record OperatorTenantCreateRequest(string? Name, string? Email);
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
using Deal.Api.Services;
|
||||
using Deal.Api.Dtos;
|
||||
|
||||
namespace Deal.Api.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Тело POST /api/operator/tenants/{id}/impersonate: опциональный логин пользователя тенанта (план Task 7).
|
||||
/// </summary>
|
||||
/// <param name="Login">Логин пользователя, под которым оператор входит (impersonation); null/пустой —
|
||||
/// берётся первый пользователь тенанта (по времени создания).</param>
|
||||
public sealed record OperatorTenantImpersonateRequest(string? Login);
|
||||
namespace Deal.Api.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Тело POST /api/operator/tenants/{id}/impersonate: опциональный логин пользователя тенанта (план Task 7).
|
||||
/// </summary>
|
||||
/// <param name="Login">Логин пользователя, под которым оператор входит (impersonation); null/пустой —
|
||||
/// берётся первый пользователь тенанта (по времени создания).</param>
|
||||
public sealed record OperatorTenantImpersonateRequest(string? Login);
|
||||
|
||||
@@ -1,296 +1,291 @@
|
||||
using Deal.Api.Extensions;
|
||||
using Deal.Api.Models;
|
||||
using Deal.Api.Services;
|
||||
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 Microsoft.Extensions.Options;
|
||||
using Deal.Api.Dtos;
|
||||
using CookieOptions = Deal.Api.Configuration.CookieOptions;
|
||||
|
||||
namespace Deal.Api.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Операторские эндпоинты тенантов: create/список/детали, suspend/unsuspend, impersonation (план Task 7, Ruling 1/4/10/11).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Все ручки — только оператору: без операторской сессии 401 «Требуется вход оператора» (как остальные
|
||||
/// /api/operator/*). POST "" (create {name, email?}) — тенант (Status active) + провижининг схемы + аудит
|
||||
/// tenant_created; список — {items:[...]} (реестр + счётчик пользователей; поля лимитов добавит Task 8),
|
||||
/// детали — {id,name,status,createdAt,users:[...]}. Suspend/unsuspend меняют Status тенанта
|
||||
/// (TenantAdminService) и пишут аудит tenant_status_changed (только при реальном изменении — повторный
|
||||
/// suspend идемпотентен). Impersonation выпускает tenant-сессию целевого пользователя
|
||||
/// (AuthService, механизм обычного входа; пароль не меняется) и возвращает {sessionToken, expiresAt,
|
||||
/// tenantId, login} — токен используется как значение куки deal_session; аудит impersonation_started
|
||||
/// (DetailJson: targetLogin, tenantId), завершение — logout'ом пользователя (impersonation_stopped в
|
||||
/// AuthEndpoints). Зафиксированные решения Task 7: suspend-гейт отвечает 403 (не 401; см. AuthEndpoints),
|
||||
/// impersonation suspended-тенанта разрешён (аудируется; ИИ заморожен гейтом Task 9), PATCH {status} плана
|
||||
/// заменён на явные POST /suspend|/unsuspend, budget? при create не принимается до Task 8/10 — отклонения
|
||||
/// для api-map/техдок Task 16 зафиксированы в task-7-report.md. Тексты ошибок — фиксированные строки
|
||||
/// HTTP-слоя (паттерн OperatorInvitesEndpoints).
|
||||
/// </remarks>
|
||||
public static class OperatorTenantsEndpoints
|
||||
{
|
||||
// Текст 400: имя тенанта пустое/пробельное (create).
|
||||
private const string TenantNameRequiredDetail = "Имя тенанта обязательно";
|
||||
|
||||
// Текст 400: email пустой/некорректного формата (create с владельцем).
|
||||
private const string InvalidEmailDetail = "Некорректный email";
|
||||
|
||||
// Текст 400: пользователь с таким email уже зарегистрирован (users.login unique, create с владельцем).
|
||||
private const string EmailTakenDetail = "Этот email уже зарегистрирован";
|
||||
|
||||
// Текст 404: тенант с таким id не найден.
|
||||
private const string TenantNotFoundDetail = "Тенант не найден";
|
||||
|
||||
// Текст 404: пользователь с таким login не найден в тенанте.
|
||||
private const string UserNotFoundInTenantDetail = "Пользователь не найден в тенанте";
|
||||
|
||||
// Текст 400: в тенанте нет пользователей, а login не указан (impersonation без выбора).
|
||||
private const string TenantHasNoUsersDetail = "В тенанте нет пользователей для входа";
|
||||
|
||||
// Префикс группы операторских ручек тенантов (Ruling 11).
|
||||
private const string TenantsGroupPrefix = "/api/operator/tenants";
|
||||
|
||||
// Относительный путь деталей тенанта.
|
||||
private const string TenantByIdPath = "/{id:guid}";
|
||||
|
||||
// Относительный путь приостановки тенанта.
|
||||
private const string SuspendPath = "/{id:guid}/suspend";
|
||||
|
||||
// Относительный путь возобновления тенанта.
|
||||
private const string UnsuspendPath = "/{id:guid}/unsuspend";
|
||||
|
||||
// Относительный путь impersonation пользователя тенанта.
|
||||
private const string ImpersonatePath = "/{id:guid}/impersonate";
|
||||
|
||||
// OpenAPI-тег группы.
|
||||
private const string TenantsOpenApiTag = "operator-tenants";
|
||||
|
||||
/// <summary>
|
||||
/// Регистрирует группу /api/operator/tenants: список, create, детали, suspend/unsuspend, impersonate.
|
||||
/// </summary>
|
||||
/// <param name="app">Построитель маршрутов приложения.</param>
|
||||
/// <returns>Построитель маршрутов для цепочки вызовов.</returns>
|
||||
public static IEndpointRouteBuilder MapOperatorTenantsEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup(TenantsGroupPrefix).WithTags(TenantsOpenApiTag);
|
||||
|
||||
group.MapGet("", ListAsync);
|
||||
group.MapPost("", CreateAsync);
|
||||
group.MapGet(TenantByIdPath, GetByIdAsync);
|
||||
group.MapPost(SuspendPath, SuspendAsync);
|
||||
group.MapPost(UnsuspendPath, UnsuspendAsync);
|
||||
group.MapPost(ImpersonatePath, ImpersonateAsync);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
// GET /api/operator/tenants: список тенантов со счётчиками пользователей (план Task 7).
|
||||
private static async Task<IResult> ListAsync(
|
||||
HttpContext context,
|
||||
TenantAdminService tenantAdminService,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var operatorIdentity = context.GetCurrentOperator();
|
||||
if (operatorIdentity is null)
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail);
|
||||
}
|
||||
|
||||
IReadOnlyList<TenantListItemDto> items = await tenantAdminService.ListAsync(ct);
|
||||
return Results.Ok(new { items });
|
||||
}
|
||||
|
||||
// POST /api/operator/tenants: создание тенанта (Status active + провижининг схемы); аудит tenant_created.
|
||||
// Решение Task 7: PATCH {status} заменён на явные POST /suspend и /unsuspend — create принимает только
|
||||
// {name, email?}; budget?/лимиты — зона Task 8/10 (прецедент: join-строка лимитов отложена в Task 6).
|
||||
private static async Task<IResult> CreateAsync(
|
||||
OperatorTenantCreateRequest body,
|
||||
HttpContext context,
|
||||
TenantAdminService tenantAdminService,
|
||||
AuditService auditService,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var operatorIdentity = context.GetCurrentOperator();
|
||||
if (operatorIdentity is null)
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail);
|
||||
}
|
||||
|
||||
TenantCreateResultDto result = await tenantAdminService.CreateAsync(body.Name, body.Email, ct);
|
||||
if (!result.Ok || result.Tenant is null)
|
||||
{
|
||||
return result.Error switch
|
||||
{
|
||||
TenantCreateResultDto.ErrorInvalidEmail => EndpointResults.BadRequest(InvalidEmailDetail),
|
||||
TenantCreateResultDto.ErrorEmailTaken => EndpointResults.BadRequest(EmailTakenDetail),
|
||||
_ => EndpointResults.BadRequest(TenantNameRequiredDetail),
|
||||
};
|
||||
}
|
||||
|
||||
// Одноразовый пароль владельца в аудит/логи не пишется (правило секретов); raw — только в ответе ниже.
|
||||
TenantRecordDto createdTenant = result.Tenant;
|
||||
await auditService.AppendAsync(new AuditRecordDto(
|
||||
AuditEvents.TenantCreated,
|
||||
AuditActorTypes.Operator,
|
||||
ActorId: operatorIdentity.OperatorId,
|
||||
TenantId: createdTenant.Id,
|
||||
Ip: ClientIp(context),
|
||||
DetailJson: AuditService.ToDetailJson(new { tenantId = createdTenant.Id, name = createdTenant.Name, email = result.OwnerLogin })), ct);
|
||||
|
||||
if (result.OwnerLogin is not null)
|
||||
{
|
||||
return Results.Ok(new
|
||||
{
|
||||
createdTenant.Id,
|
||||
createdTenant.Name,
|
||||
createdTenant.Status,
|
||||
createdTenant.CreatedAt,
|
||||
ownerEmail = result.OwnerLogin,
|
||||
initialPassword = result.InitialPassword,
|
||||
});
|
||||
}
|
||||
|
||||
return Results.Ok(new { createdTenant.Id, createdTenant.Name, createdTenant.Status, createdTenant.CreatedAt });
|
||||
}
|
||||
|
||||
// GET /api/operator/tenants/{id}: детали тенанта и его пользователи.
|
||||
private static async Task<IResult> GetByIdAsync(
|
||||
Guid id,
|
||||
HttpContext context,
|
||||
TenantAdminService tenantAdminService,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var operatorIdentity = context.GetCurrentOperator();
|
||||
if (operatorIdentity is null)
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail);
|
||||
}
|
||||
|
||||
TenantDetailDto? tenant = await tenantAdminService.GetAsync(id, ct);
|
||||
if (tenant is null)
|
||||
{
|
||||
return EndpointResults.NotFound(TenantNotFoundDetail);
|
||||
}
|
||||
|
||||
return Results.Ok(tenant);
|
||||
}
|
||||
|
||||
// POST /api/operator/tenants/{id}/suspend: приостановка тенанта; аудит tenant_status_changed.
|
||||
private static Task<IResult> SuspendAsync(
|
||||
Guid id,
|
||||
HttpContext context,
|
||||
TenantAdminService tenantAdminService,
|
||||
AuditService auditService,
|
||||
CancellationToken ct) =>
|
||||
ApplyStatusAsync(id, TenantStatuses.Suspended, context, tenantAdminService, auditService, ct);
|
||||
|
||||
// POST /api/operator/tenants/{id}/unsuspend: возобновление тенанта; аудит tenant_status_changed.
|
||||
private static Task<IResult> UnsuspendAsync(
|
||||
Guid id,
|
||||
HttpContext context,
|
||||
TenantAdminService tenantAdminService,
|
||||
AuditService auditService,
|
||||
CancellationToken ct) =>
|
||||
ApplyStatusAsync(id, TenantStatuses.Active, context, tenantAdminService, auditService, ct);
|
||||
|
||||
// Общая логика suspend/unsuspend: проверка оператора, смена статуса, аудит при реальном изменении.
|
||||
// id: Идентификатор тенанта.
|
||||
// status: Новый статус — константа TenantStatuses.
|
||||
// context: Контекст запроса (операторская сессия, IP).
|
||||
// tenantAdminService: Сервис реестра тенантов.
|
||||
// auditService: Сервис аудита (запись tenant_status_changed при изменении).
|
||||
// ct: Токен отмены.
|
||||
// Возвращает: 200 {ok,status} или 401/404 {detail}.
|
||||
private static async Task<IResult> ApplyStatusAsync(
|
||||
Guid id,
|
||||
string status,
|
||||
HttpContext context,
|
||||
TenantAdminService tenantAdminService,
|
||||
AuditService auditService,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var operatorIdentity = context.GetCurrentOperator();
|
||||
if (operatorIdentity is null)
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail);
|
||||
}
|
||||
|
||||
TenantStatusChangeResultDto result = await tenantAdminService.ChangeStatusAsync(id, status, ct);
|
||||
if (!result.Ok || result.Tenant is null)
|
||||
{
|
||||
return EndpointResults.NotFound(TenantNotFoundDetail);
|
||||
}
|
||||
|
||||
if (result.Changed)
|
||||
{
|
||||
await auditService.AppendAsync(new AuditRecordDto(
|
||||
AuditEvents.TenantStatusChanged,
|
||||
AuditActorTypes.Operator,
|
||||
ActorId: operatorIdentity.OperatorId,
|
||||
TenantId: result.Tenant.Id,
|
||||
Ip: ClientIp(context),
|
||||
DetailJson: AuditService.ToDetailJson(new { tenantId = result.Tenant.Id, status = result.Tenant.Status })), ct);
|
||||
}
|
||||
|
||||
return Results.Ok(new { ok = true, status = result.Tenant.Status });
|
||||
}
|
||||
|
||||
// POST /api/operator/tenants/{id}/impersonate: tenant-сессия пользователя тенанта; аудит impersonation_started.
|
||||
private static async Task<IResult> ImpersonateAsync(
|
||||
Guid id,
|
||||
OperatorTenantImpersonateRequest? body,
|
||||
HttpContext context,
|
||||
AuthService authService,
|
||||
AuditService auditService,
|
||||
IOptions<CookieOptions> cookieOptions,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var operatorIdentity = context.GetCurrentOperator();
|
||||
if (operatorIdentity is null)
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail);
|
||||
}
|
||||
|
||||
ImpersonationResultDto result = await authService.ImpersonateAsync(
|
||||
id, body?.Login, operatorIdentity.OperatorId, ct);
|
||||
if (!result.Ok || result.SessionToken is null || result.Login is null || result.TenantId is null)
|
||||
{
|
||||
return result.Error switch
|
||||
{
|
||||
ImpersonationResultDto.ErrorTenantNotFound => EndpointResults.NotFound(TenantNotFoundDetail),
|
||||
ImpersonationResultDto.ErrorUserNotFound => EndpointResults.NotFound(UserNotFoundInTenantDetail),
|
||||
_ => EndpointResults.BadRequest(TenantHasNoUsersDetail),
|
||||
};
|
||||
}
|
||||
|
||||
await auditService.AppendAsync(new AuditRecordDto(
|
||||
AuditEvents.ImpersonationStarted,
|
||||
AuditActorTypes.Operator,
|
||||
ActorId: operatorIdentity.OperatorId,
|
||||
TenantId: result.TenantId,
|
||||
Ip: ClientIp(context),
|
||||
DetailJson: AuditService.ToDetailJson(new { targetLogin = result.Login, tenantId = result.TenantId })), ct);
|
||||
|
||||
// Токен — это tenant-сессия (как после /api/auth/login): СТАВИМ ту же httpOnly-куку deal_session
|
||||
// на ответ, чтобы браузер оператора сразу получил tenant-сессию (JS не может записать httpOnly-куку).
|
||||
// Завершение — POST /api/auth/logout (пишет impersonation_stopped).
|
||||
SessionCookieWriter.Append(context, cookieOptions.Value, result.SessionToken);
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
sessionToken = result.SessionToken,
|
||||
expiresAt = result.ExpiresAt,
|
||||
tenantId = result.TenantId,
|
||||
login = result.Login,
|
||||
});
|
||||
}
|
||||
|
||||
// IP-адрес клиента для аудита (без порта; null, если недоступен).
|
||||
// context: Контекст запроса.
|
||||
// Возвращает: Строковое представление IP или null.
|
||||
private static string? ClientIp(HttpContext context) => context.Connection.RemoteIpAddress?.ToString();
|
||||
}
|
||||
using Deal.Api.Extensions;
|
||||
using Deal.Api.Services;
|
||||
using Deal.Modules.Tenants.Application.Models;
|
||||
using Deal.Modules.Tenants.Application.Services;
|
||||
using Microsoft.Extensions.Options;
|
||||
using CookieOptions = Deal.Api.Configuration.CookieOptions;
|
||||
|
||||
namespace Deal.Api.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Операторские эндпоинты тенантов: create/список/детали, suspend/unsuspend, impersonation (план Task 7, Ruling 1/4/10/11).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Все ручки — только оператору: без операторской сессии 401 «Требуется вход оператора» (как остальные
|
||||
/// /api/operator/*). POST "" (create {name, email?}) — тенант (Status active) + провижининг схемы + аудит
|
||||
/// tenant_created; список — {items:[...]} (реестр + счётчик пользователей; поля лимитов добавит Task 8),
|
||||
/// детали — {id,name,status,createdAt,users:[...]}. Suspend/unsuspend меняют Status тенанта
|
||||
/// (TenantAdminService) и пишут аудит tenant_status_changed (только при реальном изменении — повторный
|
||||
/// suspend идемпотентен). Impersonation выпускает tenant-сессию целевого пользователя
|
||||
/// (AuthService, механизм обычного входа; пароль не меняется) и возвращает {sessionToken, expiresAt,
|
||||
/// tenantId, login} — токен используется как значение куки deal_session; аудит impersonation_started
|
||||
/// (DetailJson: targetLogin, tenantId), завершение — logout'ом пользователя (impersonation_stopped в
|
||||
/// AuthEndpoints). Зафиксированные решения Task 7: suspend-гейт отвечает 403 (не 401; см. AuthEndpoints),
|
||||
/// impersonation suspended-тенанта разрешён (аудируется; ИИ заморожен гейтом Task 9), PATCH {status} плана
|
||||
/// заменён на явные POST /suspend|/unsuspend, budget? при create не принимается до Task 8/10 — отклонения
|
||||
/// для api-map/техдок Task 16 зафиксированы в task-7-report.md. Тексты ошибок — фиксированные строки
|
||||
/// HTTP-слоя (паттерн OperatorInvitesEndpoints).
|
||||
/// </remarks>
|
||||
public static class OperatorTenantsEndpoints
|
||||
{
|
||||
// Текст 400: имя тенанта пустое/пробельное (create).
|
||||
private const string TenantNameRequiredDetail = "Имя тенанта обязательно";
|
||||
|
||||
// Текст 400: email пустой/некорректного формата (create с владельцем).
|
||||
private const string InvalidEmailDetail = "Некорректный email";
|
||||
|
||||
// Текст 400: пользователь с таким email уже зарегистрирован (users.login unique, create с владельцем).
|
||||
private const string EmailTakenDetail = "Этот email уже зарегистрирован";
|
||||
|
||||
// Текст 404: тенант с таким id не найден.
|
||||
private const string TenantNotFoundDetail = "Тенант не найден";
|
||||
|
||||
// Текст 404: пользователь с таким login не найден в тенанте.
|
||||
private const string UserNotFoundInTenantDetail = "Пользователь не найден в тенанте";
|
||||
|
||||
// Текст 400: в тенанте нет пользователей, а login не указан (impersonation без выбора).
|
||||
private const string TenantHasNoUsersDetail = "В тенанте нет пользователей для входа";
|
||||
|
||||
// Префикс группы операторских ручек тенантов (Ruling 11).
|
||||
private const string TenantsGroupPrefix = "/api/operator/tenants";
|
||||
|
||||
// Относительный путь деталей тенанта.
|
||||
private const string TenantByIdPath = "/{id:guid}";
|
||||
|
||||
// Относительный путь приостановки тенанта.
|
||||
private const string SuspendPath = "/{id:guid}/suspend";
|
||||
|
||||
// Относительный путь возобновления тенанта.
|
||||
private const string UnsuspendPath = "/{id:guid}/unsuspend";
|
||||
|
||||
// Относительный путь impersonation пользователя тенанта.
|
||||
private const string ImpersonatePath = "/{id:guid}/impersonate";
|
||||
|
||||
// OpenAPI-тег группы.
|
||||
private const string TenantsOpenApiTag = "operator-tenants";
|
||||
|
||||
/// <summary>
|
||||
/// Регистрирует группу /api/operator/tenants: список, create, детали, suspend/unsuspend, impersonate.
|
||||
/// </summary>
|
||||
/// <param name="app">Построитель маршрутов приложения.</param>
|
||||
/// <returns>Построитель маршрутов для цепочки вызовов.</returns>
|
||||
public static IEndpointRouteBuilder MapOperatorTenantsEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup(TenantsGroupPrefix).WithTags(TenantsOpenApiTag);
|
||||
|
||||
group.MapGet("", ListAsync);
|
||||
group.MapPost("", CreateAsync);
|
||||
group.MapGet(TenantByIdPath, GetByIdAsync);
|
||||
group.MapPost(SuspendPath, SuspendAsync);
|
||||
group.MapPost(UnsuspendPath, UnsuspendAsync);
|
||||
group.MapPost(ImpersonatePath, ImpersonateAsync);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
// GET /api/operator/tenants: список тенантов со счётчиками пользователей (план Task 7).
|
||||
private static async Task<IResult> ListAsync(
|
||||
HttpContext context,
|
||||
TenantAdminService tenantAdminService,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var operatorIdentity = context.GetCurrentOperator();
|
||||
if (operatorIdentity is null)
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail);
|
||||
}
|
||||
|
||||
IReadOnlyList<TenantListItemDto> items = await tenantAdminService.ListAsync(ct);
|
||||
return Results.Ok(new { items });
|
||||
}
|
||||
|
||||
// POST /api/operator/tenants: создание тенанта (Status active + провижининг схемы); аудит tenant_created.
|
||||
// Решение Task 7: PATCH {status} заменён на явные POST /suspend и /unsuspend — create принимает только
|
||||
// {name, email?}; budget?/лимиты — зона Task 8/10 (прецедент: join-строка лимитов отложена в Task 6).
|
||||
private static async Task<IResult> CreateAsync(
|
||||
OperatorTenantCreateRequest body,
|
||||
HttpContext context,
|
||||
TenantAdminService tenantAdminService,
|
||||
AuditService auditService,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var operatorIdentity = context.GetCurrentOperator();
|
||||
if (operatorIdentity is null)
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail);
|
||||
}
|
||||
|
||||
TenantCreateResultDto result = await tenantAdminService.CreateAsync(body.Name, body.Email, ct);
|
||||
if (!result.Ok || result.Tenant is null)
|
||||
{
|
||||
return result.Error switch
|
||||
{
|
||||
TenantCreateResultDto.ErrorInvalidEmail => EndpointResults.BadRequest(InvalidEmailDetail),
|
||||
TenantCreateResultDto.ErrorEmailTaken => EndpointResults.BadRequest(EmailTakenDetail),
|
||||
_ => EndpointResults.BadRequest(TenantNameRequiredDetail),
|
||||
};
|
||||
}
|
||||
|
||||
// Одноразовый пароль владельца в аудит/логи не пишется (правило секретов); raw — только в ответе ниже.
|
||||
TenantRecordDto createdTenant = result.Tenant;
|
||||
await auditService.AppendAsync(new AuditRecordDto(
|
||||
AuditEvents.TenantCreated,
|
||||
AuditActorTypes.Operator,
|
||||
ActorId: operatorIdentity.OperatorId,
|
||||
TenantId: createdTenant.Id,
|
||||
Ip: ClientIp(context),
|
||||
DetailJson: AuditService.ToDetailJson(new { tenantId = createdTenant.Id, name = createdTenant.Name, email = result.OwnerLogin })), ct);
|
||||
|
||||
if (result.OwnerLogin is not null)
|
||||
{
|
||||
return Results.Ok(new
|
||||
{
|
||||
createdTenant.Id,
|
||||
createdTenant.Name,
|
||||
createdTenant.Status,
|
||||
createdTenant.CreatedAt,
|
||||
ownerEmail = result.OwnerLogin,
|
||||
initialPassword = result.InitialPassword,
|
||||
});
|
||||
}
|
||||
|
||||
return Results.Ok(new { createdTenant.Id, createdTenant.Name, createdTenant.Status, createdTenant.CreatedAt });
|
||||
}
|
||||
|
||||
// GET /api/operator/tenants/{id}: детали тенанта и его пользователи.
|
||||
private static async Task<IResult> GetByIdAsync(
|
||||
Guid id,
|
||||
HttpContext context,
|
||||
TenantAdminService tenantAdminService,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var operatorIdentity = context.GetCurrentOperator();
|
||||
if (operatorIdentity is null)
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail);
|
||||
}
|
||||
|
||||
TenantDetailDto? tenant = await tenantAdminService.GetAsync(id, ct);
|
||||
if (tenant is null)
|
||||
{
|
||||
return EndpointResults.NotFound(TenantNotFoundDetail);
|
||||
}
|
||||
|
||||
return Results.Ok(tenant);
|
||||
}
|
||||
|
||||
// POST /api/operator/tenants/{id}/suspend: приостановка тенанта; аудит tenant_status_changed.
|
||||
private static Task<IResult> SuspendAsync(
|
||||
Guid id,
|
||||
HttpContext context,
|
||||
TenantAdminService tenantAdminService,
|
||||
AuditService auditService,
|
||||
CancellationToken ct) =>
|
||||
ApplyStatusAsync(id, TenantStatuses.Suspended, context, tenantAdminService, auditService, ct);
|
||||
|
||||
// POST /api/operator/tenants/{id}/unsuspend: возобновление тенанта; аудит tenant_status_changed.
|
||||
private static Task<IResult> UnsuspendAsync(
|
||||
Guid id,
|
||||
HttpContext context,
|
||||
TenantAdminService tenantAdminService,
|
||||
AuditService auditService,
|
||||
CancellationToken ct) =>
|
||||
ApplyStatusAsync(id, TenantStatuses.Active, context, tenantAdminService, auditService, ct);
|
||||
|
||||
// Общая логика suspend/unsuspend: проверка оператора, смена статуса, аудит при реальном изменении.
|
||||
// id: Идентификатор тенанта.
|
||||
// status: Новый статус — константа TenantStatuses.
|
||||
// context: Контекст запроса (операторская сессия, IP).
|
||||
// tenantAdminService: Сервис реестра тенантов.
|
||||
// auditService: Сервис аудита (запись tenant_status_changed при изменении).
|
||||
// ct: Токен отмены.
|
||||
// Возвращает: 200 {ok,status} или 401/404 {detail}.
|
||||
private static async Task<IResult> ApplyStatusAsync(
|
||||
Guid id,
|
||||
string status,
|
||||
HttpContext context,
|
||||
TenantAdminService tenantAdminService,
|
||||
AuditService auditService,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var operatorIdentity = context.GetCurrentOperator();
|
||||
if (operatorIdentity is null)
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail);
|
||||
}
|
||||
|
||||
TenantStatusChangeResultDto result = await tenantAdminService.ChangeStatusAsync(id, status, ct);
|
||||
if (!result.Ok || result.Tenant is null)
|
||||
{
|
||||
return EndpointResults.NotFound(TenantNotFoundDetail);
|
||||
}
|
||||
|
||||
if (result.Changed)
|
||||
{
|
||||
await auditService.AppendAsync(new AuditRecordDto(
|
||||
AuditEvents.TenantStatusChanged,
|
||||
AuditActorTypes.Operator,
|
||||
ActorId: operatorIdentity.OperatorId,
|
||||
TenantId: result.Tenant.Id,
|
||||
Ip: ClientIp(context),
|
||||
DetailJson: AuditService.ToDetailJson(new { tenantId = result.Tenant.Id, status = result.Tenant.Status })), ct);
|
||||
}
|
||||
|
||||
return Results.Ok(new { ok = true, status = result.Tenant.Status });
|
||||
}
|
||||
|
||||
// POST /api/operator/tenants/{id}/impersonate: tenant-сессия пользователя тенанта; аудит impersonation_started.
|
||||
private static async Task<IResult> ImpersonateAsync(
|
||||
Guid id,
|
||||
OperatorTenantImpersonateRequest? body,
|
||||
HttpContext context,
|
||||
AuthService authService,
|
||||
AuditService auditService,
|
||||
IOptions<CookieOptions> cookieOptions,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var operatorIdentity = context.GetCurrentOperator();
|
||||
if (operatorIdentity is null)
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail);
|
||||
}
|
||||
|
||||
ImpersonationResultDto result = await authService.ImpersonateAsync(
|
||||
id, body?.Login, operatorIdentity.OperatorId, ct);
|
||||
if (!result.Ok || result.SessionToken is null || result.Login is null || result.TenantId is null)
|
||||
{
|
||||
return result.Error switch
|
||||
{
|
||||
ImpersonationResultDto.ErrorTenantNotFound => EndpointResults.NotFound(TenantNotFoundDetail),
|
||||
ImpersonationResultDto.ErrorUserNotFound => EndpointResults.NotFound(UserNotFoundInTenantDetail),
|
||||
_ => EndpointResults.BadRequest(TenantHasNoUsersDetail),
|
||||
};
|
||||
}
|
||||
|
||||
await auditService.AppendAsync(new AuditRecordDto(
|
||||
AuditEvents.ImpersonationStarted,
|
||||
AuditActorTypes.Operator,
|
||||
ActorId: operatorIdentity.OperatorId,
|
||||
TenantId: result.TenantId,
|
||||
Ip: ClientIp(context),
|
||||
DetailJson: AuditService.ToDetailJson(new { targetLogin = result.Login, tenantId = result.TenantId })), ct);
|
||||
|
||||
// Токен — это tenant-сессия (как после /api/auth/login): СТАВИМ ту же httpOnly-куку deal_session
|
||||
// на ответ, чтобы браузер оператора сразу получил tenant-сессию (JS не может записать httpOnly-куку).
|
||||
// Завершение — POST /api/auth/logout (пишет impersonation_stopped).
|
||||
SessionCookieWriter.Append(context, cookieOptions.Value, result.SessionToken);
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
sessionToken = result.SessionToken,
|
||||
expiresAt = result.ExpiresAt,
|
||||
tenantId = result.TenantId,
|
||||
login = result.Login,
|
||||
});
|
||||
}
|
||||
|
||||
// IP-адрес клиента для аудита (без порта; null, если недоступен).
|
||||
// context: Контекст запроса.
|
||||
// Возвращает: Строковое представление IP или null.
|
||||
private static string? ClientIp(HttpContext context) => context.Connection.RemoteIpAddress?.ToString();
|
||||
}
|
||||
|
||||
@@ -1,190 +1,186 @@
|
||||
using Deal.Api.Endpoints.RequestModels;
|
||||
using Deal.Api.Extensions;
|
||||
using Deal.Api.Models;
|
||||
using Deal.Api.Services;
|
||||
using Deal.Modules.Pipeline.Application.Abstractions;
|
||||
using Deal.Modules.Pipeline.Application.Models;
|
||||
using Deal.Modules.Pipeline.Application.Registrars;
|
||||
using Deal.Modules.Pipeline.Application.Services;
|
||||
using Deal.Api.Dtos;
|
||||
|
||||
namespace Deal.Api.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Эндпоинты вкладки «Обработка»: GET /api/pipeline/stats|queue|rejected, POST /api/pipeline/rejected/clear,
|
||||
/// DELETE /api/pipeline/rejected/{rejId}, POST /api/pipeline/rejected/{rejId}/return (план Task 9 L437–460,
|
||||
/// Rulings 6/10; прототип processing_routes.py L17–74).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Контракт 1:1 с прототипом и api-map §3.6 L178–186, §4.5: /stats → {queue:{new,ai,total}, rejected};
|
||||
/// /queue?limit= → {items, counts:{new,ai,total}, rejected} (limit ≤500, дефолт 100, фронт шлёт 120);
|
||||
/// /rejected?q=&offset=&limit= → {items, total, offset, limit} (q — FTS ∪ LIKE-поиск, Ruling 6);
|
||||
/// /rejected/clear → {ok, cleared}; DELETE /rejected/{rejId} → {ok:true} всегда (delete_one L196–198, 404 не
|
||||
/// шлём — Ruling 10); /rejected/{rejId}/return {reason=""} → {id, returned:true, returnedAt} | 400 (строки
|
||||
/// Ruling 10) | 404 «Запись не найдена» (текст 404 — слой эндпоинтов, паттерн CardsService → LeadsEndpoints).
|
||||
/// Все эндпоинты требуют сессию: 401 {detail} без куки (Ruling 10); сервисы модуля резолвятся из
|
||||
/// RequestServices ПОСЛЕ проверки сессии (scoped на tenant-контекст запроса, паттерн SettingsEndpoints).
|
||||
/// Статические сегменты (/stats, /queue, /rejected/clear) до параметризованного /rejected/{rejId} — порядок
|
||||
/// как в прототипе (api-map L19), хотя литералы имеют приоритет в ASP.NET Core.
|
||||
/// </remarks>
|
||||
public static class PipelineEndpoints
|
||||
{
|
||||
// Префикс группы (роутер processing, prefix="/api/pipeline" — processing_routes.py L10).
|
||||
private const string PipelineGroupPrefix = "/api/pipeline";
|
||||
|
||||
// OpenAPI-тег группы (в прототипе роутер processing — processing_routes.py L10).
|
||||
private const string OpenApiTag = "processing";
|
||||
|
||||
// Путь сводки вкладки «Обработка» (GET).
|
||||
private const string StatsPath = "/stats";
|
||||
|
||||
// Путь сырых сообщений очереди (GET).
|
||||
private const string QueuePath = "/queue";
|
||||
|
||||
// Путь страницы отсева (GET).
|
||||
private const string RejectedPath = "/rejected";
|
||||
|
||||
// Путь полной очистки отсева (POST).
|
||||
private const string RejectedClearPath = "/rejected/clear";
|
||||
|
||||
// Путь удаления одной записи отсева (DELETE).
|
||||
private const string RejectedIdPath = "/rejected/{rejId}";
|
||||
|
||||
// Путь возврата записи отсева в обработку (POST).
|
||||
private const string RejectedReturnPath = "/rejected/{rejId}/return";
|
||||
|
||||
// 404 return: записи отсева нет (processing_routes.py L71: KeyError → 404, Ruling 10).
|
||||
private const string RejectedNotFoundDetail = "Запись не найдена";
|
||||
|
||||
// Размер страницы по умолчанию списков очереди/отсева (processing.DEFAULT_LIMIT L48; фронт шлёт 120/80).
|
||||
private const int DefaultPageSize = 100;
|
||||
|
||||
/// <summary>
|
||||
/// Регистрирует группу /api/pipeline: stats/queue/rejected/clear/{rejId}/return.
|
||||
/// </summary>
|
||||
/// <param name="app">Построитель маршрутов приложения.</param>
|
||||
/// <returns>Построитель маршрутов для цепочки вызовов.</returns>
|
||||
public static IEndpointRouteBuilder MapPipelineEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var pipeline = app.MapGroup(PipelineGroupPrefix).WithTags(OpenApiTag);
|
||||
|
||||
// Статические сегменты до /rejected/{rejId} (Ruling 10, api-map L19; порядок 1:1 с прототипом).
|
||||
pipeline.MapGet(StatsPath, StatsAsync);
|
||||
pipeline.MapGet(QueuePath, QueueAsync);
|
||||
pipeline.MapGet(RejectedPath, RejectedAsync);
|
||||
pipeline.MapPost(RejectedClearPath, ClearAsync);
|
||||
pipeline.MapDelete(RejectedIdPath, DeleteAsync);
|
||||
pipeline.MapPost(RejectedReturnPath, ReturnAsync);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
// GET /api/pipeline/stats: сводка вкладки {queue:{new,ai,total}, rejected} (processing_routes.py L17–20, stats L315–320).
|
||||
private static async Task<IResult> StatsAsync(HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
PipelineProcessingService processing = context.RequestServices.GetRequiredService<PipelineProcessingService>();
|
||||
return Results.Ok(await processing.StatsAsync(ct));
|
||||
}
|
||||
|
||||
// GET /api/pipeline/queue?limit=: сырые сообщения очереди + счётчики + число отсева (processing_routes.py L23–31).
|
||||
// Ответ {items, counts:{new,ai,total}, rejected} 1:1 с list_queue L218–241 + queue_counts L207–215 +
|
||||
// rejected_count L201–202. limit — дефолт 100, clamp 1..500 делает сервис (ListQueueAsync).
|
||||
private static async Task<IResult> QueueAsync(
|
||||
int? limit,
|
||||
HttpContext context,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
PipelineProcessingService processing = context.RequestServices.GetRequiredService<PipelineProcessingService>();
|
||||
IReadOnlyList<QueueItemDto> items = await processing.ListQueueAsync(limit ?? DefaultPageSize, ct);
|
||||
QueueCountsDto counts = await processing.QueueCountsAsync(ct);
|
||||
int rejected = await processing.RejectedCountAsync(ct);
|
||||
return Results.Ok(new { items, counts, rejected });
|
||||
}
|
||||
|
||||
// GET /api/pipeline/rejected?q=&offset=&limit=: страница отсева (processing_routes.py L34–42, list_rejected L246–312).
|
||||
// q — поиск по тексту/причине/фразе/имени канала (FTS ∪ LIKE, Ruling 6), пустой q — весь отсев свежими
|
||||
// первыми; offset ≥ 0, limit 1..500 (clamp в сервисе), значения эхом в ответе {items,total,offset,limit}.
|
||||
private static async Task<IResult> RejectedAsync(
|
||||
string? q,
|
||||
int? offset,
|
||||
int? limit,
|
||||
HttpContext context,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
PipelineProcessingService processing = context.RequestServices.GetRequiredService<PipelineProcessingService>();
|
||||
RejectedPageDto page = await processing.ListRejectedAsync(q ?? string.Empty, offset ?? 0, limit ?? DefaultPageSize, ct);
|
||||
return Results.Ok(page);
|
||||
}
|
||||
|
||||
// POST /api/pipeline/rejected/clear: полная безвозвратная очистка отсева (processing_routes.py L45–49, clear_all L120–125).
|
||||
private static async Task<IResult> ClearAsync(HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
PipelineProcessingService processing = context.RequestServices.GetRequiredService<PipelineProcessingService>();
|
||||
int cleared = await processing.ClearAsync(ct);
|
||||
return Results.Ok(new { ok = true, cleared });
|
||||
}
|
||||
|
||||
// DELETE /api/pipeline/rejected/{rejId}: удалить запись отсева; ответ {ok:true} всегда (delete_one L196–198, Ruling 10).
|
||||
// Прототип не проверяет наличие записи — 404 не шлём (план Task 9 L444; Ruling 10 «always ok»).
|
||||
private static async Task<IResult> DeleteAsync(
|
||||
string rejId,
|
||||
HttpContext context,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
PipelineProcessingService processing = context.RequestServices.GetRequiredService<PipelineProcessingService>();
|
||||
await processing.DeleteAsync(rejId, ct);
|
||||
return Results.Ok(new { ok = true });
|
||||
}
|
||||
|
||||
// POST /api/pipeline/rejected/{rejId}/return {reason=""}: вернуть отсеянное в обработку (return_to_queue L128–193).
|
||||
// Успех — {id, returned:true, returnedAt} (запись помечается returned, НЕ удаляется — аудит Ruling 10);
|
||||
// причины 400 (уже возвращено/повтор-dup/нет текста) — константы PipelineProcessingService (строки 1:1 с
|
||||
// прототипом); записи нет — 404 «Запись не найдена» (текст 404 — слой эндпоинтов).
|
||||
private static async Task<IResult> ReturnAsync(
|
||||
string rejId,
|
||||
ReturnReasonRequest body,
|
||||
HttpContext context,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
PipelineProcessingService processing = context.RequestServices.GetRequiredService<PipelineProcessingService>();
|
||||
RejectReturnResultDto? result = await processing.ReturnAsync(rejId, body.Reason ?? string.Empty, ct);
|
||||
if (result is null)
|
||||
{
|
||||
return EndpointResults.NotFound(RejectedNotFoundDetail);
|
||||
}
|
||||
|
||||
return result.Error is not null
|
||||
? EndpointResults.BadRequest(result.Error)
|
||||
: Results.Ok(new { id = result.Id, returned = result.Returned, returnedAt = result.ReturnedAtMs });
|
||||
}
|
||||
}
|
||||
using Deal.Api.Endpoints.RequestModels;
|
||||
using Deal.Api.Extensions;
|
||||
using Deal.Api.Services;
|
||||
using Deal.Modules.Pipeline.Application.Models;
|
||||
using Deal.Modules.Pipeline.Application.Services;
|
||||
|
||||
namespace Deal.Api.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Эндпоинты вкладки «Обработка»: GET /api/pipeline/stats|queue|rejected, POST /api/pipeline/rejected/clear,
|
||||
/// DELETE /api/pipeline/rejected/{rejId}, POST /api/pipeline/rejected/{rejId}/return (план Task 9 L437–460,
|
||||
/// Rulings 6/10; прототип processing_routes.py L17–74).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Контракт 1:1 с прототипом и api-map §3.6 L178–186, §4.5: /stats → {queue:{new,ai,total}, rejected};
|
||||
/// /queue?limit= → {items, counts:{new,ai,total}, rejected} (limit ≤500, дефолт 100, фронт шлёт 120);
|
||||
/// /rejected?q=&offset=&limit= → {items, total, offset, limit} (q — FTS ∪ LIKE-поиск, Ruling 6);
|
||||
/// /rejected/clear → {ok, cleared}; DELETE /rejected/{rejId} → {ok:true} всегда (delete_one L196–198, 404 не
|
||||
/// шлём — Ruling 10); /rejected/{rejId}/return {reason=""} → {id, returned:true, returnedAt} | 400 (строки
|
||||
/// Ruling 10) | 404 «Запись не найдена» (текст 404 — слой эндпоинтов, паттерн CardsService → LeadsEndpoints).
|
||||
/// Все эндпоинты требуют сессию: 401 {detail} без куки (Ruling 10); сервисы модуля резолвятся из
|
||||
/// RequestServices ПОСЛЕ проверки сессии (scoped на tenant-контекст запроса, паттерн SettingsEndpoints).
|
||||
/// Статические сегменты (/stats, /queue, /rejected/clear) до параметризованного /rejected/{rejId} — порядок
|
||||
/// как в прототипе (api-map L19), хотя литералы имеют приоритет в ASP.NET Core.
|
||||
/// </remarks>
|
||||
public static class PipelineEndpoints
|
||||
{
|
||||
// Префикс группы (роутер processing, prefix="/api/pipeline" — processing_routes.py L10).
|
||||
private const string PipelineGroupPrefix = "/api/pipeline";
|
||||
|
||||
// OpenAPI-тег группы (в прототипе роутер processing — processing_routes.py L10).
|
||||
private const string OpenApiTag = "processing";
|
||||
|
||||
// Путь сводки вкладки «Обработка» (GET).
|
||||
private const string StatsPath = "/stats";
|
||||
|
||||
// Путь сырых сообщений очереди (GET).
|
||||
private const string QueuePath = "/queue";
|
||||
|
||||
// Путь страницы отсева (GET).
|
||||
private const string RejectedPath = "/rejected";
|
||||
|
||||
// Путь полной очистки отсева (POST).
|
||||
private const string RejectedClearPath = "/rejected/clear";
|
||||
|
||||
// Путь удаления одной записи отсева (DELETE).
|
||||
private const string RejectedIdPath = "/rejected/{rejId}";
|
||||
|
||||
// Путь возврата записи отсева в обработку (POST).
|
||||
private const string RejectedReturnPath = "/rejected/{rejId}/return";
|
||||
|
||||
// 404 return: записи отсева нет (processing_routes.py L71: KeyError → 404, Ruling 10).
|
||||
private const string RejectedNotFoundDetail = "Запись не найдена";
|
||||
|
||||
// Размер страницы по умолчанию списков очереди/отсева (processing.DEFAULT_LIMIT L48; фронт шлёт 120/80).
|
||||
private const int DefaultPageSize = 100;
|
||||
|
||||
/// <summary>
|
||||
/// Регистрирует группу /api/pipeline: stats/queue/rejected/clear/{rejId}/return.
|
||||
/// </summary>
|
||||
/// <param name="app">Построитель маршрутов приложения.</param>
|
||||
/// <returns>Построитель маршрутов для цепочки вызовов.</returns>
|
||||
public static IEndpointRouteBuilder MapPipelineEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var pipeline = app.MapGroup(PipelineGroupPrefix).WithTags(OpenApiTag);
|
||||
|
||||
// Статические сегменты до /rejected/{rejId} (Ruling 10, api-map L19; порядок 1:1 с прототипом).
|
||||
pipeline.MapGet(StatsPath, StatsAsync);
|
||||
pipeline.MapGet(QueuePath, QueueAsync);
|
||||
pipeline.MapGet(RejectedPath, RejectedAsync);
|
||||
pipeline.MapPost(RejectedClearPath, ClearAsync);
|
||||
pipeline.MapDelete(RejectedIdPath, DeleteAsync);
|
||||
pipeline.MapPost(RejectedReturnPath, ReturnAsync);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
// GET /api/pipeline/stats: сводка вкладки {queue:{new,ai,total}, rejected} (processing_routes.py L17–20, stats L315–320).
|
||||
private static async Task<IResult> StatsAsync(HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
PipelineProcessingService processing = context.RequestServices.GetRequiredService<PipelineProcessingService>();
|
||||
return Results.Ok(await processing.StatsAsync(ct));
|
||||
}
|
||||
|
||||
// GET /api/pipeline/queue?limit=: сырые сообщения очереди + счётчики + число отсева (processing_routes.py L23–31).
|
||||
// Ответ {items, counts:{new,ai,total}, rejected} 1:1 с list_queue L218–241 + queue_counts L207–215 +
|
||||
// rejected_count L201–202. limit — дефолт 100, clamp 1..500 делает сервис (ListQueueAsync).
|
||||
private static async Task<IResult> QueueAsync(
|
||||
int? limit,
|
||||
HttpContext context,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
PipelineProcessingService processing = context.RequestServices.GetRequiredService<PipelineProcessingService>();
|
||||
IReadOnlyList<QueueItemDto> items = await processing.ListQueueAsync(limit ?? DefaultPageSize, ct);
|
||||
QueueCountsDto counts = await processing.QueueCountsAsync(ct);
|
||||
int rejected = await processing.RejectedCountAsync(ct);
|
||||
return Results.Ok(new { items, counts, rejected });
|
||||
}
|
||||
|
||||
// GET /api/pipeline/rejected?q=&offset=&limit=: страница отсева (processing_routes.py L34–42, list_rejected L246–312).
|
||||
// q — поиск по тексту/причине/фразе/имени канала (FTS ∪ LIKE, Ruling 6), пустой q — весь отсев свежими
|
||||
// первыми; offset ≥ 0, limit 1..500 (clamp в сервисе), значения эхом в ответе {items,total,offset,limit}.
|
||||
private static async Task<IResult> RejectedAsync(
|
||||
string? q,
|
||||
int? offset,
|
||||
int? limit,
|
||||
HttpContext context,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
PipelineProcessingService processing = context.RequestServices.GetRequiredService<PipelineProcessingService>();
|
||||
RejectedPageDto page = await processing.ListRejectedAsync(q ?? string.Empty, offset ?? 0, limit ?? DefaultPageSize, ct);
|
||||
return Results.Ok(page);
|
||||
}
|
||||
|
||||
// POST /api/pipeline/rejected/clear: полная безвозвратная очистка отсева (processing_routes.py L45–49, clear_all L120–125).
|
||||
private static async Task<IResult> ClearAsync(HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
PipelineProcessingService processing = context.RequestServices.GetRequiredService<PipelineProcessingService>();
|
||||
int cleared = await processing.ClearAsync(ct);
|
||||
return Results.Ok(new { ok = true, cleared });
|
||||
}
|
||||
|
||||
// DELETE /api/pipeline/rejected/{rejId}: удалить запись отсева; ответ {ok:true} всегда (delete_one L196–198, Ruling 10).
|
||||
// Прототип не проверяет наличие записи — 404 не шлём (план Task 9 L444; Ruling 10 «always ok»).
|
||||
private static async Task<IResult> DeleteAsync(
|
||||
string rejId,
|
||||
HttpContext context,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
PipelineProcessingService processing = context.RequestServices.GetRequiredService<PipelineProcessingService>();
|
||||
await processing.DeleteAsync(rejId, ct);
|
||||
return Results.Ok(new { ok = true });
|
||||
}
|
||||
|
||||
// POST /api/pipeline/rejected/{rejId}/return {reason=""}: вернуть отсеянное в обработку (return_to_queue L128–193).
|
||||
// Успех — {id, returned:true, returnedAt} (запись помечается returned, НЕ удаляется — аудит Ruling 10);
|
||||
// причины 400 (уже возвращено/повтор-dup/нет текста) — константы PipelineProcessingService (строки 1:1 с
|
||||
// прототипом); записи нет — 404 «Запись не найдена» (текст 404 — слой эндпоинтов).
|
||||
private static async Task<IResult> ReturnAsync(
|
||||
string rejId,
|
||||
ReturnReasonRequest body,
|
||||
HttpContext context,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
PipelineProcessingService processing = context.RequestServices.GetRequiredService<PipelineProcessingService>();
|
||||
RejectReturnResultDto? result = await processing.ReturnAsync(rejId, body.Reason ?? string.Empty, ct);
|
||||
if (result is null)
|
||||
{
|
||||
return EndpointResults.NotFound(RejectedNotFoundDetail);
|
||||
}
|
||||
|
||||
return result.Error is not null
|
||||
? EndpointResults.BadRequest(result.Error)
|
||||
: Results.Ok(new { id = result.Id, returned = result.Returned, returnedAt = result.ReturnedAtMs });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,90 +1,86 @@
|
||||
using Deal.Api.Extensions;
|
||||
using Deal.Api.Models;
|
||||
using Deal.Api.Services;
|
||||
using Deal.Modules.Settings.Application.Abstractions;
|
||||
using Deal.Modules.Settings.Application.Models;
|
||||
using Deal.Modules.Settings.Application.Registrars;
|
||||
using Deal.Modules.Settings.Application.Services;
|
||||
using Deal.Api.Dtos;
|
||||
|
||||
namespace Deal.Api.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// HTTP-эндпоинты курсов валют: GET /api/rates, POST /api/rates/refresh (Ruling 8, api-map §3.4 L149–150).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// «Только для Settings-экрана» (Ruling 8): фронт читает курсы на boot (store.js L571–581) и обновляет
|
||||
/// по кнопке (refreshRates L1843–1848). GET — текущий кэш (ratesCache) или дефолт-мок; при протухании/
|
||||
/// смене источника/отсутствии кэша (Ruling 6) фоново запускает RefreshAsync через
|
||||
/// <see cref="RatesRefreshScheduler"/> и отвечает текущим кэшем (план Task 8 L317–318). POST — синхронный
|
||||
/// refresh 1:1 с прототипом: <c>{ok: bool, rates: {base, rates, source, updatedAt}}</c> (ok=false при сбое
|
||||
/// ЦБ, кэш не тронут). Оба требуют сессию: 401 {detail} (Ruling 10). Резолв scoped-зависимостей — через
|
||||
/// RequestServices ПОСЛЕ проверки сессии (как SettingsEndpoints/AiCheckEndpoint: ISettingsStore требует
|
||||
/// tenant-контекст запроса).
|
||||
/// </remarks>
|
||||
public static class RatesEndpoints
|
||||
{
|
||||
// Префикс группы API (общий для эндпоинтов этапа, Ruling 8).
|
||||
private const string ApiGroupPrefix = "/api";
|
||||
|
||||
// Путь текущих курсов (GET).
|
||||
private const string RatesPath = "/rates";
|
||||
|
||||
// Путь принудительного обновления (POST).
|
||||
private const string RatesRefreshPath = "/rates/refresh";
|
||||
|
||||
// OpenAPI-тег группы (в прототипе роутер settings — settings_routes.py).
|
||||
private const string OpenApiTag = "settings";
|
||||
|
||||
/// <summary>
|
||||
/// Регистрирует GET /api/rates и POST /api/rates/refresh.
|
||||
/// </summary>
|
||||
/// <param name="app">Построитель маршрутов приложения.</param>
|
||||
/// <returns>Построитель маршрутов для цепочки вызовов.</returns>
|
||||
public static IEndpointRouteBuilder MapRatesEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup(ApiGroupPrefix).WithTags(OpenApiTag);
|
||||
group.MapGet(RatesPath, GetRatesAsync);
|
||||
group.MapPost(RatesRefreshPath, RefreshRatesAsync);
|
||||
return app;
|
||||
}
|
||||
|
||||
// GET /api/rates: текущий кэш курсов тенанта (+ ленивый фоновый refresh при необходимости).
|
||||
private static async Task<IResult> GetRatesAsync(HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
// Резолв после 401-гейта: RatesService — scoped на TenantDbContext (tenant-контекст запроса).
|
||||
RatesService ratesService = context.RequestServices.GetRequiredService<RatesService>();
|
||||
|
||||
RatesDto current = await ratesService.GetAsync(ct);
|
||||
|
||||
// Ленивое обновление (Ruling 6, план Task 8): протухший кэш / смена источника / нет кэша —
|
||||
// фоновый RefreshAsync в отдельном scope; ответ — текущий кэш.
|
||||
if (await ratesService.ShouldFetchAsync(ct))
|
||||
{
|
||||
context.RequestServices.GetRequiredService<RatesRefreshScheduler>().Schedule();
|
||||
}
|
||||
|
||||
return Results.Ok(current);
|
||||
}
|
||||
|
||||
// POST /api/rates/refresh: принудительное обновление; ответ {ok, rates} (1:1 settings_routes.py L229–232).
|
||||
private static async Task<IResult> RefreshRatesAsync(HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
RatesService ratesService = context.RequestServices.GetRequiredService<RatesService>();
|
||||
|
||||
// Синхронно: refresh по текущей настройке rateSource; при сбое ЦБ ok=false и кэш не тронут.
|
||||
bool ok = await ratesService.RefreshAsync(ct);
|
||||
RatesDto current = await ratesService.GetAsync(ct);
|
||||
return Results.Ok(new { ok, rates = current });
|
||||
}
|
||||
}
|
||||
using Deal.Api.Extensions;
|
||||
using Deal.Api.Services;
|
||||
using Deal.Modules.Settings.Application.Models;
|
||||
using Deal.Modules.Settings.Application.Services;
|
||||
|
||||
namespace Deal.Api.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// HTTP-эндпоинты курсов валют: GET /api/rates, POST /api/rates/refresh (Ruling 8, api-map §3.4 L149–150).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// «Только для Settings-экрана» (Ruling 8): фронт читает курсы на boot (store.js L571–581) и обновляет
|
||||
/// по кнопке (refreshRates L1843–1848). GET — текущий кэш (ratesCache) или дефолт-мок; при протухании/
|
||||
/// смене источника/отсутствии кэша (Ruling 6) фоново запускает RefreshAsync через
|
||||
/// <see cref="RatesRefreshScheduler"/> и отвечает текущим кэшем (план Task 8 L317–318). POST — синхронный
|
||||
/// refresh 1:1 с прототипом: <c>{ok: bool, rates: {base, rates, source, updatedAt}}</c> (ok=false при сбое
|
||||
/// ЦБ, кэш не тронут). Оба требуют сессию: 401 {detail} (Ruling 10). Резолв scoped-зависимостей — через
|
||||
/// RequestServices ПОСЛЕ проверки сессии (как SettingsEndpoints/AiCheckEndpoint: ISettingsStore требует
|
||||
/// tenant-контекст запроса).
|
||||
/// </remarks>
|
||||
public static class RatesEndpoints
|
||||
{
|
||||
// Префикс группы API (общий для эндпоинтов этапа, Ruling 8).
|
||||
private const string ApiGroupPrefix = "/api";
|
||||
|
||||
// Путь текущих курсов (GET).
|
||||
private const string RatesPath = "/rates";
|
||||
|
||||
// Путь принудительного обновления (POST).
|
||||
private const string RatesRefreshPath = "/rates/refresh";
|
||||
|
||||
// OpenAPI-тег группы (в прототипе роутер settings — settings_routes.py).
|
||||
private const string OpenApiTag = "settings";
|
||||
|
||||
/// <summary>
|
||||
/// Регистрирует GET /api/rates и POST /api/rates/refresh.
|
||||
/// </summary>
|
||||
/// <param name="app">Построитель маршрутов приложения.</param>
|
||||
/// <returns>Построитель маршрутов для цепочки вызовов.</returns>
|
||||
public static IEndpointRouteBuilder MapRatesEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup(ApiGroupPrefix).WithTags(OpenApiTag);
|
||||
group.MapGet(RatesPath, GetRatesAsync);
|
||||
group.MapPost(RatesRefreshPath, RefreshRatesAsync);
|
||||
return app;
|
||||
}
|
||||
|
||||
// GET /api/rates: текущий кэш курсов тенанта (+ ленивый фоновый refresh при необходимости).
|
||||
private static async Task<IResult> GetRatesAsync(HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
// Резолв после 401-гейта: RatesService — scoped на TenantDbContext (tenant-контекст запроса).
|
||||
RatesService ratesService = context.RequestServices.GetRequiredService<RatesService>();
|
||||
|
||||
RatesDto current = await ratesService.GetAsync(ct);
|
||||
|
||||
// Ленивое обновление (Ruling 6, план Task 8): протухший кэш / смена источника / нет кэша —
|
||||
// фоновый RefreshAsync в отдельном scope; ответ — текущий кэш.
|
||||
if (await ratesService.ShouldFetchAsync(ct))
|
||||
{
|
||||
context.RequestServices.GetRequiredService<RatesRefreshScheduler>().Schedule();
|
||||
}
|
||||
|
||||
return Results.Ok(current);
|
||||
}
|
||||
|
||||
// POST /api/rates/refresh: принудительное обновление; ответ {ok, rates} (1:1 settings_routes.py L229–232).
|
||||
private static async Task<IResult> RefreshRatesAsync(HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
RatesService ratesService = context.RequestServices.GetRequiredService<RatesService>();
|
||||
|
||||
// Синхронно: refresh по текущей настройке rateSource; при сбое ЦБ ok=false и кэш не тронут.
|
||||
bool ok = await ratesService.RefreshAsync(ct);
|
||||
RatesDto current = await ratesService.GetAsync(ct);
|
||||
return Results.Ok(new { ok, rates = current });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
using Deal.Api.Services;
|
||||
using Deal.Api.Dtos;
|
||||
|
||||
namespace Deal.Api.Endpoints.RequestModels;
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
using Deal.Api.Services;
|
||||
using Deal.Api.Dtos;
|
||||
|
||||
namespace Deal.Api.Endpoints.RequestModels;
|
||||
|
||||
/// <summary>
|
||||
/// Тело POST /api/cards/clear-col — полная очистка служебной колонки (dashboard_routes.py ClearColBody L224–225, api-map §3.2 L93).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Wire-имя — camelCase: col — "trash" | "archive" (другие колонки/отсутствие значения → 400
|
||||
/// «Очищать можно только корзину или архив», валидация CardsService.ClearColAsync L237–247).
|
||||
/// </remarks>
|
||||
public sealed record ClearColBody(string? Col);
|
||||
namespace Deal.Api.Endpoints.RequestModels;
|
||||
|
||||
/// <summary>
|
||||
/// Тело POST /api/cards/clear-col — полная очистка служебной колонки (dashboard_routes.py ClearColBody L224–225, api-map §3.2 L93).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Wire-имя — camelCase: col — "trash" | "archive" (другие колонки/отсутствие значения → 400
|
||||
/// «Очищать можно только корзину или архив», валидация CardsService.ClearColAsync L237–247).
|
||||
/// </remarks>
|
||||
public sealed record ClearColBody(string? Col);
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
using Deal.Api.Services;
|
||||
using Deal.Api.Dtos;
|
||||
|
||||
namespace Deal.Api.Endpoints.RequestModels;
|
||||
|
||||
/// <summary>
|
||||
/// Тело PATCH /api/containers/{containerId}/state — смена состояния колонки (этап 9, T4).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Wire-имена — camelCase: collapsed (bool) / width ("sm"|"md"|"lg"). Поле со значением null (либо
|
||||
/// отсутствующее) текущее значение не меняет (прототип model_dump(exclude_none=True) + merge в текущее
|
||||
/// состояние колонки, L146–149). Пустой патч {} сохраняет текущее состояние (для новой колонки — {}).
|
||||
/// </remarks>
|
||||
public sealed record ColStateBody(bool? Collapsed, string? Width);
|
||||
namespace Deal.Api.Endpoints.RequestModels;
|
||||
|
||||
/// <summary>
|
||||
/// Тело PATCH /api/containers/{containerId}/state — смена состояния колонки (этап 9, T4).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Wire-имена — camelCase: collapsed (bool) / width ("sm"|"md"|"lg"). Поле со значением null (либо
|
||||
/// отсутствующее) текущее значение не меняет (прототип model_dump(exclude_none=True) + merge в текущее
|
||||
/// состояние колонки, L146–149). Пустой патч {} сохраняет текущее состояние (для новой колонки — {}).
|
||||
/// </remarks>
|
||||
public sealed record ColStateBody(bool? Collapsed, string? Width);
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
using Deal.Api.Services;
|
||||
using Deal.Api.Dtos;
|
||||
|
||||
namespace Deal.Api.Endpoints.RequestModels;
|
||||
|
||||
/// <summary>
|
||||
/// Тело POST /api/cards/{cardId}/comments — добавление комментария (dashboard_routes.py CommentBody L60–61).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Wire-имя — camelCase: text. Пустой/пробельный текст либо явный null → 400 «Пустой комментарий»
|
||||
/// (валидация CardsService.AddCommentAsync, 1:1 с dashboard_routes L240–241).
|
||||
/// </remarks>
|
||||
public sealed record CommentBody(string? Text);
|
||||
namespace Deal.Api.Endpoints.RequestModels;
|
||||
|
||||
/// <summary>
|
||||
/// Тело POST /api/cards/{cardId}/comments — добавление комментария (dashboard_routes.py CommentBody L60–61).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Wire-имя — camelCase: text. Пустой/пробельный текст либо явный null → 400 «Пустой комментарий»
|
||||
/// (валидация CardsService.AddCommentAsync, 1:1 с dashboard_routes L240–241).
|
||||
/// </remarks>
|
||||
public sealed record CommentBody(string? Text);
|
||||
|
||||
@@ -1,32 +1,30 @@
|
||||
using Deal.Modules.Kanban.Application.Models;
|
||||
using Deal.Api.Services;
|
||||
using Deal.Api.Dtos;
|
||||
|
||||
namespace Deal.Api.Endpoints.RequestModels;
|
||||
|
||||
/// <summary>
|
||||
/// Тело POST /api/containers — создание контейнера (этап 9, T4).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Wire-имена — camelCase: name/description/color/space/kind/suggested/note/rules. Name — обязательное:
|
||||
/// отсутствие либо явный null → 400 «Укажите название колонки»; пустая строка допустима — сервис
|
||||
/// подставит «Новая колонка». Description/Note имеют дефолт "". Отсутствующие группы правил
|
||||
/// трактуются как пустые (null-устойчивость).
|
||||
/// </remarks>
|
||||
/// <param name="Name">Имя контейнера (обязательно).</param>
|
||||
/// <param name="Description">Описание (опционально).</param>
|
||||
/// <param name="Color">Цвет (опционально; null — палитра).</param>
|
||||
/// <param name="Space">Пространство (dashboard/selected; по умолчанию dashboard).</param>
|
||||
/// <param name="Kind">Вид (board/stage/service/terminal; по умолчанию board).</param>
|
||||
/// <param name="Suggested">Признак ИИ-предложения.</param>
|
||||
/// <param name="Rules">Правила попадания.</param>
|
||||
/// <param name="Note">Заметка/обоснование.</param>
|
||||
public sealed record ContainerCreateRequest(
|
||||
string? Name,
|
||||
string? Description,
|
||||
string? Color,
|
||||
string? Space,
|
||||
string? Kind,
|
||||
bool? Suggested,
|
||||
ContainerRulesDto? Rules,
|
||||
string? Note);
|
||||
using Deal.Modules.Kanban.Application.Models;
|
||||
|
||||
namespace Deal.Api.Endpoints.RequestModels;
|
||||
|
||||
/// <summary>
|
||||
/// Тело POST /api/containers — создание контейнера (этап 9, T4).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Wire-имена — camelCase: name/description/color/space/kind/suggested/note/rules. Name — обязательное:
|
||||
/// отсутствие либо явный null → 400 «Укажите название колонки»; пустая строка допустима — сервис
|
||||
/// подставит «Новая колонка». Description/Note имеют дефолт "". Отсутствующие группы правил
|
||||
/// трактуются как пустые (null-устойчивость).
|
||||
/// </remarks>
|
||||
/// <param name="Name">Имя контейнера (обязательно).</param>
|
||||
/// <param name="Description">Описание (опционально).</param>
|
||||
/// <param name="Color">Цвет (опционально; null — палитра).</param>
|
||||
/// <param name="Space">Пространство (dashboard/selected; по умолчанию dashboard).</param>
|
||||
/// <param name="Kind">Вид (board/stage/service/terminal; по умолчанию board).</param>
|
||||
/// <param name="Suggested">Признак ИИ-предложения.</param>
|
||||
/// <param name="Rules">Правила попадания.</param>
|
||||
/// <param name="Note">Заметка/обоснование.</param>
|
||||
public sealed record ContainerCreateRequest(
|
||||
string? Name,
|
||||
string? Description,
|
||||
string? Color,
|
||||
string? Space,
|
||||
string? Kind,
|
||||
bool? Suggested,
|
||||
ContainerRulesDto? Rules,
|
||||
string? Note);
|
||||
|
||||
@@ -1,31 +1,29 @@
|
||||
using Deal.Modules.Kanban.Application.Models;
|
||||
using Deal.Api.Services;
|
||||
using Deal.Api.Dtos;
|
||||
|
||||
namespace Deal.Api.Endpoints.RequestModels;
|
||||
|
||||
/// <summary>
|
||||
/// Тело PATCH /api/containers/{id} — частичное обновление контейнера (этап 9, T4).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Wire-имена — camelCase: name/description/color/collapsed/suggested/note/rules/policy. Поле со
|
||||
/// значением null означает «не менять»; исключение — ЯВНЫЙ null у name → 400 «Укажите название колонки».
|
||||
/// JSON-объекты rules/policy заменяются целиком.
|
||||
/// </remarks>
|
||||
/// <param name="Name">Новое имя (null — не менять).</param>
|
||||
/// <param name="Description">Новое описание (null — не менять).</param>
|
||||
/// <param name="Color">Новый цвет (null — не менять).</param>
|
||||
/// <param name="Collapsed">Новая свёрнутость (null — не менять).</param>
|
||||
/// <param name="Suggested">Признак ИИ-предложения (null — не менять).</param>
|
||||
/// <param name="Note">Новая заметка (null — не менять).</param>
|
||||
/// <param name="Rules">Новые правила (null — не менять).</param>
|
||||
/// <param name="Policy">Новая политика (null — не менять).</param>
|
||||
public sealed record ContainerPatchRequest(
|
||||
string? Name,
|
||||
string? Description,
|
||||
string? Color,
|
||||
bool? Collapsed,
|
||||
bool? Suggested,
|
||||
string? Note,
|
||||
ContainerRulesDto? Rules,
|
||||
ContainerPolicyDto? Policy);
|
||||
using Deal.Modules.Kanban.Application.Models;
|
||||
|
||||
namespace Deal.Api.Endpoints.RequestModels;
|
||||
|
||||
/// <summary>
|
||||
/// Тело PATCH /api/containers/{id} — частичное обновление контейнера (этап 9, T4).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Wire-имена — camelCase: name/description/color/collapsed/suggested/note/rules/policy. Поле со
|
||||
/// значением null означает «не менять»; исключение — ЯВНЫЙ null у name → 400 «Укажите название колонки».
|
||||
/// JSON-объекты rules/policy заменяются целиком.
|
||||
/// </remarks>
|
||||
/// <param name="Name">Новое имя (null — не менять).</param>
|
||||
/// <param name="Description">Новое описание (null — не менять).</param>
|
||||
/// <param name="Color">Новый цвет (null — не менять).</param>
|
||||
/// <param name="Collapsed">Новая свёрнутость (null — не менять).</param>
|
||||
/// <param name="Suggested">Признак ИИ-предложения (null — не менять).</param>
|
||||
/// <param name="Note">Новая заметка (null — не менять).</param>
|
||||
/// <param name="Rules">Новые правила (null — не менять).</param>
|
||||
/// <param name="Policy">Новая политика (null — не менять).</param>
|
||||
public sealed record ContainerPatchRequest(
|
||||
string? Name,
|
||||
string? Description,
|
||||
string? Color,
|
||||
bool? Collapsed,
|
||||
bool? Suggested,
|
||||
string? Note,
|
||||
ContainerRulesDto? Rules,
|
||||
ContainerPolicyDto? Policy);
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
using Deal.Modules.Kanban.Application.Models;
|
||||
using Deal.Api.Services;
|
||||
using Deal.Api.Dtos;
|
||||
|
||||
namespace Deal.Api.Endpoints.RequestModels;
|
||||
|
||||
|
||||
@@ -1,61 +1,58 @@
|
||||
using Deal.Api.Services;
|
||||
using Deal.Api.Dtos;
|
||||
|
||||
namespace Deal.Api.Endpoints.RequestModels;
|
||||
|
||||
/// <summary>
|
||||
/// Тело POST /api/discovery/tasks (TaskCreate discovery_routes.py L50–60; api-map §3.8 L204).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Wire-имена — camelCase: name/description/keywords/minSubscribers/lang/threshold/sampleSize/planJoins/autoJoin.
|
||||
/// Значения-дефолты pydantic повторяет сервис DiscoveryTasksService: description/keywords — пустые, lang — «ru»,
|
||||
/// minSubscribers — 0, planJoins — 1, autoJoin — false; threshold/sampleSize — из настроек (null → дефолт).
|
||||
/// name — единственное поле без дефолта: пустое/пробельное значение → 400 «Укажите название задачи».
|
||||
/// </remarks>
|
||||
public sealed record DiscoveryTaskCreateBody
|
||||
{
|
||||
/// <summary>
|
||||
/// Название задачи (обязательное; Trim, пустое → 400).
|
||||
/// </summary>
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Описание ниши/цели (источник для generate-keywords); null → пустая строка.
|
||||
/// </summary>
|
||||
public string? Description { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Ключевые слова поиска; null/пусто — список пуст (start до добавления ключей → 400).
|
||||
/// </summary>
|
||||
public IReadOnlyList<string>? Keywords { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Минимальное число участников источника; null → 0.
|
||||
/// </summary>
|
||||
public int? MinSubscribers { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Язык источников: «ru»|«any»; null/иное → «ru» (нормализует сервис).
|
||||
/// </summary>
|
||||
public string? Lang { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Порог подходящих сообщений оценки, % (кламп 1..100); null → discEvalThreshold.
|
||||
/// </summary>
|
||||
public int? Threshold { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Размер выборки сообщений оценки (кламп ≥1); null → discEvalSample.
|
||||
/// </summary>
|
||||
public int? SampleSize { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// План авто-вступлений (1..discJoinLimit + бюджет); null → 1.
|
||||
/// </summary>
|
||||
public int? PlanJoins { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Авто-вступления воркером; null → false.
|
||||
/// </summary>
|
||||
public bool? AutoJoin { get; init; }
|
||||
}
|
||||
namespace Deal.Api.Endpoints.RequestModels;
|
||||
|
||||
/// <summary>
|
||||
/// Тело POST /api/discovery/tasks (TaskCreate discovery_routes.py L50–60; api-map §3.8 L204).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Wire-имена — camelCase: name/description/keywords/minSubscribers/lang/threshold/sampleSize/planJoins/autoJoin.
|
||||
/// Значения-дефолты pydantic повторяет сервис DiscoveryTasksService: description/keywords — пустые, lang — «ru»,
|
||||
/// minSubscribers — 0, planJoins — 1, autoJoin — false; threshold/sampleSize — из настроек (null → дефолт).
|
||||
/// name — единственное поле без дефолта: пустое/пробельное значение → 400 «Укажите название задачи».
|
||||
/// </remarks>
|
||||
public sealed record DiscoveryTaskCreateBody
|
||||
{
|
||||
/// <summary>
|
||||
/// Название задачи (обязательное; Trim, пустое → 400).
|
||||
/// </summary>
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Описание ниши/цели (источник для generate-keywords); null → пустая строка.
|
||||
/// </summary>
|
||||
public string? Description { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Ключевые слова поиска; null/пусто — список пуст (start до добавления ключей → 400).
|
||||
/// </summary>
|
||||
public IReadOnlyList<string>? Keywords { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Минимальное число участников источника; null → 0.
|
||||
/// </summary>
|
||||
public int? MinSubscribers { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Язык источников: «ru»|«any»; null/иное → «ru» (нормализует сервис).
|
||||
/// </summary>
|
||||
public string? Lang { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Порог подходящих сообщений оценки, % (кламп 1..100); null → discEvalThreshold.
|
||||
/// </summary>
|
||||
public int? Threshold { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Размер выборки сообщений оценки (кламп ≥1); null → discEvalSample.
|
||||
/// </summary>
|
||||
public int? SampleSize { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// План авто-вступлений (1..discJoinLimit + бюджет); null → 1.
|
||||
/// </summary>
|
||||
public int? PlanJoins { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Авто-вступления воркером; null → false.
|
||||
/// </summary>
|
||||
public bool? AutoJoin { get; init; }
|
||||
}
|
||||
|
||||
@@ -1,60 +1,57 @@
|
||||
using Deal.Api.Services;
|
||||
using Deal.Api.Dtos;
|
||||
|
||||
namespace Deal.Api.Endpoints.RequestModels;
|
||||
|
||||
/// <summary>
|
||||
/// Тело PATCH /api/discovery/tasks/{task_id} (TaskPatch discovery_routes.py L62–71; api-map §3.8 L205).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Wire-имена — camelCase; все поля optional: null/отсутствующее поле не меняется (в DiscoveryTaskPatch
|
||||
/// пробрасываются только не-null значения, как python model_dump(exclude_none=True)). keywords — полная замена
|
||||
/// списка (пустой список очищает ключи); увеличение planJoins проверяется план-бюджетом.
|
||||
/// </remarks>
|
||||
public sealed record DiscoveryTaskPatchBody
|
||||
{
|
||||
/// <summary>
|
||||
/// Новое название (после Trim; пустое допустимо на patch — 1:1 прототип).
|
||||
/// </summary>
|
||||
public string? Name { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Новое описание (пустая строка очищает).
|
||||
/// </summary>
|
||||
public string? Description { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Новые ключевые слова (полная замена; null — не менять).
|
||||
/// </summary>
|
||||
public IReadOnlyList<string>? Keywords { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Новый минимум участников (кламп ≥0).
|
||||
/// </summary>
|
||||
public int? MinSubscribers { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Новый язык: «ru»|«any» (иное → «ru»).
|
||||
/// </summary>
|
||||
public string? Lang { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Новый порог оценки, % (кламп 1..100).
|
||||
/// </summary>
|
||||
public int? Threshold { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Новый размер выборки (кламп ≥1).
|
||||
/// </summary>
|
||||
public int? SampleSize { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Новый план авто-вступлений (рост — с проверкой бюджета).
|
||||
/// </summary>
|
||||
public int? PlanJoins { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Новый флаг авто-вступлений (false — выключить).
|
||||
/// </summary>
|
||||
public bool? AutoJoin { get; init; }
|
||||
}
|
||||
namespace Deal.Api.Endpoints.RequestModels;
|
||||
|
||||
/// <summary>
|
||||
/// Тело PATCH /api/discovery/tasks/{task_id} (TaskPatch discovery_routes.py L62–71; api-map §3.8 L205).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Wire-имена — camelCase; все поля optional: null/отсутствующее поле не меняется (в DiscoveryTaskPatch
|
||||
/// пробрасываются только не-null значения, как python model_dump(exclude_none=True)). keywords — полная замена
|
||||
/// списка (пустой список очищает ключи); увеличение planJoins проверяется план-бюджетом.
|
||||
/// </remarks>
|
||||
public sealed record DiscoveryTaskPatchBody
|
||||
{
|
||||
/// <summary>
|
||||
/// Новое название (после Trim; пустое допустимо на patch — 1:1 прототип).
|
||||
/// </summary>
|
||||
public string? Name { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Новое описание (пустая строка очищает).
|
||||
/// </summary>
|
||||
public string? Description { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Новые ключевые слова (полная замена; null — не менять).
|
||||
/// </summary>
|
||||
public IReadOnlyList<string>? Keywords { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Новый минимум участников (кламп ≥0).
|
||||
/// </summary>
|
||||
public int? MinSubscribers { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Новый язык: «ru»|«any» (иное → «ru»).
|
||||
/// </summary>
|
||||
public string? Lang { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Новый порог оценки, % (кламп 1..100).
|
||||
/// </summary>
|
||||
public int? Threshold { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Новый размер выборки (кламп ≥1).
|
||||
/// </summary>
|
||||
public int? SampleSize { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Новый план авто-вступлений (рост — с проверкой бюджета).
|
||||
/// </summary>
|
||||
public int? PlanJoins { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Новый флаг авто-вступлений (false — выключить).
|
||||
/// </summary>
|
||||
public bool? AutoJoin { get; init; }
|
||||
}
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
using Deal.Api.Services;
|
||||
using Deal.Api.Dtos;
|
||||
|
||||
namespace Deal.Api.Endpoints.RequestModels;
|
||||
|
||||
/// <summary>
|
||||
/// Тело POST /api/cards/mark-col-seen — снять «новое» с колонки (dashboard_routes.py MarkColBody L183–184, api-map §3.2 L88).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Wire-имя — camelCase: col — колонка (inbox/archive/trash/доска). Отсутствие/null col → 400
|
||||
/// «Неизвестная колонка»: иначе пустой col попал бы в CardsService.MarkSeenAsync и снял «новое» со ВСЕХ
|
||||
/// карточек (truthiness-семантика прототипа: пустая строка = параметр не задан, mark_seen L250–256) —
|
||||
/// эндпоинт защищает от такого вызова (прототип: pydantic required 422).
|
||||
/// </remarks>
|
||||
public sealed record MarkColBody(string? Col);
|
||||
namespace Deal.Api.Endpoints.RequestModels;
|
||||
|
||||
/// <summary>
|
||||
/// Тело POST /api/cards/mark-col-seen — снять «новое» с колонки (dashboard_routes.py MarkColBody L183–184, api-map §3.2 L88).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Wire-имя — camelCase: col — колонка (inbox/archive/trash/доска). Отсутствие/null col → 400
|
||||
/// «Неизвестная колонка»: иначе пустой col попал бы в CardsService.MarkSeenAsync и снял «новое» со ВСЕХ
|
||||
/// карточек (truthiness-семантика прототипа: пустая строка = параметр не задан, mark_seen L250–256) —
|
||||
/// эндпоинт защищает от такого вызова (прототип: pydantic required 422).
|
||||
/// </remarks>
|
||||
public sealed record MarkColBody(string? Col);
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
using Deal.Api.Services;
|
||||
using Deal.Api.Dtos;
|
||||
|
||||
namespace Deal.Api.Endpoints.RequestModels;
|
||||
|
||||
/// <summary>
|
||||
/// Тело POST /api/cards/{lead_id}/move — перенос карточки (dashboard_routes.py MoveBody L56–57, api-map §3.2 L89).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Wire-имя — camelCase: to — колонка назначения: "inbox" либо id доски (<c>b_...</c>). Цель валидирует
|
||||
/// CardsService (400 «Переносить можно только на доски или в «Неразобранное»»); отсутствующий/null to
|
||||
/// трактуются той же валидацией (прототип — pydantic required 422).
|
||||
/// </remarks>
|
||||
public sealed record MoveBody(string? To);
|
||||
namespace Deal.Api.Endpoints.RequestModels;
|
||||
|
||||
/// <summary>
|
||||
/// Тело POST /api/cards/{lead_id}/move — перенос карточки (dashboard_routes.py MoveBody L56–57, api-map §3.2 L89).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Wire-имя — camelCase: to — колонка назначения: "inbox" либо id доски (<c>b_...</c>). Цель валидирует
|
||||
/// CardsService (400 «Переносить можно только на доски или в «Неразобранное»»); отсутствующий/null to
|
||||
/// трактуются той же валидацией (прототип — pydantic required 422).
|
||||
/// </remarks>
|
||||
public sealed record MoveBody(string? To);
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
using Deal.Api.Services;
|
||||
using Deal.Api.Dtos;
|
||||
|
||||
namespace Deal.Api.Endpoints.RequestModels;
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
using Deal.Api.Services;
|
||||
using Deal.Api.Dtos;
|
||||
|
||||
namespace Deal.Api.Endpoints.RequestModels;
|
||||
|
||||
/// <summary>
|
||||
/// Тело POST /api/containers/reorder — новый порядок контейнеров пространства (этап 9, T4).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Wire-имена — camelCase: space (пространство dashboard/selected; отсутствие — dashboard) и order
|
||||
/// (список id контейнеров в новом порядке). Отсутствие/явный null у order — 400 «Не указан порядок колонок».
|
||||
/// </remarks>
|
||||
/// <param name="Space">Пространство (dashboard/selected); null — dashboard.</param>
|
||||
/// <param name="Order">Id контейнеров в новом порядке.</param>
|
||||
public sealed record OrderBody(string? Space, IReadOnlyList<string>? Order);
|
||||
namespace Deal.Api.Endpoints.RequestModels;
|
||||
|
||||
/// <summary>
|
||||
/// Тело POST /api/containers/reorder — новый порядок контейнеров пространства (этап 9, T4).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Wire-имена — camelCase: space (пространство dashboard/selected; отсутствие — dashboard) и order
|
||||
/// (список id контейнеров в новом порядке). Отсутствие/явный null у order — 400 «Не указан порядок колонок».
|
||||
/// </remarks>
|
||||
/// <param name="Space">Пространство (dashboard/selected); null — dashboard.</param>
|
||||
/// <param name="Order">Id контейнеров в новом порядке.</param>
|
||||
public sealed record OrderBody(string? Space, IReadOnlyList<string>? Order);
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
using Deal.Api.Services;
|
||||
using Deal.Api.Dtos;
|
||||
|
||||
namespace Deal.Api.Endpoints.RequestModels;
|
||||
|
||||
/// <summary>
|
||||
/// Тело POST /api/cards/reclassify — ИИ-переклассификация «Неразобранного» (dashboard_routes.py ReclassifyBody L64–65, api-map §3.2 L95).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Wire-имя — camelCase: ids (опциональный список id карточек). Тело опционально (фронт вызывает без
|
||||
/// тела — reclassifyInbox, store.js L1115–1133; параметр эндпоинта nullable). В этапе 3 — заглушка
|
||||
/// Ruling 11: тело не используется, ответ всегда {started:false, busy:false, attempted:0, reason}.
|
||||
/// </remarks>
|
||||
public sealed record ReclassifyBody(IReadOnlyList<string>? Ids);
|
||||
namespace Deal.Api.Endpoints.RequestModels;
|
||||
|
||||
/// <summary>
|
||||
/// Тело POST /api/cards/reclassify — ИИ-переклассификация «Неразобранного» (dashboard_routes.py ReclassifyBody L64–65, api-map §3.2 L95).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Wire-имя — camelCase: ids (опциональный список id карточек). Тело опционально (фронт вызывает без
|
||||
/// тела — reclassifyInbox, store.js L1115–1133; параметр эндпоинта nullable). В этапе 3 — заглушка
|
||||
/// Ruling 11: тело не используется, ответ всегда {started:false, busy:false, attempted:0, reason}.
|
||||
/// </remarks>
|
||||
public sealed record ReclassifyBody(IReadOnlyList<string>? Ids);
|
||||
|
||||
@@ -1,20 +1,17 @@
|
||||
using Deal.Api.Services;
|
||||
using Deal.Api.Dtos;
|
||||
|
||||
namespace Deal.Api.Endpoints.RequestModels;
|
||||
|
||||
/// <summary>
|
||||
/// Тело POST /api/cards/{cardId}/reminder — установка напоминания hold-карточке
|
||||
/// (projects_routes.py ReminderBody L52–53, api-map §3.5 L172; Ruling 3).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Wire-имя — camelCase: at — время напоминания в epoch-мс (рассчитывает фронт HoldReminderDialog:
|
||||
/// «через N дней (1–30)» или «дата+время» локального времени; store.js setHoldReminder L2060–2086).
|
||||
/// Стадия карточки/будущность at сервисом НЕ проверяются (1:1 прототип: фронт шлёт только для hold;
|
||||
/// прошлое at допустимо — приёмка Tasks 11/13 «выстреливает» его ручным тиком). Ответ — полная карточка
|
||||
/// с напоминанием {at}; 400 «Напоминания об отложенных выключены в настройках» при выключенном
|
||||
/// remindersEnabled; 404 «Карточка не найдена». Отсутствующий/JSON-null at (клиентский баг; pydantic на
|
||||
/// такое — 422) эндпоинт отвергает 400 — у напоминания без времени нет осмысленной семантики.
|
||||
/// </remarks>
|
||||
/// <param name="At">Время напоминания, epoch-ms.</param>
|
||||
public sealed record ReminderSetRequest(long? At);
|
||||
namespace Deal.Api.Endpoints.RequestModels;
|
||||
|
||||
/// <summary>
|
||||
/// Тело POST /api/cards/{cardId}/reminder — установка напоминания hold-карточке
|
||||
/// (projects_routes.py ReminderBody L52–53, api-map §3.5 L172; Ruling 3).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Wire-имя — camelCase: at — время напоминания в epoch-мс (рассчитывает фронт HoldReminderDialog:
|
||||
/// «через N дней (1–30)» или «дата+время» локального времени; store.js setHoldReminder L2060–2086).
|
||||
/// Стадия карточки/будущность at сервисом НЕ проверяются (1:1 прототип: фронт шлёт только для hold;
|
||||
/// прошлое at допустимо — приёмка Tasks 11/13 «выстреливает» его ручным тиком). Ответ — полная карточка
|
||||
/// с напоминанием {at}; 400 «Напоминания об отложенных выключены в настройках» при выключенном
|
||||
/// remindersEnabled; 404 «Карточка не найдена». Отсутствующий/JSON-null at (клиентский баг; pydantic на
|
||||
/// такое — 422) эндпоинт отвергает 400 — у напоминания без времени нет осмысленной семантики.
|
||||
/// </remarks>
|
||||
/// <param name="At">Время напоминания, epoch-ms.</param>
|
||||
public sealed record ReminderSetRequest(long? At);
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
using Deal.Api.Services;
|
||||
using Deal.Api.Dtos;
|
||||
|
||||
namespace Deal.Api.Endpoints.RequestModels;
|
||||
|
||||
/// <summary>
|
||||
/// Тело POST /api/pipeline/rejected/{rejId}/return — причина возврата в обработку (processing_routes.py ReturnBody L13–15, api-map §3.6 L185).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Wire-имя — camelCase: reason (опциональна, дефолт "" — как pydantic reason: str = ""; фронт шлёт
|
||||
/// {reason} всегда). Пустая причина допустима: сервис тримит и кладёт на запись для аудита
|
||||
/// (return_to_queue L158, Ruling 10).
|
||||
/// </remarks>
|
||||
public sealed record ReturnReasonRequest(string? Reason);
|
||||
namespace Deal.Api.Endpoints.RequestModels;
|
||||
|
||||
/// <summary>
|
||||
/// Тело POST /api/pipeline/rejected/{rejId}/return — причина возврата в обработку (processing_routes.py ReturnBody L13–15, api-map §3.6 L185).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Wire-имя — camelCase: reason (опциональна, дефолт "" — как pydantic reason: str = ""; фронт шлёт
|
||||
/// {reason} всегда). Пустая причина допустима: сервис тримит и кладёт на запись для аудита
|
||||
/// (return_to_queue L158, Ruling 10).
|
||||
/// </remarks>
|
||||
public sealed record ReturnReasonRequest(string? Reason);
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
using Deal.Api.Services;
|
||||
using Deal.Api.Dtos;
|
||||
|
||||
namespace Deal.Api.Endpoints.RequestModels;
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
using Deal.Api.Services;
|
||||
using Deal.Api.Dtos;
|
||||
|
||||
namespace Deal.Api.Endpoints.RequestModels;
|
||||
|
||||
/// <summary>
|
||||
/// Тело POST /api/tg/dialogs/{dialog_id}/monitor и /monitor-all (tg_routes.py MonitorBody L54–56).
|
||||
/// </summary>
|
||||
/// <param name="Enabled">True — мониторить (сообщения → PushMessage в ядро), false — выключить.</param>
|
||||
public sealed record TgMonitorBody(bool Enabled);
|
||||
namespace Deal.Api.Endpoints.RequestModels;
|
||||
|
||||
/// <summary>
|
||||
/// Тело POST /api/tg/dialogs/{dialog_id}/monitor и /monitor-all (tg_routes.py MonitorBody L54–56).
|
||||
/// </summary>
|
||||
/// <param name="Enabled">True — мониторить (сообщения → PushMessage в ядро), false — выключить.</param>
|
||||
public sealed record TgMonitorBody(bool Enabled);
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
using Deal.Api.Services;
|
||||
using Deal.Api.Dtos;
|
||||
|
||||
namespace Deal.Api.Endpoints.RequestModels;
|
||||
|
||||
/// <summary>
|
||||
/// Тело POST /api/tg/dialogs/preview — последние сообщения диалога (tg_routes.py PreviewBody L58–60).
|
||||
/// </summary>
|
||||
/// <param name="DialogId">Id диалога (подписанный).</param>
|
||||
/// <param name="Limit">Сколько последних сообщений; дефолт 24, кламп 1..50 (python L153).</param>
|
||||
public sealed record TgPreviewBody(string DialogId, int? Limit);
|
||||
namespace Deal.Api.Endpoints.RequestModels;
|
||||
|
||||
/// <summary>
|
||||
/// Тело POST /api/tg/dialogs/preview — последние сообщения диалога (tg_routes.py PreviewBody L58–60).
|
||||
/// </summary>
|
||||
/// <param name="DialogId">Id диалога (подписанный).</param>
|
||||
/// <param name="Limit">Сколько последних сообщений; дефолт 24, кламп 1..50 (python L153).</param>
|
||||
public sealed record TgPreviewBody(string DialogId, int? Limit);
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
using Deal.Api.Services;
|
||||
using Deal.Api.Dtos;
|
||||
|
||||
namespace Deal.Api.Endpoints.RequestModels;
|
||||
|
||||
/// <summary>
|
||||
/// Тело POST /api/tg/send-code — SMS-код входа (tg_routes.py CodeBody L46–48).
|
||||
/// </summary>
|
||||
/// <param name="Code">Код из SMS/Telegram-сообщения (trim перед отправкой, python L89).</param>
|
||||
public sealed record TgSendCodeRequest(string Code);
|
||||
namespace Deal.Api.Endpoints.RequestModels;
|
||||
|
||||
/// <summary>
|
||||
/// Тело POST /api/tg/send-code — SMS-код входа (tg_routes.py CodeBody L46–48).
|
||||
/// </summary>
|
||||
/// <param name="Code">Код из SMS/Telegram-сообщения (trim перед отправкой, python L89).</param>
|
||||
public sealed record TgSendCodeRequest(string Code);
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
using Deal.Api.Services;
|
||||
using Deal.Api.Dtos;
|
||||
|
||||
namespace Deal.Api.Endpoints.RequestModels;
|
||||
|
||||
/// <summary>
|
||||
/// Тело POST /api/tg/send-password — облачный пароль 2FA (tg_routes.py PasswordBody L50–52).
|
||||
/// </summary>
|
||||
/// <param name="Password">Пароль облачной защиты (как ввёл пользователь, без trim — python L100).</param>
|
||||
public sealed record TgSendPasswordRequest(string Password);
|
||||
namespace Deal.Api.Endpoints.RequestModels;
|
||||
|
||||
/// <summary>
|
||||
/// Тело POST /api/tg/send-password — облачный пароль 2FA (tg_routes.py PasswordBody L50–52).
|
||||
/// </summary>
|
||||
/// <param name="Password">Пароль облачной защиты (как ввёл пользователь, без trim — python L100).</param>
|
||||
public sealed record TgSendPasswordRequest(string Password);
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
using Deal.Api.Services;
|
||||
using Deal.Api.Dtos;
|
||||
|
||||
namespace Deal.Api.Endpoints.RequestModels;
|
||||
|
||||
/// <summary>
|
||||
/// Тело POST /api/tg/start-phone — вход по номеру телефона (tg_routes.py PhoneBody L42–44).
|
||||
/// </summary>
|
||||
/// <param name="Phone">Номер в международном формате (как ввёл пользователь; обрезается обработчиком, python L71).</param>
|
||||
public sealed record TgStartPhoneRequest(string Phone);
|
||||
namespace Deal.Api.Endpoints.RequestModels;
|
||||
|
||||
/// <summary>
|
||||
/// Тело POST /api/tg/start-phone — вход по номеру телефона (tg_routes.py PhoneBody L42–44).
|
||||
/// </summary>
|
||||
/// <param name="Phone">Номер в международном формате (как ввёл пользователь; обрезается обработчиком, python L71).</param>
|
||||
public sealed record TgStartPhoneRequest(string Phone);
|
||||
|
||||
@@ -1,137 +1,129 @@
|
||||
using System.Text.Json;
|
||||
using Deal.Api;
|
||||
using Deal.Api.Dtos;
|
||||
using Deal.Api.Services;
|
||||
using Deal.Api.Extensions;
|
||||
using Deal.Api.Models;
|
||||
using Deal.Modules.Settings.Application.Abstractions;
|
||||
using Deal.Modules.Settings.Application.Models;
|
||||
using Deal.Modules.Settings.Application.Registrars;
|
||||
using Deal.Modules.Settings.Application.Services;
|
||||
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;
|
||||
|
||||
namespace Deal.Api.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// HTTP-эндпоинты настроек тенанта: GET/PATCH /api/settings (api-map §3.4 L146–147, §4.6).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// GET — публичный снимок настроек (дефолты + переопределения, маски секретов, providers — Ruling 3);
|
||||
/// PATCH — произвольный JSON-объект публичных полей §4.6, ответ — полный снимок после применения
|
||||
/// (фронт затирает локальный state ответом — store.js). Оба эндпоинта требуют сессию:
|
||||
/// 401 {"detail":"Требуется авторизация"} (Ruling 10). Мягкая семантика: невалидное поле PATCH
|
||||
/// просто не применяется; жёсткая ошибка — только тело не JSON-объект (400).
|
||||
/// Побочные эффекты прототипа L186–192: PATCH с полем rateSource запускает фоновое
|
||||
/// обновление кэша курсов (<see cref="RatesRefreshScheduler"/>, Ruling 6); пересчёт карточек при смене
|
||||
/// targetCurrency/conversionOn выполняет сам SettingsService через порт <see cref="IRatesChangedListener"/>
|
||||
/// (реализация — ConversionRecomputer модуля Kanban, Ruling 7, Task 12).
|
||||
/// <para>
|
||||
/// SettingsService резолвится из RequestServices ВНУТРИ обработчика после проверки сессии, а не
|
||||
/// параметром эндпоинта: DI-биндинг параметров выполняется до тела обработчика, а зависимость
|
||||
/// сервиса — scoped TenantDbContext, опции которого строятся по tenant-контексту запроса
|
||||
/// (без сессии контекст не разрешим — ошибка конфигурации). Так запрос без сессии получает 401,
|
||||
/// а не 500 при резолве.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class SettingsEndpoints
|
||||
{
|
||||
private const string ApiGroupPrefix = "/api";
|
||||
private const string SettingsPath = "/settings";
|
||||
private const string SettingsOpenApiTag = "settings";
|
||||
private const string InvalidBodyDetail = "Тело запроса должно быть JSON-объектом";
|
||||
|
||||
/// <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);
|
||||
}
|
||||
|
||||
// Произвольный JSON-объект: ключи тела — публичные ключи §4.6 (как их шлёт фронт).
|
||||
Dictionary<string, JsonElement>? body;
|
||||
try
|
||||
{
|
||||
body = await JsonSerializer.DeserializeAsync<Dictionary<string, JsonElement>>(
|
||||
context.Request.Body,
|
||||
options: null,
|
||||
cancellationToken: ct);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// Не-JSON или не-объект целиком — ошибка запроса: 400 + detail
|
||||
// (в прототипе FastAPI на такое тело — 422).
|
||||
return EndpointResults.BadRequest(InvalidBodyDetail);
|
||||
}
|
||||
|
||||
if (body is null)
|
||||
{
|
||||
return EndpointResults.BadRequest(InvalidBodyDetail);
|
||||
}
|
||||
|
||||
SettingsService settingsService = context.RequestServices.GetRequiredService<SettingsService>();
|
||||
PublicSettingsDto result = await settingsService.ApplyPatchAsync(body, ct);
|
||||
|
||||
// Аудит сохранения настроек (этап 10, T1): только имена полей — значения (в т.ч. секреты) не пишутся.
|
||||
await AuditAppender.AppendTenantAsync(context, AuditEvents.SettingsUpdated, new { fields = body.Keys }, ct);
|
||||
|
||||
// Смена источника курсов в PATCH (settings_routes.py L188–189) — фоновое обновление кэша
|
||||
// курсов (Ruling 6, Task 8). RefreshAsync читает уже сохранённую настройку rateSource.
|
||||
if (ShouldScheduleRatesRefresh(body))
|
||||
{
|
||||
context.RequestServices.GetRequiredService<RatesRefreshScheduler>().Schedule();
|
||||
}
|
||||
|
||||
return Results.Ok(result);
|
||||
}
|
||||
|
||||
// Запускать ли фоновый refresh курсов после PATCH (семантика if body.get("rateSource") L188).
|
||||
// body: Тело PATCH — публичные ключи §4.6.
|
||||
// Возвращает: True — поле rateSource передано «правдивым» значением (не null/пустая строка).
|
||||
private static bool ShouldScheduleRatesRefresh(Dictionary<string, JsonElement> body)
|
||||
{
|
||||
if (!body.TryGetValue(SettingsKeys.RateSource, out JsonElement element))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// JSON-булево/число в python «правдивы» и запускают refresh; пустая строка/null — нет.
|
||||
return element.ValueKind switch
|
||||
{
|
||||
JsonValueKind.String => !string.IsNullOrEmpty(element.GetString()),
|
||||
JsonValueKind.Null => false,
|
||||
_ => true,
|
||||
};
|
||||
}
|
||||
}
|
||||
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;
|
||||
|
||||
namespace Deal.Api.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// HTTP-эндпоинты настроек тенанта: GET/PATCH /api/settings (api-map §3.4 L146–147, §4.6).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// GET — публичный снимок настроек (дефолты + переопределения, маски секретов, providers — Ruling 3);
|
||||
/// PATCH — произвольный JSON-объект публичных полей §4.6, ответ — полный снимок после применения
|
||||
/// (фронт затирает локальный state ответом — store.js). Оба эндпоинта требуют сессию:
|
||||
/// 401 {"detail":"Требуется авторизация"} (Ruling 10). Мягкая семантика: невалидное поле PATCH
|
||||
/// просто не применяется; жёсткая ошибка — только тело не JSON-объект (400).
|
||||
/// Побочные эффекты прототипа L186–192: PATCH с полем rateSource запускает фоновое
|
||||
/// обновление кэша курсов (<see cref="RatesRefreshScheduler"/>, Ruling 6); пересчёт карточек при смене
|
||||
/// targetCurrency/conversionOn выполняет сам SettingsService через порт <see cref="IRatesChangedListener"/>
|
||||
/// (реализация — ConversionRecomputer модуля Kanban, Ruling 7, Task 12).
|
||||
/// <para>
|
||||
/// SettingsService резолвится из RequestServices ВНУТРИ обработчика после проверки сессии, а не
|
||||
/// параметром эндпоинта: DI-биндинг параметров выполняется до тела обработчика, а зависимость
|
||||
/// сервиса — scoped TenantDbContext, опции которого строятся по tenant-контексту запроса
|
||||
/// (без сессии контекст не разрешим — ошибка конфигурации). Так запрос без сессии получает 401,
|
||||
/// а не 500 при резолве.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class SettingsEndpoints
|
||||
{
|
||||
private const string ApiGroupPrefix = "/api";
|
||||
private const string SettingsPath = "/settings";
|
||||
private const string SettingsOpenApiTag = "settings";
|
||||
private const string InvalidBodyDetail = "Тело запроса должно быть JSON-объектом";
|
||||
|
||||
/// <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);
|
||||
}
|
||||
|
||||
// Произвольный JSON-объект: ключи тела — публичные ключи §4.6 (как их шлёт фронт).
|
||||
Dictionary<string, JsonElement>? body;
|
||||
try
|
||||
{
|
||||
body = await JsonSerializer.DeserializeAsync<Dictionary<string, JsonElement>>(
|
||||
context.Request.Body,
|
||||
options: null,
|
||||
cancellationToken: ct);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// Не-JSON или не-объект целиком — ошибка запроса: 400 + detail
|
||||
// (в прототипе FastAPI на такое тело — 422).
|
||||
return EndpointResults.BadRequest(InvalidBodyDetail);
|
||||
}
|
||||
|
||||
if (body is null)
|
||||
{
|
||||
return EndpointResults.BadRequest(InvalidBodyDetail);
|
||||
}
|
||||
|
||||
SettingsService settingsService = context.RequestServices.GetRequiredService<SettingsService>();
|
||||
PublicSettingsDto result = await settingsService.ApplyPatchAsync(body, ct);
|
||||
|
||||
// Аудит сохранения настроек (этап 10, T1): только имена полей — значения (в т.ч. секреты) не пишутся.
|
||||
await AuditAppender.AppendTenantAsync(context, AuditEvents.SettingsUpdated, new { fields = body.Keys }, ct);
|
||||
|
||||
// Смена источника курсов в PATCH (settings_routes.py L188–189) — фоновое обновление кэша
|
||||
// курсов (Ruling 6, Task 8). RefreshAsync читает уже сохранённую настройку rateSource.
|
||||
if (ShouldScheduleRatesRefresh(body))
|
||||
{
|
||||
context.RequestServices.GetRequiredService<RatesRefreshScheduler>().Schedule();
|
||||
}
|
||||
|
||||
return Results.Ok(result);
|
||||
}
|
||||
|
||||
// Запускать ли фоновый refresh курсов после PATCH (семантика if body.get("rateSource") L188).
|
||||
// body: Тело PATCH — публичные ключи §4.6.
|
||||
// Возвращает: True — поле rateSource передано «правдивым» значением (не null/пустая строка).
|
||||
private static bool ShouldScheduleRatesRefresh(Dictionary<string, JsonElement> body)
|
||||
{
|
||||
if (!body.TryGetValue(SettingsKeys.RateSource, out JsonElement element))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// JSON-булево/число в python «правдивы» и запускают refresh; пустая строка/null — нет.
|
||||
return element.ValueKind switch
|
||||
{
|
||||
JsonValueKind.String => !string.IsNullOrEmpty(element.GetString()),
|
||||
JsonValueKind.Null => false,
|
||||
_ => true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,90 +1,88 @@
|
||||
using Deal.Api.Extensions;
|
||||
using Deal.Api.Models;
|
||||
using Deal.Api.Services;
|
||||
using Deal.Infrastructure.Services;
|
||||
using Deal.Api.Dtos;
|
||||
|
||||
namespace Deal.Api.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Служебные storage-эндпоинты: POST /api/admin/tick и POST /api/admin/fts/rebuild (план Tasks 10–11,
|
||||
/// Rulings 6/8/11; прототип dashboard_routes.py L261–264, L327–337).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Контракт 1:1 с прототипом и api-map §3.2 L103–112: POST /admin/tick = тик правил хранения текущего
|
||||
/// тенанта + очистка отсева пайплайна (3 суток) + проверка напоминаний «Отложено» (план Task 11, Ruling 3/8)
|
||||
/// + один проход pump очереди входящих (этап 4, Ruling 8/9);
|
||||
/// ответ {storage, reminders, pipeline: {…}, queue: N} (dashboard_routes.py L327–337; storage.purgedRejected
|
||||
/// объединяет очистку отсева — Ruling 9; reminders — «выстрелившие» напоминания {id,title,stage}, пусто —
|
||||
/// сработавших нет). SSE-публикации (тосты статистики notify_tick_stats L496–504, new_card по созданным
|
||||
/// карточкам и reminder_due по «выстрелившим» напоминаниям) выполняет <see cref="AdminTickOrchestrator"/> из
|
||||
/// Api-слоя — модули остаются чистыми (Ruling 5/8); без подписчиков публикация — no-op. Сбой проверки
|
||||
/// напоминаний/pump не роняет тик: reminders/pipeline ответа пусты, очередь ждёт следующего тика/фонового
|
||||
/// цикла (Task 11). POST /admin/fts/rebuild —
|
||||
/// реальная идемпотентная пересборка FTS-индексов <see cref="FtsMaintenance"/> (CREATE INDEX IF NOT EXISTS +
|
||||
/// ANALYZE, Ruling 6), ответ {ok:true, ready:true} (при сбое {ok:false, ready:false} — 1:1 с fts_rebuild L261–264,
|
||||
/// кнопка Settings «Пересобрать индекс» store.js L1883–1889). Оба эндпоинта требуют сессию: 401 {detail} без
|
||||
/// куки (Ruling 10); сервисы резолвятся из RequestServices ПОСЛЕ проверки сессии (паттерн BoardsEndpoints).
|
||||
/// </remarks>
|
||||
public static class StorageEndpoints
|
||||
{
|
||||
// Префикс группы (роутер dashboard, prefix="/api"; admin-пути прототипа L261/L327).
|
||||
private const string AdminGroupPrefix = "/api";
|
||||
|
||||
// Путь ручного тика правил хранения (dashboard_routes.py L327).
|
||||
private const string TickPath = "/admin/tick";
|
||||
|
||||
// Путь пересборки поискового индекса (dashboard_routes.py L261; Ruling 6).
|
||||
private const string FtsRebuildPath = "/admin/fts/rebuild";
|
||||
|
||||
// OpenAPI-тег группы (в прототипе роутер dashboard — dashboard_routes.py).
|
||||
private const string OpenApiTag = "dashboard";
|
||||
|
||||
/// <summary>
|
||||
/// Регистрирует POST /api/admin/tick и POST /api/admin/fts/rebuild.
|
||||
/// </summary>
|
||||
/// <param name="app">Построитель маршрутов приложения.</param>
|
||||
/// <returns>Построитель маршрутов для цепочки вызовов.</returns>
|
||||
public static IEndpointRouteBuilder MapStorageEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var admin = app.MapGroup(AdminGroupPrefix).WithTags(OpenApiTag);
|
||||
admin.MapPost(TickPath, AdminTickAsync);
|
||||
admin.MapPost(FtsRebuildPath, FtsRebuildAsync);
|
||||
return app;
|
||||
}
|
||||
|
||||
// POST /api/admin/tick: правила хранения + очистка отсева + напоминания + pump + SSE-тосты/new_card/reminder_due (admin_tick L327–337).
|
||||
// Весь состав тика — AdminTickOrchestrator (вынесен из эндпоинта для unit-тестов логики и
|
||||
// переиспользования): тик StorageTickService (Kanban) → PurgeExpiredAsync (отсев 3 суток, merge в
|
||||
// storage.purgedRejected) → тосты статистики (включая «Отсев очищен: N записей (3 дн.)») → CheckDueAsync
|
||||
// (напоминания «Отложено»: reminders ответа + SSE reminder_due, план Task 11; сбой не роняет тик) →
|
||||
// PumpOnceAsync (сбой не роняет тик) → SSE new_card по созданным карточкам → queue. Формы — 1:1 с прототипом.
|
||||
private static async Task<IResult> AdminTickAsync(HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
AdminTickOrchestrator orchestrator = context.RequestServices.GetRequiredService<AdminTickOrchestrator>();
|
||||
Guid tenantId = context.GetCurrentUser()!.TenantId;
|
||||
return Results.Ok(await orchestrator.TickAsync(tenantId, ct));
|
||||
}
|
||||
|
||||
// POST /api/admin/fts/rebuild: пересборка FTS-индексов тенанта; ответ {ok:true, ready:true} (Ruling 6).
|
||||
// SearchTsv — генерируемые STORED-колонки Cards/RejectedItems: авто-актуальны, «пересборка» = создание
|
||||
// отсутствующих GIN-индексов (CREATE INDEX IF NOT EXISTS) + ANALYZE таблиц (FtsMaintenance.RebuildAsync).
|
||||
// Сбой обслуживания возвращает {ok:false, ready:false} (прототип fts_rebuild L261–264: rebuild() → ok,
|
||||
// is_ready() → ready) — кнопка Settings фронта показывает ошибку по ready (store.js L1883–1889).
|
||||
private static async Task<IResult> FtsRebuildAsync(HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
FtsMaintenance fts = context.RequestServices.GetRequiredService<FtsMaintenance>();
|
||||
bool ok = await fts.RebuildAsync(ct);
|
||||
return Results.Ok(new { ok, ready = ok });
|
||||
}
|
||||
}
|
||||
using Deal.Api.Extensions;
|
||||
using Deal.Api.Services;
|
||||
using Deal.Infrastructure.Services;
|
||||
|
||||
namespace Deal.Api.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Служебные storage-эндпоинты: POST /api/admin/tick и POST /api/admin/fts/rebuild (план Tasks 10–11,
|
||||
/// Rulings 6/8/11; прототип dashboard_routes.py L261–264, L327–337).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Контракт 1:1 с прототипом и api-map §3.2 L103–112: POST /admin/tick = тик правил хранения текущего
|
||||
/// тенанта + очистка отсева пайплайна (3 суток) + проверка напоминаний «Отложено» (план Task 11, Ruling 3/8)
|
||||
/// + один проход pump очереди входящих (этап 4, Ruling 8/9);
|
||||
/// ответ {storage, reminders, pipeline: {…}, queue: N} (dashboard_routes.py L327–337; storage.purgedRejected
|
||||
/// объединяет очистку отсева — Ruling 9; reminders — «выстрелившие» напоминания {id,title,stage}, пусто —
|
||||
/// сработавших нет). SSE-публикации (тосты статистики notify_tick_stats L496–504, new_card по созданным
|
||||
/// карточкам и reminder_due по «выстрелившим» напоминаниям) выполняет <see cref="AdminTickOrchestrator"/> из
|
||||
/// Api-слоя — модули остаются чистыми (Ruling 5/8); без подписчиков публикация — no-op. Сбой проверки
|
||||
/// напоминаний/pump не роняет тик: reminders/pipeline ответа пусты, очередь ждёт следующего тика/фонового
|
||||
/// цикла (Task 11). POST /admin/fts/rebuild —
|
||||
/// реальная идемпотентная пересборка FTS-индексов <see cref="FtsMaintenance"/> (CREATE INDEX IF NOT EXISTS +
|
||||
/// ANALYZE, Ruling 6), ответ {ok:true, ready:true} (при сбое {ok:false, ready:false} — 1:1 с fts_rebuild L261–264,
|
||||
/// кнопка Settings «Пересобрать индекс» store.js L1883–1889). Оба эндпоинта требуют сессию: 401 {detail} без
|
||||
/// куки (Ruling 10); сервисы резолвятся из RequestServices ПОСЛЕ проверки сессии (паттерн BoardsEndpoints).
|
||||
/// </remarks>
|
||||
public static class StorageEndpoints
|
||||
{
|
||||
// Префикс группы (роутер dashboard, prefix="/api"; admin-пути прототипа L261/L327).
|
||||
private const string AdminGroupPrefix = "/api";
|
||||
|
||||
// Путь ручного тика правил хранения (dashboard_routes.py L327).
|
||||
private const string TickPath = "/admin/tick";
|
||||
|
||||
// Путь пересборки поискового индекса (dashboard_routes.py L261; Ruling 6).
|
||||
private const string FtsRebuildPath = "/admin/fts/rebuild";
|
||||
|
||||
// OpenAPI-тег группы (в прототипе роутер dashboard — dashboard_routes.py).
|
||||
private const string OpenApiTag = "dashboard";
|
||||
|
||||
/// <summary>
|
||||
/// Регистрирует POST /api/admin/tick и POST /api/admin/fts/rebuild.
|
||||
/// </summary>
|
||||
/// <param name="app">Построитель маршрутов приложения.</param>
|
||||
/// <returns>Построитель маршрутов для цепочки вызовов.</returns>
|
||||
public static IEndpointRouteBuilder MapStorageEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var admin = app.MapGroup(AdminGroupPrefix).WithTags(OpenApiTag);
|
||||
admin.MapPost(TickPath, AdminTickAsync);
|
||||
admin.MapPost(FtsRebuildPath, FtsRebuildAsync);
|
||||
return app;
|
||||
}
|
||||
|
||||
// POST /api/admin/tick: правила хранения + очистка отсева + напоминания + pump + SSE-тосты/new_card/reminder_due (admin_tick L327–337).
|
||||
// Весь состав тика — AdminTickOrchestrator (вынесен из эндпоинта для unit-тестов логики и
|
||||
// переиспользования): тик StorageTickService (Kanban) → PurgeExpiredAsync (отсев 3 суток, merge в
|
||||
// storage.purgedRejected) → тосты статистики (включая «Отсев очищен: N записей (3 дн.)») → CheckDueAsync
|
||||
// (напоминания «Отложено»: reminders ответа + SSE reminder_due, план Task 11; сбой не роняет тик) →
|
||||
// PumpOnceAsync (сбой не роняет тик) → SSE new_card по созданным карточкам → queue. Формы — 1:1 с прототипом.
|
||||
private static async Task<IResult> AdminTickAsync(HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
AdminTickOrchestrator orchestrator = context.RequestServices.GetRequiredService<AdminTickOrchestrator>();
|
||||
Guid tenantId = context.GetCurrentUser()!.TenantId;
|
||||
return Results.Ok(await orchestrator.TickAsync(tenantId, ct));
|
||||
}
|
||||
|
||||
// POST /api/admin/fts/rebuild: пересборка FTS-индексов тенанта; ответ {ok:true, ready:true} (Ruling 6).
|
||||
// SearchTsv — генерируемые STORED-колонки Cards/RejectedItems: авто-актуальны, «пересборка» = создание
|
||||
// отсутствующих GIN-индексов (CREATE INDEX IF NOT EXISTS) + ANALYZE таблиц (FtsMaintenance.RebuildAsync).
|
||||
// Сбой обслуживания возвращает {ok:false, ready:false} (прототип fts_rebuild L261–264: rebuild() → ok,
|
||||
// is_ready() → ready) — кнопка Settings фронта показывает ошибку по ready (store.js L1883–1889).
|
||||
private static async Task<IResult> FtsRebuildAsync(HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
FtsMaintenance fts = context.RequestServices.GetRequiredService<FtsMaintenance>();
|
||||
bool ok = await fts.RebuildAsync(ct);
|
||||
return Results.Ok(new { ok, ready = ok });
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,87 +1,85 @@
|
||||
using Deal.Api.Extensions;
|
||||
using Deal.Api.Models;
|
||||
using Deal.Api.Services;
|
||||
using Deal.Contracts.Integrations.Abstractions;
|
||||
using Deal.Contracts.Integrations.Models;
|
||||
using Net.Codecrete.QrCodeGenerator;
|
||||
using Deal.Api.Dtos;
|
||||
|
||||
namespace Deal.Api.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// GET /api/tg/qr-image: SVG QR-кода входа (tg_routes.py L30–39; Ruling 8).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Фронт рисует QR картинкой: <c><img src="/api/tg/qr-image?t=N"></c> (store.js tg-флоу; api-map §3.3 L133).
|
||||
/// Активен только в фазе входа «qr» (живой статус гейта); иначе — 404 «QR не активен — начните вход по QR»
|
||||
/// (глобальная строка контракта, python L34). SVG генерирует Net.Codecrete.QrCodeGenerator (SVG-first, без
|
||||
/// внешних растровых зависимостей — план Task 14/Tech Stack); border=1 как python (border=1, L22). Заголовки —
|
||||
/// no-store + Content-Disposition: inline (python L37–38: свежий QR на каждый запрос, не кэшировать). Сессия
|
||||
/// обязательна: 401 {detail} (Ruling 10).
|
||||
/// </remarks>
|
||||
public static class TelegramQrImageEndpoint
|
||||
{
|
||||
// Префикс группы /api/tg (общий с TelegramEndpoints).
|
||||
private const string QrGroupPrefix = "/api/tg";
|
||||
|
||||
// Путь SVG QR-кода (GET; фронт добавляет ?t=N от кэша).
|
||||
private const string QrImagePath = "/qr-image";
|
||||
|
||||
// OpenAPI-тег группы (в прототипе роутер tg — tg_routes.py).
|
||||
private const string QrImageOpenApiTag = "telegram";
|
||||
|
||||
// Деталь 404: QR не активен (python L34, глобальная строка контракта).
|
||||
private const string QrNotActiveDetail = "QR не активен — начните вход по QR";
|
||||
|
||||
// Media-type SVG-ответа (python L37: image/svg+xml).
|
||||
private const string SvgMediaType = "image/svg+xml";
|
||||
|
||||
// Ширина рамки (quiet zone) QR в модулях (python L22: qrcode border=1).
|
||||
private const int QrBorderModules = 1;
|
||||
|
||||
/// <summary>
|
||||
/// Регистрирует GET /api/tg/qr-image.
|
||||
/// </summary>
|
||||
/// <param name="app">Построитель маршрутов приложения.</param>
|
||||
/// <returns>Построитель маршрутов для цепочки вызовов.</returns>
|
||||
public static IEndpointRouteBuilder MapTelegramQrImageEndpoint(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup(QrGroupPrefix).WithTags(QrImageOpenApiTag);
|
||||
group.MapGet(QrImagePath, QrImageAsync);
|
||||
return app;
|
||||
}
|
||||
|
||||
// GET /api/tg/qr-image: SVG QR-кода фазы входа «qr» (tg_routes.py L30–39).
|
||||
private static async Task<IResult> QrImageAsync(HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
// Активен только в фазе «qr» живого статуса telegram-service; сервис недоступен/фаза иная — 404 (python L33–34).
|
||||
TelegramAccountStatusDto live;
|
||||
try
|
||||
{
|
||||
ITelegramGateway gateway = context.RequestServices.GetRequiredService<ITelegramGateway>();
|
||||
live = await gateway.StatusAsync(ct);
|
||||
}
|
||||
catch (Exception exception) when (exception is not OperationCanceledException)
|
||||
{
|
||||
return EndpointResults.NotFound(QrNotActiveDetail);
|
||||
}
|
||||
|
||||
if (!string.Equals(live.Phase, "qr", StringComparison.Ordinal) || string.IsNullOrEmpty(live.QrUrl))
|
||||
{
|
||||
return EndpointResults.NotFound(QrNotActiveDetail);
|
||||
}
|
||||
|
||||
QrCode qr = QrCode.EncodeText(live.QrUrl, QrCode.Ecc.Medium);
|
||||
string svg = qr.ToSvgString(QrBorderModules);
|
||||
|
||||
// Свежий QR на каждый запрос (не кэшировать); inline — как python L37–38.
|
||||
context.Response.Headers.CacheControl = "no-store";
|
||||
context.Response.Headers.ContentDisposition = "inline";
|
||||
return Results.Text(svg, SvgMediaType);
|
||||
}
|
||||
}
|
||||
using Deal.Api.Extensions;
|
||||
using Deal.Api.Services;
|
||||
using Deal.Contracts.Integrations.Abstractions;
|
||||
using Deal.Contracts.Integrations.Models;
|
||||
using Net.Codecrete.QrCodeGenerator;
|
||||
|
||||
namespace Deal.Api.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// GET /api/tg/qr-image: SVG QR-кода входа (tg_routes.py L30–39; Ruling 8).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Фронт рисует QR картинкой: <c><img src="/api/tg/qr-image?t=N"></c> (store.js tg-флоу; api-map §3.3 L133).
|
||||
/// Активен только в фазе входа «qr» (живой статус гейта); иначе — 404 «QR не активен — начните вход по QR»
|
||||
/// (глобальная строка контракта, python L34). SVG генерирует Net.Codecrete.QrCodeGenerator (SVG-first, без
|
||||
/// внешних растровых зависимостей — план Task 14/Tech Stack); border=1 как python (border=1, L22). Заголовки —
|
||||
/// no-store + Content-Disposition: inline (python L37–38: свежий QR на каждый запрос, не кэшировать). Сессия
|
||||
/// обязательна: 401 {detail} (Ruling 10).
|
||||
/// </remarks>
|
||||
public static class TelegramQrImageEndpoint
|
||||
{
|
||||
// Префикс группы /api/tg (общий с TelegramEndpoints).
|
||||
private const string QrGroupPrefix = "/api/tg";
|
||||
|
||||
// Путь SVG QR-кода (GET; фронт добавляет ?t=N от кэша).
|
||||
private const string QrImagePath = "/qr-image";
|
||||
|
||||
// OpenAPI-тег группы (в прототипе роутер tg — tg_routes.py).
|
||||
private const string QrImageOpenApiTag = "telegram";
|
||||
|
||||
// Деталь 404: QR не активен (python L34, глобальная строка контракта).
|
||||
private const string QrNotActiveDetail = "QR не активен — начните вход по QR";
|
||||
|
||||
// Media-type SVG-ответа (python L37: image/svg+xml).
|
||||
private const string SvgMediaType = "image/svg+xml";
|
||||
|
||||
// Ширина рамки (quiet zone) QR в модулях (python L22: qrcode border=1).
|
||||
private const int QrBorderModules = 1;
|
||||
|
||||
/// <summary>
|
||||
/// Регистрирует GET /api/tg/qr-image.
|
||||
/// </summary>
|
||||
/// <param name="app">Построитель маршрутов приложения.</param>
|
||||
/// <returns>Построитель маршрутов для цепочки вызовов.</returns>
|
||||
public static IEndpointRouteBuilder MapTelegramQrImageEndpoint(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup(QrGroupPrefix).WithTags(QrImageOpenApiTag);
|
||||
group.MapGet(QrImagePath, QrImageAsync);
|
||||
return app;
|
||||
}
|
||||
|
||||
// GET /api/tg/qr-image: SVG QR-кода фазы входа «qr» (tg_routes.py L30–39).
|
||||
private static async Task<IResult> QrImageAsync(HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
// Активен только в фазе «qr» живого статуса telegram-service; сервис недоступен/фаза иная — 404 (python L33–34).
|
||||
TelegramAccountStatusDto live;
|
||||
try
|
||||
{
|
||||
ITelegramGateway gateway = context.RequestServices.GetRequiredService<ITelegramGateway>();
|
||||
live = await gateway.StatusAsync(ct);
|
||||
}
|
||||
catch (Exception exception) when (exception is not OperationCanceledException)
|
||||
{
|
||||
return EndpointResults.NotFound(QrNotActiveDetail);
|
||||
}
|
||||
|
||||
if (!string.Equals(live.Phase, "qr", StringComparison.Ordinal) || string.IsNullOrEmpty(live.QrUrl))
|
||||
{
|
||||
return EndpointResults.NotFound(QrNotActiveDetail);
|
||||
}
|
||||
|
||||
QrCode qr = QrCode.EncodeText(live.QrUrl, QrCode.Ecc.Medium);
|
||||
string svg = qr.ToSvgString(QrBorderModules);
|
||||
|
||||
// Свежий QR на каждый запрос (не кэшировать); inline — как python L37–38.
|
||||
context.Response.Headers.CacheControl = "no-store";
|
||||
context.Response.Headers.ContentDisposition = "inline";
|
||||
return Results.Text(svg, SvgMediaType);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,129 +1,127 @@
|
||||
using System.Text.Encodings.Web;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Channels;
|
||||
using Deal.Api.Services;
|
||||
using Deal.Api.Dtos;
|
||||
|
||||
namespace Deal.Api.Events;
|
||||
|
||||
/// <summary>
|
||||
/// Singleton SSE-брокер этапа: per-tenant каналы событий (Ruling 5, план Task 9).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Канал заводится на тенанта при подписке (тенант сессии — CurrentUser.TenantId), публикация идёт
|
||||
/// в канал тенанта по явному идентификатору — её делают ТОЛЬКО эндпоинты Api после вызова сервисов
|
||||
/// модулей (Task 10/13/14); фоновые задачи вне tenant-запроса публикуют со своим scope + ITenantContext
|
||||
/// (Task 11). Публикация без подписчиков канала — no-op, не падает (Ruling 5). Очередь подписчика —
|
||||
/// bounded ≤200 с вытеснением старых, как прототип sse.py (maxsize=200, при переполнении get_nowait →
|
||||
/// put_nowait текущего события). Потокобезопасен: словарь защищён гейтом; запись в каналы —
|
||||
/// неблокирующий TryWrite (DropOldest) вне гейта, подписки/отписки конкурентны публикациям.
|
||||
/// </remarks>
|
||||
public sealed class SseBroker
|
||||
{
|
||||
/// <summary>
|
||||
/// Ёмкость очереди подписчика (sse.py L20: <c>asyncio.Queue(maxsize=200)</c>).
|
||||
/// </summary>
|
||||
public const int SubscriberQueueCapacity = 200;
|
||||
|
||||
// Опции сериализации полезной нагрузки в data: — как у HTTP-ответов приложения:
|
||||
// web-дефолты (camelCase) и без \u-экранирования не-ASCII (Program.cs ConfigureHttpJsonOptions).
|
||||
private static readonly JsonSerializerOptions PublishJsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
|
||||
};
|
||||
|
||||
private readonly object _gate = new();
|
||||
|
||||
// Подписчики по тенантам: tenantId → (subscriptionId → канал).
|
||||
private readonly Dictionary<Guid, Dictionary<Guid, Channel<SseEvent>>> _subscribersByTenant = new();
|
||||
|
||||
/// <summary>
|
||||
/// Подписывает клиента на канал тенанта: новая bounded-очередь (≤200, DropOldest).
|
||||
/// </summary>
|
||||
/// <param name="tenantId">Тенант сессии запроса (Ruling 5: канал по TenantId при подписке).</param>
|
||||
/// <returns>Подписка: идентификатор для отписки и читатель канала событий.</returns>
|
||||
public SseSubscription Subscribe(Guid tenantId)
|
||||
{
|
||||
var channel = Channel.CreateBounded<SseEvent>(new BoundedChannelOptions(SubscriberQueueCapacity)
|
||||
{
|
||||
// Вытеснение старых при переполнении (sse.py L36–44): TryWrite не блокирует и не падает.
|
||||
FullMode = BoundedChannelFullMode.DropOldest,
|
||||
SingleReader = true,
|
||||
SingleWriter = false,
|
||||
});
|
||||
|
||||
var subscription = new SseSubscription(Guid.NewGuid(), tenantId, channel.Reader);
|
||||
lock (_gate)
|
||||
{
|
||||
if (!_subscribersByTenant.TryGetValue(tenantId, out Dictionary<Guid, Channel<SseEvent>>? subscribers))
|
||||
{
|
||||
subscribers = new Dictionary<Guid, Channel<SseEvent>>();
|
||||
_subscribersByTenant.Add(tenantId, subscribers);
|
||||
}
|
||||
|
||||
subscribers.Add(subscription.Id, channel);
|
||||
}
|
||||
|
||||
return subscription;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Отписывает клиента по завершении SSE-соединения (events_routes.py L27–28).
|
||||
/// </summary>
|
||||
/// <param name="tenantId">Тенант канала подписки.</param>
|
||||
/// <param name="subscriptionId">Идентификатор подписки из <see cref="Subscribe"/>.</param>
|
||||
public void Unsubscribe(Guid tenantId, Guid subscriptionId)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (!_subscribersByTenant.TryGetValue(tenantId, out Dictionary<Guid, Channel<SseEvent>>? subscribers))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
subscribers.Remove(subscriptionId);
|
||||
if (subscribers.Count == 0)
|
||||
{
|
||||
// Пустой канал тенанта убираем — публикации без подписчиков становятся no-op.
|
||||
_subscribersByTenant.Remove(tenantId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Публикует событие в канал тенанта (Ruling 5: публикации — из эндпоинтов Api).
|
||||
/// </summary>
|
||||
/// <param name="tenantId">Тенант-получатель; без подписчиков — no-op, не падает.</param>
|
||||
/// <param name="eventType">Тип события (new_card/toast этапа 3; api.js L78–79).</param>
|
||||
/// <param name="payload">Полезная нагрузка — сериализуется в JSON (camelCase, без \u).</param>
|
||||
public void Publish(
|
||||
Guid tenantId,
|
||||
string eventType,
|
||||
object payload) =>
|
||||
Publish(tenantId, new SseEvent(eventType, JsonSerializer.Serialize(payload, PublishJsonOptions)));
|
||||
|
||||
/// <summary>
|
||||
/// Публикует готовое событие (тип + JSON) в канал тенанта.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">Тенант-получатель; без подписчиков — no-op, не падает.</param>
|
||||
/// <param name="sseEvent">Событие с уже сериализованной нагрузкой.</param>
|
||||
public void Publish(Guid tenantId, SseEvent sseEvent)
|
||||
{
|
||||
Channel<SseEvent>[] channels;
|
||||
lock (_gate)
|
||||
{
|
||||
if (!_subscribersByTenant.TryGetValue(tenantId, out Dictionary<Guid, Channel<SseEvent>>? subscribers))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
channels = subscribers.Values.ToArray();
|
||||
}
|
||||
|
||||
foreach (Channel<SseEvent> channel in channels)
|
||||
{
|
||||
// DropOldest + bounded: TryWrite всегда завершается успешно (кроме закрытого канала).
|
||||
channel.Writer.TryWrite(sseEvent);
|
||||
}
|
||||
}
|
||||
}
|
||||
using System.Text.Encodings.Web;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Channels;
|
||||
|
||||
namespace Deal.Api.Events;
|
||||
|
||||
/// <summary>
|
||||
/// Singleton SSE-брокер этапа: per-tenant каналы событий (Ruling 5, план Task 9).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Канал заводится на тенанта при подписке (тенант сессии — CurrentUser.TenantId), публикация идёт
|
||||
/// в канал тенанта по явному идентификатору — её делают ТОЛЬКО эндпоинты Api после вызова сервисов
|
||||
/// модулей (Task 10/13/14); фоновые задачи вне tenant-запроса публикуют со своим scope + ITenantContext
|
||||
/// (Task 11). Публикация без подписчиков канала — no-op, не падает (Ruling 5). Очередь подписчика —
|
||||
/// bounded ≤200 с вытеснением старых, как прототип sse.py (maxsize=200, при переполнении get_nowait →
|
||||
/// put_nowait текущего события). Потокобезопасен: словарь защищён гейтом; запись в каналы —
|
||||
/// неблокирующий TryWrite (DropOldest) вне гейта, подписки/отписки конкурентны публикациям.
|
||||
/// </remarks>
|
||||
public sealed class SseBroker
|
||||
{
|
||||
/// <summary>
|
||||
/// Ёмкость очереди подписчика (sse.py L20: <c>asyncio.Queue(maxsize=200)</c>).
|
||||
/// </summary>
|
||||
public const int SubscriberQueueCapacity = 200;
|
||||
|
||||
// Опции сериализации полезной нагрузки в data: — как у HTTP-ответов приложения:
|
||||
// web-дефолты (camelCase) и без \u-экранирования не-ASCII (Program.cs ConfigureHttpJsonOptions).
|
||||
private static readonly JsonSerializerOptions PublishJsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
|
||||
};
|
||||
|
||||
private readonly object _gate = new();
|
||||
|
||||
// Подписчики по тенантам: tenantId → (subscriptionId → канал).
|
||||
private readonly Dictionary<Guid, Dictionary<Guid, Channel<SseEvent>>> _subscribersByTenant = new();
|
||||
|
||||
/// <summary>
|
||||
/// Подписывает клиента на канал тенанта: новая bounded-очередь (≤200, DropOldest).
|
||||
/// </summary>
|
||||
/// <param name="tenantId">Тенант сессии запроса (Ruling 5: канал по TenantId при подписке).</param>
|
||||
/// <returns>Подписка: идентификатор для отписки и читатель канала событий.</returns>
|
||||
public SseSubscription Subscribe(Guid tenantId)
|
||||
{
|
||||
var channel = Channel.CreateBounded<SseEvent>(new BoundedChannelOptions(SubscriberQueueCapacity)
|
||||
{
|
||||
// Вытеснение старых при переполнении (sse.py L36–44): TryWrite не блокирует и не падает.
|
||||
FullMode = BoundedChannelFullMode.DropOldest,
|
||||
SingleReader = true,
|
||||
SingleWriter = false,
|
||||
});
|
||||
|
||||
var subscription = new SseSubscription(Guid.NewGuid(), tenantId, channel.Reader);
|
||||
lock (_gate)
|
||||
{
|
||||
if (!_subscribersByTenant.TryGetValue(tenantId, out Dictionary<Guid, Channel<SseEvent>>? subscribers))
|
||||
{
|
||||
subscribers = new Dictionary<Guid, Channel<SseEvent>>();
|
||||
_subscribersByTenant.Add(tenantId, subscribers);
|
||||
}
|
||||
|
||||
subscribers.Add(subscription.Id, channel);
|
||||
}
|
||||
|
||||
return subscription;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Отписывает клиента по завершении SSE-соединения (events_routes.py L27–28).
|
||||
/// </summary>
|
||||
/// <param name="tenantId">Тенант канала подписки.</param>
|
||||
/// <param name="subscriptionId">Идентификатор подписки из <see cref="Subscribe"/>.</param>
|
||||
public void Unsubscribe(Guid tenantId, Guid subscriptionId)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (!_subscribersByTenant.TryGetValue(tenantId, out Dictionary<Guid, Channel<SseEvent>>? subscribers))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
subscribers.Remove(subscriptionId);
|
||||
if (subscribers.Count == 0)
|
||||
{
|
||||
// Пустой канал тенанта убираем — публикации без подписчиков становятся no-op.
|
||||
_subscribersByTenant.Remove(tenantId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Публикует событие в канал тенанта (Ruling 5: публикации — из эндпоинтов Api).
|
||||
/// </summary>
|
||||
/// <param name="tenantId">Тенант-получатель; без подписчиков — no-op, не падает.</param>
|
||||
/// <param name="eventType">Тип события (new_card/toast этапа 3; api.js L78–79).</param>
|
||||
/// <param name="payload">Полезная нагрузка — сериализуется в JSON (camelCase, без \u).</param>
|
||||
public void Publish(
|
||||
Guid tenantId,
|
||||
string eventType,
|
||||
object payload) =>
|
||||
Publish(tenantId, new SseEvent(eventType, JsonSerializer.Serialize(payload, PublishJsonOptions)));
|
||||
|
||||
/// <summary>
|
||||
/// Публикует готовое событие (тип + JSON) в канал тенанта.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">Тенант-получатель; без подписчиков — no-op, не падает.</param>
|
||||
/// <param name="sseEvent">Событие с уже сериализованной нагрузкой.</param>
|
||||
public void Publish(Guid tenantId, SseEvent sseEvent)
|
||||
{
|
||||
Channel<SseEvent>[] channels;
|
||||
lock (_gate)
|
||||
{
|
||||
if (!_subscribersByTenant.TryGetValue(tenantId, out Dictionary<Guid, Channel<SseEvent>>? subscribers))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
channels = subscribers.Values.ToArray();
|
||||
}
|
||||
|
||||
foreach (Channel<SseEvent> channel in channels)
|
||||
{
|
||||
// DropOldest + bounded: TryWrite всегда завершается успешно (кроме закрытого канала).
|
||||
channel.Writer.TryWrite(sseEvent);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,19 +1,16 @@
|
||||
using Deal.Api.Services;
|
||||
using Deal.Api.Dtos;
|
||||
|
||||
namespace Deal.Api.Events;
|
||||
|
||||
/// <summary>
|
||||
/// Событие SSE-потока: тип + JSON-полезная нагрузка (Ruling 5; прототип sse.py L29–30).
|
||||
/// </summary>
|
||||
/// <param name="Type">Тип события — фронт слушает <c>addEventListener</c> по имени
|
||||
/// (api.js L78–81): на этапе 3 — <c>new_card</c> (полный объект карточки) и <c>toast</c> {text, icon}.</param>
|
||||
/// <param name="Json">Полезная нагрузка, сериализованная в JSON (camelCase, без \u-экранирования).</param>
|
||||
public sealed record SseEvent(string Type, string Json)
|
||||
{
|
||||
/// <summary>
|
||||
/// Отрисовывает frame протокола SSE: <c>event: <type>\ndata: <json>\n\n</c> (sse.py L30).
|
||||
/// </summary>
|
||||
/// <returns>Готовый frame для отправки в поток ответа.</returns>
|
||||
public string RenderFrame() => $"event: {Type}\ndata: {Json}\n\n";
|
||||
}
|
||||
namespace Deal.Api.Events;
|
||||
|
||||
/// <summary>
|
||||
/// Событие SSE-потока: тип + JSON-полезная нагрузка (Ruling 5; прототип sse.py L29–30).
|
||||
/// </summary>
|
||||
/// <param name="Type">Тип события — фронт слушает <c>addEventListener</c> по имени
|
||||
/// (api.js L78–81): на этапе 3 — <c>new_card</c> (полный объект карточки) и <c>toast</c> {text, icon}.</param>
|
||||
/// <param name="Json">Полезная нагрузка, сериализованная в JSON (camelCase, без \u-экранирования).</param>
|
||||
public sealed record SseEvent(string Type, string Json)
|
||||
{
|
||||
/// <summary>
|
||||
/// Отрисовывает frame протокола SSE: <c>event: <type>\ndata: <json>\n\n</c> (sse.py L30).
|
||||
/// </summary>
|
||||
/// <returns>Готовый frame для отправки в поток ответа.</returns>
|
||||
public string RenderFrame() => $"event: {Type}\ndata: {Json}\n\n";
|
||||
}
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
using System.Threading.Channels;
|
||||
using Deal.Api.Services;
|
||||
using Deal.Api.Dtos;
|
||||
|
||||
namespace Deal.Api.Events;
|
||||
|
||||
/// <summary>
|
||||
/// Активная подписка на канал SSE тенанта (прототип sse.py — очередь подписчика L19–20).
|
||||
/// </summary>
|
||||
/// <param name="Id">Идентификатор подписки — передаётся в <see cref="SseBroker.Unsubscribe"/>.</param>
|
||||
/// <param name="TenantId">Тенант канала: тенант сессии при подписке (Ruling 5).</param>
|
||||
/// <param name="Events">Канал событий подписчика: bounded-очередь ≤200 с вытеснением старых
|
||||
/// (DropOldest, как get_nowait+put_nowait прототипа L36–44).</param>
|
||||
public sealed record SseSubscription(Guid Id, Guid TenantId, ChannelReader<SseEvent> Events);
|
||||
using System.Threading.Channels;
|
||||
|
||||
namespace Deal.Api.Events;
|
||||
|
||||
/// <summary>
|
||||
/// Активная подписка на канал SSE тенанта (прототип sse.py — очередь подписчика L19–20).
|
||||
/// </summary>
|
||||
/// <param name="Id">Идентификатор подписки — передаётся в <see cref="SseBroker.Unsubscribe"/>.</param>
|
||||
/// <param name="TenantId">Тенант канала: тенант сессии при подписке (Ruling 5).</param>
|
||||
/// <param name="Events">Канал событий подписчика: bounded-очередь ≤200 с вытеснением старых
|
||||
/// (DropOldest, как get_nowait+put_nowait прототипа L36–44).</param>
|
||||
public sealed record SseSubscription(Guid Id, Guid TenantId, ChannelReader<SseEvent> Events);
|
||||
|
||||
@@ -1,84 +1,82 @@
|
||||
using Deal.Modules.Kanban.Application.Models;
|
||||
using Deal.Api.Services;
|
||||
using Deal.Api.Dtos;
|
||||
|
||||
namespace Deal.Api.Events;
|
||||
|
||||
/// <summary>
|
||||
/// Публикация SSE-тостов статистики тика правил хранения в канал тенанта (Ruling 8; notify_tick_stats L496–504).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Единый хелпер Api-слоя для POST /api/admin/tick (StorageEndpoints/AdminTickOrchestrator, Task 10) и
|
||||
/// фонового StorageTickScheduler (Task 11): публикует тосты только по ненулевым счётчикам, тексты и иконки
|
||||
/// 1:1 с прототипом; без подписчиков канала публикация — no-op (Ruling 5). Вынесен из StorageEndpoints,
|
||||
/// чтобы ручной и фоновый тики не дублировали логику. Модуль Kanban тосты не публикует (Ruling 5:
|
||||
/// публикации SSE — обязанность Api-слоя).
|
||||
/// </remarks>
|
||||
public sealed class StorageToastPublisher
|
||||
{
|
||||
// Тип SSE-события тоста (Ruling 5; api.js L79 слушает 'toast').
|
||||
private const string ToastEventType = "toast";
|
||||
|
||||
// Текст тоста автоархива: N карточек ушло в архив (notify_tick_stats L498).
|
||||
private const string AutoArchiveToastText = "Автоархив: {0} карточек";
|
||||
|
||||
// Текст тоста очистки архива: N карточек удалено из архива (notify_tick_stats L500).
|
||||
private const string ArchiveClearedToastText = "Архив очищен: {0} (90 дн.)";
|
||||
|
||||
// Текст тоста очистки корзины: N карточек удалено из корзины (notify_tick_stats L502).
|
||||
private const string TrashClearedToastText = "Корзина очищена: {0} (7 дн.)";
|
||||
|
||||
// Текст тоста автоочистки отсева пайплайна: N записей старше 3 суток (notify_tick_stats L503–504).
|
||||
private const string RejectedPurgedToastText = "Отсев очищен: {0} записей (3 дн.)";
|
||||
|
||||
// Иконка тоста автоархива (Ruling 8, 1:1 с прототипом).
|
||||
private const string ClockIcon = "clock";
|
||||
|
||||
// Иконка тостов очисток архива/корзины (Ruling 8, 1:1 с прототипом).
|
||||
private const string TrashIcon = "trash";
|
||||
|
||||
private readonly SseBroker _broker;
|
||||
|
||||
/// <summary>
|
||||
/// Создаёт публикатора тостов тика.
|
||||
/// </summary>
|
||||
/// <param name="broker">SSE-брокер каналов тенантов (singleton).</param>
|
||||
public StorageToastPublisher(SseBroker broker)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(broker);
|
||||
_broker = broker;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Публикует тосты статистики тика по ненулевым счётчикам (notify_tick_stats L496–504).
|
||||
/// </summary>
|
||||
/// <remarks>Тексты и иконки 1:1 с прототипом; дни в скобках («90 дн.»/«7 дн.»/«3 дн.») — фиксированные
|
||||
/// строки прототипа (не пересчитываются от настроек). Ветка purgedRejected («Отсев очищен: N записей
|
||||
/// (3 дн.)») — план Task 10: счётчик наполняет оркестратор тика (AdminTickOrchestrator) очисткой отсева
|
||||
/// PipelineProcessingService.PurgeExpiredAsync; фоновый цикл Task 11 публикует ту же ветку по своему тику.
|
||||
/// Публикация в канал тенанта; без подписчиков — no-op (Ruling 5).</remarks>
|
||||
/// <param name="tenantId">Тенант-получатель тостов (сессия запроса / канал тенанта цикла).</param>
|
||||
/// <param name="stats">Статистика только что выполненного тика.</param>
|
||||
public void PublishTickToasts(Guid tenantId, StorageTickStatsDto stats)
|
||||
{
|
||||
if (stats.Archived > 0)
|
||||
{
|
||||
_broker.Publish(tenantId, ToastEventType, new { text = string.Format(AutoArchiveToastText, stats.Archived), icon = ClockIcon });
|
||||
}
|
||||
|
||||
if (stats.PurgedArchive > 0)
|
||||
{
|
||||
_broker.Publish(tenantId, ToastEventType, new { text = string.Format(ArchiveClearedToastText, stats.PurgedArchive), icon = TrashIcon });
|
||||
}
|
||||
|
||||
if (stats.PurgedTrash > 0)
|
||||
{
|
||||
_broker.Publish(tenantId, ToastEventType, new { text = string.Format(TrashClearedToastText, stats.PurgedTrash), icon = TrashIcon });
|
||||
}
|
||||
|
||||
if (stats.PurgedRejected > 0)
|
||||
{
|
||||
_broker.Publish(tenantId, ToastEventType, new { text = string.Format(RejectedPurgedToastText, stats.PurgedRejected), icon = TrashIcon });
|
||||
}
|
||||
}
|
||||
}
|
||||
using Deal.Modules.Kanban.Application.Models;
|
||||
|
||||
namespace Deal.Api.Events;
|
||||
|
||||
/// <summary>
|
||||
/// Публикация SSE-тостов статистики тика правил хранения в канал тенанта (Ruling 8; notify_tick_stats L496–504).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Единый хелпер Api-слоя для POST /api/admin/tick (StorageEndpoints/AdminTickOrchestrator, Task 10) и
|
||||
/// фонового StorageTickScheduler (Task 11): публикует тосты только по ненулевым счётчикам, тексты и иконки
|
||||
/// 1:1 с прототипом; без подписчиков канала публикация — no-op (Ruling 5). Вынесен из StorageEndpoints,
|
||||
/// чтобы ручной и фоновый тики не дублировали логику. Модуль Kanban тосты не публикует (Ruling 5:
|
||||
/// публикации SSE — обязанность Api-слоя).
|
||||
/// </remarks>
|
||||
public sealed class StorageToastPublisher
|
||||
{
|
||||
// Тип SSE-события тоста (Ruling 5; api.js L79 слушает 'toast').
|
||||
private const string ToastEventType = "toast";
|
||||
|
||||
// Текст тоста автоархива: N карточек ушло в архив (notify_tick_stats L498).
|
||||
private const string AutoArchiveToastText = "Автоархив: {0} карточек";
|
||||
|
||||
// Текст тоста очистки архива: N карточек удалено из архива (notify_tick_stats L500).
|
||||
private const string ArchiveClearedToastText = "Архив очищен: {0} (90 дн.)";
|
||||
|
||||
// Текст тоста очистки корзины: N карточек удалено из корзины (notify_tick_stats L502).
|
||||
private const string TrashClearedToastText = "Корзина очищена: {0} (7 дн.)";
|
||||
|
||||
// Текст тоста автоочистки отсева пайплайна: N записей старше 3 суток (notify_tick_stats L503–504).
|
||||
private const string RejectedPurgedToastText = "Отсев очищен: {0} записей (3 дн.)";
|
||||
|
||||
// Иконка тоста автоархива (Ruling 8, 1:1 с прототипом).
|
||||
private const string ClockIcon = "clock";
|
||||
|
||||
// Иконка тостов очисток архива/корзины (Ruling 8, 1:1 с прототипом).
|
||||
private const string TrashIcon = "trash";
|
||||
|
||||
private readonly SseBroker _broker;
|
||||
|
||||
/// <summary>
|
||||
/// Создаёт публикатора тостов тика.
|
||||
/// </summary>
|
||||
/// <param name="broker">SSE-брокер каналов тенантов (singleton).</param>
|
||||
public StorageToastPublisher(SseBroker broker)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(broker);
|
||||
_broker = broker;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Публикует тосты статистики тика по ненулевым счётчикам (notify_tick_stats L496–504).
|
||||
/// </summary>
|
||||
/// <remarks>Тексты и иконки 1:1 с прототипом; дни в скобках («90 дн.»/«7 дн.»/«3 дн.») — фиксированные
|
||||
/// строки прототипа (не пересчитываются от настроек). Ветка purgedRejected («Отсев очищен: N записей
|
||||
/// (3 дн.)») — план Task 10: счётчик наполняет оркестратор тика (AdminTickOrchestrator) очисткой отсева
|
||||
/// PipelineProcessingService.PurgeExpiredAsync; фоновый цикл Task 11 публикует ту же ветку по своему тику.
|
||||
/// Публикация в канал тенанта; без подписчиков — no-op (Ruling 5).</remarks>
|
||||
/// <param name="tenantId">Тенант-получатель тостов (сессия запроса / канал тенанта цикла).</param>
|
||||
/// <param name="stats">Статистика только что выполненного тика.</param>
|
||||
public void PublishTickToasts(Guid tenantId, StorageTickStatsDto stats)
|
||||
{
|
||||
if (stats.Archived > 0)
|
||||
{
|
||||
_broker.Publish(tenantId, ToastEventType, new { text = string.Format(AutoArchiveToastText, stats.Archived), icon = ClockIcon });
|
||||
}
|
||||
|
||||
if (stats.PurgedArchive > 0)
|
||||
{
|
||||
_broker.Publish(tenantId, ToastEventType, new { text = string.Format(ArchiveClearedToastText, stats.PurgedArchive), icon = TrashIcon });
|
||||
}
|
||||
|
||||
if (stats.PurgedTrash > 0)
|
||||
{
|
||||
_broker.Publish(tenantId, ToastEventType, new { text = string.Format(TrashClearedToastText, stats.PurgedTrash), icon = TrashIcon });
|
||||
}
|
||||
|
||||
if (stats.PurgedRejected > 0)
|
||||
{
|
||||
_broker.Publish(tenantId, ToastEventType, new { text = string.Format(RejectedPurgedToastText, stats.PurgedRejected), icon = TrashIcon });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,70 +1,68 @@
|
||||
using Deal.Api.Services;
|
||||
using Deal.Api.Dtos;
|
||||
using Deal.Api.Models;
|
||||
|
||||
namespace Deal.Api.Extensions;
|
||||
|
||||
/// <summary>
|
||||
/// Хелперы доступа к текущему пользователю запроса (минимальные API).
|
||||
/// </summary>
|
||||
public static class AuthHelpers
|
||||
{
|
||||
/// <summary>
|
||||
/// Ключ в <c>HttpContext.Items</c>, по которому SessionMiddleware хранит CurrentUser.
|
||||
/// </summary>
|
||||
public const string CurrentUserItemKey = "CurrentUser";
|
||||
|
||||
/// <summary>
|
||||
/// Ключ в <c>HttpContext.Items</c>, по которому OperatorSessionMiddleware хранит CurrentOperator.
|
||||
/// </summary>
|
||||
public const string CurrentOperatorItemKey = "CurrentOperator";
|
||||
|
||||
/// <summary>
|
||||
/// Сообщение 401 для эндпоинтов, требующих авторизации (семантика прототипа, Ruling 10).
|
||||
/// </summary>
|
||||
public const string UnauthorizedDetail = "Требуется авторизация";
|
||||
|
||||
/// <summary>
|
||||
/// Сообщение 401 для ручек /api/operator/* без разрешённой операторской сессии (Ruling 1).
|
||||
/// </summary>
|
||||
public const string OperatorUnauthorizedDetail = "Требуется вход оператора";
|
||||
|
||||
/// <summary>
|
||||
/// Кладёт пользователя в <c>HttpContext.Items</c>.
|
||||
/// </summary>
|
||||
/// <param name="context">Контекст запроса.</param>
|
||||
/// <param name="user">Пользователь, разрешённый по сессии.</param>
|
||||
public static void SetCurrentUser(this HttpContext context, CurrentUser user) =>
|
||||
context.Items[CurrentUserItemKey] = user;
|
||||
|
||||
/// <summary>
|
||||
/// Возвращает текущего пользователя запроса или null, если сессия не разрешена.
|
||||
/// </summary>
|
||||
/// <param name="context">Контекст запроса.</param>
|
||||
/// <returns>Пользователь или null.</returns>
|
||||
public static CurrentUser? GetCurrentUser(this HttpContext context) =>
|
||||
context.Items[CurrentUserItemKey] as CurrentUser;
|
||||
|
||||
/// <summary>
|
||||
/// Проверяет, разрешена ли для запроса пользовательская сессия.
|
||||
/// </summary>
|
||||
/// <param name="context">Контекст запроса.</param>
|
||||
/// <returns>True — текущий пользователь установлен.</returns>
|
||||
public static bool HasUser(this HttpContext context) => context.GetCurrentUser() is not null;
|
||||
|
||||
/// <summary>
|
||||
/// Кладёт оператора в <c>HttpContext.Items</c>.
|
||||
/// </summary>
|
||||
/// <param name="context">Контекст запроса.</param>
|
||||
/// <param name="operatorIdentity">Оператор, разрешённый по операторской сессии.</param>
|
||||
public static void SetCurrentOperator(this HttpContext context, CurrentOperator operatorIdentity) =>
|
||||
context.Items[CurrentOperatorItemKey] = operatorIdentity;
|
||||
|
||||
/// <summary>
|
||||
/// Возвращает текущего оператора запроса или null, если операторская сессия не разрешена.
|
||||
/// </summary>
|
||||
/// <param name="context">Контекст запроса.</param>
|
||||
/// <returns>Оператор или null (ручка /api/operator/* отвечает 401).</returns>
|
||||
public static CurrentOperator? GetCurrentOperator(this HttpContext context) =>
|
||||
context.Items[CurrentOperatorItemKey] as CurrentOperator;
|
||||
}
|
||||
using Deal.Api.Models;
|
||||
|
||||
namespace Deal.Api.Extensions;
|
||||
|
||||
/// <summary>
|
||||
/// Хелперы доступа к текущему пользователю запроса (минимальные API).
|
||||
/// </summary>
|
||||
public static class AuthHelpers
|
||||
{
|
||||
/// <summary>
|
||||
/// Ключ в <c>HttpContext.Items</c>, по которому SessionMiddleware хранит CurrentUser.
|
||||
/// </summary>
|
||||
public const string CurrentUserItemKey = "CurrentUser";
|
||||
|
||||
/// <summary>
|
||||
/// Ключ в <c>HttpContext.Items</c>, по которому OperatorSessionMiddleware хранит CurrentOperator.
|
||||
/// </summary>
|
||||
public const string CurrentOperatorItemKey = "CurrentOperator";
|
||||
|
||||
/// <summary>
|
||||
/// Сообщение 401 для эндпоинтов, требующих авторизации (семантика прототипа, Ruling 10).
|
||||
/// </summary>
|
||||
public const string UnauthorizedDetail = "Требуется авторизация";
|
||||
|
||||
/// <summary>
|
||||
/// Сообщение 401 для ручек /api/operator/* без разрешённой операторской сессии (Ruling 1).
|
||||
/// </summary>
|
||||
public const string OperatorUnauthorizedDetail = "Требуется вход оператора";
|
||||
|
||||
/// <summary>
|
||||
/// Кладёт пользователя в <c>HttpContext.Items</c>.
|
||||
/// </summary>
|
||||
/// <param name="context">Контекст запроса.</param>
|
||||
/// <param name="user">Пользователь, разрешённый по сессии.</param>
|
||||
public static void SetCurrentUser(this HttpContext context, CurrentUser user) =>
|
||||
context.Items[CurrentUserItemKey] = user;
|
||||
|
||||
/// <summary>
|
||||
/// Возвращает текущего пользователя запроса или null, если сессия не разрешена.
|
||||
/// </summary>
|
||||
/// <param name="context">Контекст запроса.</param>
|
||||
/// <returns>Пользователь или null.</returns>
|
||||
public static CurrentUser? GetCurrentUser(this HttpContext context) =>
|
||||
context.Items[CurrentUserItemKey] as CurrentUser;
|
||||
|
||||
/// <summary>
|
||||
/// Проверяет, разрешена ли для запроса пользовательская сессия.
|
||||
/// </summary>
|
||||
/// <param name="context">Контекст запроса.</param>
|
||||
/// <returns>True — текущий пользователь установлен.</returns>
|
||||
public static bool HasUser(this HttpContext context) => context.GetCurrentUser() is not null;
|
||||
|
||||
/// <summary>
|
||||
/// Кладёт оператора в <c>HttpContext.Items</c>.
|
||||
/// </summary>
|
||||
/// <param name="context">Контекст запроса.</param>
|
||||
/// <param name="operatorIdentity">Оператор, разрешённый по операторской сессии.</param>
|
||||
public static void SetCurrentOperator(this HttpContext context, CurrentOperator operatorIdentity) =>
|
||||
context.Items[CurrentOperatorItemKey] = operatorIdentity;
|
||||
|
||||
/// <summary>
|
||||
/// Возвращает текущего оператора запроса или null, если операторская сессия не разрешена.
|
||||
/// </summary>
|
||||
/// <param name="context">Контекст запроса.</param>
|
||||
/// <returns>Оператор или null (ручка /api/operator/* отвечает 401).</returns>
|
||||
public static CurrentOperator? GetCurrentOperator(this HttpContext context) =>
|
||||
context.Items[CurrentOperatorItemKey] as CurrentOperator;
|
||||
}
|
||||
|
||||
@@ -1,220 +1,215 @@
|
||||
using Deal.Api.Events;
|
||||
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.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Фоновый цикл SSE-алертов ИИ-бюджета (Ruling 3, Task 9; эталон StorageTickScheduler): каждые 60 с
|
||||
/// обходит ВСЕ тенанты реестра и публикует в канал тенанта тост при пересечении порогов бюджета 80%/100%.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Пересечение порога детектируется CAS-установкой флагов Warned80/NotifiedExhausted
|
||||
/// (<see cref="ITenantLimitStore.TryMarkWarnedAsync"/> / <see cref="ITenantLimitStore.TryMarkNotifiedExhaustedAsync"/>, Task 8): метод возвращает
|
||||
/// true только в момент «флаг ещё не стоял и порог достигнут» — публикация выполняется ровно один раз на порог
|
||||
/// за период (флаги сбрасываются ленивым reset периода и сменой бюджета оператором, Task 8/10). Флаги выставляет
|
||||
/// ТОЛЬКО TryMark* (review-fix Task 9): AddUsage только инкрементирует UsedTokens, поэтому естественный расход,
|
||||
/// пересекший порог, планировщик видит на ближайшем проходе как непомеченный переход — TryMark* возвращает true
|
||||
/// ровно один раз, и тост не теряется и не задваивается.
|
||||
/// Тексты и иконка
|
||||
/// 1:1 с Ruling 3 («ИИ-бюджет израсходован на 80%» / «ИИ-бюджет исчерпан — обработка в локальном режиме», icon
|
||||
/// bell); SSE-тип — существующий 'toast' (Ruling 11: новых SSE-типов нет). Списывание usage выполняется в
|
||||
/// gRPC-адаптерах (TokenUsageRecorder) и флаги при пересечении уже могут стоять — TryMark*-CAS не даёт
|
||||
/// задвоить тост. Публикация в канал тенанта; без подписчиков — no-op, не падает (Ruling 5).
|
||||
/// <para>
|
||||
/// Лимиты живут в публичной схеме (ITenantLimitStore → DealDbContext) — в отличие от StorageTickScheduler
|
||||
/// tenant-контекст проходу не нужен (SetTenant не выполняется); проверка каждого тенанта — в собственном scope
|
||||
/// (scoped-хранилище лимитов). Первый проход — сразу после старта, далее по таймеру; перекрывающиеся проходы
|
||||
/// исключены in-flight guard (Interlocked, как RatesRefreshScheduler). Ошибки логируются и наружу не выбрасываются
|
||||
/// (проверка одного тенанта не валит проход); при остановке хоста таймер останавливается и текущий проход
|
||||
/// отменяется (graceful).
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class BudgetAlertScheduler : IHostedService
|
||||
{
|
||||
/// <summary>
|
||||
/// Период проходов цикла — 60 с (план Task 9: фоновая проверка порогов бюджета).
|
||||
/// </summary>
|
||||
public const int AlertPeriodSeconds = 60;
|
||||
|
||||
// Тип SSE-события тоста (Ruling 5/11; api.js слушает 'toast', новых типов не вводим).
|
||||
private const string ToastEventType = "toast";
|
||||
|
||||
// Текст тоста пересечения порога 80% (Ruling 3).
|
||||
private const string Warned80ToastText = "ИИ-бюджет израсходован на 80%";
|
||||
|
||||
// Текст тоста исчерпания бюджета (Ruling 3).
|
||||
private const string ExhaustedToastText = "ИИ-бюджет исчерпан — обработка в локальном режиме";
|
||||
|
||||
// Иконка тостов бюджета (Ruling 3: bell).
|
||||
private const string BellIcon = "bell";
|
||||
|
||||
private static readonly TimeSpan AlertPeriod = TimeSpan.FromSeconds(AlertPeriodSeconds);
|
||||
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
private readonly SseBroker _broker;
|
||||
private readonly ILogger<BudgetAlertScheduler> _logger;
|
||||
|
||||
// Отмена при остановке хоста: прерывает текущий проход (EF-запросы наблюдают токен).
|
||||
private readonly CancellationTokenSource _shutdownCts = new();
|
||||
|
||||
private Timer? _timer;
|
||||
private Task? _currentIteration;
|
||||
private int _iterationInProgress;
|
||||
|
||||
/// <summary>
|
||||
/// Создаёт планировщик фоновых алертов ИИ-бюджета.
|
||||
/// </summary>
|
||||
/// <param name="scopeFactory">Фабрика scope: проход цикла и проверка каждого тенанта — в собственных scope.</param>
|
||||
/// <param name="broker">SSE-брокер каналов тенантов (публикация тостов; без подписчиков — no-op).</param>
|
||||
/// <param name="logger">Логгер ошибок цикла.</param>
|
||||
public BudgetAlertScheduler(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
SseBroker broker,
|
||||
ILogger<BudgetAlertScheduler> logger)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(scopeFactory);
|
||||
ArgumentNullException.ThrowIfNull(broker);
|
||||
ArgumentNullException.ThrowIfNull(logger);
|
||||
_scopeFactory = scopeFactory;
|
||||
_broker = broker;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task StartAsync(CancellationToken ct)
|
||||
{
|
||||
// Первый проход — сразу после старта (как StorageTickScheduler), далее каждые 60 с.
|
||||
_timer = new Timer(
|
||||
static state => ((BudgetAlertScheduler)state!).RunIteration(),
|
||||
this,
|
||||
TimeSpan.Zero,
|
||||
AlertPeriod);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task StopAsync(CancellationToken ct)
|
||||
{
|
||||
// Новые проходы не запускаем; текущий отменяем и ждём его завершения — не дольше лимита
|
||||
// остановки хоста (HostOptions.ShutdownTimeout).
|
||||
_timer?.Change(Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan);
|
||||
_timer?.Dispose();
|
||||
_timer = null;
|
||||
|
||||
_shutdownCts.Cancel();
|
||||
Task? iteration = Volatile.Read(ref _currentIteration);
|
||||
if (iteration is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
await iteration.WaitAsync(ct);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Лимит остановки истёк — хост продолжает остановку; ошибки прохода проглочены внутри
|
||||
// RunCycleCoreAsync, незавершённый проход безопасно завершится на отменённом токене.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Один проход цикла: список тенантов реестра и проверка порогов каждого (no-op, если проход идёт).
|
||||
/// </summary>
|
||||
/// <remarks>Публичен как точка запуска прохода для unit-тестов (тайминги цикла не тестируются); таймер
|
||||
/// вызывает этот же метод. Ошибки и отмена токена наружу не выбрасываются: сбои логируются (цикл живёт),
|
||||
/// отмена по токену останова завершает проход штатно.</remarks>
|
||||
/// <param name="ct">Токен отмены прохода (в проде — токен остановки хоста).</param>
|
||||
/// <returns>Задача прохода (завершается без исключений).</returns>
|
||||
public Task RunCycleAsync(CancellationToken ct)
|
||||
{
|
||||
if (Interlocked.CompareExchange(ref _iterationInProgress, 1, 0) != 0)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
Task iteration = RunCycleCoreAsync(ct);
|
||||
Volatile.Write(ref _currentIteration, iteration);
|
||||
return iteration;
|
||||
}
|
||||
|
||||
// Запускает проход из callback таймера (guard — внутри RunCycleAsync).
|
||||
private void RunIteration()
|
||||
{
|
||||
_ = RunCycleAsync(_shutdownCts.Token);
|
||||
}
|
||||
|
||||
// Тело прохода: проверка порогов каждого тенанта реестра; guard сбрасывается в finally.
|
||||
// ct: Токен отмены (остановка хоста).
|
||||
private async Task RunCycleCoreAsync(CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using AsyncServiceScope cycleScope = _scopeFactory.CreateAsyncScope();
|
||||
ITenantRepository tenantRepository = cycleScope.ServiceProvider.GetRequiredService<ITenantRepository>();
|
||||
IReadOnlyList<TenantRecordDto> tenants = await tenantRepository.ListAsync(ct);
|
||||
|
||||
foreach (TenantRecordDto tenant in tenants)
|
||||
{
|
||||
await CheckTenantAsync(tenant, ct);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Остановка хоста: проход прерван по токену — штатный выход, не ошибка.
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
// Сбой всего прохода (реестр недоступен и т.п.): логируем, цикл продолжит со следующего тика.
|
||||
_logger.LogError(exception, "Цикл бюджет-алертов: проход по тенантам не удался");
|
||||
}
|
||||
finally
|
||||
{
|
||||
Interlocked.Exchange(ref _iterationInProgress, 0);
|
||||
}
|
||||
}
|
||||
|
||||
// Проверка порогов одного тенанта в собственном scope: TryMark* (CAS) → тост в канал тенанта.
|
||||
// Список тенантов и лимиты живут в публичной схеме — tenant-контекст не заполняется (в отличие от
|
||||
// тиков StorageTickScheduler, работающих со схемой тенанта). Порядок: сначала 80% (Warned80), затем 100%
|
||||
// (NotifiedExhausted) — при скачке через оба порога за период выходят оба тоста по одному разу. Сбой одного
|
||||
// тенанта не валит проход: ошибка логируется, остальные тенанты обрабатываются; отмена (OCE) пробрасывается
|
||||
// наверх — проход завершается.
|
||||
// tenant: Тенант реестра (Id в формате Guid).
|
||||
// ct: Токен отмены прохода.
|
||||
private async Task CheckTenantAsync(TenantRecordDto tenant, CancellationToken ct)
|
||||
{
|
||||
await using AsyncServiceScope tenantScope = _scopeFactory.CreateAsyncScope();
|
||||
try
|
||||
{
|
||||
ITenantLimitStore limitStore = tenantScope.ServiceProvider.GetRequiredService<ITenantLimitStore>();
|
||||
|
||||
// TryMark* — атомарная установка флага: true только в момент первого наблюдения порога за период
|
||||
// (Task 8/9). Тост публикуется ровно один раз на порог; повторные проходы — false → no-op.
|
||||
if (await limitStore.TryMarkWarnedAsync(tenant.Id, ct))
|
||||
{
|
||||
_logger.LogDebug("Бюджет тенанта {TenantId}: порог 80% пересечён — SSE-тост", tenant.Id);
|
||||
_broker.Publish(tenant.Id, ToastEventType, new { text = Warned80ToastText, icon = BellIcon });
|
||||
}
|
||||
|
||||
if (await limitStore.TryMarkNotifiedExhaustedAsync(tenant.Id, ct))
|
||||
{
|
||||
_logger.LogDebug("Бюджет тенанта {TenantId}: исчерпан — SSE-тост", tenant.Id);
|
||||
_broker.Publish(tenant.Id, ToastEventType, new { text = ExhaustedToastText, icon = BellIcon });
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
_logger.LogWarning(exception, "Цикл бюджет-алертов: проверка тенанта {TenantId} не удалась", tenant.Id);
|
||||
}
|
||||
}
|
||||
}
|
||||
using Deal.Api.Events;
|
||||
using Deal.Modules.Tenants.Application.Abstractions;
|
||||
using Deal.Modules.Tenants.Application.Models;
|
||||
|
||||
namespace Deal.Api.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Фоновый цикл SSE-алертов ИИ-бюджета (Ruling 3, Task 9; эталон StorageTickScheduler): каждые 60 с
|
||||
/// обходит ВСЕ тенанты реестра и публикует в канал тенанта тост при пересечении порогов бюджета 80%/100%.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Пересечение порога детектируется CAS-установкой флагов Warned80/NotifiedExhausted
|
||||
/// (<see cref="ITenantLimitStore.TryMarkWarnedAsync"/> / <see cref="ITenantLimitStore.TryMarkNotifiedExhaustedAsync"/>, Task 8): метод возвращает
|
||||
/// true только в момент «флаг ещё не стоял и порог достигнут» — публикация выполняется ровно один раз на порог
|
||||
/// за период (флаги сбрасываются ленивым reset периода и сменой бюджета оператором, Task 8/10). Флаги выставляет
|
||||
/// ТОЛЬКО TryMark* (review-fix Task 9): AddUsage только инкрементирует UsedTokens, поэтому естественный расход,
|
||||
/// пересекший порог, планировщик видит на ближайшем проходе как непомеченный переход — TryMark* возвращает true
|
||||
/// ровно один раз, и тост не теряется и не задваивается.
|
||||
/// Тексты и иконка
|
||||
/// 1:1 с Ruling 3 («ИИ-бюджет израсходован на 80%» / «ИИ-бюджет исчерпан — обработка в локальном режиме», icon
|
||||
/// bell); SSE-тип — существующий 'toast' (Ruling 11: новых SSE-типов нет). Списывание usage выполняется в
|
||||
/// gRPC-адаптерах (TokenUsageRecorder) и флаги при пересечении уже могут стоять — TryMark*-CAS не даёт
|
||||
/// задвоить тост. Публикация в канал тенанта; без подписчиков — no-op, не падает (Ruling 5).
|
||||
/// <para>
|
||||
/// Лимиты живут в публичной схеме (ITenantLimitStore → DealDbContext) — в отличие от StorageTickScheduler
|
||||
/// tenant-контекст проходу не нужен (SetTenant не выполняется); проверка каждого тенанта — в собственном scope
|
||||
/// (scoped-хранилище лимитов). Первый проход — сразу после старта, далее по таймеру; перекрывающиеся проходы
|
||||
/// исключены in-flight guard (Interlocked, как RatesRefreshScheduler). Ошибки логируются и наружу не выбрасываются
|
||||
/// (проверка одного тенанта не валит проход); при остановке хоста таймер останавливается и текущий проход
|
||||
/// отменяется (graceful).
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class BudgetAlertScheduler : IHostedService
|
||||
{
|
||||
/// <summary>
|
||||
/// Период проходов цикла — 60 с (план Task 9: фоновая проверка порогов бюджета).
|
||||
/// </summary>
|
||||
public const int AlertPeriodSeconds = 60;
|
||||
|
||||
// Тип SSE-события тоста (Ruling 5/11; api.js слушает 'toast', новых типов не вводим).
|
||||
private const string ToastEventType = "toast";
|
||||
|
||||
// Текст тоста пересечения порога 80% (Ruling 3).
|
||||
private const string Warned80ToastText = "ИИ-бюджет израсходован на 80%";
|
||||
|
||||
// Текст тоста исчерпания бюджета (Ruling 3).
|
||||
private const string ExhaustedToastText = "ИИ-бюджет исчерпан — обработка в локальном режиме";
|
||||
|
||||
// Иконка тостов бюджета (Ruling 3: bell).
|
||||
private const string BellIcon = "bell";
|
||||
|
||||
private static readonly TimeSpan AlertPeriod = TimeSpan.FromSeconds(AlertPeriodSeconds);
|
||||
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
private readonly SseBroker _broker;
|
||||
private readonly ILogger<BudgetAlertScheduler> _logger;
|
||||
|
||||
// Отмена при остановке хоста: прерывает текущий проход (EF-запросы наблюдают токен).
|
||||
private readonly CancellationTokenSource _shutdownCts = new();
|
||||
|
||||
private Timer? _timer;
|
||||
private Task? _currentIteration;
|
||||
private int _iterationInProgress;
|
||||
|
||||
/// <summary>
|
||||
/// Создаёт планировщик фоновых алертов ИИ-бюджета.
|
||||
/// </summary>
|
||||
/// <param name="scopeFactory">Фабрика scope: проход цикла и проверка каждого тенанта — в собственных scope.</param>
|
||||
/// <param name="broker">SSE-брокер каналов тенантов (публикация тостов; без подписчиков — no-op).</param>
|
||||
/// <param name="logger">Логгер ошибок цикла.</param>
|
||||
public BudgetAlertScheduler(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
SseBroker broker,
|
||||
ILogger<BudgetAlertScheduler> logger)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(scopeFactory);
|
||||
ArgumentNullException.ThrowIfNull(broker);
|
||||
ArgumentNullException.ThrowIfNull(logger);
|
||||
_scopeFactory = scopeFactory;
|
||||
_broker = broker;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task StartAsync(CancellationToken ct)
|
||||
{
|
||||
// Первый проход — сразу после старта (как StorageTickScheduler), далее каждые 60 с.
|
||||
_timer = new Timer(
|
||||
static state => ((BudgetAlertScheduler)state!).RunIteration(),
|
||||
this,
|
||||
TimeSpan.Zero,
|
||||
AlertPeriod);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task StopAsync(CancellationToken ct)
|
||||
{
|
||||
// Новые проходы не запускаем; текущий отменяем и ждём его завершения — не дольше лимита
|
||||
// остановки хоста (HostOptions.ShutdownTimeout).
|
||||
_timer?.Change(Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan);
|
||||
_timer?.Dispose();
|
||||
_timer = null;
|
||||
|
||||
_shutdownCts.Cancel();
|
||||
Task? iteration = Volatile.Read(ref _currentIteration);
|
||||
if (iteration is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
await iteration.WaitAsync(ct);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Лимит остановки истёк — хост продолжает остановку; ошибки прохода проглочены внутри
|
||||
// RunCycleCoreAsync, незавершённый проход безопасно завершится на отменённом токене.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Один проход цикла: список тенантов реестра и проверка порогов каждого (no-op, если проход идёт).
|
||||
/// </summary>
|
||||
/// <remarks>Публичен как точка запуска прохода для unit-тестов (тайминги цикла не тестируются); таймер
|
||||
/// вызывает этот же метод. Ошибки и отмена токена наружу не выбрасываются: сбои логируются (цикл живёт),
|
||||
/// отмена по токену останова завершает проход штатно.</remarks>
|
||||
/// <param name="ct">Токен отмены прохода (в проде — токен остановки хоста).</param>
|
||||
/// <returns>Задача прохода (завершается без исключений).</returns>
|
||||
public Task RunCycleAsync(CancellationToken ct)
|
||||
{
|
||||
if (Interlocked.CompareExchange(ref _iterationInProgress, 1, 0) != 0)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
Task iteration = RunCycleCoreAsync(ct);
|
||||
Volatile.Write(ref _currentIteration, iteration);
|
||||
return iteration;
|
||||
}
|
||||
|
||||
// Запускает проход из callback таймера (guard — внутри RunCycleAsync).
|
||||
private void RunIteration()
|
||||
{
|
||||
_ = RunCycleAsync(_shutdownCts.Token);
|
||||
}
|
||||
|
||||
// Тело прохода: проверка порогов каждого тенанта реестра; guard сбрасывается в finally.
|
||||
// ct: Токен отмены (остановка хоста).
|
||||
private async Task RunCycleCoreAsync(CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using AsyncServiceScope cycleScope = _scopeFactory.CreateAsyncScope();
|
||||
ITenantRepository tenantRepository = cycleScope.ServiceProvider.GetRequiredService<ITenantRepository>();
|
||||
IReadOnlyList<TenantRecordDto> tenants = await tenantRepository.ListAsync(ct);
|
||||
|
||||
foreach (TenantRecordDto tenant in tenants)
|
||||
{
|
||||
await CheckTenantAsync(tenant, ct);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Остановка хоста: проход прерван по токену — штатный выход, не ошибка.
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
// Сбой всего прохода (реестр недоступен и т.п.): логируем, цикл продолжит со следующего тика.
|
||||
_logger.LogError(exception, "Цикл бюджет-алертов: проход по тенантам не удался");
|
||||
}
|
||||
finally
|
||||
{
|
||||
Interlocked.Exchange(ref _iterationInProgress, 0);
|
||||
}
|
||||
}
|
||||
|
||||
// Проверка порогов одного тенанта в собственном scope: TryMark* (CAS) → тост в канал тенанта.
|
||||
// Список тенантов и лимиты живут в публичной схеме — tenant-контекст не заполняется (в отличие от
|
||||
// тиков StorageTickScheduler, работающих со схемой тенанта). Порядок: сначала 80% (Warned80), затем 100%
|
||||
// (NotifiedExhausted) — при скачке через оба порога за период выходят оба тоста по одному разу. Сбой одного
|
||||
// тенанта не валит проход: ошибка логируется, остальные тенанты обрабатываются; отмена (OCE) пробрасывается
|
||||
// наверх — проход завершается.
|
||||
// tenant: Тенант реестра (Id в формате Guid).
|
||||
// ct: Токен отмены прохода.
|
||||
private async Task CheckTenantAsync(TenantRecordDto tenant, CancellationToken ct)
|
||||
{
|
||||
await using AsyncServiceScope tenantScope = _scopeFactory.CreateAsyncScope();
|
||||
try
|
||||
{
|
||||
ITenantLimitStore limitStore = tenantScope.ServiceProvider.GetRequiredService<ITenantLimitStore>();
|
||||
|
||||
// TryMark* — атомарная установка флага: true только в момент первого наблюдения порога за период
|
||||
// (Task 8/9). Тост публикуется ровно один раз на порог; повторные проходы — false → no-op.
|
||||
if (await limitStore.TryMarkWarnedAsync(tenant.Id, ct))
|
||||
{
|
||||
_logger.LogDebug("Бюджет тенанта {TenantId}: порог 80% пересечён — SSE-тост", tenant.Id);
|
||||
_broker.Publish(tenant.Id, ToastEventType, new { text = Warned80ToastText, icon = BellIcon });
|
||||
}
|
||||
|
||||
if (await limitStore.TryMarkNotifiedExhaustedAsync(tenant.Id, ct))
|
||||
{
|
||||
_logger.LogDebug("Бюджет тенанта {TenantId}: исчерпан — SSE-тост", tenant.Id);
|
||||
_broker.Publish(tenant.Id, ToastEventType, new { text = ExhaustedToastText, icon = BellIcon });
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
_logger.LogWarning(exception, "Цикл бюджет-алертов: проверка тенанта {TenantId} не удалась", tenant.Id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
using Deal.Api.Configuration;
|
||||
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.Hosting;
|
||||
|
||||
|
||||
@@ -1,196 +1,187 @@
|
||||
using Deal.Modules.Discovery.Application.Abstractions;
|
||||
using Deal.Modules.Discovery.Application.Exceptions;
|
||||
using Deal.Modules.Discovery.Application.Extensions;
|
||||
using Deal.Modules.Discovery.Application.Models;
|
||||
using Deal.Modules.Discovery.Application.Registrars;
|
||||
using Deal.Modules.Discovery.Application.Services;
|
||||
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.SharedKernel.Tenants.Abstractions;
|
||||
using Deal.SharedKernel.Tenants.Models;
|
||||
using Deal.Api.Services;
|
||||
using Deal.Api.Dtos;
|
||||
|
||||
namespace Deal.Api.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Фоновый цикл Discovery-воркера по всем тенантам (план Task 18, Ruling 10; эталон PipelineWorkerScheduler/StorageTickScheduler).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Каждые 5 с (в прототипе — <c>_discovery_loop</c> main.py: tick раз в ~5 секунд) обходит ВСЕ тенанты
|
||||
/// системного реестра и для каждого выполняет один тик <see cref="DiscoveryWorkerService.TickOnceAsync"/> в
|
||||
/// собственном scope с <c>ITenantContext.SetTenant</c> (эталон StorageTickScheduler). Тик делает ОДНО действие
|
||||
/// (поиск/оценка/вступление/done) для самой старой running-задачи тенанта — 1:1 с discovery_worker.tick L444–484.
|
||||
/// Как StorageTickScheduler: первый проход — сразу после старта, далее по таймеру; перекрывающиеся проходы
|
||||
/// исключены in-flight guard (Interlocked) — следующее срабатывание пропускается, если проход длится дольше
|
||||
/// периода (в частности, при паузах авто-вступлений 50–70 с внутри тика). Ошибки логируются и наружу не
|
||||
/// выбрасываются (тик одного тенанта не валит проход); при остановке хоста таймер останавливается и текущий
|
||||
/// проход отменяется (graceful). Пустой проход (нет running-задач/пауза/flood) — тихий no-op (action none).
|
||||
/// </remarks>
|
||||
public sealed class DiscoveryWorkerScheduler : IHostedService
|
||||
{
|
||||
// Период тиков цикла — 5 с (в прототипе _discovery_loop: tick каждые ~5 секунд).
|
||||
private const int DiscoveryPeriodSeconds = 5;
|
||||
|
||||
private static readonly TimeSpan DiscoveryPeriod = TimeSpan.FromSeconds(DiscoveryPeriodSeconds);
|
||||
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
private readonly ILogger<DiscoveryWorkerScheduler> _logger;
|
||||
|
||||
// Отмена при остановке хоста: прерывает текущий проход (EF-запросы тика наблюдают токен).
|
||||
private readonly CancellationTokenSource _shutdownCts = new();
|
||||
|
||||
private Timer? _timer;
|
||||
private Task? _currentIteration;
|
||||
private int _iterationInProgress;
|
||||
|
||||
/// <summary>
|
||||
/// Создаёт планировщик фонового цикла Discovery.
|
||||
/// </summary>
|
||||
/// <param name="scopeFactory">Фабрика scope: проход цикла и тик каждого тенанта — в собственных scope.</param>
|
||||
/// <param name="logger">Логгер ошибок цикла.</param>
|
||||
public DiscoveryWorkerScheduler(IServiceScopeFactory scopeFactory, ILogger<DiscoveryWorkerScheduler> logger)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(scopeFactory);
|
||||
ArgumentNullException.ThrowIfNull(logger);
|
||||
_scopeFactory = scopeFactory;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task StartAsync(CancellationToken ct)
|
||||
{
|
||||
// Первый проход — сразу после старта (как StorageTickScheduler), далее каждые 5 с.
|
||||
_timer = new Timer(
|
||||
static state => ((DiscoveryWorkerScheduler)state!).RunIteration(),
|
||||
this,
|
||||
TimeSpan.Zero,
|
||||
DiscoveryPeriod);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task StopAsync(CancellationToken ct)
|
||||
{
|
||||
// Новые проходы не запускаем; текущий отменяем и ждём его завершения — не дольше лимита
|
||||
// остановки хоста (HostOptions.ShutdownTimeout).
|
||||
_timer?.Change(Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan);
|
||||
_timer?.Dispose();
|
||||
_timer = null;
|
||||
|
||||
_shutdownCts.Cancel();
|
||||
Task? iteration = Volatile.Read(ref _currentIteration);
|
||||
if (iteration is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
await iteration.WaitAsync(ct);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Лимит остановки истёк — хост продолжает остановку; ошибки прохода проглочены внутри
|
||||
// RunCycleAsync, незавершённый проход безопасно завершится на отменённом токене.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Один проход цикла: список тенантов реестра и тик каждого (no-op, если проход уже идёт).
|
||||
/// </summary>
|
||||
/// <remarks>Публичен как точка запуска прохода для unit-тестов (тайминги цикла не тестируются) и
|
||||
/// ручного вызова при отладке; таймер вызывает этот же метод. Ошибки и отмена токена наружу не
|
||||
/// выбрасываются: сбои логируются (цикл живёт), отмена по токену останова завершает проход штатно.</remarks>
|
||||
/// <param name="ct">Токен отмены прохода (в проде — токен остановки хоста).</param>
|
||||
/// <returns>Задача прохода (завершается без исключений).</returns>
|
||||
public Task RunCycleAsync(CancellationToken ct)
|
||||
{
|
||||
if (Interlocked.CompareExchange(ref _iterationInProgress, 1, 0) != 0)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
Task iteration = RunCycleCoreAsync(ct);
|
||||
Volatile.Write(ref _currentIteration, iteration);
|
||||
return iteration;
|
||||
}
|
||||
|
||||
// Запускает проход из callback таймера (guard — внутри RunCycleAsync).
|
||||
private void RunIteration()
|
||||
{
|
||||
_ = RunCycleAsync(_shutdownCts.Token);
|
||||
}
|
||||
|
||||
// Тело прохода: тик Discovery каждого тенанта реестра; guard сбрасывается в finally.
|
||||
// ct: Токен отмены (остановка хоста).
|
||||
private async Task RunCycleCoreAsync(CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using AsyncServiceScope cycleScope = _scopeFactory.CreateAsyncScope();
|
||||
ITenantRepository tenantRepository = cycleScope.ServiceProvider.GetRequiredService<ITenantRepository>();
|
||||
IReadOnlyList<TenantRecordDto> tenants = await tenantRepository.ListAsync(ct);
|
||||
|
||||
foreach (TenantRecordDto tenant in tenants)
|
||||
{
|
||||
await TickTenantAsync(tenant, ct);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Остановка хоста: проход прерван по токену — штатный выход, не ошибка.
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
// Сбой всего прохода (реестр недоступен и т.п.): логируем, цикл продолжит со следующего тика.
|
||||
_logger.LogError(exception, "Цикл Discovery: проход по тенантам не удался");
|
||||
}
|
||||
finally
|
||||
{
|
||||
Interlocked.Exchange(ref _iterationInProgress, 0);
|
||||
}
|
||||
}
|
||||
|
||||
// Тик одного тенанта в собственном scope: SetTenant → один тик воркера; Reset в finally.
|
||||
// Контекст AsyncLocal сбрасывается в finally, чтобы не переживать scope тенанта (как
|
||||
// SessionMiddleware). Тик одного тенанта не валит проход: ошибка логируется, остальные тенанты
|
||||
// обрабатываются; отмена (OCE) пробрасывается наверх — проход завершается. Результат «none» — тихий
|
||||
// no-op (пауза/нет работы); прочие действия логируются в Debug (рабочие события — в DiscLog задачи).
|
||||
// tenant: Тенант реестра (Id в формате Guid; схема — tenant_<N>).
|
||||
// ct: Токен отмены прохода.
|
||||
private async Task TickTenantAsync(TenantRecordDto tenant, CancellationToken ct)
|
||||
{
|
||||
await using AsyncServiceScope tenantScope = _scopeFactory.CreateAsyncScope();
|
||||
ITenantContext tenantContext = tenantScope.ServiceProvider.GetRequiredService<ITenantContext>();
|
||||
try
|
||||
{
|
||||
tenantContext.SetTenant(new TenantId(tenant.Id.ToString("N")));
|
||||
|
||||
// Resolve ПОСЛЕ SetTenant: TenantDbContext (и его адаптеры) строятся от схемы текущего тенанта.
|
||||
DiscoveryWorkerService worker = tenantScope.ServiceProvider.GetRequiredService<DiscoveryWorkerService>();
|
||||
DiscoveryWorkerOutcome outcome = await worker.TickOnceAsync(ct);
|
||||
if (outcome.Action != DiscoveryWorkerService.ActionNone)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Discovery-тик {TenantId}: {Action} (задача {TaskId})",
|
||||
tenant.Id,
|
||||
outcome.Action,
|
||||
outcome.TaskId ?? "-");
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
_logger.LogWarning(exception, "Цикл Discovery: тик тенанта {TenantId} не удался", tenant.Id);
|
||||
}
|
||||
finally
|
||||
{
|
||||
tenantContext.Reset();
|
||||
}
|
||||
}
|
||||
}
|
||||
using Deal.Modules.Discovery.Application.Models;
|
||||
using Deal.Modules.Discovery.Application.Services;
|
||||
using Deal.Modules.Tenants.Application.Abstractions;
|
||||
using Deal.Modules.Tenants.Application.Models;
|
||||
using Deal.SharedKernel.Tenants.Abstractions;
|
||||
using Deal.SharedKernel.Tenants.Models;
|
||||
|
||||
namespace Deal.Api.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Фоновый цикл Discovery-воркера по всем тенантам (план Task 18, Ruling 10; эталон PipelineWorkerScheduler/StorageTickScheduler).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Каждые 5 с (в прототипе — <c>_discovery_loop</c> main.py: tick раз в ~5 секунд) обходит ВСЕ тенанты
|
||||
/// системного реестра и для каждого выполняет один тик <see cref="DiscoveryWorkerService.TickOnceAsync"/> в
|
||||
/// собственном scope с <c>ITenantContext.SetTenant</c> (эталон StorageTickScheduler). Тик делает ОДНО действие
|
||||
/// (поиск/оценка/вступление/done) для самой старой running-задачи тенанта — 1:1 с discovery_worker.tick L444–484.
|
||||
/// Как StorageTickScheduler: первый проход — сразу после старта, далее по таймеру; перекрывающиеся проходы
|
||||
/// исключены in-flight guard (Interlocked) — следующее срабатывание пропускается, если проход длится дольше
|
||||
/// периода (в частности, при паузах авто-вступлений 50–70 с внутри тика). Ошибки логируются и наружу не
|
||||
/// выбрасываются (тик одного тенанта не валит проход); при остановке хоста таймер останавливается и текущий
|
||||
/// проход отменяется (graceful). Пустой проход (нет running-задач/пауза/flood) — тихий no-op (action none).
|
||||
/// </remarks>
|
||||
public sealed class DiscoveryWorkerScheduler : IHostedService
|
||||
{
|
||||
// Период тиков цикла — 5 с (в прототипе _discovery_loop: tick каждые ~5 секунд).
|
||||
private const int DiscoveryPeriodSeconds = 5;
|
||||
|
||||
private static readonly TimeSpan DiscoveryPeriod = TimeSpan.FromSeconds(DiscoveryPeriodSeconds);
|
||||
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
private readonly ILogger<DiscoveryWorkerScheduler> _logger;
|
||||
|
||||
// Отмена при остановке хоста: прерывает текущий проход (EF-запросы тика наблюдают токен).
|
||||
private readonly CancellationTokenSource _shutdownCts = new();
|
||||
|
||||
private Timer? _timer;
|
||||
private Task? _currentIteration;
|
||||
private int _iterationInProgress;
|
||||
|
||||
/// <summary>
|
||||
/// Создаёт планировщик фонового цикла Discovery.
|
||||
/// </summary>
|
||||
/// <param name="scopeFactory">Фабрика scope: проход цикла и тик каждого тенанта — в собственных scope.</param>
|
||||
/// <param name="logger">Логгер ошибок цикла.</param>
|
||||
public DiscoveryWorkerScheduler(IServiceScopeFactory scopeFactory, ILogger<DiscoveryWorkerScheduler> logger)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(scopeFactory);
|
||||
ArgumentNullException.ThrowIfNull(logger);
|
||||
_scopeFactory = scopeFactory;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task StartAsync(CancellationToken ct)
|
||||
{
|
||||
// Первый проход — сразу после старта (как StorageTickScheduler), далее каждые 5 с.
|
||||
_timer = new Timer(
|
||||
static state => ((DiscoveryWorkerScheduler)state!).RunIteration(),
|
||||
this,
|
||||
TimeSpan.Zero,
|
||||
DiscoveryPeriod);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task StopAsync(CancellationToken ct)
|
||||
{
|
||||
// Новые проходы не запускаем; текущий отменяем и ждём его завершения — не дольше лимита
|
||||
// остановки хоста (HostOptions.ShutdownTimeout).
|
||||
_timer?.Change(Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan);
|
||||
_timer?.Dispose();
|
||||
_timer = null;
|
||||
|
||||
_shutdownCts.Cancel();
|
||||
Task? iteration = Volatile.Read(ref _currentIteration);
|
||||
if (iteration is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
await iteration.WaitAsync(ct);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Лимит остановки истёк — хост продолжает остановку; ошибки прохода проглочены внутри
|
||||
// RunCycleAsync, незавершённый проход безопасно завершится на отменённом токене.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Один проход цикла: список тенантов реестра и тик каждого (no-op, если проход уже идёт).
|
||||
/// </summary>
|
||||
/// <remarks>Публичен как точка запуска прохода для unit-тестов (тайминги цикла не тестируются) и
|
||||
/// ручного вызова при отладке; таймер вызывает этот же метод. Ошибки и отмена токена наружу не
|
||||
/// выбрасываются: сбои логируются (цикл живёт), отмена по токену останова завершает проход штатно.</remarks>
|
||||
/// <param name="ct">Токен отмены прохода (в проде — токен остановки хоста).</param>
|
||||
/// <returns>Задача прохода (завершается без исключений).</returns>
|
||||
public Task RunCycleAsync(CancellationToken ct)
|
||||
{
|
||||
if (Interlocked.CompareExchange(ref _iterationInProgress, 1, 0) != 0)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
Task iteration = RunCycleCoreAsync(ct);
|
||||
Volatile.Write(ref _currentIteration, iteration);
|
||||
return iteration;
|
||||
}
|
||||
|
||||
// Запускает проход из callback таймера (guard — внутри RunCycleAsync).
|
||||
private void RunIteration()
|
||||
{
|
||||
_ = RunCycleAsync(_shutdownCts.Token);
|
||||
}
|
||||
|
||||
// Тело прохода: тик Discovery каждого тенанта реестра; guard сбрасывается в finally.
|
||||
// ct: Токен отмены (остановка хоста).
|
||||
private async Task RunCycleCoreAsync(CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using AsyncServiceScope cycleScope = _scopeFactory.CreateAsyncScope();
|
||||
ITenantRepository tenantRepository = cycleScope.ServiceProvider.GetRequiredService<ITenantRepository>();
|
||||
IReadOnlyList<TenantRecordDto> tenants = await tenantRepository.ListAsync(ct);
|
||||
|
||||
foreach (TenantRecordDto tenant in tenants)
|
||||
{
|
||||
await TickTenantAsync(tenant, ct);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Остановка хоста: проход прерван по токену — штатный выход, не ошибка.
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
// Сбой всего прохода (реестр недоступен и т.п.): логируем, цикл продолжит со следующего тика.
|
||||
_logger.LogError(exception, "Цикл Discovery: проход по тенантам не удался");
|
||||
}
|
||||
finally
|
||||
{
|
||||
Interlocked.Exchange(ref _iterationInProgress, 0);
|
||||
}
|
||||
}
|
||||
|
||||
// Тик одного тенанта в собственном scope: SetTenant → один тик воркера; Reset в finally.
|
||||
// Контекст AsyncLocal сбрасывается в finally, чтобы не переживать scope тенанта (как
|
||||
// SessionMiddleware). Тик одного тенанта не валит проход: ошибка логируется, остальные тенанты
|
||||
// обрабатываются; отмена (OCE) пробрасывается наверх — проход завершается. Результат «none» — тихий
|
||||
// no-op (пауза/нет работы); прочие действия логируются в Debug (рабочие события — в DiscLog задачи).
|
||||
// tenant: Тенант реестра (Id в формате Guid; схема — tenant_<N>).
|
||||
// ct: Токен отмены прохода.
|
||||
private async Task TickTenantAsync(TenantRecordDto tenant, CancellationToken ct)
|
||||
{
|
||||
await using AsyncServiceScope tenantScope = _scopeFactory.CreateAsyncScope();
|
||||
ITenantContext tenantContext = tenantScope.ServiceProvider.GetRequiredService<ITenantContext>();
|
||||
try
|
||||
{
|
||||
tenantContext.SetTenant(new TenantId(tenant.Id.ToString("N")));
|
||||
|
||||
// Resolve ПОСЛЕ SetTenant: TenantDbContext (и его адаптеры) строятся от схемы текущего тенанта.
|
||||
DiscoveryWorkerService worker = tenantScope.ServiceProvider.GetRequiredService<DiscoveryWorkerService>();
|
||||
DiscoveryWorkerOutcome outcome = await worker.TickOnceAsync(ct);
|
||||
if (outcome.Action != DiscoveryWorkerService.ActionNone)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Discovery-тик {TenantId}: {Action} (задача {TaskId})",
|
||||
tenant.Id,
|
||||
outcome.Action,
|
||||
outcome.TaskId ?? "-");
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
_logger.LogWarning(exception, "Цикл Discovery: тик тенанта {TenantId} не удался", tenant.Id);
|
||||
}
|
||||
finally
|
||||
{
|
||||
tenantContext.Reset();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,245 +1,232 @@
|
||||
using Deal.Infrastructure.Integrations.Abstractions;
|
||||
using Deal.Infrastructure.Integrations.Exceptions;
|
||||
using Deal.Infrastructure.Integrations.Extensions;
|
||||
using Deal.Infrastructure.Integrations.Models;
|
||||
using Deal.Infrastructure.Integrations.Options;
|
||||
using Deal.Infrastructure.Integrations.Services;
|
||||
using Deal.Modules.Kanban.Application.Abstractions;
|
||||
using Deal.Modules.Kanban.Application.Extensions;
|
||||
using Deal.Modules.Kanban.Application.Models;
|
||||
using Deal.Modules.Kanban.Application.Registrars;
|
||||
using Deal.Modules.Kanban.Application.Services;
|
||||
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.SharedKernel.Tenants.Abstractions;
|
||||
using Deal.SharedKernel.Tenants.Models;
|
||||
using Deal.Api.Services;
|
||||
using Deal.Api.Dtos;
|
||||
|
||||
namespace Deal.Api.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Фоновый флашер очереди обучения ML — выгрузка MlOutbox в ml-service батчами (план Task 16, Ruling 6).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Аналог <c>_ml_sync_loop</c> python-прототипа и <c>flush_outbox</c> (ml_client.py L56–82): каждые 10 с
|
||||
/// обходит ВСЕ тенанты реестра и в собственном scope с <c>ITenantContext.SetTenant</c> (эталон
|
||||
/// PipelineWorkerScheduler/StorageTickScheduler) отправляет накопленное обучение RPC TrainBatch порциями по
|
||||
/// 10 строк, ≤100 за цикл. Строки удаляются ТОЛЬКО после успешного батча (python L78–79); при недоступности
|
||||
/// ml-service порция остаётся и уходит в следующий цикл (ретрай на каждом тике, «строки остаются» — Ruling 6).
|
||||
/// <para>
|
||||
/// Регистрируется в Deal.Api только при <c>Services:Ml:UseLocal=false</c> (gRPC-режим): Local-режиму
|
||||
/// ml-service не нужен — очередь копится (этап 3), а при «поднятом сервисе» флашер выгружает её сразу.
|
||||
/// Первый проход — сразу после старта (как PipelineWorkerScheduler); перекрывающиеся проходы исключены
|
||||
/// in-flight guard (Interlocked). Ошибки логируются и наружу не выбрасываются (флаш одного тенанта не
|
||||
/// валит цикл — остальные тенанты обрабатываются); при остановке хоста таймер останавливается и текущий
|
||||
/// проход отменяется (graceful). Пустая очередь — тихий no-op.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class MlOutboxFlushScheduler : IHostedService
|
||||
{
|
||||
/// <summary>
|
||||
/// Период циклов выгрузки — 10 с (Ruling 6).
|
||||
/// </summary>
|
||||
public const int FlushPeriodSeconds = 10;
|
||||
|
||||
/// <summary>
|
||||
/// Размер порции за один TrainBatch — 10 строк (ml_client.flush_outbox L63: chunk=10).
|
||||
/// </summary>
|
||||
public const int BatchSize = 10;
|
||||
|
||||
/// <summary>
|
||||
/// Потолок выгрузки за один цикл тенанта — 100 строк (ml_client.flush_outbox L56: batch=100).
|
||||
/// </summary>
|
||||
public const int MaxPerCycle = 100;
|
||||
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
private readonly ILogger<MlOutboxFlushScheduler> _logger;
|
||||
|
||||
// Отмена при остановке хоста: прерывает текущий проход (EF-запросы наблюдают токен).
|
||||
private readonly CancellationTokenSource _shutdownCts = new();
|
||||
|
||||
private Timer? _timer;
|
||||
private Task? _currentIteration;
|
||||
private int _iterationInProgress;
|
||||
|
||||
/// <summary>
|
||||
/// Создаёт планировщик фоновой выгрузки очереди обучения ML.
|
||||
/// </summary>
|
||||
/// <param name="scopeFactory">Фабрика scope: проход цикла и флаш каждого тенанта — в собственных scope.</param>
|
||||
/// <param name="logger">Логгер сбоев цикла.</param>
|
||||
public MlOutboxFlushScheduler(IServiceScopeFactory scopeFactory, ILogger<MlOutboxFlushScheduler> logger)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(scopeFactory);
|
||||
ArgumentNullException.ThrowIfNull(logger);
|
||||
_scopeFactory = scopeFactory;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task StartAsync(CancellationToken ct)
|
||||
{
|
||||
// Первый проход — сразу после старта (как PipelineWorkerScheduler), далее каждые 10 с.
|
||||
_timer = new Timer(
|
||||
static state => ((MlOutboxFlushScheduler)state!).RunIteration(),
|
||||
this,
|
||||
TimeSpan.Zero,
|
||||
TimeSpan.FromSeconds(FlushPeriodSeconds));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task StopAsync(CancellationToken ct)
|
||||
{
|
||||
// Новые проходы не запускаем; текущий отменяем и ждём его завершения — не дольше лимита
|
||||
// остановки хоста (HostOptions.ShutdownTimeout).
|
||||
_timer?.Change(Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan);
|
||||
_timer?.Dispose();
|
||||
_timer = null;
|
||||
|
||||
_shutdownCts.Cancel();
|
||||
Task? iteration = Volatile.Read(ref _currentIteration);
|
||||
if (iteration is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
await iteration.WaitAsync(ct);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Лимит остановки истёк — хост продолжает остановку; ошибки прохода проглочены внутри
|
||||
// FlushCycleCoreAsync, незавершённый проход безопасно завершится на отменённом токене.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Один проход цикла: список тенантов реестра и выгрузка каждого (no-op, если проход уже идёт).
|
||||
/// </summary>
|
||||
/// <remarks>Публичен как точка запуска прохода для unit-тестов (тайминги цикла не тестируются);
|
||||
/// таймер вызывает этот же метод. Ошибки и отмена токена наружу не выбрасываются: сбои логируются
|
||||
/// (цикл живёт), отмена по токену останова завершает проход штатно.</remarks>
|
||||
/// <param name="ct">Токен отмены прохода (в проде — токен остановки хоста).</param>
|
||||
/// <returns>Задача прохода (завершается без исключений).</returns>
|
||||
public Task RunCycleAsync(CancellationToken ct)
|
||||
{
|
||||
if (Interlocked.CompareExchange(ref _iterationInProgress, 1, 0) != 0)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
Task iteration = FlushCycleCoreAsync(ct);
|
||||
Volatile.Write(ref _currentIteration, iteration);
|
||||
return iteration;
|
||||
}
|
||||
|
||||
// Запускает проход из callback таймера (guard — внутри RunCycleAsync).
|
||||
private void RunIteration()
|
||||
{
|
||||
_ = RunCycleAsync(_shutdownCts.Token);
|
||||
}
|
||||
|
||||
// Тело прохода: выгрузка очереди каждого тенанта реестра; guard сбрасывается в finally.
|
||||
// ct: Токен отмены (остановка хоста).
|
||||
private async Task FlushCycleCoreAsync(CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using AsyncServiceScope cycleScope = _scopeFactory.CreateAsyncScope();
|
||||
ITenantRepository tenantRepository = cycleScope.ServiceProvider.GetRequiredService<ITenantRepository>();
|
||||
IReadOnlyList<TenantRecordDto> tenants = await tenantRepository.ListAsync(ct);
|
||||
|
||||
foreach (TenantRecordDto tenant in tenants)
|
||||
{
|
||||
await FlushTenantAsync(tenant, ct);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Остановка хоста: проход прерван по токену — штатный выход, не ошибка.
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
// Сбой всего прохода (реестр недоступен и т.п.): логируем, цикл продолжит со следующего тика.
|
||||
_logger.LogError(exception, "Флашер ML-outbox: проход по тенантам не удался");
|
||||
}
|
||||
finally
|
||||
{
|
||||
Interlocked.Exchange(ref _iterationInProgress, 0);
|
||||
}
|
||||
}
|
||||
|
||||
// Выгрузка очереди одного тенанта в собственном scope: SetTenant → порции по 10 до ≤100/цикл.
|
||||
// Строки удаляются только после успешного TrainBatch (Ruling 6); сбой батча — порция остаётся,
|
||||
// цикл тенанта завершается (следующая попытка — следующий тик). Сбой хранилища тенанта не валит проход:
|
||||
// ошибка логируется, остальные тенанты обрабатываются; отмена (OCE) пробрасывается наверх.
|
||||
// tenant: Тенант реестра (Id в формате Guid; схема — tenant_<N>).
|
||||
// ct: Токен отмены прохода.
|
||||
private async Task FlushTenantAsync(TenantRecordDto tenant, CancellationToken ct)
|
||||
{
|
||||
await using AsyncServiceScope tenantScope = _scopeFactory.CreateAsyncScope();
|
||||
ITenantContext tenantContext = tenantScope.ServiceProvider.GetRequiredService<ITenantContext>();
|
||||
try
|
||||
{
|
||||
tenantContext.SetTenant(new TenantId(tenant.Id.ToString("N")));
|
||||
|
||||
// Resolve ПОСЛЕ SetTenant: IMlLearningStore строится от схемы текущего тенанта (как в цикле pump).
|
||||
IMlLearningStore learningStore = tenantScope.ServiceProvider.GetRequiredService<IMlLearningStore>();
|
||||
IMlTrainClient trainClient = tenantScope.ServiceProvider.GetRequiredService<IMlTrainClient>();
|
||||
|
||||
int total = 0;
|
||||
while (total < MaxPerCycle)
|
||||
{
|
||||
IReadOnlyList<MlOutboxEntryDto> rows = await learningStore.TakeOutboxBatchAsync(BatchSize, ct);
|
||||
if (rows.Count == 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await trainClient.TrainBatchAsync(rows, ct);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
// Недоступность/сбой ml-service: порция остаётся в очереди (python L75–77), следующая
|
||||
// попытка — на следующем тике; флашер не роняет проход цикла.
|
||||
_logger.LogWarning(
|
||||
exception,
|
||||
"Флашер ML-outbox: отправка {RowCount} строк тенанта {TenantId} не удалась — строки остались",
|
||||
rows.Count,
|
||||
tenant.Id);
|
||||
break;
|
||||
}
|
||||
|
||||
// Удаление только после успеха (python L78–79): отправленные строки больше не нужны.
|
||||
await learningStore.DeleteOutboxAsync(rows.Select(row => row.Id).ToList(), ct);
|
||||
total += rows.Count;
|
||||
}
|
||||
|
||||
if (total > 0)
|
||||
{
|
||||
_logger.LogInformation("Флашер ML-outbox: выгружено {RowCount} строк тенанта {TenantId}", total, tenant.Id);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
_logger.LogWarning(exception, "Флашер ML-outbox: выгрузка тенанта {TenantId} не удалась", tenant.Id);
|
||||
}
|
||||
finally
|
||||
{
|
||||
tenantContext.Reset();
|
||||
}
|
||||
}
|
||||
}
|
||||
using Deal.Infrastructure.Integrations.Abstractions;
|
||||
using Deal.Modules.Kanban.Application.Abstractions;
|
||||
using Deal.Modules.Kanban.Application.Models;
|
||||
using Deal.Modules.Tenants.Application.Abstractions;
|
||||
using Deal.Modules.Tenants.Application.Models;
|
||||
using Deal.SharedKernel.Tenants.Abstractions;
|
||||
using Deal.SharedKernel.Tenants.Models;
|
||||
|
||||
namespace Deal.Api.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Фоновый флашер очереди обучения ML — выгрузка MlOutbox в ml-service батчами (план Task 16, Ruling 6).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Аналог <c>_ml_sync_loop</c> python-прототипа и <c>flush_outbox</c> (ml_client.py L56–82): каждые 10 с
|
||||
/// обходит ВСЕ тенанты реестра и в собственном scope с <c>ITenantContext.SetTenant</c> (эталон
|
||||
/// PipelineWorkerScheduler/StorageTickScheduler) отправляет накопленное обучение RPC TrainBatch порциями по
|
||||
/// 10 строк, ≤100 за цикл. Строки удаляются ТОЛЬКО после успешного батча (python L78–79); при недоступности
|
||||
/// ml-service порция остаётся и уходит в следующий цикл (ретрай на каждом тике, «строки остаются» — Ruling 6).
|
||||
/// <para>
|
||||
/// Регистрируется в Deal.Api только при <c>Services:Ml:UseLocal=false</c> (gRPC-режим): Local-режиму
|
||||
/// ml-service не нужен — очередь копится (этап 3), а при «поднятом сервисе» флашер выгружает её сразу.
|
||||
/// Первый проход — сразу после старта (как PipelineWorkerScheduler); перекрывающиеся проходы исключены
|
||||
/// in-flight guard (Interlocked). Ошибки логируются и наружу не выбрасываются (флаш одного тенанта не
|
||||
/// валит цикл — остальные тенанты обрабатываются); при остановке хоста таймер останавливается и текущий
|
||||
/// проход отменяется (graceful). Пустая очередь — тихий no-op.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class MlOutboxFlushScheduler : IHostedService
|
||||
{
|
||||
/// <summary>
|
||||
/// Период циклов выгрузки — 10 с (Ruling 6).
|
||||
/// </summary>
|
||||
public const int FlushPeriodSeconds = 10;
|
||||
|
||||
/// <summary>
|
||||
/// Размер порции за один TrainBatch — 10 строк (ml_client.flush_outbox L63: chunk=10).
|
||||
/// </summary>
|
||||
public const int BatchSize = 10;
|
||||
|
||||
/// <summary>
|
||||
/// Потолок выгрузки за один цикл тенанта — 100 строк (ml_client.flush_outbox L56: batch=100).
|
||||
/// </summary>
|
||||
public const int MaxPerCycle = 100;
|
||||
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
private readonly ILogger<MlOutboxFlushScheduler> _logger;
|
||||
|
||||
// Отмена при остановке хоста: прерывает текущий проход (EF-запросы наблюдают токен).
|
||||
private readonly CancellationTokenSource _shutdownCts = new();
|
||||
|
||||
private Timer? _timer;
|
||||
private Task? _currentIteration;
|
||||
private int _iterationInProgress;
|
||||
|
||||
/// <summary>
|
||||
/// Создаёт планировщик фоновой выгрузки очереди обучения ML.
|
||||
/// </summary>
|
||||
/// <param name="scopeFactory">Фабрика scope: проход цикла и флаш каждого тенанта — в собственных scope.</param>
|
||||
/// <param name="logger">Логгер сбоев цикла.</param>
|
||||
public MlOutboxFlushScheduler(IServiceScopeFactory scopeFactory, ILogger<MlOutboxFlushScheduler> logger)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(scopeFactory);
|
||||
ArgumentNullException.ThrowIfNull(logger);
|
||||
_scopeFactory = scopeFactory;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task StartAsync(CancellationToken ct)
|
||||
{
|
||||
// Первый проход — сразу после старта (как PipelineWorkerScheduler), далее каждые 10 с.
|
||||
_timer = new Timer(
|
||||
static state => ((MlOutboxFlushScheduler)state!).RunIteration(),
|
||||
this,
|
||||
TimeSpan.Zero,
|
||||
TimeSpan.FromSeconds(FlushPeriodSeconds));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task StopAsync(CancellationToken ct)
|
||||
{
|
||||
// Новые проходы не запускаем; текущий отменяем и ждём его завершения — не дольше лимита
|
||||
// остановки хоста (HostOptions.ShutdownTimeout).
|
||||
_timer?.Change(Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan);
|
||||
_timer?.Dispose();
|
||||
_timer = null;
|
||||
|
||||
_shutdownCts.Cancel();
|
||||
Task? iteration = Volatile.Read(ref _currentIteration);
|
||||
if (iteration is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
await iteration.WaitAsync(ct);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Лимит остановки истёк — хост продолжает остановку; ошибки прохода проглочены внутри
|
||||
// FlushCycleCoreAsync, незавершённый проход безопасно завершится на отменённом токене.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Один проход цикла: список тенантов реестра и выгрузка каждого (no-op, если проход уже идёт).
|
||||
/// </summary>
|
||||
/// <remarks>Публичен как точка запуска прохода для unit-тестов (тайминги цикла не тестируются);
|
||||
/// таймер вызывает этот же метод. Ошибки и отмена токена наружу не выбрасываются: сбои логируются
|
||||
/// (цикл живёт), отмена по токену останова завершает проход штатно.</remarks>
|
||||
/// <param name="ct">Токен отмены прохода (в проде — токен остановки хоста).</param>
|
||||
/// <returns>Задача прохода (завершается без исключений).</returns>
|
||||
public Task RunCycleAsync(CancellationToken ct)
|
||||
{
|
||||
if (Interlocked.CompareExchange(ref _iterationInProgress, 1, 0) != 0)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
Task iteration = FlushCycleCoreAsync(ct);
|
||||
Volatile.Write(ref _currentIteration, iteration);
|
||||
return iteration;
|
||||
}
|
||||
|
||||
// Запускает проход из callback таймера (guard — внутри RunCycleAsync).
|
||||
private void RunIteration()
|
||||
{
|
||||
_ = RunCycleAsync(_shutdownCts.Token);
|
||||
}
|
||||
|
||||
// Тело прохода: выгрузка очереди каждого тенанта реестра; guard сбрасывается в finally.
|
||||
// ct: Токен отмены (остановка хоста).
|
||||
private async Task FlushCycleCoreAsync(CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using AsyncServiceScope cycleScope = _scopeFactory.CreateAsyncScope();
|
||||
ITenantRepository tenantRepository = cycleScope.ServiceProvider.GetRequiredService<ITenantRepository>();
|
||||
IReadOnlyList<TenantRecordDto> tenants = await tenantRepository.ListAsync(ct);
|
||||
|
||||
foreach (TenantRecordDto tenant in tenants)
|
||||
{
|
||||
await FlushTenantAsync(tenant, ct);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Остановка хоста: проход прерван по токену — штатный выход, не ошибка.
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
// Сбой всего прохода (реестр недоступен и т.п.): логируем, цикл продолжит со следующего тика.
|
||||
_logger.LogError(exception, "Флашер ML-outbox: проход по тенантам не удался");
|
||||
}
|
||||
finally
|
||||
{
|
||||
Interlocked.Exchange(ref _iterationInProgress, 0);
|
||||
}
|
||||
}
|
||||
|
||||
// Выгрузка очереди одного тенанта в собственном scope: SetTenant → порции по 10 до ≤100/цикл.
|
||||
// Строки удаляются только после успешного TrainBatch (Ruling 6); сбой батча — порция остаётся,
|
||||
// цикл тенанта завершается (следующая попытка — следующий тик). Сбой хранилища тенанта не валит проход:
|
||||
// ошибка логируется, остальные тенанты обрабатываются; отмена (OCE) пробрасывается наверх.
|
||||
// tenant: Тенант реестра (Id в формате Guid; схема — tenant_<N>).
|
||||
// ct: Токен отмены прохода.
|
||||
private async Task FlushTenantAsync(TenantRecordDto tenant, CancellationToken ct)
|
||||
{
|
||||
await using AsyncServiceScope tenantScope = _scopeFactory.CreateAsyncScope();
|
||||
ITenantContext tenantContext = tenantScope.ServiceProvider.GetRequiredService<ITenantContext>();
|
||||
try
|
||||
{
|
||||
tenantContext.SetTenant(new TenantId(tenant.Id.ToString("N")));
|
||||
|
||||
// Resolve ПОСЛЕ SetTenant: IMlLearningStore строится от схемы текущего тенанта (как в цикле pump).
|
||||
IMlLearningStore learningStore = tenantScope.ServiceProvider.GetRequiredService<IMlLearningStore>();
|
||||
IMlTrainClient trainClient = tenantScope.ServiceProvider.GetRequiredService<IMlTrainClient>();
|
||||
|
||||
int total = 0;
|
||||
while (total < MaxPerCycle)
|
||||
{
|
||||
IReadOnlyList<MlOutboxEntryDto> rows = await learningStore.TakeOutboxBatchAsync(BatchSize, ct);
|
||||
if (rows.Count == 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await trainClient.TrainBatchAsync(rows, ct);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
// Недоступность/сбой ml-service: порция остаётся в очереди (python L75–77), следующая
|
||||
// попытка — на следующем тике; флашер не роняет проход цикла.
|
||||
_logger.LogWarning(
|
||||
exception,
|
||||
"Флашер ML-outbox: отправка {RowCount} строк тенанта {TenantId} не удалась — строки остались",
|
||||
rows.Count,
|
||||
tenant.Id);
|
||||
break;
|
||||
}
|
||||
|
||||
// Удаление только после успеха (python L78–79): отправленные строки больше не нужны.
|
||||
await learningStore.DeleteOutboxAsync(rows.Select(row => row.Id).ToList(), ct);
|
||||
total += rows.Count;
|
||||
}
|
||||
|
||||
if (total > 0)
|
||||
{
|
||||
_logger.LogInformation("Флашер ML-outbox: выгружено {RowCount} строк тенанта {TenantId}", total, tenant.Id);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
_logger.LogWarning(exception, "Флашер ML-outbox: выгрузка тенанта {TenantId} не удалась", tenant.Id);
|
||||
}
|
||||
finally
|
||||
{
|
||||
tenantContext.Reset();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,102 +1,96 @@
|
||||
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.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Hosted-шаг bootstrap оператора при старте (Ruling 1 этапа 7): env DEAL_OPERATOR_* → OperatorBootstrapService.EnsureOperatorAsync.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Регистрируется после <see cref="TenantBootstrapService"/> и работает тем же паттерном: OperatorBootstrapService —
|
||||
/// scoped (его IOperatorAuthStore живёт на scoped DealDbContext), поэтому резолвится в собственном scope из
|
||||
/// IServiceScopeFactory (как TenantService в TenantBootstrapService). Идемпотентен: существующего оператора
|
||||
/// не пересоздаёт и пароль не перезаписывает. В Development без env-кред используется dev-дефолт
|
||||
/// operator/operator (зеркало dev-seed admin/admin, логируется как dev-режим); в Production без env-кред
|
||||
/// шаг пропускается с warning — оператора заводит админ позже через env и рестарт хоста (кода регистрации
|
||||
/// оператора нет). Частичная конфигурация (задана ровно одна из DEAL_OPERATOR_LOGIN/DEAL_OPERATOR_PASSWORD)
|
||||
/// логируется warning — не молчаливый дефолт (решение ревью Task 2): в Development используются dev-дефолты,
|
||||
/// в Production шаг пропускается. Секреты (пароли) в логи не пишутся (правило этапов 1–6).
|
||||
/// </remarks>
|
||||
public sealed class OperatorBootstrapHostedService(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
IConfiguration configuration,
|
||||
IHostEnvironment environment,
|
||||
ILogger<OperatorBootstrapHostedService> logger) : IHostedService
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task StartAsync(CancellationToken ct)
|
||||
{
|
||||
await using var scope = scopeFactory.CreateAsyncScope();
|
||||
var operatorBootstrapService = scope.ServiceProvider.GetRequiredService<OperatorBootstrapService>();
|
||||
|
||||
var login = configuration[OperatorBootstrapService.LoginEnvKey];
|
||||
var password = configuration[OperatorBootstrapService.PasswordEnvKey];
|
||||
bool hasLogin = !string.IsNullOrWhiteSpace(login);
|
||||
bool hasPassword = !string.IsNullOrWhiteSpace(password);
|
||||
bool allowDevelopmentDefaults = environment.IsDevelopment();
|
||||
|
||||
// Предупреждения о пропуске/частичной конфигурации логируются ДО вызова шага — сам шаг
|
||||
// в этих случаях ничего не создаёт (EnsureOperatorAsync возвращает null, хранилище не тронуто).
|
||||
if (!hasLogin || !hasPassword)
|
||||
{
|
||||
if (hasLogin != hasPassword)
|
||||
{
|
||||
LogPartialConfigurationWarning(hasLogin, allowDevelopmentDefaults);
|
||||
}
|
||||
else if (!allowDevelopmentDefaults)
|
||||
{
|
||||
// Production без кред: пропуск с warning (Ruling 1) — кода регистрации оператора нет.
|
||||
logger.LogWarning(
|
||||
"DEAL_OPERATOR_LOGIN/DEAL_OPERATOR_PASSWORD не заданы (Production) — bootstrap оператора "
|
||||
+ "пропущен. Оператор заводится позже: задайте env DEAL_OPERATOR_* и перезапустите хост.");
|
||||
}
|
||||
else
|
||||
{
|
||||
// Development без кред: штатный dev-дефолт (зеркало dev-seed admin/admin).
|
||||
logger.LogInformation(
|
||||
"DEAL_OPERATOR_LOGIN/DEAL_OPERATOR_PASSWORD не заданы (Development) — используется "
|
||||
+ "dev-дефолт оператора {DefaultLogin}.",
|
||||
OperatorBootstrapService.DefaultOperatorLogin);
|
||||
}
|
||||
}
|
||||
|
||||
var ensuredLogin = await operatorBootstrapService.EnsureOperatorAsync(
|
||||
login, password, allowDevelopmentDefaults, ct);
|
||||
if (ensuredLogin is not null)
|
||||
{
|
||||
logger.LogInformation("Bootstrap оператора: оператор {Login} присутствует.", ensuredLogin);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task StopAsync(CancellationToken ct) => Task.CompletedTask;
|
||||
|
||||
// Логирует warning о неполной env-конфигурации (задана одна из двух переменных).
|
||||
// hasLogin: Задан ли логин (пароль при этом пуст).
|
||||
// allowDevelopmentDefaults: Разрешены ли dev-дефолты (Development).
|
||||
private void LogPartialConfigurationWarning(bool hasLogin, bool allowDevelopmentDefaults)
|
||||
{
|
||||
string missingEnv = hasLogin
|
||||
? OperatorBootstrapService.PasswordEnvKey
|
||||
: OperatorBootstrapService.LoginEnvKey;
|
||||
if (allowDevelopmentDefaults)
|
||||
{
|
||||
logger.LogWarning(
|
||||
"Env-конфигурация оператора неполна: не задан {MissingEnv}. В Development используются "
|
||||
+ "dev-дефолты оператора {DefaultLogin}.",
|
||||
missingEnv, OperatorBootstrapService.DefaultOperatorLogin);
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.LogWarning(
|
||||
"Env-конфигурация оператора неполна: не задан {MissingEnv}. Bootstrap оператора в Production "
|
||||
+ "пропущен — оператор заводится позже через env DEAL_OPERATOR_* и рестарт хоста.",
|
||||
missingEnv);
|
||||
}
|
||||
}
|
||||
}
|
||||
using Deal.Modules.Tenants.Application.Services;
|
||||
|
||||
namespace Deal.Api.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Hosted-шаг bootstrap оператора при старте (Ruling 1 этапа 7): env DEAL_OPERATOR_* → OperatorBootstrapService.EnsureOperatorAsync.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Регистрируется после <see cref="TenantBootstrapService"/> и работает тем же паттерном: OperatorBootstrapService —
|
||||
/// scoped (его IOperatorAuthStore живёт на scoped DealDbContext), поэтому резолвится в собственном scope из
|
||||
/// IServiceScopeFactory (как TenantService в TenantBootstrapService). Идемпотентен: существующего оператора
|
||||
/// не пересоздаёт и пароль не перезаписывает. В Development без env-кред используется dev-дефолт
|
||||
/// operator/operator (зеркало dev-seed admin/admin, логируется как dev-режим); в Production без env-кред
|
||||
/// шаг пропускается с warning — оператора заводит админ позже через env и рестарт хоста (кода регистрации
|
||||
/// оператора нет). Частичная конфигурация (задана ровно одна из DEAL_OPERATOR_LOGIN/DEAL_OPERATOR_PASSWORD)
|
||||
/// логируется warning — не молчаливый дефолт (решение ревью Task 2): в Development используются dev-дефолты,
|
||||
/// в Production шаг пропускается. Секреты (пароли) в логи не пишутся (правило этапов 1–6).
|
||||
/// </remarks>
|
||||
public sealed class OperatorBootstrapHostedService(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
IConfiguration configuration,
|
||||
IHostEnvironment environment,
|
||||
ILogger<OperatorBootstrapHostedService> logger) : IHostedService
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task StartAsync(CancellationToken ct)
|
||||
{
|
||||
await using var scope = scopeFactory.CreateAsyncScope();
|
||||
var operatorBootstrapService = scope.ServiceProvider.GetRequiredService<OperatorBootstrapService>();
|
||||
|
||||
var login = configuration[OperatorBootstrapService.LoginEnvKey];
|
||||
var password = configuration[OperatorBootstrapService.PasswordEnvKey];
|
||||
bool hasLogin = !string.IsNullOrWhiteSpace(login);
|
||||
bool hasPassword = !string.IsNullOrWhiteSpace(password);
|
||||
bool allowDevelopmentDefaults = environment.IsDevelopment();
|
||||
|
||||
// Предупреждения о пропуске/частичной конфигурации логируются ДО вызова шага — сам шаг
|
||||
// в этих случаях ничего не создаёт (EnsureOperatorAsync возвращает null, хранилище не тронуто).
|
||||
if (!hasLogin || !hasPassword)
|
||||
{
|
||||
if (hasLogin != hasPassword)
|
||||
{
|
||||
LogPartialConfigurationWarning(hasLogin, allowDevelopmentDefaults);
|
||||
}
|
||||
else if (!allowDevelopmentDefaults)
|
||||
{
|
||||
// Production без кред: пропуск с warning (Ruling 1) — кода регистрации оператора нет.
|
||||
logger.LogWarning(
|
||||
"DEAL_OPERATOR_LOGIN/DEAL_OPERATOR_PASSWORD не заданы (Production) — bootstrap оператора "
|
||||
+ "пропущен. Оператор заводится позже: задайте env DEAL_OPERATOR_* и перезапустите хост.");
|
||||
}
|
||||
else
|
||||
{
|
||||
// Development без кред: штатный dev-дефолт (зеркало dev-seed admin/admin).
|
||||
logger.LogInformation(
|
||||
"DEAL_OPERATOR_LOGIN/DEAL_OPERATOR_PASSWORD не заданы (Development) — используется "
|
||||
+ "dev-дефолт оператора {DefaultLogin}.",
|
||||
OperatorBootstrapService.DefaultOperatorLogin);
|
||||
}
|
||||
}
|
||||
|
||||
var ensuredLogin = await operatorBootstrapService.EnsureOperatorAsync(
|
||||
login, password, allowDevelopmentDefaults, ct);
|
||||
if (ensuredLogin is not null)
|
||||
{
|
||||
logger.LogInformation("Bootstrap оператора: оператор {Login} присутствует.", ensuredLogin);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task StopAsync(CancellationToken ct) => Task.CompletedTask;
|
||||
|
||||
// Логирует warning о неполной env-конфигурации (задана одна из двух переменных).
|
||||
// hasLogin: Задан ли логин (пароль при этом пуст).
|
||||
// allowDevelopmentDefaults: Разрешены ли dev-дефолты (Development).
|
||||
private void LogPartialConfigurationWarning(bool hasLogin, bool allowDevelopmentDefaults)
|
||||
{
|
||||
string missingEnv = hasLogin
|
||||
? OperatorBootstrapService.PasswordEnvKey
|
||||
: OperatorBootstrapService.LoginEnvKey;
|
||||
if (allowDevelopmentDefaults)
|
||||
{
|
||||
logger.LogWarning(
|
||||
"Env-конфигурация оператора неполна: не задан {MissingEnv}. В Development используются "
|
||||
+ "dev-дефолты оператора {DefaultLogin}.",
|
||||
missingEnv, OperatorBootstrapService.DefaultOperatorLogin);
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.LogWarning(
|
||||
"Env-конфигурация оператора неполна: не задан {MissingEnv}. Bootstrap оператора в Production "
|
||||
+ "пропущен — оператор заводится позже через env DEAL_OPERATOR_* и рестарт хоста.",
|
||||
missingEnv);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,252 +1,241 @@
|
||||
using Deal.Api.Events;
|
||||
using Deal.Modules.Kanban.Application.Abstractions;
|
||||
using Deal.Modules.Kanban.Application.Extensions;
|
||||
using Deal.Modules.Kanban.Application.Models;
|
||||
using Deal.Modules.Kanban.Application.Registrars;
|
||||
using Deal.Modules.Kanban.Application.Services;
|
||||
using Deal.Modules.Pipeline.Application.Abstractions;
|
||||
using Deal.Modules.Pipeline.Application.Models;
|
||||
using Deal.Modules.Pipeline.Application.Registrars;
|
||||
using Deal.Modules.Pipeline.Application.Services;
|
||||
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.SharedKernel.Tenants.Abstractions;
|
||||
using Deal.SharedKernel.Tenants.Models;
|
||||
using Deal.Api.Services;
|
||||
using Deal.Api.Dtos;
|
||||
|
||||
namespace Deal.Api.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Фоновый цикл правил хранения по всем тенантам (план Tasks 11–12, Rulings 3/8; аналог _storage_loop main.py L43–53).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Каждые 30 с (в прототипе — <c>asyncio.sleep(30)</c>, main.py L53) обходит ВСЕ тенанты системного
|
||||
/// реестра. Проход открывает собственный scope (реестр живёт в публичной схеме — ITenantRepository вне
|
||||
/// tenant-контекста, паттерн TenantBootstrapService), на каждый тенант — вложенный scope с
|
||||
/// ITenantContext.SetTenant (эталон SessionMiddleware/TenantBootstrapService) и StorageTickService.TickAsync,
|
||||
/// после чего — автоочистка отсева пайплайна (<see cref="PipelineProcessingService.PurgeExpiredAsync"/>, 3 суток;
|
||||
/// Task 11, Ruling 8/9: фоновый аналог тика AdminTickOrchestrator, tick_storage L485–493), SSE-тосты
|
||||
/// статистики в канал тенанта (StorageToastPublisher; без подписчиков — no-op, Ruling 5) и проверка
|
||||
/// наступивших напоминаний «Отложено» (CardsService.CheckDueRemindersAsync + SSE reminder_due, Task 12,
|
||||
/// Ruling 3/8 — фоновый аналог ветки AdminTickOrchestrator, check_reminders из _storage_loop main.py L49).
|
||||
/// Порядок тика тенанта 1:1 с _storage_loop main.py L47–53: тик → тосты → напоминания. Как в прототипе,
|
||||
/// первый проход выполняется сразу после старта (тик до первого sleep), далее — по таймеру. Параллельные
|
||||
/// проходы исключены in-flight guard (Interlocked, как RatesRefreshScheduler): если проход длится дольше
|
||||
/// периода, следующее срабатывание таймера пропускается. Ошибки логируются и наружу не выбрасываются
|
||||
/// (тик одного тенанта не валит проход); при остановке хоста таймер останавливается и текущий проход
|
||||
/// отменяется (graceful).
|
||||
/// </remarks>
|
||||
public sealed class StorageTickScheduler : IHostedService
|
||||
{
|
||||
// Период проходов цикла — 30 с, 1:1 с _storage_loop main.py L53 (asyncio.sleep(30)).
|
||||
private const int TickPeriodSeconds = 30;
|
||||
|
||||
// SSE-тип события «выстрелившего» напоминания «Отложено» (Ruling 8, api-map §2: {id,title,containerId}).
|
||||
private const string ReminderDueEventType = "reminder_due";
|
||||
|
||||
private static readonly TimeSpan TickPeriod = TimeSpan.FromSeconds(TickPeriodSeconds);
|
||||
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
private readonly StorageToastPublisher _toastPublisher;
|
||||
private readonly SseBroker _broker;
|
||||
private readonly ILogger<StorageTickScheduler> _logger;
|
||||
|
||||
// Отмена при остановке хоста: прерывает текущий проход (EF-запросы тика наблюдают токен).
|
||||
private readonly CancellationTokenSource _shutdownCts = new();
|
||||
|
||||
private Timer? _timer;
|
||||
private Task? _currentIteration;
|
||||
private int _iterationInProgress;
|
||||
|
||||
/// <summary>
|
||||
/// Создаёт планировщик фонового цикла правил хранения.
|
||||
/// </summary>
|
||||
/// <param name="scopeFactory">Фабрика scope: проход цикла и тик каждого тенанта — в собственных scope.</param>
|
||||
/// <param name="toastPublisher">Публикатор SSE-тостов статистики тика (общий с POST /api/admin/tick).</param>
|
||||
/// <param name="broker">SSE-брокер каналов тенантов: reminder_due «выстреливших» напоминаний в канал тенанта
|
||||
/// (как StorageToastPublisher; без подписчиков публикация — no-op, Ruling 5).</param>
|
||||
/// <param name="logger">Логгер ошибок цикла.</param>
|
||||
public StorageTickScheduler(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
StorageToastPublisher toastPublisher,
|
||||
SseBroker broker,
|
||||
ILogger<StorageTickScheduler> logger)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(scopeFactory);
|
||||
ArgumentNullException.ThrowIfNull(toastPublisher);
|
||||
ArgumentNullException.ThrowIfNull(broker);
|
||||
ArgumentNullException.ThrowIfNull(logger);
|
||||
_scopeFactory = scopeFactory;
|
||||
_toastPublisher = toastPublisher;
|
||||
_broker = broker;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task StartAsync(CancellationToken ct)
|
||||
{
|
||||
// Первый проход — сразу после старта (в прототипе тик выполняется до первого sleep); далее каждые 30 с.
|
||||
_timer = new Timer(
|
||||
static state => ((StorageTickScheduler)state!).RunIteration(),
|
||||
this,
|
||||
TimeSpan.Zero,
|
||||
TickPeriod);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task StopAsync(CancellationToken ct)
|
||||
{
|
||||
// Новые проходы не запускаем; текущий отменяем и ждём его завершения — не дольше лимита
|
||||
// остановки хоста (HostOptions.ShutdownTimeout).
|
||||
_timer?.Change(Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan);
|
||||
_timer?.Dispose();
|
||||
_timer = null;
|
||||
|
||||
_shutdownCts.Cancel();
|
||||
Task? iteration = Volatile.Read(ref _currentIteration);
|
||||
if (iteration is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
await iteration.WaitAsync(ct);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Лимит остановки истёк — хост продолжает остановку; ошибки прохода проглочены внутри
|
||||
// RunCycleAsync, незавершённый проход безопасно завершится на отменённом токене.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Один проход цикла: список тенантов реестра и тик каждого (no-op, если проход уже идёт).
|
||||
/// </summary>
|
||||
/// <remarks>Публичен как точка запуска прохода для unit-тестов (тайминги цикла не тестируются) и
|
||||
/// ручного вызова при отладке; таймер вызывает этот же метод. Ошибки и отмена токена наружу не
|
||||
/// выбрасываются: сбои логируются (цикл живёт), отмена по токену останова завершает проход штатно.</remarks>
|
||||
/// <param name="ct">Токен отмены прохода (в проде — токен остановки хоста).</param>
|
||||
/// <returns>Задача прохода (завершается без исключений).</returns>
|
||||
public Task RunCycleAsync(CancellationToken ct)
|
||||
{
|
||||
if (Interlocked.CompareExchange(ref _iterationInProgress, 1, 0) != 0)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
Task iteration = RunCycleCoreAsync(ct);
|
||||
Volatile.Write(ref _currentIteration, iteration);
|
||||
return iteration;
|
||||
}
|
||||
|
||||
// Запускает проход из callback таймера (guard — внутри RunCycleAsync).
|
||||
private void RunIteration()
|
||||
{
|
||||
_ = RunCycleAsync(_shutdownCts.Token);
|
||||
}
|
||||
|
||||
// Тело прохода: тик каждого тенанта реестра; guard сбрасывается в finally.
|
||||
// ct: Токен отмены (остановка хоста).
|
||||
private async Task RunCycleCoreAsync(CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using AsyncServiceScope cycleScope = _scopeFactory.CreateAsyncScope();
|
||||
ITenantRepository tenantRepository = cycleScope.ServiceProvider.GetRequiredService<ITenantRepository>();
|
||||
IReadOnlyList<TenantRecordDto> tenants = await tenantRepository.ListAsync(ct);
|
||||
|
||||
foreach (TenantRecordDto tenant in tenants)
|
||||
{
|
||||
await TickTenantAsync(tenant, ct);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Остановка хоста: проход прерван по токену — штатный выход, не ошибка.
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
// Сбой всего прохода (реестр недоступен и т.п.): логируем, цикл продолжит со следующего тика.
|
||||
_logger.LogError(exception, "Цикл правил хранения: проход по тенантам не удался");
|
||||
}
|
||||
finally
|
||||
{
|
||||
Interlocked.Exchange(ref _iterationInProgress, 0);
|
||||
}
|
||||
}
|
||||
|
||||
// Тик одного тенанта в собственном scope: SetTenant → Kanban-тик → purge отсева → SSE-тосты →
|
||||
// проверка напоминаний + SSE reminder_due; Reset в finally (1:1 с _storage_loop main.py L47–53: тик →
|
||||
// тосты → напоминания).
|
||||
// Контекст AsyncLocal сбрасывается в finally, чтобы не переживать scope тенанта (как
|
||||
// SessionMiddleware). Тик одного тенанта не валит проход: ошибка ветки/тенанта логируется, остальные
|
||||
// тенанты обрабатываются; отмена (OCE) пробрасывается наверх — проход завершается.
|
||||
// tenant: Тенант реестра (Id в формате Guid; схема — tenant_<N>).
|
||||
// ct: Токен отмены прохода.
|
||||
private async Task TickTenantAsync(TenantRecordDto tenant, CancellationToken ct)
|
||||
{
|
||||
await using AsyncServiceScope tenantScope = _scopeFactory.CreateAsyncScope();
|
||||
ITenantContext tenantContext = tenantScope.ServiceProvider.GetRequiredService<ITenantContext>();
|
||||
try
|
||||
{
|
||||
tenantContext.SetTenant(new TenantId(tenant.Id.ToString("N")));
|
||||
|
||||
// Resolve ПОСЛЕ SetTenant: TenantDbContext (и его адаптеры) строятся от схемы текущего тенанта.
|
||||
StorageTickService tickService = tenantScope.ServiceProvider.GetRequiredService<StorageTickService>();
|
||||
StorageTickStatsDto stats = await tickService.TickAsync(ct);
|
||||
|
||||
// Автоочистка отсева пайплайна: записи старше 3 суток — безвозвратно (Task 11, Ruling 8/9;
|
||||
// tick_storage L485–493). Счётчик вливается в storage.purgedRejected — как ручной тик
|
||||
// (AdminTickOrchestrator), тост «Отсев очищен: N записей (3 дн.)» публикуется этой же веткой.
|
||||
PipelineProcessingService processing = tenantScope.ServiceProvider.GetRequiredService<PipelineProcessingService>();
|
||||
int purgedRejected = await processing.PurgeExpiredAsync(ct);
|
||||
StorageTickStatsDto mergedStats = stats with { PurgedRejected = purgedRejected };
|
||||
|
||||
// Тосты — в канал тенанта (публикация из Api-слоя, Ruling 5; без подписчиков — no-op).
|
||||
_toastPublisher.PublishTickToasts(tenant.Id, mergedStats);
|
||||
|
||||
// Проверка наступивших напоминаний «Отложено» (Task 12, Ruling 3/8; proj_svc.check_reminders из
|
||||
// _storage_loop main.py L49): CheckDueRemindersAsync помечает due-строки hold-карточек fired и
|
||||
// возвращает их {id,title,containerId}. Сбой проверки НЕ роняет тик тенанта/проход: лог-предупреждение,
|
||||
// остальные тенанты обрабатываются (паттерн ветки AdminTickOrchestrator). SSE reminder_due по каждой
|
||||
// записи — в канал тенанта (Ruling 8: публикации только из Api; toast НЕ шлём, без подписчиков — no-op).
|
||||
IReadOnlyList<CardReminderDueDto> dueReminders;
|
||||
try
|
||||
{
|
||||
CardsService reminders = tenantScope.ServiceProvider.GetRequiredService<CardsService>();
|
||||
dueReminders = await reminders.CheckDueRemindersAsync(ct);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Остановка хоста — прерываем проход штатно (не «сбой проверки напоминаний»).
|
||||
throw;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
_logger.LogWarning(exception, "Цикл правил хранения: проверка напоминаний тенанта {TenantId} не удалась", tenant.Id);
|
||||
dueReminders = Array.Empty<CardReminderDueDto>();
|
||||
}
|
||||
|
||||
foreach (CardReminderDueDto due in dueReminders)
|
||||
{
|
||||
_broker.Publish(tenant.Id, ReminderDueEventType, due);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
_logger.LogWarning(exception, "Цикл правил хранения: тик тенанта {TenantId} не удался", tenant.Id);
|
||||
}
|
||||
finally
|
||||
{
|
||||
tenantContext.Reset();
|
||||
}
|
||||
}
|
||||
}
|
||||
using Deal.Api.Events;
|
||||
using Deal.Modules.Kanban.Application.Models;
|
||||
using Deal.Modules.Kanban.Application.Services;
|
||||
using Deal.Modules.Pipeline.Application.Services;
|
||||
using Deal.Modules.Tenants.Application.Abstractions;
|
||||
using Deal.Modules.Tenants.Application.Models;
|
||||
using Deal.SharedKernel.Tenants.Abstractions;
|
||||
using Deal.SharedKernel.Tenants.Models;
|
||||
|
||||
namespace Deal.Api.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Фоновый цикл правил хранения по всем тенантам (план Tasks 11–12, Rulings 3/8; аналог _storage_loop main.py L43–53).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Каждые 30 с (в прототипе — <c>asyncio.sleep(30)</c>, main.py L53) обходит ВСЕ тенанты системного
|
||||
/// реестра. Проход открывает собственный scope (реестр живёт в публичной схеме — ITenantRepository вне
|
||||
/// tenant-контекста, паттерн TenantBootstrapService), на каждый тенант — вложенный scope с
|
||||
/// ITenantContext.SetTenant (эталон SessionMiddleware/TenantBootstrapService) и StorageTickService.TickAsync,
|
||||
/// после чего — автоочистка отсева пайплайна (<see cref="PipelineProcessingService.PurgeExpiredAsync"/>, 3 суток;
|
||||
/// Task 11, Ruling 8/9: фоновый аналог тика AdminTickOrchestrator, tick_storage L485–493), SSE-тосты
|
||||
/// статистики в канал тенанта (StorageToastPublisher; без подписчиков — no-op, Ruling 5) и проверка
|
||||
/// наступивших напоминаний «Отложено» (CardsService.CheckDueRemindersAsync + SSE reminder_due, Task 12,
|
||||
/// Ruling 3/8 — фоновый аналог ветки AdminTickOrchestrator, check_reminders из _storage_loop main.py L49).
|
||||
/// Порядок тика тенанта 1:1 с _storage_loop main.py L47–53: тик → тосты → напоминания. Как в прототипе,
|
||||
/// первый проход выполняется сразу после старта (тик до первого sleep), далее — по таймеру. Параллельные
|
||||
/// проходы исключены in-flight guard (Interlocked, как RatesRefreshScheduler): если проход длится дольше
|
||||
/// периода, следующее срабатывание таймера пропускается. Ошибки логируются и наружу не выбрасываются
|
||||
/// (тик одного тенанта не валит проход); при остановке хоста таймер останавливается и текущий проход
|
||||
/// отменяется (graceful).
|
||||
/// </remarks>
|
||||
public sealed class StorageTickScheduler : IHostedService
|
||||
{
|
||||
// Период проходов цикла — 30 с, 1:1 с _storage_loop main.py L53 (asyncio.sleep(30)).
|
||||
private const int TickPeriodSeconds = 30;
|
||||
|
||||
// SSE-тип события «выстрелившего» напоминания «Отложено» (Ruling 8, api-map §2: {id,title,containerId}).
|
||||
private const string ReminderDueEventType = "reminder_due";
|
||||
|
||||
private static readonly TimeSpan TickPeriod = TimeSpan.FromSeconds(TickPeriodSeconds);
|
||||
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
private readonly StorageToastPublisher _toastPublisher;
|
||||
private readonly SseBroker _broker;
|
||||
private readonly ILogger<StorageTickScheduler> _logger;
|
||||
|
||||
// Отмена при остановке хоста: прерывает текущий проход (EF-запросы тика наблюдают токен).
|
||||
private readonly CancellationTokenSource _shutdownCts = new();
|
||||
|
||||
private Timer? _timer;
|
||||
private Task? _currentIteration;
|
||||
private int _iterationInProgress;
|
||||
|
||||
/// <summary>
|
||||
/// Создаёт планировщик фонового цикла правил хранения.
|
||||
/// </summary>
|
||||
/// <param name="scopeFactory">Фабрика scope: проход цикла и тик каждого тенанта — в собственных scope.</param>
|
||||
/// <param name="toastPublisher">Публикатор SSE-тостов статистики тика (общий с POST /api/admin/tick).</param>
|
||||
/// <param name="broker">SSE-брокер каналов тенантов: reminder_due «выстреливших» напоминаний в канал тенанта
|
||||
/// (как StorageToastPublisher; без подписчиков публикация — no-op, Ruling 5).</param>
|
||||
/// <param name="logger">Логгер ошибок цикла.</param>
|
||||
public StorageTickScheduler(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
StorageToastPublisher toastPublisher,
|
||||
SseBroker broker,
|
||||
ILogger<StorageTickScheduler> logger)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(scopeFactory);
|
||||
ArgumentNullException.ThrowIfNull(toastPublisher);
|
||||
ArgumentNullException.ThrowIfNull(broker);
|
||||
ArgumentNullException.ThrowIfNull(logger);
|
||||
_scopeFactory = scopeFactory;
|
||||
_toastPublisher = toastPublisher;
|
||||
_broker = broker;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task StartAsync(CancellationToken ct)
|
||||
{
|
||||
// Первый проход — сразу после старта (в прототипе тик выполняется до первого sleep); далее каждые 30 с.
|
||||
_timer = new Timer(
|
||||
static state => ((StorageTickScheduler)state!).RunIteration(),
|
||||
this,
|
||||
TimeSpan.Zero,
|
||||
TickPeriod);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task StopAsync(CancellationToken ct)
|
||||
{
|
||||
// Новые проходы не запускаем; текущий отменяем и ждём его завершения — не дольше лимита
|
||||
// остановки хоста (HostOptions.ShutdownTimeout).
|
||||
_timer?.Change(Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan);
|
||||
_timer?.Dispose();
|
||||
_timer = null;
|
||||
|
||||
_shutdownCts.Cancel();
|
||||
Task? iteration = Volatile.Read(ref _currentIteration);
|
||||
if (iteration is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
await iteration.WaitAsync(ct);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Лимит остановки истёк — хост продолжает остановку; ошибки прохода проглочены внутри
|
||||
// RunCycleAsync, незавершённый проход безопасно завершится на отменённом токене.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Один проход цикла: список тенантов реестра и тик каждого (no-op, если проход уже идёт).
|
||||
/// </summary>
|
||||
/// <remarks>Публичен как точка запуска прохода для unit-тестов (тайминги цикла не тестируются) и
|
||||
/// ручного вызова при отладке; таймер вызывает этот же метод. Ошибки и отмена токена наружу не
|
||||
/// выбрасываются: сбои логируются (цикл живёт), отмена по токену останова завершает проход штатно.</remarks>
|
||||
/// <param name="ct">Токен отмены прохода (в проде — токен остановки хоста).</param>
|
||||
/// <returns>Задача прохода (завершается без исключений).</returns>
|
||||
public Task RunCycleAsync(CancellationToken ct)
|
||||
{
|
||||
if (Interlocked.CompareExchange(ref _iterationInProgress, 1, 0) != 0)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
Task iteration = RunCycleCoreAsync(ct);
|
||||
Volatile.Write(ref _currentIteration, iteration);
|
||||
return iteration;
|
||||
}
|
||||
|
||||
// Запускает проход из callback таймера (guard — внутри RunCycleAsync).
|
||||
private void RunIteration()
|
||||
{
|
||||
_ = RunCycleAsync(_shutdownCts.Token);
|
||||
}
|
||||
|
||||
// Тело прохода: тик каждого тенанта реестра; guard сбрасывается в finally.
|
||||
// ct: Токен отмены (остановка хоста).
|
||||
private async Task RunCycleCoreAsync(CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using AsyncServiceScope cycleScope = _scopeFactory.CreateAsyncScope();
|
||||
ITenantRepository tenantRepository = cycleScope.ServiceProvider.GetRequiredService<ITenantRepository>();
|
||||
IReadOnlyList<TenantRecordDto> tenants = await tenantRepository.ListAsync(ct);
|
||||
|
||||
foreach (TenantRecordDto tenant in tenants)
|
||||
{
|
||||
await TickTenantAsync(tenant, ct);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Остановка хоста: проход прерван по токену — штатный выход, не ошибка.
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
// Сбой всего прохода (реестр недоступен и т.п.): логируем, цикл продолжит со следующего тика.
|
||||
_logger.LogError(exception, "Цикл правил хранения: проход по тенантам не удался");
|
||||
}
|
||||
finally
|
||||
{
|
||||
Interlocked.Exchange(ref _iterationInProgress, 0);
|
||||
}
|
||||
}
|
||||
|
||||
// Тик одного тенанта в собственном scope: SetTenant → Kanban-тик → purge отсева → SSE-тосты →
|
||||
// проверка напоминаний + SSE reminder_due; Reset в finally (1:1 с _storage_loop main.py L47–53: тик →
|
||||
// тосты → напоминания).
|
||||
// Контекст AsyncLocal сбрасывается в finally, чтобы не переживать scope тенанта (как
|
||||
// SessionMiddleware). Тик одного тенанта не валит проход: ошибка ветки/тенанта логируется, остальные
|
||||
// тенанты обрабатываются; отмена (OCE) пробрасывается наверх — проход завершается.
|
||||
// tenant: Тенант реестра (Id в формате Guid; схема — tenant_<N>).
|
||||
// ct: Токен отмены прохода.
|
||||
private async Task TickTenantAsync(TenantRecordDto tenant, CancellationToken ct)
|
||||
{
|
||||
await using AsyncServiceScope tenantScope = _scopeFactory.CreateAsyncScope();
|
||||
ITenantContext tenantContext = tenantScope.ServiceProvider.GetRequiredService<ITenantContext>();
|
||||
try
|
||||
{
|
||||
tenantContext.SetTenant(new TenantId(tenant.Id.ToString("N")));
|
||||
|
||||
// Resolve ПОСЛЕ SetTenant: TenantDbContext (и его адаптеры) строятся от схемы текущего тенанта.
|
||||
StorageTickService tickService = tenantScope.ServiceProvider.GetRequiredService<StorageTickService>();
|
||||
StorageTickStatsDto stats = await tickService.TickAsync(ct);
|
||||
|
||||
// Автоочистка отсева пайплайна: записи старше 3 суток — безвозвратно (Task 11, Ruling 8/9;
|
||||
// tick_storage L485–493). Счётчик вливается в storage.purgedRejected — как ручной тик
|
||||
// (AdminTickOrchestrator), тост «Отсев очищен: N записей (3 дн.)» публикуется этой же веткой.
|
||||
PipelineProcessingService processing = tenantScope.ServiceProvider.GetRequiredService<PipelineProcessingService>();
|
||||
int purgedRejected = await processing.PurgeExpiredAsync(ct);
|
||||
StorageTickStatsDto mergedStats = stats with { PurgedRejected = purgedRejected };
|
||||
|
||||
// Тосты — в канал тенанта (публикация из Api-слоя, Ruling 5; без подписчиков — no-op).
|
||||
_toastPublisher.PublishTickToasts(tenant.Id, mergedStats);
|
||||
|
||||
// Проверка наступивших напоминаний «Отложено» (Task 12, Ruling 3/8; proj_svc.check_reminders из
|
||||
// _storage_loop main.py L49): CheckDueRemindersAsync помечает due-строки hold-карточек fired и
|
||||
// возвращает их {id,title,containerId}. Сбой проверки НЕ роняет тик тенанта/проход: лог-предупреждение,
|
||||
// остальные тенанты обрабатываются (паттерн ветки AdminTickOrchestrator). SSE reminder_due по каждой
|
||||
// записи — в канал тенанта (Ruling 8: публикации только из Api; toast НЕ шлём, без подписчиков — no-op).
|
||||
IReadOnlyList<CardReminderDueDto> dueReminders;
|
||||
try
|
||||
{
|
||||
CardsService reminders = tenantScope.ServiceProvider.GetRequiredService<CardsService>();
|
||||
dueReminders = await reminders.CheckDueRemindersAsync(ct);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Остановка хоста — прерываем проход штатно (не «сбой проверки напоминаний»).
|
||||
throw;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
_logger.LogWarning(exception, "Цикл правил хранения: проверка напоминаний тенанта {TenantId} не удалась", tenant.Id);
|
||||
dueReminders = Array.Empty<CardReminderDueDto>();
|
||||
}
|
||||
|
||||
foreach (CardReminderDueDto due in dueReminders)
|
||||
{
|
||||
_broker.Publish(tenant.Id, ReminderDueEventType, due);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
_logger.LogWarning(exception, "Цикл правил хранения: тик тенанта {TenantId} не удался", tenant.Id);
|
||||
}
|
||||
finally
|
||||
{
|
||||
tenantContext.Reset();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,99 +1,95 @@
|
||||
using Deal.Infrastructure.Tenancy;
|
||||
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.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Bootstrap при старте (Ruling 8): дефолтный тенант + admin (dev-only), провижининг схем всех тенантов.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Идемпотентен. Dev-seed дефолтного тенанта (фиксированный id) и пользователя admin из env
|
||||
/// DEAL_BOOTSTRAP_LOGIN/DEAL_BOOTSTRAP_PASSWORD (по умолчанию admin/admin) выполняется только в Development
|
||||
/// или при DEAL_BOOTSTRAP_DEFAULT_TENANT=1 (Ruling 1 этапа 7); в Production тенантов заводит оператор.
|
||||
/// Провижининг схем ВСЕХ тенантов реестра выполняется всегда. Работает только через порты модулей и
|
||||
/// IPasswordHasher — без DealDbContext.
|
||||
/// </remarks>
|
||||
public sealed class TenantBootstrapService(IServiceScopeFactory scopeFactory) : IHostedService
|
||||
{
|
||||
// Имя дефолтного тенанта, если реестр пуст (Ruling 8).
|
||||
private const string DefaultTenantName = "Default";
|
||||
|
||||
// Фиксированный id дефолтного тенанта: схема tenant_000...0001 детерминирована (Ruling 8).
|
||||
private static readonly Guid DefaultTenantId = Guid.Parse("00000000-0000-0000-0000-000000000001");
|
||||
|
||||
private const string ActiveStatus = "active";
|
||||
private const string BootstrapLoginEnvKey = "DEAL_BOOTSTRAP_LOGIN";
|
||||
private const string BootstrapPasswordEnvKey = "DEAL_BOOTSTRAP_PASSWORD";
|
||||
private const string DefaultAdminLogin = "admin";
|
||||
private const string DefaultAdminPassword = "admin";
|
||||
|
||||
// Ключ env-флага принудительного dev-seed в не-Development окружениях (Ruling 1 этапа 7).
|
||||
private const string DefaultTenantBootstrapEnvKey = "DEAL_BOOTSTRAP_DEFAULT_TENANT";
|
||||
|
||||
// Значение «включено» env-флага (Ruling 1: =1).
|
||||
private const string DefaultTenantBootstrapEnabledValue = "1";
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task StartAsync(CancellationToken ct)
|
||||
{
|
||||
await using var scope = scopeFactory.CreateAsyncScope();
|
||||
var configuration = scope.ServiceProvider.GetRequiredService<IConfiguration>();
|
||||
var tenantService = scope.ServiceProvider.GetRequiredService<TenantService>();
|
||||
var tenantRepository = scope.ServiceProvider.GetRequiredService<ITenantRepository>();
|
||||
var authStore = scope.ServiceProvider.GetRequiredService<IAuthStore>();
|
||||
var passwordHasher = scope.ServiceProvider.GetRequiredService<IPasswordHasher>();
|
||||
|
||||
var login = NormalizeLogin(configuration[BootstrapLoginEnvKey] ?? DefaultAdminLogin);
|
||||
var password = configuration[BootstrapPasswordEnvKey] ?? DefaultAdminPassword;
|
||||
var environment = scope.ServiceProvider.GetRequiredService<IHostEnvironment>();
|
||||
|
||||
// Dev-seed дефолтного тенанта + admin становится dev-only (Ruling 1 этапа 7): создаётся только
|
||||
// в Development или при DEAL_BOOTSTRAP_DEFAULT_TENANT=1. В Production тенантов заводит оператор.
|
||||
var seedDefaultTenant = environment.IsDevelopment()
|
||||
|| configuration[DefaultTenantBootstrapEnvKey] == DefaultTenantBootstrapEnabledValue;
|
||||
if (seedDefaultTenant)
|
||||
{
|
||||
var tenants = await tenantRepository.ListAsync(ct);
|
||||
if (tenants.Count == 0)
|
||||
{
|
||||
// CreateTenantAsync сам провижинит схему дефолтного тенанта (TenantService → ITenantProvisioner).
|
||||
await tenantService.CreateTenantAsync(DefaultTenantName, DefaultTenantId, ct);
|
||||
}
|
||||
|
||||
// Идемпотентность: пользователь с таким логином уже есть — ничего не делаем.
|
||||
var existingUser = await authStore.FindUserByLoginAsync(login, ct);
|
||||
if (existingUser is null)
|
||||
{
|
||||
await authStore.CreateUserAsync(
|
||||
new StoredUserDto(
|
||||
Id: Guid.NewGuid(),
|
||||
Login: login,
|
||||
TenantId: DefaultTenantId,
|
||||
Status: ActiveStatus,
|
||||
PasswordHash: passwordHasher.Hash(password)),
|
||||
ct);
|
||||
}
|
||||
}
|
||||
|
||||
// Провижининг схем ВСЕХ тенантов реестра выполняется всегда (Ruling 1 этапа 7), включая только
|
||||
// что созданного в dev: пакетная идемпотентная миграция (CREATE SCHEMA IF NOT EXISTS + Migrate,
|
||||
// применяющий только неприменённые миграции) с ограниченным параллелизмом и логированием прогресса —
|
||||
// масштабируется на сотни/тысячи схем (этап 12, пакет C). Повторный старт безопасен.
|
||||
var tenantSchemaMigrationService = scope.ServiceProvider.GetRequiredService<TenantSchemaMigrationService>();
|
||||
await tenantSchemaMigrationService.MigrateAllAsync(ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task StopAsync(CancellationToken ct) => Task.CompletedTask;
|
||||
|
||||
// Нормализация логина: нижний регистр и обрезка пробелов (единая форма хранения).
|
||||
// login: Входной логин.
|
||||
// Возвращает: Нормализованный логин.
|
||||
private static string NormalizeLogin(string login) => login.Trim().ToLowerInvariant();
|
||||
}
|
||||
using Deal.Infrastructure.Tenancy;
|
||||
using Deal.Modules.Tenants.Application.Abstractions;
|
||||
using Deal.Modules.Tenants.Application.Models;
|
||||
using Deal.Modules.Tenants.Application.Services;
|
||||
|
||||
namespace Deal.Api.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Bootstrap при старте (Ruling 8): дефолтный тенант + admin (dev-only), провижининг схем всех тенантов.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Идемпотентен. Dev-seed дефолтного тенанта (фиксированный id) и пользователя admin из env
|
||||
/// DEAL_BOOTSTRAP_LOGIN/DEAL_BOOTSTRAP_PASSWORD (по умолчанию admin/admin) выполняется только в Development
|
||||
/// или при DEAL_BOOTSTRAP_DEFAULT_TENANT=1 (Ruling 1 этапа 7); в Production тенантов заводит оператор.
|
||||
/// Провижининг схем ВСЕХ тенантов реестра выполняется всегда. Работает только через порты модулей и
|
||||
/// IPasswordHasher — без DealDbContext.
|
||||
/// </remarks>
|
||||
public sealed class TenantBootstrapService(IServiceScopeFactory scopeFactory) : IHostedService
|
||||
{
|
||||
// Имя дефолтного тенанта, если реестр пуст (Ruling 8).
|
||||
private const string DefaultTenantName = "Default";
|
||||
|
||||
// Фиксированный id дефолтного тенанта: схема tenant_000...0001 детерминирована (Ruling 8).
|
||||
private static readonly Guid DefaultTenantId = Guid.Parse("00000000-0000-0000-0000-000000000001");
|
||||
|
||||
private const string ActiveStatus = "active";
|
||||
private const string BootstrapLoginEnvKey = "DEAL_BOOTSTRAP_LOGIN";
|
||||
private const string BootstrapPasswordEnvKey = "DEAL_BOOTSTRAP_PASSWORD";
|
||||
private const string DefaultAdminLogin = "admin";
|
||||
private const string DefaultAdminPassword = "admin";
|
||||
|
||||
// Ключ env-флага принудительного dev-seed в не-Development окружениях (Ruling 1 этапа 7).
|
||||
private const string DefaultTenantBootstrapEnvKey = "DEAL_BOOTSTRAP_DEFAULT_TENANT";
|
||||
|
||||
// Значение «включено» env-флага (Ruling 1: =1).
|
||||
private const string DefaultTenantBootstrapEnabledValue = "1";
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task StartAsync(CancellationToken ct)
|
||||
{
|
||||
await using var scope = scopeFactory.CreateAsyncScope();
|
||||
var configuration = scope.ServiceProvider.GetRequiredService<IConfiguration>();
|
||||
var tenantService = scope.ServiceProvider.GetRequiredService<TenantService>();
|
||||
var tenantRepository = scope.ServiceProvider.GetRequiredService<ITenantRepository>();
|
||||
var authStore = scope.ServiceProvider.GetRequiredService<IAuthStore>();
|
||||
var passwordHasher = scope.ServiceProvider.GetRequiredService<IPasswordHasher>();
|
||||
|
||||
var login = NormalizeLogin(configuration[BootstrapLoginEnvKey] ?? DefaultAdminLogin);
|
||||
var password = configuration[BootstrapPasswordEnvKey] ?? DefaultAdminPassword;
|
||||
var environment = scope.ServiceProvider.GetRequiredService<IHostEnvironment>();
|
||||
|
||||
// Dev-seed дефолтного тенанта + admin становится dev-only (Ruling 1 этапа 7): создаётся только
|
||||
// в Development или при DEAL_BOOTSTRAP_DEFAULT_TENANT=1. В Production тенантов заводит оператор.
|
||||
var seedDefaultTenant = environment.IsDevelopment()
|
||||
|| configuration[DefaultTenantBootstrapEnvKey] == DefaultTenantBootstrapEnabledValue;
|
||||
if (seedDefaultTenant)
|
||||
{
|
||||
var tenants = await tenantRepository.ListAsync(ct);
|
||||
if (tenants.Count == 0)
|
||||
{
|
||||
// CreateTenantAsync сам провижинит схему дефолтного тенанта (TenantService → ITenantProvisioner).
|
||||
await tenantService.CreateTenantAsync(DefaultTenantName, DefaultTenantId, ct);
|
||||
}
|
||||
|
||||
// Идемпотентность: пользователь с таким логином уже есть — ничего не делаем.
|
||||
var existingUser = await authStore.FindUserByLoginAsync(login, ct);
|
||||
if (existingUser is null)
|
||||
{
|
||||
await authStore.CreateUserAsync(
|
||||
new StoredUserDto(
|
||||
Id: Guid.NewGuid(),
|
||||
Login: login,
|
||||
TenantId: DefaultTenantId,
|
||||
Status: ActiveStatus,
|
||||
PasswordHash: passwordHasher.Hash(password)),
|
||||
ct);
|
||||
}
|
||||
}
|
||||
|
||||
// Провижининг схем ВСЕХ тенантов реестра выполняется всегда (Ruling 1 этапа 7), включая только
|
||||
// что созданного в dev: пакетная идемпотентная миграция (CREATE SCHEMA IF NOT EXISTS + Migrate,
|
||||
// применяющий только неприменённые миграции) с ограниченным параллелизмом и логированием прогресса —
|
||||
// масштабируется на сотни/тысячи схем (этап 12, пакет C). Повторный старт безопасен.
|
||||
var tenantSchemaMigrationService = scope.ServiceProvider.GetRequiredService<TenantSchemaMigrationService>();
|
||||
await tenantSchemaMigrationService.MigrateAllAsync(ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task StopAsync(CancellationToken ct) => Task.CompletedTask;
|
||||
|
||||
// Нормализация логина: нижний регистр и обрезка пробелов (единая форма хранения).
|
||||
// login: Входной логин.
|
||||
// Возвращает: Нормализованный логин.
|
||||
private static string NormalizeLogin(string login) => login.Trim().ToLowerInvariant();
|
||||
}
|
||||
|
||||
@@ -1,126 +1,123 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Serilog;
|
||||
using Serilog.Events;
|
||||
using Serilog.Formatting.Compact;
|
||||
using Deal.Api.Services;
|
||||
using Deal.Api.Dtos;
|
||||
|
||||
namespace Deal.Api.Logging;
|
||||
|
||||
// Serilog-конфигурация процесса core (Ruling 7/9, план Task 14).
|
||||
// Все четыре процесса (core + telegram/ai/ml-сервисы) логируют через Serilog: консоль — JSON в
|
||||
// prod-стиле (CompactJsonFormatter: одна JSON-строка на событие, поля @t/@mt/@l — парсинг
|
||||
// Loki/Promtail) либо текст в Development; плюс rolling-файл data/logs/deal-<процесс>.json
|
||||
// (под ContentRoot; у core каталог data смонтирован volume-ом, compose.prod). Уровень/каталог
|
||||
// переопределяются env: DEAL_LOG_LEVEL, DEAL_LOGS_DIR.
|
||||
// Конфигурация кодом, а не секцией appsettings: у трёх сервисов appsettings.json нет (весь конфиг —
|
||||
// env, Ruling 13), поэтому единый для четырёх хостов код-набор с env-переопределениями дешевле и не
|
||||
// расходится между процессами. Правило «секреты не логируются» (Ruling 13) соблюдается на уровне
|
||||
// сообщений (в лог-конфигурации секретов нет; запрос-логи Task 14 логируют метод/путь/статус без
|
||||
// query/заголовков/тел). OTel-метрики/трейсы и Prometheus в этапе 7 не добавляются (Ruling 7) —
|
||||
// стек: Serilog-логи → docker-логи → Promtail → Loki → Grafana.
|
||||
// Вызов — из Program.cs процесса (entry point): DealLogging.Configure(builder, "…") ДО
|
||||
// builder.Build(). Интеграционные тесты сервисов поднимают хост через *ServiceHost.Create
|
||||
// БЕЗ этого вызова (конфигурация логирования — забота production-точки входа), поэтому тесты не пишут
|
||||
// файлы-логи и не меняют своё логирование.
|
||||
internal static class DealLogging
|
||||
{
|
||||
// Env-ключ минимального уровня Serilog (Debug/Information/Warning/Error; дефолт Information).
|
||||
private const string MinimumLevelEnvKey = "DEAL_LOG_LEVEL";
|
||||
|
||||
// Env-ключ каталога rolling-файлов (дефолт data/logs под ContentRoot).
|
||||
private const string LogsDirectoryEnvKey = "DEAL_LOGS_DIR";
|
||||
|
||||
// Каталог логов по умолчанию (относительно ContentRoot): core — внутри volume /app/data.
|
||||
private const string DefaultLogsSubdirectory = "data/logs";
|
||||
|
||||
// Шаблон имени rolling-файла (Serilog добавляет дату перед расширением): deal-core-20260908.json.
|
||||
private const string LogFileNameTemplate = "deal-{0}-.json";
|
||||
|
||||
// Сколько rolling-файлов хранится (суток); старшие удаляются Serilog автоматически.
|
||||
private const int RetainedFileCount = 30;
|
||||
|
||||
// Текстовая разметка консоли в Development (цвета — дефолтной темой Serilog).
|
||||
private const string DevelopmentConsoleTemplate =
|
||||
"{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {Message:lj}{NewLine}{Exception}";
|
||||
|
||||
// Категория EF Core: команды SQL логируются на Warning+ (шум запросов не попадает в Loki).
|
||||
private const string EntityFrameworkCoreCategory = "Microsoft.EntityFrameworkCore";
|
||||
|
||||
// Категория Grpc.AspNetCore: не ниже Information (внутренние Debug-события вызовов не дублируют access-лог).
|
||||
private const string GrpcCategory = "Grpc";
|
||||
|
||||
// Дефолтный уровень при пустом/невалидном env DEAL_LOG_LEVEL.
|
||||
private const LogEventLevel DefaultMinimumLevel = LogEventLevel.Information;
|
||||
|
||||
/// <summary>
|
||||
/// Подключает Serilog к хосту (builder.Host.UseSerilog). Регистрация отложенная: конфигурация
|
||||
/// логгера применяется при <c>builder.Build()</c>, когда среда/конфигурация (env) уже собраны.
|
||||
/// </summary>
|
||||
/// <param name="builder">Билдер WebApplication процесса (до Build).</param>
|
||||
/// <param name="processName">Имя процесса для имени файла-лога (core/telegram/ai/ml).</param>
|
||||
public static void Configure(WebApplicationBuilder builder, string processName)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(builder);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(processName);
|
||||
builder.Host.UseSerilog((context, loggerConfiguration) =>
|
||||
Apply(loggerConfiguration, context.HostingEnvironment, context.Configuration, processName));
|
||||
}
|
||||
|
||||
// Собирает LoggerConfiguration процесса: уровень/фильтры, rolling-файл, консоль.
|
||||
// loggerConfiguration: Конфигурация Serilog (до CreateLogger).
|
||||
// environment: Окружение хоста (Development — текстовая консоль).
|
||||
// configuration: Конфигурация хоста (env DEAL_LOG_*).
|
||||
// processName: Имя процесса (суффикс имени rolling-файла).
|
||||
private static void Apply(
|
||||
LoggerConfiguration loggerConfiguration,
|
||||
IHostEnvironment environment,
|
||||
IConfiguration configuration,
|
||||
string processName)
|
||||
{
|
||||
loggerConfiguration
|
||||
.MinimumLevel.Is(ParseMinimumLevel(configuration[MinimumLevelEnvKey]))
|
||||
.MinimumLevel.Override(EntityFrameworkCoreCategory, LogEventLevel.Warning)
|
||||
.MinimumLevel.Override(GrpcCategory, LogEventLevel.Information)
|
||||
.Enrich.FromLogContext();
|
||||
|
||||
string logsDirectory = ResolveLogsDirectory(environment.ContentRootPath, configuration[LogsDirectoryEnvKey]);
|
||||
Directory.CreateDirectory(logsDirectory);
|
||||
string logFilePath = Path.Combine(
|
||||
logsDirectory,
|
||||
string.Format(LogFileNameTemplate, processName));
|
||||
loggerConfiguration.WriteTo.File(
|
||||
new CompactJsonFormatter(),
|
||||
logFilePath,
|
||||
rollingInterval: RollingInterval.Day,
|
||||
retainedFileCountLimit: RetainedFileCount);
|
||||
|
||||
if (environment.IsDevelopment())
|
||||
{
|
||||
// Dev: читаемый текст в консоли (Ruling 7: «dev можно текст»); файл — всегда JSON.
|
||||
loggerConfiguration.WriteTo.Console(outputTemplate: DevelopmentConsoleTemplate);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Prod-стиль: одна JSON-строка на событие — docker-логи собирает Promtail (Ruling 7/9).
|
||||
loggerConfiguration.WriteTo.Console(new CompactJsonFormatter());
|
||||
}
|
||||
}
|
||||
|
||||
// Каталог rolling-файлов: env DEAL_LOGS_DIR либо data/logs под ContentRoot процесса.
|
||||
// contentRootPath: ContentRoot хоста (/app в контейнере).
|
||||
// configuredDirectory: Значение env DEAL_LOGS_DIR (null/пусто — дефолт).
|
||||
// Возвращает: Абсолютный путь каталога логов.
|
||||
private static string ResolveLogsDirectory(string contentRootPath, string? configuredDirectory)
|
||||
=> string.IsNullOrWhiteSpace(configuredDirectory)
|
||||
? Path.Combine(contentRootPath, DefaultLogsSubdirectory)
|
||||
: configuredDirectory.Trim();
|
||||
|
||||
// Разбирает env DEAL_LOG_LEVEL; пустое/невалидное значение — DefaultMinimumLevel.
|
||||
// rawValue: Сырое значение env.
|
||||
// Возвращает: Уровень Serilog.
|
||||
private static LogEventLevel ParseMinimumLevel(string? rawValue)
|
||||
=> Enum.TryParse(rawValue, ignoreCase: true, out LogEventLevel parsedLevel)
|
||||
? parsedLevel
|
||||
: DefaultMinimumLevel;
|
||||
}
|
||||
using Serilog;
|
||||
using Serilog.Events;
|
||||
using Serilog.Formatting.Compact;
|
||||
|
||||
namespace Deal.Api.Logging;
|
||||
|
||||
// Serilog-конфигурация процесса core (Ruling 7/9, план Task 14).
|
||||
// Все четыре процесса (core + telegram/ai/ml-сервисы) логируют через Serilog: консоль — JSON в
|
||||
// prod-стиле (CompactJsonFormatter: одна JSON-строка на событие, поля @t/@mt/@l — парсинг
|
||||
// Loki/Promtail) либо текст в Development; плюс rolling-файл data/logs/deal-<процесс>.json
|
||||
// (под ContentRoot; у core каталог data смонтирован volume-ом, compose.prod). Уровень/каталог
|
||||
// переопределяются env: DEAL_LOG_LEVEL, DEAL_LOGS_DIR.
|
||||
// Конфигурация кодом, а не секцией appsettings: у трёх сервисов appsettings.json нет (весь конфиг —
|
||||
// env, Ruling 13), поэтому единый для четырёх хостов код-набор с env-переопределениями дешевле и не
|
||||
// расходится между процессами. Правило «секреты не логируются» (Ruling 13) соблюдается на уровне
|
||||
// сообщений (в лог-конфигурации секретов нет; запрос-логи Task 14 логируют метод/путь/статус без
|
||||
// query/заголовков/тел). OTel-метрики/трейсы и Prometheus в этапе 7 не добавляются (Ruling 7) —
|
||||
// стек: Serilog-логи → docker-логи → Promtail → Loki → Grafana.
|
||||
// Вызов — из Program.cs процесса (entry point): DealLogging.Configure(builder, "…") ДО
|
||||
// builder.Build(). Интеграционные тесты сервисов поднимают хост через *ServiceHost.Create
|
||||
// БЕЗ этого вызова (конфигурация логирования — забота production-точки входа), поэтому тесты не пишут
|
||||
// файлы-логи и не меняют своё логирование.
|
||||
internal static class DealLogging
|
||||
{
|
||||
// Env-ключ минимального уровня Serilog (Debug/Information/Warning/Error; дефолт Information).
|
||||
private const string MinimumLevelEnvKey = "DEAL_LOG_LEVEL";
|
||||
|
||||
// Env-ключ каталога rolling-файлов (дефолт data/logs под ContentRoot).
|
||||
private const string LogsDirectoryEnvKey = "DEAL_LOGS_DIR";
|
||||
|
||||
// Каталог логов по умолчанию (относительно ContentRoot): core — внутри volume /app/data.
|
||||
private const string DefaultLogsSubdirectory = "data/logs";
|
||||
|
||||
// Шаблон имени rolling-файла (Serilog добавляет дату перед расширением): deal-core-20260908.json.
|
||||
private const string LogFileNameTemplate = "deal-{0}-.json";
|
||||
|
||||
// Сколько rolling-файлов хранится (суток); старшие удаляются Serilog автоматически.
|
||||
private const int RetainedFileCount = 30;
|
||||
|
||||
// Текстовая разметка консоли в Development (цвета — дефолтной темой Serilog).
|
||||
private const string DevelopmentConsoleTemplate =
|
||||
"{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {Message:lj}{NewLine}{Exception}";
|
||||
|
||||
// Категория EF Core: команды SQL логируются на Warning+ (шум запросов не попадает в Loki).
|
||||
private const string EntityFrameworkCoreCategory = "Microsoft.EntityFrameworkCore";
|
||||
|
||||
// Категория Grpc.AspNetCore: не ниже Information (внутренние Debug-события вызовов не дублируют access-лог).
|
||||
private const string GrpcCategory = "Grpc";
|
||||
|
||||
// Дефолтный уровень при пустом/невалидном env DEAL_LOG_LEVEL.
|
||||
private const LogEventLevel DefaultMinimumLevel = LogEventLevel.Information;
|
||||
|
||||
/// <summary>
|
||||
/// Подключает Serilog к хосту (builder.Host.UseSerilog). Регистрация отложенная: конфигурация
|
||||
/// логгера применяется при <c>builder.Build()</c>, когда среда/конфигурация (env) уже собраны.
|
||||
/// </summary>
|
||||
/// <param name="builder">Билдер WebApplication процесса (до Build).</param>
|
||||
/// <param name="processName">Имя процесса для имени файла-лога (core/telegram/ai/ml).</param>
|
||||
public static void Configure(WebApplicationBuilder builder, string processName)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(builder);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(processName);
|
||||
builder.Host.UseSerilog((context, loggerConfiguration) =>
|
||||
Apply(loggerConfiguration, context.HostingEnvironment, context.Configuration, processName));
|
||||
}
|
||||
|
||||
// Собирает LoggerConfiguration процесса: уровень/фильтры, rolling-файл, консоль.
|
||||
// loggerConfiguration: Конфигурация Serilog (до CreateLogger).
|
||||
// environment: Окружение хоста (Development — текстовая консоль).
|
||||
// configuration: Конфигурация хоста (env DEAL_LOG_*).
|
||||
// processName: Имя процесса (суффикс имени rolling-файла).
|
||||
private static void Apply(
|
||||
LoggerConfiguration loggerConfiguration,
|
||||
IHostEnvironment environment,
|
||||
IConfiguration configuration,
|
||||
string processName)
|
||||
{
|
||||
loggerConfiguration
|
||||
.MinimumLevel.Is(ParseMinimumLevel(configuration[MinimumLevelEnvKey]))
|
||||
.MinimumLevel.Override(EntityFrameworkCoreCategory, LogEventLevel.Warning)
|
||||
.MinimumLevel.Override(GrpcCategory, LogEventLevel.Information)
|
||||
.Enrich.FromLogContext();
|
||||
|
||||
string logsDirectory = ResolveLogsDirectory(environment.ContentRootPath, configuration[LogsDirectoryEnvKey]);
|
||||
Directory.CreateDirectory(logsDirectory);
|
||||
string logFilePath = Path.Combine(
|
||||
logsDirectory,
|
||||
string.Format(LogFileNameTemplate, processName));
|
||||
loggerConfiguration.WriteTo.File(
|
||||
new CompactJsonFormatter(),
|
||||
logFilePath,
|
||||
rollingInterval: RollingInterval.Day,
|
||||
retainedFileCountLimit: RetainedFileCount);
|
||||
|
||||
if (environment.IsDevelopment())
|
||||
{
|
||||
// Dev: читаемый текст в консоли (Ruling 7: «dev можно текст»); файл — всегда JSON.
|
||||
loggerConfiguration.WriteTo.Console(outputTemplate: DevelopmentConsoleTemplate);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Prod-стиль: одна JSON-строка на событие — docker-логи собирает Promtail (Ruling 7/9).
|
||||
loggerConfiguration.WriteTo.Console(new CompactJsonFormatter());
|
||||
}
|
||||
}
|
||||
|
||||
// Каталог rolling-файлов: env DEAL_LOGS_DIR либо data/logs под ContentRoot процесса.
|
||||
// contentRootPath: ContentRoot хоста (/app в контейнере).
|
||||
// configuredDirectory: Значение env DEAL_LOGS_DIR (null/пусто — дефолт).
|
||||
// Возвращает: Абсолютный путь каталога логов.
|
||||
private static string ResolveLogsDirectory(string contentRootPath, string? configuredDirectory)
|
||||
=> string.IsNullOrWhiteSpace(configuredDirectory)
|
||||
? Path.Combine(contentRootPath, DefaultLogsSubdirectory)
|
||||
: configuredDirectory.Trim();
|
||||
|
||||
// Разбирает env DEAL_LOG_LEVEL; пустое/невалидное значение — DefaultMinimumLevel.
|
||||
// rawValue: Сырое значение env.
|
||||
// Возвращает: Уровень Serilog.
|
||||
private static LogEventLevel ParseMinimumLevel(string? rawValue)
|
||||
=> Enum.TryParse(rawValue, ignoreCase: true, out LogEventLevel parsedLevel)
|
||||
? parsedLevel
|
||||
: DefaultMinimumLevel;
|
||||
}
|
||||
|
||||
@@ -1,100 +1,98 @@
|
||||
using System.Diagnostics;
|
||||
using Deal.Api.Services;
|
||||
using Deal.Api.Dtos;
|
||||
|
||||
namespace Deal.Api.Middleware;
|
||||
|
||||
/// <summary>
|
||||
/// Access-лог HTTP-запросов core (Ruling 7, план Task 14): каждый запрос — одна структурированная
|
||||
/// строка «метод путь → статус за N мс» (Serilog JSON). Логируются метод и путь БЕЗ query-строки,
|
||||
/// заголовков и тела — секреты/токены в query не попадают в логи (Ruling 13); клиентский IP не
|
||||
/// логируется (аудит-IP живёт в AuditLog, операторский контур).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Регистрируется самым первым в HTTP-конвейере (после UseForwardedHeaders): видит результат всех
|
||||
/// слоёв ниже (CORS/session/rate-limiter/OriginGuard/эндпоинты) и полную длительность запроса.
|
||||
/// Запросы gRPC-ингресса (Content-Type application/grpc) пропускаются — их содержательный access-лог
|
||||
/// пишет интерцептор RpcCallLoggingInterceptor (HTTP-статус gRPC-вызовов всегда 200, полезен только
|
||||
/// gRPC-статус). SSE-подписка /api/events логируется по завершении потока (длительность = время жизни
|
||||
/// соединения). Отмена запроса (клиент закрыл SSE/дисконнект) отдельной строкой не пишется.
|
||||
/// </remarks>
|
||||
public sealed class HttpAccessLogMiddleware
|
||||
{
|
||||
// Content-Type gRPC-запросов (HTTP/2) — их логирует RpcCallLoggingInterceptor.
|
||||
private const string GrpcContentType = "application/grpc";
|
||||
|
||||
private readonly RequestDelegate _next;
|
||||
private readonly ILogger<HttpAccessLogMiddleware> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Создаёт middleware access-лога HTTP-запросов.
|
||||
/// </summary>
|
||||
/// <param name="next">Следующий обработчик конвейера.</param>
|
||||
/// <param name="logger">Логгер (Serilog, Ruling 7).</param>
|
||||
public HttpAccessLogMiddleware(RequestDelegate next, ILogger<HttpAccessLogMiddleware> logger)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(next);
|
||||
ArgumentNullException.ThrowIfNull(logger);
|
||||
_next = next;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Обрабатывает запрос: пропускает gRPC-ингресс, остальные логирует по завершении.
|
||||
/// </summary>
|
||||
/// <param name="context">Контекст запроса.</param>
|
||||
public async Task InvokeAsync(HttpContext context)
|
||||
{
|
||||
if (context.Request.ContentType?.StartsWith(GrpcContentType, StringComparison.OrdinalIgnoreCase) == true)
|
||||
{
|
||||
await _next(context);
|
||||
return;
|
||||
}
|
||||
|
||||
long startedAt = Stopwatch.GetTimestamp();
|
||||
try
|
||||
{
|
||||
await _next(context);
|
||||
LogCall(context, startedAt, null);
|
||||
}
|
||||
catch (Exception exception) when (exception is not OperationCanceledException)
|
||||
{
|
||||
// Необработанное исключение: фиксируем строку 500 (статус ещё не выставлен) и пробрасываем —
|
||||
// обработку ошибки продолжает хостинг (DeveloperExceptionPage в dev/закрытие соединения в prod).
|
||||
LogCall(context, startedAt, exception);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
// Пишет одну строку access-лога запроса (успех либо исключение).
|
||||
// context: Контекст запроса (метод/путь/статус ответа).
|
||||
// startedAt: Метка времени старта запроса (Stopwatch.GetTimestamp).
|
||||
// exception: Необработанное исключение (null — запрос завершился штатно).
|
||||
private void LogCall(
|
||||
HttpContext context,
|
||||
long startedAt,
|
||||
Exception? exception)
|
||||
{
|
||||
long elapsedMs = (long)Stopwatch.GetElapsedTime(startedAt).TotalMilliseconds;
|
||||
string method = context.Request.Method;
|
||||
string path = context.Request.Path.Value ?? "/";
|
||||
if (exception is null)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"HTTP {Method} {Path} -> {StatusCode} за {DurationMs} мс",
|
||||
method,
|
||||
path,
|
||||
context.Response.StatusCode,
|
||||
elapsedMs);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogError(
|
||||
exception,
|
||||
"HTTP {Method} {Path} -> исключение за {DurationMs} мс",
|
||||
method,
|
||||
path,
|
||||
elapsedMs);
|
||||
}
|
||||
}
|
||||
}
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace Deal.Api.Middleware;
|
||||
|
||||
/// <summary>
|
||||
/// Access-лог HTTP-запросов core (Ruling 7, план Task 14): каждый запрос — одна структурированная
|
||||
/// строка «метод путь → статус за N мс» (Serilog JSON). Логируются метод и путь БЕЗ query-строки,
|
||||
/// заголовков и тела — секреты/токены в query не попадают в логи (Ruling 13); клиентский IP не
|
||||
/// логируется (аудит-IP живёт в AuditLog, операторский контур).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Регистрируется самым первым в HTTP-конвейере (после UseForwardedHeaders): видит результат всех
|
||||
/// слоёв ниже (CORS/session/rate-limiter/OriginGuard/эндпоинты) и полную длительность запроса.
|
||||
/// Запросы gRPC-ингресса (Content-Type application/grpc) пропускаются — их содержательный access-лог
|
||||
/// пишет интерцептор RpcCallLoggingInterceptor (HTTP-статус gRPC-вызовов всегда 200, полезен только
|
||||
/// gRPC-статус). SSE-подписка /api/events логируется по завершении потока (длительность = время жизни
|
||||
/// соединения). Отмена запроса (клиент закрыл SSE/дисконнект) отдельной строкой не пишется.
|
||||
/// </remarks>
|
||||
public sealed class HttpAccessLogMiddleware
|
||||
{
|
||||
// Content-Type gRPC-запросов (HTTP/2) — их логирует RpcCallLoggingInterceptor.
|
||||
private const string GrpcContentType = "application/grpc";
|
||||
|
||||
private readonly RequestDelegate _next;
|
||||
private readonly ILogger<HttpAccessLogMiddleware> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Создаёт middleware access-лога HTTP-запросов.
|
||||
/// </summary>
|
||||
/// <param name="next">Следующий обработчик конвейера.</param>
|
||||
/// <param name="logger">Логгер (Serilog, Ruling 7).</param>
|
||||
public HttpAccessLogMiddleware(RequestDelegate next, ILogger<HttpAccessLogMiddleware> logger)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(next);
|
||||
ArgumentNullException.ThrowIfNull(logger);
|
||||
_next = next;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Обрабатывает запрос: пропускает gRPC-ингресс, остальные логирует по завершении.
|
||||
/// </summary>
|
||||
/// <param name="context">Контекст запроса.</param>
|
||||
public async Task InvokeAsync(HttpContext context)
|
||||
{
|
||||
if (context.Request.ContentType?.StartsWith(GrpcContentType, StringComparison.OrdinalIgnoreCase) == true)
|
||||
{
|
||||
await _next(context);
|
||||
return;
|
||||
}
|
||||
|
||||
long startedAt = Stopwatch.GetTimestamp();
|
||||
try
|
||||
{
|
||||
await _next(context);
|
||||
LogCall(context, startedAt, null);
|
||||
}
|
||||
catch (Exception exception) when (exception is not OperationCanceledException)
|
||||
{
|
||||
// Необработанное исключение: фиксируем строку 500 (статус ещё не выставлен) и пробрасываем —
|
||||
// обработку ошибки продолжает хостинг (DeveloperExceptionPage в dev/закрытие соединения в prod).
|
||||
LogCall(context, startedAt, exception);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
// Пишет одну строку access-лога запроса (успех либо исключение).
|
||||
// context: Контекст запроса (метод/путь/статус ответа).
|
||||
// startedAt: Метка времени старта запроса (Stopwatch.GetTimestamp).
|
||||
// exception: Необработанное исключение (null — запрос завершился штатно).
|
||||
private void LogCall(
|
||||
HttpContext context,
|
||||
long startedAt,
|
||||
Exception? exception)
|
||||
{
|
||||
long elapsedMs = (long)Stopwatch.GetElapsedTime(startedAt).TotalMilliseconds;
|
||||
string method = context.Request.Method;
|
||||
string path = context.Request.Path.Value ?? "/";
|
||||
if (exception is null)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"HTTP {Method} {Path} -> {StatusCode} за {DurationMs} мс",
|
||||
method,
|
||||
path,
|
||||
context.Response.StatusCode,
|
||||
elapsedMs);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogError(
|
||||
exception,
|
||||
"HTTP {Method} {Path} -> исключение за {DurationMs} мс",
|
||||
method,
|
||||
path,
|
||||
elapsedMs);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,62 +1,55 @@
|
||||
using Deal.Api.Configuration;
|
||||
using Deal.Api.Extensions;
|
||||
using Deal.Api.Models;
|
||||
using Deal.Api.Services;
|
||||
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 Microsoft.Extensions.Options;
|
||||
using Deal.Api.Dtos;
|
||||
// Имя конфигурационного типа совпадает с Microsoft.AspNetCore.Http.CookieOptions — фиксируем алиасом.
|
||||
using OperatorCookieOptions = Deal.Api.Configuration.OperatorCookieOptions;
|
||||
|
||||
namespace Deal.Api.Middleware;
|
||||
|
||||
/// <summary>
|
||||
/// Middleware операторской сессии: читает httpOnly-куку deal_operator_session, разрешает сессию через
|
||||
/// OperatorAuthService и наполняет <c>HttpContext.Items["CurrentOperator"]</c> (Ruling 1 этапа 7).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Зеркало <see cref="SessionMiddleware"/> для операторов: отдельная кука и отдельный ключ Items —
|
||||
/// операторская сессия не может подменить тенантную и наоборот (разные имена куки, разные middleware).
|
||||
/// Tenant-контекст (ITenantContext/CurrentUser) middleware не трогает — оператор не принадлежит тенанту.
|
||||
/// Middleware НЕ отвечает 401 сама (pass-through): ручки /api/operator/*, требующие оператора, проверяют
|
||||
/// <c>GetCurrentOperator()</c> и выставляют 401. OperatorAuthService — scoped, поэтому на запрос
|
||||
/// создаётся собственный scope через RequestServices (как в SessionMiddleware).
|
||||
/// </remarks>
|
||||
public sealed class OperatorSessionMiddleware
|
||||
{
|
||||
private readonly RequestDelegate _next;
|
||||
private readonly IOptionsMonitor<OperatorCookieOptions> _cookieOptions;
|
||||
|
||||
public OperatorSessionMiddleware(RequestDelegate next, IOptionsMonitor<OperatorCookieOptions> cookieOptions)
|
||||
{
|
||||
_next = next;
|
||||
_cookieOptions = cookieOptions;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Обрабатывает запрос: разрешает операторскую сессию по куке и наполняет контекст.
|
||||
/// </summary>
|
||||
/// <param name="context">Контекст запроса.</param>
|
||||
public async Task InvokeAsync(HttpContext context)
|
||||
{
|
||||
var cookieName = _cookieOptions.CurrentValue.Name;
|
||||
if (context.Request.Cookies.TryGetValue(cookieName, out var rawToken)
|
||||
&& !string.IsNullOrWhiteSpace(rawToken))
|
||||
{
|
||||
// OperatorAuthService scoped: создаём scope на запрос через RequestServices.
|
||||
await using var scope = context.RequestServices.CreateAsyncScope();
|
||||
var operatorAuthService = scope.ServiceProvider.GetRequiredService<OperatorAuthService>();
|
||||
var identity = await operatorAuthService.ResolveSessionAsync(rawToken, context.RequestAborted);
|
||||
if (identity is not null)
|
||||
{
|
||||
context.SetCurrentOperator(new CurrentOperator(identity.Id, identity.Login, identity.Status));
|
||||
}
|
||||
}
|
||||
|
||||
await _next(context);
|
||||
}
|
||||
}
|
||||
using Deal.Api.Extensions;
|
||||
using Deal.Api.Models;
|
||||
using Deal.Modules.Tenants.Application.Services;
|
||||
using Microsoft.Extensions.Options;
|
||||
// Имя конфигурационного типа совпадает с Microsoft.AspNetCore.Http.CookieOptions — фиксируем алиасом.
|
||||
using OperatorCookieOptions = Deal.Api.Configuration.OperatorCookieOptions;
|
||||
|
||||
namespace Deal.Api.Middleware;
|
||||
|
||||
/// <summary>
|
||||
/// Middleware операторской сессии: читает httpOnly-куку deal_operator_session, разрешает сессию через
|
||||
/// OperatorAuthService и наполняет <c>HttpContext.Items["CurrentOperator"]</c> (Ruling 1 этапа 7).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Зеркало <see cref="SessionMiddleware"/> для операторов: отдельная кука и отдельный ключ Items —
|
||||
/// операторская сессия не может подменить тенантную и наоборот (разные имена куки, разные middleware).
|
||||
/// Tenant-контекст (ITenantContext/CurrentUser) middleware не трогает — оператор не принадлежит тенанту.
|
||||
/// Middleware НЕ отвечает 401 сама (pass-through): ручки /api/operator/*, требующие оператора, проверяют
|
||||
/// <c>GetCurrentOperator()</c> и выставляют 401. OperatorAuthService — scoped, поэтому на запрос
|
||||
/// создаётся собственный scope через RequestServices (как в SessionMiddleware).
|
||||
/// </remarks>
|
||||
public sealed class OperatorSessionMiddleware
|
||||
{
|
||||
private readonly RequestDelegate _next;
|
||||
private readonly IOptionsMonitor<OperatorCookieOptions> _cookieOptions;
|
||||
|
||||
public OperatorSessionMiddleware(RequestDelegate next, IOptionsMonitor<OperatorCookieOptions> cookieOptions)
|
||||
{
|
||||
_next = next;
|
||||
_cookieOptions = cookieOptions;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Обрабатывает запрос: разрешает операторскую сессию по куке и наполняет контекст.
|
||||
/// </summary>
|
||||
/// <param name="context">Контекст запроса.</param>
|
||||
public async Task InvokeAsync(HttpContext context)
|
||||
{
|
||||
var cookieName = _cookieOptions.CurrentValue.Name;
|
||||
if (context.Request.Cookies.TryGetValue(cookieName, out var rawToken)
|
||||
&& !string.IsNullOrWhiteSpace(rawToken))
|
||||
{
|
||||
// OperatorAuthService scoped: создаём scope на запрос через RequestServices.
|
||||
await using var scope = context.RequestServices.CreateAsyncScope();
|
||||
var operatorAuthService = scope.ServiceProvider.GetRequiredService<OperatorAuthService>();
|
||||
var identity = await operatorAuthService.ResolveSessionAsync(rawToken, context.RequestAborted);
|
||||
if (identity is not null)
|
||||
{
|
||||
context.SetCurrentOperator(new CurrentOperator(identity.Id, identity.Login, identity.Status));
|
||||
}
|
||||
}
|
||||
|
||||
await _next(context);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,99 +1,97 @@
|
||||
using Deal.Api.Configuration;
|
||||
using Microsoft.Net.Http.Headers;
|
||||
using Deal.Api.Services;
|
||||
using Deal.Api.Dtos;
|
||||
|
||||
namespace Deal.Api.Middleware;
|
||||
|
||||
/// <summary>
|
||||
/// Origin-проверка мутаций /api (план Task 12, Ruling 10(2)): запросы не-GET/HEAD/OPTIONS к /api,
|
||||
/// у которых есть заголовок Origin, обязаны иметь Origin, равный «своему» origin запроса
|
||||
/// (схема + Host; за Caddy схема — https из X-Forwarded-Proto, см. UseForwardedHeaders) либо входящий
|
||||
/// в явный allowlist <see cref="SecurityOptions.AllowedOrigins"/>; несовпадение — HTTP 403
|
||||
/// <c>{"detail":"…"}</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Дополнительный слой CSRF поверх SameSite=Lax кук (первый рубеж, документируется в техдок §10):
|
||||
/// браузер всегда шлёт Origin на мутирующих запросах, а подделать его из чужого сайта нельзя, поэтому
|
||||
/// «чужой» Origin — надёжный признак cross-site запроса. Запросы без Origin (curl, сервер-сервер,
|
||||
/// gRPC) не проверяются и пропускаются — Origin обязателен только у браузерных вызовов. GET/HEAD —
|
||||
/// не мутации, OPTIONS — CORS-preflight: не проверяются. Пустой allowlist (dev-режим, Ruling 10(2)) —
|
||||
/// правило «Origin == свой origin запроса»; непустой список из конфига расширяет его (фронт за
|
||||
/// прокси, меняющим Host, и/или явные домены PROD, Ruling 9).
|
||||
/// <para>
|
||||
/// Регистрируется последним из security-слоёв (Ruling 5: Session → Operator → RateLimiter → OriginGuard):
|
||||
/// сессии уже разрешены, rate-limiter ответил 429 раньше, чем проверяется Origin.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class OriginGuardMiddleware
|
||||
{
|
||||
/// <summary>
|
||||
/// Текст 403 Origin-проверки (Ruling 10(2)): единая формулировка для всех отказов.
|
||||
/// </summary>
|
||||
public const string OriginRejectedDetail = "Запрос отклонён: недопустимый Origin";
|
||||
|
||||
// Префикс пути, под которым живут все HTTP-эндпоинты приложения (Ruling 11).
|
||||
private const string ApiPathPrefix = "/api";
|
||||
|
||||
private readonly RequestDelegate _next;
|
||||
private readonly HashSet<string> _allowedOrigins;
|
||||
|
||||
public OriginGuardMiddleware(RequestDelegate next, SecurityOptions securityOptions)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(next);
|
||||
ArgumentNullException.ThrowIfNull(securityOptions);
|
||||
_next = next;
|
||||
// Регистронезависимо: схема/хост origin'ов регистронечувствительны, браузеры шлют нижний регистр.
|
||||
_allowedOrigins = new HashSet<string>(securityOptions.AllowedOrigins, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Обрабатывает запрос: отклоняет мутации /api с чужим Origin (403 {detail}).
|
||||
/// </summary>
|
||||
/// <param name="context">Контекст запроса.</param>
|
||||
public async Task InvokeAsync(HttpContext context)
|
||||
{
|
||||
HttpRequest request = context.Request;
|
||||
if (request.Path.StartsWithSegments(ApiPathPrefix)
|
||||
&& !HttpMethods.IsGet(request.Method)
|
||||
&& !HttpMethods.IsHead(request.Method)
|
||||
&& !HttpMethods.IsOptions(request.Method)
|
||||
&& request.Headers.TryGetValue(HeaderNames.Origin, out var originValues))
|
||||
{
|
||||
string origin = originValues.ToString();
|
||||
if (!string.IsNullOrWhiteSpace(origin) && !IsTrustedOrigin(origin, request))
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status403Forbidden;
|
||||
await context.Response.WriteAsJsonAsync(new { detail = OriginRejectedDetail }, context.RequestAborted);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await _next(context);
|
||||
}
|
||||
|
||||
// Проверяет Origin: allowlist конфига либо «свой» origin запроса (схема + Host).
|
||||
// origin: Значение заголовка Origin запроса.
|
||||
// request: Запрос (схема/Host — с учётом прокси-заголовков, если UseForwardedHeaders включён).
|
||||
// Возвращает: true — Origin доверенный, запрос можно пропускать.
|
||||
private bool IsTrustedOrigin(string origin, HttpRequest request)
|
||||
{
|
||||
if (_allowedOrigins.Contains(origin))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// «Свой» origin: схема + Host-заголовок запроса (без завершающего слэша — как шлёт браузер).
|
||||
HostString host = request.Host;
|
||||
if (host.HasValue)
|
||||
{
|
||||
string selfOrigin = request.Scheme + "://" + host.Value;
|
||||
if (string.Equals(origin, selfOrigin, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
using Deal.Api.Configuration;
|
||||
using Microsoft.Net.Http.Headers;
|
||||
|
||||
namespace Deal.Api.Middleware;
|
||||
|
||||
/// <summary>
|
||||
/// Origin-проверка мутаций /api (план Task 12, Ruling 10(2)): запросы не-GET/HEAD/OPTIONS к /api,
|
||||
/// у которых есть заголовок Origin, обязаны иметь Origin, равный «своему» origin запроса
|
||||
/// (схема + Host; за Caddy схема — https из X-Forwarded-Proto, см. UseForwardedHeaders) либо входящий
|
||||
/// в явный allowlist <see cref="SecurityOptions.AllowedOrigins"/>; несовпадение — HTTP 403
|
||||
/// <c>{"detail":"…"}</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Дополнительный слой CSRF поверх SameSite=Lax кук (первый рубеж, документируется в техдок §10):
|
||||
/// браузер всегда шлёт Origin на мутирующих запросах, а подделать его из чужого сайта нельзя, поэтому
|
||||
/// «чужой» Origin — надёжный признак cross-site запроса. Запросы без Origin (curl, сервер-сервер,
|
||||
/// gRPC) не проверяются и пропускаются — Origin обязателен только у браузерных вызовов. GET/HEAD —
|
||||
/// не мутации, OPTIONS — CORS-preflight: не проверяются. Пустой allowlist (dev-режим, Ruling 10(2)) —
|
||||
/// правило «Origin == свой origin запроса»; непустой список из конфига расширяет его (фронт за
|
||||
/// прокси, меняющим Host, и/или явные домены PROD, Ruling 9).
|
||||
/// <para>
|
||||
/// Регистрируется последним из security-слоёв (Ruling 5: Session → Operator → RateLimiter → OriginGuard):
|
||||
/// сессии уже разрешены, rate-limiter ответил 429 раньше, чем проверяется Origin.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class OriginGuardMiddleware
|
||||
{
|
||||
/// <summary>
|
||||
/// Текст 403 Origin-проверки (Ruling 10(2)): единая формулировка для всех отказов.
|
||||
/// </summary>
|
||||
public const string OriginRejectedDetail = "Запрос отклонён: недопустимый Origin";
|
||||
|
||||
// Префикс пути, под которым живут все HTTP-эндпоинты приложения (Ruling 11).
|
||||
private const string ApiPathPrefix = "/api";
|
||||
|
||||
private readonly RequestDelegate _next;
|
||||
private readonly HashSet<string> _allowedOrigins;
|
||||
|
||||
public OriginGuardMiddleware(RequestDelegate next, SecurityOptions securityOptions)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(next);
|
||||
ArgumentNullException.ThrowIfNull(securityOptions);
|
||||
_next = next;
|
||||
// Регистронезависимо: схема/хост origin'ов регистронечувствительны, браузеры шлют нижний регистр.
|
||||
_allowedOrigins = new HashSet<string>(securityOptions.AllowedOrigins, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Обрабатывает запрос: отклоняет мутации /api с чужим Origin (403 {detail}).
|
||||
/// </summary>
|
||||
/// <param name="context">Контекст запроса.</param>
|
||||
public async Task InvokeAsync(HttpContext context)
|
||||
{
|
||||
HttpRequest request = context.Request;
|
||||
if (request.Path.StartsWithSegments(ApiPathPrefix)
|
||||
&& !HttpMethods.IsGet(request.Method)
|
||||
&& !HttpMethods.IsHead(request.Method)
|
||||
&& !HttpMethods.IsOptions(request.Method)
|
||||
&& request.Headers.TryGetValue(HeaderNames.Origin, out var originValues))
|
||||
{
|
||||
string origin = originValues.ToString();
|
||||
if (!string.IsNullOrWhiteSpace(origin) && !IsTrustedOrigin(origin, request))
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status403Forbidden;
|
||||
await context.Response.WriteAsJsonAsync(new { detail = OriginRejectedDetail }, context.RequestAborted);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await _next(context);
|
||||
}
|
||||
|
||||
// Проверяет Origin: allowlist конфига либо «свой» origin запроса (схема + Host).
|
||||
// origin: Значение заголовка Origin запроса.
|
||||
// request: Запрос (схема/Host — с учётом прокси-заголовков, если UseForwardedHeaders включён).
|
||||
// Возвращает: true — Origin доверенный, запрос можно пропускать.
|
||||
private bool IsTrustedOrigin(string origin, HttpRequest request)
|
||||
{
|
||||
if (_allowedOrigins.Contains(origin))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// «Свой» origin: схема + Host-заголовок запроса (без завершающего слэша — как шлёт браузер).
|
||||
HostString host = request.Host;
|
||||
if (host.HasValue)
|
||||
{
|
||||
string selfOrigin = request.Scheme + "://" + host.Value;
|
||||
if (string.Equals(origin, selfOrigin, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,155 +1,153 @@
|
||||
using System.Threading.RateLimiting;
|
||||
using Deal.Api.Configuration;
|
||||
using Deal.Api.Extensions;
|
||||
using Deal.Api.Models;
|
||||
using Deal.Api.Services;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Deal.Api.Dtos;
|
||||
|
||||
namespace Deal.Api.Middleware;
|
||||
|
||||
/// <summary>
|
||||
/// Регистрация встроенного rate limiter ASP.NET Core (план Task 11, Ruling 5; этап 12, пакет B — хранилище
|
||||
/// на Postgres) и его политики: "auth" — фиксированное окно на IP клиента (ручки входа /api/auth/login и
|
||||
/// /api/operator/auth/login), "api" — на тенанта из <see cref="CurrentUser"/> либо IP анонима
|
||||
/// (Session/OperatorSessionMiddleware отрабатывают раньше — порядок Session → Operator → RateLimiter).
|
||||
/// API-партиция выставляется и глобальным лимитером: все /api-эндпоинты без собственной политики
|
||||
/// ограничены 600/мин на тенанта/IP. Отказ любого лимитера — HTTP 429 с телом <c>{"detail": "…"}</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Регистрируется только при <see cref="RateLimitOptions.Enabled"/> (в dev/тестах middleware и политики
|
||||
/// не создаются — Ruling 5). gRPC-ингресс (:5082) HTTP-лимитером освобождён (DisableRateLimiting на
|
||||
/// MapGrpcService): лимит по tenant-id там считает IngressRateLimitInterceptor — иначе входящий поток
|
||||
/// telegram-service резался бы общим окном на IP.
|
||||
/// <para>
|
||||
/// Счётчики окон живут в Postgres (<see cref="IRateLimitCounterStore"/> через scoped
|
||||
/// <see cref="StoreBackedFixedWindowRateLimiter"/>) — лимиты общие для всех инстансов core. Пороги/окна
|
||||
/// не менялись: фиксированные 1 минута, PermitLimit из <see cref="RateLimitOptions"/>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class RateLimitPolicies
|
||||
{
|
||||
/// <summary>
|
||||
/// Имя политики входа: фиксированное окно по IP (RateLimit:AuthPerMinute).
|
||||
/// </summary>
|
||||
public const string AuthPolicy = "auth";
|
||||
|
||||
/// <summary>
|
||||
/// Имя API-политики: фиксированное окно по CurrentUser.TenantId либо IP анонима (RateLimit:ApiPerMinute).
|
||||
/// </summary>
|
||||
public const string ApiPolicy = "api";
|
||||
|
||||
/// <summary>
|
||||
/// Текст 429 rate limiter (Ruling 5): все отказы лимитов запросов — единый detail.
|
||||
/// </summary>
|
||||
public const string RejectedDetail = "Слишком много запросов. Повторите позже";
|
||||
|
||||
// Префикс ключа хранилища для HTTP-политик: изолирует счётчики от gRPC-ингресса и login-guard.
|
||||
private const string HttpKeyPrefix = "http:";
|
||||
|
||||
// Пространство ключа глобального лимитера: отдельные счётчики от именованной "api"-политики
|
||||
// (как и раньше — независимые FixedWindowRateLimiter'ы, лимиты не схлопываются в один бакет).
|
||||
private const string GlobalScope = "global";
|
||||
|
||||
// Префикс партиции API-политики для анонима (IP не разрешён в сессию).
|
||||
private const string IpKeyPrefix = "ip:";
|
||||
|
||||
// Префикс партиции API-политики для аутентифицированного пользователя (по тенанту).
|
||||
private const string TenantKeyPrefix = "tenant:";
|
||||
|
||||
// Ключ партиции, когда IP клиента недоступен (служебные/тестовые вызовы).
|
||||
private const string UnknownClientKey = "unknown";
|
||||
|
||||
// Длина окна политик — 1 минута (имена настроек — «PerMinute»).
|
||||
private static readonly TimeSpan Window = TimeSpan.FromMinutes(1);
|
||||
|
||||
/// <summary>
|
||||
/// Регистрирует AddRateLimiter с политиками "auth"/"api" и глобальным лимитером API-партиции.
|
||||
/// </summary>
|
||||
/// <param name="services">Коллекция сервисов хоста.</param>
|
||||
/// <param name="options">Настройки rate limiting (секция RateLimit).</param>
|
||||
/// <returns>Коллекция сервисов для цепочки вызовов.</returns>
|
||||
public static IServiceCollection AddDealRateLimiter(this IServiceCollection services, RateLimitOptions options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
int apiPerMinute = options.ApiPerMinute;
|
||||
services.AddRateLimiter(limiter =>
|
||||
{
|
||||
limiter.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
|
||||
limiter.OnRejected = OnRejectedAsync;
|
||||
limiter.AddPolicy(AuthPolicy, context => AuthPartition(context, options.AuthPerMinute));
|
||||
limiter.AddPolicy(ApiPolicy, context => ApiPartition(context, ApiPolicy, apiPerMinute));
|
||||
// Глобальный лимитер — та же API-партиция (своё пространство ключей): эндпоинты без
|
||||
// RequireRateLimiting ограничены по тенанту/IP (весь /api, кроме ручек входа и gRPC).
|
||||
limiter.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(
|
||||
context => ApiPartition(context, GlobalScope, apiPerMinute));
|
||||
});
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
// Партиция политики "auth": фиксированное окно в минуту на IP клиента.
|
||||
// context: Контекст запроса (адрес клиента).
|
||||
// permitsPerMinute: Разрешено запросов в минуту на партицию (RateLimit:AuthPerMinute).
|
||||
// Возвращает: Партиция лимитера с ключом IP.
|
||||
private static RateLimitPartition<string> AuthPartition(HttpContext context, int permitsPerMinute)
|
||||
=> StorePartition(context, AuthPolicy, ClientKey(context), permitsPerMinute);
|
||||
|
||||
// Партиция политики "api": ключ — тенант сессии (CurrentUser) либо IP анонима.
|
||||
// context: Контекст запроса (CurrentUser кладёт SessionMiddleware до UseRateLimiter).
|
||||
// scope: Пространство ключей (имя политики или "global" для глобального лимитера).
|
||||
// permitsPerMinute: Разрешено запросов в минуту на партицию (RateLimit:ApiPerMinute).
|
||||
// Возвращает: Партиция лимитера с ключом tenant:{id} либо IP.
|
||||
private static RateLimitPartition<string> ApiPartition(
|
||||
HttpContext context,
|
||||
string scope,
|
||||
int permitsPerMinute)
|
||||
{
|
||||
string partitionKey = context.GetCurrentUser() is { } user
|
||||
? TenantKeyPrefix + user.TenantId.ToString("N")
|
||||
: ClientKey(context);
|
||||
return StorePartition(context, scope, partitionKey, permitsPerMinute);
|
||||
}
|
||||
|
||||
// Строит партицию на хранилище Postgres: ключ = http:{scope}:{partitionKey}, лимитер — лениво.
|
||||
// context: Контекст запроса (источник IServiceScopeFactory для лениво создаваемого лимитера).
|
||||
// scope: Пространство ключей (политика/global).
|
||||
// partitionKey: Ключ партиции (IP/тенант).
|
||||
// permitsPerMinute: Разрешено запросов в минуту.
|
||||
// Возвращает: Партиция store-backed лимитера (ленивое создание и кеш — менеджер партиций).
|
||||
private static RateLimitPartition<string> StorePartition(
|
||||
HttpContext context,
|
||||
string scope,
|
||||
string partitionKey,
|
||||
int permitsPerMinute)
|
||||
{
|
||||
string storeKey = HttpKeyPrefix + scope + ":" + partitionKey;
|
||||
return RateLimitPartition.Get(storeKey, key => new StoreBackedFixedWindowRateLimiter(
|
||||
context.RequestServices.GetRequiredService<IServiceScopeFactory>(),
|
||||
key,
|
||||
permitsPerMinute,
|
||||
Window,
|
||||
() => DateTimeOffset.UtcNow));
|
||||
}
|
||||
|
||||
// Ключ клиента: строка IP (или unknown, если адрес недоступен).
|
||||
// context: Контекст запроса.
|
||||
// Возвращает: Ключ партиции по IP.
|
||||
private static string ClientKey(HttpContext context)
|
||||
{
|
||||
string? ip = context.Connection.RemoteIpAddress?.ToString();
|
||||
return string.IsNullOrEmpty(ip) ? UnknownClientKey : IpKeyPrefix + ip;
|
||||
}
|
||||
|
||||
// Пишет ответ 429 формата прототипа: {"detail": "…"} (Ruling 10/5).
|
||||
// context: Контекст отклонённого запроса.
|
||||
// cancellationToken: Токен отмены ответа.
|
||||
private static async ValueTask OnRejectedAsync(OnRejectedContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
context.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests;
|
||||
await context.HttpContext.Response.WriteAsJsonAsync(new { detail = RejectedDetail }, cancellationToken);
|
||||
}
|
||||
}
|
||||
using System.Threading.RateLimiting;
|
||||
using Deal.Api.Configuration;
|
||||
using Deal.Api.Extensions;
|
||||
using Deal.Api.Models;
|
||||
using Deal.Api.Services;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
|
||||
namespace Deal.Api.Middleware;
|
||||
|
||||
/// <summary>
|
||||
/// Регистрация встроенного rate limiter ASP.NET Core (план Task 11, Ruling 5; этап 12, пакет B — хранилище
|
||||
/// на Postgres) и его политики: "auth" — фиксированное окно на IP клиента (ручки входа /api/auth/login и
|
||||
/// /api/operator/auth/login), "api" — на тенанта из <see cref="CurrentUser"/> либо IP анонима
|
||||
/// (Session/OperatorSessionMiddleware отрабатывают раньше — порядок Session → Operator → RateLimiter).
|
||||
/// API-партиция выставляется и глобальным лимитером: все /api-эндпоинты без собственной политики
|
||||
/// ограничены 600/мин на тенанта/IP. Отказ любого лимитера — HTTP 429 с телом <c>{"detail": "…"}</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Регистрируется только при <see cref="RateLimitOptions.Enabled"/> (в dev/тестах middleware и политики
|
||||
/// не создаются — Ruling 5). gRPC-ингресс (:5082) HTTP-лимитером освобождён (DisableRateLimiting на
|
||||
/// MapGrpcService): лимит по tenant-id там считает IngressRateLimitInterceptor — иначе входящий поток
|
||||
/// telegram-service резался бы общим окном на IP.
|
||||
/// <para>
|
||||
/// Счётчики окон живут в Postgres (<see cref="IRateLimitCounterStore"/> через scoped
|
||||
/// <see cref="StoreBackedFixedWindowRateLimiter"/>) — лимиты общие для всех инстансов core. Пороги/окна
|
||||
/// не менялись: фиксированные 1 минута, PermitLimit из <see cref="RateLimitOptions"/>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class RateLimitPolicies
|
||||
{
|
||||
/// <summary>
|
||||
/// Имя политики входа: фиксированное окно по IP (RateLimit:AuthPerMinute).
|
||||
/// </summary>
|
||||
public const string AuthPolicy = "auth";
|
||||
|
||||
/// <summary>
|
||||
/// Имя API-политики: фиксированное окно по CurrentUser.TenantId либо IP анонима (RateLimit:ApiPerMinute).
|
||||
/// </summary>
|
||||
public const string ApiPolicy = "api";
|
||||
|
||||
/// <summary>
|
||||
/// Текст 429 rate limiter (Ruling 5): все отказы лимитов запросов — единый detail.
|
||||
/// </summary>
|
||||
public const string RejectedDetail = "Слишком много запросов. Повторите позже";
|
||||
|
||||
// Префикс ключа хранилища для HTTP-политик: изолирует счётчики от gRPC-ингресса и login-guard.
|
||||
private const string HttpKeyPrefix = "http:";
|
||||
|
||||
// Пространство ключа глобального лимитера: отдельные счётчики от именованной "api"-политики
|
||||
// (как и раньше — независимые FixedWindowRateLimiter'ы, лимиты не схлопываются в один бакет).
|
||||
private const string GlobalScope = "global";
|
||||
|
||||
// Префикс партиции API-политики для анонима (IP не разрешён в сессию).
|
||||
private const string IpKeyPrefix = "ip:";
|
||||
|
||||
// Префикс партиции API-политики для аутентифицированного пользователя (по тенанту).
|
||||
private const string TenantKeyPrefix = "tenant:";
|
||||
|
||||
// Ключ партиции, когда IP клиента недоступен (служебные/тестовые вызовы).
|
||||
private const string UnknownClientKey = "unknown";
|
||||
|
||||
// Длина окна политик — 1 минута (имена настроек — «PerMinute»).
|
||||
private static readonly TimeSpan Window = TimeSpan.FromMinutes(1);
|
||||
|
||||
/// <summary>
|
||||
/// Регистрирует AddRateLimiter с политиками "auth"/"api" и глобальным лимитером API-партиции.
|
||||
/// </summary>
|
||||
/// <param name="services">Коллекция сервисов хоста.</param>
|
||||
/// <param name="options">Настройки rate limiting (секция RateLimit).</param>
|
||||
/// <returns>Коллекция сервисов для цепочки вызовов.</returns>
|
||||
public static IServiceCollection AddDealRateLimiter(this IServiceCollection services, RateLimitOptions options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
int apiPerMinute = options.ApiPerMinute;
|
||||
services.AddRateLimiter(limiter =>
|
||||
{
|
||||
limiter.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
|
||||
limiter.OnRejected = OnRejectedAsync;
|
||||
limiter.AddPolicy(AuthPolicy, context => AuthPartition(context, options.AuthPerMinute));
|
||||
limiter.AddPolicy(ApiPolicy, context => ApiPartition(context, ApiPolicy, apiPerMinute));
|
||||
// Глобальный лимитер — та же API-партиция (своё пространство ключей): эндпоинты без
|
||||
// RequireRateLimiting ограничены по тенанту/IP (весь /api, кроме ручек входа и gRPC).
|
||||
limiter.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(
|
||||
context => ApiPartition(context, GlobalScope, apiPerMinute));
|
||||
});
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
// Партиция политики "auth": фиксированное окно в минуту на IP клиента.
|
||||
// context: Контекст запроса (адрес клиента).
|
||||
// permitsPerMinute: Разрешено запросов в минуту на партицию (RateLimit:AuthPerMinute).
|
||||
// Возвращает: Партиция лимитера с ключом IP.
|
||||
private static RateLimitPartition<string> AuthPartition(HttpContext context, int permitsPerMinute)
|
||||
=> StorePartition(context, AuthPolicy, ClientKey(context), permitsPerMinute);
|
||||
|
||||
// Партиция политики "api": ключ — тенант сессии (CurrentUser) либо IP анонима.
|
||||
// context: Контекст запроса (CurrentUser кладёт SessionMiddleware до UseRateLimiter).
|
||||
// scope: Пространство ключей (имя политики или "global" для глобального лимитера).
|
||||
// permitsPerMinute: Разрешено запросов в минуту на партицию (RateLimit:ApiPerMinute).
|
||||
// Возвращает: Партиция лимитера с ключом tenant:{id} либо IP.
|
||||
private static RateLimitPartition<string> ApiPartition(
|
||||
HttpContext context,
|
||||
string scope,
|
||||
int permitsPerMinute)
|
||||
{
|
||||
string partitionKey = context.GetCurrentUser() is { } user
|
||||
? TenantKeyPrefix + user.TenantId.ToString("N")
|
||||
: ClientKey(context);
|
||||
return StorePartition(context, scope, partitionKey, permitsPerMinute);
|
||||
}
|
||||
|
||||
// Строит партицию на хранилище Postgres: ключ = http:{scope}:{partitionKey}, лимитер — лениво.
|
||||
// context: Контекст запроса (источник IServiceScopeFactory для лениво создаваемого лимитера).
|
||||
// scope: Пространство ключей (политика/global).
|
||||
// partitionKey: Ключ партиции (IP/тенант).
|
||||
// permitsPerMinute: Разрешено запросов в минуту.
|
||||
// Возвращает: Партиция store-backed лимитера (ленивое создание и кеш — менеджер партиций).
|
||||
private static RateLimitPartition<string> StorePartition(
|
||||
HttpContext context,
|
||||
string scope,
|
||||
string partitionKey,
|
||||
int permitsPerMinute)
|
||||
{
|
||||
string storeKey = HttpKeyPrefix + scope + ":" + partitionKey;
|
||||
return RateLimitPartition.Get(storeKey, key => new StoreBackedFixedWindowRateLimiter(
|
||||
context.RequestServices.GetRequiredService<IServiceScopeFactory>(),
|
||||
key,
|
||||
permitsPerMinute,
|
||||
Window,
|
||||
() => DateTimeOffset.UtcNow));
|
||||
}
|
||||
|
||||
// Ключ клиента: строка IP (или unknown, если адрес недоступен).
|
||||
// context: Контекст запроса.
|
||||
// Возвращает: Ключ партиции по IP.
|
||||
private static string ClientKey(HttpContext context)
|
||||
{
|
||||
string? ip = context.Connection.RemoteIpAddress?.ToString();
|
||||
return string.IsNullOrEmpty(ip) ? UnknownClientKey : IpKeyPrefix + ip;
|
||||
}
|
||||
|
||||
// Пишет ответ 429 формата прототипа: {"detail": "…"} (Ruling 10/5).
|
||||
// context: Контекст отклонённого запроса.
|
||||
// cancellationToken: Токен отмены ответа.
|
||||
private static async ValueTask OnRejectedAsync(OnRejectedContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
context.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests;
|
||||
await context.HttpContext.Response.WriteAsJsonAsync(new { detail = RejectedDetail }, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,81 +1,74 @@
|
||||
using Deal.Api.Configuration;
|
||||
using Deal.Api.Extensions;
|
||||
using Deal.Api.Models;
|
||||
using Deal.Api.Services;
|
||||
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.SharedKernel.Tenants.Abstractions;
|
||||
using Deal.SharedKernel.Tenants.Models;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Deal.Api.Dtos;
|
||||
// Имя конфигурационного типа совпадает с Microsoft.AspNetCore.Http.CookieOptions — фиксируем алиасом.
|
||||
using CookieOptions = Deal.Api.Configuration.CookieOptions;
|
||||
|
||||
namespace Deal.Api.Middleware;
|
||||
|
||||
/// <summary>
|
||||
/// Middleware сессии: читает httpOnly-куку, разрешает сессию через AuthService и наполняет
|
||||
/// <c>HttpContext.Items["CurrentUser"]</c> + tenant-контекст запроса (ITenantContext).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Middleware НЕ отвечает 401 сама (pass-through): эндпоинты, требующие авторизации, проверяют
|
||||
/// пользователя и выставляют 401. Нет куки или сессия невалидна — запрос идёт дальше без
|
||||
/// пользователя. Tenant-контекст сбрасывается в finally после обработки запроса.
|
||||
/// <para>
|
||||
/// AuthService — scoped, поэтому на запрос создаётся собственный scope через RequestServices.
|
||||
/// Экземпляр middleware — singleton (стандартный паттерн UseMiddleware), опции читаются через
|
||||
/// IOptionsMonitor, чтобы подхватывать изменения конфигурации.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class SessionMiddleware
|
||||
{
|
||||
private readonly RequestDelegate _next;
|
||||
private readonly IOptionsMonitor<CookieOptions> _cookieOptions;
|
||||
private readonly ITenantContext _tenantContext;
|
||||
|
||||
public SessionMiddleware(
|
||||
RequestDelegate next,
|
||||
IOptionsMonitor<CookieOptions> cookieOptions,
|
||||
ITenantContext tenantContext)
|
||||
{
|
||||
_next = next;
|
||||
_cookieOptions = cookieOptions;
|
||||
_tenantContext = tenantContext;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Обрабатывает запрос: разрешает сессию по куке и наполняет контекст.
|
||||
/// </summary>
|
||||
/// <param name="context">Контекст запроса.</param>
|
||||
public async Task InvokeAsync(HttpContext context)
|
||||
{
|
||||
try
|
||||
{
|
||||
var cookieName = _cookieOptions.CurrentValue.Name;
|
||||
if (context.Request.Cookies.TryGetValue(cookieName, out var rawToken)
|
||||
&& !string.IsNullOrWhiteSpace(rawToken))
|
||||
{
|
||||
// AuthService scoped: создаём scope на запрос через RequestServices.
|
||||
await using var scope = context.RequestServices.CreateAsyncScope();
|
||||
var authService = scope.ServiceProvider.GetRequiredService<AuthService>();
|
||||
var user = await authService.ResolveSessionAsync(rawToken, context.RequestAborted);
|
||||
if (user is not null)
|
||||
{
|
||||
context.SetCurrentUser(new CurrentUser(user.Id, user.Login, user.TenantId, user.Status));
|
||||
// Схема тенанта именуется tenant_<id>, где id — Guid в формате "N" (см. TenantService).
|
||||
_tenantContext.SetTenant(new TenantId(user.TenantId.ToString("N")));
|
||||
}
|
||||
}
|
||||
|
||||
await _next(context);
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Контекст AsyncLocal не должен переживать запрос.
|
||||
_tenantContext.Reset();
|
||||
}
|
||||
}
|
||||
}
|
||||
using Deal.Api.Extensions;
|
||||
using Deal.Api.Models;
|
||||
using Deal.Modules.Tenants.Application.Services;
|
||||
using Deal.SharedKernel.Tenants.Abstractions;
|
||||
using Deal.SharedKernel.Tenants.Models;
|
||||
using Microsoft.Extensions.Options;
|
||||
// Имя конфигурационного типа совпадает с Microsoft.AspNetCore.Http.CookieOptions — фиксируем алиасом.
|
||||
using CookieOptions = Deal.Api.Configuration.CookieOptions;
|
||||
|
||||
namespace Deal.Api.Middleware;
|
||||
|
||||
/// <summary>
|
||||
/// Middleware сессии: читает httpOnly-куку, разрешает сессию через AuthService и наполняет
|
||||
/// <c>HttpContext.Items["CurrentUser"]</c> + tenant-контекст запроса (ITenantContext).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Middleware НЕ отвечает 401 сама (pass-through): эндпоинты, требующие авторизации, проверяют
|
||||
/// пользователя и выставляют 401. Нет куки или сессия невалидна — запрос идёт дальше без
|
||||
/// пользователя. Tenant-контекст сбрасывается в finally после обработки запроса.
|
||||
/// <para>
|
||||
/// AuthService — scoped, поэтому на запрос создаётся собственный scope через RequestServices.
|
||||
/// Экземпляр middleware — singleton (стандартный паттерн UseMiddleware), опции читаются через
|
||||
/// IOptionsMonitor, чтобы подхватывать изменения конфигурации.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class SessionMiddleware
|
||||
{
|
||||
private readonly RequestDelegate _next;
|
||||
private readonly IOptionsMonitor<CookieOptions> _cookieOptions;
|
||||
private readonly ITenantContext _tenantContext;
|
||||
|
||||
public SessionMiddleware(
|
||||
RequestDelegate next,
|
||||
IOptionsMonitor<CookieOptions> cookieOptions,
|
||||
ITenantContext tenantContext)
|
||||
{
|
||||
_next = next;
|
||||
_cookieOptions = cookieOptions;
|
||||
_tenantContext = tenantContext;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Обрабатывает запрос: разрешает сессию по куке и наполняет контекст.
|
||||
/// </summary>
|
||||
/// <param name="context">Контекст запроса.</param>
|
||||
public async Task InvokeAsync(HttpContext context)
|
||||
{
|
||||
try
|
||||
{
|
||||
var cookieName = _cookieOptions.CurrentValue.Name;
|
||||
if (context.Request.Cookies.TryGetValue(cookieName, out var rawToken)
|
||||
&& !string.IsNullOrWhiteSpace(rawToken))
|
||||
{
|
||||
// AuthService scoped: создаём scope на запрос через RequestServices.
|
||||
await using var scope = context.RequestServices.CreateAsyncScope();
|
||||
var authService = scope.ServiceProvider.GetRequiredService<AuthService>();
|
||||
var user = await authService.ResolveSessionAsync(rawToken, context.RequestAborted);
|
||||
if (user is not null)
|
||||
{
|
||||
context.SetCurrentUser(new CurrentUser(user.Id, user.Login, user.TenantId, user.Status));
|
||||
// Схема тенанта именуется tenant_<id>, где id — Guid в формате "N" (см. TenantService).
|
||||
_tenantContext.SetTenant(new TenantId(user.TenantId.ToString("N")));
|
||||
}
|
||||
}
|
||||
|
||||
await _next(context);
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Контекст AsyncLocal не должен переживать запрос.
|
||||
_tenantContext.Reset();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
using Deal.Api.Services;
|
||||
using Deal.Api.Dtos;
|
||||
using Deal.Api.Extensions;
|
||||
|
||||
namespace Deal.Api.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Текущий оператор запроса — кладёт OperatorSessionMiddleware в <c>HttpContext.Items</c> (Ruling 1).
|
||||
/// </summary>
|
||||
/// <param name="OperatorId">Идентификатор оператора.</param>
|
||||
/// <param name="Login">Логин в нижнем регистре.</param>
|
||||
/// <param name="Status">Статус учётной записи (всегда "active" — разрешаются только активные).</param>
|
||||
public sealed record CurrentOperator(Guid OperatorId, string Login, string Status);
|
||||
namespace Deal.Api.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Текущий оператор запроса — кладёт OperatorSessionMiddleware в <c>HttpContext.Items</c> (Ruling 1).
|
||||
/// </summary>
|
||||
/// <param name="OperatorId">Идентификатор оператора.</param>
|
||||
/// <param name="Login">Логин в нижнем регистре.</param>
|
||||
/// <param name="Status">Статус учётной записи (всегда "active" — разрешаются только активные).</param>
|
||||
public sealed record CurrentOperator(Guid OperatorId, string Login, string Status);
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
using Deal.Api.Services;
|
||||
using Deal.Api.Dtos;
|
||||
using Deal.Api.Extensions;
|
||||
|
||||
namespace Deal.Api.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Текущий пользователь запроса — кладётся SessionMiddleware в <c>HttpContext.Items</c>.
|
||||
/// </summary>
|
||||
/// <param name="UserId">Идентификатор пользователя.</param>
|
||||
/// <param name="Login">Логин в нижнем регистре.</param>
|
||||
/// <param name="TenantId">Идентификатор тенанта, которому принадлежит пользователь.</param>
|
||||
/// <param name="Status">Статус учётной записи.</param>
|
||||
public sealed record CurrentUser(Guid UserId, string Login, Guid TenantId, string Status);
|
||||
namespace Deal.Api.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Текущий пользователь запроса — кладётся SessionMiddleware в <c>HttpContext.Items</c>.
|
||||
/// </summary>
|
||||
/// <param name="UserId">Идентификатор пользователя.</param>
|
||||
/// <param name="Login">Логин в нижнем регистре.</param>
|
||||
/// <param name="TenantId">Идентификатор тенанта, которому принадлежит пользователь.</param>
|
||||
/// <param name="Status">Статус учётной записи.</param>
|
||||
public sealed record CurrentUser(Guid UserId, string Login, Guid TenantId, string Status);
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
using Deal.SharedKernel.Observability;
|
||||
using Deal.Api.Services;
|
||||
using Deal.Api.Dtos;
|
||||
|
||||
namespace Deal.Api.Observability;
|
||||
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
using Deal.SharedKernel.Observability;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Server.Kestrel.Core;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using OpenTelemetry;
|
||||
using OpenTelemetry.Metrics;
|
||||
using Deal.Api.Services;
|
||||
using Deal.Api.Dtos;
|
||||
|
||||
namespace Deal.Api.Observability;
|
||||
|
||||
|
||||
@@ -1,160 +1,148 @@
|
||||
using Deal.Infrastructure.Persistence;
|
||||
using Deal.Infrastructure.Persistence.Configurations;
|
||||
using Deal.Modules.Kanban.Application.Abstractions;
|
||||
using Deal.Modules.Kanban.Application.Extensions;
|
||||
using Deal.Modules.Kanban.Application.Models;
|
||||
using Deal.Modules.Kanban.Application.Registrars;
|
||||
using Deal.Modules.Kanban.Application.Services;
|
||||
using Deal.Modules.Pipeline.Application.Abstractions;
|
||||
using Deal.Modules.Pipeline.Application.Models;
|
||||
using Deal.Modules.Pipeline.Application.Registrars;
|
||||
using Deal.Modules.Pipeline.Application.Services;
|
||||
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.SharedKernel.Tenants.Abstractions;
|
||||
using Deal.SharedKernel.Tenants.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Deal.Api.Services;
|
||||
using Deal.Api.Dtos;
|
||||
|
||||
namespace Deal.Api.Observability;
|
||||
|
||||
/// <summary>
|
||||
/// Сборщик глубин очередей и активных сессий (этап 12, §10.2): единый источник для метрик и health.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Обходит реестр тенантов (на тенант — вложенный scope с <c>ITenantContext.SetTenant</c>) и считает через
|
||||
/// существующие сервисы/порты, без дублирования SQL:
|
||||
/// <list type="bullet">
|
||||
/// <item>глубина очереди пайплайна — <see cref="PipelineProcessingService.QueueCountsAsync"/> (new+filtered);</item>
|
||||
/// <item>глубина очереди обучения ML — <see cref="IMlLearningStore.CountOutboxAsync"/> (count(MlOutbox));</item>
|
||||
/// <item>активные сессии — count(public.sessions) + count(public.operator_sessions) с непросроченным ExpiresAt.</item>
|
||||
/// </list>
|
||||
/// Переиспользуется фоновым <see cref="DealMetricsCollector"/> (публикация в <c>DealMetrics</c>) и операторским
|
||||
/// health (глубины прямо в JSON). Ошибки каждой секции логируются и не выбрасываются наружу (сбой тенанта не
|
||||
/// валит проход; наружу летит только отмена); значения агрегируются по всем тенантам.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class RuntimeDepthsCollector
|
||||
{
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
private readonly ILogger<RuntimeDepthsCollector> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Создаёт сборщик глубин.
|
||||
/// </summary>
|
||||
/// <param name="scopeFactory">Фабрика scope: проход и тенант — в собственных scope.</param>
|
||||
/// <param name="logger">Логгер ошибок секций.</param>
|
||||
public RuntimeDepthsCollector(IServiceScopeFactory scopeFactory, ILogger<RuntimeDepthsCollector> logger)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(scopeFactory);
|
||||
ArgumentNullException.ThrowIfNull(logger);
|
||||
_scopeFactory = scopeFactory;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Собирает снимок глубин очередей и числа активных сессий (агрегат по всем тенантам).
|
||||
/// </summary>
|
||||
/// <param name="ct">Токен отмены (пробрасывается в EF-запросы; отмена — единственное исключение наружу).</param>
|
||||
/// <returns>Снимок: суммарные глубины pipeline/ML-outbox и число активных сессий.</returns>
|
||||
public async Task<RuntimeDepthsDto> CollectAsync(CancellationToken ct)
|
||||
{
|
||||
await using AsyncServiceScope cycleScope = _scopeFactory.CreateAsyncScope();
|
||||
int sessions = await CountActiveSessionsAsync(cycleScope, ct);
|
||||
(long queue, long outbox) = await SumTenantDepthsAsync(cycleScope, ct);
|
||||
return new RuntimeDepthsDto(queue, outbox, sessions);
|
||||
}
|
||||
|
||||
// Считает активные непросроченные сессии пользователей и операторов (public-схема).
|
||||
// cycleScope: Scope прохода (DealDbContext — без tenant-контекста).
|
||||
// ct: Токен отмены.
|
||||
// Возвращает: Число активных сессий; сбой секции — 0 (снимок остаётся полезным).
|
||||
private async Task<int> CountActiveSessionsAsync(AsyncServiceScope cycleScope, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
DealDbContext dbContext = cycleScope.ServiceProvider.GetRequiredService<DealDbContext>();
|
||||
DateTimeOffset now = DateTimeOffset.UtcNow;
|
||||
int tenantSessions = await dbContext.Sessions.CountAsync(session => session.ExpiresAt > now, ct);
|
||||
int operatorSessions = await dbContext.OperatorSessions.CountAsync(session => session.ExpiresAt > now, ct);
|
||||
return tenantSessions + operatorSessions;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
_logger.LogWarning(exception, "Сборщик глубин: подсчёт активных сессий не удался");
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Суммирует глубины очередей по всем тенантам реестра.
|
||||
// cycleScope: Scope прохода (реестр тенантов читается без tenant-контекста).
|
||||
// ct: Токен отмены.
|
||||
// Возвращает: Пара (сумма очереди пайплайна, сумма MlOutbox); недоступность реестра — (0, 0).
|
||||
private async Task<(long Queue, long Outbox)> SumTenantDepthsAsync(AsyncServiceScope cycleScope, CancellationToken ct)
|
||||
{
|
||||
long queueDepth = 0;
|
||||
long outboxDepth = 0;
|
||||
try
|
||||
{
|
||||
ITenantRepository tenantRepository = cycleScope.ServiceProvider.GetRequiredService<ITenantRepository>();
|
||||
IReadOnlyList<TenantRecordDto> tenants = await tenantRepository.ListAsync(ct);
|
||||
foreach (TenantRecordDto tenant in tenants)
|
||||
{
|
||||
(int queue, int outbox) = await CollectTenantAsync(tenant, ct);
|
||||
queueDepth += queue;
|
||||
outboxDepth += outbox;
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
_logger.LogWarning(exception, "Сборщик глубин: обход реестра тенантов не удался");
|
||||
}
|
||||
|
||||
return (queueDepth, outboxDepth);
|
||||
}
|
||||
|
||||
// Считает глубины очередей одного тенанта в собственном scope (SetTenant → сервисы → Reset).
|
||||
// tenant: Тенант реестра (Id в формате Guid; схема — tenant_<N>).
|
||||
// ct: Токен отмены прохода.
|
||||
// Возвращает: Пара (глубина очереди пайплайна, глубина MlOutbox) для тенанта; сбой — (0, 0).
|
||||
private async Task<(int Queue, int Outbox)> CollectTenantAsync(TenantRecordDto tenant, CancellationToken ct)
|
||||
{
|
||||
await using AsyncServiceScope tenantScope = _scopeFactory.CreateAsyncScope();
|
||||
ITenantContext tenantContext = tenantScope.ServiceProvider.GetRequiredService<ITenantContext>();
|
||||
try
|
||||
{
|
||||
tenantContext.SetTenant(new TenantId(tenant.Id.ToString("N")));
|
||||
PipelineProcessingService processing = tenantScope.ServiceProvider.GetRequiredService<PipelineProcessingService>();
|
||||
QueueCountsDto counts = await processing.QueueCountsAsync(ct);
|
||||
IMlLearningStore learningStore = tenantScope.ServiceProvider.GetRequiredService<IMlLearningStore>();
|
||||
int outbox = await learningStore.CountOutboxAsync(ct);
|
||||
return (counts.Total, outbox);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
_logger.LogWarning(exception, "Сборщик глубин: подсчёт очередей тенанта {TenantId} не удался", tenant.Id);
|
||||
return (0, 0);
|
||||
}
|
||||
finally
|
||||
{
|
||||
tenantContext.Reset();
|
||||
}
|
||||
}
|
||||
}
|
||||
using Deal.Infrastructure.Persistence;
|
||||
using Deal.Modules.Kanban.Application.Abstractions;
|
||||
using Deal.Modules.Pipeline.Application.Models;
|
||||
using Deal.Modules.Pipeline.Application.Services;
|
||||
using Deal.Modules.Tenants.Application.Abstractions;
|
||||
using Deal.Modules.Tenants.Application.Models;
|
||||
using Deal.SharedKernel.Tenants.Abstractions;
|
||||
using Deal.SharedKernel.Tenants.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Deal.Api.Observability;
|
||||
|
||||
/// <summary>
|
||||
/// Сборщик глубин очередей и активных сессий (этап 12, §10.2): единый источник для метрик и health.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Обходит реестр тенантов (на тенант — вложенный scope с <c>ITenantContext.SetTenant</c>) и считает через
|
||||
/// существующие сервисы/порты, без дублирования SQL:
|
||||
/// <list type="bullet">
|
||||
/// <item>глубина очереди пайплайна — <see cref="PipelineProcessingService.QueueCountsAsync"/> (new+filtered);</item>
|
||||
/// <item>глубина очереди обучения ML — <see cref="IMlLearningStore.CountOutboxAsync"/> (count(MlOutbox));</item>
|
||||
/// <item>активные сессии — count(public.sessions) + count(public.operator_sessions) с непросроченным ExpiresAt.</item>
|
||||
/// </list>
|
||||
/// Переиспользуется фоновым <see cref="DealMetricsCollector"/> (публикация в <c>DealMetrics</c>) и операторским
|
||||
/// health (глубины прямо в JSON). Ошибки каждой секции логируются и не выбрасываются наружу (сбой тенанта не
|
||||
/// валит проход; наружу летит только отмена); значения агрегируются по всем тенантам.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class RuntimeDepthsCollector
|
||||
{
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
private readonly ILogger<RuntimeDepthsCollector> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Создаёт сборщик глубин.
|
||||
/// </summary>
|
||||
/// <param name="scopeFactory">Фабрика scope: проход и тенант — в собственных scope.</param>
|
||||
/// <param name="logger">Логгер ошибок секций.</param>
|
||||
public RuntimeDepthsCollector(IServiceScopeFactory scopeFactory, ILogger<RuntimeDepthsCollector> logger)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(scopeFactory);
|
||||
ArgumentNullException.ThrowIfNull(logger);
|
||||
_scopeFactory = scopeFactory;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Собирает снимок глубин очередей и числа активных сессий (агрегат по всем тенантам).
|
||||
/// </summary>
|
||||
/// <param name="ct">Токен отмены (пробрасывается в EF-запросы; отмена — единственное исключение наружу).</param>
|
||||
/// <returns>Снимок: суммарные глубины pipeline/ML-outbox и число активных сессий.</returns>
|
||||
public async Task<RuntimeDepthsDto> CollectAsync(CancellationToken ct)
|
||||
{
|
||||
await using AsyncServiceScope cycleScope = _scopeFactory.CreateAsyncScope();
|
||||
int sessions = await CountActiveSessionsAsync(cycleScope, ct);
|
||||
(long queue, long outbox) = await SumTenantDepthsAsync(cycleScope, ct);
|
||||
return new RuntimeDepthsDto(queue, outbox, sessions);
|
||||
}
|
||||
|
||||
// Считает активные непросроченные сессии пользователей и операторов (public-схема).
|
||||
// cycleScope: Scope прохода (DealDbContext — без tenant-контекста).
|
||||
// ct: Токен отмены.
|
||||
// Возвращает: Число активных сессий; сбой секции — 0 (снимок остаётся полезным).
|
||||
private async Task<int> CountActiveSessionsAsync(AsyncServiceScope cycleScope, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
DealDbContext dbContext = cycleScope.ServiceProvider.GetRequiredService<DealDbContext>();
|
||||
DateTimeOffset now = DateTimeOffset.UtcNow;
|
||||
int tenantSessions = await dbContext.Sessions.CountAsync(session => session.ExpiresAt > now, ct);
|
||||
int operatorSessions = await dbContext.OperatorSessions.CountAsync(session => session.ExpiresAt > now, ct);
|
||||
return tenantSessions + operatorSessions;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
_logger.LogWarning(exception, "Сборщик глубин: подсчёт активных сессий не удался");
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Суммирует глубины очередей по всем тенантам реестра.
|
||||
// cycleScope: Scope прохода (реестр тенантов читается без tenant-контекста).
|
||||
// ct: Токен отмены.
|
||||
// Возвращает: Пара (сумма очереди пайплайна, сумма MlOutbox); недоступность реестра — (0, 0).
|
||||
private async Task<(long Queue, long Outbox)> SumTenantDepthsAsync(AsyncServiceScope cycleScope, CancellationToken ct)
|
||||
{
|
||||
long queueDepth = 0;
|
||||
long outboxDepth = 0;
|
||||
try
|
||||
{
|
||||
ITenantRepository tenantRepository = cycleScope.ServiceProvider.GetRequiredService<ITenantRepository>();
|
||||
IReadOnlyList<TenantRecordDto> tenants = await tenantRepository.ListAsync(ct);
|
||||
foreach (TenantRecordDto tenant in tenants)
|
||||
{
|
||||
(int queue, int outbox) = await CollectTenantAsync(tenant, ct);
|
||||
queueDepth += queue;
|
||||
outboxDepth += outbox;
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
_logger.LogWarning(exception, "Сборщик глубин: обход реестра тенантов не удался");
|
||||
}
|
||||
|
||||
return (queueDepth, outboxDepth);
|
||||
}
|
||||
|
||||
// Считает глубины очередей одного тенанта в собственном scope (SetTenant → сервисы → Reset).
|
||||
// tenant: Тенант реестра (Id в формате Guid; схема — tenant_<N>).
|
||||
// ct: Токен отмены прохода.
|
||||
// Возвращает: Пара (глубина очереди пайплайна, глубина MlOutbox) для тенанта; сбой — (0, 0).
|
||||
private async Task<(int Queue, int Outbox)> CollectTenantAsync(TenantRecordDto tenant, CancellationToken ct)
|
||||
{
|
||||
await using AsyncServiceScope tenantScope = _scopeFactory.CreateAsyncScope();
|
||||
ITenantContext tenantContext = tenantScope.ServiceProvider.GetRequiredService<ITenantContext>();
|
||||
try
|
||||
{
|
||||
tenantContext.SetTenant(new TenantId(tenant.Id.ToString("N")));
|
||||
PipelineProcessingService processing = tenantScope.ServiceProvider.GetRequiredService<PipelineProcessingService>();
|
||||
QueueCountsDto counts = await processing.QueueCountsAsync(ct);
|
||||
IMlLearningStore learningStore = tenantScope.ServiceProvider.GetRequiredService<IMlLearningStore>();
|
||||
int outbox = await learningStore.CountOutboxAsync(ct);
|
||||
return (counts.Total, outbox);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
_logger.LogWarning(exception, "Сборщик глубин: подсчёт очередей тенанта {TenantId} не удался", tenant.Id);
|
||||
return (0, 0);
|
||||
}
|
||||
finally
|
||||
{
|
||||
tenantContext.Reset();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
using Deal.Api.Services;
|
||||
using Deal.Api.Dtos;
|
||||
|
||||
namespace Deal.Api.Observability;
|
||||
|
||||
/// <summary>
|
||||
|
||||
+744
-773
File diff suppressed because it is too large
Load Diff
@@ -1,193 +1,186 @@
|
||||
using Deal.Api.Events;
|
||||
using Deal.Modules.Kanban.Application.Abstractions;
|
||||
using Deal.Modules.Kanban.Application.Extensions;
|
||||
using Deal.Modules.Kanban.Application.Models;
|
||||
using Deal.Modules.Kanban.Application.Registrars;
|
||||
using Deal.Modules.Kanban.Application.Services;
|
||||
using Deal.Modules.Pipeline.Application.Abstractions;
|
||||
using Deal.Modules.Pipeline.Application.Models;
|
||||
using Deal.Modules.Pipeline.Application.Registrars;
|
||||
using Deal.Modules.Pipeline.Application.Services;
|
||||
using Deal.Api.Dtos;
|
||||
using Deal.Api;
|
||||
using Deal.Api.Services;
|
||||
|
||||
namespace Deal.Api.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Оркестратор ручного тика POST /api/admin/tick (план Tasks 10–11, Ruling 8/9; dashboard_routes.py admin_tick L327–337).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Api-слой объединяет сервисы модулей (Kanban тик правил хранения + Pipeline очистка отсева и pump +
|
||||
/// Projects проверка напоминаний «Отложено») и публикует SSE (Ruling 5/8/9 — публикации только из Api;
|
||||
/// модули остаются чистыми). Порядок 1:1 с прототипом:
|
||||
/// (1) <see cref="StorageTickService.TickAsync"/> — автоархив и очистки архива/корзины;
|
||||
/// (2) <see cref="PipelineProcessingService.PurgeExpiredAsync"/> — отсев старше 3 суток (tick_storage L485–493),
|
||||
/// результат вливается в storage.purgedRejected (Ruling 9);
|
||||
/// (3) SSE-тосты статистики (<see cref="StorageToastPublisher"/>, notify_tick_stats L496–504) — до pump, как в
|
||||
/// прототипе (L333);
|
||||
/// (4) проверка наступивших напоминаний <see cref="CardsService.CheckDueRemindersAsync"/> (admin_tick L334,
|
||||
/// check_reminders L264–282; план Task 11, Ruling 3): «выстрелившие» {id,title,containerId} помечены fired и
|
||||
/// публикуются SSE <c>reminder_due</c> (Ruling 8 — toast НЕ шлём, у фронта модалка ReminderNotice); сбой
|
||||
/// проверки НЕ роняет тик: лог + reminders ответа пуст;
|
||||
/// (5) <see cref="PipelineWorkerService.PumpOnceAsync"/> под общим воркер-гейтом тенанта (Task 10/11): pump
|
||||
/// одного тенанта выполняет либо ручной тик, либо фоновый цикл — при занятом гейте проход пропускается;
|
||||
/// сбой pump НЕ роняет тик: исключение логируется, pipeline ответа пуст ({} как при занятом локе прототипа
|
||||
/// L901–902), очередь остаётся до следующего тика/фонового цикла. Операция отмены (OCE) пробрасывается — запрос прерван;
|
||||
/// (6) SSE new_card по каждой созданной карточке (Ruling 8/9; полный CardDto, как publish из Api);
|
||||
/// (7) queue = строк очереди после pump (queue_len L337). Ответ — <see cref="AdminTickResultDto"/>.
|
||||
/// </remarks>
|
||||
/// <param name="storageTick">Тик правил хранения канбана (StorageTickService модуля Kanban).</param>
|
||||
/// <param name="processing">Очистка отсева и счётчики очереди (модуль Pipeline).</param>
|
||||
/// <param name="worker">Один проход pump по очереди входящих (модуль Pipeline).</param>
|
||||
/// <param name="reminders">Проверка наступивших напоминаний «Отложено» (CardsService, Ruling 3).</param>
|
||||
/// <param name="toastPublisher">Публикатор SSE-тостов статистики тика (общий с фоновым циклом Task 11).</param>
|
||||
/// <param name="broker">SSE-брокер канала тенанта (публикация reminder_due/new_card).</param>
|
||||
/// <param name="pumpGate">Общий воркер-гейт pump тенанта (singleton; общий с фоновым циклом Task 11).</param>
|
||||
/// <param name="logger">Логгер сбоя проверки напоминаний/pump (тик продолжается без этих веток).</param>
|
||||
public sealed class AdminTickOrchestrator(
|
||||
StorageTickService storageTick,
|
||||
PipelineProcessingService processing,
|
||||
PipelineWorkerService worker,
|
||||
CardsService reminders,
|
||||
StorageToastPublisher toastPublisher,
|
||||
SseBroker broker,
|
||||
PipelinePumpGate pumpGate,
|
||||
ILogger<AdminTickOrchestrator> logger)
|
||||
{
|
||||
// SSE-тип события новой карточки (Ruling 5; api.js слушает 'new_card').
|
||||
private const string NewCardEventType = "new_card";
|
||||
|
||||
// SSE-тип события «выстрелившего» напоминания «Отложено» (Ruling 8, api-map §2: {id,title,containerId}).
|
||||
private const string ReminderDueEventType = "reminder_due";
|
||||
|
||||
// Ключи счётчиков pump в pipeline-словаре ответа (1:1 со словарём _pump_unlocked python L921).
|
||||
private static readonly string[] PipelineCounterKeys =
|
||||
[
|
||||
"staged", "rulesStored", "mlStored", "mlDrop", "typeDrop", "aiStored", "aiDrop", "aiFail", "noBudget",
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// Выполняет один ручной тик тенанта: правила хранения + очистка отсева + напоминания + pump + SSE-публикации.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">Тенант-получатель (сессия запроса; канал SSE-публикаций).</param>
|
||||
/// <param name="ct">Токен отмены запроса.</param>
|
||||
/// <returns>Ответ {storage, reminders, pipeline, queue}; сбой проверки напоминаний/pump не выбрасывается наружу.</returns>
|
||||
public async Task<AdminTickResultDto> TickAsync(Guid tenantId, CancellationToken ct)
|
||||
{
|
||||
// (1) Тик правил хранения канбана (как этап 3; leads.py tick_storage L454–484).
|
||||
StorageTickStatsDto storage = await storageTick.TickAsync(ct);
|
||||
|
||||
// (2) Очистка отсева пайплайна: записи старше 3 суток — безвозвратно (tick_storage L485–486); счётчик
|
||||
// вливается в storage.purgedRejected (Ruling 9: ответ тика объединяет статистику, L488–493).
|
||||
int purgedRejected = await processing.PurgeExpiredAsync(ct);
|
||||
StorageTickStatsDto mergedStorage = storage with { PurgedRejected = purgedRejected };
|
||||
|
||||
// (3) Тосты статистики — до pump, как в прототипе (L333): очистка отсева видна, даже если pump упадёт.
|
||||
toastPublisher.PublishTickToasts(tenantId, mergedStorage);
|
||||
|
||||
// (4) Проверка наступивших напоминаний «Отложено» (admin_tick L334 → check_reminders L264–282; план
|
||||
// Task 11, Ruling 3): CheckDueAsync помечает due-строки fired и возвращает их {id,title,stage}. Сбой
|
||||
// проверки НЕ роняет тик: лог + reminders ответа пуст (очередь/хранение продолжают работать).
|
||||
IReadOnlyList<CardReminderDueDto> dueReminders;
|
||||
try
|
||||
{
|
||||
dueReminders = await reminders.CheckDueRemindersAsync(ct);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Запрос отменён — прерываем тик штатно (не «сбой проверки напоминаний»).
|
||||
throw;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogWarning(exception, "POST /api/admin/tick: проверка напоминаний не удалась — reminders ответа пуст");
|
||||
dueReminders = Array.Empty<CardReminderDueDto>();
|
||||
}
|
||||
|
||||
// SSE reminder_due по каждому «выстрелившему» напоминанию (Ruling 8: событие {id,title,stage}, toast НЕ
|
||||
// шлём — у фронта модалка ReminderNotice; без подписчиков канала публикация — no-op). После MarkFired
|
||||
// (внутри CheckDueAsync), как прототип L277–281 — публикуются уже «сработавшие» записи.
|
||||
foreach (CardReminderDueDto due in dueReminders)
|
||||
{
|
||||
broker.Publish(tenantId, ReminderDueEventType, due);
|
||||
}
|
||||
|
||||
// (5) Один проход pump под гейтом тенанта; сбой не роняет тик: pipeline={}, очередь дождётся
|
||||
// следующего тика/фонового цикла (Ruling 10; Task 11 — гейт общий с фоновым циклом).
|
||||
PipelinePumpResult? pump = await PumpOnceSafelyAsync(tenantId, ct);
|
||||
|
||||
// (6) SSE new_card по карточкам, созданным проходом (Ruling 8/9; без подписчиков — no-op).
|
||||
if (pump is not null)
|
||||
{
|
||||
foreach (CardDto card in pump.CreatedCards)
|
||||
{
|
||||
broker.Publish(tenantId, NewCardEventType, card);
|
||||
}
|
||||
}
|
||||
|
||||
// (7) Строк очереди после pump (queue_len L337: total = new + ai).
|
||||
QueueCountsDto queueCounts = await processing.QueueCountsAsync(ct);
|
||||
|
||||
return new AdminTickResultDto(
|
||||
mergedStorage,
|
||||
dueReminders,
|
||||
pump is null ? new Dictionary<string, int>() : ToPipelineWireDict(pump),
|
||||
queueCounts.Total);
|
||||
}
|
||||
|
||||
// Один проход воркера под гейтом тенанта с изоляцией сбоя: исключения pump не роняют тик (Task 10).
|
||||
// tenantId: Тенант тика (ключ гейта, общего с фоновым циклом Task 11).
|
||||
// ct: Токен отмены запроса.
|
||||
// Возвращает: Результат прохода либо null — гейт занят другим воркером/pump упал (pipeline ответа пуст).
|
||||
private async Task<PipelinePumpResult?> PumpOnceSafelyAsync(Guid tenantId, CancellationToken ct)
|
||||
{
|
||||
// Общий воркер-гейт (Ruling 8, аналог asyncio.Lock pipeline.py L40): admin/tick и фоновый цикл не
|
||||
// разбирают очередь тенанта одновременно. Гейт занят (фоновый цикл уже pump'ит) — проход пропускаем,
|
||||
// как прототип при занятом локе (L901–902): pipeline={}, очередь дождётся следующего срабатывания.
|
||||
if (!pumpGate.TryEnter(tenantId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return await worker.PumpOnceAsync(ct);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Запрос отменён — прерываем тик штатно (не «сбой pump»).
|
||||
throw;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogWarning(exception, "POST /api/admin/tick: проход pump не удался — тик возвращает storage без pipeline");
|
||||
return null;
|
||||
}
|
||||
finally
|
||||
{
|
||||
pumpGate.Exit(tenantId);
|
||||
}
|
||||
}
|
||||
|
||||
// Счётчики результата pump → pipeline-словарь ответа (9 ключей словаря python L921; карточки в
|
||||
// wire не выходят — они ушли отдельными SSE new_card).
|
||||
// pump: Результат успешного прохода pump.
|
||||
// Возвращает: Словарь счётчиков в wire-порядке прототипа.
|
||||
private static IReadOnlyDictionary<string, int> ToPipelineWireDict(PipelinePumpResult pump)
|
||||
{
|
||||
int[] counters =
|
||||
[
|
||||
pump.Staged, pump.RulesStored, pump.MlStored, pump.MlDrop, pump.TypeDrop,
|
||||
pump.AiStored, pump.AiDrop, pump.AiFail, pump.NoBudget,
|
||||
];
|
||||
|
||||
var wire = new Dictionary<string, int>(PipelineCounterKeys.Length, StringComparer.Ordinal);
|
||||
for (int index = 0; index < PipelineCounterKeys.Length; index++)
|
||||
{
|
||||
wire[PipelineCounterKeys[index]] = counters[index];
|
||||
}
|
||||
|
||||
return wire;
|
||||
}
|
||||
}
|
||||
using Deal.Api.Dtos;
|
||||
using Deal.Api.Events;
|
||||
using Deal.Modules.Kanban.Application.Models;
|
||||
using Deal.Modules.Kanban.Application.Services;
|
||||
using Deal.Modules.Pipeline.Application.Models;
|
||||
using Deal.Modules.Pipeline.Application.Services;
|
||||
|
||||
namespace Deal.Api.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Оркестратор ручного тика POST /api/admin/tick (план Tasks 10–11, Ruling 8/9; dashboard_routes.py admin_tick L327–337).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Api-слой объединяет сервисы модулей (Kanban тик правил хранения + Pipeline очистка отсева и pump +
|
||||
/// Projects проверка напоминаний «Отложено») и публикует SSE (Ruling 5/8/9 — публикации только из Api;
|
||||
/// модули остаются чистыми). Порядок 1:1 с прототипом:
|
||||
/// (1) <see cref="StorageTickService.TickAsync"/> — автоархив и очистки архива/корзины;
|
||||
/// (2) <see cref="PipelineProcessingService.PurgeExpiredAsync"/> — отсев старше 3 суток (tick_storage L485–493),
|
||||
/// результат вливается в storage.purgedRejected (Ruling 9);
|
||||
/// (3) SSE-тосты статистики (<see cref="StorageToastPublisher"/>, notify_tick_stats L496–504) — до pump, как в
|
||||
/// прототипе (L333);
|
||||
/// (4) проверка наступивших напоминаний <see cref="CardsService.CheckDueRemindersAsync"/> (admin_tick L334,
|
||||
/// check_reminders L264–282; план Task 11, Ruling 3): «выстрелившие» {id,title,containerId} помечены fired и
|
||||
/// публикуются SSE <c>reminder_due</c> (Ruling 8 — toast НЕ шлём, у фронта модалка ReminderNotice); сбой
|
||||
/// проверки НЕ роняет тик: лог + reminders ответа пуст;
|
||||
/// (5) <see cref="PipelineWorkerService.PumpOnceAsync"/> под общим воркер-гейтом тенанта (Task 10/11): pump
|
||||
/// одного тенанта выполняет либо ручной тик, либо фоновый цикл — при занятом гейте проход пропускается;
|
||||
/// сбой pump НЕ роняет тик: исключение логируется, pipeline ответа пуст ({} как при занятом локе прототипа
|
||||
/// L901–902), очередь остаётся до следующего тика/фонового цикла. Операция отмены (OCE) пробрасывается — запрос прерван;
|
||||
/// (6) SSE new_card по каждой созданной карточке (Ruling 8/9; полный CardDto, как publish из Api);
|
||||
/// (7) queue = строк очереди после pump (queue_len L337). Ответ — <see cref="AdminTickResultDto"/>.
|
||||
/// </remarks>
|
||||
/// <param name="storageTick">Тик правил хранения канбана (StorageTickService модуля Kanban).</param>
|
||||
/// <param name="processing">Очистка отсева и счётчики очереди (модуль Pipeline).</param>
|
||||
/// <param name="worker">Один проход pump по очереди входящих (модуль Pipeline).</param>
|
||||
/// <param name="reminders">Проверка наступивших напоминаний «Отложено» (CardsService, Ruling 3).</param>
|
||||
/// <param name="toastPublisher">Публикатор SSE-тостов статистики тика (общий с фоновым циклом Task 11).</param>
|
||||
/// <param name="broker">SSE-брокер канала тенанта (публикация reminder_due/new_card).</param>
|
||||
/// <param name="pumpGate">Общий воркер-гейт pump тенанта (singleton; общий с фоновым циклом Task 11).</param>
|
||||
/// <param name="logger">Логгер сбоя проверки напоминаний/pump (тик продолжается без этих веток).</param>
|
||||
public sealed class AdminTickOrchestrator(
|
||||
StorageTickService storageTick,
|
||||
PipelineProcessingService processing,
|
||||
PipelineWorkerService worker,
|
||||
CardsService reminders,
|
||||
StorageToastPublisher toastPublisher,
|
||||
SseBroker broker,
|
||||
PipelinePumpGate pumpGate,
|
||||
ILogger<AdminTickOrchestrator> logger)
|
||||
{
|
||||
// SSE-тип события новой карточки (Ruling 5; api.js слушает 'new_card').
|
||||
private const string NewCardEventType = "new_card";
|
||||
|
||||
// SSE-тип события «выстрелившего» напоминания «Отложено» (Ruling 8, api-map §2: {id,title,containerId}).
|
||||
private const string ReminderDueEventType = "reminder_due";
|
||||
|
||||
// Ключи счётчиков pump в pipeline-словаре ответа (1:1 со словарём _pump_unlocked python L921).
|
||||
private static readonly string[] PipelineCounterKeys =
|
||||
[
|
||||
"staged", "rulesStored", "mlStored", "mlDrop", "typeDrop", "aiStored", "aiDrop", "aiFail", "noBudget",
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// Выполняет один ручной тик тенанта: правила хранения + очистка отсева + напоминания + pump + SSE-публикации.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">Тенант-получатель (сессия запроса; канал SSE-публикаций).</param>
|
||||
/// <param name="ct">Токен отмены запроса.</param>
|
||||
/// <returns>Ответ {storage, reminders, pipeline, queue}; сбой проверки напоминаний/pump не выбрасывается наружу.</returns>
|
||||
public async Task<AdminTickResultDto> TickAsync(Guid tenantId, CancellationToken ct)
|
||||
{
|
||||
// (1) Тик правил хранения канбана (как этап 3; leads.py tick_storage L454–484).
|
||||
StorageTickStatsDto storage = await storageTick.TickAsync(ct);
|
||||
|
||||
// (2) Очистка отсева пайплайна: записи старше 3 суток — безвозвратно (tick_storage L485–486); счётчик
|
||||
// вливается в storage.purgedRejected (Ruling 9: ответ тика объединяет статистику, L488–493).
|
||||
int purgedRejected = await processing.PurgeExpiredAsync(ct);
|
||||
StorageTickStatsDto mergedStorage = storage with { PurgedRejected = purgedRejected };
|
||||
|
||||
// (3) Тосты статистики — до pump, как в прототипе (L333): очистка отсева видна, даже если pump упадёт.
|
||||
toastPublisher.PublishTickToasts(tenantId, mergedStorage);
|
||||
|
||||
// (4) Проверка наступивших напоминаний «Отложено» (admin_tick L334 → check_reminders L264–282; план
|
||||
// Task 11, Ruling 3): CheckDueAsync помечает due-строки fired и возвращает их {id,title,stage}. Сбой
|
||||
// проверки НЕ роняет тик: лог + reminders ответа пуст (очередь/хранение продолжают работать).
|
||||
IReadOnlyList<CardReminderDueDto> dueReminders;
|
||||
try
|
||||
{
|
||||
dueReminders = await reminders.CheckDueRemindersAsync(ct);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Запрос отменён — прерываем тик штатно (не «сбой проверки напоминаний»).
|
||||
throw;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogWarning(exception, "POST /api/admin/tick: проверка напоминаний не удалась — reminders ответа пуст");
|
||||
dueReminders = Array.Empty<CardReminderDueDto>();
|
||||
}
|
||||
|
||||
// SSE reminder_due по каждому «выстрелившему» напоминанию (Ruling 8: событие {id,title,stage}, toast НЕ
|
||||
// шлём — у фронта модалка ReminderNotice; без подписчиков канала публикация — no-op). После MarkFired
|
||||
// (внутри CheckDueAsync), как прототип L277–281 — публикуются уже «сработавшие» записи.
|
||||
foreach (CardReminderDueDto due in dueReminders)
|
||||
{
|
||||
broker.Publish(tenantId, ReminderDueEventType, due);
|
||||
}
|
||||
|
||||
// (5) Один проход pump под гейтом тенанта; сбой не роняет тик: pipeline={}, очередь дождётся
|
||||
// следующего тика/фонового цикла (Ruling 10; Task 11 — гейт общий с фоновым циклом).
|
||||
PipelinePumpResult? pump = await PumpOnceSafelyAsync(tenantId, ct);
|
||||
|
||||
// (6) SSE new_card по карточкам, созданным проходом (Ruling 8/9; без подписчиков — no-op).
|
||||
if (pump is not null)
|
||||
{
|
||||
foreach (CardDto card in pump.CreatedCards)
|
||||
{
|
||||
broker.Publish(tenantId, NewCardEventType, card);
|
||||
}
|
||||
}
|
||||
|
||||
// (7) Строк очереди после pump (queue_len L337: total = new + ai).
|
||||
QueueCountsDto queueCounts = await processing.QueueCountsAsync(ct);
|
||||
|
||||
return new AdminTickResultDto(
|
||||
mergedStorage,
|
||||
dueReminders,
|
||||
pump is null ? new Dictionary<string, int>() : ToPipelineWireDict(pump),
|
||||
queueCounts.Total);
|
||||
}
|
||||
|
||||
// Один проход воркера под гейтом тенанта с изоляцией сбоя: исключения pump не роняют тик (Task 10).
|
||||
// tenantId: Тенант тика (ключ гейта, общего с фоновым циклом Task 11).
|
||||
// ct: Токен отмены запроса.
|
||||
// Возвращает: Результат прохода либо null — гейт занят другим воркером/pump упал (pipeline ответа пуст).
|
||||
private async Task<PipelinePumpResult?> PumpOnceSafelyAsync(Guid tenantId, CancellationToken ct)
|
||||
{
|
||||
// Общий воркер-гейт (Ruling 8, аналог asyncio.Lock pipeline.py L40): admin/tick и фоновый цикл не
|
||||
// разбирают очередь тенанта одновременно. Гейт занят (фоновый цикл уже pump'ит) — проход пропускаем,
|
||||
// как прототип при занятом локе (L901–902): pipeline={}, очередь дождётся следующего срабатывания.
|
||||
if (!pumpGate.TryEnter(tenantId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return await worker.PumpOnceAsync(ct);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Запрос отменён — прерываем тик штатно (не «сбой pump»).
|
||||
throw;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogWarning(exception, "POST /api/admin/tick: проход pump не удался — тик возвращает storage без pipeline");
|
||||
return null;
|
||||
}
|
||||
finally
|
||||
{
|
||||
pumpGate.Exit(tenantId);
|
||||
}
|
||||
}
|
||||
|
||||
// Счётчики результата pump → pipeline-словарь ответа (9 ключей словаря python L921; карточки в
|
||||
// wire не выходят — они ушли отдельными SSE new_card).
|
||||
// pump: Результат успешного прохода pump.
|
||||
// Возвращает: Словарь счётчиков в wire-порядке прототипа.
|
||||
private static IReadOnlyDictionary<string, int> ToPipelineWireDict(PipelinePumpResult pump)
|
||||
{
|
||||
int[] counters =
|
||||
[
|
||||
pump.Staged, pump.RulesStored, pump.MlStored, pump.MlDrop, pump.TypeDrop,
|
||||
pump.AiStored, pump.AiDrop, pump.AiFail, pump.NoBudget,
|
||||
];
|
||||
|
||||
var wire = new Dictionary<string, int>(PipelineCounterKeys.Length, StringComparer.Ordinal);
|
||||
for (int index = 0; index < PipelineCounterKeys.Length; index++)
|
||||
{
|
||||
wire[PipelineCounterKeys[index]] = counters[index];
|
||||
}
|
||||
|
||||
return wire;
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user