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