Добить структуру Api, Contracts, SharedKernel и сервисов

Deal.Api/Http -> Services/Models/Extensions; Contracts/Integrations
и SharedKernel/Tenants -> Abstractions/Models; extension-классы
telegram/ml -> Extensions. namespace/using/FQN мигрированы, using
дедуплицированы.
This commit is contained in:
Rustam Khalimov
2026-09-11 13:25:18 +03:00
parent 492950bdd0
commit e3a2692507
191 changed files with 1539 additions and 1350 deletions
@@ -1,10 +1,11 @@
using System.Text.Json;
using Deal.Api.Http;
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.Services;
using Deal.Api.Dtos;
namespace Deal.Api.Endpoints;
@@ -1,8 +1,9 @@
using Deal.Api.Events;
using Deal.Api.Http;
using Deal.Contracts.Integrations;
using Deal.Contracts.Integrations.Models;
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;
+3 -2
View File
@@ -1,4 +1,6 @@
using Deal.Api.Http;
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;
@@ -6,7 +8,6 @@ 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.Services;
using Deal.Api.Dtos;
// Имя конфигурационного типа совпадает с Microsoft.AspNetCore.Http.CookieOptions — фиксируем алиасом.
using CookieOptions = Deal.Api.Configuration.CookieOptions;
@@ -1,7 +1,9 @@
using System.Text.Json;
using Deal.Api.Endpoints.RequestModels;
using Deal.Api.Http;
using Deal.Contracts.Integrations;
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;
@@ -13,7 +15,6 @@ using Deal.Modules.Tenants.Application.Extensions;
using Deal.Modules.Tenants.Application.Models;
using Deal.Modules.Tenants.Application.Registrars;
using Deal.Modules.Tenants.Application.Services;
using Deal.Api.Services;
using Deal.Api.Dtos;
namespace Deal.Api.Endpoints;
@@ -1,6 +1,8 @@
using Deal.Api.Endpoints.RequestModels;
using Deal.Api.Events;
using Deal.Api.Http;
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;
@@ -18,7 +20,6 @@ using Deal.Modules.Tenants.Application.Extensions;
using Deal.Modules.Tenants.Application.Models;
using Deal.Modules.Tenants.Application.Registrars;
using Deal.Modules.Tenants.Application.Services;
using Deal.Api.Services;
using Deal.Api.Dtos;
namespace Deal.Api.Endpoints;
@@ -1,6 +1,8 @@
using System.Text.Json;
using Deal.Api.Endpoints.RequestModels;
using Deal.Api.Http;
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;
@@ -11,7 +13,6 @@ using Deal.Modules.Tenants.Application.Extensions;
using Deal.Modules.Tenants.Application.Models;
using Deal.Modules.Tenants.Application.Registrars;
using Deal.Modules.Tenants.Application.Services;
using Deal.Api.Services;
using Deal.Api.Dtos;
namespace Deal.Api.Endpoints;
@@ -1,7 +1,9 @@
using System.Text.Json;
using Deal.Api.Endpoints.RequestModels;
using Deal.Api.Http;
using Deal.Contracts.Integrations;
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.Discovery.Application.Abstractions;
using Deal.Modules.Discovery.Application.Exceptions;
@@ -14,7 +16,6 @@ using Deal.Modules.Settings.Application.Models;
using Deal.Modules.Settings.Application.Registrars;
using Deal.Modules.Settings.Application.Services;
using Deal.Modules.Telegram.Application;
using Deal.Api.Services;
using Deal.Api.Dtos;
namespace Deal.Api.Endpoints;
@@ -1,5 +1,6 @@
using Deal.Api.Events;
using Deal.Api.Http;
using Deal.Api.Extensions;
using Deal.Api.Models;
using Deal.Api.Services;
using Deal.Api.Dtos;
@@ -1,9 +1,10 @@
using Deal.Api.Http;
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.Services;
using Deal.Api.Dtos;
namespace Deal.Api.Endpoints;
+3 -2
View File
@@ -1,10 +1,11 @@
using Deal.Api.Http;
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.Services;
using Deal.Api.Dtos;
namespace Deal.Api.Endpoints;
+4 -3
View File
@@ -1,11 +1,12 @@
using Deal.Api.Http;
using Deal.Contracts.Integrations;
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.Services;
using Deal.Api.Dtos;
namespace Deal.Api.Endpoints;
@@ -1,10 +1,11 @@
using Deal.Api.Http;
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.Services;
using Deal.Api.Dtos;
namespace Deal.Api.Endpoints;
@@ -1,10 +1,11 @@
using Deal.Api.Http;
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.Services;
using Deal.Api.Dtos;
namespace Deal.Api.Endpoints;
@@ -1,4 +1,6 @@
using Deal.Api.Http;
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;
@@ -6,7 +8,6 @@ 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.Services;
using Deal.Api.Dtos;
using AspNetCoreCookieOptions = Microsoft.AspNetCore.Http.CookieOptions;
// Имя конфигурационного типа совпадает с Microsoft.AspNetCore.Http.CookieOptions — фиксируем алиасами.
@@ -1,4 +1,6 @@
using Deal.Api.Http;
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;
@@ -9,7 +11,6 @@ using Deal.Infrastructure.Integrations.Services;
using Deal.Infrastructure.Persistence;
using Deal.Infrastructure.Persistence.Configurations;
using Microsoft.EntityFrameworkCore;
using Deal.Api.Services;
using Deal.Api.Dtos;
namespace Deal.Api.Endpoints;
@@ -1,10 +1,11 @@
using Deal.Api.Http;
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.Services;
using Deal.Api.Dtos;
namespace Deal.Api.Endpoints;
@@ -1,10 +1,11 @@
using Deal.Api.Http;
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.Services;
using Deal.Api.Dtos;
namespace Deal.Api.Endpoints;
@@ -1,66 +1,67 @@
using Deal.Api.Http;
using Deal.Infrastructure.Tenancy;
using Deal.Api.Services;
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.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,
});
}
}
@@ -1,12 +1,13 @@
using Deal.Api.Endpoints.RequestModels;
using Deal.Api.Http;
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.Services;
using Deal.Api.Dtos;
namespace Deal.Api.Endpoints;
@@ -1,11 +1,12 @@
using Deal.Api.Http;
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.Services;
using Deal.Api.Dtos;
using CookieOptions = Deal.Api.Configuration.CookieOptions;
@@ -1,10 +1,11 @@
using Deal.Api.Endpoints.RequestModels;
using Deal.Api.Http;
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.Services;
using Deal.Api.Dtos;
namespace Deal.Api.Endpoints;
@@ -1,9 +1,10 @@
using Deal.Api.Http;
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.Services;
using Deal.Api.Dtos;
namespace Deal.Api.Endpoints;
@@ -2,7 +2,8 @@ using System.Text.Json;
using Deal.Api;
using Deal.Api.Dtos;
using Deal.Api.Services;
using Deal.Api.Http;
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;
@@ -1,6 +1,7 @@
using Deal.Api.Http;
using Deal.Infrastructure.Services;
using Deal.Api.Extensions;
using Deal.Api.Models;
using Deal.Api.Services;
using Deal.Infrastructure.Services;
using Deal.Api.Dtos;
namespace Deal.Api.Endpoints;
@@ -1,7 +1,9 @@
using Deal.Api.Endpoints.RequestModels;
using Deal.Api.Http;
using Deal.Api.Extensions;
using Deal.Api.Models;
using Deal.Api.Services;
using Deal.Api.Telegram;
using Deal.Contracts.Integrations;
using Deal.Contracts.Integrations.Abstractions;
using Deal.Contracts.Integrations.Models;
using Deal.Modules.Telegram.Application;
using Deal.Modules.Telegram.Application.Models;
@@ -10,7 +12,6 @@ using Deal.Modules.Tenants.Application.Extensions;
using Deal.Modules.Tenants.Application.Models;
using Deal.Modules.Tenants.Application.Registrars;
using Deal.Modules.Tenants.Application.Services;
using Deal.Api.Services;
using Deal.Api.Dtos;
namespace Deal.Api.Endpoints;
@@ -1,8 +1,9 @@
using Deal.Api.Http;
using Deal.Contracts.Integrations;
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.Services;
using Deal.Api.Dtos;
namespace Deal.Api.Endpoints;
@@ -1,7 +1,8 @@
using Deal.Api.Services;
using Deal.Api.Dtos;
using Deal.Api.Models;
namespace Deal.Api.Http;
namespace Deal.Api.Extensions;
/// <summary>
/// Хелперы доступа к текущему пользователю запроса (минимальные API).
@@ -9,7 +9,8 @@ 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;
using Deal.SharedKernel.Tenants.Abstractions;
using Deal.SharedKernel.Tenants.Models;
using Deal.Api.Services;
using Deal.Api.Dtos;
@@ -14,7 +14,8 @@ 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;
using Deal.SharedKernel.Tenants.Abstractions;
using Deal.SharedKernel.Tenants.Models;
using Deal.Api.Services;
using Deal.Api.Dtos;
@@ -13,7 +13,8 @@ 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;
using Deal.SharedKernel.Tenants.Abstractions;
using Deal.SharedKernel.Tenants.Models;
using Deal.Api.Services;
using Deal.Api.Dtos;
@@ -1,12 +1,13 @@
using Deal.Api.Configuration;
using Deal.Api.Http;
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.Services;
using Deal.Api.Dtos;
// Имя конфигурационного типа совпадает с Microsoft.AspNetCore.Http.CookieOptions — фиксируем алиасом.
using OperatorCookieOptions = Deal.Api.Configuration.OperatorCookieOptions;
+155 -154
View File
@@ -1,154 +1,155 @@
using System.Threading.RateLimiting;
using Deal.Api.Configuration;
using Deal.Api.Http;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.Extensions.DependencyInjection;
using Deal.Api.Services;
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;
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);
}
}
@@ -1,13 +1,15 @@
using Deal.Api.Configuration;
using Deal.Api.Http;
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;
using Deal.SharedKernel.Tenants.Abstractions;
using Deal.SharedKernel.Tenants.Models;
using Microsoft.Extensions.Options;
using Deal.Api.Services;
using Deal.Api.Dtos;
// Имя конфигурационного типа совпадает с Microsoft.AspNetCore.Http.CookieOptions — фиксируем алиасом.
using CookieOptions = Deal.Api.Configuration.CookieOptions;
@@ -1,7 +1,8 @@
using Deal.Api.Services;
using Deal.Api.Dtos;
using Deal.Api.Extensions;
namespace Deal.Api.Http;
namespace Deal.Api.Models;
/// <summary>
/// Текущий оператор запроса — кладёт OperatorSessionMiddleware в <c>HttpContext.Items</c> (Ruling 1).
@@ -1,7 +1,8 @@
using Deal.Api.Services;
using Deal.Api.Dtos;
using Deal.Api.Extensions;
namespace Deal.Api.Http;
namespace Deal.Api.Models;
/// <summary>
/// Текущий пользователь запроса — кладётся SessionMiddleware в <c>HttpContext.Items</c>.
@@ -14,7 +14,8 @@ 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;
using Deal.SharedKernel.Tenants.Abstractions;
using Deal.SharedKernel.Tenants.Models;
using Microsoft.EntityFrameworkCore;
using Deal.Api.Services;
using Deal.Api.Dtos;
+6 -3
View File
@@ -8,12 +8,14 @@ using Deal.Api.Configuration;
using Deal.Api.Endpoints;
using Deal.Api.Events;
using Deal.Api.Hosting;
using Deal.Api.Http;
using Deal.Api.Extensions;
using Deal.Api.Models;
using Deal.Api.Logging;
using Deal.Api.Middleware;
using Deal.Api.Observability;
using Deal.Api.Telegram;
using Deal.Contracts.Integrations;
using Deal.Contracts.Integrations.Abstractions;
using Deal.Contracts.Integrations.Models;
using Deal.Infrastructure;
using Deal.Infrastructure.Data;
using Deal.Infrastructure.Integrations.Abstractions;
@@ -53,7 +55,8 @@ 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;
using Deal.SharedKernel.Tenants.Abstractions;
using Deal.SharedKernel.Tenants.Models;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.AspNetCore.Server.Kestrel.Core;
using Microsoft.AspNetCore.Server.Kestrel.Https;
@@ -6,8 +6,10 @@ using Deal.Modules.Tenants.Application.Services;
using Microsoft.Extensions.DependencyInjection;
using Deal.Api.Services;
using Deal.Api.Dtos;
using Deal.Api.Extensions;
using Deal.Api.Models;
namespace Deal.Api.Http;
namespace Deal.Api.Services;
/// <summary>
/// Хелпер записи аудита действий пользователей тенанта и операторов (единая точка — <see cref="AuditService"/>).
@@ -1,7 +1,9 @@
using Deal.Api.Services;
using Deal.Api.Dtos;
using Deal.Api.Extensions;
using Deal.Api.Models;
namespace Deal.Api.Http;
namespace Deal.Api.Services;
/// <summary>
/// Общие ответы ошибок минимальных API: HTTP-код + <c>{"detail":"…"}</c> (формат прототипа, Ruling 10).
@@ -6,8 +6,10 @@ using Deal.Modules.Tenants.Application.Registrars;
using Deal.Modules.Tenants.Application.Services;
using Deal.Api.Services;
using Deal.Api.Dtos;
using Deal.Api.Extensions;
using Deal.Api.Models;
namespace Deal.Api.Http;
namespace Deal.Api.Services;
/// <summary>
/// Прикладной guard неудачных попыток входа (план Task 11, Ruling 5; этап 12, пакет B — хранилище на
@@ -9,7 +9,8 @@ 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;
using Deal.SharedKernel.Tenants.Abstractions;
using Deal.SharedKernel.Tenants.Models;
using Deal.Api.Dtos;
using Deal.Api;
using Deal.Api.Services;
@@ -3,8 +3,10 @@ using Deal.Api.Dtos;
using AspNetCoreCookieOptions = Microsoft.AspNetCore.Http.CookieOptions;
using CookieOptions = Deal.Api.Configuration.CookieOptions;
using Deal.Api.Extensions;
using Deal.Api.Models;
namespace Deal.Api.Http;
namespace Deal.Api.Services;
/// <summary>
/// Единая запись httpOnly-куки сессии тенант-пользователя (login/change-password/impersonation).
@@ -7,8 +7,10 @@ using Deal.Modules.Tenants.Application.Services;
using Microsoft.Extensions.DependencyInjection;
using Deal.Api.Services;
using Deal.Api.Dtos;
using Deal.Api.Extensions;
using Deal.Api.Models;
namespace Deal.Api.Http;
namespace Deal.Api.Services;
/// <summary>
/// RateLimiter фиксированного окна с состоянием в Postgres (этап 12, пакет B): лимиты переживают
@@ -1,110 +1,111 @@
using System.Threading.RateLimiting;
using Deal.Api.Http;
using Deal.Api.Middleware;
using Grpc.Core;
using Grpc.Core.Interceptors;
using Microsoft.Extensions.DependencyInjection;
using Deal.Api.Services;
using Deal.Api.Dtos;
namespace Deal.Api.Telegram;
/// <summary>
/// Серверный интерцептор rate limit gRPC-ингресса (план Task 11, Ruling 5; этап 12, пакет B — хранилище
/// на Postgres): фиксированное окно (GrpcIngressPerMinute в минуту) по gRPC-metadata «tenant-id» —
/// каждый тенант имеет собственный счётчик входящего потока telegram-service, общий для всех инстансов
/// core. Стандартный grpc.health.v1.Health не ограничивается (инфраструктурный liveness, как у
/// IngressServiceTokenInterceptor). Превышение лимита — RPC-отказ RESOURCE_EXHAUSTED (gRPC-аналог
/// HTTP 429) до вызова метода сервиса.
/// </summary>
/// <remarks>
/// Окно считает общий <see cref="PartitionedRateLimiter{TResource}"/> (регистрируется в DI singleton'ом
/// рядом с AddGrpc): экземпляры интерцептора создаются фреймворком, но партиции окон живут в одном
/// лимитере — иначе каждая регистрация/вызов получил бы собственное окно и лимит не работал бы.
/// Счётчики партиций — в Postgres через <see cref="StoreBackedFixedWindowRateLimiter"/>. Лимитер
/// регистрируется и интерцептор добавляется только при RateLimit:Enabled=true (dev — plaintext-поток
/// без лимита, как HTTP-политики, Ruling 5).
/// </remarks>
public sealed class IngressRateLimitInterceptor : Interceptor
{
// Ключ партиции вызовов без metadata tenant-id (общий «мусорный» бакет — сервис всё равно отклонит).
private const string MissingTenantIdKey = "missing-tenant-id";
// Префикс ключа хранилища: изолирует счётчики ингресса от HTTP-политик и login-guard.
private const string StoreKeyPrefix = "grpc:ingress:";
// Окно политики ингресса — 1 минута (имя настройки — GrpcIngressPerMinute).
private static readonly TimeSpan Window = TimeSpan.FromMinutes(1);
private readonly PartitionedRateLimiter<string> _limiter;
/// <summary>
/// Создаёт интерцептор с общим лимитером ингресса (см. CreateLimiter).
/// </summary>
/// <param name="limiter">Singleton-лимитер, зарегистрированный хостом.</param>
public IngressRateLimitInterceptor(PartitionedRateLimiter<string> limiter)
{
_limiter = limiter ?? throw new ArgumentNullException(nameof(limiter));
}
/// <summary>
/// Создаёт лимитер ингресса: фиксированное окно в минуту, партиция на каждый tenant-id,
/// счётчики — в общем хранилище Postgres.
/// </summary>
/// <param name="scopeFactory">Фабрика scope: store-backed лимитер резолвит хранилище на каждое приобретение.</param>
/// <param name="permitsPerMinute">Разрешено вызовов на тенанта в минуту (RateLimit:GrpcIngressPerMinute).</param>
/// <returns>Общий PartitionedRateLimiter по строковому tenant-id.</returns>
public static PartitionedRateLimiter<string> CreateLimiter(IServiceScopeFactory scopeFactory, int permitsPerMinute)
{
ArgumentNullException.ThrowIfNull(scopeFactory);
ArgumentOutOfRangeException.ThrowIfLessThan(permitsPerMinute, 1);
return PartitionedRateLimiter.Create<string, string>(
tenantId =>
{
string key = StoreKeyPrefix + tenantId;
return RateLimitPartition.Get(key, storeKey => new StoreBackedFixedWindowRateLimiter(
scopeFactory,
storeKey,
permitsPerMinute,
Window,
() => DateTimeOffset.UtcNow));
});
}
/// <summary>
/// Проверка лимита для unary-RPC: health-методы пропускаются, остальные получают разрешение
/// партиции tenant-id; исчерпание окна — RESOURCE_EXHAUSTED.
/// </summary>
/// <typeparam name="TRequest">Тип запроса gRPC.</typeparam>
/// <typeparam name="TResponse">Тип ответа gRPC.</typeparam>
/// <param name="request">Тело запроса.</param>
/// <param name="context">Контекст вызова (metadata tenant-id/service-token).</param>
/// <param name="continuation">Следующий обработчик в цепочке.</param>
/// <returns>Ответ нижестоящего обработчика.</returns>
public override async Task<TResponse> UnaryServerHandler<TRequest, TResponse>(
TRequest request,
ServerCallContext context,
UnaryServerMethod<TRequest, TResponse> continuation)
{
if (context.Method.StartsWith(IngressServiceTokenInterceptor.HealthMethodPrefix, StringComparison.Ordinal))
{
return await continuation(request, context).ConfigureAwait(false);
}
string? tenantId = context.RequestHeaders.GetValue(TelegramIngressService.TenantIdMetadataKey);
// permitCount указывается явно: AcquireAsync(TResource, int permitCount, CancellationToken).
using RateLimitLease lease = await _limiter
.AcquireAsync(tenantId ?? MissingTenantIdKey, permitCount: 1, context.CancellationToken)
.ConfigureAwait(false);
if (!lease.IsAcquired)
{
throw Rejection();
}
return await continuation(request, context).ConfigureAwait(false);
}
// Создаёт отказ RESOURCE_EXHAUSTED с текстом 429-детали политик (единый detail rate limit).
private static RpcException Rejection()
=> new(new Status(StatusCode.ResourceExhausted, RateLimitPolicies.RejectedDetail));
}
using System.Threading.RateLimiting;
using Deal.Api.Extensions;
using Deal.Api.Models;
using Deal.Api.Services;
using Deal.Api.Middleware;
using Grpc.Core;
using Grpc.Core.Interceptors;
using Microsoft.Extensions.DependencyInjection;
using Deal.Api.Dtos;
namespace Deal.Api.Telegram;
/// <summary>
/// Серверный интерцептор rate limit gRPC-ингресса (план Task 11, Ruling 5; этап 12, пакет B — хранилище
/// на Postgres): фиксированное окно (GrpcIngressPerMinute в минуту) по gRPC-metadata «tenant-id» —
/// каждый тенант имеет собственный счётчик входящего потока telegram-service, общий для всех инстансов
/// core. Стандартный grpc.health.v1.Health не ограничивается (инфраструктурный liveness, как у
/// IngressServiceTokenInterceptor). Превышение лимита — RPC-отказ RESOURCE_EXHAUSTED (gRPC-аналог
/// HTTP 429) до вызова метода сервиса.
/// </summary>
/// <remarks>
/// Окно считает общий <see cref="PartitionedRateLimiter{TResource}"/> (регистрируется в DI singleton'ом
/// рядом с AddGrpc): экземпляры интерцептора создаются фреймворком, но партиции окон живут в одном
/// лимитере — иначе каждая регистрация/вызов получил бы собственное окно и лимит не работал бы.
/// Счётчики партиций — в Postgres через <see cref="StoreBackedFixedWindowRateLimiter"/>. Лимитер
/// регистрируется и интерцептор добавляется только при RateLimit:Enabled=true (dev — plaintext-поток
/// без лимита, как HTTP-политики, Ruling 5).
/// </remarks>
public sealed class IngressRateLimitInterceptor : Interceptor
{
// Ключ партиции вызовов без metadata tenant-id (общий «мусорный» бакет — сервис всё равно отклонит).
private const string MissingTenantIdKey = "missing-tenant-id";
// Префикс ключа хранилища: изолирует счётчики ингресса от HTTP-политик и login-guard.
private const string StoreKeyPrefix = "grpc:ingress:";
// Окно политики ингресса — 1 минута (имя настройки — GrpcIngressPerMinute).
private static readonly TimeSpan Window = TimeSpan.FromMinutes(1);
private readonly PartitionedRateLimiter<string> _limiter;
/// <summary>
/// Создаёт интерцептор с общим лимитером ингресса (см. CreateLimiter).
/// </summary>
/// <param name="limiter">Singleton-лимитер, зарегистрированный хостом.</param>
public IngressRateLimitInterceptor(PartitionedRateLimiter<string> limiter)
{
_limiter = limiter ?? throw new ArgumentNullException(nameof(limiter));
}
/// <summary>
/// Создаёт лимитер ингресса: фиксированное окно в минуту, партиция на каждый tenant-id,
/// счётчики — в общем хранилище Postgres.
/// </summary>
/// <param name="scopeFactory">Фабрика scope: store-backed лимитер резолвит хранилище на каждое приобретение.</param>
/// <param name="permitsPerMinute">Разрешено вызовов на тенанта в минуту (RateLimit:GrpcIngressPerMinute).</param>
/// <returns>Общий PartitionedRateLimiter по строковому tenant-id.</returns>
public static PartitionedRateLimiter<string> CreateLimiter(IServiceScopeFactory scopeFactory, int permitsPerMinute)
{
ArgumentNullException.ThrowIfNull(scopeFactory);
ArgumentOutOfRangeException.ThrowIfLessThan(permitsPerMinute, 1);
return PartitionedRateLimiter.Create<string, string>(
tenantId =>
{
string key = StoreKeyPrefix + tenantId;
return RateLimitPartition.Get(key, storeKey => new StoreBackedFixedWindowRateLimiter(
scopeFactory,
storeKey,
permitsPerMinute,
Window,
() => DateTimeOffset.UtcNow));
});
}
/// <summary>
/// Проверка лимита для unary-RPC: health-методы пропускаются, остальные получают разрешение
/// партиции tenant-id; исчерпание окна — RESOURCE_EXHAUSTED.
/// </summary>
/// <typeparam name="TRequest">Тип запроса gRPC.</typeparam>
/// <typeparam name="TResponse">Тип ответа gRPC.</typeparam>
/// <param name="request">Тело запроса.</param>
/// <param name="context">Контекст вызова (metadata tenant-id/service-token).</param>
/// <param name="continuation">Следующий обработчик в цепочке.</param>
/// <returns>Ответ нижестоящего обработчика.</returns>
public override async Task<TResponse> UnaryServerHandler<TRequest, TResponse>(
TRequest request,
ServerCallContext context,
UnaryServerMethod<TRequest, TResponse> continuation)
{
if (context.Method.StartsWith(IngressServiceTokenInterceptor.HealthMethodPrefix, StringComparison.Ordinal))
{
return await continuation(request, context).ConfigureAwait(false);
}
string? tenantId = context.RequestHeaders.GetValue(TelegramIngressService.TenantIdMetadataKey);
// permitCount указывается явно: AcquireAsync(TResource, int permitCount, CancellationToken).
using RateLimitLease lease = await _limiter
.AcquireAsync(tenantId ?? MissingTenantIdKey, permitCount: 1, context.CancellationToken)
.ConfigureAwait(false);
if (!lease.IsAcquired)
{
throw Rejection();
}
return await continuation(request, context).ConfigureAwait(false);
}
// Создаёт отказ RESOURCE_EXHAUSTED с текстом 429-детали политик (единый detail rate limit).
private static RpcException Rejection()
=> new(new Status(StatusCode.ResourceExhausted, RateLimitPolicies.RejectedDetail));
}
@@ -16,7 +16,8 @@ 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;
using Deal.SharedKernel.Tenants.Abstractions;
using Deal.SharedKernel.Tenants.Models;
using Grpc.Core;
using Deal.Api.Services;
using Deal.Api.Dtos;
@@ -1,5 +1,5 @@
using System.Text.Json;
using Deal.Contracts.Integrations;
using Deal.Contracts.Integrations.Abstractions;
using Deal.Contracts.Integrations.Models;
using Deal.Modules.Settings.Application.Abstractions;
using Deal.Modules.Settings.Application.Models;
@@ -1,6 +1,6 @@
using Deal.Contracts.Integrations.Models;
namespace Deal.Contracts.Integrations;
namespace Deal.Contracts.Integrations.Abstractions;
/// <summary>
/// Порт ИИ-классификатора входящих сообщений (Ruling 5, план Task 6 L370388).
@@ -1,6 +1,6 @@
using Deal.Contracts.Integrations.Models;
namespace Deal.Contracts.Integrations;
namespace Deal.Contracts.Integrations.Abstractions;
/// <summary>
/// Порт ИИ-инструментов Discovery и генерации ключевых слов (Ruling 9, план Task 15/18/19).
@@ -1,6 +1,6 @@
using Deal.Contracts.Integrations.Models;
namespace Deal.Contracts.Integrations;
namespace Deal.Contracts.Integrations.Abstractions;
/// <summary>
/// Порт ИИ-предложений колонок и ключей — план Task 14 (L462491), Ruling 3.
@@ -1,6 +1,6 @@
using Deal.Contracts.Integrations.Models;
namespace Deal.Contracts.Integrations;
namespace Deal.Contracts.Integrations.Abstractions;
/// <summary>
/// Порт файлового хранилища вложений (Ruling 4, план Task 6; 1:1 <c>backend/app/services/object_store.py</c> L61107).
@@ -1,6 +1,6 @@
using Deal.Contracts.Integrations.Models;
namespace Deal.Contracts.Integrations;
namespace Deal.Contracts.Integrations.Abstractions;
/// <summary>
/// Порт клиента автономного ML-сервиса (Ruling 4, план Task 9 L333367).
@@ -1,6 +1,6 @@
using Deal.Contracts.Integrations.Models;
namespace Deal.Contracts.Integrations;
namespace Deal.Contracts.Integrations.Abstractions;
/// <summary>
/// Порт-гейт к автономному telegram-service: команды ядра наружу (Ruling 7, план Task 13/14/18).
@@ -4,7 +4,7 @@ namespace Deal.Contracts.Integrations.Models;
/// Дескриптор объекта файлового хранилища: ключ, размер и MIME-тип (Ruling 4, Task 6).
/// </summary>
/// <remarks>
/// Тип-описатель порта <see cref="Deal.Contracts.Integrations.IFileStorage"/>: в нём хранилище отдаёт
/// Тип-описатель порта <see cref="Deal.Contracts.Integrations.Abstractions.IFileStorage"/>: в нём хранилище отдаёт
/// метаданные объекта (аналог stat object_store.py — MinIO StatObject / размер файла на диске) методом
/// <c>StatAsync</c>. Потребитель — download-эндпоинт файлов проектных карточек (Task 9, Ruling T6):
/// Content-Length/Content-Type ответа берутся из дескриптора, а null (объекта нет) мапится в 404
@@ -1,4 +1,6 @@
namespace Deal.Contracts.Integrations;
using Deal.Contracts.Integrations.Abstractions;
namespace Deal.Contracts.Integrations.Models;
/// <summary>
/// Общие метки обучения ML-модели (значения 1:1 с ml_client.py). Единый источник для всех модулей,
@@ -4,7 +4,7 @@ namespace Deal.Contracts.Integrations.Models;
/// Тело ответа GET /api/ml/status (ml_routes.py L6675 + ml_client.snapshot() L138150).
/// </summary>
/// <remarks>
/// Собирается реализацией <see cref="Deal.Contracts.Integrations.IMlClient.StatusAsync"/> из статуса
/// Собирается реализацией <see cref="Deal.Contracts.Integrations.Abstractions.IMlClient.StatusAsync"/> из статуса
/// ML-сервиса (<see cref="Service"/>) и локальных настроек/счётчиков тенанта (KV, Ruling 1):
/// <c>enabled</c> — выключатель <c>mlEnabled</c> (семантика «не false», ml_routes.py L71), <c>reachable</c> —
/// доступность сервиса, <c>stats</c> — локальная статистика. Фронт читает: reachable,
@@ -1,4 +1,6 @@
namespace Deal.Contracts.Integrations;
using Deal.Contracts.Integrations.Abstractions;
namespace Deal.Contracts.Integrations.Models;
/// <summary>
/// Общие дефолты источника (канал/группа/кандидат), 1:1 с db.py (hue по умолчанию — «#666»).
@@ -1,4 +1,5 @@
using Deal.SharedKernel.Tenants;
using Deal.SharedKernel.Tenants.Abstractions;
using Deal.SharedKernel.Tenants.Models;
using Microsoft.Extensions.Configuration;
namespace Deal.Infrastructure.Data;
@@ -1,4 +1,5 @@
using Deal.SharedKernel.Tenants;
using Deal.SharedKernel.Tenants.Abstractions;
using Deal.SharedKernel.Tenants.Models;
namespace Deal.Infrastructure.Data;
@@ -11,7 +11,7 @@ namespace Deal.Infrastructure.Integrations.Abstractions;
/// Порт отправки батча обучения в ml-service (RPC TrainBatch, ml.proto L5255) — для MlOutboxFlushScheduler.
/// </summary>
/// <remarks>
/// Отдельный от <see cref="Deal.Contracts.Integrations.IMlClient"/> порт: сигнатура IMlClient не меняется
/// Отдельный от <see cref="Deal.Contracts.Integrations.Abstractions.IMlClient"/> порт: сигнатура IMlClient не меняется
/// (Self-Review плана L530), а выгрузку очереди делает фоновый флашер (Ruling 6), которому нужен только
/// TrainBatch. Реализуется gRPC-адаптером <see cref="GrpcMlClient"/> и регистрируется только при
/// <c>Services:Ml:UseLocal=false</c> (Local-режиму ml-service не нужен — очередь копится, как в этапе 3).
@@ -1,11 +1,12 @@
using Deal.Contracts.Integrations;
using Deal.Contracts.Integrations.Abstractions;
using Deal.Contracts.Integrations.Models;
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;
using Deal.SharedKernel.Tenants.Abstractions;
using Deal.SharedKernel.Tenants.Models;
using Microsoft.Extensions.Logging;
using Deal.Infrastructure.Integrations.Abstractions;
using Deal.Infrastructure.Integrations.Exceptions;
@@ -1,11 +1,12 @@
using Deal.Contracts.Integrations;
using Deal.Contracts.Integrations.Abstractions;
using Deal.Contracts.Integrations.Models;
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;
using Deal.SharedKernel.Tenants.Abstractions;
using Deal.SharedKernel.Tenants.Models;
using Microsoft.Extensions.Logging;
using Deal.Infrastructure.Integrations.Abstractions;
using Deal.Infrastructure.Integrations.Exceptions;
@@ -1,11 +1,12 @@
using Deal.Contracts.Integrations;
using Deal.Contracts.Integrations.Abstractions;
using Deal.Contracts.Integrations.Models;
using Deal.Grpc.Ai;
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.SharedKernel.Tenants;
using Deal.SharedKernel.Tenants.Abstractions;
using Deal.SharedKernel.Tenants.Models;
using Grpc.Core;
using Microsoft.Extensions.Logging;
using Deal.Infrastructure.Integrations.Abstractions;
@@ -1,7 +1,8 @@
using Deal.Contracts.Integrations;
using Deal.Contracts.Integrations.Abstractions;
using Deal.Contracts.Integrations.Models;
using Deal.Grpc.Ai;
using Deal.SharedKernel.Tenants;
using Deal.SharedKernel.Tenants.Abstractions;
using Deal.SharedKernel.Tenants.Models;
using Grpc.Core;
using Microsoft.Extensions.Logging;
using Deal.Infrastructure.Integrations.Abstractions;
@@ -1,5 +1,5 @@
using System.Text.Json;
using Deal.Contracts.Integrations;
using Deal.Contracts.Integrations.Abstractions;
using Deal.Contracts.Integrations.Models;
using Deal.Grpc.Ml;
using Deal.Modules.Kanban.Application.Abstractions;
@@ -16,7 +16,8 @@ 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;
using Deal.SharedKernel.Tenants.Abstractions;
using Deal.SharedKernel.Tenants.Models;
using Grpc.Core;
using Microsoft.Extensions.Logging;
using Deal.Infrastructure.Integrations.Abstractions;
@@ -1,7 +1,8 @@
using Deal.Contracts.Integrations;
using Deal.Contracts.Integrations.Abstractions;
using Deal.Contracts.Integrations.Models;
using Deal.Grpc.Telegram;
using Deal.SharedKernel.Tenants;
using Deal.SharedKernel.Tenants.Abstractions;
using Deal.SharedKernel.Tenants.Models;
using Grpc.Core;
using Microsoft.Extensions.Logging;
using Deal.Infrastructure.Integrations.Abstractions;
@@ -1,4 +1,4 @@
using Deal.Contracts.Integrations;
using Deal.Contracts.Integrations.Abstractions;
using Deal.Contracts.Integrations.Models;
using Deal.Modules.Pipeline.Application.Abstractions;
using Deal.Modules.Pipeline.Application.Models;
@@ -1,4 +1,4 @@
using Deal.Contracts.Integrations;
using Deal.Contracts.Integrations.Abstractions;
using Deal.Contracts.Integrations.Models;
using Deal.Infrastructure.Integrations.Abstractions;
using Deal.Infrastructure.Integrations.Exceptions;
@@ -1,6 +1,6 @@
using System.Globalization;
using System.Text.Json;
using Deal.Contracts.Integrations;
using Deal.Contracts.Integrations.Abstractions;
using Deal.Contracts.Integrations.Models;
using Deal.Modules.Cards.Application.Abstractions;
using Deal.Modules.Cards.Application.Dtos;
@@ -1,5 +1,5 @@
using System.Text.Json;
using Deal.Contracts.Integrations;
using Deal.Contracts.Integrations.Abstractions;
using Deal.Contracts.Integrations.Models;
using Deal.Modules.Kanban.Application.Abstractions;
using Deal.Modules.Kanban.Application.Extensions;
@@ -1,4 +1,4 @@
using Deal.Contracts.Integrations;
using Deal.Contracts.Integrations.Abstractions;
using Deal.Contracts.Integrations.Models;
using Deal.Infrastructure.Integrations.Abstractions;
using Deal.Infrastructure.Integrations.Exceptions;
@@ -11,7 +11,8 @@ using Deal.Modules.Tenants.Application.Models;
using Deal.Modules.Tenants.Application.Registrars;
using Deal.Modules.Tenants.Application.Services;
using Deal.SharedKernel.Observability;
using Deal.SharedKernel.Tenants;
using Deal.SharedKernel.Tenants.Abstractions;
using Deal.SharedKernel.Tenants.Models;
using Deal.Infrastructure.Integrations.Abstractions;
using Deal.Infrastructure.Integrations.Exceptions;
using Deal.Infrastructure.Integrations.Extensions;
@@ -1,4 +1,5 @@
using Deal.Contracts.Integrations;
using Deal.Contracts.Integrations.Abstractions;
using Deal.Contracts.Integrations.Models;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
@@ -1,4 +1,4 @@
using Deal.Contracts.Integrations;
using Deal.Contracts.Integrations.Abstractions;
using Deal.Contracts.Integrations.Models;
using Deal.Infrastructure.Integrations.Storage.Extensions;
using Deal.Infrastructure.Integrations.Storage.Options;
@@ -1,4 +1,4 @@
using Deal.Contracts.Integrations;
using Deal.Contracts.Integrations.Abstractions;
using Deal.Contracts.Integrations.Models;
using Microsoft.Extensions.Logging;
using Minio;
@@ -1,4 +1,5 @@
using Deal.Contracts.Integrations;
using Deal.Contracts.Integrations.Abstractions;
using Deal.Contracts.Integrations.Models;
using Deal.Infrastructure.Integrations.Abstractions;
using Deal.Infrastructure.Integrations.Exceptions;
using Deal.Infrastructure.Integrations.Extensions;
@@ -39,7 +40,8 @@ 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;
using Deal.SharedKernel.Tenants.Abstractions;
using Deal.SharedKernel.Tenants.Models;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
@@ -8,7 +8,8 @@ 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;
using Deal.SharedKernel.Tenants.Abstractions;
using Deal.SharedKernel.Tenants.Models;
using Microsoft.EntityFrameworkCore;
using Npgsql;
@@ -5,7 +5,8 @@ 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;
using Deal.SharedKernel.Tenants.Abstractions;
using Deal.SharedKernel.Tenants.Models;
using Microsoft.Extensions.Logging;
namespace Deal.Infrastructure.Tenancy;
@@ -1,4 +1,5 @@
using Deal.Contracts.Integrations;
using Deal.Contracts.Integrations.Abstractions;
using Deal.Contracts.Integrations.Models;
using Deal.Modules.Discovery.Application.Models;
using Deal.Modules.Discovery.Application.Abstractions;
using Deal.Modules.Discovery.Application.Exceptions;
@@ -1,337 +1,337 @@
using System.Text;
using Deal.Contracts.Integrations;
using Deal.Contracts.Integrations.Models;
using Deal.Modules.Discovery.Application.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.Discovery.Application.Abstractions;
using Deal.Modules.Discovery.Application.Exceptions;
using Deal.Modules.Discovery.Application.Extensions;
using Deal.Modules.Discovery.Application.Registrars;
namespace Deal.Modules.Discovery.Application.Services;
/// <summary>
/// Оценка содержания кандидата: фит сообщений под задачу поиска (1:1 discovery_eval.py целиком, план Task 18).
/// </summary>
/// <remarks>
/// Каскад оценки одного сообщения (python evaluate_message L174194):
/// <list type="number">
/// <item>текст пустой/короче 10 символов → «слишком короткое» (False, эвристика);</item>
/// <item>ML при mlEnabled (<see cref="IMlClient.PredictAsync"/>): take + label=spam → «ML: спам» (False);
/// сбой прогноза не роняет оценку (python L101107: «не уверен»);</item>
/// <item>ИИ при aiEnabled (<see cref="IAiTools.EvaluateFitAsync"/>): любая ошибка (Local — NotSupportedException,
/// gRPC — AiUnavailableException/…, Ruling 10) ловится и оценка продолжается эвристикой (python L186194);</item>
/// <item>эвристика: любой ключ задачи входит в очищенный текст без учёта регистра.</item>
/// </list>
/// <see cref="GroupByTopic"/> — группировка выборки форума по topic_id (null → «main») с заголовками-сниппетами
/// (python L86117); <see cref="Passed"/> — вердикт «источник подходит»: выборка ≥3 сообщений и доля fit ≥
/// threshold, % (python L229237). Результаты по сообщениям <see cref="DiscoveryMessageFit"/> собирает
/// <see cref="EvaluateSampleAsync"/> в агрегат <see cref="DiscoveryEvalSample"/> (fit_count/total/fit_ratio,
/// python L197226). Язык выборки — отдельный детектор <see cref="DiscoveryLangDetector"/> (зовёт воркер).
/// </remarks>
public sealed class DiscoveryEvaluator
{
// Минимальная длина сообщения для содержательной оценки (python _MIN_TEXT_LEN L39).
private const int MinTextLength = 10;
// Лимит текста, уходящего ИИ-провайдеру, в символах (python _AI_TEXT_LIMIT L41).
private const int AiTextLimit = 4000;
// Потолок причины из ИИ (python _AI_REASON_LIMIT L43).
private const int AiReasonLimit = 200;
// Минимальный объём содержательной выборки для вердикта оценки (python _MIN_CONTENT L77).
private const int MinContentMessages = 3;
// topic_id=null в выборке/группировке → общая тема «main» (python _MAIN_TOPIC L47).
private const string MainTopic = "main";
// Длина заголовка темы-сниппета (python _TITLE_LIMIT L45).
private const int TopicTitleLimit = 60;
// Причина по умолчанию при фите ИИ (python _ai_reason L170).
private const string AiFitReasonDefault = "подходит";
// Причина по умолчанию при не-фите ИИ (python _ai_reason L170).
private const string AiNotFitReasonDefault = "не подходит";
private readonly ISettingsStore _settings;
private readonly IMlClient _mlClient;
private readonly IAiTools _aiTools;
/// <summary>
/// Создаёт оценщик содержания кандидатов Discovery.
/// </summary>
/// <param name="settings">KV-настройки тенанта (mlEnabled/aiEnabled — ветки каскада).</param>
/// <param name="mlClient">ML-порт (спам-отсев при mlEnabled; сбой — «не уверен»).</param>
/// <param name="aiTools">ИИ-порт (фит при aiEnabled; сбой — эвристика, Ruling 10).</param>
public DiscoveryEvaluator(
ISettingsStore settings,
IMlClient mlClient,
IAiTools aiTools)
{
ArgumentNullException.ThrowIfNull(settings);
ArgumentNullException.ThrowIfNull(mlClient);
ArgumentNullException.ThrowIfNull(aiTools);
_settings = settings;
_mlClient = mlClient;
_aiTools = aiTools;
}
/// <summary>
/// Последовательная оценка всех сообщений выборки (python evaluate_sample L197226).
/// </summary>
/// <remarks>fit_count/total/fit_ratio считаются по всей выборке; per-message повторяет входной порядок
/// (воркеру нужны только агрегаты; per-message — для тестов и будущих разборов).</remarks>
/// <param name="task">Задача поиска (description/keywords — промпт и эвристика).</param>
/// <param name="texts">Тексты сообщений выборки (порядок — входной).</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>Агрегат выборки: fit_count, total, fit_ratio, per-message.</returns>
public async Task<DiscoveryEvalSample> EvaluateSampleAsync(
DiscoveryTaskDto task,
IReadOnlyList<string> texts,
CancellationToken ct)
{
// Флаги веток (mlEnabled/aiEnabled) не меняются в пределах выборки: один типизированный снимок
// настроек на выборку (C30) вместо двух GetAsync на каждое сообщение.
TenantSettingsSnapshot settingsSnapshot = await TenantSettingsSnapshot.LoadAsync(_settings, ct).ConfigureAwait(false);
var perMessage = new List<DiscoveryMessageFit>(texts?.Count ?? 0);
int fitCount = 0;
foreach (string text in texts ?? [])
{
DiscoveryMessageFit fit =
await EvaluateMessageCoreAsync(task, text, settingsSnapshot, ct).ConfigureAwait(false);
perMessage.Add(fit);
if (fit.Fit)
{
fitCount++;
}
}
int total = perMessage.Count;
return new DiscoveryEvalSample(
fitCount,
total,
total > 0 ? (double)fitCount / total : 0.0,
perMessage);
}
/// <summary>
/// Оценка фита одного сообщения под задачу: каскад короткое → ML-спам → ИИ → эвристика (python L174194).
/// </summary>
/// <param name="task">Задача поиска (description/keywords).</param>
/// <param name="text">Текст сообщения.</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>Вердикт: fit + причина + источник (heuristic|ml|ai).</returns>
public async Task<DiscoveryMessageFit> EvaluateMessageAsync(
DiscoveryTaskDto task,
string text,
CancellationToken ct)
{
TenantSettingsSnapshot settingsSnapshot = await TenantSettingsSnapshot.LoadAsync(_settings, ct).ConfigureAwait(false);
return await EvaluateMessageCoreAsync(task, text, settingsSnapshot, ct).ConfigureAwait(false);
}
// Чистое ядро оценки одного сообщения по фиксированному снимку настроек (см. EvaluateMessageAsync).
// task: Задача поиска (description/keywords).
// text: Текст сообщения.
// settingsSnapshot: Снимок настроек тенанта (mlEnabled/aiEnabled).
// ct: Токен отмены.
// Возвращает: Вердикт: fit + причина + источник (heuristic|ml|ai).
private async Task<DiscoveryMessageFit> EvaluateMessageCoreAsync(
DiscoveryTaskDto task,
string text,
TenantSettingsSnapshot settingsSnapshot,
CancellationToken ct)
{
string raw = text ?? string.Empty;
if (raw.Trim().Length < MinTextLength)
{
return new DiscoveryMessageFit(false, "слишком короткое", "heuristic");
}
if (settingsSnapshot.GetBool(SettingsKeys.MlEnabled, SettingsDefaults.MlEnabled))
{
try
{
MlPredictResultDto prediction = await _mlClient.PredictAsync(raw, ct).ConfigureAwait(false);
if (prediction.Take && string.Equals(prediction.Label, MlLearningLabels.Spam, StringComparison.OrdinalIgnoreCase))
{
return new DiscoveryMessageFit(false, "ML: спам", "ml");
}
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
// Прогноз недоступен («не уверен», python ml_client.predict L101107) — решает ИИ/эвристика.
}
}
if (settingsSnapshot.GetBool(SettingsKeys.AiEnabled, SettingsDefaults.AiEnabled))
{
try
{
AiEvaluateFitResultDto result = await _aiTools.EvaluateFitAsync(
SliceCodePoints(raw, AiTextLimit),
task.Description ?? string.Empty,
task.Keywords,
ct).ConfigureAwait(false);
string reason = (result.Reason ?? string.Empty).Trim();
if (reason.Length == 0)
{
reason = result.Fit ? AiFitReasonDefault : AiNotFitReasonDefault;
}
return new DiscoveryMessageFit(result.Fit, SliceCodePoints(reason, AiReasonLimit), "ai");
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
// Сбой ИИ (нет ключа/сеть/не-JSON; Local — NotSupportedException) не роняет оценку (python L191192).
}
}
return Heuristic(task, raw);
}
// Эвристика: ключ задачи входит в очищенный текст без учёта регистра (python _heuristic L144150).
private static DiscoveryMessageFit Heuristic(DiscoveryTaskDto task, string text)
{
string haystack = CleanShort(text);
foreach (string keyword in task.Keywords)
{
string kw = (keyword ?? string.Empty).Trim();
if (kw.Length > 0 && haystack.Contains(kw.ToLowerInvariant(), StringComparison.Ordinal))
{
return new DiscoveryMessageFit(true, $"совпал ключ \"{kw}\"", "heuristic");
}
}
return new DiscoveryMessageFit(false, "нет совпадений с ключами", "heuristic");
}
/// <summary>
/// Вердикт «источник подходит»: выборка ≥3 сообщений и доля fit ≥ threshold, % (python passed L229237).
/// </summary>
/// <param name="sample">Агрегат оценки выборки/темы.</param>
/// <param name="thresholdPercent">Порог задачи (1..100; дефолт discEvalThreshold).</param>
/// <returns>True — источник/тема проходят порог (каналы часто разбавляют контент офтопом — доля, не сплошное).</returns>
public static bool Passed(DiscoveryEvalSample sample, int thresholdPercent)
{
return sample.Total >= MinContentMessages && sample.FitRatio * 100 >= Math.Max(1, thresholdPercent);
}
/// <summary>
/// Группирует сообщения выборки по topic_id (null → «main») и сортирует группы по числу сообщений
/// (убыв.), порядок сообщений внутри группы — входной (python group_by_topic L96117).
/// </summary>
/// <remarks>Заголовок группы — сниппет первого непустого текста (python _topic_title L8693, ≤60 символов,
/// whitespace схлопнут). Возвращаемые группы переиспользуют исходные DTO (без копий).</remarks>
/// <param name="messages">Сообщения выборки (для форумов заполнен TopicId).</param>
/// <returns>Группы от большей к меньшей; пусто — выборки нет.</returns>
public static IReadOnlyList<DiscoveryTopicGroup> GroupByTopic(IReadOnlyList<TelegramEvalMessageDto> messages)
{
var groups = new Dictionary<string, List<TelegramEvalMessageDto>>(StringComparer.Ordinal);
var order = new List<string>();
foreach (TelegramEvalMessageDto message in messages ?? [])
{
string key = message.TopicId is long topicId ? topicId.ToString() : MainTopic;
if (!groups.TryGetValue(key, out List<TelegramEvalMessageDto>? bucket))
{
bucket = [];
groups[key] = bucket;
order.Add(key);
}
bucket.Add(message);
}
var outGroups = new List<DiscoveryTopicGroup>(order.Count);
foreach (string key in order)
{
List<TelegramEvalMessageDto> bucket = groups[key];
outGroups.Add(new DiscoveryTopicGroup(key, TopicTitle(bucket), bucket));
}
// python L116: sort(key=len(messages), reverse=True) — стабильная сортировка сохраняет входной порядок равных.
return outGroups
.OrderByDescending(group => group.Messages.Count)
.ToList();
}
// Сниппет первого непустого текста темы (python _topic_title L8693).
private static string TopicTitle(IReadOnlyList<TelegramEvalMessageDto> messages)
{
foreach (TelegramEvalMessageDto message in messages)
{
string text = (message.Text ?? string.Empty).Trim();
if (text.Length > 0)
{
return SliceCodePoints(CollapseWhitespace(text), TopicTitleLimit);
}
}
return string.Empty;
}
// Очистка текста для эвристики: схлопывание whitespace (python clean_short ≈ «\n+»→« » + trim).
private static string CleanShort(string text)
{
string collapsed = CollapseWhitespace(text);
return collapsed.ToLowerInvariant();
}
// Схлопывает любые пробельные последовательности в один пробел и обрезает края (python " ".join(split())).
private static string CollapseWhitespace(string text)
{
var builder = new StringBuilder(text.Length);
bool pendingSpace = false;
foreach (char ch in text)
{
if (char.IsWhiteSpace(ch))
{
pendingSpace = builder.Length > 0;
continue;
}
if (pendingSpace)
{
builder.Append(' ');
pendingSpace = false;
}
builder.Append(ch);
}
return builder.ToString();
}
// Первые max кодовых точек строки (python-срез без разрыва суррогатных пар).
private static string SliceCodePoints(string text, int max)
{
if (text.Length <= max)
{
return text;
}
var builder = new StringBuilder(max);
int taken = 0;
for (int index = 0; index < text.Length && taken < max; index++)
{
bool pair = char.IsHighSurrogate(text[index])
&& index + 1 < text.Length
&& char.IsLowSurrogate(text[index + 1]);
builder.Append(text[index]);
if (pair)
{
index++;
builder.Append(text[index]);
}
taken++;
}
return builder.ToString();
}
}
using System.Text;
using Deal.Contracts.Integrations.Abstractions;
using Deal.Contracts.Integrations.Models;
using Deal.Modules.Discovery.Application.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.Discovery.Application.Abstractions;
using Deal.Modules.Discovery.Application.Exceptions;
using Deal.Modules.Discovery.Application.Extensions;
using Deal.Modules.Discovery.Application.Registrars;
namespace Deal.Modules.Discovery.Application.Services;
/// <summary>
/// Оценка содержания кандидата: фит сообщений под задачу поиска (1:1 discovery_eval.py целиком, план Task 18).
/// </summary>
/// <remarks>
/// Каскад оценки одного сообщения (python evaluate_message L174194):
/// <list type="number">
/// <item>текст пустой/короче 10 символов → «слишком короткое» (False, эвристика);</item>
/// <item>ML при mlEnabled (<see cref="IMlClient.PredictAsync"/>): take + label=spam → «ML: спам» (False);
/// сбой прогноза не роняет оценку (python L101107: «не уверен»);</item>
/// <item>ИИ при aiEnabled (<see cref="IAiTools.EvaluateFitAsync"/>): любая ошибка (Local — NotSupportedException,
/// gRPC — AiUnavailableException/…, Ruling 10) ловится и оценка продолжается эвристикой (python L186194);</item>
/// <item>эвристика: любой ключ задачи входит в очищенный текст без учёта регистра.</item>
/// </list>
/// <see cref="GroupByTopic"/> — группировка выборки форума по topic_id (null → «main») с заголовками-сниппетами
/// (python L86117); <see cref="Passed"/> — вердикт «источник подходит»: выборка ≥3 сообщений и доля fit ≥
/// threshold, % (python L229237). Результаты по сообщениям <see cref="DiscoveryMessageFit"/> собирает
/// <see cref="EvaluateSampleAsync"/> в агрегат <see cref="DiscoveryEvalSample"/> (fit_count/total/fit_ratio,
/// python L197226). Язык выборки — отдельный детектор <see cref="DiscoveryLangDetector"/> (зовёт воркер).
/// </remarks>
public sealed class DiscoveryEvaluator
{
// Минимальная длина сообщения для содержательной оценки (python _MIN_TEXT_LEN L39).
private const int MinTextLength = 10;
// Лимит текста, уходящего ИИ-провайдеру, в символах (python _AI_TEXT_LIMIT L41).
private const int AiTextLimit = 4000;
// Потолок причины из ИИ (python _AI_REASON_LIMIT L43).
private const int AiReasonLimit = 200;
// Минимальный объём содержательной выборки для вердикта оценки (python _MIN_CONTENT L77).
private const int MinContentMessages = 3;
// topic_id=null в выборке/группировке → общая тема «main» (python _MAIN_TOPIC L47).
private const string MainTopic = "main";
// Длина заголовка темы-сниппета (python _TITLE_LIMIT L45).
private const int TopicTitleLimit = 60;
// Причина по умолчанию при фите ИИ (python _ai_reason L170).
private const string AiFitReasonDefault = "подходит";
// Причина по умолчанию при не-фите ИИ (python _ai_reason L170).
private const string AiNotFitReasonDefault = "не подходит";
private readonly ISettingsStore _settings;
private readonly IMlClient _mlClient;
private readonly IAiTools _aiTools;
/// <summary>
/// Создаёт оценщик содержания кандидатов Discovery.
/// </summary>
/// <param name="settings">KV-настройки тенанта (mlEnabled/aiEnabled — ветки каскада).</param>
/// <param name="mlClient">ML-порт (спам-отсев при mlEnabled; сбой — «не уверен»).</param>
/// <param name="aiTools">ИИ-порт (фит при aiEnabled; сбой — эвристика, Ruling 10).</param>
public DiscoveryEvaluator(
ISettingsStore settings,
IMlClient mlClient,
IAiTools aiTools)
{
ArgumentNullException.ThrowIfNull(settings);
ArgumentNullException.ThrowIfNull(mlClient);
ArgumentNullException.ThrowIfNull(aiTools);
_settings = settings;
_mlClient = mlClient;
_aiTools = aiTools;
}
/// <summary>
/// Последовательная оценка всех сообщений выборки (python evaluate_sample L197226).
/// </summary>
/// <remarks>fit_count/total/fit_ratio считаются по всей выборке; per-message повторяет входной порядок
/// (воркеру нужны только агрегаты; per-message — для тестов и будущих разборов).</remarks>
/// <param name="task">Задача поиска (description/keywords — промпт и эвристика).</param>
/// <param name="texts">Тексты сообщений выборки (порядок — входной).</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>Агрегат выборки: fit_count, total, fit_ratio, per-message.</returns>
public async Task<DiscoveryEvalSample> EvaluateSampleAsync(
DiscoveryTaskDto task,
IReadOnlyList<string> texts,
CancellationToken ct)
{
// Флаги веток (mlEnabled/aiEnabled) не меняются в пределах выборки: один типизированный снимок
// настроек на выборку (C30) вместо двух GetAsync на каждое сообщение.
TenantSettingsSnapshot settingsSnapshot = await TenantSettingsSnapshot.LoadAsync(_settings, ct).ConfigureAwait(false);
var perMessage = new List<DiscoveryMessageFit>(texts?.Count ?? 0);
int fitCount = 0;
foreach (string text in texts ?? [])
{
DiscoveryMessageFit fit =
await EvaluateMessageCoreAsync(task, text, settingsSnapshot, ct).ConfigureAwait(false);
perMessage.Add(fit);
if (fit.Fit)
{
fitCount++;
}
}
int total = perMessage.Count;
return new DiscoveryEvalSample(
fitCount,
total,
total > 0 ? (double)fitCount / total : 0.0,
perMessage);
}
/// <summary>
/// Оценка фита одного сообщения под задачу: каскад короткое → ML-спам → ИИ → эвристика (python L174194).
/// </summary>
/// <param name="task">Задача поиска (description/keywords).</param>
/// <param name="text">Текст сообщения.</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>Вердикт: fit + причина + источник (heuristic|ml|ai).</returns>
public async Task<DiscoveryMessageFit> EvaluateMessageAsync(
DiscoveryTaskDto task,
string text,
CancellationToken ct)
{
TenantSettingsSnapshot settingsSnapshot = await TenantSettingsSnapshot.LoadAsync(_settings, ct).ConfigureAwait(false);
return await EvaluateMessageCoreAsync(task, text, settingsSnapshot, ct).ConfigureAwait(false);
}
// Чистое ядро оценки одного сообщения по фиксированному снимку настроек (см. EvaluateMessageAsync).
// task: Задача поиска (description/keywords).
// text: Текст сообщения.
// settingsSnapshot: Снимок настроек тенанта (mlEnabled/aiEnabled).
// ct: Токен отмены.
// Возвращает: Вердикт: fit + причина + источник (heuristic|ml|ai).
private async Task<DiscoveryMessageFit> EvaluateMessageCoreAsync(
DiscoveryTaskDto task,
string text,
TenantSettingsSnapshot settingsSnapshot,
CancellationToken ct)
{
string raw = text ?? string.Empty;
if (raw.Trim().Length < MinTextLength)
{
return new DiscoveryMessageFit(false, "слишком короткое", "heuristic");
}
if (settingsSnapshot.GetBool(SettingsKeys.MlEnabled, SettingsDefaults.MlEnabled))
{
try
{
MlPredictResultDto prediction = await _mlClient.PredictAsync(raw, ct).ConfigureAwait(false);
if (prediction.Take && string.Equals(prediction.Label, MlLearningLabels.Spam, StringComparison.OrdinalIgnoreCase))
{
return new DiscoveryMessageFit(false, "ML: спам", "ml");
}
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
// Прогноз недоступен («не уверен», python ml_client.predict L101107) — решает ИИ/эвристика.
}
}
if (settingsSnapshot.GetBool(SettingsKeys.AiEnabled, SettingsDefaults.AiEnabled))
{
try
{
AiEvaluateFitResultDto result = await _aiTools.EvaluateFitAsync(
SliceCodePoints(raw, AiTextLimit),
task.Description ?? string.Empty,
task.Keywords,
ct).ConfigureAwait(false);
string reason = (result.Reason ?? string.Empty).Trim();
if (reason.Length == 0)
{
reason = result.Fit ? AiFitReasonDefault : AiNotFitReasonDefault;
}
return new DiscoveryMessageFit(result.Fit, SliceCodePoints(reason, AiReasonLimit), "ai");
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
// Сбой ИИ (нет ключа/сеть/не-JSON; Local — NotSupportedException) не роняет оценку (python L191192).
}
}
return Heuristic(task, raw);
}
// Эвристика: ключ задачи входит в очищенный текст без учёта регистра (python _heuristic L144150).
private static DiscoveryMessageFit Heuristic(DiscoveryTaskDto task, string text)
{
string haystack = CleanShort(text);
foreach (string keyword in task.Keywords)
{
string kw = (keyword ?? string.Empty).Trim();
if (kw.Length > 0 && haystack.Contains(kw.ToLowerInvariant(), StringComparison.Ordinal))
{
return new DiscoveryMessageFit(true, $"совпал ключ \"{kw}\"", "heuristic");
}
}
return new DiscoveryMessageFit(false, "нет совпадений с ключами", "heuristic");
}
/// <summary>
/// Вердикт «источник подходит»: выборка ≥3 сообщений и доля fit ≥ threshold, % (python passed L229237).
/// </summary>
/// <param name="sample">Агрегат оценки выборки/темы.</param>
/// <param name="thresholdPercent">Порог задачи (1..100; дефолт discEvalThreshold).</param>
/// <returns>True — источник/тема проходят порог (каналы часто разбавляют контент офтопом — доля, не сплошное).</returns>
public static bool Passed(DiscoveryEvalSample sample, int thresholdPercent)
{
return sample.Total >= MinContentMessages && sample.FitRatio * 100 >= Math.Max(1, thresholdPercent);
}
/// <summary>
/// Группирует сообщения выборки по topic_id (null → «main») и сортирует группы по числу сообщений
/// (убыв.), порядок сообщений внутри группы — входной (python group_by_topic L96117).
/// </summary>
/// <remarks>Заголовок группы — сниппет первого непустого текста (python _topic_title L8693, ≤60 символов,
/// whitespace схлопнут). Возвращаемые группы переиспользуют исходные DTO (без копий).</remarks>
/// <param name="messages">Сообщения выборки (для форумов заполнен TopicId).</param>
/// <returns>Группы от большей к меньшей; пусто — выборки нет.</returns>
public static IReadOnlyList<DiscoveryTopicGroup> GroupByTopic(IReadOnlyList<TelegramEvalMessageDto> messages)
{
var groups = new Dictionary<string, List<TelegramEvalMessageDto>>(StringComparer.Ordinal);
var order = new List<string>();
foreach (TelegramEvalMessageDto message in messages ?? [])
{
string key = message.TopicId is long topicId ? topicId.ToString() : MainTopic;
if (!groups.TryGetValue(key, out List<TelegramEvalMessageDto>? bucket))
{
bucket = [];
groups[key] = bucket;
order.Add(key);
}
bucket.Add(message);
}
var outGroups = new List<DiscoveryTopicGroup>(order.Count);
foreach (string key in order)
{
List<TelegramEvalMessageDto> bucket = groups[key];
outGroups.Add(new DiscoveryTopicGroup(key, TopicTitle(bucket), bucket));
}
// python L116: sort(key=len(messages), reverse=True) — стабильная сортировка сохраняет входной порядок равных.
return outGroups
.OrderByDescending(group => group.Messages.Count)
.ToList();
}
// Сниппет первого непустого текста темы (python _topic_title L8693).
private static string TopicTitle(IReadOnlyList<TelegramEvalMessageDto> messages)
{
foreach (TelegramEvalMessageDto message in messages)
{
string text = (message.Text ?? string.Empty).Trim();
if (text.Length > 0)
{
return SliceCodePoints(CollapseWhitespace(text), TopicTitleLimit);
}
}
return string.Empty;
}
// Очистка текста для эвристики: схлопывание whitespace (python clean_short ≈ «\n+»→« » + trim).
private static string CleanShort(string text)
{
string collapsed = CollapseWhitespace(text);
return collapsed.ToLowerInvariant();
}
// Схлопывает любые пробельные последовательности в один пробел и обрезает края (python " ".join(split())).
private static string CollapseWhitespace(string text)
{
var builder = new StringBuilder(text.Length);
bool pendingSpace = false;
foreach (char ch in text)
{
if (char.IsWhiteSpace(ch))
{
pendingSpace = builder.Length > 0;
continue;
}
if (pendingSpace)
{
builder.Append(' ');
pendingSpace = false;
}
builder.Append(ch);
}
return builder.ToString();
}
// Первые max кодовых точек строки (python-срез без разрыва суррогатных пар).
private static string SliceCodePoints(string text, int max)
{
if (text.Length <= max)
{
return text;
}
var builder = new StringBuilder(max);
int taken = 0;
for (int index = 0; index < text.Length && taken < max; index++)
{
bool pair = char.IsHighSurrogate(text[index])
&& index + 1 < text.Length
&& char.IsLowSurrogate(text[index + 1]);
builder.Append(text[index]);
if (pair)
{
index++;
builder.Append(text[index]);
}
taken++;
}
return builder.ToString();
}
}
@@ -1,4 +1,4 @@
using Deal.Contracts.Integrations;
using Deal.Contracts.Integrations.Abstractions;
using Deal.Contracts.Integrations.Models;
using Deal.Modules.Discovery.Application.Models;
using Deal.Modules.Discovery.Application.Abstractions;
@@ -1,4 +1,4 @@
using Deal.Contracts.Integrations;
using Deal.Contracts.Integrations.Abstractions;
using Deal.Contracts.Integrations.Models;
using Deal.Modules.Discovery.Application.Models;
using Deal.Modules.Discovery.Application.Abstractions;
@@ -1,4 +1,4 @@
using Deal.Contracts.Integrations;
using Deal.Contracts.Integrations.Abstractions;
using Deal.Contracts.Integrations.Models;
using Deal.Modules.Discovery.Application.Models;
using Deal.Modules.Discovery.Application.Abstractions;
@@ -1,4 +1,4 @@
using Deal.Contracts.Integrations;
using Deal.Contracts.Integrations.Abstractions;
using Deal.Contracts.Integrations.Models;
using Deal.Modules.Discovery.Application.Models;
using Deal.Modules.Discovery.Application.Abstractions;
@@ -1,4 +1,4 @@
using Deal.Contracts.Integrations;
using Deal.Contracts.Integrations.Abstractions;
using Deal.Contracts.Integrations.Models;
using Deal.Modules.Discovery.Application.Models;
using Deal.Modules.Discovery.Application.Abstractions;
@@ -1,4 +1,5 @@
using Deal.Contracts.Integrations;
using Deal.Contracts.Integrations.Abstractions;
using Deal.Contracts.Integrations.Models;
using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Kanban.Application.Abstractions;
using Deal.Modules.Kanban.Application.Extensions;
@@ -1,130 +1,130 @@
using Deal.Contracts.Integrations;
using Deal.Contracts.Integrations.Models;
using Deal.Modules.Cards.Application.Abstractions;
using Deal.Modules.Cards.Application.Dtos;
using Deal.Modules.Cards.Application.Models;
using Deal.Modules.Kanban.Application.ColumnRules;
using Deal.Modules.Kanban.Application.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;
// Алиас: статический класс ColumnRules лежит в одноимённом пространстве имён — внутри пространства имён
// Deal.Modules.Kanban.Application имя ColumnRules резолвится в пространство (CS0234), нужен явный алиас.
using KanbanColumnRules = Deal.Modules.Kanban.Application.ColumnRules.ColumnRules;
using Deal.Modules.Kanban.Application.Abstractions;
using Deal.Modules.Kanban.Application.Extensions;
using Deal.Modules.Kanban.Application.Registrars;
namespace Deal.Modules.Kanban.Application.Services;
/// <summary>
/// Приватные помощники <see cref="CardsService"/> — partial-часть (C32: выделено из общего файла,
/// поведение не менялось): текст обучающего примера, перенос колонки с журналом, hits правил доски и
/// колонка возврата (leads.py L163174, L209, L311319; Ruling 2/4).
/// </summary>
public sealed partial class CardsService
{
// ── Внутреннее ─────────────────────────────────────────────────────────
// Текст обучающего примера: source_msg (после Trim) или title (leads.py L167, L189190, L199200).
// card: Карточка.
// Возвращает: source_msg без краевых пробелов; пустой — title как сохранён (1:1 с (x or "").strip() or (y or "")).
private static string LearningText(CardDto card)
{
string source = card.SourceMsg.Trim();
return source.Length > 0 ? source : card.Title;
}
// Меняет колонку карточки и пишет строку журнала CardMoves (leads.py _move L170174).
// card: Карточка ДО переноса (для prev_col/from_col журнала).
// toCol: Новая колонка.
// hits: matchHits для новой колонки (пересчитаны вызывающим, Ruling 2).
// action: Действие журнала: move/trash.
// ct: Токен отмены.
private async Task MoveToColumnAsync(
CardDto card,
string toCol,
IReadOnlyList<MatchHitDto> hits,
string action,
CancellationToken ct)
{
await _store.UpdateColumnAsync(new CardColumnUpdateDto(
CardId: card.Id,
Col: toCol,
IsNew: false,
PrevCol: card.Col,
ArchivedAt: null,
MatchHits: hits), ct);
await LogMoveAsync(card.Id, action, card.Col, toCol, ct);
}
// Пишет строку журнала действия (leads.py _log_learning L4044): id lm_ генерирует модуль.
// cardId: Id карточки.
// action: Действие: move/trash/restore/comment.
// fromCol: Прежняя колонка (для comment — null).
// toCol: Новая колонка (для comment — null).
// ct: Токен отмены.
private async Task LogMoveAsync(
string cardId,
string action,
string? fromCol,
string? toCol,
CancellationToken ct)
{
await _store.AddMoveAsync(new CardMoveDto(
PrefixId.New(KanbanIdPrefixes.CardMove),
cardId,
action,
fromCol,
toCol), ct);
}
// Совпавшие критерии правил доски (hits_for_board L311319 через ColumnRules, Ruling 2).
// boardId: Id доски (b_...).
// text: Текст карточки для правил (source_msg или title).
// ct: Токен отмены.
// Возвращает: Список совпавших критериев; доски нет/правил нет → пусто.
private async Task<IReadOnlyList<MatchHitDto>> ComputeHitsForBoardAsync(
string boardId,
string text,
CancellationToken ct)
{
ContainerDto? board = await _store.GetContainerAsync(boardId, ct);
return board is null
? Array.Empty<MatchHitDto>()
: await ComputeHitsAsync(board.Rules, text, ct);
}
// Совпавшие критерии правил колонки-доски (ColumnRules.ComputeHits с курсами для бюджета).
// Резолв имени — через алиас KanbanColumnRules: одноимённые класс и namespace ColumnRules в одном модуле.
// rules: Правила доски; null («правил нет») → пусто (Ruling 2).
// text: Текст карточки для правил.
// ct: Токен отмены.
// Возвращает: Совпавшие критерии (label/term[/word]); нет активных правил → пусто.
private async Task<IReadOnlyList<MatchHitDto>> ComputeHitsAsync(
ContainerRulesDto? rules,
string text,
CancellationToken ct)
{
// Кэш курсов из типизированного снимка настроек (C30): null → мок-курсы (дефолт RatesService).
IReadOnlyDictionary<string, double> rates =
(await TenantSettingsSnapshot.LoadAsync(_settings, ct)).TryGetRatesCache()?.Rates ?? MockRates.Values;
return KanbanColumnRules.ComputeHits(rules, text, rates);
}
// Колонка возврата карточки: prev_col, если inbox или существующая доска, иначе inbox (restore_lead L209).
// prevCol: Сохранённая prev_col карточки.
// ct: Токен отмены.
// Возвращает: Колонка возврата (inbox/доска).
private async Task<string> ResolveReturnColAsync(string prevCol, CancellationToken ct)
{
if (prevCol == CardIds.Inbox)
{
return prevCol;
}
ContainerDto? board = await _store.GetContainerAsync(prevCol, ct);
return board is null ? CardIds.Inbox : prevCol;
}
}
using Deal.Contracts.Integrations.Abstractions;
using Deal.Contracts.Integrations.Models;
using Deal.Modules.Cards.Application.Abstractions;
using Deal.Modules.Cards.Application.Dtos;
using Deal.Modules.Cards.Application.Models;
using Deal.Modules.Kanban.Application.ColumnRules;
using Deal.Modules.Kanban.Application.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;
// Алиас: статический класс ColumnRules лежит в одноимённом пространстве имён — внутри пространства имён
// Deal.Modules.Kanban.Application имя ColumnRules резолвится в пространство (CS0234), нужен явный алиас.
using KanbanColumnRules = Deal.Modules.Kanban.Application.ColumnRules.ColumnRules;
using Deal.Modules.Kanban.Application.Abstractions;
using Deal.Modules.Kanban.Application.Extensions;
using Deal.Modules.Kanban.Application.Registrars;
namespace Deal.Modules.Kanban.Application.Services;
/// <summary>
/// Приватные помощники <see cref="CardsService"/> — partial-часть (C32: выделено из общего файла,
/// поведение не менялось): текст обучающего примера, перенос колонки с журналом, hits правил доски и
/// колонка возврата (leads.py L163174, L209, L311319; Ruling 2/4).
/// </summary>
public sealed partial class CardsService
{
// ── Внутреннее ─────────────────────────────────────────────────────────
// Текст обучающего примера: source_msg (после Trim) или title (leads.py L167, L189190, L199200).
// card: Карточка.
// Возвращает: source_msg без краевых пробелов; пустой — title как сохранён (1:1 с (x or "").strip() or (y or "")).
private static string LearningText(CardDto card)
{
string source = card.SourceMsg.Trim();
return source.Length > 0 ? source : card.Title;
}
// Меняет колонку карточки и пишет строку журнала CardMoves (leads.py _move L170174).
// card: Карточка ДО переноса (для prev_col/from_col журнала).
// toCol: Новая колонка.
// hits: matchHits для новой колонки (пересчитаны вызывающим, Ruling 2).
// action: Действие журнала: move/trash.
// ct: Токен отмены.
private async Task MoveToColumnAsync(
CardDto card,
string toCol,
IReadOnlyList<MatchHitDto> hits,
string action,
CancellationToken ct)
{
await _store.UpdateColumnAsync(new CardColumnUpdateDto(
CardId: card.Id,
Col: toCol,
IsNew: false,
PrevCol: card.Col,
ArchivedAt: null,
MatchHits: hits), ct);
await LogMoveAsync(card.Id, action, card.Col, toCol, ct);
}
// Пишет строку журнала действия (leads.py _log_learning L4044): id lm_ генерирует модуль.
// cardId: Id карточки.
// action: Действие: move/trash/restore/comment.
// fromCol: Прежняя колонка (для comment — null).
// toCol: Новая колонка (для comment — null).
// ct: Токен отмены.
private async Task LogMoveAsync(
string cardId,
string action,
string? fromCol,
string? toCol,
CancellationToken ct)
{
await _store.AddMoveAsync(new CardMoveDto(
PrefixId.New(KanbanIdPrefixes.CardMove),
cardId,
action,
fromCol,
toCol), ct);
}
// Совпавшие критерии правил доски (hits_for_board L311319 через ColumnRules, Ruling 2).
// boardId: Id доски (b_...).
// text: Текст карточки для правил (source_msg или title).
// ct: Токен отмены.
// Возвращает: Список совпавших критериев; доски нет/правил нет → пусто.
private async Task<IReadOnlyList<MatchHitDto>> ComputeHitsForBoardAsync(
string boardId,
string text,
CancellationToken ct)
{
ContainerDto? board = await _store.GetContainerAsync(boardId, ct);
return board is null
? Array.Empty<MatchHitDto>()
: await ComputeHitsAsync(board.Rules, text, ct);
}
// Совпавшие критерии правил колонки-доски (ColumnRules.ComputeHits с курсами для бюджета).
// Резолв имени — через алиас KanbanColumnRules: одноимённые класс и namespace ColumnRules в одном модуле.
// rules: Правила доски; null («правил нет») → пусто (Ruling 2).
// text: Текст карточки для правил.
// ct: Токен отмены.
// Возвращает: Совпавшие критерии (label/term[/word]); нет активных правил → пусто.
private async Task<IReadOnlyList<MatchHitDto>> ComputeHitsAsync(
ContainerRulesDto? rules,
string text,
CancellationToken ct)
{
// Кэш курсов из типизированного снимка настроек (C30): null → мок-курсы (дефолт RatesService).
IReadOnlyDictionary<string, double> rates =
(await TenantSettingsSnapshot.LoadAsync(_settings, ct)).TryGetRatesCache()?.Rates ?? MockRates.Values;
return KanbanColumnRules.ComputeHits(rules, text, rates);
}
// Колонка возврата карточки: prev_col, если inbox или существующая доска, иначе inbox (restore_lead L209).
// prevCol: Сохранённая prev_col карточки.
// ct: Токен отмены.
// Возвращает: Колонка возврата (inbox/доска).
private async Task<string> ResolveReturnColAsync(string prevCol, CancellationToken ct)
{
if (prevCol == CardIds.Inbox)
{
return prevCol;
}
ContainerDto? board = await _store.GetContainerAsync(prevCol, ct);
return board is null ? CardIds.Inbox : prevCol;
}
}
@@ -1,361 +1,361 @@
using Deal.Contracts.Integrations;
using Deal.Contracts.Integrations.Models;
using Deal.Modules.Cards.Application.Abstractions;
using Deal.Modules.Cards.Application.Dtos;
using Deal.Modules.Cards.Application.Models;
using Deal.Modules.Kanban.Application.ColumnRules;
using Deal.Modules.Kanban.Application.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.Kanban.Application.Abstractions;
using Deal.Modules.Kanban.Application.Extensions;
using Deal.Modules.Kanban.Application.Registrars;
namespace Deal.Modules.Kanban.Application.Services;
/// <summary>
/// Публичные операции карточек — partial-часть <see cref="CardsService"/> (C32: выделено из общего
/// файла по темам, поведение не менялось): чтение списка/карточки, переносы/корзина/возврат/удаление/
/// очистка колонки, комментарии, mark-seen, счётчики и поиск (leads.py L151279, L509551).
/// </summary>
public sealed partial class CardsService
{
// ── Чтение (list_leads/get_lead L151160) ───────────────────────────────
/// <summary>
/// Карточки колонки или всех колонок дашборда, received_at DESC (list_leads L151156).
/// </summary>
/// <param name="col">Колонка-фильтр (inbox/archive/trash/доска); null — все колонки дашборда.</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>Полные карточки (маппинг/комментарии/time — адаптер); пусто — карточек нет.</returns>
public Task<IReadOnlyList<CardDto>> ListCardsAsync(string? col, CancellationToken ct)
{
return _store.ListCardsAsync(new CardsQuery(col), ct);
}
/// <summary>
/// Одна карточка по id (get_lead L159160; GET /api/cards/{id}).
/// </summary>
/// <param name="cardId">Id карточки (<c>c_...</c>).</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>Карточка или null — строки нет (эндпоинт отвечает 404 «Карточка не найдена»).</returns>
public Task<CardDto?> GetCardAsync(string cardId, CancellationToken ct)
{
return _store.GetCardAsync(cardId, ct);
}
// ── Переносы / архив / корзина (L163–247) ───────────────────────────────
/// <summary>
/// Перенос карточки на доску или в «Неразобранное» (move_lead L177191 + _move L163174).
/// </summary>
/// <remarks>
/// Цель валидируется до чтения карточки: не inbox и не существующая доска → 400
/// <see cref="MoveTargetInvalidDetail"/>. Исходная колонка archive/trash для MoveLeadAsync недоступна
/// → 400 <see cref="MoveSourceRestrictedDetail"/>: вывод из них — только restore_lead (иначе перенос минует
/// снятие метки «спам» возврата из корзины, Ruling 4). Перенос «в ту же колонку» — no-op (карточка
/// возвращается без изменений; журнал и обучение не пишутся). При реальном переносе: колонка меняется
/// (is_new=FALSE, prev_col = прежняя колонка), matchHits пересчитываются для доски через
/// <see cref="ColumnRules.ComputeHits"/> (Ruling 2; для inbox — пусто), пишется строка журнала action=move,
/// а при to≠inbox — обучающий сигнал PushAsync(text, id доски, 1.0) (Ruling 4; текст = source_msg или title;
/// пустой текст не учим — L188–191).
/// </remarks>
/// <param name="cardId">Id карточки (<c>c_...</c>).</param>
/// <param name="toCol">Цель: <c>inbox</c> либо id доски (<c>b_...</c>).</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>Результат: Error (400) | Card=null (карточки нет, 404) | Card — карточка после переноса.</returns>
public async Task<CardResultDto> MoveDashboardCardAsync(
string cardId,
string toCol,
CancellationToken ct)
{
ContainerDto? board = null;
if (toCol != CardIds.Inbox)
{
board = await _store.GetContainerAsync(toCol, ct);
if (board is null)
{
return new CardResultDto(MoveTargetInvalidDetail, null);
}
}
CardDto? card = await _store.GetCardAsync(cardId, ct);
if (card is null)
{
return new CardResultDto(null, null);
}
// Из archive/trash карточку выводит только restore (L204222): прямой move в доску
// прошёл бы мимо снятия у ML веса «спама» возврата из корзины (Ruling 4, L218221).
if (card.Col == CardIds.Archive || card.Col == CardIds.Trash)
{
return new CardResultDto(MoveSourceRestrictedDetail, null);
}
if (card.Col == toCol)
{
return new CardResultDto(null, card);
}
string text = LearningText(card);
IReadOnlyList<MatchHitDto> hits = toCol == CardIds.Inbox
? Array.Empty<MatchHitDto>()
: await ComputeHitsAsync(board!.Rules, text, ct);
await MoveToColumnAsync(card, toCol, hits, ActionMove, ct);
if (toCol != CardIds.Inbox && text.Length > 0)
{
await _mlClient.PushAsync(text, toCol, PushWeightUser, ct);
}
return new CardResultDto(null, await _store.GetCardAsync(cardId, ct));
}
/// <summary>
/// Перенос карточки в корзину (trash_lead L194201): col=trash, is_new=FALSE, matchHits пусто.
/// </summary>
/// <remarks>
/// Карточка уже в корзине — no-op (как в _move L165166). Журнал action=trash пишется при реальном
/// переносе; обучающий сигнал «спам» 1.0 — только если карточка была НЕ в trash/archive (L198201,
/// Ruling 4). Исключение archive: перенос архива в корзину не «переучивает» на спам.
/// </remarks>
/// <param name="cardId">Id карточки (<c>c_...</c>).</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>Карточка после переноса (при no-op — как была) либо null — карточки нет (404).</returns>
public Task<CardDto?> TrashCardAsync(string cardId, CancellationToken ct)
{
return TrashCardAsync(cardId, teach: true, ct);
}
/// <summary>
/// Перенос карточки в корзину с управлением обучением ML (trash_lead L194201).
/// </summary>
/// <remarks>
/// <paramref name="teach"/> = false — «тихое» перемещение без сигнала «спам»: используется ручной
/// переклассификацией (leads.py reclassify_lead L311/L316), где обучение кладётся ЯВНО одним сигналом
/// с весом гипотезы ИИ (0.4), а не весом действия пользователя (1.0). Журнал action=trash пишется всегда.
/// </remarks>
/// <param name="cardId">Id карточки (<c>c_...</c>).</param>
/// <param name="teach">True — писать сигнал «спам» (действие пользователя); false — не писать.</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>Карточка после переноса (при no-op — как была) либо null — карточки нет (404).</returns>
public async Task<CardDto?> TrashCardAsync(
string cardId,
bool teach,
CancellationToken ct)
{
CardDto? card = await _store.GetCardAsync(cardId, ct);
if (card is null)
{
return null;
}
if (card.Col == CardIds.Trash)
{
return card;
}
string text = LearningText(card);
await MoveToColumnAsync(card, CardIds.Trash, Array.Empty<MatchHitDto>(), ActionTrash, ct);
if (teach && card.Col != CardIds.Archive && text.Length > 0)
{
await _mlClient.PushAsync(text, MlLearningLabels.Spam, PushWeightUser, ct);
}
return await _store.GetCardAsync(cardId, ct);
}
/// <summary>
/// Возврат карточки из архива/корзины на канбан (restore_lead L204222).
/// </summary>
/// <remarks>
/// Куда возвращаем: prev_col, если это «Неразобранное» или существующая доска, иначе inbox (L209).
/// При возврате is_new=TRUE, prev_col='inbox', archived_at=NULL (Ruling 10), matchHits пересчитаны для
/// доски (Ruling 2), журнал action=restore. Возврат ИЗ корзины снимает метку спам:
/// PushAsync(text, "spam", 1.0) (L218221, Ruling 4); из архива сигнал не шлётся.
/// </remarks>
/// <param name="cardId">Id карточки (<c>c_...</c>).</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>Колонка возврата (inbox/доска) либо null — карточки нет (404).</returns>
public async Task<string?> RestoreCardAsync(string cardId, CancellationToken ct)
{
CardDto? card = await _store.GetCardAsync(cardId, ct);
if (card is null)
{
return null;
}
string back = await ResolveReturnColAsync(card.PrevCol, ct);
string text = LearningText(card);
IReadOnlyList<MatchHitDto> hits = back == CardIds.Inbox
? Array.Empty<MatchHitDto>()
: await ComputeHitsForBoardAsync(back, text, ct);
await _store.UpdateColumnAsync(new CardColumnUpdateDto(
CardId: cardId,
Col: back,
IsNew: true,
PrevCol: CardIds.Inbox,
ArchivedAt: null,
MatchHits: hits), ct);
await LogMoveAsync(cardId, ActionRestore, card.Col, back, ct);
if (card.Col == CardIds.Trash && text.Length > 0)
{
await _mlClient.PushAsync(text, MlLearningLabels.Spam, PushWeightUnlearn, ct);
}
return back;
}
/// <summary>
/// Полное удаление карточки (delete_forever L225234): Cards + комментарии (FK cascade),
/// журнал CardMoves/MlOutbox не трогаются (Ruling 10).
/// </summary>
/// <param name="cardId">Id карточки (<c>c_...</c>).</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>False — карточки нет (404 «Карточка не найдена»); True — удалена.</returns>
public async Task<bool> DeleteForeverAsync(string cardId, CancellationToken ct)
{
if (await _store.GetCardAsync(cardId, ct) is null)
{
return false;
}
await _store.DeleteForeverAsync(cardId, ct);
return true;
}
/// <summary>
/// Полная ручная очистка служебной колонки trash/archive (clear_col L237247).
/// </summary>
/// <remarks>Другая колонка (inbox/доска/…) → 400 <see cref="ClearColInvalidDetail"/> (как ValueError L239240).</remarks>
/// <param name="col">Очищаемая колонка: <c>trash</c> | <c>archive</c>.</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>Результат: Error (400) либо Cleared — сколько карточек удалено навсегда.</returns>
public async Task<ClearColResultDto> ClearColAsync(string col, CancellationToken ct)
{
if (col != CardIds.Trash && col != CardIds.Archive)
{
return new ClearColResultDto(ClearColInvalidDetail, 0);
}
int cleared = await _store.ClearColAsync(col, ct);
return new ClearColResultDto(null, cleared);
}
// ── Комментарии (add_comment L259265) ──────────────────────────────────
/// <summary>
/// Добавляет комментарий к карточке: строка LeadComments (id <c>cm_</c>) + журнал action=comment.
/// </summary>
/// <remarks>
/// Текст Trim'ится (пустой после Trim → 400 <see cref="EmptyCommentDetail"/>, как dashboard_routes L240241);
/// автор — «Вы»; ответ — полный список комментариев (свежий — «только что», маппинг адаптера). Карточки
/// нет → Comments=null, Error=null (404 «Карточка не найдена» — сервис читает карточку до записи, порт
/// LeadComments ссылается FK, Task 4).
/// </remarks>
/// <param name="cardId">Id карточки (<c>c_...</c>).</param>
/// <param name="text">Текст комментария (непустой после Trim).</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>Результат: Error (400) | Comments=null (404) | Comments — список после добавления.</returns>
public async Task<AddCommentResultDto> AddCommentAsync(
string cardId,
string text,
CancellationToken ct)
{
string trimmed = (text ?? string.Empty).Trim();
if (trimmed.Length == 0)
{
return new AddCommentResultDto(EmptyCommentDetail, null);
}
if (await _store.GetCardAsync(cardId, ct) is null)
{
return new AddCommentResultDto(null, null);
}
await _store.AddCommentAsync(PrefixId.New(KanbanIdPrefixes.Comment), cardId, CommentAuthor, trimmed, ct);
await LogMoveAsync(cardId, ActionComment, null, null, ct);
IReadOnlyList<CardCommentDto> comments = await _store.ListCommentsAsync(cardId, ct);
return new AddCommentResultDto(null, comments);
}
// ── Пометить прочитанным (mark_seen L250–256) ───────────────────────────
/// <summary>
/// Снимает флаг «новое»: с одной карточки (cardId), колонки (col) или всех (оба null/пустые).
/// </summary>
/// <remarks>Семантика 1:1 с mark_seen L250256 (проверка на truthiness: пустая строка = параметр не задан).
/// Эндпоинты этапа: mark-col-seen {col}, mark-all-seen (Ruling 11); /leads/{id}/seen фронтом не вызывается.</remarks>
/// <param name="cardId">Id карточки либо null/пусто.</param>
/// <param name="col">Колонка либо null/пусто (используется, когда cardId не задан).</param>
/// <param name="ct">Токен отмены.</param>
public Task MarkSeenAsync(
string? cardId,
string? col,
CancellationToken ct)
{
return _store.UpdateSeenAsync(
string.IsNullOrEmpty(cardId) ? null : cardId,
string.IsNullOrEmpty(col) ? null : col,
ct);
}
// ── Счётчики (counts L268–279) ──────────────────────────────────────────
/// <summary>
/// Счётчики колонок (count+new по Cards) + статистика обучения/решений ML (learning/ml/ai).
/// </summary>
/// <remarks>
/// Форма CardCountsDto: Columns — только колонки с карточками; New — сумма «новых» по колонкам
/// (counts L270274). learning/ml/ai — из IMlClient.StatusAsync (L275278, план Task 7 L321): learning =
/// count(CardMoves), ml/ai — KV-счётчики решений пайплайна (на этапе 3 — 0, Ruling 4). Плоскую wire-форму
/// «{new, &lt;col&gt;:{…}, learning, ml, ai}» собирает эндпоинт Task 8.
/// </remarks>
/// <param name="ct">Токен отмены.</param>
/// <returns>Счётчики: колонки + learning/ml/ai (поля New/Learning/Ml/Ai и словарь Columns).</returns>
public async Task<CardCountsDto> CountsAsync(CancellationToken ct)
{
IReadOnlyDictionary<string, CardColumnCountDto> columns = await _store.CountCardsByColAsync(ct);
MlStatusResponseDto mlStatus = await _mlClient.StatusAsync(ct);
return new CardCountsDto
{
New = columns.Values.Sum(column => column.New),
Columns = columns,
Learning = mlStatus.Stats.Learning,
Ml = mlStatus.Stats.Ml,
Ai = mlStatus.Stats.Ai,
};
}
// ── Поиск (search L509551, LIKE-вариант Ruling 6) ──────────────────────
/// <summary>
/// Поиск карточек: FTS по Cards.SearchTsv + LIKE-дополнение (search L509551, Ruling 6/Task 12).
/// </summary>
/// <remarks>
/// q после Trim короче 2 символов → пусто, порт не вызывается (поведение этапа 3, L511–512). Сам поиск
/// выполняет адаптер — <see cref="ICardStore.SearchCardsAsync"/>: SearchTsv @@ plainto_tsquery('russian')
/// (морфология) LIKE по lower(title/summary/contact/source_msg), контейнеры-стадии «Выбранных»
/// исключены, порядок
/// ts_rank DESC, ReceivedAt DESC, результат ограничен <see cref="SearchLimit"/> = 12. messages: [] — на
/// совесть эндпоинта (Task 8). Запрос нормализуется trim+lowercase (как отсев-поиск Ruling 6).
/// </remarks>
/// <param name="query">Поисковый запрос (trim + lowercase внутри).</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>Найденные карточки (≤12); пусто — запрос короче 2 символов или нет совпадений.</returns>
public async Task<IReadOnlyList<CardDto>> SearchCardsAsync(string? query, CancellationToken ct)
{
string lowered = (query ?? string.Empty).Trim().ToLowerInvariant();
if (lowered.Length < MinSearchQueryLength)
{
return Array.Empty<CardDto>();
}
return await _store.SearchCardsAsync(lowered, SearchLimit, ct);
}
}
using Deal.Contracts.Integrations.Abstractions;
using Deal.Contracts.Integrations.Models;
using Deal.Modules.Cards.Application.Abstractions;
using Deal.Modules.Cards.Application.Dtos;
using Deal.Modules.Cards.Application.Models;
using Deal.Modules.Kanban.Application.ColumnRules;
using Deal.Modules.Kanban.Application.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.Kanban.Application.Abstractions;
using Deal.Modules.Kanban.Application.Extensions;
using Deal.Modules.Kanban.Application.Registrars;
namespace Deal.Modules.Kanban.Application.Services;
/// <summary>
/// Публичные операции карточек — partial-часть <see cref="CardsService"/> (C32: выделено из общего
/// файла по темам, поведение не менялось): чтение списка/карточки, переносы/корзина/возврат/удаление/
/// очистка колонки, комментарии, mark-seen, счётчики и поиск (leads.py L151279, L509551).
/// </summary>
public sealed partial class CardsService
{
// ── Чтение (list_leads/get_lead L151160) ───────────────────────────────
/// <summary>
/// Карточки колонки или всех колонок дашборда, received_at DESC (list_leads L151156).
/// </summary>
/// <param name="col">Колонка-фильтр (inbox/archive/trash/доска); null — все колонки дашборда.</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>Полные карточки (маппинг/комментарии/time — адаптер); пусто — карточек нет.</returns>
public Task<IReadOnlyList<CardDto>> ListCardsAsync(string? col, CancellationToken ct)
{
return _store.ListCardsAsync(new CardsQuery(col), ct);
}
/// <summary>
/// Одна карточка по id (get_lead L159160; GET /api/cards/{id}).
/// </summary>
/// <param name="cardId">Id карточки (<c>c_...</c>).</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>Карточка или null — строки нет (эндпоинт отвечает 404 «Карточка не найдена»).</returns>
public Task<CardDto?> GetCardAsync(string cardId, CancellationToken ct)
{
return _store.GetCardAsync(cardId, ct);
}
// ── Переносы / архив / корзина (L163–247) ───────────────────────────────
/// <summary>
/// Перенос карточки на доску или в «Неразобранное» (move_lead L177191 + _move L163174).
/// </summary>
/// <remarks>
/// Цель валидируется до чтения карточки: не inbox и не существующая доска → 400
/// <see cref="MoveTargetInvalidDetail"/>. Исходная колонка archive/trash для MoveLeadAsync недоступна
/// → 400 <see cref="MoveSourceRestrictedDetail"/>: вывод из них — только restore_lead (иначе перенос минует
/// снятие метки «спам» возврата из корзины, Ruling 4). Перенос «в ту же колонку» — no-op (карточка
/// возвращается без изменений; журнал и обучение не пишутся). При реальном переносе: колонка меняется
/// (is_new=FALSE, prev_col = прежняя колонка), matchHits пересчитываются для доски через
/// <see cref="ColumnRules.ComputeHits"/> (Ruling 2; для inbox — пусто), пишется строка журнала action=move,
/// а при to≠inbox — обучающий сигнал PushAsync(text, id доски, 1.0) (Ruling 4; текст = source_msg или title;
/// пустой текст не учим — L188–191).
/// </remarks>
/// <param name="cardId">Id карточки (<c>c_...</c>).</param>
/// <param name="toCol">Цель: <c>inbox</c> либо id доски (<c>b_...</c>).</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>Результат: Error (400) | Card=null (карточки нет, 404) | Card — карточка после переноса.</returns>
public async Task<CardResultDto> MoveDashboardCardAsync(
string cardId,
string toCol,
CancellationToken ct)
{
ContainerDto? board = null;
if (toCol != CardIds.Inbox)
{
board = await _store.GetContainerAsync(toCol, ct);
if (board is null)
{
return new CardResultDto(MoveTargetInvalidDetail, null);
}
}
CardDto? card = await _store.GetCardAsync(cardId, ct);
if (card is null)
{
return new CardResultDto(null, null);
}
// Из archive/trash карточку выводит только restore (L204222): прямой move в доску
// прошёл бы мимо снятия у ML веса «спама» возврата из корзины (Ruling 4, L218221).
if (card.Col == CardIds.Archive || card.Col == CardIds.Trash)
{
return new CardResultDto(MoveSourceRestrictedDetail, null);
}
if (card.Col == toCol)
{
return new CardResultDto(null, card);
}
string text = LearningText(card);
IReadOnlyList<MatchHitDto> hits = toCol == CardIds.Inbox
? Array.Empty<MatchHitDto>()
: await ComputeHitsAsync(board!.Rules, text, ct);
await MoveToColumnAsync(card, toCol, hits, ActionMove, ct);
if (toCol != CardIds.Inbox && text.Length > 0)
{
await _mlClient.PushAsync(text, toCol, PushWeightUser, ct);
}
return new CardResultDto(null, await _store.GetCardAsync(cardId, ct));
}
/// <summary>
/// Перенос карточки в корзину (trash_lead L194201): col=trash, is_new=FALSE, matchHits пусто.
/// </summary>
/// <remarks>
/// Карточка уже в корзине — no-op (как в _move L165166). Журнал action=trash пишется при реальном
/// переносе; обучающий сигнал «спам» 1.0 — только если карточка была НЕ в trash/archive (L198201,
/// Ruling 4). Исключение archive: перенос архива в корзину не «переучивает» на спам.
/// </remarks>
/// <param name="cardId">Id карточки (<c>c_...</c>).</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>Карточка после переноса (при no-op — как была) либо null — карточки нет (404).</returns>
public Task<CardDto?> TrashCardAsync(string cardId, CancellationToken ct)
{
return TrashCardAsync(cardId, teach: true, ct);
}
/// <summary>
/// Перенос карточки в корзину с управлением обучением ML (trash_lead L194201).
/// </summary>
/// <remarks>
/// <paramref name="teach"/> = false — «тихое» перемещение без сигнала «спам»: используется ручной
/// переклассификацией (leads.py reclassify_lead L311/L316), где обучение кладётся ЯВНО одним сигналом
/// с весом гипотезы ИИ (0.4), а не весом действия пользователя (1.0). Журнал action=trash пишется всегда.
/// </remarks>
/// <param name="cardId">Id карточки (<c>c_...</c>).</param>
/// <param name="teach">True — писать сигнал «спам» (действие пользователя); false — не писать.</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>Карточка после переноса (при no-op — как была) либо null — карточки нет (404).</returns>
public async Task<CardDto?> TrashCardAsync(
string cardId,
bool teach,
CancellationToken ct)
{
CardDto? card = await _store.GetCardAsync(cardId, ct);
if (card is null)
{
return null;
}
if (card.Col == CardIds.Trash)
{
return card;
}
string text = LearningText(card);
await MoveToColumnAsync(card, CardIds.Trash, Array.Empty<MatchHitDto>(), ActionTrash, ct);
if (teach && card.Col != CardIds.Archive && text.Length > 0)
{
await _mlClient.PushAsync(text, MlLearningLabels.Spam, PushWeightUser, ct);
}
return await _store.GetCardAsync(cardId, ct);
}
/// <summary>
/// Возврат карточки из архива/корзины на канбан (restore_lead L204222).
/// </summary>
/// <remarks>
/// Куда возвращаем: prev_col, если это «Неразобранное» или существующая доска, иначе inbox (L209).
/// При возврате is_new=TRUE, prev_col='inbox', archived_at=NULL (Ruling 10), matchHits пересчитаны для
/// доски (Ruling 2), журнал action=restore. Возврат ИЗ корзины снимает метку спам:
/// PushAsync(text, "spam", 1.0) (L218221, Ruling 4); из архива сигнал не шлётся.
/// </remarks>
/// <param name="cardId">Id карточки (<c>c_...</c>).</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>Колонка возврата (inbox/доска) либо null — карточки нет (404).</returns>
public async Task<string?> RestoreCardAsync(string cardId, CancellationToken ct)
{
CardDto? card = await _store.GetCardAsync(cardId, ct);
if (card is null)
{
return null;
}
string back = await ResolveReturnColAsync(card.PrevCol, ct);
string text = LearningText(card);
IReadOnlyList<MatchHitDto> hits = back == CardIds.Inbox
? Array.Empty<MatchHitDto>()
: await ComputeHitsForBoardAsync(back, text, ct);
await _store.UpdateColumnAsync(new CardColumnUpdateDto(
CardId: cardId,
Col: back,
IsNew: true,
PrevCol: CardIds.Inbox,
ArchivedAt: null,
MatchHits: hits), ct);
await LogMoveAsync(cardId, ActionRestore, card.Col, back, ct);
if (card.Col == CardIds.Trash && text.Length > 0)
{
await _mlClient.PushAsync(text, MlLearningLabels.Spam, PushWeightUnlearn, ct);
}
return back;
}
/// <summary>
/// Полное удаление карточки (delete_forever L225234): Cards + комментарии (FK cascade),
/// журнал CardMoves/MlOutbox не трогаются (Ruling 10).
/// </summary>
/// <param name="cardId">Id карточки (<c>c_...</c>).</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>False — карточки нет (404 «Карточка не найдена»); True — удалена.</returns>
public async Task<bool> DeleteForeverAsync(string cardId, CancellationToken ct)
{
if (await _store.GetCardAsync(cardId, ct) is null)
{
return false;
}
await _store.DeleteForeverAsync(cardId, ct);
return true;
}
/// <summary>
/// Полная ручная очистка служебной колонки trash/archive (clear_col L237247).
/// </summary>
/// <remarks>Другая колонка (inbox/доска/…) → 400 <see cref="ClearColInvalidDetail"/> (как ValueError L239240).</remarks>
/// <param name="col">Очищаемая колонка: <c>trash</c> | <c>archive</c>.</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>Результат: Error (400) либо Cleared — сколько карточек удалено навсегда.</returns>
public async Task<ClearColResultDto> ClearColAsync(string col, CancellationToken ct)
{
if (col != CardIds.Trash && col != CardIds.Archive)
{
return new ClearColResultDto(ClearColInvalidDetail, 0);
}
int cleared = await _store.ClearColAsync(col, ct);
return new ClearColResultDto(null, cleared);
}
// ── Комментарии (add_comment L259265) ──────────────────────────────────
/// <summary>
/// Добавляет комментарий к карточке: строка LeadComments (id <c>cm_</c>) + журнал action=comment.
/// </summary>
/// <remarks>
/// Текст Trim'ится (пустой после Trim → 400 <see cref="EmptyCommentDetail"/>, как dashboard_routes L240241);
/// автор — «Вы»; ответ — полный список комментариев (свежий — «только что», маппинг адаптера). Карточки
/// нет → Comments=null, Error=null (404 «Карточка не найдена» — сервис читает карточку до записи, порт
/// LeadComments ссылается FK, Task 4).
/// </remarks>
/// <param name="cardId">Id карточки (<c>c_...</c>).</param>
/// <param name="text">Текст комментария (непустой после Trim).</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>Результат: Error (400) | Comments=null (404) | Comments — список после добавления.</returns>
public async Task<AddCommentResultDto> AddCommentAsync(
string cardId,
string text,
CancellationToken ct)
{
string trimmed = (text ?? string.Empty).Trim();
if (trimmed.Length == 0)
{
return new AddCommentResultDto(EmptyCommentDetail, null);
}
if (await _store.GetCardAsync(cardId, ct) is null)
{
return new AddCommentResultDto(null, null);
}
await _store.AddCommentAsync(PrefixId.New(KanbanIdPrefixes.Comment), cardId, CommentAuthor, trimmed, ct);
await LogMoveAsync(cardId, ActionComment, null, null, ct);
IReadOnlyList<CardCommentDto> comments = await _store.ListCommentsAsync(cardId, ct);
return new AddCommentResultDto(null, comments);
}
// ── Пометить прочитанным (mark_seen L250–256) ───────────────────────────
/// <summary>
/// Снимает флаг «новое»: с одной карточки (cardId), колонки (col) или всех (оба null/пустые).
/// </summary>
/// <remarks>Семантика 1:1 с mark_seen L250256 (проверка на truthiness: пустая строка = параметр не задан).
/// Эндпоинты этапа: mark-col-seen {col}, mark-all-seen (Ruling 11); /leads/{id}/seen фронтом не вызывается.</remarks>
/// <param name="cardId">Id карточки либо null/пусто.</param>
/// <param name="col">Колонка либо null/пусто (используется, когда cardId не задан).</param>
/// <param name="ct">Токен отмены.</param>
public Task MarkSeenAsync(
string? cardId,
string? col,
CancellationToken ct)
{
return _store.UpdateSeenAsync(
string.IsNullOrEmpty(cardId) ? null : cardId,
string.IsNullOrEmpty(col) ? null : col,
ct);
}
// ── Счётчики (counts L268–279) ──────────────────────────────────────────
/// <summary>
/// Счётчики колонок (count+new по Cards) + статистика обучения/решений ML (learning/ml/ai).
/// </summary>
/// <remarks>
/// Форма CardCountsDto: Columns — только колонки с карточками; New — сумма «новых» по колонкам
/// (counts L270274). learning/ml/ai — из IMlClient.StatusAsync (L275278, план Task 7 L321): learning =
/// count(CardMoves), ml/ai — KV-счётчики решений пайплайна (на этапе 3 — 0, Ruling 4). Плоскую wire-форму
/// «{new, &lt;col&gt;:{…}, learning, ml, ai}» собирает эндпоинт Task 8.
/// </remarks>
/// <param name="ct">Токен отмены.</param>
/// <returns>Счётчики: колонки + learning/ml/ai (поля New/Learning/Ml/Ai и словарь Columns).</returns>
public async Task<CardCountsDto> CountsAsync(CancellationToken ct)
{
IReadOnlyDictionary<string, CardColumnCountDto> columns = await _store.CountCardsByColAsync(ct);
MlStatusResponseDto mlStatus = await _mlClient.StatusAsync(ct);
return new CardCountsDto
{
New = columns.Values.Sum(column => column.New),
Columns = columns,
Learning = mlStatus.Stats.Learning,
Ml = mlStatus.Stats.Ml,
Ai = mlStatus.Stats.Ai,
};
}
// ── Поиск (search L509551, LIKE-вариант Ruling 6) ──────────────────────
/// <summary>
/// Поиск карточек: FTS по Cards.SearchTsv + LIKE-дополнение (search L509551, Ruling 6/Task 12).
/// </summary>
/// <remarks>
/// q после Trim короче 2 символов → пусто, порт не вызывается (поведение этапа 3, L511–512). Сам поиск
/// выполняет адаптер — <see cref="ICardStore.SearchCardsAsync"/>: SearchTsv @@ plainto_tsquery('russian')
/// (морфология) LIKE по lower(title/summary/contact/source_msg), контейнеры-стадии «Выбранных»
/// исключены, порядок
/// ts_rank DESC, ReceivedAt DESC, результат ограничен <see cref="SearchLimit"/> = 12. messages: [] — на
/// совесть эндпоинта (Task 8). Запрос нормализуется trim+lowercase (как отсев-поиск Ruling 6).
/// </remarks>
/// <param name="query">Поисковый запрос (trim + lowercase внутри).</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>Найденные карточки (≤12); пусто — запрос короче 2 символов или нет совпадений.</returns>
public async Task<IReadOnlyList<CardDto>> SearchCardsAsync(string? query, CancellationToken ct)
{
string lowered = (query ?? string.Empty).Trim().ToLowerInvariant();
if (lowered.Length < MinSearchQueryLength)
{
return Array.Empty<CardDto>();
}
return await _store.SearchCardsAsync(lowered, SearchLimit, ct);
}
}
@@ -1,4 +1,4 @@
using Deal.Contracts.Integrations;
using Deal.Contracts.Integrations.Abstractions;
using Deal.Contracts.Integrations.Models;
using Deal.Modules.Kanban.Application.ColumnRules;
using Deal.Modules.Kanban.Application.Models;
@@ -1,4 +1,4 @@
using Deal.Contracts.Integrations;
using Deal.Contracts.Integrations.Abstractions;
using Deal.Contracts.Integrations.Models;
using Deal.Modules.Cards.Application.Abstractions;
using Deal.Modules.Cards.Application.Dtos;
@@ -1,4 +1,4 @@
using Deal.Contracts.Integrations;
using Deal.Contracts.Integrations.Abstractions;
using Deal.Contracts.Integrations.Models;
using Deal.Modules.Cards.Application.Abstractions;
using Deal.Modules.Cards.Application.Dtos;
@@ -1,4 +1,4 @@
using Deal.Contracts.Integrations;
using Deal.Contracts.Integrations.Abstractions;
using Deal.Contracts.Integrations.Models;
using Deal.Modules.Cards.Application.Abstractions;
using Deal.Modules.Cards.Application.Dtos;
@@ -1,4 +1,4 @@
using Deal.Contracts.Integrations;
using Deal.Contracts.Integrations.Abstractions;
using Deal.Contracts.Integrations.Models;
using Deal.Modules.Cards.Application.Abstractions;
using Deal.Modules.Cards.Application.Dtos;
@@ -1,4 +1,5 @@
using Deal.Contracts.Integrations;
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;
@@ -1,6 +1,6 @@
using System.Globalization;
using System.Text.Json;
using Deal.Contracts.Integrations;
using Deal.Contracts.Integrations.Abstractions;
using Deal.Contracts.Integrations.Models;
using Deal.Modules.Kanban.Application.Abstractions;
using Deal.Modules.Kanban.Application.Extensions;
@@ -1,6 +1,6 @@
using System.Globalization;
using System.Text.Json;
using Deal.Contracts.Integrations;
using Deal.Contracts.Integrations.Abstractions;
using Deal.Contracts.Integrations.Models;
using Deal.Modules.Kanban.Application.Abstractions;
using Deal.Modules.Kanban.Application.Extensions;
@@ -1,6 +1,6 @@
using System.Globalization;
using System.Text.Json;
using Deal.Contracts.Integrations;
using Deal.Contracts.Integrations.Abstractions;
using Deal.Contracts.Integrations.Models;
using Deal.Modules.Kanban.Application.Abstractions;
using Deal.Modules.Kanban.Application.Extensions;
@@ -1,6 +1,6 @@
using System.Globalization;
using System.Text.Json;
using Deal.Contracts.Integrations;
using Deal.Contracts.Integrations.Abstractions;
using Deal.Contracts.Integrations.Models;
using Deal.Modules.Kanban.Application.Abstractions;
using Deal.Modules.Kanban.Application.Extensions;
@@ -1,6 +1,6 @@
using System.Globalization;
using System.Text.Json;
using Deal.Contracts.Integrations;
using Deal.Contracts.Integrations.Abstractions;
using Deal.Contracts.Integrations.Models;
using Deal.Modules.Kanban.Application.Abstractions;
using Deal.Modules.Kanban.Application.Extensions;
@@ -1,6 +1,6 @@
using System.Globalization;
using System.Text.Json;
using Deal.Contracts.Integrations;
using Deal.Contracts.Integrations.Abstractions;
using Deal.Contracts.Integrations.Models;
using Deal.Modules.Kanban.Application.Abstractions;
using Deal.Modules.Kanban.Application.Extensions;
@@ -1,6 +1,6 @@
using System.Globalization;
using System.Text.Json;
using Deal.Contracts.Integrations;
using Deal.Contracts.Integrations.Abstractions;
using Deal.Contracts.Integrations.Models;
using Deal.Modules.Kanban.Application.Abstractions;
using Deal.Modules.Kanban.Application.Extensions;

Some files were not shown because too many files have changed in this diff Show More