Разбить модули Deal.Modules.* по назначению

Application проектов Discovery, Kanban, Pipeline, Settings, Tenants
разделён на Abstractions/Exceptions/Extensions/Models/Registrars/Services;
namespace приведён к путям, using потребителей мигрированы и
дедуплицированы (169 файлов), cref/FQN обновлены.
This commit is contained in:
Rustam Khalimov
2026-09-11 13:18:14 +03:00
parent 31c434ed93
commit cd0b3b606b
356 changed files with 14436 additions and 12906 deletions
+7 -2
View File
@@ -1,8 +1,13 @@
using Deal.Api.Events; using Deal.Api.Events;
using Deal.Modules.Kanban.Application; using Deal.Modules.Kanban.Application.Abstractions;
using Deal.Modules.Kanban.Application.Extensions;
using Deal.Modules.Kanban.Application.Models; using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Pipeline.Application; using Deal.Modules.Kanban.Application.Registrars;
using Deal.Modules.Kanban.Application.Services;
using Deal.Modules.Pipeline.Application.Abstractions;
using Deal.Modules.Pipeline.Application.Models; using Deal.Modules.Pipeline.Application.Models;
using Deal.Modules.Pipeline.Application.Registrars;
using Deal.Modules.Pipeline.Application.Services;
namespace Deal.Api; namespace Deal.Api;
@@ -1,4 +1,8 @@
using Deal.Modules.Tenants.Application; using Deal.Modules.Tenants.Application.Abstractions;
using Deal.Modules.Tenants.Application.Extensions;
using Deal.Modules.Tenants.Application.Models;
using Deal.Modules.Tenants.Application.Registrars;
using Deal.Modules.Tenants.Application.Services;
namespace Deal.Api.Configuration; namespace Deal.Api.Configuration;
@@ -1,4 +1,8 @@
using Deal.Modules.Tenants.Application; using Deal.Modules.Tenants.Application.Abstractions;
using Deal.Modules.Tenants.Application.Extensions;
using Deal.Modules.Tenants.Application.Models;
using Deal.Modules.Tenants.Application.Registrars;
using Deal.Modules.Tenants.Application.Services;
namespace Deal.Api.Configuration; namespace Deal.Api.Configuration;
@@ -1,7 +1,9 @@
using System.Text.Json; using System.Text.Json;
using Deal.Api.Http; using Deal.Api.Http;
using Deal.Modules.Settings.Application; 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.Services;
namespace Deal.Api.Endpoints; namespace Deal.Api.Endpoints;
+4 -1
View File
@@ -1,7 +1,10 @@
using Deal.Api.Http; using Deal.Api.Http;
using Deal.Api.Middleware; using Deal.Api.Middleware;
using Deal.Modules.Tenants.Application; using Deal.Modules.Tenants.Application.Abstractions;
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.Services;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
// Имя конфигурационного типа совпадает с Microsoft.AspNetCore.Http.CookieOptions — фиксируем алиасом. // Имя конфигурационного типа совпадает с Microsoft.AspNetCore.Http.CookieOptions — фиксируем алиасом.
using CookieOptions = Deal.Api.Configuration.CookieOptions; using CookieOptions = Deal.Api.Configuration.CookieOptions;
@@ -3,9 +3,16 @@ using Deal.Api.Endpoints.RequestModels;
using Deal.Api.Http; using Deal.Api.Http;
using Deal.Contracts.Integrations; using Deal.Contracts.Integrations;
using Deal.Contracts.Integrations.Models; using Deal.Contracts.Integrations.Models;
using Deal.Modules.Kanban.Application; using Deal.Modules.Kanban.Application.Abstractions;
using Deal.Modules.Kanban.Application.Extensions;
using Deal.Modules.Kanban.Application.Models; using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Tenants.Application; using Deal.Modules.Kanban.Application.Registrars;
using Deal.Modules.Kanban.Application.Services;
using Deal.Modules.Tenants.Application.Abstractions;
using Deal.Modules.Tenants.Application.Extensions;
using Deal.Modules.Tenants.Application.Models;
using Deal.Modules.Tenants.Application.Registrars;
using Deal.Modules.Tenants.Application.Services;
namespace Deal.Api.Endpoints; namespace Deal.Api.Endpoints;
+12 -3
View File
@@ -4,11 +4,20 @@ using Deal.Api.Http;
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; using Deal.Modules.Kanban.Application.Abstractions;
using Deal.Modules.Kanban.Application.Extensions;
using Deal.Modules.Kanban.Application.Models; using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Pipeline.Application; using Deal.Modules.Kanban.Application.Registrars;
using Deal.Modules.Kanban.Application.Services;
using Deal.Modules.Pipeline.Application.Abstractions;
using Deal.Modules.Pipeline.Application.Models; using Deal.Modules.Pipeline.Application.Models;
using Deal.Modules.Tenants.Application; using Deal.Modules.Pipeline.Application.Registrars;
using Deal.Modules.Pipeline.Application.Services;
using Deal.Modules.Tenants.Application.Abstractions;
using Deal.Modules.Tenants.Application.Extensions;
using Deal.Modules.Tenants.Application.Models;
using Deal.Modules.Tenants.Application.Registrars;
using Deal.Modules.Tenants.Application.Services;
namespace Deal.Api.Endpoints; namespace Deal.Api.Endpoints;
@@ -1,9 +1,16 @@
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.Http;
using Deal.Modules.Kanban.Application; using Deal.Modules.Kanban.Application.Abstractions;
using Deal.Modules.Kanban.Application.Extensions;
using Deal.Modules.Kanban.Application.Models; using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Tenants.Application; using Deal.Modules.Kanban.Application.Registrars;
using Deal.Modules.Kanban.Application.Services;
using Deal.Modules.Tenants.Application.Abstractions;
using Deal.Modules.Tenants.Application.Extensions;
using Deal.Modules.Tenants.Application.Models;
using Deal.Modules.Tenants.Application.Registrars;
using Deal.Modules.Tenants.Application.Services;
namespace Deal.Api.Endpoints; namespace Deal.Api.Endpoints;
@@ -3,10 +3,16 @@ using Deal.Api.Endpoints.RequestModels;
using Deal.Api.Http; using Deal.Api.Http;
using Deal.Contracts.Integrations; using Deal.Contracts.Integrations;
using Deal.Contracts.Integrations.Models; using Deal.Contracts.Integrations.Models;
using Deal.Modules.Discovery.Application; using Deal.Modules.Discovery.Application.Abstractions;
using Deal.Modules.Discovery.Application.Exceptions;
using Deal.Modules.Discovery.Application.Extensions;
using Deal.Modules.Discovery.Application.Models; using Deal.Modules.Discovery.Application.Models;
using Deal.Modules.Settings.Application; using Deal.Modules.Discovery.Application.Registrars;
using Deal.Modules.Discovery.Application.Services;
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.Services;
using Deal.Modules.Telegram.Application; using Deal.Modules.Telegram.Application;
namespace Deal.Api.Endpoints; namespace Deal.Api.Endpoints;
@@ -1,6 +1,8 @@
using Deal.Api.Http; using Deal.Api.Http;
using Deal.Modules.Settings.Application; 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.Services;
namespace Deal.Api.Endpoints; namespace Deal.Api.Endpoints;
+4 -1
View File
@@ -1,6 +1,9 @@
using Deal.Api.Http; using Deal.Api.Http;
using Deal.Modules.Tenants.Application; using Deal.Modules.Tenants.Application.Abstractions;
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.Services;
namespace Deal.Api.Endpoints; namespace Deal.Api.Endpoints;
+3 -1
View File
@@ -1,8 +1,10 @@
using Deal.Api.Http; using Deal.Api.Http;
using Deal.Contracts.Integrations; using Deal.Contracts.Integrations;
using Deal.Contracts.Integrations.Models; using Deal.Contracts.Integrations.Models;
using Deal.Modules.Pipeline.Application; 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.Services;
namespace Deal.Api.Endpoints; namespace Deal.Api.Endpoints;
@@ -1,168 +1,171 @@
using Deal.Api.Http; using Deal.Api.Http;
using Deal.Modules.Tenants.Application; using Deal.Modules.Tenants.Application.Abstractions;
using Deal.Modules.Tenants.Application.Models; using Deal.Modules.Tenants.Application.Extensions;
using Deal.Modules.Tenants.Application.Models;
namespace Deal.Api.Endpoints; using Deal.Modules.Tenants.Application.Registrars;
using Deal.Modules.Tenants.Application.Services;
/// <summary>
/// Операторские read-only эндпоинты аналитики: /api/operator/analytics/{overview,tokens,activity} (этап 10, T3). namespace Deal.Api.Endpoints;
/// </summary>
/// <remarks> /// <summary>
/// Только под операторской сессией: без неё 401 «Требуется вход оператора» (как прочие /api/operator/*). /// Операторские read-only эндпоинты аналитики: /api/operator/analytics/{overview,tokens,activity} (этап 10, T3).
/// Ничего не меняет (read-only). groupBy — day|tenant|provider|model (неизвестное — 400 {detail}); from/to — /// </summary>
/// ISO-8601 (включительно), как у аудита; activity поддерживает фильтры eventType/actorType/actorId/tenantId, /// <remarks>
/// limit (1..500) и offset. Все ответы — camelCase (контракт: docs/architecture/2026-09-10-operator-analytics-contract.md). /// Только под операторской сессией: без неё 401 «Требуется вход оператора» (как прочие /api/operator/*).
/// </remarks> /// Ничего не меняет (read-only). groupBy — day|tenant|provider|model (неизвестное — 400 {detail}); from/to —
public static class OperatorAnalyticsEndpoints /// ISO-8601 (включительно), как у аудита; activity поддерживает фильтры eventType/actorType/actorId/tenantId,
{ /// limit (1..500) и offset. Все ответы — camelCase (контракт: docs/architecture/2026-09-10-operator-analytics-contract.md).
// Префикс группы аналитики (Ruling 4 этапа 10). /// </remarks>
private const string AnalyticsGroupPrefix = "/api/operator/analytics"; public static class OperatorAnalyticsEndpoints
{
// OpenAPI-тег группы. // Префикс группы аналитики (Ruling 4 этапа 10).
private const string OperatorOpenApiTag = "operator"; private const string AnalyticsGroupPrefix = "/api/operator/analytics";
// Группировка расхода токенов по умолчанию (сутки). // OpenAPI-тег группы.
private const string DefaultGroupBy = TokenUsageGroupBys.Day; private const string OperatorOpenApiTag = "operator";
// 400 tokens: неизвестная группировка. // Группировка расхода токенов по умолчанию (сутки).
private const string InvalidGroupByDetail = "Неизвестная группировка (day|tenant|provider|model)"; private const string DefaultGroupBy = TokenUsageGroupBys.Day;
/// <summary> // 400 tokens: неизвестная группировка.
/// Регистрирует группу /api/operator/analytics: overview/tokens/activity. private const string InvalidGroupByDetail = "Неизвестная группировка (day|tenant|provider|model)";
/// </summary>
/// <param name="app">Построитель маршрутов приложения.</param> /// <summary>
/// <returns>Построитель маршрутов для цепочки вызовов.</returns> /// Регистрирует группу /api/operator/analytics: overview/tokens/activity.
public static IEndpointRouteBuilder MapOperatorAnalyticsEndpoints(this IEndpointRouteBuilder app) /// </summary>
{ /// <param name="app">Построитель маршрутов приложения.</param>
var group = app.MapGroup(AnalyticsGroupPrefix).WithTags(OperatorOpenApiTag); /// <returns>Построитель маршрутов для цепочки вызовов.</returns>
group.MapGet("/overview", OverviewAsync); public static IEndpointRouteBuilder MapOperatorAnalyticsEndpoints(this IEndpointRouteBuilder app)
group.MapGet("/tokens", TokensAsync); {
group.MapGet("/activity", ActivityAsync); var group = app.MapGroup(AnalyticsGroupPrefix).WithTags(OperatorOpenApiTag);
group.MapGet("/suspicious", SuspiciousAsync); group.MapGet("/overview", OverviewAsync);
return app; group.MapGet("/tokens", TokensAsync);
} group.MapGet("/activity", ActivityAsync);
group.MapGet("/suspicious", SuspiciousAsync);
// GET /api/operator/analytics/suspicious?from=&amp;to=: находки детектора подозрительной активности (§10.5). return app;
// from: Начало окна анализа (включительно; ISO-8601); null — последние 24 часа. }
// to: Конец окна анализа (включительно; ISO-8601); null — «сейчас».
// context: Контекст запроса. // GET /api/operator/analytics/suspicious?from=&amp;to=: находки детектора подозрительной активности (§10.5).
// suspiciousService: Детектор подозрительной активности (scoped). // from: Начало окна анализа (включительно; ISO-8601); null — последние 24 часа.
// ct: Токен отмены. // to: Конец окна анализа (включительно; ISO-8601); null — «сейчас».
// Возвращает: 200 сводка находок или 401 без операторской сессии. // context: Контекст запроса.
private static async Task<IResult> SuspiciousAsync( // suspiciousService: Детектор подозрительной активности (scoped).
DateTimeOffset? from, // ct: Токен отмены.
DateTimeOffset? to, // Возвращает: 200 сводка находок или 401 без операторской сессии.
HttpContext context, private static async Task<IResult> SuspiciousAsync(
SuspiciousActivityService suspiciousService, DateTimeOffset? from,
CancellationToken ct) DateTimeOffset? to,
{ HttpContext context,
if (context.GetCurrentOperator() is null) SuspiciousActivityService suspiciousService,
{ CancellationToken ct)
return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail); {
} if (context.GetCurrentOperator() is null)
{
SuspiciousActivityDto report = await suspiciousService.AnalyzeAsync(from, to, ct); return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail);
return Results.Ok(report); }
}
SuspiciousActivityDto report = await suspiciousService.AnalyzeAsync(from, to, ct);
// GET /api/operator/analytics/overview?from=&amp;to=: сводка (тенанты, токены, события, входы/выходы). return Results.Ok(report);
// from: Начало периода (включительно; ISO-8601). }
// to: Конец периода (включительно; ISO-8601).
// context: Контекст запроса. // GET /api/operator/analytics/overview?from=&amp;to=: сводка (тенанты, токены, события, входы/выходы).
// analyticsService: Сервис аналитики (scoped). // from: Начало периода (включительно; ISO-8601).
// ct: Токен отмены. // to: Конец периода (включительно; ISO-8601).
// Возвращает: 200 сводка или 401 без операторской сессии. // context: Контекст запроса.
private static async Task<IResult> OverviewAsync( // analyticsService: Сервис аналитики (scoped).
DateTimeOffset? from, // ct: Токен отмены.
DateTimeOffset? to, // Возвращает: 200 сводка или 401 без операторской сессии.
HttpContext context, private static async Task<IResult> OverviewAsync(
AnalyticsService analyticsService, DateTimeOffset? from,
CancellationToken ct) DateTimeOffset? to,
{ HttpContext context,
if (context.GetCurrentOperator() is null) AnalyticsService analyticsService,
{ CancellationToken ct)
return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail); {
} if (context.GetCurrentOperator() is null)
{
AnalyticsOverviewDto overview = await analyticsService.OverviewAsync(from, to, ct); return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail);
return Results.Ok(overview); }
}
AnalyticsOverviewDto overview = await analyticsService.OverviewAsync(from, to, ct);
// GET /api/operator/analytics/tokens?groupBy=&amp;tenantId=&amp;from=&amp;to=: агрегаты расхода токенов. return Results.Ok(overview);
// groupBy: Группировка day|tenant|provider|model (дефолт day). }
// tenantId: Тенант (равенство; пусто — все тенанты).
// from: Начало периода (включительно; ISO-8601). // GET /api/operator/analytics/tokens?groupBy=&amp;tenantId=&amp;from=&amp;to=: агрегаты расхода токенов.
// to: Конец периода (включительно; ISO-8601). // groupBy: Группировка day|tenant|provider|model (дефолт day).
// context: Контекст запроса. // tenantId: Тенант (равенство; пусто — все тенанты).
// analyticsService: Сервис аналитики (scoped). // from: Начало периода (включительно; ISO-8601).
// ct: Токен отмены. // to: Конец периода (включительно; ISO-8601).
// Возвращает: 200 агрегаты, 400 неизвестная группировка или 401 без операторской сессии. // context: Контекст запроса.
private static async Task<IResult> TokensAsync( // analyticsService: Сервис аналитики (scoped).
string? groupBy, // ct: Токен отмены.
Guid? tenantId, // Возвращает: 200 агрегаты, 400 неизвестная группировка или 401 без операторской сессии.
DateTimeOffset? from, private static async Task<IResult> TokensAsync(
DateTimeOffset? to, string? groupBy,
HttpContext context, Guid? tenantId,
AnalyticsService analyticsService, DateTimeOffset? from,
CancellationToken ct) DateTimeOffset? to,
{ HttpContext context,
if (context.GetCurrentOperator() is null) AnalyticsService analyticsService,
{ CancellationToken ct)
return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail); {
} if (context.GetCurrentOperator() is null)
{
string normalizedGroupBy = string.IsNullOrWhiteSpace(groupBy) ? DefaultGroupBy : groupBy; return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail);
if (!IsKnownGroupBy(normalizedGroupBy)) }
{
return EndpointResults.BadRequest(InvalidGroupByDetail); string normalizedGroupBy = string.IsNullOrWhiteSpace(groupBy) ? DefaultGroupBy : groupBy;
} if (!IsKnownGroupBy(normalizedGroupBy))
{
AnalyticsTokensDto tokens = await analyticsService.TokensAsync(normalizedGroupBy, tenantId, from, to, ct); return EndpointResults.BadRequest(InvalidGroupByDetail);
return Results.Ok(tokens); }
}
AnalyticsTokensDto tokens = await analyticsService.TokensAsync(normalizedGroupBy, tenantId, from, to, ct);
// GET /api/operator/analytics/activity?eventType=&amp;actorType=&amp;actorId=&amp;tenantId=&amp;from=&amp;to=&amp;limit=&amp;offset=: лента действий. return Results.Ok(tokens);
// eventType: Тип события (равенство; пусто — без фильтра). }
// actorType: Тип актора operator|tenant|system (равенство).
// actorId: Идентификатор актора (равенство). // GET /api/operator/analytics/activity?eventType=&amp;actorType=&amp;actorId=&amp;tenantId=&amp;from=&amp;to=&amp;limit=&amp;offset=: лента действий.
// tenantId: Тенант (равенство). // eventType: Тип события (равенство; пусто — без фильтра).
// from: Нижняя граница At (включительно; ISO-8601). // actorType: Тип актора operator|tenant|system (равенство).
// to: Верхняя граница At (включительно; ISO-8601). // actorId: Идентификатор актора (равенство).
// limit: Размер страницы (дефолт 100, кламп 1..500). // tenantId: Тенант (равенство).
// offset: Смещение страницы (≥0). // from: Нижняя граница At (включительно; ISO-8601).
// context: Контекст запроса. // to: Верхняя граница At (включительно; ISO-8601).
// analyticsService: Сервис аналитики (scoped). // limit: Размер страницы (дефолт 100, кламп 1..500).
// ct: Токен отмены. // offset: Смещение страницы (≥0).
// Возвращает: 200 {items, total, limit, offset} или 401 без операторской сессии. // context: Контекст запроса.
private static async Task<IResult> ActivityAsync( // analyticsService: Сервис аналитики (scoped).
string? eventType, // ct: Токен отмены.
string? actorType, // Возвращает: 200 {items, total, limit, offset} или 401 без операторской сессии.
Guid? actorId, private static async Task<IResult> ActivityAsync(
Guid? tenantId, string? eventType,
DateTimeOffset? from, string? actorType,
DateTimeOffset? to, Guid? actorId,
int? limit, Guid? tenantId,
int? offset, DateTimeOffset? from,
HttpContext context, DateTimeOffset? to,
AnalyticsService analyticsService, int? limit,
CancellationToken ct) int? offset,
{ HttpContext context,
if (context.GetCurrentOperator() is null) AnalyticsService analyticsService,
{ CancellationToken ct)
return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail); {
} if (context.GetCurrentOperator() is null)
{
AnalyticsActivityDto activity = await analyticsService.ActivityAsync( return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail);
eventType, actorType, actorId, tenantId, from, to, limit, offset, ct); }
return Results.Ok(activity);
} AnalyticsActivityDto activity = await analyticsService.ActivityAsync(
eventType, actorType, actorId, tenantId, from, to, limit, offset, ct);
/// <summary> return Results.Ok(activity);
/// Известна ли группировка расхода токенов (day|tenant|provider|model). }
/// </summary>
/// <param name="groupBy">Значение группировки.</param> /// <summary>
/// <returns>True — поддерживаемая группировка.</returns> /// Известна ли группировка расхода токенов (day|tenant|provider|model).
public static bool IsKnownGroupBy(string groupBy) => /// </summary>
groupBy is TokenUsageGroupBys.Day or TokenUsageGroupBys.Tenant /// <param name="groupBy">Значение группировки.</param>
or TokenUsageGroupBys.Provider or TokenUsageGroupBys.Model; /// <returns>True — поддерживаемая группировка.</returns>
} public static bool IsKnownGroupBy(string groupBy) =>
groupBy is TokenUsageGroupBys.Day or TokenUsageGroupBys.Tenant
or TokenUsageGroupBys.Provider or TokenUsageGroupBys.Model;
}
@@ -1,6 +1,9 @@
using Deal.Api.Http; using Deal.Api.Http;
using Deal.Modules.Tenants.Application; using Deal.Modules.Tenants.Application.Abstractions;
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.Services;
namespace Deal.Api.Endpoints; namespace Deal.Api.Endpoints;
@@ -1,171 +1,174 @@
using Deal.Api.Http; using Deal.Api.Http;
using Deal.Api.Middleware; using Deal.Api.Middleware;
using Deal.Modules.Tenants.Application; using Deal.Modules.Tenants.Application.Abstractions;
using Deal.Modules.Tenants.Application.Models; using Deal.Modules.Tenants.Application.Extensions;
using Microsoft.Extensions.Options; using Deal.Modules.Tenants.Application.Models;
using AspNetCoreCookieOptions = Microsoft.AspNetCore.Http.CookieOptions; using Deal.Modules.Tenants.Application.Registrars;
// Имя конфигурационного типа совпадает с Microsoft.AspNetCore.Http.CookieOptions — фиксируем алиасами. using Deal.Modules.Tenants.Application.Services;
using OperatorCookieOptions = Deal.Api.Configuration.OperatorCookieOptions; using Microsoft.Extensions.Options;
using AspNetCoreCookieOptions = Microsoft.AspNetCore.Http.CookieOptions;
namespace Deal.Api.Endpoints; // Имя конфигурационного типа совпадает с Microsoft.AspNetCore.Http.CookieOptions — фиксируем алиасами.
using OperatorCookieOptions = Deal.Api.Configuration.OperatorCookieOptions;
/// <summary>
/// HTTP-эндпоинты аутентификации оператора (группа /api/operator/auth). Зеркало AuthEndpoints для операторов (Ruling 1). namespace Deal.Api.Endpoints;
/// </summary>
/// <remarks> /// <summary>
/// Оператор ≠ пользователь тенанта: вход по отдельным public-таблицам (OperatorAuthService/IOperatorAuthStore), /// HTTP-эндпоинты аутентификации оператора (группа /api/operator/auth). Зеркало AuthEndpoints для операторов (Ruling 1).
/// сессия — в куке deal_operator_session (отдельная от deal_session; 12 ч, httpOnly, SameSite=Lax). /// </summary>
/// Успех-ответы — <c>{ok:true,...}</c>, ошибки — HTTP-код + <c>{"detail":"..."}</c> (Ruling 10). Защищённые /// <remarks>
/// ручки (me) требуют операторскую сессию (401 «Требуется вход оператора») — тенантная кука не проходит. /// Оператор ≠ пользователь тенанта: вход по отдельным public-таблицам (OperatorAuthService/IOperatorAuthStore),
/// Результаты входа пишутся в аудит (operator_login_ok/failed, Task 4/Ruling 4). /// сессия — в куке deal_operator_session (отдельная от deal_session; 12 ч, httpOnly, SameSite=Lax).
/// </remarks> /// Успех-ответы — <c>{ok:true,...}</c>, ошибки — HTTP-код + <c>{"detail":"..."}</c> (Ruling 10). Защищённые
public static class OperatorAuthEndpoints /// ручки (me) требуют операторскую сессию (401 «Требуется вход оператора») — тенантная кука не проходит.
{ /// Результаты входа пишутся в аудит (operator_login_ok/failed, Task 4/Ruling 4).
private const string InvalidCredentialsDetail = "Неверный логин или пароль оператора"; /// </remarks>
private const string OperatorAuthGroupPrefix = "/api/operator/auth"; public static class OperatorAuthEndpoints
private const string OperatorAuthOpenApiTag = "operator-auth"; {
private const string InvalidCredentialsDetail = "Неверный логин или пароль оператора";
/// <summary> private const string OperatorAuthGroupPrefix = "/api/operator/auth";
/// Регистрирует группу /api/operator/auth: login, logout, me. private const string OperatorAuthOpenApiTag = "operator-auth";
/// </summary>
/// <param name="app">Построитель маршрутов приложения.</param> /// <summary>
/// <returns>Построитель маршрутов для цепочки вызовов.</returns> /// Регистрирует группу /api/operator/auth: login, logout, me.
public static IEndpointRouteBuilder MapOperatorAuthEndpoints(this IEndpointRouteBuilder app) /// </summary>
{ /// <param name="app">Построитель маршрутов приложения.</param>
var group = app.MapGroup(OperatorAuthGroupPrefix).WithTags(OperatorAuthOpenApiTag); /// <returns>Построитель маршрутов для цепочки вызовов.</returns>
public static IEndpointRouteBuilder MapOperatorAuthEndpoints(this IEndpointRouteBuilder app)
// Политика "auth" rate limiter (план Task 11, Ruling 5): фиксированное окно 10/мин на IP ручки {
// входа оператора; остальные ручки группы — под глобальной API-политикой (по тенанту/IP). var group = app.MapGroup(OperatorAuthGroupPrefix).WithTags(OperatorAuthOpenApiTag);
group.MapPost("/login", LoginAsync).RequireRateLimiting(RateLimitPolicies.AuthPolicy);
group.MapPost("/logout", LogoutAsync); // Политика "auth" rate limiter (план Task 11, Ruling 5): фиксированное окно 10/мин на IP ручки
group.MapGet("/me", MeAsync); // входа оператора; остальные ручки группы — под глобальной API-политикой (по тенанту/IP).
group.MapPost("/login", LoginAsync).RequireRateLimiting(RateLimitPolicies.AuthPolicy);
return app; group.MapPost("/logout", LogoutAsync);
} group.MapGet("/me", MeAsync);
// POST /api/operator/auth/login: проверка учётных данных оператора, выдача куки сессии; результат пишется в аудит (Task 4). return app;
// До OperatorAuthService отрабатывает LoginAttemptGuard (5 неудач ip|login за 15 мин → 429, Ruling 5). }
private static async Task<IResult> LoginAsync(
LoginRequest body, // POST /api/operator/auth/login: проверка учётных данных оператора, выдача куки сессии; результат пишется в аудит (Task 4).
OperatorAuthService operatorAuthService, // До OperatorAuthService отрабатывает LoginAttemptGuard (5 неудач ip|login за 15 мин → 429, Ruling 5).
AuditService auditService, private static async Task<IResult> LoginAsync(
IOptions<OperatorCookieOptions> cookieOptions, LoginRequest body,
HttpContext context, OperatorAuthService operatorAuthService,
CancellationToken ct, AuditService auditService,
LoginAttemptGuard loginAttemptGuard) IOptions<OperatorCookieOptions> cookieOptions,
{ HttpContext context,
string? attemptedLogin = NormalizeLogin(body.Login); CancellationToken ct,
LoginAttemptGuard loginAttemptGuard)
// Защита входа оператора (план Task 11, Ruling 5): зеркало AuthEndpoints — блокировка ключа {
// ip|login до проверки учётных данных (в dev при RateLimit:Enabled=false гвард выключен). string? attemptedLogin = NormalizeLogin(body.Login);
if (await loginAttemptGuard.IsBlockedAsync(ClientIp(context), attemptedLogin, ct))
{ // Защита входа оператора (план Task 11, Ruling 5): зеркало AuthEndpoints — блокировка ключа
return EndpointResults.TooManyRequests(LoginAttemptGuard.BlockedDetail); // ip|login до проверки учётных данных (в dev при RateLimit:Enabled=false гвард выключен).
} if (await loginAttemptGuard.IsBlockedAsync(ClientIp(context), attemptedLogin, ct))
{
var result = await operatorAuthService.LoginAsync(body.Login, body.Password, ct); return EndpointResults.TooManyRequests(LoginAttemptGuard.BlockedDetail);
if (result.Login is null || result.Token is null) }
{
// Неверные учётные данные оператора — одно сообщение (зеркало AuthEndpoints). var result = await operatorAuthService.LoginAsync(body.Login, body.Password, ct);
// Аудит operator_login_failed — только для реальной попытки (непустой логин), без пароля (Ruling 4); if (result.Login is null || result.Token is null)
// счётчик неудач гварда растёт там же (пустые логины ключа не имеют). {
if (attemptedLogin is not null) // Неверные учётные данные оператора — одно сообщение (зеркало AuthEndpoints).
{ // Аудит operator_login_failed — только для реальной попытки (непустой логин), без пароля (Ruling 4);
await loginAttemptGuard.RecordFailureAsync(ClientIp(context), attemptedLogin, ct); // счётчик неудач гварда растёт там же (пустые логины ключа не имеют).
await auditService.AppendAsync(new AuditRecordDto( if (attemptedLogin is not null)
AuditEvents.OperatorLoginFailed, {
AuditActorTypes.Operator, await loginAttemptGuard.RecordFailureAsync(ClientIp(context), attemptedLogin, ct);
ActorId: null, await auditService.AppendAsync(new AuditRecordDto(
TenantId: null, AuditEvents.OperatorLoginFailed,
Ip: ClientIp(context), AuditActorTypes.Operator,
DetailJson: AuditService.ToDetailJson(new { login = attemptedLogin })), ct); ActorId: null,
} TenantId: null,
Ip: ClientIp(context),
return EndpointResults.Unauthorized(InvalidCredentialsDetail); DetailJson: AuditService.ToDetailJson(new { login = attemptedLogin })), ct);
} }
// Успешный вход оператора сбрасывает счётчик неудач ключа ip|login (Ruling 5). return EndpointResults.Unauthorized(InvalidCredentialsDetail);
await loginAttemptGuard.ResetAsync(ClientIp(context), result.Login, ct); }
await auditService.AppendAsync(new AuditRecordDto( // Успешный вход оператора сбрасывает счётчик неудач ключа ip|login (Ruling 5).
AuditEvents.OperatorLoginOk, await loginAttemptGuard.ResetAsync(ClientIp(context), result.Login, ct);
AuditActorTypes.Operator,
ActorId: result.OperatorId, await auditService.AppendAsync(new AuditRecordDto(
TenantId: null, AuditEvents.OperatorLoginOk,
Ip: ClientIp(context), AuditActorTypes.Operator,
DetailJson: AuditService.ToDetailJson(new { login = result.Login })), ct); ActorId: result.OperatorId,
TenantId: null,
SetOperatorSessionCookie(context, cookieOptions.Value, result.Token); Ip: ClientIp(context),
return Results.Ok(new { ok = true, login = result.Login }); DetailJson: AuditService.ToDetailJson(new { login = result.Login })), ct);
}
SetOperatorSessionCookie(context, cookieOptions.Value, result.Token);
// POST /api/operator/auth/logout: удаление операторской сессии по токену из куки и очистка куки (всегда ok). return Results.Ok(new { ok = true, login = result.Login });
private static async Task<IResult> LogoutAsync( }
OperatorAuthService operatorAuthService,
IOptions<OperatorCookieOptions> cookieOptions, // POST /api/operator/auth/logout: удаление операторской сессии по токену из куки и очистка куки (всегда ok).
HttpContext context, private static async Task<IResult> LogoutAsync(
CancellationToken ct) OperatorAuthService operatorAuthService,
{ IOptions<OperatorCookieOptions> cookieOptions,
var cookieName = cookieOptions.Value.Name; HttpContext context,
var rawToken = context.Request.Cookies[cookieName]; CancellationToken ct)
// Оператор разрешённой сессии — до её удаления (OperatorSessionMiddleware наполнил Items). {
CurrentOperator? operatorIdentity = context.GetCurrentOperator(); var cookieName = cookieOptions.Value.Name;
await operatorAuthService.LogoutAsync(rawToken, ct); var rawToken = context.Request.Cookies[cookieName];
context.Response.Cookies.Delete(cookieName); // Оператор разрешённой сессии — до её удаления (OperatorSessionMiddleware наполнил Items).
CurrentOperator? operatorIdentity = context.GetCurrentOperator();
// Выход оператора (этап 10, T1): событие пишется при живой разрешённой сессии. await operatorAuthService.LogoutAsync(rawToken, ct);
if (operatorIdentity is not null) context.Response.Cookies.Delete(cookieName);
{
await AuditAppender.AppendOperatorAsync(context, AuditEvents.OperatorLogout, new { login = operatorIdentity.Login }, ct); // Выход оператора (этап 10, T1): событие пишется при живой разрешённой сессии.
} if (operatorIdentity is not null)
{
return Results.Ok(new { ok = true }); await AuditAppender.AppendOperatorAsync(context, AuditEvents.OperatorLogout, new { login = operatorIdentity.Login }, ct);
} }
// GET /api/operator/auth/me: проверка живой операторской сессии (401 без неё, Ruling 1). return Results.Ok(new { ok = true });
private static IResult MeAsync(HttpContext context) }
{
var operatorIdentity = context.GetCurrentOperator(); // GET /api/operator/auth/me: проверка живой операторской сессии (401 без неё, Ruling 1).
if (operatorIdentity is null) private static IResult MeAsync(HttpContext context)
{ {
return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail); var operatorIdentity = context.GetCurrentOperator();
} if (operatorIdentity is null)
{
return Results.Ok(new { login = operatorIdentity.Login, ok = true }); return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail);
} }
// Выставляет httpOnly-куку сессии оператора: SameSite=Lax, Path=/, MaxAge=Hours, Secure — из конфига. return Results.Ok(new { login = operatorIdentity.Login, ok = true });
// context: Контекст запроса. }
// options: Настройки куки из конфигурации (секция OperatorCookies).
// rawToken: Raw-токен операторской сессии. // Выставляет httpOnly-куку сессии оператора: SameSite=Lax, Path=/, MaxAge=Hours, Secure — из конфига.
private static void SetOperatorSessionCookie(HttpContext context, OperatorCookieOptions options, string rawToken) // context: Контекст запроса.
{ // options: Настройки куки из конфигурации (секция OperatorCookies).
// MaxAge — OperatorCookies:Hours; код-дефолт значения ссылается на // rawToken: Raw-токен операторской сессии.
// OperatorAuthService.SessionLifetimeHours (единый источник «12 часов», см. OperatorCookieOptions). private static void SetOperatorSessionCookie(HttpContext context, OperatorCookieOptions options, string rawToken)
context.Response.Cookies.Append( {
options.Name, // MaxAge — OperatorCookies:Hours; код-дефолт значения ссылается на
rawToken, // OperatorAuthService.SessionLifetimeHours (единый источник «12 часов», см. OperatorCookieOptions).
new AspNetCoreCookieOptions context.Response.Cookies.Append(
{ options.Name,
HttpOnly = true, rawToken,
SameSite = SameSiteMode.Lax, new AspNetCoreCookieOptions
Path = "/", {
MaxAge = TimeSpan.FromHours(options.Hours), HttpOnly = true,
Secure = options.Secure, SameSite = SameSiteMode.Lax,
}); Path = "/",
} MaxAge = TimeSpan.FromHours(options.Hours),
Secure = options.Secure,
// Нормализованная попытка логина для аудита (нижний регистр/обрезка); null — писать нечего. });
// login: Логин из тела запроса. }
// Возвращает: Нормализованный логин или null при пустом/пробельном входе.
private static string? NormalizeLogin(string? login) // Нормализованная попытка логина для аудита (нижний регистр/обрезка); null — писать нечего.
{ // login: Логин из тела запроса.
string? normalized = login?.Trim().ToLowerInvariant(); // Возвращает: Нормализованный логин или null при пустом/пробельном входе.
return string.IsNullOrEmpty(normalized) ? null : normalized; private static string? NormalizeLogin(string? login)
} {
string? normalized = login?.Trim().ToLowerInvariant();
// IP-адрес клиента для аудита (без порта; null, если недоступен). return string.IsNullOrEmpty(normalized) ? null : normalized;
// context: Контекст запроса. }
// Возвращает: Строковое представление IP или null.
private static string? ClientIp(HttpContext context) => context.Connection.RemoteIpAddress?.ToString(); // IP-адрес клиента для аудита (без порта; null, если недоступен).
} // context: Контекст запроса.
// Возвращает: Строковое представление IP или null.
private static string? ClientIp(HttpContext context) => context.Connection.RemoteIpAddress?.ToString();
}
@@ -1,6 +1,9 @@
using Deal.Api.Http; using Deal.Api.Http;
using Deal.Modules.Tenants.Application; using Deal.Modules.Tenants.Application.Abstractions;
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.Services;
namespace Deal.Api.Endpoints; namespace Deal.Api.Endpoints;
@@ -1,6 +1,9 @@
using Deal.Api.Http; using Deal.Api.Http;
using Deal.Modules.Tenants.Application; using Deal.Modules.Tenants.Application.Abstractions;
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.Services;
namespace Deal.Api.Endpoints; namespace Deal.Api.Endpoints;
@@ -1,154 +1,157 @@
using Deal.Api.Endpoints.RequestModels; using Deal.Api.Endpoints.RequestModels;
using Deal.Api.Http; using Deal.Api.Http;
using Deal.Api.Telegram; using Deal.Api.Telegram;
using Deal.Modules.Tenants.Application; using Deal.Modules.Tenants.Application.Abstractions;
using Deal.Modules.Tenants.Application.Models; using Deal.Modules.Tenants.Application.Extensions;
using Deal.Modules.Tenants.Application.Models;
namespace Deal.Api.Endpoints; using Deal.Modules.Tenants.Application.Registrars;
using Deal.Modules.Tenants.Application.Services;
/// <summary>
/// Операторские ручки глобальных (системных) настроек: ключи приложения Telegram namespace Deal.Api.Endpoints;
/// (ТЗ §4.1/§8.1).
/// </summary> /// <summary>
/// <remarks> /// Операторские ручки глобальных (системных) настроек: ключи приложения Telegram
/// Все ручки — только под операторской сессией: без неё 401 «Требуется вход оператора». Ключи Telegram /// (ТЗ §4.1/§8.1).
/// задаёт оператор глобально (едины для всех тенантов), тенант их не видит и не задаёт. /// </summary>
/// <list type="bullet"> /// <remarks>
/// <item>GET /api/operator/settings/telegram-keys — маскированный снимок: apiId (не секрет, открыт), /// Все ручки — только под операторской сессией: без неё 401 «Требуется вход оператора». Ключи Telegram
/// apiHash (маска) и keysSet;</item> /// задаёт оператор глобально (едины для всех тенантов), тенант их не видит и не задаёт.
/// <item>PUT /api/operator/settings/telegram-keys {apiId?, apiHash?} — частичное сохранение (можно /// <list type="bullet">
/// передать только одно поле, второе сохраняется); валидация (api_id 5..9 цифр, api_hash непустой), /// <item>GET /api/operator/settings/telegram-keys — маскированный снимок: apiId (не секрет, открыт),
/// шифрование секрета и аудит telegram_keys_changed (без секретов в деталях).</item> /// apiHash (маска) и keysSet;</item>
/// </list> /// <item>PUT /api/operator/settings/telegram-keys {apiId?, apiHash?} — частичное сохранение (можно
/// Ошибки — 400/401 <c>{detail}</c> (формат прототипа, Ruling 10). /// передать только одно поле, второе сохраняется); валидация (api_id 5..9 цифр, api_hash непустой),
/// </remarks> /// шифрование секрета и аудит telegram_keys_changed (без секретов в деталях).</item>
public static class OperatorSettingsEndpoints /// </list>
{ /// Ошибки — 400/401 <c>{detail}</c> (формат прототипа, Ruling 10).
// Префикс группы операторских настроек. /// </remarks>
private const string SettingsGroupPrefix = "/api/operator/settings"; public static class OperatorSettingsEndpoints
{
// OpenAPI-тег группы. // Префикс группы операторских настроек.
private const string SettingsOpenApiTag = "operator-settings"; private const string SettingsGroupPrefix = "/api/operator/settings";
// Относительный путь глобальных ключей Telegram (GET/PUT). // OpenAPI-тег группы.
private const string TelegramKeysPath = "/telegram-keys"; private const string SettingsOpenApiTag = "operator-settings";
// Текст 400: пустое тело PUT (ни одного поля). // Относительный путь глобальных ключей Telegram (GET/PUT).
private const string EmptyBodyDetail = "Укажите api_id и api_hash"; private const string TelegramKeysPath = "/telegram-keys";
// Текст 400: частичное обновление, но ключей ещё нет — нужны оба поля. // Текст 400: пустое тело PUT (ни одного поля).
private const string MissingKeysDetail = "Ключи ещё не заданы — укажите и api_id, и api_hash"; private const string EmptyBodyDetail = "Укажите api_id и api_hash";
// Текст 400: api_id не 5..9 цифр. // Текст 400: частичное обновление, но ключей ещё нет — нужны оба поля.
private const string InvalidApiIdDetail = "api_id должен состоять из 5–9 цифр"; private const string MissingKeysDetail = "Ключи ещё не заданы — укажите и api_id, и api_hash";
// Текст 400: api_hash пустой/маска/с префиксом enc:. // Текст 400: api_id не 5..9 цифр.
private const string InvalidApiHashDetail = "Укажите непустой api_hash"; private const string InvalidApiIdDetail = "api_id должен состоять из 5–9 цифр";
/// <summary> // Текст 400: api_hash пустой/маска/с префиксом enc:.
/// Регистрирует группу /api/operator/settings: telegram-keys (GET/PUT). private const string InvalidApiHashDetail = "Укажите непустой api_hash";
/// </summary>
/// <param name="app">Построитель маршрутов приложения.</param> /// <summary>
/// <returns>Построитель маршрутов для цепочки вызовов.</returns> /// Регистрирует группу /api/operator/settings: telegram-keys (GET/PUT).
public static IEndpointRouteBuilder MapOperatorSettingsEndpoints(this IEndpointRouteBuilder app) /// </summary>
{ /// <param name="app">Построитель маршрутов приложения.</param>
var group = app.MapGroup(SettingsGroupPrefix).WithTags(SettingsOpenApiTag); /// <returns>Построитель маршрутов для цепочки вызовов.</returns>
group.MapGet(TelegramKeysPath, GetTelegramKeysAsync); public static IEndpointRouteBuilder MapOperatorSettingsEndpoints(this IEndpointRouteBuilder app)
group.MapPut(TelegramKeysPath, PutTelegramKeysAsync); {
return app; var group = app.MapGroup(SettingsGroupPrefix).WithTags(SettingsOpenApiTag);
} group.MapGet(TelegramKeysPath, GetTelegramKeysAsync);
group.MapPut(TelegramKeysPath, PutTelegramKeysAsync);
// GET /api/operator/settings/telegram-keys: маскированные глобальные ключи Telegram. return app;
// context: Контекст запроса. }
// keys: Сервис глобальных ключей Telegram (scoped).
// ct: Токен отмены. // GET /api/operator/settings/telegram-keys: маскированные глобальные ключи Telegram.
// Возвращает: 200 маскированный снимок или 401 без операторской сессии. // context: Контекст запроса.
private static async Task<IResult> GetTelegramKeysAsync( // keys: Сервис глобальных ключей Telegram (scoped).
HttpContext context, // ct: Токен отмены.
TelegramKeysService keys, // Возвращает: 200 маскированный снимок или 401 без операторской сессии.
CancellationToken ct) private static async Task<IResult> GetTelegramKeysAsync(
{ HttpContext context,
if (context.GetCurrentOperator() is null) TelegramKeysService keys,
{ CancellationToken ct)
return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail); {
} if (context.GetCurrentOperator() is null)
{
TelegramKeysMaskedDto snapshot = await keys.GetMaskedAsync(ct); return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail);
return Results.Ok(snapshot); }
}
TelegramKeysMaskedDto snapshot = await keys.GetMaskedAsync(ct);
// PUT /api/operator/settings/telegram-keys: частичное сохранение глобальных ключей Telegram return Results.Ok(snapshot);
// оператором. }
// Поля можно передавать по отдельности: непереданное поле (null) сохраняет текущее значение,
// явное значение (в т.ч. пустая строка) валидируется. Если ключей ещё нет, оба поля обязательны. // PUT /api/operator/settings/telegram-keys: частичное сохранение глобальных ключей Telegram
// body: Тело {apiId?, apiHash?} (хотя бы одно поле). // оператором.
// context: Контекст запроса. // Поля можно передавать по отдельности: непереданное поле (null) сохраняет текущее значение,
// keys: Сервис глобальных ключей Telegram (scoped). // явное значение (в т.ч. пустая строка) валидируется. Если ключей ещё нет, оба поля обязательны.
// auditService: Сервис аудита (событие telegram_keys_changed). // body: Тело {apiId?, apiHash?} (хотя бы одно поле).
// ct: Токен отмены. // context: Контекст запроса.
// Возвращает: 200 маскированный снимок, 400 при невалидных/недостающих полях или 401 без операторской сессии. // keys: Сервис глобальных ключей Telegram (scoped).
private static async Task<IResult> PutTelegramKeysAsync( // auditService: Сервис аудита (событие telegram_keys_changed).
OperatorTelegramKeysRequest? body, // ct: Токен отмены.
HttpContext context, // Возвращает: 200 маскированный снимок, 400 при невалидных/недостающих полях или 401 без операторской сессии.
TelegramKeysService keys, private static async Task<IResult> PutTelegramKeysAsync(
AuditService auditService, OperatorTelegramKeysRequest? body,
CancellationToken ct) HttpContext context,
{ TelegramKeysService keys,
var operatorIdentity = context.GetCurrentOperator(); AuditService auditService,
if (operatorIdentity is null) CancellationToken ct)
{ {
return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail); var operatorIdentity = context.GetCurrentOperator();
} if (operatorIdentity is null)
{
if (body is null) return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail);
{ }
return EndpointResults.BadRequest(EmptyBodyDetail);
} if (body is null)
{
// null — поле не передано (сохраняем текущее); непустая строка/плейсхолдер — валидируем явно. return EndpointResults.BadRequest(EmptyBodyDetail);
string? apiId = body.ApiId?.Trim(); }
string? apiHash = body.ApiHash?.Trim();
if (apiId is null && apiHash is null) // null — поле не передано (сохраняем текущее); непустая строка/плейсхолдер — валидируем явно.
{ string? apiId = body.ApiId?.Trim();
return EndpointResults.BadRequest(EmptyBodyDetail); string? apiHash = body.ApiHash?.Trim();
} if (apiId is null && apiHash is null)
{
if (apiId is not null && !TelegramKeysService.IsValidApiId(apiId)) return EndpointResults.BadRequest(EmptyBodyDetail);
{ }
return EndpointResults.BadRequest(InvalidApiIdDetail);
} if (apiId is not null && !TelegramKeysService.IsValidApiId(apiId))
{
if (apiHash is not null && !TelegramKeysService.IsValidApiHash(apiHash)) return EndpointResults.BadRequest(InvalidApiIdDetail);
{ }
return EndpointResults.BadRequest(InvalidApiHashDetail);
} if (apiHash is not null && !TelegramKeysService.IsValidApiHash(apiHash))
{
// Частичное обновление: недостающее поле берём из текущих ключей; если ключей ещё нет — нужны оба. return EndpointResults.BadRequest(InvalidApiHashDetail);
TgKeysSnapshot current = await keys.GetAsync(ct); }
string effectiveApiId = apiId ?? current.ApiId;
string effectiveApiHash = apiHash ?? current.ApiHash; // Частичное обновление: недостающее поле берём из текущих ключей; если ключей ещё нет — нужны оба.
if (effectiveApiId.Length == 0 || effectiveApiHash.Length == 0) TgKeysSnapshot current = await keys.GetAsync(ct);
{ string effectiveApiId = apiId ?? current.ApiId;
return EndpointResults.BadRequest(MissingKeysDetail); string effectiveApiHash = apiHash ?? current.ApiHash;
} if (effectiveApiId.Length == 0 || effectiveApiHash.Length == 0)
{
await keys.SaveAsync(effectiveApiId, effectiveApiHash, ct); return EndpointResults.BadRequest(MissingKeysDetail);
}
// Аудит смены глобальных ключей: apiId — не секрет, apiHash в детали не пишется (Ruling 4).
await auditService.AppendAsync(new AuditRecordDto( await keys.SaveAsync(effectiveApiId, effectiveApiHash, ct);
AuditEvents.TelegramKeysChanged,
AuditActorTypes.Operator, // Аудит смены глобальных ключей: apiId — не секрет, apiHash в детали не пишется (Ruling 4).
ActorId: operatorIdentity.OperatorId, await auditService.AppendAsync(new AuditRecordDto(
TenantId: null, AuditEvents.TelegramKeysChanged,
Ip: ClientIp(context), AuditActorTypes.Operator,
DetailJson: AuditService.ToDetailJson(new { apiId = effectiveApiId, apiHashSet = true })), ct); ActorId: operatorIdentity.OperatorId,
TenantId: null,
TelegramKeysMaskedDto snapshot = await keys.GetMaskedAsync(ct); Ip: ClientIp(context),
return Results.Ok(snapshot); DetailJson: AuditService.ToDetailJson(new { apiId = effectiveApiId, apiHashSet = true })), ct);
}
TelegramKeysMaskedDto snapshot = await keys.GetMaskedAsync(ct);
// IP-адрес клиента для аудита (без порта; null, если недоступен). return Results.Ok(snapshot);
// context: Контекст запроса. }
// Возвращает: Строковое представление IP или null.
private static string? ClientIp(HttpContext context) => context.Connection.RemoteIpAddress?.ToString(); // IP-адрес клиента для аудита (без порта; null, если недоступен).
} // context: Контекст запроса.
// Возвращает: Строковое представление IP или null.
private static string? ClientIp(HttpContext context) => context.Connection.RemoteIpAddress?.ToString();
}
@@ -1,6 +1,9 @@
using Deal.Api.Http; using Deal.Api.Http;
using Deal.Modules.Tenants.Application; using Deal.Modules.Tenants.Application.Abstractions;
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.Services;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using CookieOptions = Deal.Api.Configuration.CookieOptions; using CookieOptions = Deal.Api.Configuration.CookieOptions;
@@ -1,7 +1,9 @@
using Deal.Api.Endpoints.RequestModels; using Deal.Api.Endpoints.RequestModels;
using Deal.Api.Http; using Deal.Api.Http;
using Deal.Modules.Pipeline.Application; 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.Services;
namespace Deal.Api.Endpoints; namespace Deal.Api.Endpoints;
@@ -1,6 +1,8 @@
using Deal.Api.Http; using Deal.Api.Http;
using Deal.Modules.Settings.Application; 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.Services;
namespace Deal.Api.Endpoints; namespace Deal.Api.Endpoints;
@@ -1,9 +1,15 @@
using System.Text.Json; using System.Text.Json;
using Deal.Api; using Deal.Api;
using Deal.Api.Http; using Deal.Api.Http;
using Deal.Modules.Settings.Application; using Deal.Modules.Settings.Application.Abstractions;
using Deal.Modules.Settings.Application.Models; using Deal.Modules.Settings.Application.Models;
using Deal.Modules.Tenants.Application; using Deal.Modules.Settings.Application.Registrars;
using Deal.Modules.Settings.Application.Services;
using Deal.Modules.Tenants.Application.Abstractions;
using Deal.Modules.Tenants.Application.Extensions;
using Deal.Modules.Tenants.Application.Models;
using Deal.Modules.Tenants.Application.Registrars;
using Deal.Modules.Tenants.Application.Services;
namespace Deal.Api.Endpoints; namespace Deal.Api.Endpoints;
@@ -5,7 +5,11 @@ using Deal.Contracts.Integrations;
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;
using Deal.Modules.Tenants.Application; using Deal.Modules.Tenants.Application.Abstractions;
using Deal.Modules.Tenants.Application.Extensions;
using Deal.Modules.Tenants.Application.Models;
using Deal.Modules.Tenants.Application.Registrars;
using Deal.Modules.Tenants.Application.Services;
namespace Deal.Api.Endpoints; namespace Deal.Api.Endpoints;
@@ -1,6 +1,9 @@
using Deal.Api.Events; using Deal.Api.Events;
using Deal.Modules.Tenants.Application; using Deal.Modules.Tenants.Application.Abstractions;
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.Services;
namespace Deal.Api.Hosting; namespace Deal.Api.Hosting;
@@ -1,5 +1,9 @@
using Deal.Api.Configuration; using Deal.Api.Configuration;
using Deal.Modules.Tenants.Application; using Deal.Modules.Tenants.Application.Abstractions;
using Deal.Modules.Tenants.Application.Extensions;
using Deal.Modules.Tenants.Application.Models;
using Deal.Modules.Tenants.Application.Registrars;
using Deal.Modules.Tenants.Application.Services;
namespace Deal.Api.Hosting; namespace Deal.Api.Hosting;
@@ -1,6 +1,14 @@
using Deal.Modules.Discovery.Application; using Deal.Modules.Discovery.Application.Abstractions;
using Deal.Modules.Tenants.Application; using Deal.Modules.Discovery.Application.Exceptions;
using Deal.Modules.Discovery.Application.Extensions;
using Deal.Modules.Discovery.Application.Models;
using Deal.Modules.Discovery.Application.Registrars;
using Deal.Modules.Discovery.Application.Services;
using Deal.Modules.Tenants.Application.Abstractions;
using Deal.Modules.Tenants.Application.Extensions;
using Deal.Modules.Tenants.Application.Models; using Deal.Modules.Tenants.Application.Models;
using Deal.Modules.Tenants.Application.Registrars;
using Deal.Modules.Tenants.Application.Services;
using Deal.SharedKernel.Tenants; using Deal.SharedKernel.Tenants;
namespace Deal.Api.Hosting; namespace Deal.Api.Hosting;
@@ -1,8 +1,14 @@
using Deal.Infrastructure.Integrations; using Deal.Infrastructure.Integrations;
using Deal.Modules.Kanban.Application; using Deal.Modules.Kanban.Application.Abstractions;
using Deal.Modules.Kanban.Application.Extensions;
using Deal.Modules.Kanban.Application.Models; using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Tenants.Application; using Deal.Modules.Kanban.Application.Registrars;
using Deal.Modules.Kanban.Application.Services;
using Deal.Modules.Tenants.Application.Abstractions;
using Deal.Modules.Tenants.Application.Extensions;
using Deal.Modules.Tenants.Application.Models; using Deal.Modules.Tenants.Application.Models;
using Deal.Modules.Tenants.Application.Registrars;
using Deal.Modules.Tenants.Application.Services;
using Deal.SharedKernel.Tenants; using Deal.SharedKernel.Tenants;
namespace Deal.Api.Hosting; namespace Deal.Api.Hosting;
@@ -1,4 +1,8 @@
using Deal.Modules.Tenants.Application; using Deal.Modules.Tenants.Application.Abstractions;
using Deal.Modules.Tenants.Application.Extensions;
using Deal.Modules.Tenants.Application.Models;
using Deal.Modules.Tenants.Application.Registrars;
using Deal.Modules.Tenants.Application.Services;
namespace Deal.Api.Hosting; namespace Deal.Api.Hosting;
@@ -1,9 +1,18 @@
using Deal.Api.Events; using Deal.Api.Events;
using Deal.Modules.Kanban.Application; using Deal.Modules.Kanban.Application.Abstractions;
using Deal.Modules.Kanban.Application.Extensions;
using Deal.Modules.Kanban.Application.Models; using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Pipeline.Application; using Deal.Modules.Kanban.Application.Registrars;
using Deal.Modules.Tenants.Application; using Deal.Modules.Kanban.Application.Services;
using Deal.Modules.Pipeline.Application.Abstractions;
using Deal.Modules.Pipeline.Application.Models;
using Deal.Modules.Pipeline.Application.Registrars;
using Deal.Modules.Pipeline.Application.Services;
using Deal.Modules.Tenants.Application.Abstractions;
using Deal.Modules.Tenants.Application.Extensions;
using Deal.Modules.Tenants.Application.Models; using Deal.Modules.Tenants.Application.Models;
using Deal.Modules.Tenants.Application.Registrars;
using Deal.Modules.Tenants.Application.Services;
using Deal.SharedKernel.Tenants; using Deal.SharedKernel.Tenants;
namespace Deal.Api.Hosting; namespace Deal.Api.Hosting;
@@ -1,6 +1,9 @@
using Deal.Infrastructure.Tenancy; using Deal.Infrastructure.Tenancy;
using Deal.Modules.Tenants.Application; using Deal.Modules.Tenants.Application.Abstractions;
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.Services;
namespace Deal.Api.Hosting; namespace Deal.Api.Hosting;
+86 -83
View File
@@ -1,83 +1,86 @@
using Deal.Modules.Tenants.Application; using Deal.Modules.Tenants.Application.Abstractions;
using Deal.Modules.Tenants.Application.Models; using Deal.Modules.Tenants.Application.Extensions;
using Microsoft.Extensions.DependencyInjection; using Deal.Modules.Tenants.Application.Models;
using Deal.Modules.Tenants.Application.Registrars;
namespace Deal.Api.Http; using Deal.Modules.Tenants.Application.Services;
using Microsoft.Extensions.DependencyInjection;
/// <summary>
/// Хелпер записи аудита действий пользователей тенанта и операторов (единая точка — <see cref="AuditService"/>). namespace Deal.Api.Http;
/// </summary>
/// <remarks> /// <summary>
/// Актор берётся из разрешённой сессии (<c>HttpContext.Items</c>, наполняют SessionMiddleware/ /// Хелпер записи аудита действий пользователей тенанта и операторов (единая точка — <see cref="AuditService"/>).
/// OperatorSessionMiddleware): для тенанта — <see cref="AuditActorTypes.Tenant"/> с userId/tenantId, для /// </summary>
/// оператора — <see cref="AuditActorTypes.Operator"/> без tenantId. IP — адрес клиента без порта. /// <remarks>
/// Детали — минимальные, без секретов (пароли/токены/api-ключи). Нет сессии — no-op (вызывать после /// Актор берётся из разрешённой сессии (<c>HttpContext.Items</c>, наполняют SessionMiddleware/
/// проверки HasUser, но безопасно и без неё). Append-only, как весь аудит. /// OperatorSessionMiddleware): для тенанта — <see cref="AuditActorTypes.Tenant"/> с userId/tenantId, для
/// </remarks> /// оператора — <see cref="AuditActorTypes.Operator"/> без tenantId. IP — адрес клиента без порта.
public static class AuditAppender /// Детали — минимальные, без секретов (пароли/токены/api-ключи). Нет сессии — no-op (вызывать после
{ /// проверки HasUser, но безопасно и без неё). Append-only, как весь аудит.
/// <summary> /// </remarks>
/// Пишет событие действия пользователя тенанта (актор tenant). public static class AuditAppender
/// </summary> {
/// <param name="context">Контекст запроса (источник актора и IP).</param> /// <summary>
/// <param name="eventType">Тип события — константа <see cref="AuditEvents"/>.</param> /// Пишет событие действия пользователя тенанта (актор tenant).
/// <param name="details">Минимальные детали события (обычно анонимный объект) или null.</param> /// </summary>
/// <param name="ct">Токен отмены.</param> /// <param name="context">Контекст запроса (источник актора и IP).</param>
public static async Task AppendTenantAsync(HttpContext context, string eventType, object? details, CancellationToken ct) /// <param name="eventType">Тип события — константа <see cref="AuditEvents"/>.</param>
{ /// <param name="details">Минимальные детали события (обычно анонимный объект) или null.</param>
CurrentUser? user = context.GetCurrentUser(); /// <param name="ct">Токен отмены.</param>
if (user is null) public static async Task AppendTenantAsync(HttpContext context, string eventType, object? details, CancellationToken ct)
{ {
return; CurrentUser? user = context.GetCurrentUser();
} if (user is null)
{
AuditService audit = context.RequestServices.GetRequiredService<AuditService>(); return;
await audit.AppendAsync( }
new AuditRecordDto(
eventType, AuditService audit = context.RequestServices.GetRequiredService<AuditService>();
AuditActorTypes.Tenant, await audit.AppendAsync(
ActorId: user.UserId, new AuditRecordDto(
TenantId: user.TenantId, eventType,
Ip: ClientIp(context), AuditActorTypes.Tenant,
DetailJson: DetailJson(details)), ActorId: user.UserId,
ct); TenantId: user.TenantId,
} Ip: ClientIp(context),
DetailJson: DetailJson(details)),
/// <summary> ct);
/// Пишет событие действия оператора (актор operator, без tenantId). }
/// </summary>
/// <param name="context">Контекст запроса (источник актора и IP).</param> /// <summary>
/// <param name="eventType">Тип события — константа <see cref="AuditEvents"/>.</param> /// Пишет событие действия оператора (актор operator, без tenantId).
/// <param name="details">Минимальные детали события (обычно анонимный объект) или null.</param> /// </summary>
/// <param name="ct">Токен отмены.</param> /// <param name="context">Контекст запроса (источник актора и IP).</param>
public static async Task AppendOperatorAsync(HttpContext context, string eventType, object? details, CancellationToken ct) /// <param name="eventType">Тип события — константа <see cref="AuditEvents"/>.</param>
{ /// <param name="details">Минимальные детали события (обычно анонимный объект) или null.</param>
CurrentOperator? operatorIdentity = context.GetCurrentOperator(); /// <param name="ct">Токен отмены.</param>
if (operatorIdentity is null) public static async Task AppendOperatorAsync(HttpContext context, string eventType, object? details, CancellationToken ct)
{ {
return; CurrentOperator? operatorIdentity = context.GetCurrentOperator();
} if (operatorIdentity is null)
{
AuditService audit = context.RequestServices.GetRequiredService<AuditService>(); return;
await audit.AppendAsync( }
new AuditRecordDto(
eventType, AuditService audit = context.RequestServices.GetRequiredService<AuditService>();
AuditActorTypes.Operator, await audit.AppendAsync(
ActorId: operatorIdentity.OperatorId, new AuditRecordDto(
TenantId: null, eventType,
Ip: ClientIp(context), AuditActorTypes.Operator,
DetailJson: DetailJson(details)), ActorId: operatorIdentity.OperatorId,
ct); TenantId: null,
} Ip: ClientIp(context),
DetailJson: DetailJson(details)),
// Сериализует детали события (null — деталей нет). ct);
// details: Объект деталей или null. }
// Возвращает: JSON деталей (camelCase) или null.
private static string? DetailJson(object? details) => // Сериализует детали события (null — деталей нет).
details is null ? null : AuditService.ToDetailJson(details); // details: Объект деталей или null.
// Возвращает: JSON деталей (camelCase) или null.
// IP-адрес клиента для аудита (без порта; null, если недоступен). private static string? DetailJson(object? details) =>
// context: Контекст запроса. details is null ? null : AuditService.ToDetailJson(details);
// Возвращает: Строковое представление IP или null.
private static string? ClientIp(HttpContext context) => context.Connection.RemoteIpAddress?.ToString(); // IP-адрес клиента для аудита (без порта; null, если недоступен).
} // context: Контекст запроса.
// Возвращает: Строковое представление IP или null.
private static string? ClientIp(HttpContext context) => context.Connection.RemoteIpAddress?.ToString();
}
+5 -1
View File
@@ -1,5 +1,9 @@
using Deal.Api.Configuration; using Deal.Api.Configuration;
using Deal.Modules.Tenants.Application; using Deal.Modules.Tenants.Application.Abstractions;
using Deal.Modules.Tenants.Application.Extensions;
using Deal.Modules.Tenants.Application.Models;
using Deal.Modules.Tenants.Application.Registrars;
using Deal.Modules.Tenants.Application.Services;
namespace Deal.Api.Http; namespace Deal.Api.Http;
@@ -1,5 +1,9 @@
using System.Threading.RateLimiting; using System.Threading.RateLimiting;
using Deal.Modules.Tenants.Application; using Deal.Modules.Tenants.Application.Abstractions;
using Deal.Modules.Tenants.Application.Extensions;
using Deal.Modules.Tenants.Application.Models;
using Deal.Modules.Tenants.Application.Registrars;
using Deal.Modules.Tenants.Application.Services;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
namespace Deal.Api.Http; namespace Deal.Api.Http;
@@ -1,6 +1,10 @@
using Deal.Api.Configuration; using Deal.Api.Configuration;
using Deal.Api.Http; using Deal.Api.Http;
using Deal.Modules.Tenants.Application; using Deal.Modules.Tenants.Application.Abstractions;
using Deal.Modules.Tenants.Application.Extensions;
using Deal.Modules.Tenants.Application.Models;
using Deal.Modules.Tenants.Application.Registrars;
using Deal.Modules.Tenants.Application.Services;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
// Имя конфигурационного типа совпадает с Microsoft.AspNetCore.Http.CookieOptions — фиксируем алиасом. // Имя конфигурационного типа совпадает с Microsoft.AspNetCore.Http.CookieOptions — фиксируем алиасом.
using OperatorCookieOptions = Deal.Api.Configuration.OperatorCookieOptions; using OperatorCookieOptions = Deal.Api.Configuration.OperatorCookieOptions;
@@ -1,6 +1,10 @@
using Deal.Api.Configuration; using Deal.Api.Configuration;
using Deal.Api.Http; using Deal.Api.Http;
using Deal.Modules.Tenants.Application; using Deal.Modules.Tenants.Application.Abstractions;
using Deal.Modules.Tenants.Application.Extensions;
using Deal.Modules.Tenants.Application.Models;
using Deal.Modules.Tenants.Application.Registrars;
using Deal.Modules.Tenants.Application.Services;
using Deal.SharedKernel.Tenants; using Deal.SharedKernel.Tenants;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
// Имя конфигурационного типа совпадает с Microsoft.AspNetCore.Http.CookieOptions — фиксируем алиасом. // Имя конфигурационного типа совпадает с Microsoft.AspNetCore.Http.CookieOptions — фиксируем алиасом.
@@ -1,147 +1,156 @@
using Deal.Infrastructure.Persistence; using Deal.Infrastructure.Persistence;
using Deal.Modules.Kanban.Application; using Deal.Modules.Kanban.Application.Abstractions;
using Deal.Modules.Pipeline.Application; using Deal.Modules.Kanban.Application.Extensions;
using Deal.Modules.Pipeline.Application.Models; using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Tenants.Application; using Deal.Modules.Kanban.Application.Registrars;
using Deal.Modules.Tenants.Application.Models; using Deal.Modules.Kanban.Application.Services;
using Deal.SharedKernel.Tenants; using Deal.Modules.Pipeline.Application.Abstractions;
using Microsoft.EntityFrameworkCore; using Deal.Modules.Pipeline.Application.Models;
using Deal.Modules.Pipeline.Application.Registrars;
namespace Deal.Api.Observability; using Deal.Modules.Pipeline.Application.Services;
using Deal.Modules.Tenants.Application.Abstractions;
/// <summary> using Deal.Modules.Tenants.Application.Extensions;
/// Сборщик глубин очередей и активных сессий (этап 12, §10.2): единый источник для метрик и health. using Deal.Modules.Tenants.Application.Models;
/// </summary> using Deal.Modules.Tenants.Application.Registrars;
/// <remarks> using Deal.Modules.Tenants.Application.Services;
/// <para> using Deal.SharedKernel.Tenants;
/// Обходит реестр тенантов (на тенант — вложенный scope с <c>ITenantContext.SetTenant</c>) и считает через using Microsoft.EntityFrameworkCore;
/// существующие сервисы/порты, без дублирования SQL:
/// <list type="bullet"> namespace Deal.Api.Observability;
/// <item>глубина очереди пайплайна — <see cref="PipelineProcessingService.QueueCountsAsync"/> (new+filtered);</item>
/// <item>глубина очереди обучения ML — <see cref="IMlLearningStore.CountOutboxAsync"/> (count(MlOutbox));</item> /// <summary>
/// <item>активные сессии — count(public.sessions) + count(public.operator_sessions) с непросроченным ExpiresAt.</item> /// Сборщик глубин очередей и активных сессий (этап 12, §10.2): единый источник для метрик и health.
/// </list> /// </summary>
/// Переиспользуется фоновым <see cref="DealMetricsCollector"/> (публикация в <c>DealMetrics</c>) и операторским /// <remarks>
/// health (глубины прямо в JSON). Ошибки каждой секции логируются и не выбрасываются наружу (сбой тенанта не /// <para>
/// валит проход; наружу летит только отмена); значения агрегируются по всем тенантам. /// Обходит реестр тенантов (на тенант — вложенный scope с <c>ITenantContext.SetTenant</c>) и считает через
/// </para> /// существующие сервисы/порты, без дублирования SQL:
/// </remarks> /// <list type="bullet">
public sealed class RuntimeDepthsCollector /// <item>глубина очереди пайплайна — <see cref="PipelineProcessingService.QueueCountsAsync"/> (new+filtered);</item>
{ /// <item>глубина очереди обучения ML — <see cref="IMlLearningStore.CountOutboxAsync"/> (count(MlOutbox));</item>
private readonly IServiceScopeFactory _scopeFactory; /// <item>активные сессии — count(public.sessions) + count(public.operator_sessions) с непросроченным ExpiresAt.</item>
private readonly ILogger<RuntimeDepthsCollector> _logger; /// </list>
/// Переиспользуется фоновым <see cref="DealMetricsCollector"/> (публикация в <c>DealMetrics</c>) и операторским
/// <summary> /// health (глубины прямо в JSON). Ошибки каждой секции логируются и не выбрасываются наружу (сбой тенанта не
/// Создаёт сборщик глубин. /// валит проход; наружу летит только отмена); значения агрегируются по всем тенантам.
/// </summary> /// </para>
/// <param name="scopeFactory">Фабрика scope: проход и тенант — в собственных scope.</param> /// </remarks>
/// <param name="logger">Логгер ошибок секций.</param> public sealed class RuntimeDepthsCollector
public RuntimeDepthsCollector(IServiceScopeFactory scopeFactory, ILogger<RuntimeDepthsCollector> logger) {
{ private readonly IServiceScopeFactory _scopeFactory;
ArgumentNullException.ThrowIfNull(scopeFactory); private readonly ILogger<RuntimeDepthsCollector> _logger;
ArgumentNullException.ThrowIfNull(logger);
_scopeFactory = scopeFactory; /// <summary>
_logger = logger; /// Создаёт сборщик глубин.
} /// </summary>
/// <param name="scopeFactory">Фабрика scope: проход и тенант — в собственных scope.</param>
/// <summary> /// <param name="logger">Логгер ошибок секций.</param>
/// Собирает снимок глубин очередей и числа активных сессий (агрегат по всем тенантам). public RuntimeDepthsCollector(IServiceScopeFactory scopeFactory, ILogger<RuntimeDepthsCollector> logger)
/// </summary> {
/// <param name="ct">Токен отмены (пробрасывается в EF-запросы; отмена — единственное исключение наружу).</param> ArgumentNullException.ThrowIfNull(scopeFactory);
/// <returns>Снимок: суммарные глубины pipeline/ML-outbox и число активных сессий.</returns> ArgumentNullException.ThrowIfNull(logger);
public async Task<RuntimeDepthsDto> CollectAsync(CancellationToken ct) _scopeFactory = scopeFactory;
{ _logger = logger;
await using AsyncServiceScope cycleScope = _scopeFactory.CreateAsyncScope(); }
int sessions = await CountActiveSessionsAsync(cycleScope, ct);
(long queue, long outbox) = await SumTenantDepthsAsync(cycleScope, ct); /// <summary>
return new RuntimeDepthsDto(queue, outbox, sessions); /// Собирает снимок глубин очередей и числа активных сессий (агрегат по всем тенантам).
} /// </summary>
/// <param name="ct">Токен отмены (пробрасывается в EF-запросы; отмена — единственное исключение наружу).</param>
// Считает активные непросроченные сессии пользователей и операторов (public-схема). /// <returns>Снимок: суммарные глубины pipeline/ML-outbox и число активных сессий.</returns>
// cycleScope: Scope прохода (DealDbContext — без tenant-контекста). public async Task<RuntimeDepthsDto> CollectAsync(CancellationToken ct)
// ct: Токен отмены. {
// Возвращает: Число активных сессий; сбой секции — 0 (снимок остаётся полезным). await using AsyncServiceScope cycleScope = _scopeFactory.CreateAsyncScope();
private async Task<int> CountActiveSessionsAsync(AsyncServiceScope cycleScope, CancellationToken ct) int sessions = await CountActiveSessionsAsync(cycleScope, ct);
{ (long queue, long outbox) = await SumTenantDepthsAsync(cycleScope, ct);
try return new RuntimeDepthsDto(queue, outbox, sessions);
{ }
DealDbContext dbContext = cycleScope.ServiceProvider.GetRequiredService<DealDbContext>();
DateTimeOffset now = DateTimeOffset.UtcNow; // Считает активные непросроченные сессии пользователей и операторов (public-схема).
int tenantSessions = await dbContext.Sessions.CountAsync(session => session.ExpiresAt > now, ct); // cycleScope: Scope прохода (DealDbContext — без tenant-контекста).
int operatorSessions = await dbContext.OperatorSessions.CountAsync(session => session.ExpiresAt > now, ct); // ct: Токен отмены.
return tenantSessions + operatorSessions; // Возвращает: Число активных сессий; сбой секции — 0 (снимок остаётся полезным).
} private async Task<int> CountActiveSessionsAsync(AsyncServiceScope cycleScope, CancellationToken ct)
catch (OperationCanceledException) {
{ try
throw; {
} DealDbContext dbContext = cycleScope.ServiceProvider.GetRequiredService<DealDbContext>();
catch (Exception exception) DateTimeOffset now = DateTimeOffset.UtcNow;
{ int tenantSessions = await dbContext.Sessions.CountAsync(session => session.ExpiresAt > now, ct);
_logger.LogWarning(exception, "Сборщик глубин: подсчёт активных сессий не удался"); int operatorSessions = await dbContext.OperatorSessions.CountAsync(session => session.ExpiresAt > now, ct);
return 0; return tenantSessions + operatorSessions;
} }
} catch (OperationCanceledException)
{
// Суммирует глубины очередей по всем тенантам реестра. throw;
// cycleScope: Scope прохода (реестр тенантов читается без tenant-контекста). }
// ct: Токен отмены. catch (Exception exception)
// Возвращает: Пара (сумма очереди пайплайна, сумма MlOutbox); недоступность реестра — (0, 0). {
private async Task<(long Queue, long Outbox)> SumTenantDepthsAsync(AsyncServiceScope cycleScope, CancellationToken ct) _logger.LogWarning(exception, "Сборщик глубин: подсчёт активных сессий не удался");
{ return 0;
long queueDepth = 0; }
long outboxDepth = 0; }
try
{ // Суммирует глубины очередей по всем тенантам реестра.
ITenantRepository tenantRepository = cycleScope.ServiceProvider.GetRequiredService<ITenantRepository>(); // cycleScope: Scope прохода (реестр тенантов читается без tenant-контекста).
IReadOnlyList<TenantRecordDto> tenants = await tenantRepository.ListAsync(ct); // ct: Токен отмены.
foreach (TenantRecordDto tenant in tenants) // Возвращает: Пара (сумма очереди пайплайна, сумма MlOutbox); недоступность реестра — (0, 0).
{ private async Task<(long Queue, long Outbox)> SumTenantDepthsAsync(AsyncServiceScope cycleScope, CancellationToken ct)
(int queue, int outbox) = await CollectTenantAsync(tenant, ct); {
queueDepth += queue; long queueDepth = 0;
outboxDepth += outbox; long outboxDepth = 0;
} try
} {
catch (OperationCanceledException) ITenantRepository tenantRepository = cycleScope.ServiceProvider.GetRequiredService<ITenantRepository>();
{ IReadOnlyList<TenantRecordDto> tenants = await tenantRepository.ListAsync(ct);
throw; foreach (TenantRecordDto tenant in tenants)
} {
catch (Exception exception) (int queue, int outbox) = await CollectTenantAsync(tenant, ct);
{ queueDepth += queue;
_logger.LogWarning(exception, "Сборщик глубин: обход реестра тенантов не удался"); outboxDepth += outbox;
} }
}
return (queueDepth, outboxDepth); catch (OperationCanceledException)
} {
throw;
// Считает глубины очередей одного тенанта в собственном scope (SetTenant → сервисы → Reset). }
// tenant: Тенант реестра (Id в формате Guid; схема — tenant_&lt;N&gt;). catch (Exception exception)
// ct: Токен отмены прохода. {
// Возвращает: Пара (глубина очереди пайплайна, глубина MlOutbox) для тенанта; сбой — (0, 0). _logger.LogWarning(exception, "Сборщик глубин: обход реестра тенантов не удался");
private async Task<(int Queue, int Outbox)> CollectTenantAsync(TenantRecordDto tenant, CancellationToken ct) }
{
await using AsyncServiceScope tenantScope = _scopeFactory.CreateAsyncScope(); return (queueDepth, outboxDepth);
ITenantContext tenantContext = tenantScope.ServiceProvider.GetRequiredService<ITenantContext>(); }
try
{ // Считает глубины очередей одного тенанта в собственном scope (SetTenant → сервисы → Reset).
tenantContext.SetTenant(new TenantId(tenant.Id.ToString("N"))); // tenant: Тенант реестра (Id в формате Guid; схема — tenant_&lt;N&gt;).
PipelineProcessingService processing = tenantScope.ServiceProvider.GetRequiredService<PipelineProcessingService>(); // ct: Токен отмены прохода.
QueueCountsDto counts = await processing.QueueCountsAsync(ct); // Возвращает: Пара (глубина очереди пайплайна, глубина MlOutbox) для тенанта; сбой — (0, 0).
IMlLearningStore learningStore = tenantScope.ServiceProvider.GetRequiredService<IMlLearningStore>(); private async Task<(int Queue, int Outbox)> CollectTenantAsync(TenantRecordDto tenant, CancellationToken ct)
int outbox = await learningStore.CountOutboxAsync(ct); {
return (counts.Total, outbox); await using AsyncServiceScope tenantScope = _scopeFactory.CreateAsyncScope();
} ITenantContext tenantContext = tenantScope.ServiceProvider.GetRequiredService<ITenantContext>();
catch (OperationCanceledException) try
{ {
throw; tenantContext.SetTenant(new TenantId(tenant.Id.ToString("N")));
} PipelineProcessingService processing = tenantScope.ServiceProvider.GetRequiredService<PipelineProcessingService>();
catch (Exception exception) QueueCountsDto counts = await processing.QueueCountsAsync(ct);
{ IMlLearningStore learningStore = tenantScope.ServiceProvider.GetRequiredService<IMlLearningStore>();
_logger.LogWarning(exception, "Сборщик глубин: подсчёт очередей тенанта {TenantId} не удался", tenant.Id); int outbox = await learningStore.CountOutboxAsync(ct);
return (0, 0); return (counts.Total, outbox);
} }
finally catch (OperationCanceledException)
{ {
tenantContext.Reset(); throw;
} }
} catch (Exception exception)
} {
_logger.LogWarning(exception, "Сборщик глубин: подсчёт очередей тенанта {TenantId} не удался", tenant.Id);
return (0, 0);
}
finally
{
tenantContext.Reset();
}
}
}
+7 -2
View File
@@ -1,9 +1,14 @@
using Deal.Api.Events; using Deal.Api.Events;
using Deal.Modules.Kanban.Application.Models; using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Pipeline.Application; using Deal.Modules.Pipeline.Application.Abstractions;
using Deal.Modules.Pipeline.Application.Models; using Deal.Modules.Pipeline.Application.Models;
using Deal.Modules.Tenants.Application; using Deal.Modules.Pipeline.Application.Registrars;
using Deal.Modules.Pipeline.Application.Services;
using Deal.Modules.Tenants.Application.Abstractions;
using Deal.Modules.Tenants.Application.Extensions;
using Deal.Modules.Tenants.Application.Models; using Deal.Modules.Tenants.Application.Models;
using Deal.Modules.Tenants.Application.Registrars;
using Deal.Modules.Tenants.Application.Services;
using Deal.SharedKernel.Tenants; using Deal.SharedKernel.Tenants;
namespace Deal.Api; namespace Deal.Api;
File diff suppressed because it is too large Load Diff
+4 -1
View File
@@ -1,4 +1,7 @@
using Deal.Modules.Settings.Application; using Deal.Modules.Settings.Application.Abstractions;
using Deal.Modules.Settings.Application.Models;
using Deal.Modules.Settings.Application.Registrars;
using Deal.Modules.Settings.Application.Services;
namespace Deal.Api; namespace Deal.Api;
@@ -1,417 +1,424 @@
using System.Text.Json; using System.Text.Json;
using Deal.Api.Events; using Deal.Api.Events;
using Deal.Contracts.Integrations.Models; using Deal.Contracts.Integrations.Models;
using Deal.Grpc.Telegram; using Deal.Grpc.Telegram;
using Deal.Modules.Pipeline.Application; using Deal.Modules.Pipeline.Application.Abstractions;
using Deal.Modules.Pipeline.Application.Models; using Deal.Modules.Pipeline.Application.Models;
using Deal.Modules.Settings.Application; using Deal.Modules.Pipeline.Application.Registrars;
using Deal.Modules.Settings.Application.Models; using Deal.Modules.Pipeline.Application.Services;
using Deal.Modules.Telegram.Application; using Deal.Modules.Settings.Application.Abstractions;
using Deal.Modules.Tenants.Application; using Deal.Modules.Settings.Application.Models;
using Deal.Modules.Tenants.Application.Models; using Deal.Modules.Settings.Application.Registrars;
using Deal.SharedKernel.Tenants; using Deal.Modules.Settings.Application.Services;
using Grpc.Core; using Deal.Modules.Telegram.Application;
using Deal.Modules.Tenants.Application.Abstractions;
namespace Deal.Api.Telegram; using Deal.Modules.Tenants.Application.Extensions;
using Deal.Modules.Tenants.Application.Models;
/// <summary> using Deal.Modules.Tenants.Application.Registrars;
/// gRPC-сервер входящего потока telegram-service → ядро (план Task 12, L361377; Ruling 1/7). using Deal.Modules.Tenants.Application.Services;
/// using Deal.SharedKernel.Tenants;
/// Реализация серверной стороны Deal.Grpc.Telegram.IngressService (telegram.proto, L380395): using Grpc.Core;
/// PushMessage — новое/догоняющее сообщение мониторящегося диалога в очередь пайплайна
/// (<see cref="PipelineIngestService.EnqueueAsync"/>, тот же контракт, что приём сообщений пайплайна) в схеме тенанта namespace Deal.Api.Telegram;
/// + превью (DialogsService.SavePreview: TgMessages + «последнее сообщение» каталога, Ruling 7);
/// SyncDialogs — применение каталога диалогов (DialogsService.SyncFromTelegram) и ответ со списком /// <summary>
/// monitored id (зеркало сервиса); ReportStatus — статус аккаунта в KV (tgStatus/tgAccount) + SSE /// gRPC-сервер входящего потока telegram-service → ядро (план Task 12, L361377; Ruling 1/7).
/// system_status/тосты на переходах фаз. ///
/// </summary> /// Реализация серверной стороны Deal.Grpc.Telegram.IngressService (telegram.proto, L380395):
/// <remarks> /// PushMessage — новое/догоняющее сообщение мониторящегося диалога в очередь пайплайна
/// Tenant-id берётся ТОЛЬКО из gRPC-metadata (полю в теле не доверяем — Ruling 1), принадлежность /// (<see cref="PipelineIngestService.EnqueueAsync"/>, тот же контракт, что приём сообщений пайплайна) в схеме тенанта
/// подтверждается реестром тенантов (public.tenants), затем для работы открывается собственный scope /// + превью (DialogsService.SavePreview: TgMessages + «последнее сообщение» каталога, Ruling 7);
/// с <c>ITenantContext.SetTenant</c> (эталон PipelineWorkerScheduler, L169213): tenant-scoped адаптеры /// SyncDialogs — применение каталога диалогов (DialogsService.SyncFromTelegram) и ответ со списком
/// (PipelineStore/SettingsStore) строятся от схемы тенанта. Неизвестный тенант/сбой схемы — RPC не падает: /// monitored id (зеркало сервиса); ReportStatus — статус аккаунта в KV (tgStatus/tgAccount) + SSE
/// ответ не-принято (accepted=false / ok=false, план Task 12) + лог аудита (Ruling 13); недоступный сервис /// system_status/тосты на переходах фаз.
/// догоняет упущенное realtime-sweep (контракт README). /// </summary>
/// <para> /// <remarks>
/// Полная синхронизация каталога (применение entries к таблице Dialogs, ответ = список monitored id) — /// Tenant-id берётся ТОЛЬКО из gRPC-metadata (полю в теле не доверяем — Ruling 1), принадлежность
/// модуль Deal.Modules.Telegram (план Task 13): DialogsService.SyncFromTelegram (upsert/удаление, авто- /// подтверждается реестром тенантов (public.tenants), затем для работы открывается собственный scope
/// мониторинг новых по autoMonitorNew), превью сообщений — DialogsService.SavePreview (PushMessage). /// с <c>ITenantContext.SetTenant</c> (эталон PipelineWorkerScheduler, L169213): tenant-scoped адаптеры
/// </para> /// (PipelineStore/SettingsStore) строятся от схемы тенанта. Неизвестный тенант/сбой схемы — RPC не падает:
/// </remarks> /// ответ не-принято (accepted=false / ok=false, план Task 12) + лог аудита (Ruling 13); недоступный сервис
public sealed class TelegramIngressService( /// догоняет упущенное realtime-sweep (контракт README).
IServiceScopeFactory scopeFactory, /// <para>
SseBroker broker, /// Полная синхронизация каталога (применение entries к таблице Dialogs, ответ = список monitored id) —
ILogger<TelegramIngressService> logger) : IngressService.IngressServiceBase /// модуль Deal.Modules.Telegram (план Task 13): DialogsService.SyncFromTelegram (upsert/удаление, авто-
{ /// мониторинг новых по autoMonitorNew), превью сообщений — DialogsService.SavePreview (PushMessage).
/// <summary> /// </para>
/// Ключ gRPC-metadata с id тенанта (единственный источник принадлежности — Ruling 1). /// </remarks>
/// </summary> public sealed class TelegramIngressService(
public const string TenantIdMetadataKey = "tenant-id"; IServiceScopeFactory scopeFactory,
SseBroker broker,
// Тип SSE-события статуса Telegram (фронт по нему перечитывает GET /api/tg/status, Ruling 7). ILogger<TelegramIngressService> logger) : IngressService.IngressServiceBase
private const string SystemStatusEventType = "system_status"; {
/// <summary>
// Тип SSE-события тоста (Ruling 5; api.js L79 слушает 'toast'). /// Ключ gRPC-metadata с id тенанта (единственный источник принадлежности — Ruling 1).
private const string ToastEventType = "toast"; /// </summary>
public const string TenantIdMetadataKey = "tenant-id";
// Текст тоста подключения (Ruling 7, 1:1 с прототипом).
private const string ConnectedToastText = "Telegram подключён, сессия сохранена"; // Тип SSE-события статуса Telegram (фронт по нему перечитывает GET /api/tg/status, Ruling 7).
private const string SystemStatusEventType = "system_status";
// Текст тоста отключения (Ruling 7, 1:1 с прототипом).
private const string DisconnectedToastText = "Telegram отключён"; // Тип SSE-события тоста (Ruling 5; api.js L79 слушает 'toast').
private const string ToastEventType = "toast";
// Иконка тоста подключения (из набора Icon.vue фронта).
private const string ConnectedToastIcon = "send"; // Текст тоста подключения (Ruling 7, 1:1 с прототипом).
private const string ConnectedToastText = "Telegram подключён, сессия сохранена";
// Иконка тоста отключения (из набора Icon.vue фронта).
private const string DisconnectedToastIcon = "logout"; // Текст тоста отключения (Ruling 7, 1:1 с прототипом).
private const string DisconnectedToastText = "Telegram отключён";
// Деталь отказа: metadata tenant-id отсутствует (UNAUTHENTICATED, README src/contracts).
private const string MissingTenantIdDetail = "tenant-id отсутствует в metadata"; // Иконка тоста подключения (из набора Icon.vue фронта).
private const string ConnectedToastIcon = "send";
// Опции JSON KV-статуса: camelCase (1:1 с wire-именами) + терпимость регистра при чтении.
private static readonly JsonSerializerOptions StatusJsonOptions = new() // Иконка тоста отключения (из набора Icon.vue фронта).
{ private const string DisconnectedToastIcon = "logout";
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true, // Деталь отказа: metadata tenant-id отсутствует (UNAUTHENTICATED, README src/contracts).
}; private const string MissingTenantIdDetail = "tenant-id отсутствует в metadata";
/// <summary> // Опции JSON KV-статуса: camelCase (1:1 с wire-именами) + терпимость регистра при чтении.
/// PushMessage — сообщение диалога в очередь пайплайна тенанта + превью (PushMessageRequest, Ruling 7). private static readonly JsonSerializerOptions StatusJsonOptions = new()
/// </summary> {
/// <remarks>Дубль dialog_id+msg_id уже в очереди — duplicate=true, очередь не растёт (гвард PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
/// PipelineIngestService). Пустой текст/диалог — no-op приёма (accepted=false, контракт proto). PropertyNameCaseInsensitive = true,
/// После постановки в очередь пишется превью (DialogsService.SavePreview: строка TgMessages };
/// «m_&lt;dialog&gt;_&lt;msg&gt;» + «последнее сообщение» каталога — 1:1 _on_message python L270274);
/// сбой превью не влияет на приём (accepted определён очередью, лог дебага). /// <summary>
/// Неизвестный тенант или сбой схемы/БД — не-принято (accepted=false) без исключения RPC.</remarks> /// PushMessage — сообщение диалога в очередь пайплайна тенанта + превью (PushMessageRequest, Ruling 7).
/// <param name="request">Сообщение из потока telegram-service.</param> /// </summary>
/// <param name="context">Контекст вызова (metadata tenant-id + service-token).</param> /// <remarks>Дубль dialog_id+msg_id уже в очереди — duplicate=true, очередь не растёт (гвард
/// <returns>accepted — сообщение принято (либо дубль), duplicate — уже было в очереди.</returns> /// PipelineIngestService). Пустой текст/диалог — no-op приёма (accepted=false, контракт proto).
public override async Task<PushMessageReply> PushMessage(PushMessageRequest request, ServerCallContext context) /// После постановки в очередь пишется превью (DialogsService.SavePreview: строка TgMessages
{ /// «m_&lt;dialog&gt;_&lt;msg&gt;» + «последнее сообщение» каталога — 1:1 _on_message python L270274);
TenantRecordDto? tenant = await ResolveTenantAsync(context).ConfigureAwait(false); /// сбой превью не влияет на приём (accepted определён очередью, лог дебага).
if (tenant is null) /// Неизвестный тенант или сбой схемы/БД — не-принято (accepted=false) без исключения RPC.</remarks>
{ /// <param name="request">Сообщение из потока telegram-service.</param>
return new PushMessageReply(); /// <param name="context">Контекст вызова (metadata tenant-id + service-token).</param>
} /// <returns>accepted — сообщение принято (либо дубль), duplicate — уже было в очереди.</returns>
public override async Task<PushMessageReply> PushMessage(PushMessageRequest request, ServerCallContext context)
await using AsyncServiceScope tenantScope = scopeFactory.CreateAsyncScope(); {
ITenantContext tenantContext = tenantScope.ServiceProvider.GetRequiredService<ITenantContext>(); TenantRecordDto? tenant = await ResolveTenantAsync(context).ConfigureAwait(false);
try if (tenant is null)
{ {
// Resolve ПОСЛЕ SetTenant: TenantDbContext (и его адаптеры) строятся от схемы текущего тенанта. return new PushMessageReply();
tenantContext.SetTenant(new TenantId(tenant.Id.ToString("N"))); }
PipelineIngestService ingest = tenantScope.ServiceProvider.GetRequiredService<PipelineIngestService>();
await using AsyncServiceScope tenantScope = scopeFactory.CreateAsyncScope();
PipelineIngestResultDto result = await ingest.EnqueueAsync( ITenantContext tenantContext = tenantScope.ServiceProvider.GetRequiredService<ITenantContext>();
new QueuedMessage try
{ {
DialogId = request.DialogId, // Resolve ПОСЛЕ SetTenant: TenantDbContext (и его адаптеры) строятся от схемы текущего тенанта.
ChannelName = request.ChannelName, tenantContext.SetTenant(new TenantId(tenant.Id.ToString("N")));
ChannelHandle = request.ChannelHandle, PipelineIngestService ingest = tenantScope.ServiceProvider.GetRequiredService<PipelineIngestService>();
ChannelHue = request.ChannelHue,
MsgId = request.HasMsgId ? request.MsgId : null, PipelineIngestResultDto result = await ingest.EnqueueAsync(
Text = request.Text, new QueuedMessage
MsgAtMs = request.HasMsgAt ? request.MsgAt : null, {
}, DialogId = request.DialogId,
context.CancellationToken).ConfigureAwait(false); ChannelName = request.ChannelName,
ChannelHandle = request.ChannelHandle,
await SavePreviewSafelyAsync(tenantScope, tenant, request, context.CancellationToken).ConfigureAwait(false); ChannelHue = request.ChannelHue,
MsgId = request.HasMsgId ? request.MsgId : null,
logger.LogInformation( Text = request.Text,
"Аудит: PushMessage {TenantId} диалог {DialogId} msg {MsgId} → {Outcome}", MsgAtMs = request.HasMsgAt ? request.MsgAt : null,
tenant.Id, },
request.DialogId, context.CancellationToken).ConfigureAwait(false);
request.HasMsgId ? request.MsgId.ToString() : "-",
result.Duplicate ? "duplicate" : result.Id is null ? "no-op" : "queued"); await SavePreviewSafelyAsync(tenantScope, tenant, request, context.CancellationToken).ConfigureAwait(false);
return new PushMessageReply logger.LogInformation(
{ "Аудит: PushMessage {TenantId} диалог {DialogId} msg {MsgId} → {Outcome}",
Accepted = result.Id is not null || result.Duplicate, tenant.Id,
Duplicate = result.Duplicate, request.DialogId,
}; request.HasMsgId ? request.MsgId.ToString() : "-",
} result.Duplicate ? "duplicate" : result.Id is null ? "no-op" : "queued");
catch (OperationCanceledException)
{ return new PushMessageReply
throw; {
} Accepted = result.Id is not null || result.Duplicate,
catch (Exception exception) Duplicate = result.Duplicate,
{ };
// Сбой схемы/БД тенанта (напр. схема ещё не провижинена): RPC не падает — reply not-accepted }
// (план Task 12), упущенное сообщение при необходимости догонит realtime-sweep сервиса. catch (OperationCanceledException)
logger.LogWarning(exception, "Аудит: PushMessage {TenantId} → не принято (сбой схемы/БД)", tenant.Id); {
return new PushMessageReply(); throw;
} }
finally catch (Exception exception)
{ {
tenantContext.Reset(); // Сбой схемы/БД тенанта (напр. схема ещё не провижинена): RPC не падает — reply not-accepted
} // (план Task 12), упущенное сообщение при необходимости догонит realtime-sweep сервиса.
} logger.LogWarning(exception, "Аудит: PushMessage {TenantId} → не принято (сбой схемы/БД)", tenant.Id);
return new PushMessageReply();
/// <summary> }
/// SyncDialogs — синхронизация каталога диалогов аккаунта (Ruling 7, L386390). finally
/// </summary> {
/// <remarks> tenantContext.Reset();
/// Модуль Deal.Modules.Telegram (план Task 13) применяет entries к таблице Dialogs }
/// (DialogsService.SyncFromTelegram: upsert + удаление отсутствующих; авто-мониторинг новых — по }
/// настройке autoMonitorNew). Ответ несёт актуальный список monitored id — по нему telegram-service
/// держит своё зеркало мониторинга в памяти (обновляется ответом SyncDialogs и командой SetMonitor, /// <summary>
/// Ruling 7) и фильтрует события realtime. /// SyncDialogs — синхронизация каталога диалогов аккаунта (Ruling 7, L386390).
/// </remarks> /// </summary>
/// <param name="request">Актуальный каталог диалогов (entries).</param> /// <remarks>
/// <param name="context">Контекст вызова.</param> /// Модуль Deal.Modules.Telegram (план Task 13) применяет entries к таблице Dialogs
/// <returns>monitored_ids — диалоги с включённым мониторингом после применения каталога.</returns> /// (DialogsService.SyncFromTelegram: upsert + удаление отсутствующих; авто-мониторинг новых — по
public override async Task<SyncDialogsReply> SyncDialogs(SyncDialogsRequest request, ServerCallContext context) /// настройке autoMonitorNew). Ответ несёт актуальный список monitored id — по нему telegram-service
{ /// держит своё зеркало мониторинга в памяти (обновляется ответом SyncDialogs и командой SetMonitor,
TenantRecordDto? tenant = await ResolveTenantAsync(context).ConfigureAwait(false); /// Ruling 7) и фильтрует события realtime.
if (tenant is null) /// </remarks>
{ /// <param name="request">Актуальный каталог диалогов (entries).</param>
return new SyncDialogsReply(); /// <param name="context">Контекст вызова.</param>
} /// <returns>monitored_ids — диалоги с включённым мониторингом после применения каталога.</returns>
public override async Task<SyncDialogsReply> SyncDialogs(SyncDialogsRequest request, ServerCallContext context)
await using AsyncServiceScope tenantScope = scopeFactory.CreateAsyncScope(); {
ITenantContext tenantContext = tenantScope.ServiceProvider.GetRequiredService<ITenantContext>(); TenantRecordDto? tenant = await ResolveTenantAsync(context).ConfigureAwait(false);
try if (tenant is null)
{ {
tenantContext.SetTenant(new TenantId(tenant.Id.ToString("N"))); return new SyncDialogsReply();
DialogsService dialogs = tenantScope.ServiceProvider.GetRequiredService<DialogsService>(); }
List<TelegramDialogEntryDto> entries = new(request.Entries.Count); await using AsyncServiceScope tenantScope = scopeFactory.CreateAsyncScope();
foreach (DialogEntry entry in request.Entries) ITenantContext tenantContext = tenantScope.ServiceProvider.GetRequiredService<ITenantContext>();
{ try
entries.Add(new TelegramDialogEntryDto(entry.Id, entry.Name, entry.Username, entry.Kind, entry.Hue)); {
} tenantContext.SetTenant(new TenantId(tenant.Id.ToString("N")));
DialogsService dialogs = tenantScope.ServiceProvider.GetRequiredService<DialogsService>();
int synced = await dialogs.SyncFromTelegramAsync(entries, context.CancellationToken).ConfigureAwait(false);
IReadOnlyCollection<string> monitoredIds = List<TelegramDialogEntryDto> entries = new(request.Entries.Count);
await dialogs.ListMonitoredIdsAsync(context.CancellationToken).ConfigureAwait(false); foreach (DialogEntry entry in request.Entries)
{
logger.LogInformation( entries.Add(new TelegramDialogEntryDto(entry.Id, entry.Name, entry.Username, entry.Kind, entry.Hue));
"Аудит: SyncDialogs {TenantId}: каталог {Count} → применено {Synced}, monitored {Monitored}", }
tenant.Id,
request.Entries.Count, int synced = await dialogs.SyncFromTelegramAsync(entries, context.CancellationToken).ConfigureAwait(false);
synced, IReadOnlyCollection<string> monitoredIds =
monitoredIds.Count); await dialogs.ListMonitoredIdsAsync(context.CancellationToken).ConfigureAwait(false);
var reply = new SyncDialogsReply(); logger.LogInformation(
reply.MonitoredIds.AddRange(monitoredIds); "Аудит: SyncDialogs {TenantId}: каталог {Count} → применено {Synced}, monitored {Monitored}",
return reply; tenant.Id,
} request.Entries.Count,
catch (OperationCanceledException) synced,
{ monitoredIds.Count);
throw;
} var reply = new SyncDialogsReply();
catch (Exception exception) reply.MonitoredIds.AddRange(monitoredIds);
{ return reply;
logger.LogWarning(exception, "Аудит: SyncDialogs {TenantId} → каталог не применён (сбой схемы/БД)", tenant.Id); }
return new SyncDialogsReply(); catch (OperationCanceledException)
} {
finally throw;
{ }
tenantContext.Reset(); catch (Exception exception)
} {
} logger.LogWarning(exception, "Аудит: SyncDialogs {TenantId} → каталог не применён (сбой схемы/БД)", tenant.Id);
return new SyncDialogsReply();
/// <summary> }
/// ReportStatus — статус аккаунта в KV + SSE system_status/тосты на переходах фаз (Ruling 7). finally
/// </summary> {
/// <remarks>KV tgStatus (снимок без account) и tgAccount (JSON-строка) пишутся в схему тенанта; tenantContext.Reset();
/// system_status публикуется на каждый репорт (фронт перечитывает /api/tg/status), тосты — только на }
/// переходы connected: false→true «Telegram подключён, сессия сохранена», true→false «Telegram отключён» }
/// (сервис шлёт статус по событию и heartbeat'ом — без гарда переходов тосты дублировались бы).
/// Неизвестный тенант/сбой схемы — ok=false без исключения RPC (план Task 12).</remarks> /// <summary>
/// <param name="request">Статус аккаунта из _publish_status прототипа.</param> /// ReportStatus — статус аккаунта в KV + SSE system_status/тосты на переходах фаз (Ruling 7).
/// <param name="context">Контекст вызова.</param> /// </summary>
/// <returns>ok — статус принят и сохранён.</returns> /// <remarks>KV tgStatus (снимок без account) и tgAccount (JSON-строка) пишутся в схему тенанта;
public override async Task<ReportStatusReply> ReportStatus(ReportStatusRequest request, ServerCallContext context) /// system_status публикуется на каждый репорт (фронт перечитывает /api/tg/status), тосты — только на
{ /// переходы connected: false→true «Telegram подключён, сессия сохранена», true→false «Telegram отключён»
TenantRecordDto? tenant = await ResolveTenantAsync(context).ConfigureAwait(false); /// (сервис шлёт статус по событию и heartbeat'ом — без гарда переходов тосты дублировались бы).
if (tenant is null) /// Неизвестный тенант/сбой схемы — ok=false без исключения RPC (план Task 12).</remarks>
{ /// <param name="request">Статус аккаунта из _publish_status прототипа.</param>
return new ReportStatusReply(); /// <param name="context">Контекст вызова.</param>
} /// <returns>ok — статус принят и сохранён.</returns>
public override async Task<ReportStatusReply> ReportStatus(ReportStatusRequest request, ServerCallContext context)
await using AsyncServiceScope tenantScope = scopeFactory.CreateAsyncScope(); {
ITenantContext tenantContext = tenantScope.ServiceProvider.GetRequiredService<ITenantContext>(); TenantRecordDto? tenant = await ResolveTenantAsync(context).ConfigureAwait(false);
try if (tenant is null)
{ {
tenantContext.SetTenant(new TenantId(tenant.Id.ToString("N"))); return new ReportStatusReply();
ISettingsStore settings = tenantScope.ServiceProvider.GetRequiredService<ISettingsStore>(); }
TgReportedStatus current = ToReportedStatus(request); await using AsyncServiceScope tenantScope = scopeFactory.CreateAsyncScope();
TgReportedStatus? previous = await ReadPreviousStatusAsync(settings, context.CancellationToken).ConfigureAwait(false); ITenantContext tenantContext = tenantScope.ServiceProvider.GetRequiredService<ITenantContext>();
try
// SSE до записи KV: канал тенанта обновляется и при сбое записи (следующий репорт перепишет KV). {
PublishStatusEvents(tenant.Id, previous, current); tenantContext.SetTenant(new TenantId(tenant.Id.ToString("N")));
ISettingsStore settings = tenantScope.ServiceProvider.GetRequiredService<ISettingsStore>();
await settings.SetAsync(SettingsKeys.TgStatus, ToJson(current), context.CancellationToken).ConfigureAwait(false);
await settings.SetAsync(SettingsKeys.TgAccount, JsonSerializer.Serialize(request.Account), context.CancellationToken).ConfigureAwait(false); TgReportedStatus current = ToReportedStatus(request);
TgReportedStatus? previous = await ReadPreviousStatusAsync(settings, context.CancellationToken).ConfigureAwait(false);
logger.LogInformation(
"Аудит: ReportStatus {TenantId} → фаза {Phase}, connected {Connected}", // SSE до записи KV: канал тенанта обновляется и при сбое записи (следующий репорт перепишет KV).
tenant.Id, PublishStatusEvents(tenant.Id, previous, current);
request.Phase,
request.Connected); await settings.SetAsync(SettingsKeys.TgStatus, ToJson(current), context.CancellationToken).ConfigureAwait(false);
return new ReportStatusReply { Ok = true }; await settings.SetAsync(SettingsKeys.TgAccount, JsonSerializer.Serialize(request.Account), context.CancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException) logger.LogInformation(
{ "Аудит: ReportStatus {TenantId} → фаза {Phase}, connected {Connected}",
throw; tenant.Id,
} request.Phase,
catch (Exception exception) request.Connected);
{ return new ReportStatusReply { Ok = true };
logger.LogWarning(exception, "Аудит: ReportStatus {TenantId} → не сохранён (сбой схемы/БД)", tenant.Id); }
return new ReportStatusReply(); catch (OperationCanceledException)
} {
finally throw;
{ }
tenantContext.Reset(); catch (Exception exception)
} {
} logger.LogWarning(exception, "Аудит: ReportStatus {TenantId} → не сохранён (сбой схемы/БД)", tenant.Id);
return new ReportStatusReply();
// Разрешает тенанта запроса: metadata tenant-id → реестр public.tenants. }
// Отсутствующий/пустой tenant-id — RPC-отказ UNAUTHENTICATED (README: tenant-id обязателен). finally
// Id не Guid либо записи нет в реестре — неизвестный тенант: лог аудита и null (RPC отвечает не-принято, {
// план Task 12: «для несуществующего тенанта не падает»). tenantContext.Reset();
// context: Контекст вызова. }
// Возвращает: Запись тенанта реестра либо null (тенант неизвестен). }
private async Task<TenantRecordDto?> ResolveTenantAsync(ServerCallContext context)
{ // Разрешает тенанта запроса: metadata tenant-id → реестр public.tenants.
string tenantId = RequireTenantIdMetadata(context); // Отсутствующий/пустой tenant-id — RPC-отказ UNAUTHENTICATED (README: tenant-id обязателен).
if (!Guid.TryParse(tenantId, out Guid tenantGuid)) // Id не Guid либо записи нет в реестре — неизвестный тенант: лог аудита и null (RPC отвечает не-принято,
{ // план Task 12: «для несуществующего тенанта не падает»).
logger.LogWarning("Аудит: ингресс {Action} → тенант {TenantId} неизвестен (id не Guid)", context.Method, tenantId); // context: Контекст вызова.
return null; // Возвращает: Запись тенанта реестра либо null (тенант неизвестен).
} private async Task<TenantRecordDto?> ResolveTenantAsync(ServerCallContext context)
{
await using AsyncServiceScope registryScope = scopeFactory.CreateAsyncScope(); string tenantId = RequireTenantIdMetadata(context);
ITenantRepository repository = registryScope.ServiceProvider.GetRequiredService<ITenantRepository>(); if (!Guid.TryParse(tenantId, out Guid tenantGuid))
TenantRecordDto? tenant = await repository.FindByIdAsync(tenantGuid, context.CancellationToken).ConfigureAwait(false); {
if (tenant is null) logger.LogWarning("Аудит: ингресс {Action} → тенант {TenantId} неизвестен (id не Guid)", context.Method, tenantId);
{ return null;
logger.LogWarning("Аудит: ингресс {Action} → тенант {TenantId} неизвестен (нет в реестре)", context.Method, tenantId); }
}
await using AsyncServiceScope registryScope = scopeFactory.CreateAsyncScope();
return tenant; ITenantRepository repository = registryScope.ServiceProvider.GetRequiredService<ITenantRepository>();
} TenantRecordDto? tenant = await repository.FindByIdAsync(tenantGuid, context.CancellationToken).ConfigureAwait(false);
if (tenant is null)
// Читает tenant-id из metadata (обязателен; отсутствие — UNAUTHENTICATED, README). {
// context: Контекст вызова. logger.LogWarning("Аудит: ингресс {Action} → тенант {TenantId} неизвестен (нет в реестре)", context.Method, tenantId);
// Возвращает: Значение tenant-id. }
private static string RequireTenantIdMetadata(ServerCallContext context)
{ return tenant;
string? tenantId = context.RequestHeaders.GetValue(TenantIdMetadataKey); }
if (string.IsNullOrWhiteSpace(tenantId))
{ // Читает tenant-id из metadata (обязателен; отсутствие — UNAUTHENTICATED, README).
throw new RpcException(new Status(StatusCode.Unauthenticated, MissingTenantIdDetail)); // context: Контекст вызова.
} // Возвращает: Значение tenant-id.
private static string RequireTenantIdMetadata(ServerCallContext context)
return tenantId; {
} string? tenantId = context.RequestHeaders.GetValue(TenantIdMetadataKey);
if (string.IsNullOrWhiteSpace(tenantId))
// Пишет превью принятого сообщения (TgMessages + «последнее сообщение» каталога) без влияния на приём. {
// Ruling 7: PushMessage → EnqueueAsync + превью. Сбой превью (нет таблиц/строки каталога и т.п.) throw new RpcException(new Status(StatusCode.Unauthenticated, MissingTenantIdDetail));
// не роняет RPC и не меняет accepted — очередь уже записана, упущенное догонит realtime-sweep (как }
// python: обновление last_text после enqueue в том же обработчике, ошибка не отменяет приём).
// tenantScope: Scope тенанта (TenantDbContext построен на схеме тенанта). return tenantId;
// tenant: Тенант канала (для лога аудита). }
// request: Сообщение PushMessage.
// ct: Токен отмены. // Пишет превью принятого сообщения (TgMessages + «последнее сообщение» каталога) без влияния на приём.
private async Task SavePreviewSafelyAsync( // Ruling 7: PushMessage → EnqueueAsync + превью. Сбой превью (нет таблиц/строки каталога и т.п.)
AsyncServiceScope tenantScope, TenantRecordDto tenant, PushMessageRequest request, CancellationToken ct) // не роняет RPC и не меняет accepted — очередь уже записана, упущенное догонит realtime-sweep (как
{ // python: обновление last_text после enqueue в том же обработчике, ошибка не отменяет приём).
try // tenantScope: Scope тенанта (TenantDbContext построен на схеме тенанта).
{ // tenant: Тенант канала (для лога аудита).
DialogsService dialogs = tenantScope.ServiceProvider.GetRequiredService<DialogsService>(); // request: Сообщение PushMessage.
DateTimeOffset? msgAt = request.HasMsgAt ? DateTimeOffset.FromUnixTimeMilliseconds(request.MsgAt) : null; // ct: Токен отмены.
await dialogs.SavePreviewAsync( private async Task SavePreviewSafelyAsync(
request.DialogId, AsyncServiceScope tenantScope, TenantRecordDto tenant, PushMessageRequest request, CancellationToken ct)
request.HasMsgId ? request.MsgId : null, {
request.Text, try
msgAt, {
ct).ConfigureAwait(false); DialogsService dialogs = tenantScope.ServiceProvider.GetRequiredService<DialogsService>();
} DateTimeOffset? msgAt = request.HasMsgAt ? DateTimeOffset.FromUnixTimeMilliseconds(request.MsgAt) : null;
catch (OperationCanceledException) await dialogs.SavePreviewAsync(
{ request.DialogId,
throw; request.HasMsgId ? request.MsgId : null,
} request.Text,
catch (Exception exception) msgAt,
{ ct).ConfigureAwait(false);
// Превью — вторичная запись: приём не затронут (лог дебага, не ошибка RPC). }
logger.LogDebug(exception, "PushMessage {TenantId}: превью не сохранено (приём не затронут)", tenant.Id); catch (OperationCanceledException)
} {
} throw;
}
// Публикует SSE system_status (каждый репорт) и тосты на переходах connected. catch (Exception exception)
// tenantId: Тенант канала (реестровый Guid). {
// previous: Предыдущий снимок из KV (null — первый репорт). // Превью — вторичная запись: приём не затронут (лог дебага, не ошибка RPC).
// current: Текущий снимок репорта. logger.LogDebug(exception, "PushMessage {TenantId}: превью не сохранено (приём не затронут)", tenant.Id);
private void PublishStatusEvents(Guid tenantId, TgReportedStatus? previous, TgReportedStatus current) }
{ }
broker.Publish(tenantId, SystemStatusEventType, current);
if (previous is null) // Публикует SSE system_status (каждый репорт) и тосты на переходах connected.
{ // tenantId: Тенант канала (реестровый Guid).
// Первый репорт после старта сервиса: переходов нет, статус фронт получит по system_status. // previous: Предыдущий снимок из KV (null — первый репорт).
return; // current: Текущий снимок репорта.
} private void PublishStatusEvents(Guid tenantId, TgReportedStatus? previous, TgReportedStatus current)
{
if (!previous.Connected && current.Connected) broker.Publish(tenantId, SystemStatusEventType, current);
{ if (previous is null)
PublishToast(tenantId, ConnectedToastText, ConnectedToastIcon); {
} // Первый репорт после старта сервиса: переходов нет, статус фронт получит по system_status.
else if (previous.Connected && !current.Connected) return;
{ }
PublishToast(tenantId, DisconnectedToastText, DisconnectedToastIcon);
} if (!previous.Connected && current.Connected)
} {
PublishToast(tenantId, ConnectedToastText, ConnectedToastIcon);
// Публикует SSE-тост в канал тенанта (без подписчиков — no-op, Ruling 5). }
// tenantId: Тенант-получатель. else if (previous.Connected && !current.Connected)
// text: Текст тоста. {
// icon: Иконка тоста (набор Icon.vue фронта). PublishToast(tenantId, DisconnectedToastText, DisconnectedToastIcon);
private void PublishToast(Guid tenantId, string text, string icon) }
{ }
broker.Publish(tenantId, ToastEventType, new { text, icon });
} // Публикует SSE-тост в канал тенанта (без подписчиков — no-op, Ruling 5).
// tenantId: Тенант-получатель.
// Снимок предыдущего статуса из KV tgStatus (нет записи/битый JSON — null). // text: Текст тоста.
// settings: KV-хранилище настроек схемы тенанта. // icon: Иконка тоста (набор Icon.vue фронта).
// ct: Токен отмены. private void PublishToast(Guid tenantId, string text, string icon)
// Возвращает: Предыдущий снимок либо null. {
private async Task<TgReportedStatus?> ReadPreviousStatusAsync(ISettingsStore settings, CancellationToken ct) broker.Publish(tenantId, ToastEventType, new { text, icon });
{ }
SettingValue? stored = await settings.GetAsync(SettingsKeys.TgStatus, ct).ConfigureAwait(false);
if (stored is null) // Снимок предыдущего статуса из KV tgStatus (нет записи/битый JSON — null).
{ // settings: KV-хранилище настроек схемы тенанта.
return null; // ct: Токен отмены.
} // Возвращает: Предыдущий снимок либо null.
private async Task<TgReportedStatus?> ReadPreviousStatusAsync(ISettingsStore settings, CancellationToken ct)
try {
{ SettingValue? stored = await settings.GetAsync(SettingsKeys.TgStatus, ct).ConfigureAwait(false);
return JsonSerializer.Deserialize<TgReportedStatus>(stored.ValueJson, StatusJsonOptions); if (stored is null)
} {
catch (JsonException exception) return null;
{ }
logger.LogWarning(exception, "Аудит: KV tgStatus повреждён — переходы фаз не определяются");
return null; try
} {
} return JsonSerializer.Deserialize<TgReportedStatus>(stored.ValueJson, StatusJsonOptions);
}
// Маппит запрос ReportStatus в снимок KV (account живёт отдельным ключом tgAccount). catch (JsonException exception)
// request: Запрос ReportStatus. {
// Возвращает: Снимок статуса. logger.LogWarning(exception, "Аудит: KV tgStatus повреждён — переходы фаз не определяются");
private static TgReportedStatus ToReportedStatus(ReportStatusRequest request) => new() return null;
{ }
Phase = request.Phase, }
Connected = request.Connected,
Listener = request.Listener, // Маппит запрос ReportStatus в снимок KV (account живёт отдельным ключом tgAccount).
Error = request.HasError ? request.Error : null, // request: Запрос ReportStatus.
QrUrl = request.HasQrUrl ? request.QrUrl : null, // Возвращает: Снимок статуса.
}; private static TgReportedStatus ToReportedStatus(ReportStatusRequest request) => new()
{
// Сериализует снимок в JSON (camelCase, конвенция value_json). Phase = request.Phase,
// status: Снимок статуса. Connected = request.Connected,
// Возвращает: JSON-строка. Listener = request.Listener,
private static string ToJson(TgReportedStatus status) => JsonSerializer.Serialize(status, StatusJsonOptions); Error = request.HasError ? request.Error : null,
} QrUrl = request.HasQrUrl ? request.QrUrl : null,
};
// Сериализует снимок в JSON (camelCase, конвенция value_json).
// status: Снимок статуса.
// Возвращает: JSON-строка.
private static string ToJson(TgReportedStatus status) => JsonSerializer.Serialize(status, StatusJsonOptions);
}
@@ -1,6 +1,8 @@
using System.Text.Json; using System.Text.Json;
using Deal.Modules.Settings.Application; 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.Services;
namespace Deal.Api.Telegram; namespace Deal.Api.Telegram;
+110 -108
View File
@@ -1,108 +1,110 @@
using System.Text.Json; using System.Text.Json;
using Deal.Contracts.Integrations; using Deal.Contracts.Integrations;
using Deal.Contracts.Integrations.Models; using Deal.Contracts.Integrations.Models;
using Deal.Modules.Settings.Application; using Deal.Modules.Settings.Application.Abstractions;
using Deal.Modules.Settings.Application.Models; using Deal.Modules.Settings.Application.Models;
using Deal.Modules.Telegram.Application; using Deal.Modules.Settings.Application.Registrars;
using Deal.Modules.Telegram.Application.Models; using Deal.Modules.Settings.Application.Services;
using Deal.Modules.Telegram.Application;
namespace Deal.Api.Telegram; using Deal.Modules.Telegram.Application.Models;
/// <summary> namespace Deal.Api.Telegram;
/// Сборка статуса вкладки Telegram — GET /api/tg/status (Ruling 8, api-map §4.9 L357359).
/// </summary> /// <summary>
/// <remarks> /// Сборка статуса вкладки Telegram — GET /api/tg/status (Ruling 8, api-map §4.9 L357359).
/// Форма 1:1 с status() python L103119 в терминах этапа 6: /// </summary>
/// <list type="bullet"> /// <remarks>
/// <item>live-поля (phase/connected/listener/error/qrUrl) — из гейта <see cref="ITelegramGateway.StatusAsync"/> /// Форма 1:1 с status() python L103119 в терминах этапа 6:
/// (живой telegram-service); сервис недоступен/сессии нет (RPC-отказ) → idle-форма (Ruling 8);</item> /// <list type="bullet">
/// <item>account — из KV tgAccount (источник истины — ReportStatus ингресса, Ruling 7);</item> /// <item>live-поля (phase/connected/listener/error/qrUrl) — из гейта <see cref="ITelegramGateway.StatusAsync"/>
/// <item>monitored — count(Dialogs WHERE Monitor) ядра (DialogsService.ListMonitoredIds);</item> /// (живой telegram-service); сервис недоступен/сессии нет (RPC-отказ) → idle-форма (Ruling 8);</item>
/// <item>keysSet — оба глобальных ключа приложения заданы оператором (TelegramKeysService, Ruling 3, ТЗ §4.1/§8.1).</item> /// <item>account — из KV tgAccount (источник истины — ReportStatus ингресса, Ruling 7);</item>
/// </list> /// <item>monitored — count(Dialogs WHERE Monitor) ядра (DialogsService.ListMonitoredIds);</item>
/// Scoped: зависимости живут на контекстах запроса (ISettingsStore — схема тенанта, IGlobalSettingsStore — public). /// <item>keysSet — оба глобальных ключа приложения заданы оператором (TelegramKeysService, Ruling 3, ТЗ §4.1/§8.1).</item>
/// </remarks> /// </list>
/// <param name="gateway">Порт-гейт telegram-service (живой статус аккаунта).</param> /// Scoped: зависимости живут на контекстах запроса (ISettingsStore — схема тенанта, IGlobalSettingsStore — public).
/// <param name="dialogs">Сервис каталога диалогов ядра (счётчик мониторящихся).</param> /// </remarks>
/// <param name="settings">KV-хранилище настроек тенанта (tgAccount).</param> /// <param name="gateway">Порт-гейт telegram-service (живой статус аккаунта).</param>
/// <param name="keys">Глобальные ключи приложения Telegram (keysSet).</param> /// <param name="dialogs">Сервис каталога диалогов ядра (счётчик мониторящихся).</param>
public sealed class TgStatusService( /// <param name="settings">KV-хранилище настроек тенанта (tgAccount).</param>
ITelegramGateway gateway, /// <param name="keys">Глобальные ключи приложения Telegram (keysSet).</param>
DialogsService dialogs, public sealed class TgStatusService(
ISettingsStore settings, ITelegramGateway gateway,
TelegramKeysService keys) DialogsService dialogs,
{ ISettingsStore settings,
// Фаза idle-формы (аккаунт не подключён/сервис недоступен — Ruling 8). TelegramKeysService keys)
private const string IdlePhase = "idle"; {
// Фаза idle-формы (аккаунт не подключён/сервис недоступен — Ruling 8).
// Опции JSON KV-значений статуса: camelCase (как пишет ингресс) + терпимость регистра. private const string IdlePhase = "idle";
private static readonly JsonSerializerOptions KvJsonOptions = new()
{ // Опции JSON KV-значений статуса: camelCase (как пишет ингресс) + терпимость регистра.
PropertyNamingPolicy = JsonNamingPolicy.CamelCase, private static readonly JsonSerializerOptions KvJsonOptions = new()
PropertyNameCaseInsensitive = true, {
}; PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true,
/// <summary> };
/// Форма GET /api/tg/status текущего тенанта (поля §4.9).
/// </summary> /// <summary>
/// <param name="ct">Токен отмены.</param> /// Форма GET /api/tg/status текущего тенанта (поля §4.9).
/// <returns>Полный статус вкладки Telegram.</returns> /// </summary>
public async Task<TgStatusDto> GetAsync(CancellationToken ct) /// <param name="ct">Токен отмены.</param>
{ /// <returns>Полный статус вкладки Telegram.</returns>
TelegramAccountStatusDto live = await ReadLiveAsync(ct).ConfigureAwait(false); public async Task<TgStatusDto> GetAsync(CancellationToken ct)
string account = await ReadAccountAsync(ct).ConfigureAwait(false); {
int monitored = (await dialogs.ListMonitoredIdsAsync(ct).ConfigureAwait(false)).Count; TelegramAccountStatusDto live = await ReadLiveAsync(ct).ConfigureAwait(false);
TgKeysSnapshot snapshot = await keys.GetAsync(ct).ConfigureAwait(false); string account = await ReadAccountAsync(ct).ConfigureAwait(false);
int monitored = (await dialogs.ListMonitoredIdsAsync(ct).ConfigureAwait(false)).Count;
return new TgStatusDto( TgKeysSnapshot snapshot = await keys.GetAsync(ct).ConfigureAwait(false);
Phase: live.Phase,
Connected: live.Connected, return new TgStatusDto(
Listener: live.Listener, Phase: live.Phase,
Account: account, Connected: live.Connected,
Monitored: monitored, Listener: live.Listener,
KeysSet: snapshot.KeysSet, Account: account,
Error: live.Error, Monitored: monitored,
QrUrl: live.QrUrl); KeysSet: snapshot.KeysSet,
} Error: live.Error,
QrUrl: live.QrUrl);
// Живой статус из гейта; сбой (сервис недоступен/нет сессии) → idle-форма (Ruling 8). }
// ct: Токен отмены.
// Возвращает: Статус гейта либо idle-поля. // Живой статус из гейта; сбой (сервис недоступен/нет сессии) → idle-форма (Ruling 8).
private async Task<TelegramAccountStatusDto> ReadLiveAsync(CancellationToken ct) // ct: Токен отмены.
{ // Возвращает: Статус гейта либо idle-поля.
try private async Task<TelegramAccountStatusDto> ReadLiveAsync(CancellationToken ct)
{ {
return await gateway.StatusAsync(ct).ConfigureAwait(false); try
} {
catch (Exception exception) when (exception is not OperationCanceledException || !ct.IsCancellationRequested) return await gateway.StatusAsync(ct).ConfigureAwait(false);
{ }
// «Сервис недоступен → idle-форма» (Ruling 8): connected=false, live-поля пусты. Аккаунт/счётчики catch (Exception exception) when (exception is not OperationCanceledException || !ct.IsCancellationRequested)
// ядро всё равно докладывает из своего KV/БД (ниже) — как python при отключённом клиенте. {
return new TelegramAccountStatusDto(IdlePhase, Connected: false, Listener: false, string.Empty, null, null); // «Сервис недоступен → idle-форма» (Ruling 8): connected=false, live-поля пусты. Аккаунт/счётчики
} // ядро всё равно докладывает из своего KV/БД (ниже) — как python при отключённом клиенте.
} return new TelegramAccountStatusDto(IdlePhase, Connected: false, Listener: false, string.Empty, null, null);
}
// Аккаунт «@username» из KV tgAccount (JSON-строка, пишет ReportStatus ингресса, Ruling 7). }
// ct: Токен отмены.
// Возвращает: Аккаунт или пустая строка. // Аккаунт «@username» из KV tgAccount (JSON-строка, пишет ReportStatus ингресса, Ruling 7).
private async Task<string> ReadAccountAsync(CancellationToken ct) // ct: Токен отмены.
{ // Возвращает: Аккаунт или пустая строка.
SettingValue? row = await settings.GetAsync(SettingsKeys.TgAccount, ct).ConfigureAwait(false); private async Task<string> ReadAccountAsync(CancellationToken ct)
if (row is null) {
{ SettingValue? row = await settings.GetAsync(SettingsKeys.TgAccount, ct).ConfigureAwait(false);
return string.Empty; if (row is null)
} {
return string.Empty;
try }
{
using JsonDocument document = JsonDocument.Parse(row.ValueJson); try
return document.RootElement.ValueKind == JsonValueKind.String {
? document.RootElement.GetString() ?? string.Empty using JsonDocument document = JsonDocument.Parse(row.ValueJson);
: string.Empty; return document.RootElement.ValueKind == JsonValueKind.String
} ? document.RootElement.GetString() ?? string.Empty
catch (JsonException) : string.Empty;
{ }
return string.Empty; catch (JsonException)
} {
} return string.Empty;
} }
}
}
@@ -1,7 +1,9 @@
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using System.Net.Http.Headers; using System.Net.Http.Headers;
using Deal.Modules.Settings.Application; 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.Services;
namespace Deal.Infrastructure.Integrations; namespace Deal.Infrastructure.Integrations;
@@ -1,7 +1,9 @@
using System.Text.Json.Nodes; using System.Text.Json.Nodes;
using Deal.Grpc.Ai; using Deal.Grpc.Ai;
using Deal.Modules.Settings.Application; 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.Services;
namespace Deal.Infrastructure.Integrations; namespace Deal.Infrastructure.Integrations;
@@ -1,7 +1,10 @@
using Deal.Contracts.Integrations; using Deal.Contracts.Integrations;
using Deal.Contracts.Integrations.Models; using Deal.Contracts.Integrations.Models;
using Deal.Modules.Tenants.Application; using Deal.Modules.Tenants.Application.Abstractions;
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.Services;
using Deal.SharedKernel.Tenants; using Deal.SharedKernel.Tenants;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
@@ -1,7 +1,10 @@
using Deal.Contracts.Integrations; using Deal.Contracts.Integrations;
using Deal.Contracts.Integrations.Models; using Deal.Contracts.Integrations.Models;
using Deal.Modules.Tenants.Application; using Deal.Modules.Tenants.Application.Abstractions;
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.Services;
using Deal.SharedKernel.Tenants; using Deal.SharedKernel.Tenants;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
@@ -1,6 +1,9 @@
using System.Globalization; using System.Globalization;
using System.Text.Json; using System.Text.Json;
using Deal.Modules.Settings.Application; using Deal.Modules.Settings.Application.Abstractions;
using Deal.Modules.Settings.Application.Models;
using Deal.Modules.Settings.Application.Registrars;
using Deal.Modules.Settings.Application.Services;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
namespace Deal.Infrastructure.Integrations; namespace Deal.Infrastructure.Integrations;
@@ -1,7 +1,10 @@
using Deal.Contracts.Integrations; using Deal.Contracts.Integrations;
using Deal.Contracts.Integrations.Models; using Deal.Contracts.Integrations.Models;
using Deal.Grpc.Ai; using Deal.Grpc.Ai;
using Deal.Modules.Pipeline.Application; using Deal.Modules.Pipeline.Application.Abstractions;
using Deal.Modules.Pipeline.Application.Models;
using Deal.Modules.Pipeline.Application.Registrars;
using Deal.Modules.Pipeline.Application.Services;
using Deal.SharedKernel.Tenants; using Deal.SharedKernel.Tenants;
using Grpc.Core; using Grpc.Core;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
@@ -2,11 +2,20 @@ using System.Text.Json;
using Deal.Contracts.Integrations; using Deal.Contracts.Integrations;
using Deal.Contracts.Integrations.Models; using Deal.Contracts.Integrations.Models;
using Deal.Grpc.Ml; using Deal.Grpc.Ml;
using Deal.Modules.Kanban.Application; using Deal.Modules.Kanban.Application.Abstractions;
using Deal.Modules.Kanban.Application.Extensions;
using Deal.Modules.Kanban.Application.Models; using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Settings.Application; using Deal.Modules.Kanban.Application.Registrars;
using Deal.Modules.Kanban.Application.Services;
using Deal.Modules.Settings.Application.Abstractions;
using Deal.Modules.Settings.Application.Models; using Deal.Modules.Settings.Application.Models;
using Deal.Modules.Tenants.Application; using Deal.Modules.Settings.Application.Registrars;
using Deal.Modules.Settings.Application.Services;
using Deal.Modules.Tenants.Application.Abstractions;
using Deal.Modules.Tenants.Application.Extensions;
using Deal.Modules.Tenants.Application.Models;
using Deal.Modules.Tenants.Application.Registrars;
using Deal.Modules.Tenants.Application.Services;
using Deal.SharedKernel.Tenants; using Deal.SharedKernel.Tenants;
using Grpc.Core; using Grpc.Core;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
@@ -1,7 +1,9 @@
using Deal.Contracts.Integrations; using Deal.Contracts.Integrations;
using Deal.Contracts.Integrations.Models; using Deal.Contracts.Integrations.Models;
using Deal.Modules.Pipeline.Application; 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.Services;
using Deal.Modules.Pipeline.Application.Parse; using Deal.Modules.Pipeline.Application.Parse;
namespace Deal.Infrastructure.Integrations; namespace Deal.Infrastructure.Integrations;
@@ -1,247 +1,252 @@
using System.Globalization; using System.Globalization;
using System.Text.Json; using System.Text.Json;
using Deal.Contracts.Integrations; using Deal.Contracts.Integrations;
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; using Deal.Modules.Kanban.Application.Abstractions;
using Deal.Modules.Kanban.Application.Models; using Deal.Modules.Kanban.Application.Extensions;
using Deal.Modules.Settings.Application; using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Settings.Application.Models; using Deal.Modules.Kanban.Application.Registrars;
// Алиас: статический класс ColumnRules лежит в одноимённом пространстве имён (см. CardsService) — using Deal.Modules.Kanban.Application.Services;
// внутри Deal.Modules.Kanban.Application имя ColumnRules резолвится в пространство. using Deal.Modules.Settings.Application.Abstractions;
using KanbanColumnRules = Deal.Modules.Kanban.Application.ColumnRules.ColumnRules; using Deal.Modules.Settings.Application.Models;
using Deal.Modules.Settings.Application.Registrars;
namespace Deal.Infrastructure.Integrations; using Deal.Modules.Settings.Application.Services;
// Алиас: статический класс ColumnRules лежит в одноимённом пространстве имён (см. CardsService) —
/// <summary> // внутри Deal.Modules.Kanban.Application имя ColumnRules резолвится в пространство.
/// Адаптер ИИ-предложений колонок/ключей — детерминированная эвристика этапа 3 (Ruling 3, план Task 14). using KanbanColumnRules = Deal.Modules.Kanban.Application.ColumnRules.ColumnRules;
/// </summary>
/// <remarks> namespace Deal.Infrastructure.Integrations;
/// Реализует порт <see cref="IColumnSuggester"/> поверх порта <see cref="ICardStore"/> и чистого ядра
/// <see cref="SuggestHeuristics"/> (модуль Kanban): читает «Неразобранное» (ListInboxWithSourceAsync), /// <summary>
/// считает группы слов-тем и создаёт доски suggested=true (RulesJson {mode:"any", keywords:[…]}, /// Адаптер ИИ-предложений колонок/ключей — детерминированная эвристика этапа 3 (Ruling 3, план Task 14).
/// note-обоснование, цвет/позицию даёт ContainersService) и раскладывает карточки (is_new=TRUE, /// </summary>
/// prev_col='inbox', matchHits по правилам доски — Ruling 2). Причины отказов — детерминированные /// <remarks>
/// строки прототипа/Ruling 3: «мало карточек в «Неразобранном» (нужно от 6)», «похожие колонки уже /// Реализует порт <see cref="IColumnSuggester"/> поверх порта <see cref="ICardStore"/> и чистого ядра
/// есть или нечего сгруппировать»; кулдаун повторов — KV-ключ <see cref="SettingsKeys.LastSuggestAt"/> /// <see cref="SuggestHeuristics"/> (модуль Kanban): читает «Неразобранное» (ListInboxWithSourceAsync),
/// (прототип COOLDOWN_S L51 + «недавно предлагали — подождите» L95). Журнал CardMoves/ML-сигналы при /// считает группы слов-тем и создаёт доски suggested=true (RulesJson {mode:"any", keywords:[…]},
/// раскладке НЕ пишутся (suggest.py _assign_ids L220239 — это не действие пользователя, а предложение). /// note-обоснование, цвет/позицию даёт ContainersService) и раскладывает карточки (is_new=TRUE,
/// Suggest-keywords читает карточки вне trash/archive (suggest_domain_keywords L172178). /// prev_col='inbox', matchHits по правилам доски — Ruling 2). Причины отказов — детерминированные
/// </remarks> /// строки прототипа/Ruling 3: «мало карточек в «Неразобранном» (нужно от 6)», «похожие колонки уже
/// <param name="store">Порт хранилища (карточки «Неразобранного», переносы в колонки-доски).</param> /// есть или нечего сгруппировать»; кулдаун повторов — KV-ключ <see cref="SettingsKeys.LastSuggestAt"/>
/// <param name="settings">KV-хранилище настроек тенанта (кулдаун lastSuggestAt, как KEY suggest.py L52).</param> /// (прототип COOLDOWN_S L51 + «недавно предлагали — подождите» L95). Журнал CardMoves/ML-сигналы при
/// <param name="containersService">Сервис контейнеров: список существующих и создание suggested-колонок с дефолтами.</param> /// раскладке НЕ пишутся (suggest.py _assign_ids L220239 — это не действие пользователя, а предложение).
public sealed class LocalColumnSuggester( /// Suggest-keywords читает карточки вне trash/archive (suggest_domain_keywords L172178).
ICardStore store, /// </remarks>
ISettingsStore settings, /// <param name="store">Порт хранилища (карточки «Неразобранного», переносы в колонки-доски).</param>
ContainersService containersService) : IColumnSuggester /// <param name="settings">KV-хранилище настроек тенанта (кулдаун lastSuggestAt, как KEY suggest.py L52).</param>
{ /// <param name="containersService">Сервис контейнеров: список существующих и создание suggested-колонок с дефолтами.</param>
// ── Кулдаун повторов (suggest.py COOLDOWN_S L51; KEY lastSuggestAt L52) ── public sealed class LocalColumnSuggester(
ICardStore store,
// Как часто можно переспрашивать ИИ-предложения: 20 минут (COOLDOWN_S = 20 * 60, L51). ISettingsStore settings,
private const long CooldownSeconds = 20 * 60; ContainersService containersService) : IColumnSuggester
{
// ── Детерминированные причины (Ruling 3; строки прототипа suggest.py) ── // ── Кулдаун повторов (suggest.py COOLDOWN_S L51; KEY lastSuggestAt L52) ──
// Кулдаун: повторный вызов слишком рано (suggest.py L95 «недавно предлагали — подождите»). // Как часто можно переспрашивать ИИ-предложения: 20 минут (COOLDOWN_S = 20 * 60, L51).
private const string CooldownReason = "недавно предлагали — подождите"; private const long CooldownSeconds = 20 * 60;
// Мало карточек в «Неразобранном»: {0} — порог MIN_INBOX (suggest.py L102). // ── Детерминированные причины (Ruling 3; строки прототипа suggest.py) ──
private const string TooFewCardsReasonFormat = "мало карточек в «Неразобранном» (нужно от {0})";
// Кулдаун: повторный вызов слишком рано (suggest.py L95 «недавно предлагали — подождите»).
// Групп не вышло: темы похожи на существующие доски или карточкам нечего разделить (L159). private const string CooldownReason = "недавно предлагали — подождите";
private const string NothingGroupedReason = "похожие колонки уже есть или нечего сгруппировать";
// Мало карточек в «Неразобранном»: {0} — порог MIN_INBOX (suggest.py L102).
// Мало карточек для ключей: нужно хотя бы 3 (suggest_domain_keywords L178). private const string TooFewCardsReasonFormat = "мало карточек в «Неразобранном» (нужно от {0})";
private const string KeywordsTooFewReason = "мало карточек — сначала накопите заявки (нужно хотя бы 3)";
// Групп не вышло: темы похожи на существующие доски или карточкам нечего разделить (L159).
// Повторяющихся слов-маркеров не нашлось (suggest_domain_keywords L187, текст прототипа). private const string NothingGroupedReason = "похожие колонки уже есть или нечего сгруппировать";
private const string KeywordsEmptyReason = "ИИ не смог выделить ключи — попробуйте ещё раз";
// Мало карточек для ключей: нужно хотя бы 3 (suggest_domain_keywords L178).
// Режим правил колонки-предложения: «любое из условий» (suggest.py _rules_for L68 mode: any). private const string KeywordsTooFewReason = "мало карточек — сначала накопите заявки (нужно хотя бы 3)";
private const string RulesModeAny = "any";
// Повторяющихся слов-маркеров не нашлось (suggest_domain_keywords L187, текст прототипа).
/// <inheritdoc /> private const string KeywordsEmptyReason = "ИИ не смог выделить ключи — попробуйте ещё раз";
public async Task<SuggestColumnsResultDto> SuggestColumnsAsync(CancellationToken ct)
{ // Режим правил колонки-предложения: «любое из условий» (suggest.py _rules_for L68 mode: any).
if (await WithinCooldownAsync(ct)) private const string RulesModeAny = "any";
{
return new SuggestColumnsResultDto(Ok: false, Created: 0, Reason: CooldownReason, Cooldown: true); /// <inheritdoc />
} public async Task<SuggestColumnsResultDto> SuggestColumnsAsync(CancellationToken ct)
{
IReadOnlyList<CardDto> inbox = await store.ListInboxWithSourceAsync(ct); if (await WithinCooldownAsync(ct))
if (inbox.Count < SuggestHeuristics.MinInbox) {
{ return new SuggestColumnsResultDto(Ok: false, Created: 0, Reason: CooldownReason, Cooldown: true);
return new SuggestColumnsResultDto( }
Ok: false,
Created: 0, IReadOnlyList<CardDto> inbox = await store.ListInboxWithSourceAsync(ct);
Reason: string.Format(TooFewCardsReasonFormat, SuggestHeuristics.MinInbox), if (inbox.Count < SuggestHeuristics.MinInbox)
Cooldown: false); {
} return new SuggestColumnsResultDto(
Ok: false,
// Существующие (suggested=false) колонки: похожие темы не предлагаем (suggest.py L105, L138139). Created: 0,
IReadOnlyList<ContainerDto> containers = await containersService.ListAsync(ContainerSpaces.Dashboard, ct); Reason: string.Format(TooFewCardsReasonFormat, SuggestHeuristics.MinInbox),
IReadOnlyList<string> existingNames = containers Cooldown: false);
.Where(container => !container.Suggested) }
.Select(container => container.Name)
.ToList(); // Существующие (suggested=false) колонки: похожие темы не предлагаем (suggest.py L105, L138139).
IReadOnlyList<ContainerDto> containers = await containersService.ListAsync(ContainerSpaces.Dashboard, ct);
IReadOnlyList<SuggestedColumnPlan> plans = SuggestHeuristics.PlanColumns(inbox, existingNames); IReadOnlyList<string> existingNames = containers
if (plans.Count == 0) .Where(container => !container.Suggested)
{ .Select(container => container.Name)
return new SuggestColumnsResultDto(Ok: false, Created: 0, Reason: NothingGroupedReason, Cooldown: false); .ToList();
}
IReadOnlyList<SuggestedColumnPlan> plans = SuggestHeuristics.PlanColumns(inbox, existingNames);
int created = await StoreSuggestedColumnsAsync(inbox, plans, ct); if (plans.Count == 0)
if (created == 0) {
{ return new SuggestColumnsResultDto(Ok: false, Created: 0, Reason: NothingGroupedReason, Cooldown: false);
// Все колонки откатаны: карточки групп разобраны между чтением и раскладкой (suggest.py L153156). }
return new SuggestColumnsResultDto(Ok: false, Created: 0, Reason: NothingGroupedReason, Cooldown: false);
} int created = await StoreSuggestedColumnsAsync(inbox, plans, ct);
if (created == 0)
await WriteLastSuggestAtAsync(ct); {
return new SuggestColumnsResultDto(Ok: true, Created: created, Reason: null, Cooldown: false); // Все колонки откатаны: карточки групп разобраны между чтением и раскладкой (suggest.py L153156).
} return new SuggestColumnsResultDto(Ok: false, Created: 0, Reason: NothingGroupedReason, Cooldown: false);
}
/// <inheritdoc />
public async Task<SuggestKeywordsResultDto> SuggestKeywordsAsync(CancellationToken ct) await WriteLastSuggestAtAsync(ct);
{ return new SuggestColumnsResultDto(Ok: true, Created: created, Reason: null, Cooldown: false);
// Выборка ключей — как suggest_domain_keywords L172176: карточки вне trash/archive с текстом, }
// свежие 40 (ListCardsAsync(null) = «все, кроме taken», ORDER BY received_at DESC).
IReadOnlyList<CardDto> cards = await store.ListCardsAsync(new CardsQuery(null), ct); /// <inheritdoc />
List<string> texts = cards public async Task<SuggestKeywordsResultDto> SuggestKeywordsAsync(CancellationToken ct)
.Where(card => card.Col != CardIds.Trash {
&& card.Col != CardIds.Archive // Выборка ключей — как suggest_domain_keywords L172176: карточки вне trash/archive с текстом,
&& card.SourceMsg.Trim().Length > 0) // свежие 40 (ListCardsAsync(null) = «все, кроме taken», ORDER BY received_at DESC).
.Take(SuggestHeuristics.KeywordsSampleLimit) IReadOnlyList<CardDto> cards = await store.ListCardsAsync(new CardsQuery(null), ct);
.Select(card => card.SourceMsg.Trim()) List<string> texts = cards
.ToList(); .Where(card => card.Col != CardIds.Trash
if (texts.Count < SuggestHeuristics.MinKeywordsSample) && card.Col != CardIds.Archive
{ && card.SourceMsg.Trim().Length > 0)
return new SuggestKeywordsResultDto(Ok: false, Keywords: null, Reason: KeywordsTooFewReason); .Take(SuggestHeuristics.KeywordsSampleLimit)
} .Select(card => card.SourceMsg.Trim())
.ToList();
IReadOnlyList<string> keywords = SuggestHeuristics.SuggestDomainKeywords(texts); if (texts.Count < SuggestHeuristics.MinKeywordsSample)
if (keywords.Count == 0) {
{ return new SuggestKeywordsResultDto(Ok: false, Keywords: null, Reason: KeywordsTooFewReason);
return new SuggestKeywordsResultDto(Ok: false, Keywords: null, Reason: KeywordsEmptyReason); }
}
IReadOnlyList<string> keywords = SuggestHeuristics.SuggestDomainKeywords(texts);
return new SuggestKeywordsResultDto(Ok: true, Keywords: keywords, Reason: null); if (keywords.Count == 0)
} {
return new SuggestKeywordsResultDto(Ok: false, Keywords: null, Reason: KeywordsEmptyReason);
// Создаёт доски-предложения по планам и раскладывает карточки (suggest.py L129156). }
// inbox: Снимок «Неразобранного» (карточки планов берутся из него).
// plans: Планы колонок (SuggestHeuristics.PlanColumns, ≤4). return new SuggestKeywordsResultDto(Ok: true, Keywords: keywords, Reason: null);
// ct: Токен отмены. }
// Возвращает: Сколько досок реально создано (0 — все откатаны из-за разобранных карточек).
// Каждая доска — suggested=true c правилами {mode:"any", keywords:[тема]} и note-обоснованием. // Создаёт доски-предложения по планам и раскладывает карточки (suggest.py L129156).
// Перед раскладкой перечитывается «Неразобранное»: карточки, ушедшие из inbox между снимком и // inbox: Снимок «Неразобранного» (карточки планов берутся из него).
// раскладкой (пользователь/тик), пропускаются — 1:1 со страховкой _assign_ids L231233. Если в // plans: Планы колонок (SuggestHeuristics.PlanColumns, ≤4).
// колонку не легло ни одной карточки, пустая доска-предложение откатывается (_rollback_suggested // ct: Токен отмены.
// L242248). matchHits считаются по правилам созданной доски (Ruling 2); журнал/ML не пишутся. // Возвращает: Сколько досок реально создано (0 — все откатаны из-за разобранных карточек).
private async Task<int> StoreSuggestedColumnsAsync( // Каждая доска — suggested=true c правилами {mode:"any", keywords:[тема]} и note-обоснованием.
IReadOnlyList<CardDto> inbox, // Перед раскладкой перечитывается «Неразобранное»: карточки, ушедшие из inbox между снимком и
IReadOnlyList<SuggestedColumnPlan> plans, // раскладкой (пользователь/тик), пропускаются — 1:1 со страховкой _assign_ids L231233. Если в
CancellationToken ct) // колонку не легло ни одной карточки, пустая доска-предложение откатывается (_rollback_suggested
{ // L242248). matchHits считаются по правилам созданной доски (Ruling 2); журнал/ML не пишутся.
// Свежий снимок inbox — страховка «карточку уже разобрали» (suggest.py _assign_ids L231233). private async Task<int> StoreSuggestedColumnsAsync(
HashSet<string> inboxIds = (await store.ListInboxWithSourceAsync(ct)) IReadOnlyList<CardDto> inbox,
.Select(card => card.Id) IReadOnlyList<SuggestedColumnPlan> plans,
.ToHashSet(StringComparer.Ordinal); CancellationToken ct)
Dictionary<string, string> textByCardId = inbox {
.ToDictionary(card => card.Id, card => card.SourceMsg, StringComparer.Ordinal); // Свежий снимок inbox — страховка «карточку уже разобрали» (suggest.py _assign_ids L231233).
HashSet<string> inboxIds = (await store.ListInboxWithSourceAsync(ct))
int created = 0; .Select(card => card.Id)
foreach (SuggestedColumnPlan plan in plans) .ToHashSet(StringComparer.Ordinal);
{ Dictionary<string, string> textByCardId = inbox
var rules = new ContainerRulesDto( .ToDictionary(card => card.Id, card => card.SourceMsg, StringComparer.Ordinal);
Mode: RulesModeAny,
Direction: Array.Empty<string>(), int created = 0;
Keywords: [plan.Word], foreach (SuggestedColumnPlan plan in plans)
Stack: Array.Empty<string>(), {
Grade: Array.Empty<string>(), var rules = new ContainerRulesDto(
Exclude: Array.Empty<string>(), Mode: RulesModeAny,
Budget: null); Direction: Array.Empty<string>(),
ContainerDto container = await containersService.CreateAsync(new ContainerCreateDto( Keywords: [plan.Word],
Name: plan.Name, Stack: Array.Empty<string>(),
Description: string.Empty, Grade: Array.Empty<string>(),
Color: null, Exclude: Array.Empty<string>(),
Space: ContainerSpaces.Dashboard, Budget: null);
Kind: ContainerKinds.Board, ContainerDto container = await containersService.CreateAsync(new ContainerCreateDto(
Suggested: true, Name: plan.Name,
Rules: rules, Description: string.Empty,
Note: plan.Note), ct); Color: null,
Space: ContainerSpaces.Dashboard,
int placed = 0; Kind: ContainerKinds.Board,
foreach (string cardId in plan.CardIds) Suggested: true,
{ Rules: rules,
if (!inboxIds.Contains(cardId)) Note: plan.Note), ct);
{
continue; // карточка уже разобрана другим предложением/пользователем (L231–233) int placed = 0;
} foreach (string cardId in plan.CardIds)
{
IReadOnlyList<MatchHitDto> hits = KanbanColumnRules.ComputeHits(rules, textByCardId[cardId]); if (!inboxIds.Contains(cardId))
await store.UpdateColumnAsync(new CardColumnUpdateDto( {
CardId: cardId, continue; // карточка уже разобрана другим предложением/пользователем (L231–233)
Col: container.Id, }
IsNew: true,
PrevCol: CardIds.Inbox, IReadOnlyList<MatchHitDto> hits = KanbanColumnRules.ComputeHits(rules, textByCardId[cardId]);
ArchivedAt: null, await store.UpdateColumnAsync(new CardColumnUpdateDto(
MatchHits: hits), ct); CardId: cardId,
placed++; Col: container.Id,
} IsNew: true,
PrevCol: CardIds.Inbox,
if (placed == 0) ArchivedAt: null,
{ MatchHits: hits), ct);
// Ничего не легло — пустое предложение не нужно (suggest.py L152156). placed++;
await containersService.DeleteAsync(container.Id, ct); }
continue;
} if (placed == 0)
{
created++; // Ничего не легло — пустое предложение не нужно (suggest.py L152156).
} await containersService.DeleteAsync(container.Id, ct);
continue;
return created; }
}
created++;
// Сработал ли кулдаун: с последнего успешного предложения прошло меньше 20 минут. }
// Повреждённое/отсутствующее значение lastSuggestAt — кулдауна нет (как прототип: значение
// пишется только после успеха, L160–161; битый KV — дефолт «никогда»). return created;
// ct: Токен отмены. }
// Возвращает: True — повторный вызов слишком рано (ответ {ok:false, reason, cooldown:true}).
private async Task<bool> WithinCooldownAsync(CancellationToken ct) // Сработал ли кулдаун: с последнего успешного предложения прошло меньше 20 минут.
{ // Повреждённое/отсутствующее значение lastSuggestAt — кулдауна нет (как прототип: значение
SettingValue? row = await settings.GetAsync(SettingsKeys.LastSuggestAt, ct); // пишется только после успеха, L160–161; битый KV — дефолт «никогда»).
if (row is null) // ct: Токен отмены.
{ // Возвращает: True — повторный вызов слишком рано (ответ {ok:false, reason, cooldown:true}).
return false; private async Task<bool> WithinCooldownAsync(CancellationToken ct)
} {
SettingValue? row = await settings.GetAsync(SettingsKeys.LastSuggestAt, ct);
try if (row is null)
{ {
using JsonDocument document = JsonDocument.Parse(row.ValueJson); return false;
if (document.RootElement.ValueKind != JsonValueKind.Number) }
{
return false; try
} {
using JsonDocument document = JsonDocument.Parse(row.ValueJson);
long lastSuggestAt = document.RootElement.GetInt64(); if (document.RootElement.ValueKind != JsonValueKind.Number)
return DateTimeOffset.UtcNow.ToUnixTimeSeconds() - lastSuggestAt < CooldownSeconds; {
} return false;
catch (JsonException) }
{
return false; // повреждённое значение не должно блокировать предложения long lastSuggestAt = document.RootElement.GetInt64();
} return DateTimeOffset.UtcNow.ToUnixTimeSeconds() - lastSuggestAt < CooldownSeconds;
} }
catch (JsonException)
// Записывает метку успешного предложения (suggest.py L160: set_setting(KEY, time.time())). {
// ct: Токен отмены. return false; // повреждённое значение не должно блокировать предложения
private Task WriteLastSuggestAtAsync(CancellationToken ct) => }
settings.SetAsync( }
SettingsKeys.LastSuggestAt,
DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture), // Записывает метку успешного предложения (suggest.py L160: set_setting(KEY, time.time())).
ct); // ct: Токен отмены.
} private Task WriteLastSuggestAtAsync(CancellationToken ct) =>
settings.SetAsync(
SettingsKeys.LastSuggestAt,
DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture),
ct);
}
@@ -1,9 +1,15 @@
using System.Text.Json; using System.Text.Json;
using Deal.Contracts.Integrations; using Deal.Contracts.Integrations;
using Deal.Contracts.Integrations.Models; using Deal.Contracts.Integrations.Models;
using Deal.Modules.Kanban.Application; using Deal.Modules.Kanban.Application.Abstractions;
using Deal.Modules.Settings.Application; using Deal.Modules.Kanban.Application.Extensions;
using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Kanban.Application.Registrars;
using Deal.Modules.Kanban.Application.Services;
using Deal.Modules.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.Services;
namespace Deal.Infrastructure.Integrations; namespace Deal.Infrastructure.Integrations;
@@ -1,5 +1,9 @@
using System.Security.Cryptography; using System.Security.Cryptography;
using Deal.Modules.Kanban.Application; using Deal.Modules.Kanban.Application.Abstractions;
using Deal.Modules.Kanban.Application.Extensions;
using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Kanban.Application.Registrars;
using Deal.Modules.Kanban.Application.Services;
namespace Deal.Infrastructure.Integrations; namespace Deal.Infrastructure.Integrations;
@@ -1,10 +1,15 @@
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Nodes; using System.Text.Json.Nodes;
using Deal.Grpc.Ai; using Deal.Grpc.Ai;
using Deal.Modules.Settings.Application; using Deal.Modules.Settings.Application.Abstractions;
using Deal.Modules.Settings.Application.Models; using Deal.Modules.Settings.Application.Models;
using Deal.Modules.Tenants.Application; using Deal.Modules.Settings.Application.Registrars;
using Deal.Modules.Settings.Application.Services;
using Deal.Modules.Tenants.Application.Abstractions;
using Deal.Modules.Tenants.Application.Extensions;
using Deal.Modules.Tenants.Application.Models; using Deal.Modules.Tenants.Application.Models;
using Deal.Modules.Tenants.Application.Registrars;
using Deal.Modules.Tenants.Application.Services;
using Deal.SharedKernel.Observability; using Deal.SharedKernel.Observability;
using Deal.SharedKernel.Tenants; using Deal.SharedKernel.Tenants;
@@ -6,7 +6,7 @@ namespace Deal.Infrastructure.Persistence.Entities;
/// <remarks> /// <remarks>
/// Единое KV-хранилище всего SaaS-контура (ТЗ §4.1/§8.1): значения задаёт оператор, видят все /// Единое KV-хранилище всего SaaS-контура (ТЗ §4.1/§8.1): значения задаёт оператор, видят все
/// тенанты. Секреты хранятся зашифрованными (префикс <c>enc:</c>), формат значения определяет ключ /// тенанты. Секреты хранятся зашифрованными (префикс <c>enc:</c>), формат значения определяет ключ
/// (<see cref="Deal.Modules.Settings.Application.GlobalSettingsKeys"/>). /// (<see cref="Deal.Modules.Settings.Application.Models.GlobalSettingsKeys"/>).
/// </remarks> /// </remarks>
public sealed class GlobalSettingEntity public sealed class GlobalSettingEntity
{ {
@@ -1,6 +1,9 @@
using Deal.Infrastructure.Persistence.Entities; using Deal.Infrastructure.Persistence.Entities;
using Deal.Modules.Tenants.Application; using Deal.Modules.Tenants.Application.Abstractions;
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.Services;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace Deal.Infrastructure.Persistence.Repositories; namespace Deal.Infrastructure.Persistence.Repositories;
@@ -1,6 +1,9 @@
using Deal.Infrastructure.Persistence.Entities; using Deal.Infrastructure.Persistence.Entities;
using Deal.Modules.Tenants.Application; using Deal.Modules.Tenants.Application.Abstractions;
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.Services;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace Deal.Infrastructure.Persistence.Repositories; namespace Deal.Infrastructure.Persistence.Repositories;
@@ -1,65 +1,69 @@
using System.Text.Json; using System.Text.Json;
using Deal.Infrastructure.Persistence.Entities; using Deal.Infrastructure.Persistence.Entities;
using Deal.Modules.Discovery.Application; using Deal.Modules.Discovery.Application.Abstractions;
using Deal.Modules.Discovery.Application.Models; using Deal.Modules.Discovery.Application.Exceptions;
using Microsoft.EntityFrameworkCore; using Deal.Modules.Discovery.Application.Extensions;
using Deal.Modules.Discovery.Application.Models;
namespace Deal.Infrastructure.Persistence.Repositories; using Deal.Modules.Discovery.Application.Registrars;
using Deal.Modules.Discovery.Application.Services;
/// <summary> using Microsoft.EntityFrameworkCore;
/// Чёрный список Discovery — partial-часть <see cref="DiscoveryStore"/> (C32: выделено из общего
/// файла, поведение не менялось): upsert/удаление/чтение/список DiscBlacklist (ON CONFLICT DO UPDATE). namespace Deal.Infrastructure.Persistence.Repositories;
/// </summary>
public sealed partial class DiscoveryStore /// <summary>
{ /// Чёрный список Discovery — partial-часть <see cref="DiscoveryStore"/> (C32: выделено из общего
/// <inheritdoc /> /// файла, поведение не менялось): upsert/удаление/чтение/список DiscBlacklist (ON CONFLICT DO UPDATE).
public async Task UpsertBlacklistAsync(string dialogId, string name, string reason, CancellationToken ct) /// </summary>
{ public sealed partial class DiscoveryStore
DiscBlacklistEntity? row = await _dbContext.DiscBlacklist {
.FirstOrDefaultAsync(entry => entry.DialogId == dialogId, ct); /// <inheritdoc />
if (row is null) public async Task UpsertBlacklistAsync(string dialogId, string name, string reason, CancellationToken ct)
{ {
_dbContext.DiscBlacklist.Add(new DiscBlacklistEntity DiscBlacklistEntity? row = await _dbContext.DiscBlacklist
{ .FirstOrDefaultAsync(entry => entry.DialogId == dialogId, ct);
DialogId = dialogId, if (row is null)
Name = name, {
Reason = reason, _dbContext.DiscBlacklist.Add(new DiscBlacklistEntity
CreatedAt = DateTimeOffset.UtcNow, {
}); DialogId = dialogId,
} Name = name,
else Reason = reason,
{ CreatedAt = DateTimeOffset.UtcNow,
// add_blacklist L572575 (ON CONFLICT DO UPDATE): name/reason обновляются, CreatedAt сохраняется. });
row.Name = name; }
row.Reason = reason; else
} {
// add_blacklist L572575 (ON CONFLICT DO UPDATE): name/reason обновляются, CreatedAt сохраняется.
await _dbContext.SaveChangesAsync(ct); row.Name = name;
} row.Reason = reason;
}
/// <inheritdoc />
public async Task RemoveBlacklistAsync(string dialogId, CancellationToken ct) await _dbContext.SaveChangesAsync(ct);
{ }
await _dbContext.DiscBlacklist.Where(entry => entry.DialogId == dialogId).ExecuteDeleteAsync(ct);
} /// <inheritdoc />
public async Task RemoveBlacklistAsync(string dialogId, CancellationToken ct)
/// <inheritdoc /> {
public async Task<DiscoveryBlacklistDto?> GetBlacklistAsync(string dialogId, CancellationToken ct) await _dbContext.DiscBlacklist.Where(entry => entry.DialogId == dialogId).ExecuteDeleteAsync(ct);
{ }
DiscBlacklistEntity? row = await _dbContext.DiscBlacklist
.AsNoTracking() /// <inheritdoc />
.FirstOrDefaultAsync(entry => entry.DialogId == dialogId, ct); public async Task<DiscoveryBlacklistDto?> GetBlacklistAsync(string dialogId, CancellationToken ct)
return row is null ? null : ToBlacklistDto(row); {
} DiscBlacklistEntity? row = await _dbContext.DiscBlacklist
.AsNoTracking()
/// <inheritdoc /> .FirstOrDefaultAsync(entry => entry.DialogId == dialogId, ct);
public async Task<IReadOnlyList<DiscoveryBlacklistDto>> ListBlacklistAsync(CancellationToken ct) return row is null ? null : ToBlacklistDto(row);
{ }
List<DiscBlacklistEntity> rows = await _dbContext.DiscBlacklist
.AsNoTracking() /// <inheritdoc />
.OrderByDescending(entry => entry.CreatedAt) public async Task<IReadOnlyList<DiscoveryBlacklistDto>> ListBlacklistAsync(CancellationToken ct)
.ToListAsync(ct); {
return rows.Select(ToBlacklistDto).ToList(); List<DiscBlacklistEntity> rows = await _dbContext.DiscBlacklist
} .AsNoTracking()
.OrderByDescending(entry => entry.CreatedAt)
} .ToListAsync(ct);
return rows.Select(ToBlacklistDto).ToList();
}
}
@@ -1,158 +1,162 @@
using System.Text.Json; using System.Text.Json;
using Deal.Infrastructure.Persistence.Entities; using Deal.Infrastructure.Persistence.Entities;
using Deal.Modules.Discovery.Application; using Deal.Modules.Discovery.Application.Abstractions;
using Deal.Modules.Discovery.Application.Models; using Deal.Modules.Discovery.Application.Exceptions;
using Microsoft.EntityFrameworkCore; using Deal.Modules.Discovery.Application.Extensions;
using Deal.Modules.Discovery.Application.Models;
namespace Deal.Infrastructure.Persistence.Repositories; using Deal.Modules.Discovery.Application.Registrars;
using Deal.Modules.Discovery.Application.Services;
/// <summary> using Microsoft.EntityFrameworkCore;
/// Кандидаты Discovery — partial-часть <see cref="DiscoveryStore"/> (C32: выделено из общего файла,
/// поведение не менялось): список/чтение кандидатов (DiscCandidates), мониторинг диалога и чёрный список, namespace Deal.Infrastructure.Persistence.Repositories;
/// создание/патч/статусы joined/rejected и счётчик сбоев вступлений.
/// </summary> /// <summary>
public sealed partial class DiscoveryStore /// Кандидаты Discovery — partial-часть <see cref="DiscoveryStore"/> (C32: выделено из общего файла,
{ /// поведение не менялось): список/чтение кандидатов (DiscCandidates), мониторинг диалога и чёрный список,
/// <inheritdoc /> /// создание/патч/статусы joined/rejected и счётчик сбоев вступлений.
public async Task<IReadOnlyList<DiscoveryCandidateDto>> ListCandidatesAsync(string taskId, string? status, CancellationToken ct) /// </summary>
{ public sealed partial class DiscoveryStore
IQueryable<DiscCandidateEntity> query = _dbContext.DiscCandidates.AsNoTracking().Where(candidate => candidate.TaskId == taskId); {
if (status is not null) /// <inheritdoc />
{ public async Task<IReadOnlyList<DiscoveryCandidateDto>> ListCandidatesAsync(string taskId, string? status, CancellationToken ct)
query = query.Where(candidate => candidate.Status == status); {
} IQueryable<DiscCandidateEntity> query = _dbContext.DiscCandidates.AsNoTracking().Where(candidate => candidate.TaskId == taskId);
if (status is not null)
List<DiscCandidateEntity> rows = await query.OrderBy(candidate => candidate.CreatedAt).ToListAsync(ct); {
return rows.Select(ToCandidateDto).ToList(); query = query.Where(candidate => candidate.Status == status);
} }
/// <inheritdoc /> List<DiscCandidateEntity> rows = await query.OrderBy(candidate => candidate.CreatedAt).ToListAsync(ct);
public async Task<DiscoveryCandidateDto?> GetCandidateAsync(string dialogId, CancellationToken ct) return rows.Select(ToCandidateDto).ToList();
{ }
DiscCandidateEntity? row = await _dbContext.DiscCandidates
.AsNoTracking() /// <inheritdoc />
.FirstOrDefaultAsync(candidate => candidate.DialogId == dialogId, ct); public async Task<DiscoveryCandidateDto?> GetCandidateAsync(string dialogId, CancellationToken ct)
return row is null ? null : ToCandidateDto(row); {
} DiscCandidateEntity? row = await _dbContext.DiscCandidates
.AsNoTracking()
/// <inheritdoc /> .FirstOrDefaultAsync(candidate => candidate.DialogId == dialogId, ct);
public async Task<bool> IsDialogMonitoredAsync(string dialogId, CancellationToken ct) return row is null ? null : ToCandidateDto(row);
{ }
return await _dbContext.Dialogs.AnyAsync(dialog => dialog.Id == dialogId, ct);
} /// <inheritdoc />
public async Task<bool> IsDialogMonitoredAsync(string dialogId, CancellationToken ct)
/// <inheritdoc /> {
public Task<bool> IsBlacklistedAsync(string dialogId, CancellationToken ct) return await _dbContext.Dialogs.AnyAsync(dialog => dialog.Id == dialogId, ct);
{ }
return _dbContext.DiscBlacklist.AnyAsync(row => row.DialogId == dialogId, ct);
} /// <inheritdoc />
public Task<bool> IsBlacklistedAsync(string dialogId, CancellationToken ct)
/// <inheritdoc /> {
public async Task CreateCandidateAsync(DiscoveryCandidateRow row, CancellationToken ct) return _dbContext.DiscBlacklist.AnyAsync(row => row.DialogId == dialogId, ct);
{ }
DateTimeOffset now = DateTimeOffset.UtcNow;
_dbContext.DiscCandidates.Add(new DiscCandidateEntity /// <inheritdoc />
{ public async Task CreateCandidateAsync(DiscoveryCandidateRow row, CancellationToken ct)
DialogId = row.DialogId, {
TaskId = row.TaskId, DateTimeOffset now = DateTimeOffset.UtcNow;
Name = row.Name, _dbContext.DiscCandidates.Add(new DiscCandidateEntity
Username = row.Username, {
Kind = row.Kind, DialogId = row.DialogId,
Hue = row.Hue, TaskId = row.TaskId,
Status = "new", Name = row.Name,
CreatedAt = now, Username = row.Username,
UpdatedAt = now, Kind = row.Kind,
}); Hue = row.Hue,
await _dbContext.SaveChangesAsync(ct); Status = "new",
} CreatedAt = now,
UpdatedAt = now,
/// <inheritdoc /> });
public async Task DeleteCandidateAsync(string dialogId, CancellationToken ct) await _dbContext.SaveChangesAsync(ct);
{ }
await _dbContext.DiscCandidates.Where(candidate => candidate.DialogId == dialogId).ExecuteDeleteAsync(ct);
} /// <inheritdoc />
public async Task DeleteCandidateAsync(string dialogId, CancellationToken ct)
/// <inheritdoc /> {
public async Task<bool> PatchCandidateAsync(string dialogId, DiscoveryCandidatePatch patch, CancellationToken ct) await _dbContext.DiscCandidates.Where(candidate => candidate.DialogId == dialogId).ExecuteDeleteAsync(ct);
{ }
DiscCandidateEntity? row = await _dbContext.DiscCandidates
.FirstOrDefaultAsync(candidate => candidate.DialogId == dialogId, ct); /// <inheritdoc />
if (row is null) public async Task<bool> PatchCandidateAsync(string dialogId, DiscoveryCandidatePatch patch, CancellationToken ct)
{ {
return false; DiscCandidateEntity? row = await _dbContext.DiscCandidates
} .FirstOrDefaultAsync(candidate => candidate.DialogId == dialogId, ct);
if (row is null)
ApplyCandidatePatch(row, patch); {
row.UpdatedAt = DateTimeOffset.UtcNow; return false;
await _dbContext.SaveChangesAsync(ct); }
return true;
} ApplyCandidatePatch(row, patch);
row.UpdatedAt = DateTimeOffset.UtcNow;
/// <inheritdoc /> await _dbContext.SaveChangesAsync(ct);
public async Task<bool> SetCandidateStatusAsync(string dialogId, string status, CancellationToken ct) return true;
{ }
DiscCandidateEntity? row = await _dbContext.DiscCandidates
.FirstOrDefaultAsync(candidate => candidate.DialogId == dialogId, ct); /// <inheritdoc />
if (row is null) public async Task<bool> SetCandidateStatusAsync(string dialogId, string status, CancellationToken ct)
{ {
return false; DiscCandidateEntity? row = await _dbContext.DiscCandidates
} .FirstOrDefaultAsync(candidate => candidate.DialogId == dialogId, ct);
if (row is null)
row.Status = status; {
row.UpdatedAt = DateTimeOffset.UtcNow; return false;
await _dbContext.SaveChangesAsync(ct); }
return true;
} row.Status = status;
row.UpdatedAt = DateTimeOffset.UtcNow;
/// <inheritdoc /> await _dbContext.SaveChangesAsync(ct);
public async Task<bool> SetCandidateJoinedAsync(string dialogId, bool autoJoined, CancellationToken ct) return true;
{ }
DiscCandidateEntity? row = await _dbContext.DiscCandidates
.FirstOrDefaultAsync(candidate => candidate.DialogId == dialogId, ct); /// <inheritdoc />
if (row is null) public async Task<bool> SetCandidateJoinedAsync(string dialogId, bool autoJoined, CancellationToken ct)
{ {
return false; DiscCandidateEntity? row = await _dbContext.DiscCandidates
} .FirstOrDefaultAsync(candidate => candidate.DialogId == dialogId, ct);
if (row is null)
// mark_joined L531534: status=joined + auto_joined. {
row.Status = "joined"; return false;
row.AutoJoined = autoJoined; }
row.UpdatedAt = DateTimeOffset.UtcNow;
await _dbContext.SaveChangesAsync(ct); // mark_joined L531534: status=joined + auto_joined.
return true; row.Status = "joined";
} row.AutoJoined = autoJoined;
row.UpdatedAt = DateTimeOffset.UtcNow;
/// <inheritdoc /> await _dbContext.SaveChangesAsync(ct);
public async Task<int?> IncrementJoinFailuresAsync(string dialogId, CancellationToken ct) return true;
{ }
DiscCandidateEntity? row = await _dbContext.DiscCandidates
.FirstOrDefaultAsync(candidate => candidate.DialogId == dialogId, ct); /// <inheritdoc />
if (row is null || row.Status != "review") public async Task<int?> IncrementJoinFailuresAsync(string dialogId, CancellationToken ct)
{ {
// воркер L404–416: счётчик и удаление трогаем только у живой записи в статусе review. DiscCandidateEntity? row = await _dbContext.DiscCandidates
return null; .FirstOrDefaultAsync(candidate => candidate.DialogId == dialogId, ct);
} if (row is null || row.Status != "review")
{
row.JoinFailures += 1; // воркер L404–416: счётчик и удаление трогаем только у живой записи в статусе review.
row.UpdatedAt = DateTimeOffset.UtcNow; return null;
await _dbContext.SaveChangesAsync(ct); }
return row.JoinFailures;
} row.JoinFailures += 1;
row.UpdatedAt = DateTimeOffset.UtcNow;
/// <inheritdoc /> await _dbContext.SaveChangesAsync(ct);
public async Task<bool> SetCandidateRejectedAsync(string dialogId, CancellationToken ct) return row.JoinFailures;
{ }
DiscCandidateEntity? row = await _dbContext.DiscCandidates
.FirstOrDefaultAsync(candidate => candidate.DialogId == dialogId, ct); /// <inheritdoc />
if (row is null) public async Task<bool> SetCandidateRejectedAsync(string dialogId, CancellationToken ct)
{ {
return false; DiscCandidateEntity? row = await _dbContext.DiscCandidates
} .FirstOrDefaultAsync(candidate => candidate.DialogId == dialogId, ct);
if (row is null)
row.Status = "rejected"; {
row.UpdatedAt = DateTimeOffset.UtcNow; return false;
await _dbContext.SaveChangesAsync(ct); }
return true;
} row.Status = "rejected";
row.UpdatedAt = DateTimeOffset.UtcNow;
} await _dbContext.SaveChangesAsync(ct);
return true;
}
}
@@ -1,47 +1,51 @@
using System.Text.Json; using System.Text.Json;
using Deal.Infrastructure.Persistence.Entities; using Deal.Infrastructure.Persistence.Entities;
using Deal.Modules.Discovery.Application; using Deal.Modules.Discovery.Application.Abstractions;
using Deal.Modules.Discovery.Application.Models; using Deal.Modules.Discovery.Application.Exceptions;
using Microsoft.EntityFrameworkCore; using Deal.Modules.Discovery.Application.Extensions;
using Deal.Modules.Discovery.Application.Models;
namespace Deal.Infrastructure.Persistence.Repositories; using Deal.Modules.Discovery.Application.Registrars;
using Deal.Modules.Discovery.Application.Services;
/// <summary> using Microsoft.EntityFrameworkCore;
/// Лог Discovery — partial-часть <see cref="DiscoveryStore"/> (C32: выделено из общего файла,
/// поведение не менялось): запись события, счётчик событий с начала суток (квота вступлений) и лог задачи. namespace Deal.Infrastructure.Persistence.Repositories;
/// </summary>
public sealed partial class DiscoveryStore /// <summary>
{ /// Лог Discovery — partial-часть <see cref="DiscoveryStore"/> (C32: выделено из общего файла,
/// <inheritdoc /> /// поведение не менялось): запись события, счётчик событий с начала суток (квота вступлений) и лог задачи.
public async Task AddLogAsync(string logId, string taskId, string logEvent, string text, CancellationToken ct) /// </summary>
{ public sealed partial class DiscoveryStore
_dbContext.DiscLog.Add(new DiscLogEntity {
{ /// <inheritdoc />
Id = logId, public async Task AddLogAsync(string logId, string taskId, string logEvent, string text, CancellationToken ct)
TaskId = taskId, {
Event = logEvent, _dbContext.DiscLog.Add(new DiscLogEntity
Text = text, {
CreatedAt = DateTimeOffset.UtcNow, Id = logId,
}); TaskId = taskId,
await _dbContext.SaveChangesAsync(ct); Event = logEvent,
} Text = text,
CreatedAt = DateTimeOffset.UtcNow,
/// <inheritdoc /> });
public async Task<int> CountLogEventAsync(string logEvent, DateTimeOffset sinceUtc, CancellationToken ct) await _dbContext.SaveChangesAsync(ct);
{ }
// ban_guard.joins_today_auto L2935: число событий лога по типу с начала UTC-суток (счётчик квоты).
return await _dbContext.DiscLog.CountAsync(row => row.Event == logEvent && row.CreatedAt >= sinceUtc, ct); /// <inheritdoc />
} public async Task<int> CountLogEventAsync(string logEvent, DateTimeOffset sinceUtc, CancellationToken ct)
{
/// <inheritdoc /> // ban_guard.joins_today_auto L2935: число событий лога по типу с начала UTC-суток (счётчик квоты).
public async Task<IReadOnlyList<DiscoveryLogDto>> ListTaskLogAsync(string taskId, int limit, CancellationToken ct) return await _dbContext.DiscLog.CountAsync(row => row.Event == logEvent && row.CreatedAt >= sinceUtc, ct);
{ }
List<DiscLogEntity> rows = await _dbContext.DiscLog
.AsNoTracking() /// <inheritdoc />
.Where(log => log.TaskId == taskId) public async Task<IReadOnlyList<DiscoveryLogDto>> ListTaskLogAsync(string taskId, int limit, CancellationToken ct)
.OrderByDescending(log => log.CreatedAt) {
.Take(limit) List<DiscLogEntity> rows = await _dbContext.DiscLog
.ToListAsync(ct); .AsNoTracking()
return rows.Select(ToLogDto).ToList(); .Where(log => log.TaskId == taskId)
} .OrderByDescending(log => log.CreatedAt)
} .Take(limit)
.ToListAsync(ct);
return rows.Select(ToLogDto).ToList();
}
}
@@ -1,217 +1,221 @@
using System.Text.Json; using System.Text.Json;
using Deal.Infrastructure.Persistence.Entities; using Deal.Infrastructure.Persistence.Entities;
using Deal.Modules.Discovery.Application; using Deal.Modules.Discovery.Application.Abstractions;
using Deal.Modules.Discovery.Application.Models; using Deal.Modules.Discovery.Application.Exceptions;
using Microsoft.EntityFrameworkCore; using Deal.Modules.Discovery.Application.Extensions;
using Deal.Modules.Discovery.Application.Models;
namespace Deal.Infrastructure.Persistence.Repositories; using Deal.Modules.Discovery.Application.Registrars;
using Deal.Modules.Discovery.Application.Services;
/// <summary> using Microsoft.EntityFrameworkCore;
/// Задачи Discovery — partial-часть <see cref="DiscoveryStore"/> (C32: выделено из общего файла,
/// поведение не менялось): CRUD задач (DiscTasks), состояние running/paused/done, атомарные инкременты namespace Deal.Infrastructure.Persistence.Repositories;
/// счётчиков и сдвиг поиска, сумма плана (discovery.py db.py; Ruling 9, Task 17).
/// </summary> /// <summary>
public sealed partial class DiscoveryStore /// Задачи Discovery — partial-часть <see cref="DiscoveryStore"/> (C32: выделено из общего файла,
{ /// поведение не менялось): CRUD задач (DiscTasks), состояние running/paused/done, атомарные инкременты
/// <inheritdoc /> /// счётчиков и сдвиг поиска, сумма плана (discovery.py db.py; Ruling 9, Task 17).
public async Task<IReadOnlyList<DiscoveryTaskDto>> ListTasksAsync(CancellationToken ct) /// </summary>
{ public sealed partial class DiscoveryStore
List<DiscTaskEntity> rows = await _dbContext.DiscTasks {
.AsNoTracking() /// <inheritdoc />
.OrderBy(task => task.CreatedAt) public async Task<IReadOnlyList<DiscoveryTaskDto>> ListTasksAsync(CancellationToken ct)
.ToListAsync(ct); {
return rows.Select(ToTaskDto).ToList(); List<DiscTaskEntity> rows = await _dbContext.DiscTasks
} .AsNoTracking()
.OrderBy(task => task.CreatedAt)
/// <inheritdoc /> .ToListAsync(ct);
public async Task<DiscoveryTaskDto?> GetTaskAsync(string taskId, CancellationToken ct) return rows.Select(ToTaskDto).ToList();
{ }
DiscTaskEntity? row = await _dbContext.DiscTasks
.AsNoTracking() /// <inheritdoc />
.FirstOrDefaultAsync(task => task.Id == taskId, ct); public async Task<DiscoveryTaskDto?> GetTaskAsync(string taskId, CancellationToken ct)
return row is null ? null : ToTaskDto(row); {
} DiscTaskEntity? row = await _dbContext.DiscTasks
.AsNoTracking()
/// <inheritdoc /> .FirstOrDefaultAsync(task => task.Id == taskId, ct);
public async Task CreateTaskAsync(DiscoveryTaskRow row, CancellationToken ct) return row is null ? null : ToTaskDto(row);
{ }
DateTimeOffset now = DateTimeOffset.UtcNow;
_dbContext.DiscTasks.Add(new DiscTaskEntity /// <inheritdoc />
{ public async Task CreateTaskAsync(DiscoveryTaskRow row, CancellationToken ct)
Id = row.Id, {
Name = row.Name, DateTimeOffset now = DateTimeOffset.UtcNow;
Description = row.Description, _dbContext.DiscTasks.Add(new DiscTaskEntity
KeywordsJson = ToJson(row.Keywords), {
MinSubscribers = row.MinSubscribers, Id = row.Id,
Lang = row.Lang, Name = row.Name,
Threshold = row.Threshold, Description = row.Description,
SampleSize = row.SampleSize, KeywordsJson = ToJson(row.Keywords),
PlanJoins = row.PlanJoins, MinSubscribers = row.MinSubscribers,
AutoJoin = row.AutoJoin, Lang = row.Lang,
Status = "draft", Threshold = row.Threshold,
CreatedAt = now, SampleSize = row.SampleSize,
UpdatedAt = now, PlanJoins = row.PlanJoins,
}); AutoJoin = row.AutoJoin,
await _dbContext.SaveChangesAsync(ct); Status = "draft",
} CreatedAt = now,
UpdatedAt = now,
/// <inheritdoc /> });
public async Task<bool> PatchTaskAsync(string taskId, DiscoveryTaskPatch patch, CancellationToken ct) await _dbContext.SaveChangesAsync(ct);
{ }
DiscTaskEntity? row = await _dbContext.DiscTasks
.FirstOrDefaultAsync(task => task.Id == taskId, ct); /// <inheritdoc />
if (row is null) public async Task<bool> PatchTaskAsync(string taskId, DiscoveryTaskPatch patch, CancellationToken ct)
{ {
return false; DiscTaskEntity? row = await _dbContext.DiscTasks
} .FirstOrDefaultAsync(task => task.Id == taskId, ct);
if (row is null)
ApplyTaskPatch(row, patch); {
row.UpdatedAt = DateTimeOffset.UtcNow; return false;
await _dbContext.SaveChangesAsync(ct); }
return true;
} ApplyTaskPatch(row, patch);
row.UpdatedAt = DateTimeOffset.UtcNow;
/// <inheritdoc /> await _dbContext.SaveChangesAsync(ct);
public async Task<bool> DeleteTaskAsync(string taskId, CancellationToken ct) return true;
{ }
DiscTaskEntity? row = await _dbContext.DiscTasks
.FirstOrDefaultAsync(task => task.Id == taskId, ct); /// <inheritdoc />
if (row is null) public async Task<bool> DeleteTaskAsync(string taskId, CancellationToken ct)
{ {
return false; DiscTaskEntity? row = await _dbContext.DiscTasks
} .FirstOrDefaultAsync(task => task.Id == taskId, ct);
if (row is null)
// delete_task L314318: задача удаляется вместе с кандидатами и логом; чёрный список общий — не трогаем. {
await _dbContext.DiscCandidates.Where(candidate => candidate.TaskId == taskId).ExecuteDeleteAsync(ct); return false;
await _dbContext.DiscLog.Where(log => log.TaskId == taskId).ExecuteDeleteAsync(ct); }
_dbContext.DiscTasks.Remove(row);
await _dbContext.SaveChangesAsync(ct); // delete_task L314318: задача удаляется вместе с кандидатами и логом; чёрный список общий — не трогаем.
return true; await _dbContext.DiscCandidates.Where(candidate => candidate.TaskId == taskId).ExecuteDeleteAsync(ct);
} await _dbContext.DiscLog.Where(log => log.TaskId == taskId).ExecuteDeleteAsync(ct);
_dbContext.DiscTasks.Remove(row);
/// <inheritdoc /> await _dbContext.SaveChangesAsync(ct);
public async Task<bool> SetTaskRunningAsync(string taskId, bool resetProgress, CancellationToken ct) return true;
{ }
DiscTaskEntity? row = await _dbContext.DiscTasks
.FirstOrDefaultAsync(task => task.Id == taskId, ct); /// <inheritdoc />
if (row is null) public async Task<bool> SetTaskRunningAsync(string taskId, bool resetProgress, CancellationToken ct)
{ {
return false; DiscTaskEntity? row = await _dbContext.DiscTasks
} .FirstOrDefaultAsync(task => task.Id == taskId, ct);
if (row is null)
row.Status = "running"; {
if (resetProgress) return false;
{ }
// start_task L333339: повторный прогон завершённой/упавшей — свежий проход по ключам.
row.SearchIdx = 0; row.Status = "running";
row.SearchDone = false; if (resetProgress)
row.Found = 0; {
row.Evaluated = 0; // start_task L333339: повторный прогон завершённой/упавшей — свежий проход по ключам.
row.Joined = 0; row.SearchIdx = 0;
row.Rejected = 0; row.SearchDone = false;
} row.Found = 0;
row.Evaluated = 0;
row.UpdatedAt = DateTimeOffset.UtcNow; row.Joined = 0;
await _dbContext.SaveChangesAsync(ct); row.Rejected = 0;
return true; }
}
row.UpdatedAt = DateTimeOffset.UtcNow;
/// <inheritdoc /> await _dbContext.SaveChangesAsync(ct);
public async Task<bool> SetTaskPausedAsync(string taskId, CancellationToken ct) return true;
{ }
DiscTaskEntity? row = await _dbContext.DiscTasks
.FirstOrDefaultAsync(task => task.Id == taskId, ct); /// <inheritdoc />
if (row is null) public async Task<bool> SetTaskPausedAsync(string taskId, CancellationToken ct)
{ {
return false; DiscTaskEntity? row = await _dbContext.DiscTasks
} .FirstOrDefaultAsync(task => task.Id == taskId, ct);
if (row is null)
row.Status = "paused"; {
row.UpdatedAt = DateTimeOffset.UtcNow; return false;
await _dbContext.SaveChangesAsync(ct); }
return true;
} row.Status = "paused";
row.UpdatedAt = DateTimeOffset.UtcNow;
/// <inheritdoc /> await _dbContext.SaveChangesAsync(ct);
public async Task<bool> SetTaskDoneAsync(string taskId, CancellationToken ct) return true;
{ }
DiscTaskEntity? row = await _dbContext.DiscTasks
.FirstOrDefaultAsync(task => task.Id == taskId, ct); /// <inheritdoc />
if (row is null) public async Task<bool> SetTaskDoneAsync(string taskId, CancellationToken ct)
{ {
return false; DiscTaskEntity? row = await _dbContext.DiscTasks
} .FirstOrDefaultAsync(task => task.Id == taskId, ct);
if (row is null)
// воркер _finish_done L126136: план вступлений выполнен — status=done, бюджет планов освобождается. {
row.Status = "done"; return false;
row.UpdatedAt = DateTimeOffset.UtcNow; }
await _dbContext.SaveChangesAsync(ct);
return true; // воркер _finish_done L126136: план вступлений выполнен — status=done, бюджет планов освобождается.
} row.Status = "done";
row.UpdatedAt = DateTimeOffset.UtcNow;
/// <inheritdoc /> await _dbContext.SaveChangesAsync(ct);
public async Task<bool> BumpTaskCounterAsync(string taskId, DiscoveryCounterField field, int n, CancellationToken ct) return true;
{ }
DiscTaskEntity? row = await _dbContext.DiscTasks
.FirstOrDefaultAsync(task => task.Id == taskId, ct); /// <inheritdoc />
if (row is null || n <= 0) public async Task<bool> BumpTaskCounterAsync(string taskId, DiscoveryCounterField field, int n, CancellationToken ct)
{ {
return row is not null; DiscTaskEntity? row = await _dbContext.DiscTasks
} .FirstOrDefaultAsync(task => task.Id == taskId, ct);
if (row is null || n <= 0)
// bump_counter L359368: приращение счётчика + bump UpdatedAt (python читает и пишет абсолютное значение). {
switch (field) return row is not null;
{ }
case DiscoveryCounterField.Found:
row.Found += n; // bump_counter L359368: приращение счётчика + bump UpdatedAt (python читает и пишет абсолютное значение).
break; switch (field)
case DiscoveryCounterField.Evaluated: {
row.Evaluated += n; case DiscoveryCounterField.Found:
break; row.Found += n;
case DiscoveryCounterField.Joined: break;
row.Joined += n; case DiscoveryCounterField.Evaluated:
break; row.Evaluated += n;
case DiscoveryCounterField.Rejected: break;
row.Rejected += n; case DiscoveryCounterField.Joined:
break; row.Joined += n;
default: break;
throw new ArgumentOutOfRangeException(nameof(field), field, "Неизвестный счётчик задачи"); case DiscoveryCounterField.Rejected:
} row.Rejected += n;
break;
row.UpdatedAt = DateTimeOffset.UtcNow; default:
await _dbContext.SaveChangesAsync(ct); throw new ArgumentOutOfRangeException(nameof(field), field, "Неизвестный счётчик задачи");
return true; }
}
row.UpdatedAt = DateTimeOffset.UtcNow;
/// <inheritdoc /> await _dbContext.SaveChangesAsync(ct);
public async Task<bool> AdvanceSearchAsync(string taskId, int nextIndex, bool searchDone, CancellationToken ct) return true;
{ }
DiscTaskEntity? row = await _dbContext.DiscTasks
.FirstOrDefaultAsync(task => task.Id == taskId, ct); /// <inheritdoc />
if (row is null) public async Task<bool> AdvanceSearchAsync(string taskId, int nextIndex, bool searchDone, CancellationToken ct)
{ {
return false; DiscTaskEntity? row = await _dbContext.DiscTasks
} .FirstOrDefaultAsync(task => task.Id == taskId, ct);
if (row is null)
// advance_search L371380: новый индекс и флаг завершения прохода (значения считает сервис). {
row.SearchIdx = nextIndex; return false;
row.SearchDone = searchDone; }
row.UpdatedAt = DateTimeOffset.UtcNow;
await _dbContext.SaveChangesAsync(ct); // advance_search L371380: новый индекс и флаг завершения прохода (значения считает сервис).
return true; row.SearchIdx = nextIndex;
} row.SearchDone = searchDone;
row.UpdatedAt = DateTimeOffset.UtcNow;
/// <inheritdoc /> await _dbContext.SaveChangesAsync(ct);
public async Task<int> SumActivePlanAsync(string? excludeTaskId, CancellationToken ct) return true;
{ }
IQueryable<DiscTaskEntity> query = _dbContext.DiscTasks
.Where(task => task.Status != "done" && task.Status != "failed"); /// <inheritdoc />
if (excludeTaskId is not null) public async Task<int> SumActivePlanAsync(string? excludeTaskId, CancellationToken ct)
{ {
query = query.Where(task => task.Id != excludeTaskId); IQueryable<DiscTaskEntity> query = _dbContext.DiscTasks
} .Where(task => task.Status != "done" && task.Status != "failed");
if (excludeTaskId is not null)
return await query.SumAsync(task => task.PlanJoins, ct); {
} query = query.Where(task => task.Id != excludeTaskId);
}
}
return await query.SumAsync(task => task.PlanJoins, ct);
}
}
@@ -1,219 +1,223 @@
using System.Text.Json; using System.Text.Json;
using Deal.Infrastructure.Persistence.Entities; using Deal.Infrastructure.Persistence.Entities;
using Deal.Modules.Discovery.Application; using Deal.Modules.Discovery.Application.Abstractions;
using Deal.Modules.Discovery.Application.Models; using Deal.Modules.Discovery.Application.Exceptions;
using Microsoft.EntityFrameworkCore; using Deal.Modules.Discovery.Application.Extensions;
using Deal.Modules.Discovery.Application.Models;
namespace Deal.Infrastructure.Persistence.Repositories; using Deal.Modules.Discovery.Application.Registrars;
using Deal.Modules.Discovery.Application.Services;
/// <summary> using Microsoft.EntityFrameworkCore;
/// EF-адаптер хранилища Discovery: таблицы DiscTasks/DiscCandidates/DiscBlacklist/DiscLog схемы тенанта (Ruling 9, Task 17).
/// </summary> namespace Deal.Infrastructure.Persistence.Repositories;
/// <remarks>
/// Реализация порта <see cref="IDiscoveryStore"/> на <see cref="TenantDbContext"/> (эталон TelegramStore/ /// <summary>
/// KanbanStore). Семантика 1:1 с python discovery.py/db.py: INSERT со служебными дефолтами (draft/new/счётчики 0), /// EF-адаптер хранилища Discovery: таблицы DiscTasks/DiscCandidates/DiscBlacklist/DiscLog схемы тенанта (Ruling 9, Task 17).
/// UPDATE присутствующих полей патча с bump UpdatedAt, атомарные инкременты счётчиков (bump_counter), upsert /// </summary>
/// чёрного списка (ON CONFLICT DO UPDATE name/reason при сохранённом CreatedAt), каскад delete_task /// <remarks>
/// (задача + кандидаты + лог). JSON-колонки (keywords/marks/topics) — text с сериализованным JSON camelCase; /// Реализация порта <see cref="IDiscoveryStore"/> на <see cref="TenantDbContext"/> (эталон TelegramStore/
/// времена — timestamptz (DateTimeOffset), наружу epoch-ms. Чтение Dialogs (проверка «уже мониторится») — тот же /// KanbanStore). Семантика 1:1 с python discovery.py/db.py: INSERT со служебными дефолтами (draft/new/счётчики 0),
/// TenantDbContext (владелец каталога — модуль Telegram; доступ по БД, реверс-зависимостей нет). /// UPDATE присутствующих полей патча с bump UpdatedAt, атомарные инкременты счётчиков (bump_counter), upsert
/// C32: класс разделён на partial-файлы по агрегатам (DiscoveryStore.Tasks/Candidates/Blacklist/Logs.cs); /// чёрного списка (ON CONFLICT DO UPDATE name/reason при сохранённом CreatedAt), каскад delete_task
/// маппинг DTO ↔ строк остаётся в этом файле. Поведение и сигнатуры не менялись. /// (задача + кандидаты + лог). JSON-колонки (keywords/marks/topics) — text с сериализованным JSON camelCase;
/// </remarks> /// времена — timestamptz (DateTimeOffset), наружу epoch-ms. Чтение Dialogs (проверка «уже мониторится») — тот же
public sealed partial class DiscoveryStore : IDiscoveryStore /// TenantDbContext (владелец каталога — модуль Telegram; доступ по БД, реверс-зависимостей нет).
{ /// C32: класс разделён на partial-файлы по агрегатам (DiscoveryStore.Tasks/Candidates/Blacklist/Logs.cs);
private readonly TenantDbContext _dbContext; /// маппинг DTO ↔ строк остаётся в этом файле. Поведение и сигнатуры не менялись.
/// </remarks>
/// <summary> public sealed partial class DiscoveryStore : IDiscoveryStore
/// Создаёт EF-адаптер хранилища Discovery (зависимости — tenant-контекст БД). {
/// </summary> private readonly TenantDbContext _dbContext;
/// <param name="dbContext">Scoped-контекст тенанта запроса (search_path).</param>
public DiscoveryStore(TenantDbContext dbContext) /// <summary>
{ /// Создаёт EF-адаптер хранилища Discovery (зависимости — tenant-контекст БД).
_dbContext = dbContext; /// </summary>
} /// <param name="dbContext">Scoped-контекст тенанта запроса (search_path).</param>
public DiscoveryStore(TenantDbContext dbContext)
// Опции JSON: camelCase для wire-форм Discovery (1:1 с §4.8; эталон KanbanStore JsonOptions). {
private static readonly JsonSerializerOptions JsonOptions = new() _dbContext = dbContext;
{ }
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true, // Опции JSON: camelCase для wire-форм Discovery (1:1 с §4.8; эталон KanbanStore JsonOptions).
}; private static readonly JsonSerializerOptions JsonOptions = new()
// Маппит строку задачи в DTO (task_view L116137; keywords — JSON-список, времена — epoch-ms). {
private static DiscoveryTaskDto ToTaskDto(DiscTaskEntity row) PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
{ PropertyNameCaseInsensitive = true,
return new DiscoveryTaskDto( };
row.Id, // Маппит строку задачи в DTO (task_view L116137; keywords — JSON-список, времена — epoch-ms).
row.Name, private static DiscoveryTaskDto ToTaskDto(DiscTaskEntity row)
row.Description, {
FromJson<string>(row.KeywordsJson), return new DiscoveryTaskDto(
row.MinSubscribers, row.Id,
row.Lang, row.Name,
row.Threshold, row.Description,
row.SampleSize, FromJson<string>(row.KeywordsJson),
row.PlanJoins, row.MinSubscribers,
row.AutoJoin, row.Lang,
row.Status, row.Threshold,
row.SearchIdx, row.SampleSize,
row.SearchDone, row.PlanJoins,
row.Found, row.AutoJoin,
row.Evaluated, row.Status,
row.Joined, row.SearchIdx,
row.Rejected, row.SearchDone,
row.CreatedAt.ToUnixTimeMilliseconds(), row.Found,
row.UpdatedAt.ToUnixTimeMilliseconds()); row.Evaluated,
} row.Joined,
row.Rejected,
// Маппит строку кандидата в DTO (candidate_view L140158; marks/topics — JSON-списки). row.CreatedAt.ToUnixTimeMilliseconds(),
private static DiscoveryCandidateDto ToCandidateDto(DiscCandidateEntity row) row.UpdatedAt.ToUnixTimeMilliseconds());
{ }
return new DiscoveryCandidateDto(
row.DialogId, // Маппит строку кандидата в DTO (candidate_view L140158; marks/topics — JSON-списки).
row.TaskId, private static DiscoveryCandidateDto ToCandidateDto(DiscCandidateEntity row)
row.Name, {
row.Username, return new DiscoveryCandidateDto(
row.Kind, row.DialogId,
row.Hue, row.TaskId,
row.Participants, row.Name,
row.LangRu, row.Username,
FromJson<string>(row.MarksJson), row.Kind,
FromJson<DiscoveryTopicDto>(row.TopicsJson), row.Hue,
row.FitRatio, row.Participants,
row.Status, row.LangRu,
row.AutoJoined, FromJson<string>(row.MarksJson),
row.JoinFailures, FromJson<DiscoveryTopicDto>(row.TopicsJson),
row.CreatedAt.ToUnixTimeMilliseconds(), row.FitRatio,
row.UpdatedAt.ToUnixTimeMilliseconds()); row.Status,
} row.AutoJoined,
row.JoinFailures,
// Маппит строку чёрного списка в DTO (blacklist_view L161167). row.CreatedAt.ToUnixTimeMilliseconds(),
private static DiscoveryBlacklistDto ToBlacklistDto(DiscBlacklistEntity row) row.UpdatedAt.ToUnixTimeMilliseconds());
{ }
return new DiscoveryBlacklistDto(row.DialogId, row.Name, row.Reason, row.CreatedAt.ToUnixTimeMilliseconds());
} // Маппит строку чёрного списка в DTO (blacklist_view L161167).
private static DiscoveryBlacklistDto ToBlacklistDto(DiscBlacklistEntity row)
// Маппит строку лога в DTO (log_view L170177). {
private static DiscoveryLogDto ToLogDto(DiscLogEntity row) return new DiscoveryBlacklistDto(row.DialogId, row.Name, row.Reason, row.CreatedAt.ToUnixTimeMilliseconds());
{ }
return new DiscoveryLogDto(row.Id, row.TaskId, row.Event, row.Text, row.CreatedAt.ToUnixTimeMilliseconds());
} // Маппит строку лога в DTO (log_view L170177).
private static DiscoveryLogDto ToLogDto(DiscLogEntity row)
// Применяет патч задачи к строке (patch_task L292310: только присутствующие поля; keywords — JSON). {
private static void ApplyTaskPatch(DiscTaskEntity row, DiscoveryTaskPatch patch) return new DiscoveryLogDto(row.Id, row.TaskId, row.Event, row.Text, row.CreatedAt.ToUnixTimeMilliseconds());
{ }
if (patch.Name is not null)
{ // Применяет патч задачи к строке (patch_task L292310: только присутствующие поля; keywords — JSON).
row.Name = patch.Name; private static void ApplyTaskPatch(DiscTaskEntity row, DiscoveryTaskPatch patch)
} {
if (patch.Name is not null)
if (patch.Description is not null) {
{ row.Name = patch.Name;
row.Description = patch.Description; }
}
if (patch.Description is not null)
if (patch.Keywords is not null) {
{ row.Description = patch.Description;
row.KeywordsJson = ToJson(patch.Keywords); }
}
if (patch.Keywords is not null)
if (patch.MinSubscribers is int minSubscribers) {
{ row.KeywordsJson = ToJson(patch.Keywords);
row.MinSubscribers = minSubscribers; }
}
if (patch.MinSubscribers is int minSubscribers)
if (patch.Lang is not null) {
{ row.MinSubscribers = minSubscribers;
row.Lang = patch.Lang; }
}
if (patch.Lang is not null)
if (patch.Threshold is int threshold) {
{ row.Lang = patch.Lang;
row.Threshold = threshold; }
}
if (patch.Threshold is int threshold)
if (patch.SampleSize is int sampleSize) {
{ row.Threshold = threshold;
row.SampleSize = sampleSize; }
}
if (patch.SampleSize is int sampleSize)
if (patch.PlanJoins is int planJoins) {
{ row.SampleSize = sampleSize;
row.PlanJoins = planJoins; }
}
if (patch.PlanJoins is int planJoins)
if (patch.AutoJoin is bool autoJoin) {
{ row.PlanJoins = planJoins;
row.AutoJoin = autoJoin; }
}
} if (patch.AutoJoin is bool autoJoin)
{
// Применяет патч кандидата к строке (set_candidate L468492; marks/topics — JSON-замена). row.AutoJoin = autoJoin;
private static void ApplyCandidatePatch(DiscCandidateEntity row, DiscoveryCandidatePatch patch) }
{ }
if (patch.Name is not null)
{ // Применяет патч кандидата к строке (set_candidate L468492; marks/topics — JSON-замена).
row.Name = patch.Name; private static void ApplyCandidatePatch(DiscCandidateEntity row, DiscoveryCandidatePatch patch)
} {
if (patch.Name is not null)
if (patch.Username is not null) {
{ row.Name = patch.Name;
row.Username = patch.Username; }
}
if (patch.Username is not null)
if (patch.Kind is not null) {
{ row.Username = patch.Username;
row.Kind = patch.Kind; }
}
if (patch.Kind is not null)
if (patch.Hue is not null) {
{ row.Kind = patch.Kind;
row.Hue = patch.Hue; }
}
if (patch.Hue is not null)
if (patch.Participants is int participants) {
{ row.Hue = patch.Hue;
row.Participants = participants; }
}
if (patch.Participants is int participants)
if (patch.LangRu is bool langRu) {
{ row.Participants = participants;
row.LangRu = langRu; }
}
if (patch.LangRu is bool langRu)
if (patch.Marks is not null) {
{ row.LangRu = langRu;
row.MarksJson = ToJson(patch.Marks); }
}
if (patch.Marks is not null)
if (patch.Topics is not null) {
{ row.MarksJson = ToJson(patch.Marks);
row.TopicsJson = ToJson(patch.Topics); }
}
if (patch.Topics is not null)
if (patch.FitRatio is double fitRatio) {
{ row.TopicsJson = ToJson(patch.Topics);
row.FitRatio = fitRatio; }
}
if (patch.FitRatio is double fitRatio)
if (patch.AutoJoined is bool autoJoined) {
{ row.FitRatio = fitRatio;
row.AutoJoined = autoJoined; }
}
} if (patch.AutoJoined is bool autoJoined)
{
// Разбирает JSON-массив колонки (повреждённая строка/не-массив → пустой список, python _loads L7377). row.AutoJoined = autoJoined;
private static IReadOnlyList<T> FromJson<T>(string json) }
{ }
try
{ // Разбирает JSON-массив колонки (повреждённая строка/не-массив → пустой список, python _loads L7377).
return JsonSerializer.Deserialize<List<T>>(json, JsonOptions) ?? []; private static IReadOnlyList<T> FromJson<T>(string json)
} {
catch (JsonException) try
{ {
return []; return JsonSerializer.Deserialize<List<T>>(json, JsonOptions) ?? [];
} }
} catch (JsonException)
{
// Сериализует значение в JSON (camelCase, конвенция value_json). return [];
private static string ToJson<T>(T value) => JsonSerializer.Serialize(value, JsonOptions); }
} }
// Сериализует значение в JSON (camelCase, конвенция value_json).
private static string ToJson<T>(T value) => JsonSerializer.Serialize(value, JsonOptions);
}
@@ -1,51 +1,53 @@
using Deal.Infrastructure.Persistence.Entities; using Deal.Infrastructure.Persistence.Entities;
using Deal.Modules.Settings.Application; using Deal.Modules.Settings.Application.Abstractions;
using Deal.Modules.Settings.Application.Models; using Deal.Modules.Settings.Application.Models;
using Microsoft.EntityFrameworkCore; using Deal.Modules.Settings.Application.Registrars;
using Deal.Modules.Settings.Application.Services;
namespace Deal.Infrastructure.Persistence.Repositories; using Microsoft.EntityFrameworkCore;
/// <summary> namespace Deal.Infrastructure.Persistence.Repositories;
/// EF-адаптер KV-хранилища глобальных (системных) настроек оператора: таблица public.global_settings.
/// </summary> /// <summary>
/// <remarks> /// EF-адаптер KV-хранилища глобальных (системных) настроек оператора: таблица public.global_settings.
/// Маппинг 1:1 со строкой таблицы: key/value/updated_at (сущность GlobalSettingEntity, системный /// </summary>
/// DealDbContext схемы public). Порт оперирует готовыми JSON-строками (сериализацию выполняет /// <remarks>
/// владелец ключа), поэтому адаптер хранит значение как текст без интерпретации. <see cref="SetAsync"/> /// Маппинг 1:1 со строкой таблицы: key/value/updated_at (сущность GlobalSettingEntity, системный
/// пишет updated_at = UTC-now. /// DealDbContext схемы public). Порт оперирует готовыми JSON-строками (сериализацию выполняет
/// </remarks> /// владелец ключа), поэтому адаптер хранит значение как текст без интерпретации. <see cref="SetAsync"/>
public sealed class GlobalSettingsStore(DealDbContext dbContext) : IGlobalSettingsStore /// пишет updated_at = UTC-now.
{ /// </remarks>
/// <inheritdoc /> public sealed class GlobalSettingsStore(DealDbContext dbContext) : IGlobalSettingsStore
public async Task<SettingValue?> GetAsync(string key, CancellationToken ct) {
{ /// <inheritdoc />
var entity = await dbContext.GlobalSettings public async Task<SettingValue?> GetAsync(string key, CancellationToken ct)
.AsNoTracking() {
.SingleOrDefaultAsync(s => s.Key == key, ct); var entity = await dbContext.GlobalSettings
return entity is null ? null : new SettingValue(entity.Key, entity.Value, entity.UpdatedAt); .AsNoTracking()
} .SingleOrDefaultAsync(s => s.Key == key, ct);
return entity is null ? null : new SettingValue(entity.Key, entity.Value, entity.UpdatedAt);
/// <inheritdoc /> }
public async Task SetAsync(string key, string valueJson, CancellationToken ct)
{ /// <inheritdoc />
// Upsert по ключу (PK): существующая строка обновляется, отсутствующая — добавляется. public async Task SetAsync(string key, string valueJson, CancellationToken ct)
GlobalSettingEntity? entity = await dbContext.GlobalSettings {
.SingleOrDefaultAsync(s => s.Key == key, ct); // Upsert по ключу (PK): существующая строка обновляется, отсутствующая — добавляется.
if (entity is null) GlobalSettingEntity? entity = await dbContext.GlobalSettings
{ .SingleOrDefaultAsync(s => s.Key == key, ct);
dbContext.GlobalSettings.Add(new GlobalSettingEntity if (entity is null)
{ {
Key = key, dbContext.GlobalSettings.Add(new GlobalSettingEntity
Value = valueJson, {
UpdatedAt = DateTimeOffset.UtcNow, Key = key,
}); Value = valueJson,
} UpdatedAt = DateTimeOffset.UtcNow,
else });
{ }
entity.Value = valueJson; else
entity.UpdatedAt = DateTimeOffset.UtcNow; {
} entity.Value = valueJson;
entity.UpdatedAt = DateTimeOffset.UtcNow;
await dbContext.SaveChangesAsync(ct); }
}
} await dbContext.SaveChangesAsync(ct);
}
}
@@ -1,6 +1,9 @@
using Deal.Infrastructure.Persistence.Entities; using Deal.Infrastructure.Persistence.Entities;
using Deal.Modules.Tenants.Application; using Deal.Modules.Tenants.Application.Abstractions;
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.Services;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace Deal.Infrastructure.Persistence.Repositories; namespace Deal.Infrastructure.Persistence.Repositories;
@@ -1,7 +1,10 @@
using System.Text.Json; using System.Text.Json;
using Deal.Infrastructure.Persistence.Entities; using Deal.Infrastructure.Persistence.Entities;
using Deal.Modules.Kanban.Application; using Deal.Modules.Kanban.Application.Abstractions;
using Deal.Modules.Kanban.Application.Extensions;
using Deal.Modules.Kanban.Application.Models; using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Kanban.Application.Registrars;
using Deal.Modules.Kanban.Application.Services;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace Deal.Infrastructure.Persistence.Repositories; namespace Deal.Infrastructure.Persistence.Repositories;
@@ -1,90 +1,93 @@
using System.Text.Json; using System.Text.Json;
using Deal.Infrastructure.Persistence.Entities; using Deal.Infrastructure.Persistence.Entities;
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; using Deal.Modules.Kanban.Application.Abstractions;
using Deal.Modules.Kanban.Application.Models; using Deal.Modules.Kanban.Application.Extensions;
using Microsoft.EntityFrameworkCore; using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Kanban.Application.Registrars;
namespace Deal.Infrastructure.Persistence.Repositories; using Deal.Modules.Kanban.Application.Services;
using Microsoft.EntityFrameworkCore;
/// <summary>
/// Комментарии и журнал действий — partial-часть <see cref="KanbanStore"/> (C32: выделено из общего namespace Deal.Infrastructure.Persistence.Repositories;
/// файла, поведение не менялось): LeadComments (список/добавление), CardMoves (запись/счётчик learning)
/// и few-shot-примеры разметки для ИИ (leads.py add_comment/_log_learning/_learning_examples). /// <summary>
/// </summary> /// Комментарии и журнал действий — partial-часть <see cref="KanbanStore"/> (C32: выделено из общего
public sealed partial class KanbanStore /// файла, поведение не менялось): LeadComments (список/добавление), CardMoves (запись/счётчик learning)
{ /// и few-shot-примеры разметки для ИИ (leads.py add_comment/_log_learning/_learning_examples).
/// <inheritdoc /> /// </summary>
public async Task<IReadOnlyList<CardCommentDto>> ListCommentsAsync(string cardId, CancellationToken ct) public sealed partial class KanbanStore
{ {
List<LeadCommentEntity> entities = await _dbContext.LeadComments /// <inheritdoc />
.AsNoTracking() public async Task<IReadOnlyList<CardCommentDto>> ListCommentsAsync(string cardId, CancellationToken ct)
.Where(comment => comment.CardId == cardId) {
.OrderBy(comment => comment.CreatedAt) List<LeadCommentEntity> entities = await _dbContext.LeadComments
.ThenBy(comment => comment.Id) .AsNoTracking()
.ToListAsync(ct); .Where(comment => comment.CardId == cardId)
return entities.Select(ToCommentDto).ToList(); .OrderBy(comment => comment.CreatedAt)
} .ThenBy(comment => comment.Id)
.ToListAsync(ct);
/// <inheritdoc /> return entities.Select(ToCommentDto).ToList();
public async Task AddCommentAsync(string commentId, string cardId, string by, string text, CancellationToken ct) }
{
// CreatedAt — UTC-now (leads.py add_comment L259265). Целостность ссылки на карточку держит FK: /// <inheritdoc />
// комментарий без карточки не запишется (404-семантику несуществующей карточки отдаёт сервис, public async Task AddCommentAsync(string commentId, string cardId, string by, string text, CancellationToken ct)
// прочитав карточку перед добавлением, — адаптеру возвращать нечего: порт void). {
_dbContext.LeadComments.Add(new LeadCommentEntity // CreatedAt — UTC-now (leads.py add_comment L259265). Целостность ссылки на карточку держит FK:
{ // комментарий без карточки не запишется (404-семантику несуществующей карточки отдаёт сервис,
Id = commentId, // прочитав карточку перед добавлением, — адаптеру возвращать нечего: порт void).
CardId = cardId, _dbContext.LeadComments.Add(new LeadCommentEntity
By = by, {
Text = text, Id = commentId,
CreatedAt = DateTimeOffset.UtcNow, CardId = cardId,
}); By = by,
await _dbContext.SaveChangesAsync(ct); Text = text,
} CreatedAt = DateTimeOffset.UtcNow,
});
/// <inheritdoc /> await _dbContext.SaveChangesAsync(ct);
public async Task AddMoveAsync(CardMoveDto move, CancellationToken ct) }
{
// Каждое действие пользователя пишет строку журнала (leads.py _log_learning L4044); счётчик /// <inheritdoc />
// learning = число записей CardMoves (Ruling 4). CreatedAt — UTC-now. public async Task AddMoveAsync(CardMoveDto move, CancellationToken ct)
_dbContext.CardMoves.Add(new CardMoveEntity {
{ // Каждое действие пользователя пишет строку журнала (leads.py _log_learning L4044); счётчик
Id = move.Id, // learning = число записей CardMoves (Ruling 4). CreatedAt — UTC-now.
LeadId = move.LeadId, _dbContext.CardMoves.Add(new CardMoveEntity
Action = move.Action, {
FromCol = move.FromCol, Id = move.Id,
ToCol = move.ToCol, LeadId = move.LeadId,
CreatedAt = DateTimeOffset.UtcNow, Action = move.Action,
}); FromCol = move.FromCol,
await _dbContext.SaveChangesAsync(ct); ToCol = move.ToCol,
} CreatedAt = DateTimeOffset.UtcNow,
});
/// <inheritdoc /> await _dbContext.SaveChangesAsync(ct);
public Task<int> CountMovesAsync(CancellationToken ct) => _dbContext.CardMoves.CountAsync(ct); }
/// <inheritdoc /> /// <inheritdoc />
public async Task<IReadOnlyList<AiMarkupExampleDto>> GetAiMarkupExamplesAsync(int limit, CancellationToken ct) public Task<int> CountMovesAsync(CancellationToken ct) => _dbContext.CardMoves.CountAsync(ct);
{
// Few-shot-примеры ИИ-классификации (pipeline.py _learning_examples L201215): join журнала с карточками, /// <inheritdoc />
// действия move/restore, цель не служебная (trash/archive), source_msg непустой, свежие первыми. public async Task<IReadOnlyList<AiMarkupExampleDto>> GetAiMarkupExamplesAsync(int limit, CancellationToken ct)
return await _dbContext.CardMoves {
.AsNoTracking() // Few-shot-примеры ИИ-классификации (pipeline.py _learning_examples L201215): join журнала с карточками,
.Where(move => move.ToCol != null && move.ToCol != CardIds.Trash && move.ToCol != CardIds.Archive) // действия move/restore, цель не служебная (trash/archive), source_msg непустой, свежие первыми.
.Join( return await _dbContext.CardMoves
_dbContext.Cards.AsNoTracking(), .AsNoTracking()
move => move.LeadId, .Where(move => move.ToCol != null && move.ToCol != CardIds.Trash && move.ToCol != CardIds.Archive)
card => card.Id, .Join(
(move, card) => new { move, card }) _dbContext.Cards.AsNoTracking(),
.Where(joined => (joined.move.Action == MoveAction || joined.move.Action == RestoreAction) move => move.LeadId,
&& joined.card.SourceMsg.Length > 0) card => card.Id,
.OrderByDescending(joined => joined.move.CreatedAt) (move, card) => new { move, card })
.Take(limit) .Where(joined => (joined.move.Action == MoveAction || joined.move.Action == RestoreAction)
.Select(joined => new AiMarkupExampleDto(joined.card.SourceMsg, joined.move.ToCol!)) && joined.card.SourceMsg.Length > 0)
.ToListAsync(ct); .OrderByDescending(joined => joined.move.CreatedAt)
} .Take(limit)
.Select(joined => new AiMarkupExampleDto(joined.card.SourceMsg, joined.move.ToCol!))
/// <inheritdoc /> .ToListAsync(ct);
} }
/// <inheritdoc />
}
@@ -2,8 +2,11 @@ using Deal.Infrastructure.Persistence.Entities;
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; using Deal.Modules.Kanban.Application.Abstractions;
using Deal.Modules.Kanban.Application.Extensions;
using Deal.Modules.Kanban.Application.Models; using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Kanban.Application.Registrars;
using Deal.Modules.Kanban.Application.Services;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace Deal.Infrastructure.Persistence.Repositories; namespace Deal.Infrastructure.Persistence.Repositories;
@@ -1,144 +1,147 @@
using System.Text.Json; using System.Text.Json;
using Deal.Infrastructure.Persistence.Entities; using Deal.Infrastructure.Persistence.Entities;
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; using Deal.Modules.Kanban.Application.Abstractions;
using Deal.Modules.Kanban.Application.Models; using Deal.Modules.Kanban.Application.Extensions;
using Microsoft.EntityFrameworkCore; using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Kanban.Application.Registrars;
namespace Deal.Infrastructure.Persistence.Repositories; using Deal.Modules.Kanban.Application.Services;
using Microsoft.EntityFrameworkCore;
/// <summary>
/// Правила хранения, конверсии и вход эвристики suggest — partial-часть <see cref="KanbanStore"/> namespace Deal.Infrastructure.Persistence.Repositories;
/// (C32: выделено из общего файла, поведение не менялось): кандидаты тика (автоархив/очистка архива и
/// корзины), batch-архивация и жёсткое удаление пачки, пересчёт бюджетных конверсий и «Неразобранное» /// <summary>
/// с исходным текстом для ИИ-предложений (tick_storage/recompute_conversions/suggest, Rulings 3/7/8). /// Правила хранения, конверсии и вход эвристики suggest — partial-часть <see cref="KanbanStore"/>
/// </summary> /// (C32: выделено из общего файла, поведение не менялось): кандидаты тика (автоархив/очистка архива и
public sealed partial class KanbanStore /// корзины), batch-архивация и жёсткое удаление пачки, пересчёт бюджетных конверсий и «Неразобранное»
{ /// с исходным текстом для ИИ-предложений (tick_storage/recompute_conversions/suggest, Rulings 3/7/8).
/// <inheritdoc /> /// </summary>
public async Task<IReadOnlyList<string>> ListArchiveCandidatesAsync(DateTimeOffset receivedBeforeUtc, CancellationToken ct) public sealed partial class KanbanStore
{ {
// Кандидаты автоархива: карточки пользовательских колонок (kind=board) и «Неразобранного» /// <inheritdoc />
// (tick_storage L462467) — колонки динамические, поэтому id колонок читаем реестром контейнеров. public async Task<IReadOnlyList<string>> ListArchiveCandidatesAsync(DateTimeOffset receivedBeforeUtc, CancellationToken ct)
List<string> boardIds = await _dbContext.Containers {
.AsNoTracking() // Кандидаты автоархива: карточки пользовательских колонок (kind=board) и «Неразобранного»
.Where(container => container.Kind == ContainerKinds.Board) // (tick_storage L462467) — колонки динамические, поэтому id колонок читаем реестром контейнеров.
.Select(container => container.Id) List<string> boardIds = await _dbContext.Containers
.ToListAsync(ct); .AsNoTracking()
.Where(container => container.Kind == ContainerKinds.Board)
return await _dbContext.Cards .Select(container => container.Id)
.AsNoTracking() .ToListAsync(ct);
.Where(card => card.ReceivedAt < receivedBeforeUtc
&& (card.Col == CardIds.Inbox || boardIds.Contains(card.Col))) return await _dbContext.Cards
.Select(card => card.Id) .AsNoTracking()
.ToListAsync(ct); .Where(card => card.ReceivedAt < receivedBeforeUtc
} && (card.Col == CardIds.Inbox || boardIds.Contains(card.Col)))
.Select(card => card.Id)
/// <inheritdoc /> .ToListAsync(ct);
public async Task<int> ArchiveAsync(IReadOnlyList<string> cardIds, DateTimeOffset archivedAt, CancellationToken ct) }
{
// Автоархив тика пачкой (tick_storage L462473): один UPDATE вместо N по-карточных — как по-карточный /// <inheritdoc />
// UpdateColumnAsync автоархива: col=archive, is_new=false, archived_at=now, matchHits пусто; public async Task<int> ArchiveAsync(IReadOnlyList<string> cardIds, DateTimeOffset archivedAt, CancellationToken ct)
// prev_col не трогается (Ruling 8). Возврат — сколько строк обновлено. {
if (cardIds.Count == 0) // Автоархив тика пачкой (tick_storage L462473): один UPDATE вместо N по-карточных — как по-карточный
{ // UpdateColumnAsync автоархива: col=archive, is_new=false, archived_at=now, matchHits пусто;
return 0; // prev_col не трогается (Ruling 8). Возврат — сколько строк обновлено.
} if (cardIds.Count == 0)
{
return await _dbContext.Cards return 0;
.Where(card => cardIds.Contains(card.Id)) }
.ExecuteUpdateAsync(setters => setters
.SetProperty(card => card.Col, CardIds.Archive) return await _dbContext.Cards
.SetProperty(card => card.IsNew, false) .Where(card => cardIds.Contains(card.Id))
.SetProperty(card => card.ArchivedAt, archivedAt) .ExecuteUpdateAsync(setters => setters
.SetProperty(card => card.MatchHitsJson, ToJson(Array.Empty<MatchHitDto>())), .SetProperty(card => card.Col, CardIds.Archive)
ct); .SetProperty(card => card.IsNew, false)
} .SetProperty(card => card.ArchivedAt, archivedAt)
.SetProperty(card => card.MatchHitsJson, ToJson(Array.Empty<MatchHitDto>())),
/// <inheritdoc /> ct);
public async Task<IReadOnlyList<string>> ListExpiredArchiveCandidatesAsync(DateTimeOffset archivedBeforeUtc, CancellationToken ct) }
{
// Очистка архива: col='archive' и archived_at старше срока (tick_storage L475478). /// <inheritdoc />
return await _dbContext.Cards public async Task<IReadOnlyList<string>> ListExpiredArchiveCandidatesAsync(DateTimeOffset archivedBeforeUtc, CancellationToken ct)
.AsNoTracking() {
.Where(card => card.Col == CardIds.Archive // Очистка архива: col='archive' и archived_at старше срока (tick_storage L475478).
&& card.ArchivedAt != null return await _dbContext.Cards
&& card.ArchivedAt < archivedBeforeUtc) .AsNoTracking()
.Select(card => card.Id) .Where(card => card.Col == CardIds.Archive
.ToListAsync(ct); && card.ArchivedAt != null
} && card.ArchivedAt < archivedBeforeUtc)
.Select(card => card.Id)
/// <inheritdoc /> .ToListAsync(ct);
public async Task<IReadOnlyList<string>> ListTrashCandidatesAsync(DateTimeOffset receivedBeforeUtc, CancellationToken ct) }
{
// Очистка корзины: col='trash' и received_at старше срока (tick_storage L480483). /// <inheritdoc />
return await _dbContext.Cards public async Task<IReadOnlyList<string>> ListTrashCandidatesAsync(DateTimeOffset receivedBeforeUtc, CancellationToken ct)
.AsNoTracking() {
.Where(card => card.Col == CardIds.Trash && card.ReceivedAt < receivedBeforeUtc) // Очистка корзины: col='trash' и received_at старше срока (tick_storage L480483).
.Select(card => card.Id) return await _dbContext.Cards
.ToListAsync(ct); .AsNoTracking()
} .Where(card => card.Col == CardIds.Trash && card.ReceivedAt < receivedBeforeUtc)
.Select(card => card.Id)
/// <inheritdoc /> .ToListAsync(ct);
public async Task<int> PurgeAsync(IReadOnlyList<string> cardIds, CancellationToken ct) }
{
// Жёсткое удаление пачки (очистки тика и clear-col, Ruling 8): комментарии чистит каскад БД, /// <inheritdoc />
// строки дедупа удаляемых карточек — здесь же в транзакции (Ruling 3, _hard_delete L229). public async Task<int> PurgeAsync(IReadOnlyList<string> cardIds, CancellationToken ct)
if (cardIds.Count == 0) {
{ // Жёсткое удаление пачки (очистки тика и clear-col, Ruling 8): комментарии чистит каскад БД,
return 0; // строки дедупа удаляемых карточек — здесь же в транзакции (Ruling 3, _hard_delete L229).
} if (cardIds.Count == 0)
{
await using var transaction = await _dbContext.Database.BeginTransactionAsync(ct); return 0;
await _dbContext.DedupEntries }
.Where(entry => entry.LeadId != null && cardIds.Contains(entry.LeadId!))
.ExecuteDeleteAsync(ct); await using var transaction = await _dbContext.Database.BeginTransactionAsync(ct);
int deleted = await _dbContext.Cards await _dbContext.DedupEntries
.Where(card => cardIds.Contains(card.Id)) .Where(entry => entry.LeadId != null && cardIds.Contains(entry.LeadId!))
.ExecuteDeleteAsync(ct); .ExecuteDeleteAsync(ct);
await transaction.CommitAsync(ct); int deleted = await _dbContext.Cards
return deleted; .Where(card => cardIds.Contains(card.Id))
} .ExecuteDeleteAsync(ct);
await transaction.CommitAsync(ct);
/// <inheritdoc /> return deleted;
public async Task<IReadOnlyList<CardDto>> ListCardsForConversionAsync(CancellationToken ct) }
{
// Карточки с бюджетом вне служебных колонок (recompute_conversions L106130, Ruling 7): /// <inheritdoc />
// пересчёт читает только бюджетные/conv-поля, комментарии не нужны. public async Task<IReadOnlyList<CardDto>> ListCardsForConversionAsync(CancellationToken ct)
List<CardEntity> entities = await _dbContext.Cards {
.AsNoTracking() // Карточки с бюджетом вне служебных колонок (recompute_conversions L106130, Ruling 7):
.Where(card => card.BudgetCur != string.Empty // пересчёт читает только бюджетные/conv-поля, комментарии не нужны.
&& !ConversionExcludedCols.Contains(card.Col)) List<CardEntity> entities = await _dbContext.Cards
.OrderByDescending(card => card.ReceivedAt) .AsNoTracking()
.ToListAsync(ct); .Where(card => card.BudgetCur != string.Empty
return entities.Select(card => ToCardDto(card, Array.Empty<CardCommentDto>())).ToList(); && !ConversionExcludedCols.Contains(card.Col))
} .OrderByDescending(card => card.ReceivedAt)
.ToListAsync(ct);
/// <inheritdoc /> return entities.Select(card => ToCardDto(card, Array.Empty<CardCommentDto>())).ToList();
public async Task UpdateConversionAsync(string cardId, double? convFrom, double? convTo, string convCur, CancellationToken ct) }
{
// Пишутся только conv-поля карточки (Ruling 7); convCur пуст — конверсия снята. /// <inheritdoc />
await _dbContext.Cards public async Task UpdateConversionAsync(string cardId, double? convFrom, double? convTo, string convCur, CancellationToken ct)
.Where(card => card.Id == cardId) {
.ExecuteUpdateAsync(setters => setters // Пишутся только conv-поля карточки (Ruling 7); convCur пуст — конверсия снята.
.SetProperty(card => card.ConvFrom, convFrom) await _dbContext.Cards
.SetProperty(card => card.ConvTo, convTo) .Where(card => card.Id == cardId)
.SetProperty(card => card.ConvCur, convCur), .ExecuteUpdateAsync(setters => setters
ct); .SetProperty(card => card.ConvFrom, convFrom)
} .SetProperty(card => card.ConvTo, convTo)
.SetProperty(card => card.ConvCur, convCur),
/// <inheritdoc /> ct);
public async Task<IReadOnlyList<CardDto>> ListInboxWithSourceAsync(CancellationToken ct) }
{
// Вход эвристики suggest (Ruling 3): «Неразобранное» с непустым source_msg — только тексты нужны. /// <inheritdoc />
List<CardEntity> entities = await _dbContext.Cards public async Task<IReadOnlyList<CardDto>> ListInboxWithSourceAsync(CancellationToken ct)
.AsNoTracking() {
.Where(card => card.Col == CardIds.Inbox && card.SourceMsg != string.Empty) // Вход эвристики suggest (Ruling 3): «Неразобранное» с непустым source_msg — только тексты нужны.
.OrderByDescending(card => card.ReceivedAt) List<CardEntity> entities = await _dbContext.Cards
.ToListAsync(ct); .AsNoTracking()
return entities.Select(card => ToCardDto(card, Array.Empty<CardCommentDto>())).ToList(); .Where(card => card.Col == CardIds.Inbox && card.SourceMsg != string.Empty)
} .OrderByDescending(card => card.ReceivedAt)
.ToListAsync(ct);
} return entities.Select(card => ToCardDto(card, Array.Empty<CardCommentDto>())).ToList();
}
}
@@ -1,320 +1,323 @@
using System.Text.Json; using System.Text.Json;
using Deal.Infrastructure.Persistence.Entities; using Deal.Infrastructure.Persistence.Entities;
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; using Deal.Modules.Kanban.Application.Abstractions;
using Deal.Modules.Kanban.Application.Models; using Deal.Modules.Kanban.Application.Extensions;
using Microsoft.EntityFrameworkCore; using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Kanban.Application.Registrars;
namespace Deal.Infrastructure.Persistence.Repositories; using Deal.Modules.Kanban.Application.Services;
using Microsoft.EntityFrameworkCore;
/// <summary>
/// EF-адаптер хранилища карточек и контейнеров (Ruling 1/12): таблицы Cards/Containers/LeadComments/CardMoves схемы тенанта. namespace Deal.Infrastructure.Persistence.Repositories;
/// </summary>
/// <remarks> /// <summary>
/// Маппинг DTO ↔ строк выполняется вручную (эталон SettingsStore.cs): порт модуля не видит EF-сущности. /// EF-адаптер хранилища карточек и контейнеров (Ruling 1/12): таблицы Cards/Containers/LeadComments/CardMoves схемы тенанта.
/// JSON-поля (KeywordsJson/VisibleFieldsJson/RulesJson, StackJson/ContactsJson/MatchHitsJson) хранятся текстом /// </summary>
/// с сериализованным JSON camelCase (конвенция value_json) — адаптер сериализует при записи и разбирает при чтении. /// <remarks>
/// Времена — timestamptz (DateTimeOffset); наружу карточки отдают receivedAt epoch-ms (ToUnixTimeMilliseconds), /// Маппинг DTO ↔ строк выполняется вручную (эталон SettingsStore.cs): порт модуля не видит EF-сущности.
/// а human-метку «time» адаптер считает на лету от ReceivedAt/CreatedAt (Ruling 10, pipeline.py human_age L528537). /// JSON-поля (KeywordsJson/VisibleFieldsJson/RulesJson, StackJson/ContactsJson/MatchHitsJson) хранятся текстом
/// Чтения — AsNoTracking; сортировки (received_at DESC и т.п.) — здесь, как требует порт. Id записей генерирует /// с сериализованным JSON camelCase (конвенция value_json) — адаптер сериализует при записи и разбирает при чтении.
/// модуль (Ruling 12) — адаптер только сохраняет готовые id. Транзакции: одиночная запись — SaveChanges /// Времена — timestamptz (DateTimeOffset); наружу карточки отдают receivedAt epoch-ms (ToUnixTimeMilliseconds),
/// (ExecuteUpdate/ExecuteDelete — одним statement'ом); методы с несколькими изменениями (DeleteContainerAsync: /// а human-метку «time» адаптер считает на лету от ReceivedAt/CreatedAt (Ruling 10, pipeline.py human_age L528537).
/// карточки + доска; ReorderContainersAsync: позиции всех досок) обёрнуты в явную транзакцию. /// Чтения — AsNoTracking; сортировки (received_at DESC и т.п.) — здесь, как требует порт. Id записей генерирует
/// Удаление карточки чистит комментарии каскадом БД (FK LeadComments → Cards, Ruling 1); журнал CardMoves /// модуль (Ruling 12) — адаптер только сохраняет готовые id. Транзакции: одиночная запись — SaveChanges
/// и MlOutbox при удалении карточек не трогаются (прототип _hard_delete). Строки DedupEntries карточки /// (ExecuteUpdate/ExecuteDelete — одним statement'ом); методы с несколькими изменениями (DeleteContainerAsync:
/// (LeadId = id карточки) удаляются вместе с ней в той же транзакции (Ruling 3) — «сирота» не должна /// карточки + доска; ReorderContainersAsync: позиции всех досок) обёрнуты в явную транзакцию.
/// блокировать повторное создание карточки; таблицы соседние в том же TenantDbContext, порт Pipeline /// Удаление карточки чистит комментарии каскадом БД (FK LeadComments → Cards, Ruling 1); журнал CardMoves
/// не привлекается (циклов модулей нет). /// и MlOutbox при удалении карточек не трогаются (прототип _hard_delete). Строки DedupEntries карточки
/// C32: класс разделён на partial-файлы по темам (KanbanStore.Containers/Cards/Selected/Comments/StorageRules.cs); /// (LeadId = id карточки) удаляются вместе с ней в той же транзакции (Ruling 3) — «сирота» не должна
/// поведение, сигнатуры и тексты ошибок не менялись. /// блокировать повторное создание карточки; таблицы соседние в том же TenantDbContext, порт Pipeline
/// </remarks> /// не привлекается (циклов модулей нет).
public sealed partial class KanbanStore : ICardStore /// C32: класс разделён на partial-файлы по темам (KanbanStore.Containers/Cards/Selected/Comments/StorageRules.cs);
{ /// поведение, сигнатуры и тексты ошибок не менялись.
private readonly TenantDbContext _dbContext; /// </remarks>
public sealed partial class KanbanStore : ICardStore
/// <summary> {
/// Создаёт EF-адаптер хранилища канбана (зависимости — tenant-контекст БД). private readonly TenantDbContext _dbContext;
/// </summary>
/// <param name="dbContext">Контекст схемы тенанта (Containers/Cards/LeadComments/CardMoves/DedupEntries).</param> /// <summary>
public KanbanStore(TenantDbContext dbContext) /// Создаёт EF-адаптер хранилища канбана (зависимости — tenant-контекст БД).
{ /// </summary>
_dbContext = dbContext; /// <param name="dbContext">Контекст схемы тенанта (Containers/Cards/LeadComments/CardMoves/DedupEntries).</param>
} public KanbanStore(TenantDbContext dbContext)
{
// JSON-представление «правил нет» (в DTO — null Rules, Ruling 1: как value_json настроек). _dbContext = dbContext;
private const string NoRulesJson = "{}"; }
// Действие журнала CardMoves «перенос» (источник few-shot-примеров, python move). // JSON-представление «правил нет» (в DTO — null Rules, Ruling 1: как value_json настроек).
private const string MoveAction = "move"; private const string NoRulesJson = "{}";
// Действие журнала CardMoves «возврат из корзины/архива» (python restore). // Действие журнала CardMoves «перенос» (источник few-shot-примеров, python move).
private const string RestoreAction = "restore"; private const string MoveAction = "move";
private const long MillisPerMinute = 60_000L; // Действие журнала CardMoves «возврат из корзины/архива» (python restore).
private const long MinutesPerHour = 60L; private const string RestoreAction = "restore";
private const long HoursPerDay = 24L;
private const long MillisPerMinute = 60_000L;
// Колонки, исключённые из пересчёта конверсий (recompute_conversions L106130, Ruling 7). private const long MinutesPerHour = 60L;
private static readonly string[] ConversionExcludedCols = private const long HoursPerDay = 24L;
[CardIds.Archive, CardIds.Trash];
// Колонки, исключённые из пересчёта конверсий (recompute_conversions L106130, Ruling 7).
// Id контейнеров-стадий пространства «Выбранные»: дашборд эти карточки не показывает (этап 9). private static readonly string[] ConversionExcludedCols =
private static readonly string[] SelectedStageIds = CardsDefaultContainers.Ids.ToArray(); [CardIds.Archive, CardIds.Trash];
// Стадия «Отложено» — единственная, чьи напоминания проверяет проверка напоминаний (Ruling 3). // Id контейнеров-стадий пространства «Выбранные»: дашборд эти карточки не показывает (этап 9).
private const string HoldStage = CardsDefaultContainers.Hold; private static readonly string[] SelectedStageIds = CardsDefaultContainers.Ids.ToArray();
// Опции JSON: camelCase для записей модуля Kanban (1:1 с wire-именами §4.1/§4.2). // Стадия «Отложено» — единственная, чьи напоминания проверяет проверка напоминаний (Ruling 3).
private static readonly JsonSerializerOptions JsonOptions = new() private const string HoldStage = CardsDefaultContainers.Hold;
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase, // Опции JSON: camelCase для записей модуля Kanban (1:1 с wire-именами §4.1/§4.2).
PropertyNameCaseInsensitive = true, private static readonly JsonSerializerOptions JsonOptions = new()
}; {
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
// Читает карточки пачкой с приложенными комментариями (один запрос на все id). PropertyNameCaseInsensitive = true,
// cards: Карточки (уже отсортированы запросом чтения). };
// ct: Токен отмены.
// Возвращает: DTO карточек в порядке входа: JSON-поля разобраны, комментарии приложены, time посчитан. // Читает карточки пачкой с приложенными комментариями (один запрос на все id).
private async Task<IReadOnlyList<CardDto>> ToCardDtosAsync(List<CardEntity> cards, CancellationToken ct) // cards: Карточки (уже отсортированы запросом чтения).
{ // ct: Токен отмены.
if (cards.Count == 0) // Возвращает: DTO карточек в порядке входа: JSON-поля разобраны, комментарии приложены, time посчитан.
{ private async Task<IReadOnlyList<CardDto>> ToCardDtosAsync(List<CardEntity> cards, CancellationToken ct)
return Array.Empty<CardDto>(); {
} if (cards.Count == 0)
{
List<string> cardIds = cards.Select(card => card.Id).ToList(); return Array.Empty<CardDto>();
List<LeadCommentEntity> comments = await _dbContext.LeadComments }
.AsNoTracking()
.Where(comment => cardIds.Contains(comment.CardId)) List<string> cardIds = cards.Select(card => card.Id).ToList();
.OrderBy(comment => comment.CreatedAt) List<LeadCommentEntity> comments = await _dbContext.LeadComments
.ThenBy(comment => comment.Id) .AsNoTracking()
.ToListAsync(ct); .Where(comment => cardIds.Contains(comment.CardId))
ILookup<string, LeadCommentEntity> commentsByCardId = comments.ToLookup(comment => comment.CardId); .OrderBy(comment => comment.CreatedAt)
.ThenBy(comment => comment.Id)
return cards .ToListAsync(ct);
.Select(card => ToCardDto(card, commentsByCardId[card.Id].Select(ToCommentDto).ToList())) ILookup<string, LeadCommentEntity> commentsByCardId = comments.ToLookup(comment => comment.CardId);
.ToList();
} return cards
.Select(card => ToCardDto(card, commentsByCardId[card.Id].Select(ToCommentDto).ToList()))
// Маппинг строки Containers в DTO (JSON-поля разбираются). .ToList();
private static ContainerDto ToContainerDto(ContainerEntity entity) => new() }
{
Id = entity.Id, // Маппинг строки Containers в DTO (JSON-поля разбираются).
Name = entity.Name, private static ContainerDto ToContainerDto(ContainerEntity entity) => new()
Description = entity.Description, {
Color = entity.Color, Id = entity.Id,
Order = entity.Position, Name = entity.Name,
Space = entity.Space, Description = entity.Description,
Kind = entity.Kind, Color = entity.Color,
Collapsed = entity.Collapsed, Order = entity.Position,
Suggested = entity.Suggested, Space = entity.Space,
Rules = ToRulesOrNull(entity.RulesJson), Kind = entity.Kind,
Policy = ToPolicyOrEmpty(entity.PolicyJson), Collapsed = entity.Collapsed,
Note = entity.Note, Suggested = entity.Suggested,
}; Rules = ToRulesOrNull(entity.RulesJson),
Policy = ToPolicyOrEmpty(entity.PolicyJson),
// Маппинг DTO в строку Containers (JSON-поля сериализуются; CreatedAt — UTC-now). Note = entity.Note,
private static ContainerEntity ToContainerEntity(ContainerDto container) => new() };
{
Id = container.Id, // Маппинг DTO в строку Containers (JSON-поля сериализуются; CreatedAt — UTC-now).
Name = container.Name, private static ContainerEntity ToContainerEntity(ContainerDto container) => new()
Description = container.Description, {
Color = container.Color, Id = container.Id,
Position = container.Order, Name = container.Name,
Space = container.Space, Description = container.Description,
Kind = container.Kind, Color = container.Color,
Collapsed = container.Collapsed, Position = container.Order,
Suggested = container.Suggested, Space = container.Space,
RulesJson = ToRulesJson(container.Rules), Kind = container.Kind,
PolicyJson = ToPolicyJson(container.Policy), Collapsed = container.Collapsed,
Note = container.Note, Suggested = container.Suggested,
CreatedAt = DateTimeOffset.UtcNow, RulesJson = ToRulesJson(container.Rules),
}; PolicyJson = ToPolicyJson(container.Policy),
Note = container.Note,
// Маппинг строки Cards в DTO: JSON-поля разобраны, budget/converted собраны из пар (from,to,cur). CreatedAt = DateTimeOffset.UtcNow,
// entity: Строка карточки. };
// comments: Комментарии карточки (уже в DTO с human-меткой time).
private static CardDto ToCardDto(CardEntity entity, IReadOnlyList<CardCommentDto> comments) => new() // Маппинг строки Cards в DTO: JSON-поля разобраны, budget/converted собраны из пар (from,to,cur).
{ // entity: Строка карточки.
Id = entity.Id, // comments: Комментарии карточки (уже в DTO с human-меткой time).
Col = entity.Col, private static CardDto ToCardDto(CardEntity entity, IReadOnlyList<CardCommentDto> comments) => new()
IsNew = entity.IsNew, {
Local = entity.Local, Id = entity.Id,
IsVacancy = entity.IsVacancy, Col = entity.Col,
IsVacancyKnown = entity.IsVacancyKnown, IsNew = entity.IsNew,
Title = entity.Title, Local = entity.Local,
Summary = entity.Summary, IsVacancy = entity.IsVacancy,
Source = new CardSourceDto( IsVacancyKnown = entity.IsVacancyKnown,
Kind: ResolveSourceKind(entity), Title = entity.Title,
DisplayName: entity.ChannelName, Summary = entity.Summary,
OriginRef: entity.SourceDialogId, Source = new CardSourceDto(
ReceivedAt: entity.ReceivedAt.ToUnixTimeMilliseconds()), Kind: ResolveSourceKind(entity),
Stack = ToJsonList<string>(entity.StackJson), DisplayName: entity.ChannelName,
Budget = entity.BudgetCur.Length == 0 OriginRef: entity.SourceDialogId,
? null ReceivedAt: entity.ReceivedAt.ToUnixTimeMilliseconds()),
: new CardBudgetDto(entity.BudgetFrom, entity.BudgetTo, entity.BudgetCur), Stack = ToJsonList<string>(entity.StackJson),
Converted = entity.ConvCur.Length == 0 Budget = entity.BudgetCur.Length == 0
? null ? null
: new CardBudgetDto(entity.ConvFrom, entity.ConvTo, entity.ConvCur), : new CardBudgetDto(entity.BudgetFrom, entity.BudgetTo, entity.BudgetCur),
Contact = entity.Contact, Converted = entity.ConvCur.Length == 0
Contacts = ToJsonList<CardContactDto>(entity.ContactsJson), ? null
Channel = new CardChannelDto(entity.ChannelName, entity.ChannelHandle, entity.ChannelHue), : new CardBudgetDto(entity.ConvFrom, entity.ConvTo, entity.ConvCur),
Time = HumanAge(entity.ReceivedAt), Contact = entity.Contact,
ReceivedAtMs = entity.ReceivedAt.ToUnixTimeMilliseconds(), Contacts = ToJsonList<CardContactDto>(entity.ContactsJson),
SourceMsg = entity.SourceMsg, Channel = new CardChannelDto(entity.ChannelName, entity.ChannelHandle, entity.ChannelHue),
SourceDialogId = entity.SourceDialogId, Time = HumanAge(entity.ReceivedAt),
SourceMsgId = entity.SourceMsgId, ReceivedAtMs = entity.ReceivedAt.ToUnixTimeMilliseconds(),
PrevCol = entity.PrevCol, SourceMsg = entity.SourceMsg,
MatchHits = ToJsonList<MatchHitDto>(entity.MatchHitsJson), SourceDialogId = entity.SourceDialogId,
Comments = comments, SourceMsgId = entity.SourceMsgId,
Links = ToJsonList<CardLinkDto>(entity.LinksJson), PrevCol = entity.PrevCol,
Files = ToJsonList<CardFileDto>(entity.FilesJson), MatchHits = ToJsonList<MatchHitDto>(entity.MatchHitsJson),
History = ToJsonList<CardHistoryDto>(entity.HistoryJson), Comments = comments,
TzText = entity.TzText, Links = ToJsonList<CardLinkDto>(entity.LinksJson),
Reminder = entity.ReminderAt is { } reminderAt ? new CardReminderDto(reminderAt.ToUnixTimeMilliseconds()) : null, Files = ToJsonList<CardFileDto>(entity.FilesJson),
CreatedAtMs = entity.CreatedAt.ToUnixTimeMilliseconds(), History = ToJsonList<CardHistoryDto>(entity.HistoryJson),
UpdatedAtMs = entity.UpdatedAt.ToUnixTimeMilliseconds(), TzText = entity.TzText,
}; Reminder = entity.ReminderAt is { } reminderAt ? new CardReminderDto(reminderAt.ToUnixTimeMilliseconds()) : null,
CreatedAtMs = entity.CreatedAt.ToUnixTimeMilliseconds(),
// Вид источника карточки (производная проекция): local/telegram/other. UpdatedAtMs = entity.UpdatedAt.ToUnixTimeMilliseconds(),
// entity: Строка карточки. };
// Возвращает: Вид источника (строка wire-контракта).
private static string ResolveSourceKind(CardEntity entity) // Вид источника карточки (производная проекция): local/telegram/other.
{ // entity: Строка карточки.
if (entity.Local) // Возвращает: Вид источника (строка wire-контракта).
{ private static string ResolveSourceKind(CardEntity entity)
return "local"; {
} if (entity.Local)
{
return entity.ChannelName.Length > 0 || entity.SourceDialogId.Length > 0 ? "telegram" : "other"; return "local";
} }
// Маппинг снимка (write-модель создания карточки) в строку Cards; CreatedAt — UTC-now. return entity.ChannelName.Length > 0 || entity.SourceDialogId.Length > 0 ? "telegram" : "other";
private static CardEntity ToCardEntity(CardSnapshot snapshot) => new() }
{
Id = snapshot.Id, // Маппинг снимка (write-модель создания карточки) в строку Cards; CreatedAt — UTC-now.
Col = snapshot.Col, private static CardEntity ToCardEntity(CardSnapshot snapshot) => new()
IsNew = snapshot.IsNew, {
Local = snapshot.Local, Id = snapshot.Id,
IsVacancy = snapshot.IsVacancy, Col = snapshot.Col,
IsVacancyKnown = snapshot.IsVacancyKnown, IsNew = snapshot.IsNew,
Title = snapshot.Title, Local = snapshot.Local,
Summary = snapshot.Summary, IsVacancy = snapshot.IsVacancy,
StackJson = ToJson(snapshot.Stack), IsVacancyKnown = snapshot.IsVacancyKnown,
BudgetFrom = snapshot.BudgetFrom, Title = snapshot.Title,
BudgetTo = snapshot.BudgetTo, Summary = snapshot.Summary,
BudgetCur = snapshot.BudgetCur, StackJson = ToJson(snapshot.Stack),
ConvFrom = snapshot.ConvFrom, BudgetFrom = snapshot.BudgetFrom,
ConvTo = snapshot.ConvTo, BudgetTo = snapshot.BudgetTo,
ConvCur = snapshot.ConvCur, BudgetCur = snapshot.BudgetCur,
Contact = snapshot.Contact, ConvFrom = snapshot.ConvFrom,
ContactsJson = ToJson(snapshot.Contacts), ConvTo = snapshot.ConvTo,
ChannelName = snapshot.ChannelName, ConvCur = snapshot.ConvCur,
ChannelHandle = snapshot.ChannelHandle, Contact = snapshot.Contact,
ChannelHue = snapshot.ChannelHue, ContactsJson = ToJson(snapshot.Contacts),
ReceivedAt = snapshot.ReceivedAt, ChannelName = snapshot.ChannelName,
SourceMsg = snapshot.SourceMsg, ChannelHandle = snapshot.ChannelHandle,
SourceDialogId = snapshot.SourceDialogId, ChannelHue = snapshot.ChannelHue,
SourceMsgId = snapshot.SourceMsgId, ReceivedAt = snapshot.ReceivedAt,
PrevCol = snapshot.PrevCol, SourceMsg = snapshot.SourceMsg,
ArchivedAt = snapshot.ArchivedAt, SourceDialogId = snapshot.SourceDialogId,
MatchHitsJson = ToJson(snapshot.MatchHits), SourceMsgId = snapshot.SourceMsgId,
TzText = snapshot.TzText, PrevCol = snapshot.PrevCol,
HistoryJson = ToJson(snapshot.History), ArchivedAt = snapshot.ArchivedAt,
CreatedAt = DateTimeOffset.UtcNow, MatchHitsJson = ToJson(snapshot.MatchHits),
UpdatedAt = DateTimeOffset.UtcNow, TzText = snapshot.TzText,
}; HistoryJson = ToJson(snapshot.History),
CreatedAt = DateTimeOffset.UtcNow,
// Маппинг строки LeadComments в DTO (human-метка time от CreatedAt, Ruling 10). UpdatedAt = DateTimeOffset.UtcNow,
private static CardCommentDto ToCommentDto(LeadCommentEntity entity) => };
new(entity.Id, entity.By, entity.Text, HumanAge(entity.CreatedAt));
// Маппинг строки LeadComments в DTO (human-метка time от CreatedAt, Ruling 10).
// Человеческая метка возраста: «только что»/«N мин»/«N ч»/«N дн» (Ruling 10, pipeline.py human_age L528537). private static CardCommentDto ToCommentDto(LeadCommentEntity entity) =>
// timestamp: Время события (получения сообщения/добавления комментария). new(entity.Id, entity.By, entity.Text, HumanAge(entity.CreatedAt));
// Возвращает: Метка от текущего UTC-момента; будущее время даёт «только что» (delta зажат в 0, как в прототипе).
private static string HumanAge(DateTimeOffset timestamp) // Человеческая метка возраста: «только что»/«N мин»/«N ч»/«N дн» (Ruling 10, pipeline.py human_age L528537).
{ // timestamp: Время события (получения сообщения/добавления комментария).
long deltaMs = Math.Max(0, DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() - timestamp.ToUnixTimeMilliseconds()); // Возвращает: Метка от текущего UTC-момента; будущее время даёт «только что» (delta зажат в 0, как в прототипе).
long minutes = deltaMs / MillisPerMinute; private static string HumanAge(DateTimeOffset timestamp)
if (minutes < MinutesPerHour) {
{ long deltaMs = Math.Max(0, DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() - timestamp.ToUnixTimeMilliseconds());
return minutes < 1 ? CardsService.JustNowLabel : $"{minutes} мин"; long minutes = deltaMs / MillisPerMinute;
} if (minutes < MinutesPerHour)
{
long hours = minutes / MinutesPerHour; return minutes < 1 ? CardsService.JustNowLabel : $"{minutes} мин";
if (hours < HoursPerDay) }
{
return $"{hours} ч"; long hours = minutes / MinutesPerHour;
} if (hours < HoursPerDay)
{
long days = hours / HoursPerDay; return $"{hours} ч";
return $"{days} дн"; }
}
long days = hours / HoursPerDay;
// Разбирает JSON-массив в список; пустая/битая строка — пустой список (как json.loads в прототипе). return $"{days} дн";
private static IReadOnlyList<T> ToJsonList<T>(string json) }
{
if (string.IsNullOrWhiteSpace(json)) // Разбирает JSON-массив в список; пустая/битая строка — пустой список (как json.loads в прототипе).
{ private static IReadOnlyList<T> ToJsonList<T>(string json)
return Array.Empty<T>(); {
} if (string.IsNullOrWhiteSpace(json))
{
try return Array.Empty<T>();
{ }
return JsonSerializer.Deserialize<List<T>>(json, JsonOptions) ?? [];
} try
catch (JsonException) {
{ return JsonSerializer.Deserialize<List<T>>(json, JsonOptions) ?? [];
return Array.Empty<T>(); }
} catch (JsonException)
} {
return Array.Empty<T>();
// Разбирает rules-объект: {}/пустая/битая строка — null («правил нет»). }
private static ContainerRulesDto? ToRulesOrNull(string json) }
{
if (string.IsNullOrWhiteSpace(json) || json == NoRulesJson) // Разбирает rules-объект: {}/пустая/битая строка — null («правил нет»).
{ private static ContainerRulesDto? ToRulesOrNull(string json)
return null; {
} if (string.IsNullOrWhiteSpace(json) || json == NoRulesJson)
{
try return null;
{ }
return JsonSerializer.Deserialize<ContainerRulesDto>(json, JsonOptions);
} try
catch (JsonException) {
{ return JsonSerializer.Deserialize<ContainerRulesDto>(json, JsonOptions);
return null; }
} catch (JsonException)
} {
return null;
// Сериализует rules-объект: null («правил нет») хранится как {}. }
private static string ToRulesJson(ContainerRulesDto? rules) => }
rules is null ? NoRulesJson : JsonSerializer.Serialize(rules, JsonOptions);
// Сериализует rules-объект: null («правил нет») хранится как {}.
// Разбирает policy-объект: пустая/битая строка — политика по умолчанию (обычная колонка). private static string ToRulesJson(ContainerRulesDto? rules) =>
private static ContainerPolicyDto ToPolicyOrEmpty(string json) rules is null ? NoRulesJson : JsonSerializer.Serialize(rules, JsonOptions);
{
if (string.IsNullOrWhiteSpace(json) || json == NoRulesJson) // Разбирает policy-объект: пустая/битая строка — политика по умолчанию (обычная колонка).
{ private static ContainerPolicyDto ToPolicyOrEmpty(string json)
return new ContainerPolicyDto(); {
} if (string.IsNullOrWhiteSpace(json) || json == NoRulesJson)
{
try return new ContainerPolicyDto();
{ }
return JsonSerializer.Deserialize<ContainerPolicyDto>(json, JsonOptions) ?? new ContainerPolicyDto();
} try
catch (JsonException) {
{ return JsonSerializer.Deserialize<ContainerPolicyDto>(json, JsonOptions) ?? new ContainerPolicyDto();
return new ContainerPolicyDto(); }
} catch (JsonException)
} {
return new ContainerPolicyDto();
// Сериализует policy-объект в JSON (camelCase, конвенция value_json). }
private static string ToPolicyJson(ContainerPolicyDto policy) => JsonSerializer.Serialize(policy, JsonOptions); }
// Сериализует значение в JSON (camelCase, конвенция value_json). // Сериализует policy-объект в JSON (camelCase, конвенция value_json).
private static string ToJson<T>(T value) => JsonSerializer.Serialize(value, JsonOptions); private static string ToPolicyJson(ContainerPolicyDto policy) => JsonSerializer.Serialize(policy, JsonOptions);
}
// Сериализует значение в JSON (camelCase, конвенция value_json).
private static string ToJson<T>(T value) => JsonSerializer.Serialize(value, JsonOptions);
}
@@ -1,6 +1,9 @@
using Deal.Infrastructure.Persistence.Entities; using Deal.Infrastructure.Persistence.Entities;
using Deal.Modules.Kanban.Application; using Deal.Modules.Kanban.Application.Abstractions;
using Deal.Modules.Kanban.Application.Extensions;
using Deal.Modules.Kanban.Application.Models; using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Kanban.Application.Registrars;
using Deal.Modules.Kanban.Application.Services;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace Deal.Infrastructure.Persistence.Repositories; namespace Deal.Infrastructure.Persistence.Repositories;
@@ -1,6 +1,9 @@
using Deal.Infrastructure.Persistence.Entities; using Deal.Infrastructure.Persistence.Entities;
using Deal.Modules.Tenants.Application; using Deal.Modules.Tenants.Application.Abstractions;
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.Services;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace Deal.Infrastructure.Persistence.Repositories; namespace Deal.Infrastructure.Persistence.Repositories;
@@ -1,7 +1,13 @@
using Deal.Infrastructure.Persistence.Entities; using Deal.Infrastructure.Persistence.Entities;
using Deal.Modules.Kanban.Application; using Deal.Modules.Kanban.Application.Abstractions;
using Deal.Modules.Pipeline.Application; using Deal.Modules.Kanban.Application.Extensions;
using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Kanban.Application.Registrars;
using Deal.Modules.Kanban.Application.Services;
using Deal.Modules.Pipeline.Application.Abstractions;
using Deal.Modules.Pipeline.Application.Models; using Deal.Modules.Pipeline.Application.Models;
using Deal.Modules.Pipeline.Application.Registrars;
using Deal.Modules.Pipeline.Application.Services;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace Deal.Infrastructure.Persistence.Repositories; namespace Deal.Infrastructure.Persistence.Repositories;
@@ -2,7 +2,11 @@ using System.Data;
using System.Data.Common; using System.Data.Common;
using System.Globalization; using System.Globalization;
using Deal.Infrastructure.Persistence.Entities; using Deal.Infrastructure.Persistence.Entities;
using Deal.Modules.Tenants.Application; using Deal.Modules.Tenants.Application.Abstractions;
using Deal.Modules.Tenants.Application.Extensions;
using Deal.Modules.Tenants.Application.Models;
using Deal.Modules.Tenants.Application.Registrars;
using Deal.Modules.Tenants.Application.Services;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace Deal.Infrastructure.Persistence.Repositories; namespace Deal.Infrastructure.Persistence.Repositories;
@@ -1,6 +1,8 @@
using Deal.Infrastructure.Persistence.Entities; using Deal.Infrastructure.Persistence.Entities;
using Deal.Modules.Settings.Application; 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.Services;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace Deal.Infrastructure.Persistence.Repositories; namespace Deal.Infrastructure.Persistence.Repositories;
@@ -1,6 +1,9 @@
using Deal.Infrastructure.Persistence.Entities; using Deal.Infrastructure.Persistence.Entities;
using Deal.Modules.Tenants.Application; using Deal.Modules.Tenants.Application.Abstractions;
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.Services;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace Deal.Infrastructure.Persistence.Repositories; namespace Deal.Infrastructure.Persistence.Repositories;
@@ -1,6 +1,9 @@
using Deal.Infrastructure.Persistence.Entities; using Deal.Infrastructure.Persistence.Entities;
using Deal.Modules.Tenants.Application; using Deal.Modules.Tenants.Application.Abstractions;
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.Services;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace Deal.Infrastructure.Persistence.Repositories; namespace Deal.Infrastructure.Persistence.Repositories;
@@ -1,183 +1,186 @@
using System.Linq.Expressions; using System.Linq.Expressions;
using Deal.Infrastructure.Persistence.Entities; using Deal.Infrastructure.Persistence.Entities;
using Deal.Modules.Tenants.Application; using Deal.Modules.Tenants.Application.Abstractions;
using Deal.Modules.Tenants.Application.Models; using Deal.Modules.Tenants.Application.Extensions;
using Microsoft.EntityFrameworkCore; using Deal.Modules.Tenants.Application.Models;
using Deal.Modules.Tenants.Application.Registrars;
namespace Deal.Infrastructure.Persistence.Repositories; using Deal.Modules.Tenants.Application.Services;
using Microsoft.EntityFrameworkCore;
/// <summary>
/// EF-адаптер истории расхода токенов: таблица public.token_usage_events (append-only + агрегаты, этап 10, T2). namespace Deal.Infrastructure.Persistence.Repositories;
/// </summary>
/// <remarks> /// <summary>
/// Маппинг DTO ↔ сущности вручную (порт модуля не видит EF-сущности, Ruling 1). Запись — только Add+SaveChanges. /// EF-адаптер истории расхода токенов: таблица public.token_usage_events (append-only + агрегаты, этап 10, T2).
/// Агрегация — group by день UTC (Year/Month/Day), тенант, провайдер или модель; фильтры TenantId/Provider/Model/ /// </summary>
/// Kind/At-range. Порядок: day — по возрастанию даты, остальные — по убыванию total (детерминизм витрины). /// <remarks>
/// Неизвестный groupBy — <see cref="ArgumentException"/> (HTTP-слой валидирует и отвечает 400 раньше). /// Маппинг DTO ↔ сущности вручную (порт модуля не видит EF-сущности, Ruling 1). Запись — только Add+SaveChanges.
/// </remarks> /// Агрегация — group by день UTC (Year/Month/Day), тенант, провайдер или модель; фильтры TenantId/Provider/Model/
public sealed class TokenUsageEventStore(DealDbContext dbContext) : ITokenUsageEventStore /// Kind/At-range. Порядок: day — по возрастанию даты, остальные — по убыванию total (детерминизм витрины).
{ /// Неизвестный groupBy — <see cref="ArgumentException"/> (HTTP-слой валидирует и отвечает 400 раньше).
/// <inheritdoc /> /// </remarks>
public async Task AppendAsync(TokenUsageEventDto record, CancellationToken ct) public sealed class TokenUsageEventStore(DealDbContext dbContext) : ITokenUsageEventStore
{ {
dbContext.TokenUsageEvents.Add(new TokenUsageEventEntity /// <inheritdoc />
{ public async Task AppendAsync(TokenUsageEventDto record, CancellationToken ct)
// Id генерирует БД (identity) — из DTO не копируется. {
TenantId = record.TenantId, dbContext.TokenUsageEvents.Add(new TokenUsageEventEntity
At = record.At, {
Provider = record.Provider, // Id генерирует БД (identity) — из DTO не копируется.
Model = record.Model, TenantId = record.TenantId,
Kind = record.Kind, At = record.At,
PromptTokens = record.PromptTokens, Provider = record.Provider,
CompletionTokens = record.CompletionTokens, Model = record.Model,
TotalTokens = record.TotalTokens, Kind = record.Kind,
DetailJson = record.DetailJson, PromptTokens = record.PromptTokens,
}); CompletionTokens = record.CompletionTokens,
await dbContext.SaveChangesAsync(ct); TotalTokens = record.TotalTokens,
} DetailJson = record.DetailJson,
});
/// <inheritdoc /> await dbContext.SaveChangesAsync(ct);
public async Task<IReadOnlyList<TokenUsageAggregateDto>> AggregateAsync(TokenUsageEventQueryDto query, CancellationToken ct) }
{
IQueryable<TokenUsageEventEntity> source = ApplyFilters(dbContext.TokenUsageEvents.AsNoTracking(), query); /// <inheritdoc />
return query.GroupBy switch public async Task<IReadOnlyList<TokenUsageAggregateDto>> AggregateAsync(TokenUsageEventQueryDto query, CancellationToken ct)
{ {
TokenUsageGroupBys.Day => await AggregateByDayAsync(source, ct), IQueryable<TokenUsageEventEntity> source = ApplyFilters(dbContext.TokenUsageEvents.AsNoTracking(), query);
TokenUsageGroupBys.Tenant => await AggregateByTenantAsync(source, ct), return query.GroupBy switch
TokenUsageGroupBys.Provider => await AggregateByStringAsync(source, entity => entity.Provider, ct), {
TokenUsageGroupBys.Model => await AggregateByStringAsync(source, entity => entity.Model, ct), TokenUsageGroupBys.Day => await AggregateByDayAsync(source, ct),
_ => throw new ArgumentException($"Неизвестная группировка расхода токенов: '{query.GroupBy}'.", nameof(query)), TokenUsageGroupBys.Tenant => await AggregateByTenantAsync(source, ct),
}; TokenUsageGroupBys.Provider => await AggregateByStringAsync(source, entity => entity.Provider, ct),
} TokenUsageGroupBys.Model => await AggregateByStringAsync(source, entity => entity.Model, ct),
_ => throw new ArgumentException($"Неизвестная группировка расхода токенов: '{query.GroupBy}'.", nameof(query)),
// Применяет фильтры агрегации (TenantId/Provider/Model/Kind/At-range). };
// source: Базовый запрос. }
// query: Фильтр/группировка.
// Возвращает: Запрос с фильтрами. // Применяет фильтры агрегации (TenantId/Provider/Model/Kind/At-range).
private static IQueryable<TokenUsageEventEntity> ApplyFilters(IQueryable<TokenUsageEventEntity> source, TokenUsageEventQueryDto query) // source: Базовый запрос.
{ // query: Фильтр/группировка.
if (query.TenantId is not null) // Возвращает: Запрос с фильтрами.
{ private static IQueryable<TokenUsageEventEntity> ApplyFilters(IQueryable<TokenUsageEventEntity> source, TokenUsageEventQueryDto query)
source = source.Where(e => e.TenantId == query.TenantId); {
} if (query.TenantId is not null)
{
if (!string.IsNullOrWhiteSpace(query.Provider)) source = source.Where(e => e.TenantId == query.TenantId);
{ }
source = source.Where(e => e.Provider == query.Provider);
} if (!string.IsNullOrWhiteSpace(query.Provider))
{
if (!string.IsNullOrWhiteSpace(query.Model)) source = source.Where(e => e.Provider == query.Provider);
{ }
source = source.Where(e => e.Model == query.Model);
} if (!string.IsNullOrWhiteSpace(query.Model))
{
if (!string.IsNullOrWhiteSpace(query.Kind)) source = source.Where(e => e.Model == query.Model);
{ }
source = source.Where(e => e.Kind == query.Kind);
} if (!string.IsNullOrWhiteSpace(query.Kind))
{
if (query.From is not null) source = source.Where(e => e.Kind == query.Kind);
{ }
source = source.Where(e => e.At >= query.From.Value);
} if (query.From is not null)
{
if (query.To is not null) source = source.Where(e => e.At >= query.From.Value);
{ }
source = source.Where(e => e.At <= query.To.Value);
} if (query.To is not null)
{
return source; source = source.Where(e => e.At <= query.To.Value);
} }
// Агрегат по суткам UTC (ключ ГГГГ-ММ-ДД), порядок — по возрастанию даты. return source;
// source: Отфильтрованный запрос. }
// ct: Токен отмены.
// Возвращает: Строки агрегатов по дням. // Агрегат по суткам UTC (ключ ГГГГ-ММ-ДД), порядок — по возрастанию даты.
private static async Task<IReadOnlyList<TokenUsageAggregateDto>> AggregateByDayAsync( // source: Отфильтрованный запрос.
IQueryable<TokenUsageEventEntity> source, CancellationToken ct) // ct: Токен отмены.
{ // Возвращает: Строки агрегатов по дням.
var rows = await source private static async Task<IReadOnlyList<TokenUsageAggregateDto>> AggregateByDayAsync(
.GroupBy(e => new { e.At.Year, e.At.Month, e.At.Day }) IQueryable<TokenUsageEventEntity> source, CancellationToken ct)
.Select(group => new {
{ var rows = await source
group.Key.Year, .GroupBy(e => new { e.At.Year, e.At.Month, e.At.Day })
group.Key.Month, .Select(group => new
group.Key.Day, {
Prompt = group.Sum(x => x.PromptTokens), group.Key.Year,
Completion = group.Sum(x => x.CompletionTokens), group.Key.Month,
Total = group.Sum(x => x.TotalTokens), group.Key.Day,
Count = group.LongCount(), Prompt = group.Sum(x => x.PromptTokens),
}) Completion = group.Sum(x => x.CompletionTokens),
.ToListAsync(ct); Total = group.Sum(x => x.TotalTokens),
Count = group.LongCount(),
return rows })
.OrderBy(row => row.Year) .ToListAsync(ct);
.ThenBy(row => row.Month)
.ThenBy(row => row.Day) return rows
.Select(row => new TokenUsageAggregateDto( .OrderBy(row => row.Year)
$"{row.Year:D4}-{row.Month:D2}-{row.Day:D2}", .ThenBy(row => row.Month)
row.Prompt, .ThenBy(row => row.Day)
row.Completion, .Select(row => new TokenUsageAggregateDto(
row.Total, $"{row.Year:D4}-{row.Month:D2}-{row.Day:D2}",
row.Count)) row.Prompt,
.ToList(); row.Completion,
} row.Total,
row.Count))
// Агрегат по тенантам (ключ — Guid "D"), порядок — по убыванию total. .ToList();
// source: Отфильтрованный запрос. }
// ct: Токен отмены.
// Возвращает: Строки агрегатов по тенантам. // Агрегат по тенантам (ключ — Guid "D"), порядок — по убыванию total.
private static async Task<IReadOnlyList<TokenUsageAggregateDto>> AggregateByTenantAsync( // source: Отфильтрованный запрос.
IQueryable<TokenUsageEventEntity> source, CancellationToken ct) // ct: Токен отмены.
{ // Возвращает: Строки агрегатов по тенантам.
var rows = await source private static async Task<IReadOnlyList<TokenUsageAggregateDto>> AggregateByTenantAsync(
.GroupBy(e => e.TenantId) IQueryable<TokenUsageEventEntity> source, CancellationToken ct)
.Select(group => new {
{ var rows = await source
group.Key, .GroupBy(e => e.TenantId)
Prompt = group.Sum(x => x.PromptTokens), .Select(group => new
Completion = group.Sum(x => x.CompletionTokens), {
Total = group.Sum(x => x.TotalTokens), group.Key,
Count = group.LongCount(), Prompt = group.Sum(x => x.PromptTokens),
}) Completion = group.Sum(x => x.CompletionTokens),
.ToListAsync(ct); Total = group.Sum(x => x.TotalTokens),
Count = group.LongCount(),
return rows })
.OrderByDescending(row => row.Total) .ToListAsync(ct);
.Select(row => new TokenUsageAggregateDto(
row.Key.ToString("D"), return rows
row.Prompt, .OrderByDescending(row => row.Total)
row.Completion, .Select(row => new TokenUsageAggregateDto(
row.Total, row.Key.ToString("D"),
row.Count)) row.Prompt,
.ToList(); row.Completion,
} row.Total,
row.Count))
// Агрегат по строковому ключу (провайдер/модель), порядок — по убыванию total. .ToList();
// source: Отфильтрованный запрос. }
// keySelector: Селектор ключа группы (провайдер/модель).
// ct: Токен отмены. // Агрегат по строковому ключу (провайдер/модель), порядок — по убыванию total.
// Возвращает: Строки агрегатов по ключу. // source: Отфильтрованный запрос.
private static async Task<IReadOnlyList<TokenUsageAggregateDto>> AggregateByStringAsync( // keySelector: Селектор ключа группы (провайдер/модель).
IQueryable<TokenUsageEventEntity> source, // ct: Токен отмены.
Expression<Func<TokenUsageEventEntity, string>> keySelector, // Возвращает: Строки агрегатов по ключу.
CancellationToken ct) private static async Task<IReadOnlyList<TokenUsageAggregateDto>> AggregateByStringAsync(
{ IQueryable<TokenUsageEventEntity> source,
var rows = await source Expression<Func<TokenUsageEventEntity, string>> keySelector,
.GroupBy(keySelector) CancellationToken ct)
.Select(group => new {
{ var rows = await source
Key = group.Key, .GroupBy(keySelector)
Prompt = group.Sum(x => x.PromptTokens), .Select(group => new
Completion = group.Sum(x => x.CompletionTokens), {
Total = group.Sum(x => x.TotalTokens), Key = group.Key,
Count = group.LongCount(), Prompt = group.Sum(x => x.PromptTokens),
}) Completion = group.Sum(x => x.CompletionTokens),
.ToListAsync(ct); Total = group.Sum(x => x.TotalTokens),
Count = group.LongCount(),
return rows })
.OrderByDescending(row => row.Total) .ToListAsync(ct);
.Select(row => new TokenUsageAggregateDto(row.Key, row.Prompt, row.Completion, row.Total, row.Count))
.ToList(); return rows
} .OrderByDescending(row => row.Total)
} .Select(row => new TokenUsageAggregateDto(row.Key, row.Prompt, row.Completion, row.Total, row.Count))
.ToList();
}
}
@@ -1,6 +1,9 @@
using System.Security.Cryptography; using System.Security.Cryptography;
using System.Text; using System.Text;
using Deal.Modules.Settings.Application; using Deal.Modules.Settings.Application.Abstractions;
using Deal.Modules.Settings.Application.Models;
using Deal.Modules.Settings.Application.Registrars;
using Deal.Modules.Settings.Application.Services;
namespace Deal.Infrastructure.Security; namespace Deal.Infrastructure.Security;
@@ -1,267 +1,285 @@
using Deal.Contracts.Integrations; using Deal.Contracts.Integrations;
using Deal.Infrastructure.Integrations; using Deal.Infrastructure.Integrations;
using Deal.Infrastructure.Persistence; using Deal.Infrastructure.Persistence;
using Deal.Infrastructure.Persistence.Repositories; using Deal.Infrastructure.Persistence.Repositories;
using Deal.Infrastructure.Security; using Deal.Infrastructure.Security;
using Deal.Infrastructure.Services; using Deal.Infrastructure.Services;
using Deal.Infrastructure.Tenancy; using Deal.Infrastructure.Tenancy;
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.Discovery.Application; using Deal.Modules.Discovery.Application.Abstractions;
using Deal.Modules.Kanban.Application; using Deal.Modules.Discovery.Application.Exceptions;
using Deal.Modules.Pipeline.Application; using Deal.Modules.Discovery.Application.Extensions;
using Deal.Modules.Settings.Application; using Deal.Modules.Discovery.Application.Models;
using Deal.Modules.Telegram.Application; using Deal.Modules.Discovery.Application.Registrars;
using Deal.Modules.Tenants.Application; using Deal.Modules.Discovery.Application.Services;
using Deal.Modules.Tenants.Application.Models; using Deal.Modules.Kanban.Application.Abstractions;
using Deal.SharedKernel.Tenants; using Deal.Modules.Kanban.Application.Extensions;
using Microsoft.Extensions.DependencyInjection; using Deal.Modules.Kanban.Application.Models;
using Microsoft.Extensions.Logging; using Deal.Modules.Kanban.Application.Registrars;
using Deal.Modules.Kanban.Application.Services;
namespace Deal.Infrastructure; using Deal.Modules.Pipeline.Application.Abstractions;
using Deal.Modules.Pipeline.Application.Models;
/// <summary> using Deal.Modules.Pipeline.Application.Registrars;
/// DI-регистрация адаптеров персистентности Deal.Infrastructure (паттерн «port &amp; adapter», Ruling 1). using Deal.Modules.Pipeline.Application.Services;
/// </summary> using Deal.Modules.Settings.Application.Abstractions;
/// <remarks> using Deal.Modules.Settings.Application.Models;
/// Регистрируются только EF-адаптеры портов модулей. Сервисы самих модулей регистрируют using Deal.Modules.Settings.Application.Registrars;
/// модульные регистраторы (AddTenantsModule в Deal.Api). Адаптеры — scoped, потому что живут using Deal.Modules.Settings.Application.Services;
/// на scoped-контекстах EF (DealDbContext), которыми владеет запрос. using Deal.Modules.Telegram.Application;
/// </remarks> using Deal.Modules.Tenants.Application.Abstractions;
public static class ServiceCollectionExtensions using Deal.Modules.Tenants.Application.Extensions;
{ using Deal.Modules.Tenants.Application.Models;
/// <summary> using Deal.Modules.Tenants.Application.Registrars;
/// Регистрирует EF-адаптеры портов модулей: IAuthStore, IOperatorAuthStore, ITenantRepository, IAuditLogStore, IInviteStore, ITenantLimitStore, ISettingsStore, IGlobalSettingsStore, ICardStore, IPipelineStore, IMlLearningStore, ITenantProvisioner. using Deal.Modules.Tenants.Application.Services;
/// </summary> using Deal.SharedKernel.Tenants;
/// <param name="services">Коллекция сервисов.</param> using Microsoft.Extensions.DependencyInjection;
/// <param name="tenantLimitDefaults">Дефолт-бюджет лениво создаваемых строк tenant_limits (Ruling 3; null — константа TokenBudgetDefaults). using Microsoft.Extensions.Logging;
/// Передаётся из конфигурации/env DEAL_DEFAULT_AI_BUDGET в Program.cs (Task 8).</param>
/// <returns>Коллекция сервисов для цепочки вызовов.</returns> namespace Deal.Infrastructure;
public static IServiceCollection AddDealPersistence(
this IServiceCollection services, TokenLimitDefaults? tenantLimitDefaults = null) /// <summary>
{ /// DI-регистрация адаптеров персистентности Deal.Infrastructure (паттерн «port &amp; adapter», Ruling 1).
services.AddScoped<IAuthStore, AuthStore>(); /// </summary>
/// <remarks>
// Хранилище аутентификации оператора (Ruling 1 этапа 7): таблицы operators/operator_sessions /// Регистрируются только EF-адаптеры портов модулей. Сервисы самих модулей регистрируют
// схемы public — отдельный порт от IAuthStore (оператор ≠ пользователь тенанта). /// модульные регистраторы (AddTenantsModule в Deal.Api). Адаптеры — scoped, потому что живут
services.AddScoped<IOperatorAuthStore, OperatorAuthStore>(); /// на scoped-контекстах EF (DealDbContext), которыми владеет запрос.
services.AddScoped<ITenantRepository, TenantRepository>(); /// </remarks>
public static class ServiceCollectionExtensions
// Хранилище аудита (Ruling 4): таблица public.audit_log — append-only, порт без Update/Delete. {
services.AddScoped<IAuditLogStore, AuditLogStore>(); /// <summary>
/// Регистрирует EF-адаптеры портов модулей: IAuthStore, IOperatorAuthStore, ITenantRepository, IAuditLogStore, IInviteStore, ITenantLimitStore, ISettingsStore, IGlobalSettingsStore, ICardStore, IPipelineStore, IMlLearningStore, ITenantProvisioner.
// Хранилище истории расхода токенов (этап 10, T2): таблица public.token_usage_events — append-only /// </summary>
// + агрегаты для аналитики; порт без Update/Delete. /// <param name="services">Коллекция сервисов.</param>
services.AddScoped<ITokenUsageEventStore, TokenUsageEventStore>(); /// <param name="tenantLimitDefaults">Дефолт-бюджет лениво создаваемых строк tenant_limits (Ruling 3; null — константа TokenBudgetDefaults).
/// Передаётся из конфигурации/env DEAL_DEFAULT_AI_BUDGET в Program.cs (Task 8).</param>
// Хранилище приглашений (Ruling 2): таблица public.invites — создание/чтение/отзыв оператором (Task 5), /// <returns>Коллекция сервисов для цепочки вызовов.</returns>
// статусные переходы меняет прикладной слой (InvitesService), активацию выполнит /api/join (Task 6). public static IServiceCollection AddDealPersistence(
services.AddScoped<IInviteStore, InviteStore>(); this IServiceCollection services, TokenLimitDefaults? tenantLimitDefaults = null)
{
// Хранилище лимитов ИИ-бюджета (Ruling 3, Task 8): таблица public.tenant_limits. Фабрика передаёт services.AddScoped<IAuthStore, AuthStore>();
// дефолт-бюджет из конфигурации (Program.cs) — ленивый GetOrCreate новой строки использует его;
// DealDbContext уже зарегистрирован в Api (AddDbContext до AddDealPersistence). // Хранилище аутентификации оператора (Ruling 1 этапа 7): таблицы operators/operator_sessions
services.AddScoped<ITenantLimitStore>(provider => new TenantLimitStore( // схемы public — отдельный порт от IAuthStore (оператор ≠ пользователь тенанта).
provider.GetRequiredService<DealDbContext>(), services.AddScoped<IOperatorAuthStore, OperatorAuthStore>();
tenantLimitDefaults ?? TokenBudgetDefaults.Default)); services.AddScoped<ITenantRepository, TenantRepository>();
// Хранилище счётчиков фиксированного окна (этап 12, пакет B): таблица public.rate_limit_counters — // Хранилище аудита (Ruling 4): таблица public.audit_log — append-only, порт без Update/Delete.
// общее хранилище распределённого rate limiting (auth/api/gRPC) и учёта попыток входа. Scoped services.AddScoped<IAuditLogStore, AuditLogStore>();
// (DealDbContext); лимитер резолвит его в собственном scope на каждое приобретение.
services.AddScoped<IRateLimitCounterStore, RateLimitCounterStore>(); // Хранилище истории расхода токенов (этап 10, T2): таблица public.token_usage_events — append-only
// + агрегаты для аналитики; порт без Update/Delete.
// KV-хранилище настроек тенанта (Ruling 1): таблица settings в схеме тенанта, services.AddScoped<ITokenUsageEventStore, TokenUsageEventStore>();
// контекст — scoped TenantDbContext запроса (см. AddDbContext в Deal.Api).
services.AddScoped<ISettingsStore, SettingsStore>(); // Хранилище приглашений (Ruling 2): таблица public.invites — создание/чтение/отзыв оператором (Task 5),
// статусные переходы меняет прикладной слой (InvitesService), активацию выполнит /api/join (Task 6).
// KV-хранилище глобальных (системных) настроек оператора (ТЗ §4.1/§8.1): таблица services.AddScoped<IInviteStore, InviteStore>();
// public.global_settings, контекст — системный DealDbContext (public-схема). Ключи Telegram
// задаёт оператор, ядро читает их для команд входа (TelegramKeysService). // Хранилище лимитов ИИ-бюджета (Ruling 3, Task 8): таблица public.tenant_limits. Фабрика передаёт
services.AddScoped<IGlobalSettingsStore, GlobalSettingsStore>(); // дефолт-бюджет из конфигурации (Program.cs) — ленивый GetOrCreate новой строки использует его;
// DealDbContext уже зарегистрирован в Api (AddDbContext до AddDealPersistence).
// Хранилище карточек и контейнеров (Ruling 12): таблицы Cards/Containers/LeadComments/CardMoves в схеме тенанта. services.AddScoped<ITenantLimitStore>(provider => new TenantLimitStore(
services.AddScoped<ICardStore, KanbanStore>(); provider.GetRequiredService<DealDbContext>(),
tenantLimitDefaults ?? TokenBudgetDefaults.Default));
// Хранилище пайплайна (Ruling 10): таблицы QueueItems/RejectedItems/DedupEntries в схеме тенанта.
// KanbanStore при жёстком удалении карточки чистит DedupEntries напрямую тем же TenantDbContext // Хранилище счётчиков фиксированного окна (этап 12, пакет B): таблица public.rate_limit_counters —
// (Ruling 3) — IPipelineStore для этого не привлекается, цикла Kanban → Pipeline нет. // общее хранилище распределённого rate limiting (auth/api/gRPC) и учёта попыток входа. Scoped
services.AddScoped<IPipelineStore, PipelineStore>(); // (DealDbContext); лимитер резолвит его в собственном scope на каждое приобретение.
services.AddScoped<IRateLimitCounterStore, RateLimitCounterStore>();
// Хранилище обучения ML (Ruling 4, Task 5): очередь MlOutbox + счётчик журнала CardMoves.
services.AddScoped<IMlLearningStore, MlLearningStore>(); // KV-хранилище настроек тенанта (Ruling 1): таблица settings в схеме тенанта,
// контекст — scoped TenantDbContext запроса (см. AddDbContext в Deal.Api).
// Хранилище каталога диалогов Telegram (Ruling 7, Task 13): таблицы Dialogs/TgMessages в схеме тенанта. services.AddScoped<ISettingsStore, SettingsStore>();
services.AddScoped<ITelegramStore, TelegramStore>();
// KV-хранилище глобальных (системных) настроек оператора (ТЗ §4.1/§8.1): таблица
// Хранилище Discovery (Ruling 9, Task 17): таблицы DiscTasks/DiscCandidates/DiscBlacklist/DiscLog в схеме // public.global_settings, контекст — системный DealDbContext (public-схема). Ключи Telegram
// тенанта. Проверки «уже мониторится» читают таблицу Dialogs (владелец — Telegram) тем же TenantDbContext. // задаёт оператор, ядро читает их для команд входа (TelegramKeysService).
services.AddScoped<IDiscoveryStore, DiscoveryStore>(); services.AddScoped<IGlobalSettingsStore, GlobalSettingsStore>();
// Провижининг схем тенантов (Ruling 3). TenantProvisioningService зависит от // Хранилище карточек и контейнеров (Ruling 12): таблицы Cards/Containers/LeadComments/CardMoves в схеме тенанта.
// ConnectionStringProvider — он регистрируется в Deal.Api (Program.cs) как singleton. services.AddScoped<ICardStore, KanbanStore>();
services.AddScoped<ITenantProvisioner, TenantProvisioningService>();
// Хранилище пайплайна (Ruling 10): таблицы QueueItems/RejectedItems/DedupEntries в схеме тенанта.
// Пакетная (maintenance) миграция схем всех тенантов (этап 12, пакет C): ограниченный // KanbanStore при жёстком удалении карточки чистит DedupEntries напрямую тем же TenantDbContext
// параллелизм + логирование прогресса поверх ITenantRepository и ITenantProvisioner. // (Ruling 3) — IPipelineStore для этого не привлекается, цикла Kanban → Pipeline нет.
services.AddScoped<TenantSchemaMigrationService>(); services.AddScoped<IPipelineStore, PipelineStore>();
// Единая точка перехода карточки между контейнерами (R4 этапа 9): выбор маршрута «стадия // Хранилище обучения ML (Ruling 4, Task 5): очередь MlOutbox + счётчик журнала CardMoves.
// Выбранных vs дашборд-контейнер» живёт в адаптере, а не в эндпоинте. Scoped — services.AddScoped<IMlLearningStore, MlLearningStore>();
// композирует scoped-сервис карточек в рамках tenant-запроса.
services.AddScoped<ICardMover, CardMover>(); // Хранилище каталога диалогов Telegram (Ruling 7, Task 13): таблицы Dialogs/TgMessages в схеме тенанта.
return services; services.AddScoped<ITelegramStore, TelegramStore>();
}
// Хранилище Discovery (Ruling 9, Task 17): таблицы DiscTasks/DiscCandidates/DiscBlacklist/DiscLog в схеме
/// <summary> // тенанта. Проверки «уже мониторится» читают таблицу Dialogs (владелец — Telegram) тем же TenantDbContext.
/// Регистрирует адаптеры внешних интеграций (Rulings 4/5/6/9): IMlClient, IAiClassifier, IAiTools, IColumnSuggester, ITelegramGateway. services.AddScoped<IDiscoveryStore, DiscoveryStore>();
/// </summary>
/// <param name="services">Коллекция сервисов.</param> // Провижининг схем тенантов (Ruling 3). TenantProvisioningService зависит от
/// <param name="mlOptions">Конфигурация секции <c>Services:Ml</c> — выбор реализации IMlClient (Ruling 6).</param> // ConnectionStringProvider — он регистрируется в Deal.Api (Program.cs) как singleton.
/// <param name="aiOptions">Конфигурация секции <c>Services:Ai</c> — выбор реализации IAiClassifier/IAiTools (Ruling 6).</param> services.AddScoped<ITenantProvisioner, TenantProvisioningService>();
/// <param name="telegramOptions">Конфигурация секции <c>Services:Telegram</c> — выбор реализации ITelegramGateway (Ruling 6).</param>
/// <returns>Коллекция сервисов для цепочки вызовов.</returns> // Пакетная (maintenance) миграция схем всех тенантов (этап 12, пакет C): ограниченный
/// <remarks> // параллелизм + логирование прогресса поверх ITenantRepository и ITenantProvisioner.
/// IMlClient: при <c>UseLocal=true</c> (default) — Local-заглушка LocalMlClient (фолбэк этапов 2–5); при services.AddScoped<TenantSchemaMigrationService>();
/// <c>UseLocal=false</c> — gRPC-адаптер GrpcMlClient (ml.proto, Ruling 1/4) + синглтон-транспорт
/// MlGrpcConnection и кэш статуса MlStatusCache; тот же адаптер реализует IMlTrainClient для фонового // Единая точка перехода карточки между контейнерами (R4 этапа 9): выбор маршрута «стадия
/// MlOutboxFlushScheduler (регистрируется в Deal.Api под тем же флагом). PushAsync в обоих режимах пишет // Выбранных vs дашборд-контейнер» живёт в адаптере, а не в эндпоинте. Scoped —
/// в MlOutbox (Ruling 6 — обучение всегда локально), выгрузку батчами делает флашер. IAiClassifier: при // композирует scoped-сервис карточек в рамках tenant-запроса.
/// <c>Services:Ai:UseLocal=true</c> — LocalAiClassifier (детерминированный разбор ядра, Ruling 5 этапа 4); services.AddScoped<ICardMover, CardMover>();
/// при false — GrpcAiClassifier (ai.proto; Ruling 5/6) + синглтон AiGrpcConnection (fail-fast, как return services;
/// MlGrpcConnection) + scoped-сервисы конфига провайдера/учёта токенов. Бюджетный гейт (Task 9, Ruling 3): }
/// в gRPC-режиме порты наружу отдаются декораторами BudgetedAiClassifier/BudgetedAiTools поверх gRPC-адаптеров
/// (порядок Grpc → Budgeted), Local-реализации регистрируются как бесплатный fallback гейта. IAiTools: /// <summary>
/// LocalAiTools (методы не поддерживаются — NotSupportedException, Ruling 9) либо GrpcAiTools за тем же флагом. /// Регистрирует адаптеры внешних интеграций (Rulings 4/5/6/9): IMlClient, IAiClassifier, IAiTools, IColumnSuggester, ITelegramGateway.
/// IColumnSuggester — /// </summary>
/// детерминированная Local-эвристика LocalColumnSuggester (Self-Review плана L525527: на gRPC сознательно /// <param name="services">Коллекция сервисов.</param>
/// не заменяется). ITelegramGateway: при <c>Services:Telegram:UseLocal=true</c> (default) — LocalTelegramGateway /// <param name="mlOptions">Конфигурация секции <c>Services:Ml</c> — выбор реализации IMlClient (Ruling 6).</param>
/// (нейтральный no-op/idle, фолбэк до Task 14); при false — GrpcTelegramClient (telegram.proto, Ruling 7) + /// <param name="aiOptions">Конфигурация секции <c>Services:Ai</c> — выбор реализации IAiClassifier/IAiTools (Ruling 6).</param>
/// синглтон-транспорт TelegramGrpcConnection (fail-fast). /// <param name="telegramOptions">Конфигурация секции <c>Services:Telegram</c> — выбор реализации ITelegramGateway (Ruling 6).</param>
/// Scoped: адаптеры читают KV-настройки тенанта (ISettingsStore → scoped TenantDbContext запроса) и /// <returns>Коллекция сервисов для цепочки вызовов.</returns>
/// пишут в таблицы схемы тенанта, поэтому не могут жить дольше scope. HTTP-адаптеры других /// <remarks>
/// интеграций (IAiConnectionChecker/IRatesSource) регистрируются в Deal.Api через AddHttpClient — /// IMlClient: при <c>UseLocal=true</c> (default) — Local-заглушка LocalMlClient (фолбэк этапов 2–5); при
/// см. Program.cs (Tasks 6/8). /// <c>UseLocal=false</c> — gRPC-адаптер GrpcMlClient (ml.proto, Ruling 1/4) + синглтон-транспорт
/// </remarks> /// MlGrpcConnection и кэш статуса MlStatusCache; тот же адаптер реализует IMlTrainClient для фонового
public static IServiceCollection AddDealIntegrations( /// MlOutboxFlushScheduler (регистрируется в Deal.Api под тем же флагом). PushAsync в обоих режимах пишет
this IServiceCollection services, MlServiceOptions mlOptions, AiServiceOptions aiOptions, /// в MlOutbox (Ruling 6 — обучение всегда локально), выгрузку батчами делает флашер. IAiClassifier: при
TelegramServiceOptions telegramOptions, MtlsCertificates? mtlsCertificates = null) /// <c>Services:Ai:UseLocal=true</c> — LocalAiClassifier (детерминированный разбор ядра, Ruling 5 этапа 4);
{ /// при false — GrpcAiClassifier (ai.proto; Ruling 5/6) + синглтон AiGrpcConnection (fail-fast, как
// Сертификаты mTLS-каналов (Ruling 6, Task 13): null (флаг DEAL_MTLS_ENABLED выключен) — каналы /// MlGrpcConnection) + scoped-сервисы конфига провайдера/учёта токенов. Бюджетный гейт (Task 9, Ruling 3):
// остаются plaintext + service-token (dev); при включённом флаге каждый транспорт подписывает запрос /// в gRPC-режиме порты наружу отдаются декораторами BudgetedAiClassifier/BudgetedAiTools поверх gRPC-адаптеров
// клиентским сертификатом и проверяет CA сервера (fail-fast на загрузку — в MtlsCertificates.Load). /// (порядок Grpc → Budgeted), Local-реализации регистрируются как бесплатный fallback гейта. IAiTools:
// Recorder расхода токенов (Ruling 3 этапа 7; история — этап 10, T2): пишет бюджет/lifetime/историю /// LocalAiTools (методы не поддерживаются — NotSupportedException, Ruling 9) либо GrpcAiTools за тем же флагом.
// событий. Регистрируется независимо от AI-режима — им пользуется и gRPC ML-клиент (событие kind=ml). /// IColumnSuggester —
services.AddScoped<TokenUsageRecorder>(); /// детерминированная Local-эвристика LocalColumnSuggester (Self-Review плана L525527: на gRPC сознательно
/// не заменяется). ITelegramGateway: при <c>Services:Telegram:UseLocal=true</c> (default) — LocalTelegramGateway
// ML-клиент (Ruling 6, план Task 16): выбор на старте — Local-заглушка либо gRPC-адаптер ml-service. /// (нейтральный no-op/idle, фолбэк до Task 14); при false — GrpcTelegramClient (telegram.proto, Ruling 7) +
if (mlOptions.UseLocal) /// синглтон-транспорт TelegramGrpcConnection (fail-fast).
{ /// Scoped: адаптеры читают KV-настройки тенанта (ISettingsStore → scoped TenantDbContext запроса) и
// Обучение копится в MlOutbox (этап 3); отправка в ML-сервис не выполняется (сервиса нет в dev) — /// пишут в таблицы схемы тенанта, поэтому не могут жить дольше scope. HTTP-адаптеры других
// очередь остаётся накопленной, как в прототипе при недоступном сервисе (python L5682). /// интеграций (IAiConnectionChecker/IRatesSource) регистрируются в Deal.Api через AddHttpClient —
services.AddScoped<IMlClient, LocalMlClient>(); /// см. Program.cs (Tasks 6/8).
} /// </remarks>
else public static IServiceCollection AddDealIntegrations(
{ this IServiceCollection services, MlServiceOptions mlOptions, AiServiceOptions aiOptions,
// gRPC-клиент ml-service: транспорт создаётся сразу (fail-fast: пустой endpoint/токен останавливают TelegramServiceOptions telegramOptions, MtlsCertificates? mtlsCertificates = null)
// старт — Ruling 2/13), кэш статуса 15 с переживает scope запросов. Scoped GrpcMlClient читает {
// KV/таблицы тенанта (как LocalMlClient) и ходит в сервис по metadata tenant-id/service-token. // Сертификаты mTLS-каналов (Ruling 6, Task 13): null (флаг DEAL_MTLS_ENABLED выключен) — каналы
services.AddSingleton(new MlGrpcConnection(mlOptions, mtlsCertificates)); // остаются plaintext + service-token (dev); при включённом флаге каждый транспорт подписывает запрос
services.AddSingleton<MlStatusCache>(); // клиентским сертификатом и проверяет CA сервера (fail-fast на загрузку — в MtlsCertificates.Load).
services.AddScoped<GrpcMlClient>(); // Recorder расхода токенов (Ruling 3 этапа 7; история — этап 10, T2): пишет бюджет/lifetime/историю
services.AddScoped<IMlClient>(provider => provider.GetRequiredService<GrpcMlClient>()); // событий. Регистрируется независимо от AI-режима — им пользуется и gRPC ML-клиент (событие kind=ml).
services.AddScoped<IMlTrainClient>(provider => provider.GetRequiredService<GrpcMlClient>()); services.AddScoped<TokenUsageRecorder>();
}
// ML-клиент (Ruling 6, план Task 16): выбор на старте — Local-заглушка либо gRPC-адаптер ml-service.
// ИИ-предложения колонок/ключей (Ruling 3, Task 14): порт Contracts → детерминированная эвристика. if (mlOptions.UseLocal)
// LocalColumnSuggester читает карточки через ICardStore, считает группы ядром SuggestHeuristics {
// (модуль Kanban) и создаёт доски suggested=true через ContainersService; scoped — его зависимости // Обучение копится в MlOutbox (этап 3); отправка в ML-сервис не выполняется (сервиса нет в dev) —
// живут в рамках tenant-запроса (KanbanStore/TenantDbContext). На этапе 6 адаптер заменяется // очередь остаётся накопленной, как в прототипе при недоступном сервисе (python L5682).
// gRPC-клиентом ai-service с тем же контрактом. services.AddScoped<IMlClient, LocalMlClient>();
services.AddScoped<IColumnSuggester, LocalColumnSuggester>(); }
else
// ИИ-классификатор входящих (Ruling 5/6, Task 15): порт Contracts → детерминированная Local-реализация {
// (UseLocal=true) либо gRPC-адаптер ai-service (UseLocal=false). LocalAiClassifier разбирает сообщение // gRPC-клиент ml-service: транспорт создаётся сразу (fail-fast: пустой endpoint/токен останавливают
// ядром LocalFieldsParser модуля Pipeline (маркерная гипотеза типа: is_vacancy_known=false, board=null — // старт — Ruling 2/13), кэш статуса 15 с переживает scope запросов. Scoped GrpcMlClient читает
// «смысловые колонки до ИИ не назначаем») и всегда пропускает ИИ-фильтр {pass:true, skipped:true}; // KV/таблицы тенанта (как LocalMlClient) и ходит в сервис по metadata tenant-id/service-token.
// scoped — LocalFieldsParser читает маркеры hireMarkers/levelTerms из KV-настроек тенанта (ISettingsStore). services.AddSingleton(new MlGrpcConnection(mlOptions, mtlsCertificates));
// GrpcAiClassifier строит промпты/контекст классификации (AiClassifyContextBuilder модуля Pipeline, services.AddSingleton<MlStatusCache>();
// регистрируется AddPipelineModule), ходит в ai-service с ProviderConfig из настроек (расшифровка apiKey) services.AddScoped<GrpcMlClient>();
// и копит usage в KV aiTokenUsage; недоступность/ok=false → AiUnavailableException — воркер падает в services.AddScoped<IMlClient>(provider => provider.GetRequiredService<GrpcMlClient>());
// локальный разбор (aiFail, как raw={} python L11081114). Выбор на старте, рантайм-логики нет (Ruling 6). services.AddScoped<IMlTrainClient>(provider => provider.GetRequiredService<GrpcMlClient>());
// Списывание в tenant_limits + lifetime-KV aiTokenUsage выполняет TokenUsageRecorder (Ruling 3, Task 8). }
if (aiOptions.UseLocal)
{ // ИИ-предложения колонок/ключей (Ruling 3, Task 14): порт Contracts → детерминированная эвристика.
services.AddScoped<IAiClassifier, LocalAiClassifier>(); // LocalColumnSuggester читает карточки через ICardStore, считает группы ядром SuggestHeuristics
services.AddScoped<IAiTools, LocalAiTools>(); // (модуль Kanban) и создаёт доски suggested=true через ContainersService; scoped — его зависимости
} // живут в рамках tenant-запроса (KanbanStore/TenantDbContext). На этапе 6 адаптер заменяется
else // gRPC-клиентом ai-service с тем же контрактом.
{ services.AddScoped<IColumnSuggester, LocalColumnSuggester>();
// gRPC-клиент ai-service: транспорт создаётся сразу (fail-fast: пустой endpoint/токен останавливают
// старт — Ruling 2/13), как MlGrpcConnection. Scoped-адаптеры читают настройки/данные тенанта и ходят // ИИ-классификатор входящих (Ruling 5/6, Task 15): порт Contracts → детерминированная Local-реализация
// в сервис по metadata tenant-id/service-token; usage ответов списывает TokenUsageRecorder // (UseLocal=true) либо gRPC-адаптер ai-service (UseLocal=false). LocalAiClassifier разбирает сообщение
// (tenant_limits + lifetime-KV aiTokenUsage, Ruling 3 этапа 7). // ядром LocalFieldsParser модуля Pipeline (маркерная гипотеза типа: is_vacancy_known=false, board=null —
services.AddSingleton(new AiGrpcConnection(aiOptions, mtlsCertificates)); // «смысловые колонки до ИИ не назначаем») и всегда пропускает ИИ-фильтр {pass:true, skipped:true};
services.AddScoped<AiProviderConfigBuilder>(); // scoped — LocalFieldsParser читает маркеры hireMarkers/levelTerms из KV-настроек тенанта (ISettingsStore).
services.AddScoped<GrpcAiClassifier>(); // GrpcAiClassifier строит промпты/контекст классификации (AiClassifyContextBuilder модуля Pipeline,
// Локальные адаптеры как fallback бюджетного гейта (Ruling 3, Task 9): даже в gRPC-режиме декоратор при // регистрируется AddPipelineModule), ходит в ai-service с ProviderConfig из настроек (расшифровка apiKey)
// исчерпанном бюджете/приостановке уводит вызов на детерминированный бесплатный локальный разбор // и копит usage в KV aiTokenUsage; недоступность/ok=false → AiUnavailableException — воркер падает в
// (LocalAiClassifier; LocalFieldsParser регистрирует AddPipelineModule в Program.cs). Инструменты локального // локальный разбор (aiFail, как raw={} python L11081114). Выбор на старте, рантайм-логики нет (Ruling 6).
// fallback не имеют — при запрете BudgetedAiTools бросает AiUnavailableException/мягкую ошибку (Ruling 3). // Списывание в tenant_limits + lifetime-KV aiTokenUsage выполняет TokenUsageRecorder (Ruling 3, Task 8).
services.AddScoped<LocalAiClassifier>(); if (aiOptions.UseLocal)
// Декораторы бюджетного гейта (порядок Grpc → Budgeted → наружу): перед каждым платным вызовом {
// GetStateAsync (ITenantLimitStore, scoped) — исчерпано/приостановлено → Local-классификатор либо services.AddScoped<IAiClassifier, LocalAiClassifier>();
// исключение/мягкая ошибка инструментов; списание usage остаётся внутри gRPC-адаптеров (Task 8). services.AddScoped<IAiTools, LocalAiTools>();
services.AddScoped<IAiClassifier>(provider => new BudgetedAiClassifier( }
provider.GetRequiredService<GrpcAiClassifier>(), else
provider.GetRequiredService<LocalAiClassifier>(), {
provider.GetRequiredService<ITenantLimitStore>(), // gRPC-клиент ai-service: транспорт создаётся сразу (fail-fast: пустой endpoint/токен останавливают
provider.GetRequiredService<ITenantContext>(), // старт — Ruling 2/13), как MlGrpcConnection. Scoped-адаптеры читают настройки/данные тенанта и ходят
provider.GetRequiredService<ILogger<BudgetedAiClassifier>>())); // в сервис по metadata tenant-id/service-token; usage ответов списывает TokenUsageRecorder
services.AddScoped<GrpcAiTools>(); // (tenant_limits + lifetime-KV aiTokenUsage, Ruling 3 этапа 7).
services.AddScoped<IAiTools>(provider => new BudgetedAiTools( services.AddSingleton(new AiGrpcConnection(aiOptions, mtlsCertificates));
provider.GetRequiredService<GrpcAiTools>(), services.AddScoped<AiProviderConfigBuilder>();
provider.GetRequiredService<ITenantLimitStore>(), services.AddScoped<GrpcAiClassifier>();
provider.GetRequiredService<ITenantContext>(), // Локальные адаптеры как fallback бюджетного гейта (Ruling 3, Task 9): даже в gRPC-режиме декоратор при
provider.GetRequiredService<ILogger<BudgetedAiTools>>())); // исчерпанном бюджете/приостановке уводит вызов на детерминированный бесплатный локальный разбор
} // (LocalAiClassifier; LocalFieldsParser регистрирует AddPipelineModule в Program.cs). Инструменты локального
// fallback не имеют — при запрете BudgetedAiTools бросает AiUnavailableException/мягкую ошибку (Ruling 3).
// Гейт telegram-service (Ruling 6/7, план Task 14): порт Contracts. По умолчанию (UseLocal=true) — services.AddScoped<LocalAiClassifier>();
// локальная заглушка dev LocalTelegramGateway (нейтральный no-op/idle — сервис не поднят); при // Декораторы бюджетного гейта (порядок Grpc → Budgeted → наружу): перед каждым платным вызовом
// UseLocal=false — gRPC-клиент GrpcTelegramClient (telegram.proto) + синглтон-транспорт // GetStateAsync (ITenantLimitStore, scoped) — исчерпано/приостановлено → Local-классификатор либо
// TelegramGrpcConnection (fail-fast, как MlGrpcConnection). Scoped-адаптер: tenant-id для metadata // исключение/мягкая ошибка инструментов; списание usage остаётся внутри gRPC-адаптеров (Task 8).
// берёт из ITenantContext (AsyncLocal) scope вызова (HTTP-запрос/tenant-циклы ингресса). services.AddScoped<IAiClassifier>(provider => new BudgetedAiClassifier(
if (telegramOptions.UseLocal) provider.GetRequiredService<GrpcAiClassifier>(),
{ provider.GetRequiredService<LocalAiClassifier>(),
services.AddSingleton<ITelegramGateway, LocalTelegramGateway>(); provider.GetRequiredService<ITenantLimitStore>(),
} provider.GetRequiredService<ITenantContext>(),
else provider.GetRequiredService<ILogger<BudgetedAiClassifier>>()));
{ services.AddScoped<GrpcAiTools>();
services.AddSingleton(new TelegramGrpcConnection(telegramOptions, mtlsCertificates)); services.AddScoped<IAiTools>(provider => new BudgetedAiTools(
services.AddScoped<GrpcTelegramClient>(); provider.GetRequiredService<GrpcAiTools>(),
services.AddScoped<ITelegramGateway>(provider => provider.GetRequiredService<GrpcTelegramClient>()); provider.GetRequiredService<ITenantLimitStore>(),
} provider.GetRequiredService<ITenantContext>(),
provider.GetRequiredService<ILogger<BudgetedAiTools>>()));
return services; }
}
// Гейт telegram-service (Ruling 6/7, план Task 14): порт Contracts. По умолчанию (UseLocal=true) —
/// <summary> // локальная заглушка dev LocalTelegramGateway (нейтральный no-op/idle — сервис не поднят); при
/// Регистрирует сервисы шифрования секретов: ISecretCipher → AesGcmSecretCipher (Ruling 2). // UseLocal=false — gRPC-клиент GrpcTelegramClient (telegram.proto) + синглтон-транспорт
/// </summary> // TelegramGrpcConnection (fail-fast, как MlGrpcConnection). Scoped-адаптер: tenant-id для metadata
/// <param name="services">Коллекция сервисов.</param> // берёт из ITenantContext (AsyncLocal) scope вызова (HTTP-запрос/tenant-циклы ингресса).
/// <param name="contentRootPath">ContentRoot приложения — каталог по умолчанию для файла-ключа data/encryption.key.</param> if (telegramOptions.UseLocal)
/// <returns>Коллекция сервисов для цепочки вызовов.</returns> {
/// <remarks> services.AddSingleton<ITelegramGateway, LocalTelegramGateway>();
/// Ключ разрешается один раз при вызове: невалидный DEAL_ENCRYPTION_KEY → исключение при старте }
/// (план Task 1). EncryptionKeyProvider в контейнер не регистрируется — после разрешения ключа else
/// он рантайм-сервисам не нужен. ISecretCipher — singleton: реализация без разделяемого {
/// состояния (AesGcm создаётся на операцию), поэтому потокобезопасна. services.AddSingleton(new TelegramGrpcConnection(telegramOptions, mtlsCertificates));
/// </remarks> services.AddScoped<GrpcTelegramClient>();
public static IServiceCollection AddDealSecurity(this IServiceCollection services, string contentRootPath) services.AddScoped<ITelegramGateway>(provider => provider.GetRequiredService<GrpcTelegramClient>());
{ }
EncryptionKeyProvider keyProvider = new(contentRootPath);
byte[] key = keyProvider.GetKey(); return services;
services.AddSingleton<ISecretCipher>(new AesGcmSecretCipher(key)); }
return services;
} /// <summary>
} /// Регистрирует сервисы шифрования секретов: ISecretCipher → AesGcmSecretCipher (Ruling 2).
/// </summary>
/// <param name="services">Коллекция сервисов.</param>
/// <param name="contentRootPath">ContentRoot приложения — каталог по умолчанию для файла-ключа data/encryption.key.</param>
/// <returns>Коллекция сервисов для цепочки вызовов.</returns>
/// <remarks>
/// Ключ разрешается один раз при вызове: невалидный DEAL_ENCRYPTION_KEY → исключение при старте
/// (план Task 1). EncryptionKeyProvider в контейнер не регистрируется — после разрешения ключа
/// он рантайм-сервисам не нужен. ISecretCipher — singleton: реализация без разделяемого
/// состояния (AesGcm создаётся на операцию), поэтому потокобезопасна.
/// </remarks>
public static IServiceCollection AddDealSecurity(this IServiceCollection services, string contentRootPath)
{
EncryptionKeyProvider keyProvider = new(contentRootPath);
byte[] key = keyProvider.GetKey();
services.AddSingleton<ISecretCipher>(new AesGcmSecretCipher(key));
return services;
}
}
@@ -1,38 +1,41 @@
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; using Deal.Modules.Kanban.Application.Abstractions;
using Deal.Modules.Kanban.Application.Models; using Deal.Modules.Kanban.Application.Extensions;
using Deal.Modules.Kanban.Application.Models;
namespace Deal.Infrastructure.Services; using Deal.Modules.Kanban.Application.Registrars;
using Deal.Modules.Kanban.Application.Services;
/// <summary>
/// Единая точка перехода карточки между контейнерами — реализация порта <see cref="ICardMover"/> namespace Deal.Infrastructure.Services;
/// (R4 этапа 9).
/// </summary> /// <summary>
/// <remarks> /// Единая точка перехода карточки между контейнерами — реализация порта <see cref="ICardMover"/>
/// Маршрутизация цели живёт в домене: цель-стадия пространства «Выбранные» /// (R4 этапа 9).
/// (<see cref="CardsDefaultContainers"/>) переходит через <see cref="CardsService.MoveStageCardAsync"/> — /// </summary>
/// запись истории движения и сброс напоминания; остальные цели (доски/служебные зоны) — штатный перенос /// <remarks>
/// дашборда <see cref="CardsService.MoveDashboardCardAsync"/> (журнал CardMoves, matchHits, обучение ML). /// Маршрутизация цели живёт в домене: цель-стадия пространства «Выбранные»
/// Адаптер только нормализует результаты обоих маршрутов к <see cref="CardMoveResultDto"/>; снимок карточки /// (<see cref="CardsDefaultContainers"/>) переходит через <see cref="CardsService.MoveStageCardAsync"/> —
/// эндпоинт перечитывает единым чтением. Scoped: зависимости живут в рамках tenant-запроса. /// запись истории движения и сброс напоминания; остальные цели (доски/служебные зоны) — штатный перенос
/// </remarks> /// дашборда <see cref="CardsService.MoveDashboardCardAsync"/> (журнал CardMoves, matchHits, обучение ML).
/// <param name="cardsService">Сервис карточек — единый домен перехода (стадии и контейнеры дашборда).</param> /// Адаптер только нормализует результаты обоих маршрутов к <see cref="CardMoveResultDto"/>; снимок карточки
public sealed class CardMover(CardsService cardsService) : ICardMover /// эндпоинт перечитывает единым чтением. Scoped: зависимости живут в рамках tenant-запроса.
{ /// </remarks>
/// <inheritdoc /> /// <param name="cardsService">Сервис карточек — единый домен перехода (стадии и контейнеры дашборда).</param>
public async Task<CardMoveResultDto> MoveAsync( public sealed class CardMover(CardsService cardsService) : ICardMover
string cardId, string toContainerId, TransitionContext ctx, CancellationToken ct) {
{ /// <inheritdoc />
// Контекст перехода (инициатор/обучение) учтён внутри маршрутов: дашборд-перенос обучает ML по public async Task<CardMoveResultDto> MoveAsync(
// цели пользователя, переход по стадии пишет историю и сбрасывает напоминание. Отдельного string cardId, string toContainerId, TransitionContext ctx, CancellationToken ct)
// ветвления по ctx сейчас нет — оно появится с политиками контейнеров (R4). {
CardResultDto result = CardsDefaultContainers.Contains(toContainerId) // Контекст перехода (инициатор/обучение) учтён внутри маршрутов: дашборд-перенос обучает ML по
? await cardsService.MoveStageCardAsync(cardId, toContainerId, ct) // цели пользователя, переход по стадии пишет историю и сбрасывает напоминание. Отдельного
: await cardsService.MoveDashboardCardAsync(cardId, toContainerId, ct); // ветвления по ctx сейчас нет — оно появится с политиками контейнеров (R4).
return result.Error is not null CardResultDto result = CardsDefaultContainers.Contains(toContainerId)
? new CardMoveResultDto(result.Error, Exists: true) ? await cardsService.MoveStageCardAsync(cardId, toContainerId, ct)
: new CardMoveResultDto(null, Exists: result.Card is not null); : await cardsService.MoveDashboardCardAsync(cardId, toContainerId, ct);
} return result.Error is not null
} ? new CardMoveResultDto(result.Error, Exists: true)
: new CardMoveResultDto(null, Exists: result.Card is not null);
}
}
@@ -4,8 +4,11 @@ using Deal.Infrastructure.Persistence.Entities;
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; using Deal.Modules.Kanban.Application.Abstractions;
using Deal.Modules.Kanban.Application.Extensions;
using Deal.Modules.Kanban.Application.Models; using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Kanban.Application.Registrars;
using Deal.Modules.Kanban.Application.Services;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace Deal.Infrastructure.Tenancy; namespace Deal.Infrastructure.Tenancy;
@@ -2,7 +2,11 @@ using System.Collections.Concurrent;
using Deal.Infrastructure.Data; using Deal.Infrastructure.Data;
using Deal.Infrastructure.Migrations; using Deal.Infrastructure.Migrations;
using Deal.Infrastructure.Persistence; using Deal.Infrastructure.Persistence;
using Deal.Modules.Tenants.Application; using Deal.Modules.Tenants.Application.Abstractions;
using Deal.Modules.Tenants.Application.Extensions;
using Deal.Modules.Tenants.Application.Models;
using Deal.Modules.Tenants.Application.Registrars;
using Deal.Modules.Tenants.Application.Services;
using Deal.SharedKernel.Tenants; using Deal.SharedKernel.Tenants;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Npgsql; using Npgsql;
@@ -1,110 +1,113 @@
using System.Collections.Concurrent; using System.Collections.Concurrent;
using System.Diagnostics; using System.Diagnostics;
using Deal.Modules.Tenants.Application; using Deal.Modules.Tenants.Application.Abstractions;
using Deal.Modules.Tenants.Application.Models; using Deal.Modules.Tenants.Application.Extensions;
using Deal.SharedKernel.Tenants; using Deal.Modules.Tenants.Application.Models;
using Microsoft.Extensions.Logging; using Deal.Modules.Tenants.Application.Registrars;
using Deal.Modules.Tenants.Application.Services;
namespace Deal.Infrastructure.Tenancy; using Deal.SharedKernel.Tenants;
using Microsoft.Extensions.Logging;
/// <summary>
/// Пакетная (maintenance) миграция схем ВСЕХ существующих тенантов реестра (этап 12, пакет C) с namespace Deal.Infrastructure.Tenancy;
/// ограниченным параллелизмом и логированием прогресса — для провижининга SaaS на сотни/тысячи схем.
/// </summary> /// <summary>
/// <remarks> /// Пакетная (maintenance) миграция схем ВСЕХ существующих тенантов реестра (этап 12, пакет C) с
/// Идемпотентность обеспечивает сам провижининг: <c>CREATE SCHEMA IF NOT EXISTS</c> + EF Core /// ограниченным параллелизмом и логированием прогресса — для провижининга SaaS на сотни/тысячи схем.
/// <c>MigrateAsync</c>, который сверяется с таблицей истории <c>__TenantMigrationsHistory</c> схемы тенанта /// </summary>
/// и применяет ТОЛЬКО неприменённые миграции (повторный прогон ничего не меняет). Поэтому пакетный прогон /// <remarks>
/// безопасен и при повторном запуске, и параллельно со стартовым bootstrap. Сбой одной схемы не прерывает /// Идемпотентность обеспечивает сам провижининг: <c>CREATE SCHEMA IF NOT EXISTS</c> + EF Core
/// остальные: ошибка логируется, схема попадает в <see cref="TenantMigrationSummary.FailedSchemas"/>, а /// <c>MigrateAsync</c>, который сверяется с таблицей истории <c>__TenantMigrationsHistory</c> схемы тенанта
/// операция завершается итоговой сводкой. Степень параллелизма клампится разумными границами, чтобы не /// и применяет ТОЛЬКО неприменённые миграции (повторный прогон ничего не меняет). Поэтому пакетный прогон
/// перегрузить Postgres при большом реестре. /// безопасен и при повторном запуске, и параллельно со стартовым bootstrap. Сбой одной схемы не прерывает
/// </remarks> /// остальные: ошибка логируется, схема попадает в <see cref="TenantMigrationSummary.FailedSchemas"/>, а
public sealed class TenantSchemaMigrationService( /// операция завершается итоговой сводкой. Степень параллелизма клампится разумными границами, чтобы не
ITenantRepository tenantRepository, /// перегрузить Postgres при большом реестре.
ITenantProvisioner tenantProvisioner, /// </remarks>
ILogger<TenantSchemaMigrationService> logger) public sealed class TenantSchemaMigrationService(
{ ITenantRepository tenantRepository,
/// <summary> ITenantProvisioner tenantProvisioner,
/// Параллелизм пакетной миграции по умолчанию (схем одновременно). ILogger<TenantSchemaMigrationService> logger)
/// </summary> {
public const int DefaultMaxParallelism = 4; /// <summary>
/// Параллелизм пакетной миграции по умолчанию (схем одновременно).
// Нижняя граница параллелизма (ноль/отрицательное значение недопустимо). /// </summary>
private const int MinMaxParallelism = 1; public const int DefaultMaxParallelism = 4;
// Верхняя граница параллелизма: защита Postgres от лавины одновременных DDL-подключений. // Нижняя граница параллелизма (ноль/отрицательное значение недопустимо).
private const int MaxMaxParallelism = 32; private const int MinMaxParallelism = 1;
/// <summary> // Верхняя граница параллелизма: защита Postgres от лавины одновременных DDL-подключений.
/// Мигрирует схемы всех тенантов реестра с параллелизмом по умолчанию. private const int MaxMaxParallelism = 32;
/// </summary>
/// <param name="ct">Токен отмены.</param> /// <summary>
/// <returns>Итоговая сводка пакетной миграции.</returns> /// Мигрирует схемы всех тенантов реестра с параллелизмом по умолчанию.
public Task<TenantMigrationSummary> MigrateAllAsync(CancellationToken ct) /// </summary>
=> MigrateAllAsync(DefaultMaxParallelism, ct); /// <param name="ct">Токен отмены.</param>
/// <returns>Итоговая сводка пакетной миграции.</returns>
/// <summary> public Task<TenantMigrationSummary> MigrateAllAsync(CancellationToken ct)
/// Мигрирует схемы всех тенантов реестра с заданным параллелизмом (значение клампится). => MigrateAllAsync(DefaultMaxParallelism, ct);
/// </summary>
/// <param name="maxParallelism">Желаемое число схем, мигрируемых одновременно.</param> /// <summary>
/// <param name="ct">Токен отмены.</param> /// Мигрирует схемы всех тенантов реестра с заданным параллелизмом (значение клампится).
/// <returns>Итоговая сводка пакетной миграции.</returns> /// </summary>
public async Task<TenantMigrationSummary> MigrateAllAsync(int maxParallelism, CancellationToken ct) /// <param name="maxParallelism">Желаемое число схем, мигрируемых одновременно.</param>
{ /// <param name="ct">Токен отмены.</param>
int parallelism = Math.Clamp(maxParallelism, MinMaxParallelism, MaxMaxParallelism); /// <returns>Итоговая сводка пакетной миграции.</returns>
IReadOnlyList<TenantRecordDto> tenants = await tenantRepository.ListAsync(ct).ConfigureAwait(false); public async Task<TenantMigrationSummary> MigrateAllAsync(int maxParallelism, CancellationToken ct)
var stopwatch = Stopwatch.StartNew(); {
int parallelism = Math.Clamp(maxParallelism, MinMaxParallelism, MaxMaxParallelism);
if (tenants.Count == 0) IReadOnlyList<TenantRecordDto> tenants = await tenantRepository.ListAsync(ct).ConfigureAwait(false);
{ var stopwatch = Stopwatch.StartNew();
stopwatch.Stop();
logger.LogInformation("Пакетная миграция схем: в реестре нет тенантов — мигрировать нечего"); if (tenants.Count == 0)
return new TenantMigrationSummary(0, 0, 0, stopwatch.ElapsedMilliseconds, []); {
} stopwatch.Stop();
logger.LogInformation("Пакетная миграция схем: в реестре нет тенантов — мигрировать нечего");
var failedSchemas = new ConcurrentBag<string>(); return new TenantMigrationSummary(0, 0, 0, stopwatch.ElapsedMilliseconds, []);
int migrated = 0; }
await Parallel.ForEachAsync( var failedSchemas = new ConcurrentBag<string>();
tenants, int migrated = 0;
new ParallelOptions { MaxDegreeOfParallelism = parallelism, CancellationToken = ct },
async (tenant, token) => await Parallel.ForEachAsync(
{ tenants,
var tenantId = new TenantId(tenant.Id.ToString("N")); new ParallelOptions { MaxDegreeOfParallelism = parallelism, CancellationToken = ct },
try async (tenant, token) =>
{ {
await tenantProvisioner.ProvisionAsync(tenantId, token).ConfigureAwait(false); var tenantId = new TenantId(tenant.Id.ToString("N"));
int done = Interlocked.Increment(ref migrated); try
logger.LogInformation( {
"Пакетная миграция схем: {Done}/{Total} — {Schema} готова", await tenantProvisioner.ProvisionAsync(tenantId, token).ConfigureAwait(false);
done, int done = Interlocked.Increment(ref migrated);
tenants.Count, logger.LogInformation(
tenantId.SchemaName); "Пакетная миграция схем: {Done}/{Total} — {Schema} готова",
} done,
catch (Exception exception) when (exception is not OperationCanceledException) tenants.Count,
{ tenantId.SchemaName);
failedSchemas.Add(tenantId.SchemaName); }
logger.LogError( catch (Exception exception) when (exception is not OperationCanceledException)
exception, {
"Пакетная миграция схем: {Schema} не мигрирована", failedSchemas.Add(tenantId.SchemaName);
tenantId.SchemaName); logger.LogError(
} exception,
}).ConfigureAwait(false); "Пакетная миграция схем: {Schema} не мигрирована",
tenantId.SchemaName);
stopwatch.Stop(); }
var summary = new TenantMigrationSummary( }).ConfigureAwait(false);
tenants.Count,
migrated, stopwatch.Stop();
failedSchemas.Count, var summary = new TenantMigrationSummary(
stopwatch.ElapsedMilliseconds, tenants.Count,
failedSchemas.OrderBy(schema => schema, StringComparer.Ordinal).ToArray()); migrated,
logger.LogInformation( failedSchemas.Count,
"Пакетная миграция схем завершена: всего {Total}, успешно {Migrated}, сбоев {Failed}, {DurationMs} мс", stopwatch.ElapsedMilliseconds,
summary.Total, failedSchemas.OrderBy(schema => schema, StringComparer.Ordinal).ToArray());
summary.Migrated, logger.LogInformation(
summary.Failed, "Пакетная миграция схем завершена: всего {Total}, успешно {Migrated}, сбоев {Failed}, {DurationMs} мс",
summary.DurationMs); summary.Total,
return summary; summary.Migrated,
} summary.Failed,
} summary.DurationMs);
return summary;
}
}
@@ -1,4 +1,10 @@
namespace Deal.Modules.Discovery.Application; using Deal.Modules.Discovery.Application.Exceptions;
using Deal.Modules.Discovery.Application.Extensions;
using Deal.Modules.Discovery.Application.Models;
using Deal.Modules.Discovery.Application.Registrars;
using Deal.Modules.Discovery.Application.Services;
namespace Deal.Modules.Discovery.Application.Abstractions;
/// <summary> /// <summary>
/// Порт «пейсера» Discovery: паузы между сетевыми действиями (план Task 18, Ruling 10). /// Порт «пейсера» Discovery: паузы между сетевыми действиями (план Task 18, Ruling 10).
@@ -1,4 +1,10 @@
namespace Deal.Modules.Discovery.Application; using Deal.Modules.Discovery.Application.Exceptions;
using Deal.Modules.Discovery.Application.Extensions;
using Deal.Modules.Discovery.Application.Models;
using Deal.Modules.Discovery.Application.Registrars;
using Deal.Modules.Discovery.Application.Services;
namespace Deal.Modules.Discovery.Application.Abstractions;
/// <summary> /// <summary>
/// Счётчик ошибок поиска одного ключа по задачам (discovery_worker._search_errors L82, план Task 18). /// Счётчик ошибок поиска одного ключа по задачам (discovery_worker._search_errors L82, план Task 18).
@@ -1,6 +1,10 @@
using Deal.Modules.Discovery.Application.Models; using Deal.Modules.Discovery.Application.Models;
using Deal.Modules.Discovery.Application.Exceptions;
using Deal.Modules.Discovery.Application.Extensions;
using Deal.Modules.Discovery.Application.Registrars;
using Deal.Modules.Discovery.Application.Services;
namespace Deal.Modules.Discovery.Application; namespace Deal.Modules.Discovery.Application.Abstractions;
/// <summary> /// <summary>
/// Порт хранилища Discovery: таблицы DiscTasks/DiscCandidates/DiscBlacklist/DiscLog схемы тенанта (Ruling 9, Task 17). /// Порт хранилища Discovery: таблицы DiscTasks/DiscCandidates/DiscBlacklist/DiscLog схемы тенанта (Ruling 9, Task 17).
@@ -1,4 +1,10 @@
namespace Deal.Modules.Discovery.Application; using Deal.Modules.Discovery.Application.Abstractions;
using Deal.Modules.Discovery.Application.Extensions;
using Deal.Modules.Discovery.Application.Models;
using Deal.Modules.Discovery.Application.Registrars;
using Deal.Modules.Discovery.Application.Services;
namespace Deal.Modules.Discovery.Application.Exceptions;
/// <summary> /// <summary>
/// Доменная ошибка запроса Discovery — 400-семантика (аналог ValueError discovery.py). /// Доменная ошибка запроса Discovery — 400-семантика (аналог ValueError discovery.py).
@@ -1,6 +1,10 @@
using Deal.Modules.Discovery.Application.Models; using Deal.Modules.Discovery.Application.Models;
using Deal.Modules.Discovery.Application.Abstractions;
using Deal.Modules.Discovery.Application.Exceptions;
using Deal.Modules.Discovery.Application.Registrars;
using Deal.Modules.Discovery.Application.Services;
namespace Deal.Modules.Discovery.Application; namespace Deal.Modules.Discovery.Application.Extensions;
internal static class DiscoveryTaskPatchExtensions internal static class DiscoveryTaskPatchExtensions
{ {
@@ -6,21 +6,21 @@ namespace Deal.Modules.Discovery.Application.Models;
/// <remarks> /// <remarks>
/// Поля 1:1 с _candidate_view discovery.py L140158 и строкой disc_candidates (db.py L159176). marks/topics — /// Поля 1:1 с _candidate_view discovery.py L140158 и строкой disc_candidates (db.py L159176). marks/topics —
/// JSON-колонки, наружу всегда списки (marks — строки-метки, topics — элементы <see cref="DiscoveryTopicDto"/> для /// JSON-колонки, наружу всегда списки (marks — строки-метки, topics — элементы <see cref="DiscoveryTopicDto"/> для
/// форумов). Статус — <see cref="Deal.Modules.Discovery.Application.DiscoveryCandidateStatuses"/>; переводы в /// форумов). Статус — <see cref="Deal.Modules.Discovery.Application.Models.DiscoveryCandidateStatuses"/>; переводы в
/// joined/rejected — только через mark_joined/mark_rejected. /// joined/rejected — только через mark_joined/mark_rejected.
/// </remarks> /// </remarks>
/// <param name="DialogId">Подписанный id источника (первичный ключ кандидата; как Dialogs.Id).</param> /// <param name="DialogId">Подписанный id источника (первичный ключ кандидата; как Dialogs.Id).</param>
/// <param name="TaskId">Id задачи поиска, которой принадлежит кандидат.</param> /// <param name="TaskId">Id задачи поиска, которой принадлежит кандидат.</param>
/// <param name="Name">Отображаемое имя источника (пусто → DialogId).</param> /// <param name="Name">Отображаемое имя источника (пусто → DialogId).</param>
/// <param name="Username">Username (handle) источника; пуст, если нет публичного username.</param> /// <param name="Username">Username (handle) источника; пуст, если нет публичного username.</param>
/// <param name="Kind">Тип источника: channel|group|forum (см. <see cref="Deal.Modules.Discovery.Application.DiscoveryCandidateKinds"/>).</param> /// <param name="Kind">Тип источника: channel|group|forum (см. <see cref="Deal.Modules.Discovery.Application.Models.DiscoveryCandidateKinds"/>).</param>
/// <param name="Hue">Цвет источника из палитры DIALOG_HUES (hex «#rrggbb»; дефолт «#666»).</param> /// <param name="Hue">Цвет источника из палитры DIALOG_HUES (hex «#rrggbb»; дефолт «#666»).</param>
/// <param name="Participants">Число участников (после discovery_info); null — неизвестно.</param> /// <param name="Participants">Число участников (после discovery_info); null — неизвестно.</param>
/// <param name="LangRu">Язык источника: true — русский, false — не русский, null — не определён.</param> /// <param name="LangRu">Язык источника: true — русский, false — не русский, null — не определён.</param>
/// <param name="Marks">Метки оценки («закрытая группа (история скрыта)», «мало сообщений», …).</param> /// <param name="Marks">Метки оценки («закрытая группа (история скрыта)», «мало сообщений», …).</param>
/// <param name="Topics">Оценка тем форума (kind=forum; для каналов/групп пуст).</param> /// <param name="Topics">Оценка тем форума (kind=forum; для каналов/групп пуст).</param>
/// <param name="FitRatio">Доля подходящих сообщений оценки (0..1); null — контент не оценён.</param> /// <param name="FitRatio">Доля подходящих сообщений оценки (0..1); null — контент не оценён.</param>
/// <param name="Status">Статус кандидата (см. <see cref="Deal.Modules.Discovery.Application.DiscoveryCandidateStatuses"/>).</param> /// <param name="Status">Статус кандидата (см. <see cref="Deal.Modules.Discovery.Application.Models.DiscoveryCandidateStatuses"/>).</param>
/// <param name="AutoJoined">Вступили автоматически (воркером); false — вручную (join из UI).</param> /// <param name="AutoJoined">Вступили автоматически (воркером); false — вручную (join из UI).</param>
/// <param name="JoinFailures">Неудачные авто-вступления подряд (3 → кандидат удаляется, Task 18).</param> /// <param name="JoinFailures">Неудачные авто-вступления подряд (3 → кандидат удаляется, Task 18).</param>
/// <param name="CreatedAt">Время добавления, epoch-ms.</param> /// <param name="CreatedAt">Время добавления, epoch-ms.</param>
@@ -1,4 +1,10 @@
namespace Deal.Modules.Discovery.Application; using Deal.Modules.Discovery.Application.Abstractions;
using Deal.Modules.Discovery.Application.Exceptions;
using Deal.Modules.Discovery.Application.Extensions;
using Deal.Modules.Discovery.Application.Registrars;
using Deal.Modules.Discovery.Application.Services;
namespace Deal.Modules.Discovery.Application.Models;
/// <summary> /// <summary>
/// Типы источников-кандидатов Discovery (колонка DiscCandidates.Kind; api-map §4.8, discovery_worker _kind_code). /// Типы источников-кандидатов Discovery (колонка DiscCandidates.Kind; api-map §4.8, discovery_worker _kind_code).
@@ -1,4 +1,10 @@
namespace Deal.Modules.Discovery.Application; using Deal.Modules.Discovery.Application.Abstractions;
using Deal.Modules.Discovery.Application.Exceptions;
using Deal.Modules.Discovery.Application.Extensions;
using Deal.Modules.Discovery.Application.Registrars;
using Deal.Modules.Discovery.Application.Services;
namespace Deal.Modules.Discovery.Application.Models;
/// <summary> /// <summary>
/// Статусы кандидата Discovery (колонка DiscCandidates.Status; discovery.py L171, api-map §4.8). /// Статусы кандидата Discovery (колонка DiscCandidates.Status; discovery.py L171, api-map §4.8).
@@ -1,4 +1,10 @@
namespace Deal.Modules.Discovery.Application; using Deal.Modules.Discovery.Application.Abstractions;
using Deal.Modules.Discovery.Application.Exceptions;
using Deal.Modules.Discovery.Application.Extensions;
using Deal.Modules.Discovery.Application.Registrars;
using Deal.Modules.Discovery.Application.Services;
namespace Deal.Modules.Discovery.Application.Models;
/// <summary> /// <summary>
/// Счётчики прогресса задачи поиска (колонки DiscTasks Found/Evaluated/Joined/Rejected; discovery.py L359368). /// Счётчики прогресса задачи поиска (колонки DiscTasks Found/Evaluated/Joined/Rejected; discovery.py L359368).
@@ -1,4 +1,10 @@
namespace Deal.Modules.Discovery.Application; using Deal.Modules.Discovery.Application.Abstractions;
using Deal.Modules.Discovery.Application.Exceptions;
using Deal.Modules.Discovery.Application.Extensions;
using Deal.Modules.Discovery.Application.Registrars;
using Deal.Modules.Discovery.Application.Services;
namespace Deal.Modules.Discovery.Application.Models;
/// <summary> /// <summary>
/// Агрегат оценки выборки сообщений (python evaluate_sample L197226: fit_count/total/fit_ratio/per_message). /// Агрегат оценки выборки сообщений (python evaluate_sample L197226: fit_count/total/fit_ratio/per_message).
@@ -1,6 +1,11 @@
using System.Security.Cryptography; using System.Security.Cryptography;
using Deal.Modules.Discovery.Application.Abstractions;
using Deal.Modules.Discovery.Application.Exceptions;
using Deal.Modules.Discovery.Application.Extensions;
using Deal.Modules.Discovery.Application.Registrars;
using Deal.Modules.Discovery.Application.Services;
namespace Deal.Modules.Discovery.Application; namespace Deal.Modules.Discovery.Application.Models;
/// <summary> /// <summary>
/// Префиксы коротких id модуля Discovery и их генерация (прототип store.uid в discovery.py). /// Префиксы коротких id модуля Discovery и их генерация (прототип store.uid в discovery.py).
@@ -5,12 +5,12 @@ namespace Deal.Modules.Discovery.Application.Models;
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// Поля 1:1 с _log_view discovery.py L170177 и строкой disc_log (db.py L189195). Событие — каталог /// Поля 1:1 с _log_view discovery.py L170177 и строкой disc_log (db.py L189195). Событие — каталог
/// <see cref="Deal.Modules.Discovery.Application.DiscoveryLogEvents"/> (search|skip|review|join_auto|join_manual| /// <see cref="Deal.Modules.Discovery.Application.Models.DiscoveryLogEvents"/> (search|skip|review|join_auto|join_manual|
/// reject|flood|error|done|…). Список — последние события задачи, новые сверху (ORDER BY created_at DESC). /// reject|flood|error|done|…). Список — последние события задачи, новые сверху (ORDER BY created_at DESC).
/// </remarks> /// </remarks>
/// <param name="Id">Короткий id записи (префикс <c>dl_</c>).</param> /// <param name="Id">Короткий id записи (префикс <c>dl_</c>).</param>
/// <param name="TaskId">Id задачи поиска.</param> /// <param name="TaskId">Id задачи поиска.</param>
/// <param name="Event">Событие (см. <see cref="Deal.Modules.Discovery.Application.DiscoveryLogEvents"/>).</param> /// <param name="Event">Событие (см. <see cref="Deal.Modules.Discovery.Application.Models.DiscoveryLogEvents"/>).</param>
/// <param name="Text">Текст/детали события (русская строка 1:1 с прототипом).</param> /// <param name="Text">Текст/детали события (русская строка 1:1 с прототипом).</param>
/// <param name="CreatedAt">Время события, epoch-ms.</param> /// <param name="CreatedAt">Время события, epoch-ms.</param>
public sealed record DiscoveryLogDto( public sealed record DiscoveryLogDto(
@@ -1,4 +1,10 @@
namespace Deal.Modules.Discovery.Application; using Deal.Modules.Discovery.Application.Abstractions;
using Deal.Modules.Discovery.Application.Exceptions;
using Deal.Modules.Discovery.Application.Extensions;
using Deal.Modules.Discovery.Application.Registrars;
using Deal.Modules.Discovery.Application.Services;
namespace Deal.Modules.Discovery.Application.Models;
/// <summary> /// <summary>
/// События лога задачи Discovery (колонка DiscLog.Event; discovery.py L187188, api-map §3.8). /// События лога задачи Discovery (колонка DiscLog.Event; discovery.py L187188, api-map §3.8).
@@ -1,4 +1,10 @@
namespace Deal.Modules.Discovery.Application; using Deal.Modules.Discovery.Application.Abstractions;
using Deal.Modules.Discovery.Application.Exceptions;
using Deal.Modules.Discovery.Application.Extensions;
using Deal.Modules.Discovery.Application.Registrars;
using Deal.Modules.Discovery.Application.Services;
namespace Deal.Modules.Discovery.Application.Models;
/// <summary> /// <summary>
/// Вердикт фита одного сообщения (python evaluate_message L174194). /// Вердикт фита одного сообщения (python evaluate_message L174194).
@@ -5,7 +5,7 @@ namespace Deal.Modules.Discovery.Application.Models;
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// Поля 1:1 с _task_view discovery.py L116137 и строкой disc_tasks (db.py L136156). JSON-поле keywords /// Поля 1:1 с _task_view discovery.py L116137 и строкой disc_tasks (db.py L136156). JSON-поле keywords
/// наружу всегда список строк; статус — <see cref="Deal.Modules.Discovery.Application.DiscoveryTaskStatuses"/>. /// наружу всегда список строк; статус — <see cref="Deal.Modules.Discovery.Application.Models.DiscoveryTaskStatuses"/>.
/// createdAt/updatedAt — epoch-ms (времена хранятся UTC, на границе переводятся в ms — конвенция проекта). /// createdAt/updatedAt — epoch-ms (времена хранятся UTC, на границе переводятся в ms — конвенция проекта).
/// </remarks> /// </remarks>
/// <param name="Id">Короткий id задачи (префикс <c>dt_</c>).</param> /// <param name="Id">Короткий id задачи (префикс <c>dt_</c>).</param>
@@ -18,7 +18,7 @@ namespace Deal.Modules.Discovery.Application.Models;
/// <param name="SampleSize">Размер выборки сообщений при оценке (≥1; дефолт discEvalSample).</param> /// <param name="SampleSize">Размер выборки сообщений при оценке (≥1; дефолт discEvalSample).</param>
/// <param name="PlanJoins">План авто-вступлений задачи (1..discJoinLimit; занимает суточный бюджет).</param> /// <param name="PlanJoins">План авто-вступлений задачи (1..discJoinLimit; занимает суточный бюджет).</param>
/// <param name="AutoJoin">Авто-вступления воркером включены (иначе кандидатов вступает человек).</param> /// <param name="AutoJoin">Авто-вступления воркером включены (иначе кандидатов вступает человек).</param>
/// <param name="Status">Статус задачи (см. <see cref="Deal.Modules.Discovery.Application.DiscoveryTaskStatuses"/>).</param> /// <param name="Status">Статус задачи (см. <see cref="Deal.Modules.Discovery.Application.Models.DiscoveryTaskStatuses"/>).</param>
/// <param name="SearchIdx">Индекс текущего ключа поиска (прогресс прохода по keywords).</param> /// <param name="SearchIdx">Индекс текущего ключа поиска (прогресс прохода по keywords).</param>
/// <param name="SearchDone">Проход по всем ключам завершён (searchIdx ≥ keywords.Count).</param> /// <param name="SearchDone">Проход по всем ключам завершён (searchIdx ≥ keywords.Count).</param>
/// <param name="Found">Найдено кандидатов (счётчик found).</param> /// <param name="Found">Найдено кандидатов (счётчик found).</param>
@@ -5,7 +5,7 @@ namespace Deal.Modules.Discovery.Application.Models;
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// Write-модель: содержит полное состояние новой задачи, включая готовый id (<c>dt_...</c>, генерирует модуль — /// Write-модель: содержит полное состояние новой задачи, включая готовый id (<c>dt_...</c>, генерирует модуль —
/// <see cref="Deal.Modules.Discovery.Application.DiscoveryIdPrefixes"/>). Служебные значения, вычисленные до /// <see cref="Deal.Modules.Discovery.Application.Models.DiscoveryIdPrefixes"/>). Служебные значения, вычисленные до
/// записи: Status=<c>draft</c>, SearchIdx=0, SearchDone=false, счётчики Found/Evaluated/Joined/Rejected=0 — /// записи: Status=<c>draft</c>, SearchIdx=0, SearchDone=false, счётчики Found/Evaluated/Joined/Rejected=0 —
/// как в INSERT прототипа discovery.py L260280. CreatedAt/UpdatedAt проставляет хранилище (UTC-now); /// как в INSERT прототипа discovery.py L260280. CreatedAt/UpdatedAt проставляет хранилище (UTC-now);
/// keywords адаптер сериализует в JSON при записи. /// keywords адаптер сериализует в JSON при записи.
@@ -1,4 +1,10 @@
namespace Deal.Modules.Discovery.Application; using Deal.Modules.Discovery.Application.Abstractions;
using Deal.Modules.Discovery.Application.Exceptions;
using Deal.Modules.Discovery.Application.Extensions;
using Deal.Modules.Discovery.Application.Registrars;
using Deal.Modules.Discovery.Application.Services;
namespace Deal.Modules.Discovery.Application.Models;
/// <summary> /// <summary>
/// Статусы задачи поиска Discovery (колонка DiscTasks.Status; discovery.py L147, api-map §4.8). /// Статусы задачи поиска Discovery (колонка DiscTasks.Status; discovery.py L147, api-map §4.8).

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