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