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

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