Почистить комментарии от ссылок на ТЗ и обрывков

Удаление целых //-блоков со ссылками (Task/Ruling/этап/ТЗ/§/
дизайн-док/api-map/python/прототип) вместо построчного вырезания —
без обрывков фраз; снят боилерплейт <param>/<returns>; то же в
.proto.
This commit is contained in:
Rustam Khalimov
2026-09-11 13:49:26 +03:00
parent b053d58335
commit 79c931d88e
220 changed files with 32 additions and 1259 deletions
Binary file not shown.
@@ -12,10 +12,6 @@ using Microsoft.Extensions.DependencyInjection;
namespace Deal.Ai.Tests.Ai; namespace Deal.Ai.Tests.Ai;
// в процессе теста на эфемерном порту и через configureServices-хук подменяет LLM-фасад фейком
// (FakeProviderClient, без сети) и функцию паузы ретраев (мгновенная) — сценарии не
// ждут 0.8/2 с между попытками. Регистрация, добавленная харнессом после дефолтных, побеждает
// (DI резолвит последнюю). Исходное значение env DEAL_SERVICE_TOKEN восстанавливается.
internal static class AiTestHost internal static class AiTestHost
{ {
/// <summary> /// <summary>
@@ -101,7 +97,6 @@ internal static class AiTestHost
/// <summary> /// <summary>
/// Подменяет HTTP-фасад вызовов модели фейком сценария /// Подменяет HTTP-фасад вызовов модели фейком сценария
/// </summary> /// </summary>
/// <param name="services">Коллекция сервисов хоста.</param>
/// <param name="fake">Фейк-провайдер сценария.</param> /// <param name="fake">Фейк-провайдер сценария.</param>
public static void UseFakeProvider(IServiceCollection services, FakeProviderClient fake) public static void UseFakeProvider(IServiceCollection services, FakeProviderClient fake)
=> services.AddSingleton<IProviderClient>(fake); => services.AddSingleton<IProviderClient>(fake);
@@ -109,7 +104,6 @@ internal static class AiTestHost
/// <summary> /// <summary>
/// Делает паузы ретраев мгновенными /// Делает паузы ретраев мгновенными
/// </summary> /// </summary>
/// <param name="services">Коллекция сервисов хоста.</param>
public static void DisableRetryDelays(IServiceCollection services) public static void DisableRetryDelays(IServiceCollection services)
=> services.AddSingleton<Func<TimeSpan, CancellationToken, Task>>(static (_, _) => Task.CompletedTask); => services.AddSingleton<Func<TimeSpan, CancellationToken, Task>>(static (_, _) => Task.CompletedTask);
@@ -8,8 +8,6 @@ namespace Deal.Ai.Tests.Ai;
// Config: Конфиг провайдера вызова. // Config: Конфиг провайдера вызова.
internal sealed record FakeProviderCall(string SystemPrompt, string UserText, LlmConfig Config); internal sealed record FakeProviderCall(string SystemPrompt, string UserText, LlmConfig Config);
// usage либо сбой), каждый вызов записывается в Calls — тесты проверяют и собранные
// RPC-слоем промпты, и число попыток ретраев.
internal sealed class FakeProviderClient : IProviderClient internal sealed class FakeProviderClient : IProviderClient
{ {
private readonly Func<string, string, LlmConfig, Task<ProviderChatResult>> _handler; private readonly Func<string, string, LlmConfig, Task<ProviderChatResult>> _handler;
@@ -119,11 +119,6 @@ public sealed class AiServiceHostTests
channel => AssertRejectedAsync(channel, tokenHeader, expected)); channel => AssertRejectedAsync(channel, tokenHeader, expected));
} }
// Вызывает Classify и проверяет, что сервер ответил ожидаемым кодом статуса.
// Запрос несёт полный metadata (service-token + tenant-id), чтобы верный токен доходил
// channel: Канал к хосту ai-service.
// tokenHeader: Значение metadata «service-token» либо null (нет заголовка).
// expected: Ожидаемый StatusCode.
private static async Task AssertRejectedAsync( private static async Task AssertRejectedAsync(
GrpcChannel channel, GrpcChannel channel,
string? tokenHeader, string? tokenHeader,
@@ -13,7 +13,6 @@ internal sealed record CapturedHttpRequest(
IReadOnlyDictionary<string, string> Headers, IReadOnlyDictionary<string, string> Headers,
string? Body); string? Body);
// запросы (URL/заголовки/тело) и отвечает по сценарию; опциональная задержка — для теста таймаута.
internal sealed class StubHttpMessageHandler : HttpMessageHandler internal sealed class StubHttpMessageHandler : HttpMessageHandler
{ {
private readonly Func<HttpRequestMessage, HttpResponseMessage> _responder; private readonly Func<HttpRequestMessage, HttpResponseMessage> _responder;
-4
View File
@@ -26,15 +26,11 @@ public static class AiServiceHost
{ {
WebApplicationBuilder builder = WebApplication.CreateBuilder(args ?? []); WebApplicationBuilder builder = WebApplication.CreateBuilder(args ?? []);
// Общая серверная обвязка (Deal.Grpc.Hosting, C31): mTLS env DEAL_MTLS_* — загрузка
// сертификатов сразу с fail-fast (compose-prod монтирует deploy/certs, scripts/mtls-certs.sh);
MtlsCertificates? mtlsCertificates = GrpcServer.LoadMtlsCertificates(builder); MtlsCertificates? mtlsCertificates = GrpcServer.LoadMtlsCertificates(builder);
GrpcServer.ConfigureKestrelHttp2Endpoint(builder, grpcPort, mtlsCertificates); GrpcServer.ConfigureKestrelHttp2Endpoint(builder, grpcPort, mtlsCertificates);
builder.Services.AddDealGrpcServer(); builder.Services.AddDealGrpcServer();
builder.Services.AddReadyHealthCheck("хост ai-service готов"); builder.Services.AddReadyHealthCheck("хост ai-service готов");
// попытки управляется внутри — 90 с OpenAI / 60 с Anthropic; клиент без общего таймаута) и
// оркестратор ретраев/JSON/usage поверх него. Ключи API — в конфиге запроса, не в DI/логах.
builder.Services.AddHttpClient<Llm.LlmHttpClient>(static httpClient => builder.Services.AddHttpClient<Llm.LlmHttpClient>(static httpClient =>
httpClient.Timeout = Timeout.InfiniteTimeSpan); httpClient.Timeout = Timeout.InfiniteTimeSpan);
builder.Services.AddSingleton<Llm.IProviderClient>( builder.Services.AddSingleton<Llm.IProviderClient>(
-14
View File
@@ -275,7 +275,6 @@ public sealed class AiServiceImpl : AiService.AiServiceBase
} }
} }
// context: Контекст вызова.
private static string RequireTenantId(ServerCallContext context) private static string RequireTenantId(ServerCallContext context)
{ {
string? tenantId = context.RequestHeaders.GetValue(TenantIdMetadataKey); string? tenantId = context.RequestHeaders.GetValue(TenantIdMetadataKey);
@@ -349,10 +348,6 @@ public sealed class AiServiceImpl : AiService.AiServiceBase
providerConfig.ApiStyle); providerConfig.ApiStyle);
} }
// method: Имя RPC для аудита.
// tenantId: Id тенанта.
// config: Конфиг провайдера вызова.
// callError: Итоговая ошибка фасада.
private void LogAiUnavailable( private void LogAiUnavailable(
string method, string method,
string tenantId, string tenantId,
@@ -365,14 +360,9 @@ public sealed class AiServiceImpl : AiService.AiServiceBase
config.DisplayName, config.DisplayName,
callError.Kind); callError.Kind);
// callError: Итоговая ошибка фасада.
private static RpcException ToUnavailable(LlmCallException callError) private static RpcException ToUnavailable(LlmCallException callError)
=> new(new Status(StatusCode.Unavailable, callError.Message)); => new(new Status(StatusCode.Unavailable, callError.Message));
// Читает булево поле JSON-ответа модели: bool как есть; строка — ложь только для значений из
// json: Корневой объект ответа модели.
// fieldName: Имя поля.
// defaultValue: Значение при отсутствии/неразбираемости поля.
private static bool ReadBoolField( private static bool ReadBoolField(
JsonObject json, JsonObject json,
string fieldName, string fieldName,
@@ -416,9 +406,6 @@ public sealed class AiServiceImpl : AiService.AiServiceBase
return trimmed.Length == 0 ? null : trimmed; return trimmed.Length == 0 ? null : trimmed;
} }
// Причина решения fit: поле reason модели, при отсутствии — «подходит»/«не подходит»
// json: Корневой объект ответа модели.
// fit: Решение модели.
private static string ReadFitReason(JsonObject json, bool fit) private static string ReadFitReason(JsonObject json, bool fit)
{ {
string reason = ReadStringField(json, ReasonFieldName) ?? (fit ? FitReasonDefault : NotFitReasonDefault); string reason = ReadStringField(json, ReasonFieldName) ?? (fit ? FitReasonDefault : NotFitReasonDefault);
@@ -447,7 +434,6 @@ public sealed class AiServiceImpl : AiService.AiServiceBase
} }
} }
// keywords: Ключи задачи.
private static string JoinKeywords(IEnumerable<string> keywords) private static string JoinKeywords(IEnumerable<string> keywords)
=> string.Join( => string.Join(
", ", ", ",
@@ -46,8 +46,6 @@ public static class JsonExtractor
} }
} }
// между открывающей и закрывающей обёртками; обёртки нет/незакрыта — null.
// raw: Текст ответа (уже обрезанный).
private static string? UnwrapFence(string raw) private static string? UnwrapFence(string raw)
{ {
int open = raw.IndexOf(CodeFence, StringComparison.Ordinal); int open = raw.IndexOf(CodeFence, StringComparison.Ordinal);
@@ -97,10 +97,6 @@ public sealed class LlmHttpClient : IProviderClient
} }
} }
// Собирает запрос OpenAI-совместимого чата: {base}/chat/completions, Bearer при заданном ключе,
// config: Конфиг провайдера.
// systemPrompt: Системный промпт.
// userText: Пользовательское сообщение.
private static HttpRequestMessage BuildOpenAiRequest( private static HttpRequestMessage BuildOpenAiRequest(
LlmConfig config, LlmConfig config,
string systemPrompt, string systemPrompt,
@@ -126,10 +122,6 @@ public sealed class LlmHttpClient : IProviderClient
return request; return request;
} }
// Собирает запрос Anthropic Messages API: {base}/v1/messages, x-api-key + anthropic-version,
// config: Конфиг провайдера.
// systemPrompt: Системный промпт.
// userText: Пользовательское сообщение.
private static HttpRequestMessage BuildAnthropicRequest( private static HttpRequestMessage BuildAnthropicRequest(
LlmConfig config, LlmConfig config,
string systemPrompt, string systemPrompt,
@@ -171,7 +163,6 @@ public sealed class LlmHttpClient : IProviderClient
return isAnthropic ? ReadAnthropicBody(body) : ReadOpenAiBody(body); return isAnthropic ? ReadAnthropicBody(body) : ReadOpenAiBody(body);
} }
// body: Тело ответа.
private static ProviderChatResult ReadOpenAiBody(string body) private static ProviderChatResult ReadOpenAiBody(string body)
{ {
JsonObject? payload = ParseObjectOrThrow(body); JsonObject? payload = ParseObjectOrThrow(body);
@@ -193,7 +184,6 @@ public sealed class LlmHttpClient : IProviderClient
return new ProviderChatResult(content ?? string.Empty, ReadOpenAiUsage(payload["usage"])); return new ProviderChatResult(content ?? string.Empty, ReadOpenAiUsage(payload["usage"]));
} }
// body: Тело ответа.
private static ProviderChatResult ReadAnthropicBody(string body) private static ProviderChatResult ReadAnthropicBody(string body)
{ {
JsonObject? payload = ParseObjectOrThrow(body); JsonObject? payload = ParseObjectOrThrow(body);
@@ -265,7 +255,6 @@ public sealed class LlmHttpClient : IProviderClient
throw UnexpectedApiResponse("тело не является JSON-объектом"); throw UnexpectedApiResponse("тело не является JSON-объектом");
} }
// failureKind: Короткая причина (без тела ответа и секретов).
private static LlmHttpException UnexpectedApiResponse(string failureKind) private static LlmHttpException UnexpectedApiResponse(string failureKind)
=> new($"Неожиданный ответ ИИ-провайдера: {failureKind}"); => new($"Неожиданный ответ ИИ-провайдера: {failureKind}");
@@ -310,6 +299,5 @@ public sealed class LlmHttpClient : IProviderClient
return result; return result;
} }
// baseUrl: Базовый URL из конфига.
private static string NormalizeBaseUrl(string baseUrl) => baseUrl.TrimEnd('/'); private static string NormalizeBaseUrl(string baseUrl) => baseUrl.TrimEnd('/');
} }
-11
View File
@@ -1,11 +1,4 @@
// //
// Kestrel HTTP/2 на порту 5102 (env GRPC_PORT, затем PORT) + AddGrpc с интерцепторами service-token
// и access-лога + стандартный gRPC-health (grpc.health.v1.Health). Транспорт: dev — plaintext
// Production без mTLS не стартует (GrpcHostEnvironment.RequireMtlsInProduction).
// Серверная обвязка (Kestrel/AddGrpc/health) — общий Deal.Grpc.Hosting (C31): хост-фабрика
// AiServiceHost.Create используется и интеграционными тестами (Deal.Ai.Tests), которые поднимают
// его в своём процессе на эфемерном порту. Методы AiService (Filter/Classify/GenerateKeywords/
// задачи 7–8): фасад живёт в Deal.Ai/Llm.
using Deal.Ai; using Deal.Ai;
using Deal.Grpc.Hosting.Interceptors; using Deal.Grpc.Hosting.Interceptors;
@@ -13,16 +6,12 @@ using Deal.Grpc.Hosting.Models;
using Deal.Grpc.Hosting.Options; using Deal.Grpc.Hosting.Options;
using Deal.Grpc.Hosting.Services; using Deal.Grpc.Hosting.Services;
// или PORT (общий конвенциональный env хостинг-платформ) — см. GrpcHostEnvironment.ResolveGrpcPort.
const int defaultGrpcPort = 5102; const int defaultGrpcPort = 5102;
const string aiProcessName = "ai"; const string aiProcessName = "ai";
int grpcPort = GrpcHostEnvironment.ResolveGrpcPort(defaultGrpcPort); int grpcPort = GrpcHostEnvironment.ResolveGrpcPort(defaultGrpcPort);
int metricsPort = DealMetricsHosting.ResolveMetricsPort(DealMetricsHosting.DefaultMetricsPort); int metricsPort = DealMetricsHosting.ResolveMetricsPort(DealMetricsHosting.DefaultMetricsPort);
// конфигурируется production-точкой входа через configureBuilder-хук хоста (тесты хост поднимают
// без Serilog, DealLogging.Configure в AiServiceHost/Create вызывается только здесь). Метрики
// (OTel → Prometheus, /metrics) — тем же хуком до builder.Build().
WebApplication app = AiServiceHost.Create( WebApplication app = AiServiceHost.Create(
grpcPort, grpcPort,
configureBuilder: builder => configureBuilder: builder =>
-4
View File
@@ -41,7 +41,6 @@ service TelegramService {
// само. Нет сессии — FAILED_PRECONDITION «Telegram не подключён». // само. Нет сессии — FAILED_PRECONDITION «Telegram не подключён».
rpc GetStatus(GetStatusRequest) returns (GetStatusReply); rpc GetStatus(GetStatusRequest) returns (GetStatusReply);
// api_id/api_hash — глобальные ключи приложения Telegram, задаёт оператор (ТЗ §4.1/§8.1),
// не заданы оператором» до вызова. Ответ: новая фаза ("code"). // не заданы оператором» до вызова. Ответ: новая фаза ("code").
rpc StartPhone(StartPhoneRequest) returns (StartPhoneReply); rpc StartPhone(StartPhoneRequest) returns (StartPhoneReply);
@@ -73,7 +72,6 @@ service TelegramService {
rpc Backfill(BackfillRequest) returns (BackfillReply); rpc Backfill(BackfillRequest) returns (BackfillReply);
// свежие из Telegram; признак lead и фолбэк на БД добавляет ядро // свежие из Telegram; признак lead и фолбэк на БД добавляет ядро
// (у сервиса нет БД тенанта). limit 1..50 (api-map /dialogs/preview).
rpc ReadRecent(ReadRecentRequest) returns (ReadRecentReply); rpc ReadRecent(ReadRecentRequest) returns (ReadRecentReply);
// Пауза анти-бана после поиска — внутри сервиса. Личные чаты/боты ядро // Пауза анти-бана после поиска — внутри сервиса. Личные чаты/боты ядро
@@ -208,7 +206,6 @@ message SetMonitorAllRequest {
message SetMonitorAllReply { message SetMonitorAllReply {
bool ok = 1; bool ok = 1;
// Сколько диалогов в каталоге тенанта (api-map /monitor-all → count).
int32 count = 2; int32 count = 2;
bool enabled = 3; bool enabled = 3;
} }
@@ -228,7 +225,6 @@ message BackfillReply {
message ReadRecentRequest { message ReadRecentRequest {
// Id диалога. // Id диалога.
string dialog_id = 1; string dialog_id = 1;
// Сколько последних сообщений (1..50; api-map /dialogs/preview limit 1..50).
int32 limit = 2; int32 limit = 2;
} }
@@ -48,11 +48,6 @@ public static class AiCheckEndpoint
return Results.Ok(result); return Results.Ok(result);
} }
// store: KV-хранилище настроек тенанта.
// secretCipher: Шифр секретов (расшифровка apiKey).
// ct: Токен отмены.
// Возвращает: Запрос проверки: id провайдера + эффективные base/model + расшифрованный ключ.
// Эффективные значения = дефолты SettingsDefaults, перекрытые сохранёнными
private static async Task<AiCheckRequest> BuildActiveCheckRequestAsync( private static async Task<AiCheckRequest> BuildActiveCheckRequestAsync(
ISettingsStore store, ISettingsStore store,
ISecretCipher secretCipher, ISecretCipher secretCipher,
@@ -38,8 +38,6 @@ public static class AiSuggestEndpoints
return app; return app;
} }
// POST /api/ai/suggest-columns: анализ «Неразобранного» и создание колонок-предложений.
// SSE-toast), {ok:false, reason} (+ cooldown) — мягкая причина (HTTP 200). Кулдаун/«мало карточек»/
private static async Task<IResult> SuggestColumnsAsync(HttpContext context, CancellationToken ct) private static async Task<IResult> SuggestColumnsAsync(HttpContext context, CancellationToken ct)
{ {
if (!context.HasUser()) if (!context.HasUser())
@@ -61,7 +59,6 @@ public static class AiSuggestEndpoints
return Results.Ok(result); return Results.Ok(result);
} }
// POST /api/ai/suggest-keywords: слова-маркеры сферы по карточкам (настройки «Сфера и ключи»).
private static async Task<IResult> SuggestKeywordsAsync(HttpContext context, CancellationToken ct) private static async Task<IResult> SuggestKeywordsAsync(HttpContext context, CancellationToken ct)
{ {
if (!context.HasUser()) if (!context.HasUser())
@@ -31,7 +31,6 @@ public static class AuthEndpoints
{ {
var group = app.MapGroup(AuthGroupPrefix).WithTags(AuthOpenApiTag); var group = app.MapGroup(AuthGroupPrefix).WithTags(AuthOpenApiTag);
// ручки входа; остальные ручки группы — под глобальной API-политикой (по тенанту/IP).
group.MapPost("/login", LoginAsync).RequireRateLimiting(RateLimitPolicies.AuthPolicy); group.MapPost("/login", LoginAsync).RequireRateLimiting(RateLimitPolicies.AuthPolicy);
group.MapPost("/logout", LogoutAsync); group.MapPost("/logout", LogoutAsync);
group.MapGet("/me", MeAsync); group.MapGet("/me", MeAsync);
@@ -51,7 +50,6 @@ public static class AuthEndpoints
{ {
string? attemptedLogin = NormalizeLogin(body.Login); string? attemptedLogin = NormalizeLogin(body.Login);
// 429 «Слишком много попыток входа…» (в dev при RateLimit:Enabled=false гвард выключен).
if (await loginAttemptGuard.IsBlockedAsync(ClientIp(context), attemptedLogin, ct)) if (await loginAttemptGuard.IsBlockedAsync(ClientIp(context), attemptedLogin, ct))
{ {
return EndpointResults.TooManyRequests(LoginAttemptGuard.BlockedDetail); return EndpointResults.TooManyRequests(LoginAttemptGuard.BlockedDetail);
@@ -74,7 +72,6 @@ public static class AuthEndpoints
if (result.Login is null || result.Token is null) if (result.Login is null || result.Token is null)
{ {
// счётчик неудач гварда растёт там же — пустые логины ключа не имеют (блокирует только auth-политика).
if (attemptedLogin is not null) if (attemptedLogin is not null)
{ {
await loginAttemptGuard.RecordFailureAsync(ClientIp(context), attemptedLogin, ct); await loginAttemptGuard.RecordFailureAsync(ClientIp(context), attemptedLogin, ct);
@@ -104,7 +101,6 @@ public static class AuthEndpoints
return Results.Ok(new { ok = true, login = result.Login }); return Results.Ok(new { ok = true, login = result.Login });
} }
// POST /api/auth/logout: удаление сессии по токену из куки и очистка куки (всегда ok).
private static async Task<IResult> LogoutAsync( private static async Task<IResult> LogoutAsync(
AuthService authService, AuthService authService,
AuditService auditService, AuditService auditService,
@@ -425,8 +425,6 @@ public static class CardDetailsEndpoints
// Возвращает: Имя, безопасное для заголовка. // Возвращает: Имя, безопасное для заголовка.
private static string ToDownloadFileName(string name) => name.Replace(FileNameQuoteCharacter, string.Empty); private static string ToDownloadFileName(string name) => name.Replace(FileNameQuoteCharacter, string.Empty);
// body: Тело запроса.
// Возвращает: DTO модуля для CardsService.CreateLocalCardAsync.
private static CardLocalCreateDto ToCreateLocalDto(CreateCardRequest body) private static CardLocalCreateDto ToCreateLocalDto(CreateCardRequest body)
{ {
return new CardLocalCreateDto( return new CardLocalCreateDto(
@@ -158,7 +158,6 @@ public static class CardsEndpoints
if (body.Col is null) if (body.Col is null)
{ {
// Пустая/отсутствующая col попала бы в mark_seen как «не задана» и сняла бы «новое» со ВСЕХ
return EndpointResults.BadRequest(UnknownColumnDetail); return EndpointResults.BadRequest(UnknownColumnDetail);
} }
@@ -167,10 +166,6 @@ public static class CardsEndpoints
return Results.Ok(new { ok = true }); return Results.Ok(new { ok = true });
} }
// Маршрутизацию цели (стадия «Выбранных» vs дашборд-контейнер) и побочные эффекты выполняет единый
// доменный механизм перехода ICardMover: стадия — запись истории и сброс напоминания
// (move_stage), дашборд-контейнер — журнал/обучение ML. Ответ — обновлённая карточка; 400 при
// несуществующем контейнере, 404 — карточки нет.
private static async Task<IResult> MoveAsync( private static async Task<IResult> MoveAsync(
string cardId, string cardId,
MoveBody body, MoveBody body,
@@ -380,11 +375,6 @@ public static class CardsEndpoints
reason = result.Reason, reason = result.Reason,
}; };
// Публикуется только когда проход реально выполнен и что-то изменил (started и
// reclassified &gt; 0): пустой inbox/всё пропущено не меняют доску — событие не шлём. Нагрузка
// минимальная: сколько обработано и перемещено (фронт перечитывает доску). Без подписчиков — no-op.
// context: Контекст запроса (тенант-канал сессии).
// result: Итог прохода.
private static void PublishReclassified(HttpContext context, ReclassifyResultDto result) private static void PublishReclassified(HttpContext context, ReclassifyResultDto result)
{ {
if (!result.Started || result.Reclassified == 0) if (!result.Started || result.Reclassified == 0)
@@ -210,9 +210,6 @@ public static class DiscoveryEndpoints
return task is null ? EndpointResults.NotFound(TaskNotFoundDetail) : Results.Ok(task); return task is null ? EndpointResults.NotFound(TaskNotFoundDetail) : Results.Ok(task);
} }
// POST /api/discovery/tasks/{task_id}/generate-keywords: ИИ-ключи по описанию задачи
// символов, дедуп casefold); описание режет до 4000 сам адаптер (GrpcAiTools.MaxDescriptionCodePoints).
// Локальный режим (LocalAiTools, UseLocal=true) — NotSupportedException → та же мягкая ветка с текстом причины.
private static async Task<IResult> GenerateKeywordsAsync( private static async Task<IResult> GenerateKeywordsAsync(
string task_id, string task_id,
HttpContext context, HttpContext context,
@@ -259,7 +256,6 @@ public static class DiscoveryEndpoints
} }
} }
// GET /api/discovery/tasks/{task_id}/candidates?status=: кандидаты задачи с фильтром
private static async Task<IResult> ListCandidatesAsync( private static async Task<IResult> ListCandidatesAsync(
string task_id, string task_id,
string? status, string? status,
@@ -283,8 +279,6 @@ public static class DiscoveryEndpoints
return Results.Ok(new { items }); return Results.Ok(new { items });
} }
// RPC Join → строка каталога Dialogs (монитор on) + зеркало → фоновый первый разбор → снятие чёрного списка →
// mark_joined(auto:false). Ошибка Telegram → 400 с текстом причины.
private static async Task<IResult> JoinCandidateAsync( private static async Task<IResult> JoinCandidateAsync(
string dialog_id, string dialog_id,
HttpContext context, HttpContext context,
@@ -319,11 +313,9 @@ public static class DiscoveryEndpoints
return EndpointResults.BadRequest(string.Format(JoinFailedFormat, username, reason)); return EndpointResults.BadRequest(string.Format(JoinFailedFormat, username, reason));
} }
// Источник в каталоге (монитор on, backfilled=false) + монитор-зеркало telegram-service
DialogsService dialogs = context.RequestServices.GetRequiredService<DialogsService>(); DialogsService dialogs = context.RequestServices.GetRequiredService<DialogsService>();
await dialogs.AddDiscoveredMonitoredAsync(dialog_id, row.Name, username, row.Kind, row.Hue, ct); await dialogs.AddDiscoveredMonitoredAsync(dialog_id, row.Name, username, row.Kind, row.Hue, ct);
// Догон последних сообщений — в фоне: join из UI не должен висеть на паузах backfill
context.RequestServices.GetRequiredService<TelegramBackfillScheduler>().ScheduleFirstBackfill(dialog_id); context.RequestServices.GetRequiredService<TelegramBackfillScheduler>().ScheduleFirstBackfill(dialog_id);
DiscoveryBlacklistService blacklist = context.RequestServices.GetRequiredService<DiscoveryBlacklistService>(); DiscoveryBlacklistService blacklist = context.RequestServices.GetRequiredService<DiscoveryBlacklistService>();
@@ -340,7 +332,6 @@ public static class DiscoveryEndpoints
} }
} }
// POST /api/discovery/candidates/{dialog_id}/reject: отклонить кандидата — в чёрный список
private static async Task<IResult> RejectCandidateAsync( private static async Task<IResult> RejectCandidateAsync(
string dialog_id, string dialog_id,
HttpContext context, HttpContext context,
@@ -462,9 +453,6 @@ public static class DiscoveryEndpoints
private const string ServiceUnavailableText = "ИИ недоступен — повторите попытку через несколько секунд"; private const string ServiceUnavailableText = "ИИ недоступен — повторите попытку через несколько секунд";
// Читает настройку aiEnabled (KV; отсутствие строки — дефолт SettingsDefaults).
// settings: KV-хранилище настроек тенанта.
// ct: Токен отмены.
private static async Task<bool> ReadAiEnabledAsync(ISettingsStore settings, CancellationToken ct) private static async Task<bool> ReadAiEnabledAsync(ISettingsStore settings, CancellationToken ct)
{ {
SettingValue? row = await settings.GetAsync(SettingsKeys.AiEnabled, ct); SettingValue? row = await settings.GetAsync(SettingsKeys.AiEnabled, ct);
@@ -505,8 +493,6 @@ public static class DiscoveryEndpoints
}; };
} }
// body: Тело запроса (wire-поля camelCase).
// Возвращает: Патч задачи (DiscoveryTaskPatch).
private static DiscoveryTaskPatch ToPatch(DiscoveryTaskPatchBody body) private static DiscoveryTaskPatch ToPatch(DiscoveryTaskPatchBody body)
{ {
return new DiscoveryTaskPatch return new DiscoveryTaskPatch
@@ -3,6 +3,6 @@ namespace Deal.Api.Endpoints;
/// <summary> /// <summary>
/// Тело POST /api/ml/candidates. /// Тело POST /api/ml/candidates.
/// </summary> /// </summary>
/// <param name="DialogId">Id диалога/канала Telegram; пусто — выборка по всем источникам тенанта (§8).</param> /// <param name="DialogId">Id диалога/канала Telegram; пусто — выборка по всем источникам тенанта.</param>
/// <param name="Limit">Сколько последних сообщений вернуть (кламп 1..60, дефолт 10).</param> /// <param name="Limit">Сколько последних сообщений вернуть (кламп 1..60, дефолт 10).</param>
public sealed record MlCandidatesRequest(string DialogId, int Limit); public sealed record MlCandidatesRequest(string DialogId, int Limit);
@@ -36,13 +36,6 @@ public static class OperatorAnalyticsEndpoints
return app; return app;
} }
// GET /api/operator/analytics/suspicious?from=&amp;to=: находки детектора подозрительной активности (§10.5).
// from: Начало окна анализа (включительно; ISO-8601); null — последние 24 часа.
// to: Конец окна анализа (включительно; ISO-8601); null — «сейчас».
// context: Контекст запроса.
// suspiciousService: Детектор подозрительной активности (scoped).
// ct: Токен отмены.
// Возвращает: 200 сводка находок или 401 без операторской сессии.
private static async Task<IResult> SuspiciousAsync( private static async Task<IResult> SuspiciousAsync(
DateTimeOffset? from, DateTimeOffset? from,
DateTimeOffset? to, DateTimeOffset? to,
@@ -28,18 +28,6 @@ public static class OperatorAuditEndpoints
return app; return app;
} }
// GET /api/operator/audit?eventType=&amp;actorType=&amp;actorId=&amp;tenantId=&amp;from=&amp;to=&amp;limit=&amp;offset=: лента аудита.
// eventType: Фильтр по типу события (равенство; пусто — без фильтра).
// actorType: Фильтр по типу актора operator|tenant|system (равенство).
// actorId: Фильтр по идентификатору актора (равенство).
// tenantId: Фильтр по тенанту (равенство).
// from: Нижняя граница At (включительно; ISO-8601).
// to: Верхняя граница At (включительно; ISO-8601).
// limit: Размер выборки (дефолт 100, клампится 1..500).
// context: Контекст запроса.
// auditService: Сервис аудита (scoped).
// ct: Токен отмены.
// Возвращает: 200 {items:[...], total} или 401 без операторской сессии.
private static async Task<IResult> ListAsync( private static async Task<IResult> ListAsync(
string? eventType, string? eventType,
string? actorType, string? actorType,
@@ -29,7 +29,6 @@ public static class OperatorAuthEndpoints
{ {
var group = app.MapGroup(OperatorAuthGroupPrefix).WithTags(OperatorAuthOpenApiTag); var group = app.MapGroup(OperatorAuthGroupPrefix).WithTags(OperatorAuthOpenApiTag);
// входа оператора; остальные ручки группы — под глобальной API-политикой (по тенанту/IP).
group.MapPost("/login", LoginAsync).RequireRateLimiting(RateLimitPolicies.AuthPolicy); group.MapPost("/login", LoginAsync).RequireRateLimiting(RateLimitPolicies.AuthPolicy);
group.MapPost("/logout", LogoutAsync); group.MapPost("/logout", LogoutAsync);
group.MapGet("/me", MeAsync); group.MapGet("/me", MeAsync);
@@ -48,7 +47,6 @@ public static class OperatorAuthEndpoints
{ {
string? attemptedLogin = NormalizeLogin(body.Login); string? attemptedLogin = NormalizeLogin(body.Login);
// ip|login до проверки учётных данных (в dev при RateLimit:Enabled=false гвард выключен).
if (await loginAttemptGuard.IsBlockedAsync(ClientIp(context), attemptedLogin, ct)) if (await loginAttemptGuard.IsBlockedAsync(ClientIp(context), attemptedLogin, ct))
{ {
return EndpointResults.TooManyRequests(LoginAttemptGuard.BlockedDetail); return EndpointResults.TooManyRequests(LoginAttemptGuard.BlockedDetail);
@@ -57,8 +55,6 @@ public static class OperatorAuthEndpoints
var result = await operatorAuthService.LoginAsync(body.Login, body.Password, ct); var result = await operatorAuthService.LoginAsync(body.Login, body.Password, ct);
if (result.Login is null || result.Token is null) if (result.Login is null || result.Token is null)
{ {
// Неверные учётные данные оператора — одно сообщение (зеркало AuthEndpoints).
// счётчик неудач гварда растёт там же (пустые логины ключа не имеют).
if (attemptedLogin is not null) if (attemptedLogin is not null)
{ {
await loginAttemptGuard.RecordFailureAsync(ClientIp(context), attemptedLogin, ct); await loginAttemptGuard.RecordFailureAsync(ClientIp(context), attemptedLogin, ct);
@@ -81,7 +81,6 @@ public static class OperatorTenantsEndpoints
return Results.Ok(new { items }); return Results.Ok(new { items });
} }
// POST /api/operator/tenants: создание тенанта (Status active + провижининг схемы); аудит tenant_created.
private static async Task<IResult> CreateAsync( private static async Task<IResult> CreateAsync(
OperatorTenantCreateRequest body, OperatorTenantCreateRequest body,
HttpContext context, HttpContext context,
@@ -84,7 +84,6 @@ public static class PipelineEndpoints
return Results.Ok(new { items, counts, rejected }); return Results.Ok(new { items, counts, rejected });
} }
// первыми; offset ≥ 0, limit 1..500 (clamp в сервисе), значения эхом в ответе {items,total,offset,limit}.
private static async Task<IResult> RejectedAsync( private static async Task<IResult> RejectedAsync(
string? q, string? q,
int? offset, int? offset,
@@ -46,7 +46,6 @@ public static class RatesEndpoints
RatesDto current = await ratesService.GetAsync(ct); RatesDto current = await ratesService.GetAsync(ct);
// фоновый RefreshAsync в отдельном scope; ответ — текущий кэш.
if (await ratesService.ShouldFetchAsync(ct)) if (await ratesService.ShouldFetchAsync(ct))
{ {
context.RequestServices.GetRequiredService<RatesRefreshScheduler>().Schedule(); context.RequestServices.GetRequiredService<RatesRefreshScheduler>().Schedule();
@@ -53,7 +53,6 @@ public static class SettingsEndpoints
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
} }
// Произвольный JSON-объект: ключи тела — публичные ключи §4.6 (как их шлёт фронт).
Dictionary<string, JsonElement>? body; Dictionary<string, JsonElement>? body;
try try
{ {
@@ -64,7 +63,6 @@ public static class SettingsEndpoints
} }
catch (JsonException) catch (JsonException)
{ {
// Не-JSON или не-объект целиком — ошибка запроса: 400 + detail
return EndpointResults.BadRequest(InvalidBodyDetail); return EndpointResults.BadRequest(InvalidBodyDetail);
} }
@@ -86,8 +84,6 @@ public static class SettingsEndpoints
return Results.Ok(result); return Results.Ok(result);
} }
// body: Тело PATCH — публичные ключи §4.6.
// Возвращает: True — поле rateSource передано «правдивым» значением (не null/пустая строка).
private static bool ShouldScheduleRatesRefresh(Dictionary<string, JsonElement> body) private static bool ShouldScheduleRatesRefresh(Dictionary<string, JsonElement> body)
{ {
if (!body.TryGetValue(SettingsKeys.RateSource, out JsonElement element)) if (!body.TryGetValue(SettingsKeys.RateSource, out JsonElement element))
@@ -30,9 +30,6 @@ public static class StorageEndpoints
return app; return app;
} }
// Весь состав тика — AdminTickOrchestrator (вынесен из эндпоинта для unit-тестов логики и
// переиспользования): тик StorageTickService (Kanban) → PurgeExpiredAsync (отсев 3 суток, merge в
// storage.purgedRejected) → тосты статистики (включая «Отсев очищен: N записей (3 дн.)») → CheckDueAsync
private static async Task<IResult> AdminTickAsync(HttpContext context, CancellationToken ct) private static async Task<IResult> AdminTickAsync(HttpContext context, CancellationToken ct)
{ {
if (!context.HasUser()) if (!context.HasUser())
@@ -45,8 +42,6 @@ public static class StorageEndpoints
return Results.Ok(await orchestrator.TickAsync(tenantId, ct)); return Results.Ok(await orchestrator.TickAsync(tenantId, ct));
} }
// SearchTsv — генерируемые STORED-колонки Cards/RejectedItems: авто-актуальны, «пересборка» = создание
// отсутствующих GIN-индексов (CREATE INDEX IF NOT EXISTS) + ANALYZE таблиц (FtsMaintenance.RebuildAsync).
private static async Task<IResult> FtsRebuildAsync(HttpContext context, CancellationToken ct) private static async Task<IResult> FtsRebuildAsync(HttpContext context, CancellationToken ct)
{ {
if (!context.HasUser()) if (!context.HasUser())
@@ -55,13 +55,11 @@ public static class TelegramEndpoints
// Путь включения/выключения мониторинга одного диалога (POST, параметр dialog_id). // Путь включения/выключения мониторинга одного диалога (POST, параметр dialog_id).
private const string DialogMonitorPath = "/dialogs/{dialog_id}/monitor"; private const string DialogMonitorPath = "/dialogs/{dialog_id}/monitor";
// Путь разбора одного диалога (POST, параметр dialog_id; сервер-only, api-map п.9).
private const string DialogBackfillPath = "/dialogs/{dialog_id}/backfill"; private const string DialogBackfillPath = "/dialogs/{dialog_id}/backfill";
// Деталь 400: сохранённый api_id не является корректным числом (Security review: int.TryParse). // Деталь 400: сохранённый api_id не является корректным числом (Security review: int.TryParse).
private const string InvalidApiIdDetail = "Некорректный api_id — проверьте глобальные ключи Telegram"; private const string InvalidApiIdDetail = "Некорректный api_id — проверьте глобальные ключи Telegram";
// Деталь 400 без ключей приложения: оператор не задал глобальные ключи Telegram (ТЗ §4.1/§8.1).
private const string NoKeysDetail = "Ключи Telegram не заданы оператором"; private const string NoKeysDetail = "Ключи Telegram не заданы оператором";
// Деталь недоступного telegram-service/неподключённого аккаунта (глобальная строка контракта). // Деталь недоступного telegram-service/неподключённого аккаунта (глобальная строка контракта).
@@ -296,7 +294,6 @@ public static class TelegramEndpoints
DialogsService dialogs = context.RequestServices.GetRequiredService<DialogsService>(); DialogsService dialogs = context.RequestServices.GetRequiredService<DialogsService>();
ITelegramGateway gateway = context.RequestServices.GetRequiredService<ITelegramGateway>(); ITelegramGateway gateway = context.RequestServices.GetRequiredService<ITelegramGateway>();
// {ok:false, reason:"not-connected", count:0} HTTP 200 — refresh не ошибка запроса.
bool connected; bool connected;
try try
{ {
@@ -456,8 +453,6 @@ public static class TelegramEndpoints
return keys.KeysSet ? keys : null; return keys.KeysSet ? keys : null;
} }
// exception: Исключение вызова гейта (RpcException домена/транспорта, прочее).
// Возвращает: 400-ответ с текстом причины.
private static IResult GatewayError(Exception exception) private static IResult GatewayError(Exception exception)
{ {
return EndpointResults.BadRequest(GatewayErrorText(exception)); return EndpointResults.BadRequest(GatewayErrorText(exception));
@@ -30,7 +30,6 @@ public static class AuthHelpers
/// <summary> /// <summary>
/// Кладёт пользователя в <c>HttpContext.Items</c>. /// Кладёт пользователя в <c>HttpContext.Items</c>.
/// </summary> /// </summary>
/// <param name="context">Контекст запроса.</param>
/// <param name="user">Пользователь, разрешённый по сессии.</param> /// <param name="user">Пользователь, разрешённый по сессии.</param>
public static void SetCurrentUser(this HttpContext context, CurrentUser user) => public static void SetCurrentUser(this HttpContext context, CurrentUser user) =>
context.Items[CurrentUserItemKey] = user; context.Items[CurrentUserItemKey] = user;
@@ -38,7 +37,6 @@ public static class AuthHelpers
/// <summary> /// <summary>
/// Возвращает текущего пользователя запроса или null, если сессия не разрешена. /// Возвращает текущего пользователя запроса или null, если сессия не разрешена.
/// </summary> /// </summary>
/// <param name="context">Контекст запроса.</param>
/// <returns>Пользователь или null.</returns> /// <returns>Пользователь или null.</returns>
public static CurrentUser? GetCurrentUser(this HttpContext context) => public static CurrentUser? GetCurrentUser(this HttpContext context) =>
context.Items[CurrentUserItemKey] as CurrentUser; context.Items[CurrentUserItemKey] as CurrentUser;
@@ -46,14 +44,12 @@ public static class AuthHelpers
/// <summary> /// <summary>
/// Проверяет, разрешена ли для запроса пользовательская сессия. /// Проверяет, разрешена ли для запроса пользовательская сессия.
/// </summary> /// </summary>
/// <param name="context">Контекст запроса.</param>
/// <returns>True — текущий пользователь установлен.</returns> /// <returns>True — текущий пользователь установлен.</returns>
public static bool HasUser(this HttpContext context) => context.GetCurrentUser() is not null; public static bool HasUser(this HttpContext context) => context.GetCurrentUser() is not null;
/// <summary> /// <summary>
/// Кладёт оператора в <c>HttpContext.Items</c>. /// Кладёт оператора в <c>HttpContext.Items</c>.
/// </summary> /// </summary>
/// <param name="context">Контекст запроса.</param>
/// <param name="operatorIdentity">Оператор, разрешённый по операторской сессии.</param> /// <param name="operatorIdentity">Оператор, разрешённый по операторской сессии.</param>
public static void SetCurrentOperator(this HttpContext context, CurrentOperator operatorIdentity) => public static void SetCurrentOperator(this HttpContext context, CurrentOperator operatorIdentity) =>
context.Items[CurrentOperatorItemKey] = operatorIdentity; context.Items[CurrentOperatorItemKey] = operatorIdentity;
@@ -61,7 +57,6 @@ public static class AuthHelpers
/// <summary> /// <summary>
/// Возвращает текущего оператора запроса или null, если операторская сессия не разрешена. /// Возвращает текущего оператора запроса или null, если операторская сессия не разрешена.
/// </summary> /// </summary>
/// <param name="context">Контекст запроса.</param>
/// <returns>Оператор или null (ручка /api/operator/* отвечает 401).</returns> /// <returns>Оператор или null (ручка /api/operator/* отвечает 401).</returns>
public static CurrentOperator? GetCurrentOperator(this HttpContext context) => public static CurrentOperator? GetCurrentOperator(this HttpContext context) =>
context.Items[CurrentOperatorItemKey] as CurrentOperator; context.Items[CurrentOperatorItemKey] as CurrentOperator;
@@ -158,7 +158,6 @@ public sealed class BudgetAlertScheduler : IHostedService
{ {
ITenantLimitStore limitStore = tenantScope.ServiceProvider.GetRequiredService<ITenantLimitStore>(); ITenantLimitStore limitStore = tenantScope.ServiceProvider.GetRequiredService<ITenantLimitStore>();
// TryMark* — атомарная установка флага: true только в момент первого наблюдения порога за период
if (await limitStore.TryMarkWarnedAsync(tenant.Id, ct)) if (await limitStore.TryMarkWarnedAsync(tenant.Id, ct))
{ {
_logger.LogDebug("Бюджет тенанта {TenantId}: порог 80% пересечён — SSE-тост", tenant.Id); _logger.LogDebug("Бюджет тенанта {TenantId}: порог 80% пересечён — SSE-тост", tenant.Id);
@@ -140,11 +140,6 @@ public sealed class MlOutboxFlushScheduler : IHostedService
} }
} }
// Выгрузка очереди одного тенанта в собственном scope: SetTenant → порции по 10 до ≤100/цикл.
// цикл тенанта завершается (следующая попытка — следующий тик). Сбой хранилища тенанта не валит проход:
// ошибка логируется, остальные тенанты обрабатываются; отмена (OCE) пробрасывается наверх.
// tenant: Тенант реестра (Id в формате Guid; схема — tenant_&lt;N&gt;).
// ct: Токен отмены прохода.
private async Task FlushTenantAsync(TenantRecordDto tenant, CancellationToken ct) private async Task FlushTenantAsync(TenantRecordDto tenant, CancellationToken ct)
{ {
await using AsyncServiceScope tenantScope = _scopeFactory.CreateAsyncScope(); await using AsyncServiceScope tenantScope = _scopeFactory.CreateAsyncScope();
@@ -176,7 +171,6 @@ public sealed class MlOutboxFlushScheduler : IHostedService
} }
catch (Exception exception) catch (Exception exception)
{ {
// попытка — на следующем тике; флашер не роняет проход цикла.
_logger.LogWarning( _logger.LogWarning(
exception, exception,
"Флашер ML-outbox: отправка {RowCount} строк тенанта {TenantId} не удалась — строки остались", "Флашер ML-outbox: отправка {RowCount} строк тенанта {TenantId} не удалась — строки остались",
@@ -143,13 +143,6 @@ public sealed class StorageTickScheduler : IHostedService
} }
} }
// Тик одного тенанта в собственном scope: SetTenant → Kanban-тик → purge отсева → SSE-тосты →
// тосты → напоминания).
// Контекст AsyncLocal сбрасывается в finally, чтобы не переживать scope тенанта (как
// SessionMiddleware). Тик одного тенанта не валит проход: ошибка ветки/тенанта логируется, остальные
// тенанты обрабатываются; отмена (OCE) пробрасывается наверх — проход завершается.
// tenant: Тенант реестра (Id в формате Guid; схема — tenant_&lt;N&gt;).
// ct: Токен отмены прохода.
private async Task TickTenantAsync(TenantRecordDto tenant, CancellationToken ct) private async Task TickTenantAsync(TenantRecordDto tenant, CancellationToken ct)
{ {
await using AsyncServiceScope tenantScope = _scopeFactory.CreateAsyncScope(); await using AsyncServiceScope tenantScope = _scopeFactory.CreateAsyncScope();
@@ -162,15 +155,12 @@ public sealed class StorageTickScheduler : IHostedService
StorageTickService tickService = tenantScope.ServiceProvider.GetRequiredService<StorageTickService>(); StorageTickService tickService = tenantScope.ServiceProvider.GetRequiredService<StorageTickService>();
StorageTickStatsDto stats = await tickService.TickAsync(ct); StorageTickStatsDto stats = await tickService.TickAsync(ct);
// (AdminTickOrchestrator), тост «Отсев очищен: N записей (3 дн.)» публикуется этой же веткой.
PipelineProcessingService processing = tenantScope.ServiceProvider.GetRequiredService<PipelineProcessingService>(); PipelineProcessingService processing = tenantScope.ServiceProvider.GetRequiredService<PipelineProcessingService>();
int purgedRejected = await processing.PurgeExpiredAsync(ct); int purgedRejected = await processing.PurgeExpiredAsync(ct);
StorageTickStatsDto mergedStats = stats with { PurgedRejected = purgedRejected }; StorageTickStatsDto mergedStats = stats with { PurgedRejected = purgedRejected };
_toastPublisher.PublishTickToasts(tenant.Id, mergedStats); _toastPublisher.PublishTickToasts(tenant.Id, mergedStats);
// возвращает их {id,title,containerId}. Сбой проверки НЕ роняет тик тенанта/проход: лог-предупреждение,
// остальные тенанты обрабатываются (паттерн ветки AdminTickOrchestrator). SSE reminder_due по каждой
IReadOnlyList<CardReminderDueDto> dueReminders; IReadOnlyList<CardReminderDueDto> dueReminders;
try try
{ {
@@ -38,7 +38,6 @@ public sealed class TenantBootstrapService(IServiceScopeFactory scopeFactory) :
var password = configuration[BootstrapPasswordEnvKey] ?? DefaultAdminPassword; var password = configuration[BootstrapPasswordEnvKey] ?? DefaultAdminPassword;
var environment = scope.ServiceProvider.GetRequiredService<IHostEnvironment>(); var environment = scope.ServiceProvider.GetRequiredService<IHostEnvironment>();
// в Development или при DEAL_BOOTSTRAP_DEFAULT_TENANT=1. В Production тенантов заводит оператор.
var seedDefaultTenant = environment.IsDevelopment() var seedDefaultTenant = environment.IsDevelopment()
|| configuration[DefaultTenantBootstrapEnvKey] == DefaultTenantBootstrapEnabledValue; || configuration[DefaultTenantBootstrapEnvKey] == DefaultTenantBootstrapEnabledValue;
if (seedDefaultTenant) if (seedDefaultTenant)
@@ -65,8 +64,6 @@ public sealed class TenantBootstrapService(IServiceScopeFactory scopeFactory) :
} }
} }
// что созданного в dev: пакетная идемпотентная миграция (CREATE SCHEMA IF NOT EXISTS + Migrate,
// применяющий только неприменённые миграции) с ограниченным параллелизмом и логированием прогресса —
var tenantSchemaMigrationService = scope.ServiceProvider.GetRequiredService<TenantSchemaMigrationService>(); var tenantSchemaMigrationService = scope.ServiceProvider.GetRequiredService<TenantSchemaMigrationService>();
await tenantSchemaMigrationService.MigrateAllAsync(ct); await tenantSchemaMigrationService.MigrateAllAsync(ct);
} }
-11
View File
@@ -4,17 +4,6 @@ using Serilog.Formatting.Compact;
namespace Deal.Api.Logging; namespace Deal.Api.Logging;
// Все четыре процесса (core + telegram/ai/ml-сервисы) логируют через Serilog: консоль — JSON в
// prod-стиле (CompactJsonFormatter: одна JSON-строка на событие, поля @t/@mt/@l — парсинг
// Loki/Promtail) либо текст в Development; плюс rolling-файл data/logs/deal-&lt;процесс&gt;.json
// (под ContentRoot; у core каталог data смонтирован volume-ом, compose.prod). Уровень/каталог
// переопределяются env: DEAL_LOG_LEVEL, DEAL_LOGS_DIR.
// Конфигурация кодом, а не секцией appsettings: у трёх сервисов appsettings.json нет (весь конфиг —
// стек: Serilog-логи → docker-логи → Promtail → Loki → Grafana.
// Вызов — из Program.cs процесса (entry point): DealLogging.Configure(builder, "…") ДО
// builder.Build(). Интеграционные тесты сервисов поднимают хост через *ServiceHost.Create
// БЕЗ этого вызова (конфигурация логирования — забота production-точки входа), поэтому тесты не пишут
// файлы-логи и не меняют своё логирование.
internal static class DealLogging internal static class DealLogging
{ {
// Env-ключ минимального уровня Serilog (Debug/Information/Warning/Error; дефолт Information). // Env-ключ минимального уровня Serilog (Debug/Information/Warning/Error; дефолт Information).
@@ -29,7 +29,6 @@ public sealed class HttpAccessLogMiddleware
/// <summary> /// <summary>
/// Обрабатывает запрос /// Обрабатывает запрос
/// </summary> /// </summary>
/// <param name="context">Контекст запроса.</param>
public async Task InvokeAsync(HttpContext context) public async Task InvokeAsync(HttpContext context)
{ {
if (context.Request.ContentType?.StartsWith(GrpcContentType, StringComparison.OrdinalIgnoreCase) == true) if (context.Request.ContentType?.StartsWith(GrpcContentType, StringComparison.OrdinalIgnoreCase) == true)
@@ -24,7 +24,6 @@ public sealed class OperatorSessionMiddleware
/// <summary> /// <summary>
/// Обрабатывает запрос /// Обрабатывает запрос
/// </summary> /// </summary>
/// <param name="context">Контекст запроса.</param>
public async Task InvokeAsync(HttpContext context) public async Task InvokeAsync(HttpContext context)
{ {
var cookieName = _cookieOptions.CurrentValue.Name; var cookieName = _cookieOptions.CurrentValue.Name;
@@ -30,7 +30,6 @@ public sealed class OriginGuardMiddleware
/// <summary> /// <summary>
/// Обрабатывает запрос /// Обрабатывает запрос
/// </summary> /// </summary>
/// <param name="context">Контекст запроса.</param>
public async Task InvokeAsync(HttpContext context) public async Task InvokeAsync(HttpContext context)
{ {
HttpRequest request = context.Request; HttpRequest request = context.Request;
@@ -49,9 +49,7 @@ public static class RateLimitPolicies
/// <summary> /// <summary>
/// Регистрирует AddRateLimiter с политиками "auth"/"api" и глобальным лимитером API-партиции. /// Регистрирует AddRateLimiter с политиками "auth"/"api" и глобальным лимитером API-партиции.
/// </summary> /// </summary>
/// <param name="services">Коллекция сервисов хоста.</param>
/// <param name="options">Настройки rate limiting (секция RateLimit).</param> /// <param name="options">Настройки rate limiting (секция RateLimit).</param>
/// <returns>Коллекция сервисов для цепочки вызовов.</returns>
public static IServiceCollection AddDealRateLimiter(this IServiceCollection services, RateLimitOptions options) public static IServiceCollection AddDealRateLimiter(this IServiceCollection services, RateLimitOptions options)
{ {
ArgumentNullException.ThrowIfNull(services); ArgumentNullException.ThrowIfNull(services);
@@ -126,8 +124,6 @@ public static class RateLimitPolicies
return string.IsNullOrEmpty(ip) ? UnknownClientKey : IpKeyPrefix + ip; return string.IsNullOrEmpty(ip) ? UnknownClientKey : IpKeyPrefix + ip;
} }
// context: Контекст отклонённого запроса.
// cancellationToken: Токен отмены ответа.
private static async ValueTask OnRejectedAsync(OnRejectedContext context, CancellationToken cancellationToken) private static async ValueTask OnRejectedAsync(OnRejectedContext context, CancellationToken cancellationToken)
{ {
context.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests; context.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests;
@@ -31,7 +31,6 @@ public sealed class SessionMiddleware
/// <summary> /// <summary>
/// Обрабатывает запрос /// Обрабатывает запрос
/// </summary> /// </summary>
/// <param name="context">Контекст запроса.</param>
public async Task InvokeAsync(HttpContext context) public async Task InvokeAsync(HttpContext context)
{ {
try try
-102
View File
@@ -52,25 +52,17 @@ const string serverUrlsKey = "urls";
// Фолбэк основного HTTP-адреса при отсутствии явных URL (дефолт ASP.NET Core http://localhost:5000). // Фолбэк основного HTTP-адреса при отсутствии явных URL (дефолт ASP.NET Core http://localhost:5000).
const string defaultHttpUrl = "http://localhost:5000"; const string defaultHttpUrl = "http://localhost:5000";
const string defaultAiBudgetEnvKey = "DEAL_DEFAULT_AI_BUDGET"; const string defaultAiBudgetEnvKey = "DEAL_DEFAULT_AI_BUDGET";
// умолчанию (curl-приёмки не режутся); PROD включает env-переопределением RateLimit__Enabled=true
const string rateLimitSectionName = "RateLimit"; const string rateLimitSectionName = "RateLimit";
// фоновый цикл DataRetentionScheduler чистит audit_log по retention, лимиты/счётчики прошедших окон.
const string dataRetentionSectionName = "DataRetention"; const string dataRetentionSectionName = "DataRetention";
const string securitySectionName = "Security"; const string securitySectionName = "Security";
// KnownNetworks — доверенные прокси (Caddy в PROD); dev-дефолт в appsettings — loopback.
const string forwardedHeadersSectionName = "ForwardedHeaders"; const string forwardedHeadersSectionName = "ForwardedHeaders";
const string coreProcessName = "core"; const string coreProcessName = "core";
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
// rolling-файл data/logs/deal-core-*.json (data — volume контейнера). Уровень/каталог — env
// DEAL_LOG_LEVEL/DEAL_LOGS_DIR (см. DealLogging). Регистрируется до остальных сервисов: логирование
// заменяет провайдеры Microsoft при builder.Build().
DealLogging.Configure(builder, coreProcessName); DealLogging.Configure(builder, coreProcessName);
// отдельном HTTP/1.1 Kestrel-эндпоинте (порт 9464/env METRICS_PORT) + инструментация входящих HTTP-
// запросов и исходящих HTTP-клиентов; прикладные метрики — SharedKernel/Observability/DealMetrics.
int metricsPort = DealMetricsHosting.ResolveMetricsPort(DealMetricsHosting.DefaultMetricsPort); int metricsPort = DealMetricsHosting.ResolveMetricsPort(DealMetricsHosting.DefaultMetricsPort);
DealMetricsHosting.AddDealMetrics(builder, metricsPort); DealMetricsHosting.AddDealMetrics(builder, metricsPort);
@@ -82,9 +74,6 @@ builder.Services.AddDbContext<DealDbContext>(options => options.UseNpgsql(connec
builder.Services.AddSingleton<ITenantContext, TenantContext>(); builder.Services.AddSingleton<ITenantContext, TenantContext>();
builder.Services.AddSingleton<ConnectionStringProvider>(); builder.Services.AddSingleton<ConnectionStringProvider>();
// (генерация — scripts/mtls-certs.sh). При DEAL_MTLS_ENABLED=1 сертификаты грузятся сразу (fail-fast на
// битые пути/пароли) — один экземпляр используют и Kestrel-ингресс ниже, и gRPC-клиенты
// (Ml/Ai/Telegram-каналы + ServiceHealthProbe).
MtlsOptions mtlsOptions = MtlsOptions.FromConfiguration(builder.Configuration); MtlsOptions mtlsOptions = MtlsOptions.FromConfiguration(builder.Configuration);
MtlsCertificates? mtlsCertificates = MtlsCertificates.Load(mtlsOptions); MtlsCertificates? mtlsCertificates = MtlsCertificates.Load(mtlsOptions);
if (mtlsCertificates is not null) if (mtlsCertificates is not null)
@@ -92,9 +81,6 @@ if (mtlsCertificates is not null)
builder.Services.AddSingleton(mtlsCertificates); builder.Services.AddSingleton(mtlsCertificates);
} }
// Kestrel: основной HTTP/1.1-эндпоинт из URL-конфигурации (как раньше — --urls/ASPNETCORE_URLS/
// launchSettings) + второй endpoint gRPC-ингресса telegram-service (:5082, HTTP/2, env GRPC_INGRESS_PORT;
// пере-биндим адресами конфигурации "urls" явно (см. BindMainHttpEndpoints ниже). Ingress слушает все
builder.WebHost.ConfigureKestrel(kestrel => builder.WebHost.ConfigureKestrel(kestrel =>
{ {
BindMainHttpEndpoints(kestrel, builder.Configuration[serverUrlsKey]); BindMainHttpEndpoints(kestrel, builder.Configuration[serverUrlsKey]);
@@ -104,7 +90,6 @@ builder.WebHost.ConfigureKestrel(kestrel =>
listen.Protocols = HttpProtocols.Http2; listen.Protocols = HttpProtocols.Http2;
if (mtlsCertificates is not null) if (mtlsCertificates is not null)
{ {
// сертификат (цепочка до CA из DEAL_MTLS_CA_PEM). Основной HTTP :5080 остаётся http — TLS наружу
listen.UseHttps(https => listen.UseHttps(https =>
{ {
https.ServerCertificate = mtlsCertificates.ServerCertificate; https.ServerCertificate = mtlsCertificates.ServerCertificate;
@@ -141,83 +126,49 @@ builder.Services.AddDbContext<TenantDbContext>(
builder.Services.AddTenantsModule(); builder.Services.AddTenantsModule();
// env DEAL_DEFAULT_AI_BUDGET (токенов в месяц) с фолбэком на константу модуля TokenBudgetDefaults (10 000 000);
// период нового тенанта — month (константа). Значение читается один раз на старте и передаётся адаптеру
// TenantLimitStore (GetOrCreateAsync при первом чтении/списании, задачи 7/10 list-путь тоже закрыт).
TokenLimitDefaults tenantLimitDefaults = new( TokenLimitDefaults tenantLimitDefaults = new(
ResolveDefaultAiBudget(builder.Configuration), TokenBudgetDefaults.DefaultPeriod); ResolveDefaultAiBudget(builder.Configuration), TokenBudgetDefaults.DefaultPeriod);
builder.Services.AddDealPersistence(tenantLimitDefaults); builder.Services.AddDealPersistence(tenantLimitDefaults);
// либо файла data/encryption.key под ContentRoot (dev). Ключ разрешается на старте —
// невалидный env-ключ останавливает запуск.
builder.Services.AddDealSecurity(builder.Environment.ContentRootPath); builder.Services.AddDealSecurity(builder.Environment.ContentRootPath);
// ml-service :5103). Local-адаптеры читают KV-настройки тенанта через ISettingsStore — scoped (вне
MlServiceOptions mlOptions = builder.Configuration.GetSection(servicesSectionName).Get<MlServiceOptions>() ?? new MlServiceOptions(); MlServiceOptions mlOptions = builder.Configuration.GetSection(servicesSectionName).Get<MlServiceOptions>() ?? new MlServiceOptions();
builder.Services.AddSingleton(mlOptions); builder.Services.AddSingleton(mlOptions);
// default; локальный разбор ядра / инструменты не поддерживаются) либо декораторы бюджетного гейта поверх
// gRPC-клиентов ai-service (UseLocal=false, ai-service :5102; AddDealIntegrations регистрирует GrpcAiClassifier/
// GrpcAiTools + транспорт AiGrpcConnection — fail-fast, как MlGrpcConnection — и оборачивает их в
AiServiceOptions aiOptions = builder.Configuration.GetSection(aiServicesSectionName).Get<AiServiceOptions>() ?? new AiServiceOptions(); AiServiceOptions aiOptions = builder.Configuration.GetSection(aiServicesSectionName).Get<AiServiceOptions>() ?? new AiServiceOptions();
builder.Services.AddSingleton(aiOptions); builder.Services.AddSingleton(aiOptions);
// нейтральный no-op/idle) либо gRPC-клиент GrpcTelegramClient (UseLocal=false, telegram-service :5101).
TelegramServiceOptions telegramOptions = builder.Configuration.GetSection(telegramServicesSectionName).Get<TelegramServiceOptions>() ?? new TelegramServiceOptions(); TelegramServiceOptions telegramOptions = builder.Configuration.GetSection(telegramServicesSectionName).Get<TelegramServiceOptions>() ?? new TelegramServiceOptions();
builder.Services.AddSingleton(telegramOptions); builder.Services.AddSingleton(telegramOptions);
builder.Services.AddDealIntegrations(mlOptions, aiOptions, telegramOptions, mtlsCertificates); builder.Services.AddDealIntegrations(mlOptions, aiOptions, telegramOptions, mtlsCertificates);
// к Services:*:Endpoint с дедлайном 3 с (ServiceHealthProbe). Stateless, singleton — пробы строят
builder.Services.AddSingleton(new ServiceHealthProbe(mtlsCertificates)); builder.Services.AddSingleton(new ServiceHealthProbe(mtlsCertificates));
// под ContentRoot) — dev/curl/unit по умолчанию; MinioFileStorage регистрируется, только когда сконфигурирован
// MinIO (секция Storage:Minio либо env-алиасы DEAL_MINIO_*; compose-сервис deal-minio, порты 9000/9001).
// Singleton: хранилище не привязано к схеме тенанта (объекты — в едином бакете/каталоге, мульти-аренда
builder.Services.AddDealFileStorage(builder.Configuration, builder.Environment.ContentRootPath); builder.Services.AddDealFileStorage(builder.Configuration, builder.Environment.ContentRootPath);
// Модуль Settings (сервис настроек тенанта); адаптеры ISettingsStore/ISecretCipher уже
builder.Services.AddSettingsModule(); builder.Services.AddSettingsModule();
// порт-адаптер ICardStore → KanbanStore уже зарегистрирован AddDealPersistence.
builder.Services.AddKanbanModule(); builder.Services.AddKanbanModule();
// IPipelineStore → PipelineStore и внешние порты (IMlClient/IAiClassifier) уже зарегистрированы
// AddDealPersistence/AddDealIntegrations выше; сервисы модуля вызывают из эндпоинтов /api/pipeline/*
builder.Services.AddPipelineModule(); builder.Services.AddPipelineModule();
// Dialogs/TgMessages схемы тенанта (миграция TenantTelegram). Порт-адаптеры ITelegramStore → TelegramStore и
// ITelegramGateway → LocalTelegramGateway/GrpcTelegramClient зарегистрированы AddDealPersistence/AddDealIntegrations
builder.Services.AddTelegramModule(); builder.Services.AddTelegramModule();
// и воркер (оценка/бан-гард/паузы). Порт-адаптер IDiscoveryStore → DiscoveryStore зарегистрирован
// AddDealPersistence; внешние порты (ITelegramGateway/IAiTools/IMlClient) — AddDealIntegrations выше. Эндпоинты
builder.Services.AddDiscoveryModule(); builder.Services.AddDiscoveryModule();
// счётчик мониторящихся + keysSet) и чтение глобальных ключей приложения (telegramKeys в public.global_settings,
// расшифровка apiHash — задаёт оператор, ТЗ §4.1/§8.1). Scoped: зависимости — ISettingsStore/ITelegramStore
// на TenantDbContext схемы тенанта запроса, IGlobalSettingsStore — на системном DealDbContext.
builder.Services.AddScoped<TgStatusService>(); builder.Services.AddScoped<TgStatusService>();
builder.Services.AddScoped<TelegramKeysService>(); builder.Services.AddScoped<TelegramKeysService>();
// мониторинга/«Перечитать» отвечают сразу, тяжёлый разбор идёт в отдельном scope с захваченным tenant-контекстом.
builder.Services.AddSingleton<TelegramBackfillScheduler>(); builder.Services.AddSingleton<TelegramBackfillScheduler>();
// Pipeline + один проход pump + SSE-публикации (тосты/new_card) для POST /api/admin/tick (StorageEndpoints).
// Scoped: зависимости живут в рамках tenant-запроса (scoped-сервисы модулей на TenantDbContext схемы).
builder.Services.AddScoped<AdminTickOrchestrator>(); builder.Services.AddScoped<AdminTickOrchestrator>();
// CREATE INDEX IF NOT EXISTS + ANALYZE (FtsMaintenance) на TenantDbContext запроса (scoped, как адаптеры).
builder.Services.AddScoped<FtsMaintenance>(); builder.Services.AddScoped<FtsMaintenance>();
builder.Services.AddSingleton<SseBroker>(); builder.Services.AddSingleton<SseBroker>();
builder.Services.AddSingleton<StorageToastPublisher>(); builder.Services.AddSingleton<StorageToastPublisher>();
// POST /api/admin/tick (AdminTickOrchestrator) и фоновый цикл разбора очереди (PipelineWorkerScheduler)
// не разбирают очередь одного тенанта одновременно (singleton per-tenant флагов, Interlocked).
builder.Services.AddSingleton<PipelinePumpGate>(); builder.Services.AddSingleton<PipelinePumpGate>();
// "RateLimit" (appsettings.json + env RateLimit__*). Enabled=false в dev/тестах — политики/middleware/
// LoginAttemptGuard (окно ip|login 5 неудач/15 мин в public.rate_limit_counters) регистрируется всегда,
// но активен только при Enabled.
RateLimitOptions rateLimitOptions = builder.Configuration RateLimitOptions rateLimitOptions = builder.Configuration
.GetSection(rateLimitSectionName) .GetSection(rateLimitSectionName)
.Get<RateLimitOptions>() ?? new RateLimitOptions(); .Get<RateLimitOptions>() ?? new RateLimitOptions();
@@ -230,18 +181,12 @@ if (rateLimitOptions.Enabled)
builder.Services.AddDealRateLimiter(rateLimitOptions); builder.Services.AddDealRateLimiter(rateLimitOptions);
} }
// на отдельном Kestrel-endpoint (:5082, HTTP/2, см. ConfigureKestrel выше) в том же процессе. Token
// из metadata «service-token» проверяет интерцептор (fail-closed, DEAL_SERVICE_TOKEN); AddAuthentication
// не нужен — пользовательская сессия HTTP ингрессом не используется (tenant-id из metadata → SetTenant).
builder.Services.AddGrpc(grpc => builder.Services.AddGrpc(grpc =>
{ {
// вызовы (401/429); gRPC-health не логируется (см. RpcCallLoggingInterceptor).
grpc.Interceptors.Add<RpcCallLoggingInterceptor>(); grpc.Interceptors.Add<RpcCallLoggingInterceptor>();
grpc.Interceptors.Add<IngressServiceTokenInterceptor>(); grpc.Interceptors.Add<IngressServiceTokenInterceptor>();
if (rateLimitOptions.Enabled) if (rateLimitOptions.Enabled)
{ {
// singleton-лимитер (CreateLimiter) — экземпляры интерцептора общий PartitionedRateLimiter
// разделяют; health-методы освобождены (см. IngressRateLimitInterceptor).
grpc.Interceptors.Add<IngressRateLimitInterceptor>(); grpc.Interceptors.Add<IngressRateLimitInterceptor>();
} }
}); });
@@ -255,62 +200,39 @@ if (rateLimitOptions.Enabled)
builder.Services.AddScoped<TelegramIngressService>(); builder.Services.AddScoped<TelegramIngressService>();
// grpc.health.v1.Health интерцептор токеном не проверяет (инфраструктурный liveness, как в сервисах
// Живучесть интеграций (ml/ai/telegram) health не проверяет — недоступность сервиса это UNAVAILABLE
builder.Services builder.Services
.AddGrpcHealthChecks() .AddGrpcHealthChecks()
.AddCheck("ready", () => HealthCheckResult.Healthy("хост Deal.Api готов")); .AddCheck("ready", () => HealthCheckResult.Healthy("хост Deal.Api готов"));
// HTTP-адаптер Infrastructure с собственным HttpClient (фабрика AddHttpClient, таймаут 12 с).
// HTTP наружу ходит только по действию Settings-экрана (POST /api/ai/check) — GET {base}/models.
builder.Services.AddHttpClient<IAiConnectionChecker, AiConnectionChecker>( builder.Services.AddHttpClient<IAiConnectionChecker, AiConnectionChecker>(
client => client.Timeout = TimeSpan.FromSeconds(AiConnectionChecker.RequestTimeoutSeconds)); client => client.Timeout = TimeSpan.FromSeconds(AiConnectionChecker.RequestTimeoutSeconds));
// адаптера (SSRF-allowlist), источник тенантом не настраивается. Типизированный клиент
builder.Services.AddHttpClient<IRatesSource, CbrRateSource>( builder.Services.AddHttpClient<IRatesSource, CbrRateSource>(
client => client.Timeout = TimeSpan.FromSeconds(CbrRateSource.RequestTimeoutSeconds)); client => client.Timeout = TimeSpan.FromSeconds(CbrRateSource.RequestTimeoutSeconds));
// собственный scope + in-flight guard (см. RatesRefreshScheduler).
builder.Services.AddSingleton<RatesRefreshScheduler>(); builder.Services.AddSingleton<RatesRefreshScheduler>();
builder.Services.AddHostedService<TenantBootstrapService>(); builder.Services.AddHostedService<TenantBootstrapService>();
// dev-дефолт operator/operator в Development; в Production без env — warning и пропуск. Идёт после
// TenantBootstrapService: операторские public-таблицы не зависят от провижининга схем тенантов.
builder.Services.AddHostedService<OperatorBootstrapHostedService>(); builder.Services.AddHostedService<OperatorBootstrapHostedService>();
// 30 с тикает ВСЕ тенанты (StorageTickService + автоочистка отсева пайплайна 3 суток) и публикует
// SSE-тосты. Регистрируется после Bootstrap — первый проход стартует уже после провижининга схем.
builder.Services.AddHostedService<StorageTickScheduler>(); builder.Services.AddHostedService<StorageTickScheduler>();
// проверяет ВСЕ тенанты и публикует в канал тенанта тост при пересечении порогов 80/100% (TryMark*-CAS —
// один тост на порог за период). Идёт после Bootstrap: реестр тенантов провижинен до первого прохода.
builder.Services.AddHostedService<BudgetAlertScheduler>(); builder.Services.AddHostedService<BudgetAlertScheduler>();
// каждые 2 с pump'ит ВСЕ тенанты (PipelineWorkerService.PumpOnceAsync под общим PipelinePumpGate) и
// После StorageTickScheduler: очередь цикла — 2 с, первый проход сразу после старта.
builder.Services.AddHostedService<PipelineWorkerScheduler>(); builder.Services.AddHostedService<PipelineWorkerScheduler>();
// выгружает MlOutbox тенантов в ml-service (TrainBatch, порции по 10, ≤100/цикл; удаление после успеха).
if (!mlOptions.UseLocal) if (!mlOptions.UseLocal)
{ {
builder.Services.AddHostedService<MlOutboxFlushScheduler>(); builder.Services.AddHostedService<MlOutboxFlushScheduler>();
} }
// делает ОДИН шаг (поиск/оценка/авто-вступление/done) для каждой running-задачи всех тенантов. Работает
// всегда: в Local-режиме гейт нейтрален (поиск пуст/история недоступна), реальные действия — при
// подключённом telegram-service (UseLocal=false). После Bootstrap: первый проход стартует после провижининга.
builder.Services.AddHostedService<DiscoveryWorkerScheduler>(); builder.Services.AddHostedService<DiscoveryWorkerScheduler>();
builder.Services.AddSingleton<RuntimeDepthsCollector>(); builder.Services.AddSingleton<RuntimeDepthsCollector>();
// MlOutbox) по всем тенантам и число активных сессий в meter Deal (callback /metrics отдаёт их Prometheus).
// Регистрируется последним из фоновых: после Bootstrap (реестр тенантов провижинен до первого прохода).
builder.Services.AddHostedService<DealMetricsCollector>(); builder.Services.AddHostedService<DealMetricsCollector>();
// записи audit_log старше DataRetention:AuditRetentionDays (дефолт 180 дней), сбрасывает накопительные
// поля лимитов прошедших периодов и убирает завершившиеся окна распределённых счётчиков. Регистрируется
// последним из фоновых: после Bootstrap (реестр тенантов провижинен до первого прохода).
DataRetentionOptions dataRetentionOptions = builder.Configuration DataRetentionOptions dataRetentionOptions = builder.Configuration
.GetSection(dataRetentionSectionName) .GetSection(dataRetentionSectionName)
.Get<DataRetentionOptions>() ?? new DataRetentionOptions(); .Get<DataRetentionOptions>() ?? new DataRetentionOptions();
@@ -320,28 +242,21 @@ builder.Services.AddHostedService<DataRetentionScheduler>();
// Кука сессии: имя/срок/Secure из секции "Cookies" (appsettings.json + env Cookies__*). // Кука сессии: имя/срок/Secure из секции "Cookies" (appsettings.json + env Cookies__*).
builder.Services.Configure<CookieOptions>(builder.Configuration.GetSection(cookiesSectionName)); builder.Services.Configure<CookieOptions>(builder.Configuration.GetSection(cookiesSectionName));
// "OperatorCookies" (appsettings.json + env OperatorCookies__*) — отдельная от тенантной deal_session.
builder.Services.Configure<OperatorCookieOptions>(builder.Configuration.GetSection(operatorCookiesSectionName)); builder.Services.Configure<OperatorCookieOptions>(builder.Configuration.GetSection(operatorCookiesSectionName));
builder.Services.ConfigureHttpJsonOptions(options => builder.Services.ConfigureHttpJsonOptions(options =>
options.SerializerOptions.Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping); options.SerializerOptions.Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping);
// проверки мутаций и CORS (пусто — dev-режим «свой origin», см. AddCors ниже; PROD — домен фронта в
// compose-prod). Инстанс регистрируется в DI: значение читается один раз на старте (политики формируются
// при старте хоста), OriginGuardMiddleware получает его конструктором.
SecurityOptions securityOptions = builder.Configuration SecurityOptions securityOptions = builder.Configuration
.GetSection(securitySectionName) .GetSection(securitySectionName)
.Get<SecurityOptions>() ?? new SecurityOptions(); .Get<SecurityOptions>() ?? new SecurityOptions();
builder.Services.AddSingleton(securityOptions); builder.Services.AddSingleton(securityOptions);
// dev-дефолт — false (прокси в dev-стеке нет, compose.dev публикует core напрямую).
ForwardedHeadersConfig forwardedHeadersConfig = builder.Configuration ForwardedHeadersConfig forwardedHeadersConfig = builder.Configuration
.GetSection(forwardedHeadersSectionName) .GetSection(forwardedHeadersSectionName)
.Get<ForwardedHeadersConfig>() ?? new ForwardedHeadersConfig(); .Get<ForwardedHeadersConfig>() ?? new ForwardedHeadersConfig();
builder.Services.AddSingleton(forwardedHeadersConfig); builder.Services.AddSingleton(forwardedHeadersConfig);
// (любой origin/method/header, credentials=true; AllowAnyOrigin + AllowCredentials несовместимы — любой
// Security-заголовки ответов (nosniff/X-Frame-Options/Referrer-Policy; CSP/HSTS) — на edge (Caddyfile,
builder.Services.AddCors(options => builder.Services.AddCors(options =>
options.AddPolicy(corsPolicyName, cors => options.AddPolicy(corsPolicyName, cors =>
{ {
@@ -380,7 +295,6 @@ if (app.Environment.IsProduction())
} }
} }
// с путём data/attachments; при сконфигурированном MinIO — MinioFileStorage с endpoint/бакетом).
app.Logger.LogInformation("Файловое хранилище: {FileStorage}", app.Services.GetRequiredService<IFileStorage>()); app.Logger.LogInformation("Файловое хранилище: {FileStorage}", app.Services.GetRequiredService<IFileStorage>());
app.Logger.LogInformation( app.Logger.LogInformation(
@@ -404,14 +318,9 @@ app.Logger.LogInformation(
if (forwardedHeadersConfig.Enabled) if (forwardedHeadersConfig.Enabled)
{ {
// только клиентам из ForwardedHeaders:KnownProxies/KnownNetworks (конфиг; appsettings — loopback для dev).
// Middleware — ПЕРВЫЙ в конвейере: RemoteIpAddress/Scheme читают слои ниже (CORS, Session/OperatorSession —
// audit-IP эндпоинтов, RateLimiter — ключи по IP, LoginAttemptGuard). Без него за Caddy (compose-prod,
app.UseForwardedHeaders(BuildForwardedHeadersOptions(forwardedHeadersConfig)); app.UseForwardedHeaders(BuildForwardedHeadersOptions(forwardedHeadersConfig));
} }
// и статус всего пути обработки. gRPC-ингресс (Content-Type application/grpc) middleware пропускает —
// его логирует интерцептор RpcCallLoggingInterceptor (см. HttpAccessLogMiddleware).
app.UseMiddleware<HttpAccessLogMiddleware>(); app.UseMiddleware<HttpAccessLogMiddleware>();
app.UseCors(corsPolicyName); app.UseCors(corsPolicyName);
@@ -419,13 +328,9 @@ app.UseMiddleware<SessionMiddleware>();
app.UseMiddleware<OperatorSessionMiddleware>(); app.UseMiddleware<OperatorSessionMiddleware>();
if (rateLimitOptions.Enabled) if (rateLimitOptions.Enabled)
{ {
// CurrentUser.TenantId либо IP анонима; сессии уже разрешены). При Enabled=false лимитер не
app.UseRateLimiter(); app.UseRateLimiter();
} }
// RateLimiter → OriginGuard (сессии разрешены, 429 важнее 403). Проверяются не-GET/HEAD/OPTIONS запросы
// с заголовком Origin: Origin == «свой» origin (схема + Host; за Caddy — https из X-Forwarded-Proto)
// либо входит в Security:AllowedOrigins; иначе 403 {detail}. Без Origin (curl/сервер-сервер) пропускаются;
app.UseMiddleware<OriginGuardMiddleware>(); app.UseMiddleware<OriginGuardMiddleware>();
app.MapGet("/api/health", () => Results.Ok(new { ok = true, service = "deal" })); app.MapGet("/api/health", () => Results.Ok(new { ok = true, service = "deal" }));
@@ -435,12 +340,9 @@ app.MapOperatorAuditEndpoints();
app.MapOperatorAnalyticsEndpoints(); app.MapOperatorAnalyticsEndpoints();
app.MapOperatorInvitesEndpoints(); app.MapOperatorInvitesEndpoints();
app.MapOperatorTenantsEndpoints(); app.MapOperatorTenantsEndpoints();
// .../limit, аудит tenant_limit_changed) + health ядра/БД и сервисов ml/ai/telegram.
app.MapOperatorLimitsEndpoints(); app.MapOperatorLimitsEndpoints();
app.MapOperatorHealthEndpoints(); app.MapOperatorHealthEndpoints();
// Глобальные настройки оператора (ТЗ §4.1/§8.1): ключи приложения Telegram — чтение (маска) и смена.
app.MapOperatorSettingsEndpoints(); app.MapOperatorSettingsEndpoints();
// (ограниченный параллелизм + логирование прогресса) — для SaaS с сотнями/тысячами схем.
app.MapOperatorMaintenanceEndpoints(); app.MapOperatorMaintenanceEndpoints();
app.MapJoinEndpoint(); app.MapJoinEndpoint();
app.MapSettingsEndpoints(); app.MapSettingsEndpoints();
@@ -455,12 +357,9 @@ app.MapStorageEndpoints();
app.MapEventsEndpoint(); app.MapEventsEndpoint();
app.MapAiSuggestEndpoints(); app.MapAiSuggestEndpoints();
app.MapPipelineEndpoints(); app.MapPipelineEndpoints();
// (BootStubEndpoints удалён); qr-image — отдельным файлом.
app.MapTelegramEndpoints(); app.MapTelegramEndpoints();
app.MapTelegramQrImageEndpoint(); app.MapTelegramQrImageEndpoint();
app.MapDiscoveryEndpoints(); app.MapDiscoveryEndpoints();
// входящего потока считает IngressRateLimitInterceptor по tenant-id из metadata (иначе общее окно на IP
// telegram-service резало бы весь ингресс раньше интерцептора); health — инфраструктурный liveness.
app.MapGrpcService<TelegramIngressService>().DisableRateLimiting(); app.MapGrpcService<TelegramIngressService>().DisableRateLimiting();
app.MapGrpcHealthChecksService().DisableRateLimiting(); app.MapGrpcHealthChecksService().DisableRateLimiting();
@@ -531,7 +430,6 @@ static List<Uri> ParseHttpAddresses(string? urlsConfig)
static int? ParsePort(string? rawValue) static int? ParsePort(string? rawValue)
=> int.TryParse(rawValue, out int parsedPort) ? parsedPort : null; => int.TryParse(rawValue, out int parsedPort) ? parsedPort : null;
// значение (пустая переменная, опечатка) — константа модуля TokenBudgetDefaults.DefaultBudgetTokens. Период
long ResolveDefaultAiBudget(IConfiguration configuration) long ResolveDefaultAiBudget(IConfiguration configuration)
{ {
string? rawValue = configuration[defaultAiBudgetEnvKey]; string? rawValue = configuration[defaultAiBudgetEnvKey];
@@ -51,7 +51,6 @@ public sealed class AdminTickOrchestrator(
toastPublisher.PublishTickToasts(tenantId, mergedStorage); toastPublisher.PublishTickToasts(tenantId, mergedStorage);
// проверки НЕ роняет тик: лог + reminders ответа пуст (очередь/хранение продолжают работать).
IReadOnlyList<CardReminderDueDto> dueReminders; IReadOnlyList<CardReminderDueDto> dueReminders;
try try
{ {
@@ -68,13 +67,11 @@ public sealed class AdminTickOrchestrator(
dueReminders = Array.Empty<CardReminderDueDto>(); dueReminders = Array.Empty<CardReminderDueDto>();
} }
// шлём — у фронта модалка ReminderNotice; без подписчиков канала публикация — no-op). После MarkFired
foreach (CardReminderDueDto due in dueReminders) foreach (CardReminderDueDto due in dueReminders)
{ {
broker.Publish(tenantId, ReminderDueEventType, due); broker.Publish(tenantId, ReminderDueEventType, due);
} }
// (5) Один проход pump под гейтом тенанта; сбой не роняет тик: pipeline={}, очередь дождётся
PipelinePumpResult? pump = await PumpOnceSafelyAsync(tenantId, ct); PipelinePumpResult? pump = await PumpOnceSafelyAsync(tenantId, ct);
if (pump is not null) if (pump is not null)
@@ -94,11 +91,8 @@ public sealed class AdminTickOrchestrator(
queueCounts.Total); queueCounts.Total);
} }
// ct: Токен отмены запроса.
// Возвращает: Результат прохода либо null — гейт занят другим воркером/pump упал (pipeline ответа пуст).
private async Task<PipelinePumpResult?> PumpOnceSafelyAsync(Guid tenantId, CancellationToken ct) private async Task<PipelinePumpResult?> PumpOnceSafelyAsync(Guid tenantId, CancellationToken ct)
{ {
// разбирают очередь тенанта одновременно. Гейт занят (фоновый цикл уже pump'ит) — проход пропускаем,
if (!pumpGate.TryEnter(tenantId)) if (!pumpGate.TryEnter(tenantId))
{ {
return null; return null;
@@ -124,8 +118,6 @@ public sealed class AdminTickOrchestrator(
} }
} }
// wire не выходят — они ушли отдельными SSE new_card).
// pump: Результат успешного прохода pump.
private static IReadOnlyDictionary<string, int> ToPipelineWireDict(PipelinePumpResult pump) private static IReadOnlyDictionary<string, int> ToPipelineWireDict(PipelinePumpResult pump)
{ {
int[] counters = int[] counters =
@@ -13,7 +13,6 @@ public static class AuditAppender
/// <summary> /// <summary>
/// Пишет событие действия пользователя тенанта /// Пишет событие действия пользователя тенанта
/// </summary> /// </summary>
/// <param name="context">Контекст запроса (источник актора и IP).</param>
/// <param name="eventType">Тип события — константа <see cref="AuditEvents"/>.</param> /// <param name="eventType">Тип события — константа <see cref="AuditEvents"/>.</param>
/// <param name="details">Минимальные детали события (обычно анонимный объект) или null.</param> /// <param name="details">Минимальные детали события (обычно анонимный объект) или null.</param>
public static async Task AppendTenantAsync( public static async Task AppendTenantAsync(
@@ -43,7 +42,6 @@ public static class AuditAppender
/// <summary> /// <summary>
/// Пишет событие действия оператора /// Пишет событие действия оператора
/// </summary> /// </summary>
/// <param name="context">Контекст запроса (источник актора и IP).</param>
/// <param name="eventType">Тип события — константа <see cref="AuditEvents"/>.</param> /// <param name="eventType">Тип события — константа <see cref="AuditEvents"/>.</param>
/// <param name="details">Минимальные детали события (обычно анонимный объект) или null.</param> /// <param name="details">Минимальные детали события (обычно анонимный объект) или null.</param>
public static async Task AppendOperatorAsync( public static async Task AppendOperatorAsync(
@@ -11,7 +11,6 @@ public static class SessionCookieWriter
/// <summary> /// <summary>
/// Выставляет httpOnly-куку сессии на текущем ответе. /// Выставляет httpOnly-куку сессии на текущем ответе.
/// </summary> /// </summary>
/// <param name="context">Контекст запроса.</param>
/// <param name="options">Настройки куки из конфигурации (секция Cookies).</param> /// <param name="options">Настройки куки из конфигурации (секция Cookies).</param>
/// <param name="rawToken">Raw-токен сессии.</param> /// <param name="rawToken">Raw-токен сессии.</param>
public static void Append( public static void Append(
@@ -107,7 +107,6 @@ public sealed class TelegramIngressService(
} }
catch (Exception exception) catch (Exception exception)
{ {
// Сбой схемы/БД тенанта (напр. схема ещё не провижинена): RPC не падает — reply not-accepted
logger.LogWarning(exception, "Аудит: PushMessage {TenantId} → не принято (сбой схемы/БД)", tenant.Id); logger.LogWarning(exception, "Аудит: PushMessage {TenantId} → не принято (сбой схемы/БД)", tenant.Id);
return new PushMessageReply(); return new PushMessageReply();
} }
@@ -226,11 +225,6 @@ public sealed class TelegramIngressService(
} }
} }
// Разрешает тенанта запроса: metadata tenant-id → реестр public.tenants.
// Отсутствующий/пустой tenant-id — RPC-отказ UNAUTHENTICATED (README: tenant-id обязателен).
// Id не Guid либо записи нет в реестре — неизвестный тенант: лог аудита и null (RPC отвечает не-принято,
// context: Контекст вызова.
// Возвращает: Запись тенанта реестра либо null (тенант неизвестен).
private async Task<TenantRecordDto?> ResolveTenantAsync(ServerCallContext context) private async Task<TenantRecordDto?> ResolveTenantAsync(ServerCallContext context)
{ {
string tenantId = RequireTenantIdMetadata(context); string tenantId = RequireTenantIdMetadata(context);
@@ -265,12 +259,6 @@ public sealed class TelegramIngressService(
return tenantId; return tenantId;
} }
// Пишет превью принятого сообщения (TgMessages + «последнее сообщение» каталога) без влияния на приём.
// не роняет RPC и не меняет accepted — очередь уже записана, упущенное догонит realtime-sweep (как
// tenantScope: Scope тенанта (TenantDbContext построен на схеме тенанта).
// tenant: Тенант канала (для лога аудита).
// request: Сообщение PushMessage.
// ct: Токен отмены.
private async Task SavePreviewSafelyAsync( private async Task SavePreviewSafelyAsync(
AsyncServiceScope tenantScope, AsyncServiceScope tenantScope,
TenantRecordDto tenant, TenantRecordDto tenant,
@@ -325,9 +313,6 @@ public sealed class TelegramIngressService(
} }
} }
// tenantId: Тенант-получатель.
// text: Текст тоста.
// icon: Иконка тоста (набор Icon.vue фронта).
private void PublishToast( private void PublishToast(
Guid tenantId, Guid tenantId,
string text, string text,
@@ -31,7 +31,7 @@ public sealed class TgStatusService(
}; };
/// <summary> /// <summary>
/// Форма GET /api/tg/status текущего тенанта /// Форма GET /api/tg/status текущего тенанта.
/// </summary> /// </summary>
/// <returns>Полный статус вкладки Telegram.</returns> /// <returns>Полный статус вкладки Telegram.</returns>
public async Task<TgStatusDto> GetAsync(CancellationToken ct) public async Task<TgStatusDto> GetAsync(CancellationToken ct)
@@ -52,8 +52,6 @@ public sealed class TgStatusService(
QrUrl: live.QrUrl); QrUrl: live.QrUrl);
} }
// ct: Токен отмены.
// Возвращает: Статус гейта либо idle-поля.
private async Task<TelegramAccountStatusDto> ReadLiveAsync(CancellationToken ct) private async Task<TelegramAccountStatusDto> ReadLiveAsync(CancellationToken ct)
{ {
try try
@@ -66,8 +64,6 @@ public sealed class TgStatusService(
} }
} }
// ct: Токен отмены.
// Возвращает: Аккаунт или пустая строка.
private async Task<string> ReadAccountAsync(CancellationToken ct) private async Task<string> ReadAccountAsync(CancellationToken ct)
{ {
SettingValue? row = await settings.GetAsync(SettingsKeys.TgAccount, ct).ConfigureAwait(false); SettingValue? row = await settings.GetAsync(SettingsKeys.TgAccount, ct).ConfigureAwait(false);
@@ -15,7 +15,7 @@ public interface IAiClassifier
public Task<AiFilterResultDto> FilterAsync(string text, CancellationToken ct); public Task<AiFilterResultDto> FilterAsync(string text, CancellationToken ct);
/// <summary> /// <summary>
/// Полный разбор карточки — структура классификации ТЗ §5 и classify. /// Полный разбор карточки — структура классификации и classify.
/// </summary> /// </summary>
/// <param name="text">Текст сообщения (как в очереди, уже обрезанный до 6000 при приёме).</param> /// <param name="text">Текст сообщения (как в очереди, уже обрезанный до 6000 при приёме).</param>
/// <returns>Структурированный разбор карточки (поля карточки + решение о типе/колонке).</returns> /// <returns>Структурированный разбор карточки (поля карточки + решение о типе/колонке).</returns>
@@ -96,7 +96,7 @@ public interface ITelegramGateway
/// Последние сообщения диалога для превью, свежие из Telegram. /// Последние сообщения диалога для превью, свежие из Telegram.
/// </summary> /// </summary>
/// <param name="dialogId">Id диалога.</param> /// <param name="dialogId">Id диалога.</param>
/// <param name="limit">Сколько последних сообщений (1..50; api-map /dialogs/preview).</param> /// <param name="limit">Сколько последних сообщений.</param>
/// <returns>Сообщения от новых к старым; признак lead и фолбэк на БД добавляет ядро.</returns> /// <returns>Сообщения от новых к старым; признак lead и фолбэк на БД добавляет ядро.</returns>
public Task<IReadOnlyList<TelegramRecentMessageDto>> ReadRecentAsync( public Task<IReadOnlyList<TelegramRecentMessageDto>> ReadRecentAsync(
string dialogId, string dialogId,
@@ -15,7 +15,6 @@ public sealed class ConnectionStringProvider
{ {
_baseConnectionString = configuration.GetConnectionString("DealPostgres") _baseConnectionString = configuration.GetConnectionString("DealPostgres")
?? throw new InvalidOperationException("ConnectionStrings:DealPostgres не задан"); ?? throw new InvalidOperationException("ConnectionStrings:DealPostgres не задан");
// Опциональная роль мигратора: задаётся только в проде (см. техдок §10/§13).
_migratorConnectionString = configuration.GetConnectionString("DealMigrator"); _migratorConnectionString = configuration.GetConnectionString("DealMigrator");
} }
@@ -151,11 +151,6 @@ public sealed class AiConnectionChecker : IAiConnectionChecker
} }
} }
// request: Запрос проверки (поля статуса провайдера).
// name: Имя провайдера из каталога AiProviders.
// ok: Результат подключения.
// message: Сообщение ветки.
// Возвращает: DTO ответа (наружу — camelCase).
private static AiCheckResultDto BuildResult( private static AiCheckResultDto BuildResult(
AiCheckRequest request, AiCheckRequest request,
string name, string name,
@@ -174,8 +169,6 @@ public sealed class AiConnectionChecker : IAiConnectionChecker
KeyMasked: MaskKey(request.ApiKey)); KeyMasked: MaskKey(request.ApiKey));
} }
// key: Ключ открытым текстом.
// Возвращает: Маскированная строка.
private static string MaskKey(string key) private static string MaskKey(string key)
{ {
if (string.IsNullOrEmpty(key)) if (string.IsNullOrEmpty(key))
@@ -191,10 +184,6 @@ public sealed class AiConnectionChecker : IAiConnectionChecker
return string.Concat(key.AsSpan(0, 4), "…", key.AsSpan(key.Length - 4)); return string.Concat(key.AsSpan(0, 4), "…", key.AsSpan(key.Length - 4));
} }
// baseUrl: Эффективный базовый URL из конфигурации провайдера.
// apiStyle: Стиль API провайдера (null — OpenAI-совместимый).
// modelsUri: URL списка моделей (валиден только при возврате true).
// Возвращает: True — URL построен; False — base URL не абсолютный http(s) (SSRF-гейт).
private static bool TryBuildModelsUri( private static bool TryBuildModelsUri(
string baseUrl, string baseUrl,
string? apiStyle, string? apiStyle,
@@ -87,8 +87,6 @@ public sealed class AiProviderConfigBuilder
return config; return config;
} }
// ct: Токен отмены.
// Возвращает: Id провайдера (каталога AiProviders).
private async Task<string> ReadProviderIdAsync(CancellationToken ct) private async Task<string> ReadProviderIdAsync(CancellationToken ct)
{ {
SettingValue? row = await _store.GetAsync(SettingsKeys.AiProvider, ct); SettingValue? row = await _store.GetAsync(SettingsKeys.AiProvider, ct);
@@ -62,7 +62,6 @@ public sealed class BudgetedAiClassifier : IAiClassifier
return await _paidClassifier.FilterAsync(text, ct); return await _paidClassifier.FilterAsync(text, ct);
} }
// Запрет гейта — фильтр через Local-реализацию {pass:true, skipped:true} (семантика «фильтр недоступен»,
_logger.LogDebug( _logger.LogDebug(
"ИИ-фильтр: {Reason} — Local-пропуск (тенант {TenantId})", GateDeniedLogText, TenantIdForLog()); "ИИ-фильтр: {Reason} — Local-пропуск (тенант {TenantId})", GateDeniedLogText, TenantIdForLog());
return await _localClassifier.FilterAsync(text, ct); return await _localClassifier.FilterAsync(text, ct);
@@ -76,14 +75,11 @@ public sealed class BudgetedAiClassifier : IAiClassifier
return await _paidClassifier.ClassifyAsync(text, ct); return await _paidClassifier.ClassifyAsync(text, ct);
} }
// без платного ИИ, приём не блокируется.
_logger.LogDebug( _logger.LogDebug(
"ИИ-классификация: {Reason} — Local-разбор (тенант {TenantId})", GateDeniedLogText, TenantIdForLog()); "ИИ-классификация: {Reason} — Local-разбор (тенант {TenantId})", GateDeniedLogText, TenantIdForLog());
return await _localClassifier.ClassifyAsync(text, ct); return await _localClassifier.ClassifyAsync(text, ct);
} }
// ct: Токен отмены.
// Возвращает: True — можно звать платного исполнителя.
private async Task<bool> IsPaidAllowedAsync(CancellationToken ct) private async Task<bool> IsPaidAllowedAsync(CancellationToken ct)
{ {
BudgetStateDto state = await _tenantLimits.GetStateAsync(RequireTenantId(), ct); BudgetStateDto state = await _tenantLimits.GetStateAsync(RequireTenantId(), ct);
@@ -90,8 +90,6 @@ public sealed class BudgetedAiTools : IAiTools
state.Status == TenantStatuses.Suspended ? SuspendedFitError : ExhaustedFitError); state.Status == TenantStatuses.Suspended ? SuspendedFitError : ExhaustedFitError);
} }
// ct: Токен отмены.
// Возвращает: Состояние бюджета тенанта на сейчас.
private async Task<BudgetStateDto> GateStateAsync(CancellationToken ct) private async Task<BudgetStateDto> GateStateAsync(CancellationToken ct)
=> await _tenantLimits.GetStateAsync(RequireTenantId(), ct); => await _tenantLimits.GetStateAsync(RequireTenantId(), ct);
@@ -100,9 +100,6 @@ public sealed class CbrRateSource : IRatesSource
return rates; return rates;
} }
// currency: Пара «код валюты → объект {Value, Nominal}».
// rate: Курс единицы валюты к рублю (валиден при возврате true).
// Возвращает: True — запись распознана; False — повреждённая запись (весь fetch — сбой).
private static bool TryParseCurrency(JsonProperty currency, out double rate) private static bool TryParseCurrency(JsonProperty currency, out double rate)
{ {
rate = 0; rate = 0;
@@ -89,7 +89,6 @@ public sealed class GrpcAiClassifier : IAiClassifier
CallOptions(tenantId.Value, ct)); CallOptions(tenantId.Value, ct));
await _usageRecorder.AddAsync(reply.Usage, providerConfig.ProviderId, providerConfig.Model, ct); await _usageRecorder.AddAsync(reply.Usage, providerConfig.ProviderId, providerConfig.Model, ct);
// Фильтр применён (воркер звал его только при aiFilterEnabled и не force) — skipped=false
return new AiFilterResultDto( return new AiFilterResultDto(
Pass: reply.Pass, Pass: reply.Pass,
Reason: reply.HasReason ? reply.Reason : null, Reason: reply.HasReason ? reply.Reason : null,
@@ -142,7 +141,6 @@ public sealed class GrpcAiClassifier : IAiClassifier
await _usageRecorder.AddAsync(reply.Usage, providerConfig.ProviderId, providerConfig.Model, ct); await _usageRecorder.AddAsync(reply.Usage, providerConfig.ProviderId, providerConfig.Model, ct);
if (!reply.Ok) if (!reply.Ok)
{ {
// Модель не вернула разбираемый JSON после ретраев — контрактная ok=false (README ai.proto):
_logger.LogWarning("ИИ-классификация: ok=false (тенант {TenantId})", tenantId.Value); _logger.LogWarning("ИИ-классификация: ok=false (тенант {TenantId})", tenantId.Value);
throw new AiUnavailableException(NoJsonAnswerText); throw new AiUnavailableException(NoJsonAnswerText);
} }
@@ -167,27 +165,18 @@ public sealed class GrpcAiClassifier : IAiClassifier
?? throw new InvalidOperationException( ?? throw new InvalidOperationException(
"GrpcAiClassifier запрошен вне tenant-контекста (ITenantContext.TenantId == null)."); "GrpcAiClassifier запрошен вне tenant-контекста (ITenantContext.TenantId == null).");
// tenantId: Id тенанта (формат N).
// ct: Токен отмены вызова.
// Возвращает: Опции вызова с заголовками, deadline и отменой.
private CallOptions CallOptions(string tenantId, CancellationToken ct) private CallOptions CallOptions(string tenantId, CancellationToken ct)
=> new( => new(
headers: _connection.CreateMetadata(tenantId), headers: _connection.CreateMetadata(tenantId),
deadline: DateTime.UtcNow.Add(TimeSpan.FromSeconds(RpcDeadlineSeconds)), deadline: DateTime.UtcNow.Add(TimeSpan.FromSeconds(RpcDeadlineSeconds)),
cancellationToken: ct); cancellationToken: ct);
// секреты/тела ответов не логируются и в текст не попадают).
// exception: Исключение RPC-вызова.
// Возвращает: Текст ошибки.
private static string ErrorText(RpcException exception) private static string ErrorText(RpcException exception)
{ {
string detail = exception.Status.Detail?.Trim() ?? string.Empty; string detail = exception.Status.Detail?.Trim() ?? string.Empty;
return detail.Length > 0 ? detail : ServiceUnavailableText; return detail.Length > 0 ? detail : ServiceUnavailableText;
} }
// text: Строка.
// max: Лимит.
// Возвращает: Усечённая строка.
private static string SliceCodePoints(string text, int max) private static string SliceCodePoints(string text, int max)
{ {
return text.Length <= max ? text : SliceByCodePoints(text, max); return text.Length <= max ? text : SliceByCodePoints(text, max);
@@ -158,27 +158,18 @@ public sealed class GrpcAiTools : IAiTools
?? throw new InvalidOperationException( ?? throw new InvalidOperationException(
"GrpcAiTools запрошен вне tenant-контекста (ITenantContext.TenantId == null)."); "GrpcAiTools запрошен вне tenant-контекста (ITenantContext.TenantId == null).");
// tenantId: Id тенанта (формат N).
// ct: Токен отмены вызова.
// Возвращает: Опции вызова с заголовками, deadline и отменой.
private CallOptions CallOptions(string tenantId, CancellationToken ct) private CallOptions CallOptions(string tenantId, CancellationToken ct)
=> new( => new(
headers: _connection.CreateMetadata(tenantId), headers: _connection.CreateMetadata(tenantId),
deadline: DateTime.UtcNow.Add(TimeSpan.FromSeconds(RpcDeadlineSeconds)), deadline: DateTime.UtcNow.Add(TimeSpan.FromSeconds(RpcDeadlineSeconds)),
cancellationToken: ct); cancellationToken: ct);
// секреты/тела ответов не логируются и в текст не попадают).
// exception: Исключение RPC-вызова.
// Возвращает: Текст ошибки.
private static string ErrorText(RpcException exception) private static string ErrorText(RpcException exception)
{ {
string detail = exception.Status.Detail?.Trim() ?? string.Empty; string detail = exception.Status.Detail?.Trim() ?? string.Empty;
return detail.Length > 0 ? detail : ServiceUnavailableText; return detail.Length > 0 ? detail : ServiceUnavailableText;
} }
// text: Строка.
// max: Лимит.
// Возвращает: Усечённая строка.
private static string SliceCodePoints(string text, int max) private static string SliceCodePoints(string text, int max)
{ {
if (text.Length <= max) if (text.Length <= max)
@@ -133,7 +133,6 @@ public sealed class GrpcMlClient : IMlClient, IMlTrainClient
new PredictRequest { Text = text ?? string.Empty }, new PredictRequest { Text = text ?? string.Empty },
CallOptions(tenantId.Value, TimeSpan.FromSeconds(PredictDeadlineSeconds), ct)); CallOptions(tenantId.Value, TimeSpan.FromSeconds(PredictDeadlineSeconds), ct));
// бюджет/lifetime AI-счётчик не затрагиваются (локальная модель бесплатна).
await _usageRecorder.AddEstimatedAsync(text, TokenUsageSources.Local, TokenUsageSources.Ml, ct); await _usageRecorder.AddEstimatedAsync(text, TokenUsageSources.Local, TokenUsageSources.Ml, ct);
return MapPredict(reply); return MapPredict(reply);
} }
@@ -246,8 +245,6 @@ public sealed class GrpcMlClient : IMlClient, IMlTrainClient
} }
} }
// reply: Ответ ml-service.
// Возвращает: DTO статуса модели.
private static MlServiceStatusDto MapStatus(StatusReply reply) private static MlServiceStatusDto MapStatus(StatusReply reply)
{ {
return new MlServiceStatusDto( return new MlServiceStatusDto(
@@ -260,8 +257,6 @@ public sealed class GrpcMlClient : IMlClient, IMlTrainClient
Accuracy: reply.Eval?.Accuracy ?? 0.0)); Accuracy: reply.Eval?.Accuracy ?? 0.0));
} }
// reply: Ответ ml-service.
// Возвращает: DTO предсказания.
private static MlPredictResultDto MapPredict(PredictReply reply) private static MlPredictResultDto MapPredict(PredictReply reply)
{ {
return new MlPredictResultDto( return new MlPredictResultDto(
@@ -289,10 +284,6 @@ public sealed class GrpcMlClient : IMlClient, IMlTrainClient
Margin: decision.Margin); Margin: decision.Margin);
} }
// tenantId: Id тенанта (формат N).
// deadline: Лимит времени вызова.
// ct: Токен отмены вызова.
// Возвращает: Опции вызова с заголовками, deadline и отменой.
private CallOptions CallOptions( private CallOptions CallOptions(
string tenantId, string tenantId,
TimeSpan deadline, TimeSpan deadline,
@@ -327,8 +318,6 @@ public sealed class GrpcMlClient : IMlClient, IMlTrainClient
Learned: 0, Learned: 0,
Eval: new MlEvalDto(Count: 0, Correct: 0, Accuracy: 0.0)); Eval: new MlEvalDto(Count: 0, Correct: 0, Accuracy: 0.0));
// ct: Токен отмены.
// Возвращает: True, если ключ отсутствует, повреждён или хранит JSON-true.
private async Task<bool> ReadMlEnabledAsync(CancellationToken ct) private async Task<bool> ReadMlEnabledAsync(CancellationToken ct)
{ {
SettingValue? row = await _store.GetAsync(SettingsKeys.MlEnabled, ct); SettingValue? row = await _store.GetAsync(SettingsKeys.MlEnabled, ct);
@@ -398,11 +398,6 @@ public sealed class GrpcTelegramClient : ITelegramGateway
?? throw new InvalidOperationException( ?? throw new InvalidOperationException(
"GrpcTelegramClient запрошен вне tenant-контекста (ITenantContext.TenantId == null)."); "GrpcTelegramClient запрошен вне tenant-контекста (ITenantContext.TenantId == null).");
// TReply: Тип ответа RPC.
// tenantId: Id тенанта (формат N).
// ct: Токен отмены вызова.
// call: Вызов клиента (принимает клиент и CallOptions).
// Возвращает: Ответ RPC.
private async Task<TReply> CallAsync<TReply>( private async Task<TReply> CallAsync<TReply>(
TenantId tenantId, TenantId tenantId,
TimeSpan deadline, TimeSpan deadline,
@@ -418,13 +413,6 @@ public sealed class GrpcTelegramClient : ITelegramGateway
return await call(client, options); return await call(client, options);
} }
// Нормализует транспортные сбои в RpcException «Telegram не подключён»; RpcException домена — как есть.
// Отмена по токену вызывающего пробрасывается без нормализации (не сбой сервиса). Доменные
// RPC-ошибки (INVALID_ARGUMENT/FAILED_PRECONDITION/…) несут канонический detail — их трогать нельзя:
// exception: Исключение вызова.
// tenantId: Id тенанта (лог).
// operation: Имя RPC (лог-аудит).
// Возвращает: Исключение для проброса: транспортный сбой — нормализованный RpcException.
private Exception TranslateTransportFailure( private Exception TranslateTransportFailure(
Exception exception, Exception exception,
TenantId tenantId, TenantId tenantId,
@@ -436,8 +424,6 @@ public sealed class GrpcTelegramClient : ITelegramGateway
return exception; return exception;
} }
// Доменная RPC-ошибка сервиса (INVALID_ARGUMENT/FAILED_PRECONDITION/NOT_FOUND…) несёт канонический
// Unavailable с detail (сервис сам ответил причиной) — тоже как есть.
if (exception is RpcException rpc && if (exception is RpcException rpc &&
(rpc.StatusCode != StatusCode.Unavailable || !string.IsNullOrEmpty(rpc.Status.Detail))) (rpc.StatusCode != StatusCode.Unavailable || !string.IsNullOrEmpty(rpc.Status.Detail)))
{ {
@@ -448,8 +434,6 @@ public sealed class GrpcTelegramClient : ITelegramGateway
return new RpcException(new Status(StatusCode.Unavailable, NotConnectedDetail)); return new RpcException(new Status(StatusCode.Unavailable, NotConnectedDetail));
} }
// entries: Записи каталога telegram-service.
// Возвращает: Записи в форме контракта (username → handle).
private static IReadOnlyList<TelegramDialogEntryDto> MapEntries(Google.Protobuf.Collections.RepeatedField<DialogEntry> entries) private static IReadOnlyList<TelegramDialogEntryDto> MapEntries(Google.Protobuf.Collections.RepeatedField<DialogEntry> entries)
{ {
return entries return entries
@@ -84,7 +84,6 @@ ContainersService containersService) : IColumnSuggester
/// <inheritdoc /> /// <inheritdoc />
public async Task<SuggestKeywordsResultDto> SuggestKeywordsAsync(CancellationToken ct) public async Task<SuggestKeywordsResultDto> SuggestKeywordsAsync(CancellationToken ct)
{ {
// свежие 40 (ListCardsAsync(null) = «все, кроме taken», ORDER BY received_at DESC).
IReadOnlyList<CardDto> cards = await store.ListCardsAsync(new CardsQuery(null), ct); IReadOnlyList<CardDto> cards = await store.ListCardsAsync(new CardsQuery(null), ct);
List<string> texts = cards List<string> texts = cards
.Where(card => card.Col != CardIds.Trash .Where(card => card.Col != CardIds.Trash
@@ -107,12 +106,6 @@ ContainersService containersService) : IColumnSuggester
return new SuggestKeywordsResultDto(Ok: true, Keywords: keywords, Reason: null); return new SuggestKeywordsResultDto(Ok: true, Keywords: keywords, Reason: null);
} }
// inbox: Снимок «Неразобранного» (карточки планов берутся из него).
// plans: Планы колонок (SuggestHeuristics.PlanColumns, ≤4).
// ct: Токен отмены.
// Возвращает: Сколько досок реально создано (0 — все откатаны из-за разобранных карточек).
// Каждая доска — suggested=true c правилами {mode:"any", keywords:[тема]} и note-обоснованием.
// Перед раскладкой перечитывается «Неразобранное»: карточки, ушедшие из inbox между снимком и
private async Task<int> StoreSuggestedColumnsAsync( private async Task<int> StoreSuggestedColumnsAsync(
IReadOnlyList<CardDto> inbox, IReadOnlyList<CardDto> inbox,
IReadOnlyList<SuggestedColumnPlan> plans, IReadOnlyList<SuggestedColumnPlan> plans,
@@ -176,9 +169,6 @@ ContainersService containersService) : IColumnSuggester
return created; return created;
} }
// Сработал ли кулдаун: с последнего успешного предложения прошло меньше 20 минут.
// ct: Токен отмены.
// Возвращает: True — повторный вызов слишком рано (ответ {ok:false, reason, cooldown:true}).
private async Task<bool> WithinCooldownAsync(CancellationToken ct) private async Task<bool> WithinCooldownAsync(CancellationToken ct)
{ {
SettingValue? row = await settings.GetAsync(SettingsKeys.LastSuggestAt, ct); SettingValue? row = await settings.GetAsync(SettingsKeys.LastSuggestAt, ct);
@@ -204,7 +194,6 @@ ContainersService containersService) : IColumnSuggester
} }
} }
// ct: Токен отмены.
private Task WriteLastSuggestAtAsync(CancellationToken ct) => private Task WriteLastSuggestAtAsync(CancellationToken ct) =>
settings.SetAsync( settings.SetAsync(
SettingsKeys.LastSuggestAt, SettingsKeys.LastSuggestAt,
@@ -75,12 +75,9 @@ public sealed class LocalMlClient(ISettingsStore store, IMlLearningStore learnin
double delta, double delta,
CancellationToken ct) CancellationToken ct)
{ {
// пустые — тихий no-op, text[:6000], id mle_+hex) — в MlOutboxQueue, общем для Local/Grpc-адаптеров.
await MlOutboxQueue.PushAsync(learningStore, text, label, delta, ct); await MlOutboxQueue.PushAsync(learningStore, text, label, delta, ct);
} }
// ct: Токен отмены.
// Возвращает: True, если ключ отсутствует, повреждён или хранит JSON-true.
private async Task<bool> ReadMlEnabledAsync(CancellationToken ct) private async Task<bool> ReadMlEnabledAsync(CancellationToken ct)
{ {
SettingValue? row = await store.GetAsync(SettingsKeys.MlEnabled, ct); SettingValue? row = await store.GetAsync(SettingsKeys.MlEnabled, ct);
@@ -4,8 +4,6 @@ using Deal.Modules.Kanban.Application.Models;
namespace Deal.Infrastructure.Integrations.Services; namespace Deal.Infrastructure.Integrations.Services;
// тихий no-op, text обрезается до 6000 символов (без разрыва суррогатной пары), id — mle_ +
// и в Local-, и в gRPC-режиме сигнал сначала пишется в outbox, отправку в ml-service делает фоновый
internal static class MlOutboxQueue internal static class MlOutboxQueue
{ {
internal const int MaxLearningTextLength = 6000; internal const int MaxLearningTextLength = 6000;
@@ -42,14 +40,9 @@ internal static class MlOutboxQueue
ct); ct);
} }
// Возвращает: Короткий id записи очереди.
private static string NewOutboxId() private static string NewOutboxId()
=> KanbanIdPrefixes.MlOutbox + Convert.ToHexString(RandomNumberGenerator.GetBytes(OutboxIdRandomBytes)).ToLowerInvariant(); => KanbanIdPrefixes.MlOutbox + Convert.ToHexString(RandomNumberGenerator.GetBytes(OutboxIdRandomBytes)).ToLowerInvariant();
// Обрезает текст до MaxLearningTextLength символов, не разбивая суррогатную пару на конце.
// text: Текст (уже trim-нут).
// Возвращает: Первые 6000 символов (или весь текст, если короче).
// (text[:6000]) режет по code points — хвостовой high-surrogate убираем, чтобы в БД не ушла «битая» пара.
private static string TruncateText(string text) private static string TruncateText(string text)
{ {
if (text.Length <= MaxLearningTextLength) if (text.Length <= MaxLearningTextLength)
@@ -76,7 +76,6 @@ public sealed class TokenUsageRecorder
} }
await AddToLifetimeAsync(usage, ct); await AddToLifetimeAsync(usage, ct);
// token_usage_events (без tenantId в метках).
DealMetrics.RecordAiUsage(usage.Prompt, usage.Completion); DealMetrics.RecordAiUsage(usage.Prompt, usage.Completion);
await RecordEventAsync( await RecordEventAsync(
provider, provider,
@@ -102,7 +101,6 @@ public sealed class TokenUsageRecorder
CancellationToken ct) CancellationToken ct)
{ {
long promptTokens = EstimateTokens(text); long promptTokens = EstimateTokens(text);
// что и событие token_usage_events, kind=ml).
DealMetrics.RecordMlUsage(promptTokens); DealMetrics.RecordMlUsage(promptTokens);
await RecordEventAsync( await RecordEventAsync(
provider, provider,
@@ -170,8 +168,6 @@ public sealed class TokenUsageRecorder
return id; return id;
} }
// usage: Оценка токенов ответа.
// ct: Токен отмены.
private async Task AddToLifetimeAsync(Usage usage, CancellationToken ct) private async Task AddToLifetimeAsync(Usage usage, CancellationToken ct)
{ {
JsonObject? current = await ReadAsync(ct); JsonObject? current = await ReadAsync(ct);
@@ -38,10 +38,8 @@ public static class FileStorageRegistrar
/// <summary> /// <summary>
/// Регистрирует IFileStorage — LocalFileStorage или MinioFileStorage по конфигурации. /// Регистрирует IFileStorage — LocalFileStorage или MinioFileStorage по конфигурации.
/// </summary> /// </summary>
/// <param name="services">Коллекция сервисов.</param>
/// <param name="configuration">Конфигурация приложения (секция Storage + env-алиасы DEAL_MINIO_*).</param> /// <param name="configuration">Конфигурация приложения (секция Storage + env-алиасы DEAL_MINIO_*).</param>
/// <param name="contentRootPath">ContentRoot приложения — база для дефолтного корня data/attachments.</param> /// <param name="contentRootPath">ContentRoot приложения — база для дефолтного корня data/attachments.</param>
/// <returns>Коллекция сервисов для цепочки вызовов.</returns>
public static IServiceCollection AddDealFileStorage( public static IServiceCollection AddDealFileStorage(
this IServiceCollection services, this IServiceCollection services,
IConfiguration configuration, IConfiguration configuration,
@@ -51,7 +49,6 @@ public static class FileStorageRegistrar
StorageOptions options = ReadOptions(configuration); StorageOptions options = ReadOptions(configuration);
// недоступен» — dev/curl/unit по умолчанию работают на LocalFileStorage без MinIO).
if (options.Minio.IsConfigured()) if (options.Minio.IsConfigured())
{ {
services.AddSingleton<IFileStorage>(serviceProvider => services.AddSingleton<IFileStorage>(serviceProvider =>
@@ -40,8 +40,6 @@ public sealed class LocalFileStorage : IFileStorage
{ {
ArgumentNullException.ThrowIfNull(content); ArgumentNullException.ThrowIfNull(content);
// может быть прочитан эндпоинтом раньше; перемотаемые потоки сбрасываем (неперемотаемые читаются
// с текущей позиции, как есть). Выравнивание с Minio-адаптером PutAsync.
if (content.CanSeek && content.Position != 0) if (content.CanSeek && content.Position != 0)
{ {
content.Position = 0; content.Position = 0;
@@ -95,9 +93,6 @@ public sealed class LocalFileStorage : IFileStorage
return Task.CompletedTask; return Task.CompletedTask;
} }
// objectKey: Ключ объекта (сегменты по '/', без «.»/«..»).
// Возвращает: Полный путь файла под root.
// Исключение ArgumentException: objectKey пуст либо содержит обходные сегменты «.»/«..».
private string ResolvePath(string objectKey) private string ResolvePath(string objectKey)
{ {
if (string.IsNullOrWhiteSpace(objectKey)) if (string.IsNullOrWhiteSpace(objectKey))
@@ -77,7 +77,6 @@ public sealed class MinioFileStorage : IFileStorage
await EnsureBucketAsync(ct); await EnsureBucketAsync(ct);
// (Content-Length), поэтому поток буферизуется — Local-адаптер буферизации не требует.
if (content.CanSeek && content.Position != 0) if (content.CanSeek && content.Position != 0)
{ {
content.Position = 0; content.Position = 0;
@@ -134,7 +133,6 @@ public sealed class MinioFileStorage : IFileStorage
{ {
try try
{ {
// → null-семантика порта). Иные ошибки (MinIO недоступен) уходят вызывающему — он мапит их в 404.
ObjectStat stat = await _client.StatObjectAsync( ObjectStat stat = await _client.StatObjectAsync(
new StatObjectArgs().WithBucket(_bucket).WithObject(objectKey), new StatObjectArgs().WithBucket(_bucket).WithObject(objectKey),
ct); ct);
@@ -167,7 +165,6 @@ public sealed class MinioFileStorage : IFileStorage
} }
} }
// ct: Токен отмены.
private async Task EnsureBucketAsync(CancellationToken ct) private async Task EnsureBucketAsync(CancellationToken ct)
{ {
if (_bucketChecked) if (_bucketChecked)
@@ -193,7 +190,6 @@ public sealed class MinioFileStorage : IFileStorage
} }
catch (MinioException exception) catch (MinioException exception)
{ {
// Бакет не проверить/создать (MinIO недоступен и т.п.): put продолжит и упадёт с понятной
_logger.LogWarning( _logger.LogWarning(
exception, exception,
"Не удалось проверить/создать бакет MinIO «{Bucket}»: {Message}", "Не удалось проверить/создать бакет MinIO «{Bucket}»: {Message}",
@@ -36,7 +36,6 @@ public sealed class CardConfiguration : IEntityTypeConfiguration<CardEntity>
// Сортировка пространства «Выбранные» — updated_at DESC. // Сортировка пространства «Выбранные» — updated_at DESC.
builder.HasIndex(x => x.UpdatedAt).IsDescending(); builder.HasIndex(x => x.UpdatedAt).IsDescending();
// Полнотекстовый вектор карточки (russian): Title+Summary+SourceMsg+Contact — вычисляемая STORED-
builder.Property(x => x.SearchTsv) builder.Property(x => x.SearchTsv)
.HasComputedColumnSql( .HasComputedColumnSql(
"to_tsvector('russian', coalesce(\"Title\",'')||' '||coalesce(\"Summary\",'')||' '||coalesce(\"SourceMsg\",'')||' '||coalesce(\"Contact\",''))", "to_tsvector('russian', coalesce(\"Title\",'')||' '||coalesce(\"Summary\",'')||' '||coalesce(\"SourceMsg\",'')||' '||coalesce(\"Contact\",''))",
@@ -107,8 +107,6 @@ public sealed class AuditLogStore(DealDbContext dbContext) : IAuditLogStore
return query; return query;
} }
// limit: Запрошенный размер.
// Возвращает: Клампированное значение.
private static int ClampLimit(int limit) => Math.Max(1, Math.Min(AuditService.MaxQueryLimit, limit)); private static int ClampLimit(int limit) => Math.Max(1, Math.Min(AuditService.MaxQueryLimit, limit));
private static AuditRecordDto ToAuditRecordDto(AuditLogEntity entity) => private static AuditRecordDto ToAuditRecordDto(AuditLogEntity entity) =>
@@ -72,7 +72,6 @@ public sealed class InviteStore(DealDbContext dbContext) : IInviteStore
DateTimeOffset activatedAt, DateTimeOffset activatedAt,
CancellationToken ct) CancellationToken ct)
{ {
// иначе параллельный отзыв/активация не перезаписываются (ExecuteUpdate выполняется одним оператором SQL).
int affected = await dbContext.Invites int affected = await dbContext.Invites
.Where(i => i.Code == code && i.Status == InviteStatuses.Pending) .Where(i => i.Code == code && i.Status == InviteStatuses.Pending)
.ExecuteUpdateAsync(s => s .ExecuteUpdateAsync(s => s
@@ -15,7 +15,6 @@ public sealed partial class KanbanStore
IQueryable<CardEntity> queryable = _dbContext.Cards.AsNoTracking(); IQueryable<CardEntity> queryable = _dbContext.Cards.AsNoTracking();
if (query.Col is null) if (query.Col is null)
{ {
// в работу, живёт в том же пространстве «Выбранных», а не на дашборде.
queryable = queryable.Where(card => !SelectedStageIds.Contains(card.Col)); queryable = queryable.Where(card => !SelectedStageIds.Contains(card.Col));
} }
else else
@@ -41,9 +40,6 @@ public sealed partial class KanbanStore
return Array.Empty<CardDto>(); return Array.Empty<CardDto>();
} }
// исключены, порядок — ts_rank DESC, ReceivedAt DESC, limit 12. plainto_tsquery со стоп-словами даёт
// пустой tsquery — совпадений нет (не ошибка), LIKE ниже всё равно отработает. Всё параметризовано
// (никакой конкатенации пользовательского ввода); LIKE-паттерн — как в отсев-поиске PipelineStore.
string pattern = $"%{query}%"; string pattern = $"%{query}%";
List<CardEntity> entities = await _dbContext.Cards List<CardEntity> entities = await _dbContext.Cards
.FromSqlInterpolated( .FromSqlInterpolated(
@@ -133,7 +129,6 @@ public sealed partial class KanbanStore
/// <inheritdoc /> /// <inheritdoc />
public async Task UpdateColumnAsync(CardColumnUpdateDto update, CancellationToken ct) public async Task UpdateColumnAsync(CardColumnUpdateDto update, CancellationToken ct)
{ {
// через ExecuteUpdate потребовало бы двух запросов, чтение со слежением проще и точнее.
CardEntity? entity = await _dbContext.Cards.SingleOrDefaultAsync(card => card.Id == update.CardId, ct); CardEntity? entity = await _dbContext.Cards.SingleOrDefaultAsync(card => card.Id == update.CardId, ct);
if (entity is null) if (entity is null)
{ {
@@ -155,7 +150,6 @@ public sealed partial class KanbanStore
/// <inheritdoc /> /// <inheritdoc />
public async Task<bool> ApplyReclassificationAsync(CardReclassificationDto update, CancellationToken ct) public async Task<bool> ApplyReclassificationAsync(CardReclassificationDto update, CancellationToken ct)
{ {
// валюты и отсутствие конверсии очищают поля (BudgetCur/ConvCur = ""), PrevCol/ArchivedAt не трогаются.
CardEntity? entity = await _dbContext.Cards.SingleOrDefaultAsync(card => card.Id == update.CardId, ct); CardEntity? entity = await _dbContext.Cards.SingleOrDefaultAsync(card => card.Id == update.CardId, ct);
if (entity is null) if (entity is null)
{ {
@@ -205,7 +199,6 @@ public sealed partial class KanbanStore
/// <inheritdoc /> /// <inheritdoc />
public async Task DeleteForeverAsync(string cardId, CancellationToken ct) public async Task DeleteForeverAsync(string cardId, CancellationToken ct)
{ {
// «сирота» заблокирует повторное создание карточки при перечитывании канала.
await using var transaction = await _dbContext.Database.BeginTransactionAsync(ct); await using var transaction = await _dbContext.Database.BeginTransactionAsync(ct);
await _dbContext.DedupEntries await _dbContext.DedupEntries
.Where(entry => entry.LeadId == cardId) .Where(entry => entry.LeadId == cardId)
@@ -219,7 +212,6 @@ public sealed partial class KanbanStore
/// <inheritdoc /> /// <inheritdoc />
public async Task<int> ClearColAsync(string col, CancellationToken ct) public async Task<int> ClearColAsync(string col, CancellationToken ct)
{ {
// корзины/архива тоже должна снимать «сирот», иначе текст из удалённой карточки не пройдёт дедуп.
await using var transaction = await _dbContext.Database.BeginTransactionAsync(ct); await using var transaction = await _dbContext.Database.BeginTransactionAsync(ct);
await _dbContext.DedupEntries await _dbContext.DedupEntries
.Where(entry => _dbContext.Cards.Any(card => card.Col == col && card.Id == entry.LeadId)) .Where(entry => _dbContext.Cards.Any(card => card.Col == col && card.Id == entry.LeadId))
@@ -30,8 +30,6 @@ public sealed partial class KanbanStore
string text, string text,
CancellationToken ct) CancellationToken ct)
{ {
// комментарий без карточки не запишется (404-семантику несуществующей карточки отдаёт сервис,
// прочитав карточку перед добавлением, — адаптеру возвращать нечего: порт void).
_dbContext.LeadComments.Add(new LeadCommentEntity _dbContext.LeadComments.Add(new LeadCommentEntity
{ {
Id = commentId, Id = commentId,
@@ -64,7 +62,6 @@ public sealed partial class KanbanStore
/// <inheritdoc /> /// <inheritdoc />
public async Task<IReadOnlyList<AiMarkupExampleDto>> GetAiMarkupExamplesAsync(int limit, CancellationToken ct) public async Task<IReadOnlyList<AiMarkupExampleDto>> GetAiMarkupExamplesAsync(int limit, CancellationToken ct)
{ {
// действия move/restore, цель не служебная (trash/archive), source_msg непустой, свежие первыми.
return await _dbContext.CardMoves return await _dbContext.CardMoves
.AsNoTracking() .AsNoTracking()
.Where(move => move.ToCol != null && move.ToCol != CardIds.Trash && move.ToCol != CardIds.Archive) .Where(move => move.ToCol != null && move.ToCol != CardIds.Trash && move.ToCol != CardIds.Archive)
@@ -34,7 +34,6 @@ public sealed partial class KanbanStore
CardPatch patch, CardPatch patch,
CancellationToken ct) CancellationToken ct)
{ {
// Точечная правка по присутствующим полям патча: null-поле не меняется, JSON-поля заменяются
CardEntity? entity = await _dbContext.Cards CardEntity? entity = await _dbContext.Cards
.SingleOrDefaultAsync(card => card.Id == cardId, ct); .SingleOrDefaultAsync(card => card.Id == cardId, ct);
if (entity is null) if (entity is null)
@@ -152,7 +151,6 @@ public sealed partial class KanbanStore
long atMs, long atMs,
CancellationToken ct) CancellationToken ct)
{ {
// Смена контейнера-стадии: Col=containerId, сброс напоминания, updated_at=atMs, история + запись
CardEntity? entity = await _dbContext.Cards CardEntity? entity = await _dbContext.Cards
.AsNoTracking() .AsNoTracking()
.SingleOrDefaultAsync(card => card.Id == cardId, ct); .SingleOrDefaultAsync(card => card.Id == cardId, ct);
@@ -206,7 +204,6 @@ public sealed partial class KanbanStore
/// <inheritdoc /> /// <inheritdoc />
public async Task<int> ClearStageAsync(string containerId, CancellationToken ct) public async Task<int> ClearStageAsync(string containerId, CancellationToken ct)
{ {
// снимает каскад FK LeadComments → Cards.
return await _dbContext.Cards return await _dbContext.Cards
.Where(card => card.Col == containerId) .Where(card => card.Col == containerId)
.ExecuteDeleteAsync(ct); .ExecuteDeleteAsync(ct);
@@ -13,7 +13,6 @@ public sealed partial class KanbanStore
/// <inheritdoc /> /// <inheritdoc />
public async Task<IReadOnlyList<string>> ListArchiveCandidatesAsync(DateTimeOffset receivedBeforeUtc, CancellationToken ct) public async Task<IReadOnlyList<string>> ListArchiveCandidatesAsync(DateTimeOffset receivedBeforeUtc, CancellationToken ct)
{ {
// Кандидаты автоархива: карточки пользовательских колонок (kind=board) и «Неразобранного»
List<string> boardIds = await _dbContext.Containers List<string> boardIds = await _dbContext.Containers
.AsNoTracking() .AsNoTracking()
.Where(container => container.Kind == ContainerKinds.Board) .Where(container => container.Kind == ContainerKinds.Board)
@@ -34,7 +33,6 @@ public sealed partial class KanbanStore
DateTimeOffset archivedAt, DateTimeOffset archivedAt,
CancellationToken ct) CancellationToken ct)
{ {
// UpdateColumnAsync автоархива: col=archive, is_new=false, archived_at=now, matchHits пусто;
if (cardIds.Count == 0) if (cardIds.Count == 0)
{ {
return 0; return 0;
@@ -94,7 +92,6 @@ public sealed partial class KanbanStore
/// <inheritdoc /> /// <inheritdoc />
public async Task<IReadOnlyList<CardDto>> ListCardsForConversionAsync(CancellationToken ct) public async Task<IReadOnlyList<CardDto>> ListCardsForConversionAsync(CancellationToken ct)
{ {
// пересчёт читает только бюджетные/conv-поля, комментарии не нужны.
List<CardEntity> entities = await _dbContext.Cards List<CardEntity> entities = await _dbContext.Cards
.AsNoTracking() .AsNoTracking()
.Where(card => card.BudgetCur != string.Empty .Where(card => card.BudgetCur != string.Empty
@@ -204,7 +204,6 @@ public sealed partial class KanbanStore : ICardStore
private static CardCommentDto ToCommentDto(LeadCommentEntity entity) => private static CardCommentDto ToCommentDto(LeadCommentEntity entity) =>
new(entity.Id, entity.By, entity.Text, HumanAge(entity.CreatedAt)); new(entity.Id, entity.By, entity.Text, HumanAge(entity.CreatedAt));
// timestamp: Время события (получения сообщения/добавления комментария).
private static string HumanAge(DateTimeOffset timestamp) private static string HumanAge(DateTimeOffset timestamp)
{ {
long deltaMs = Math.Max(0, DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() - timestamp.ToUnixTimeMilliseconds()); long deltaMs = Math.Max(0, DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() - timestamp.ToUnixTimeMilliseconds());
@@ -90,8 +90,6 @@ public sealed class PipelineStore(TenantDbContext dbContext) : IPipelineStore
string id = record.DeterministicId ?? PrefixId.New(PipelineIdPrefixes.Rejected); string id = record.DeterministicId ?? PrefixId.New(PipelineIdPrefixes.Rejected);
DateTimeOffset rejectedAt = DateTimeOffset.UtcNow; DateTimeOffset rejectedAt = DateTimeOffset.UtcNow;
// Upsert по детерминированному id: повторное отбрасывание того же сообщения (dialog+msgId)
// аудит возврата (returned/returnedAt/returnReason) при повторном отсеве переживает.
await dbContext.Database.ExecuteSqlInterpolatedAsync( await dbContext.Database.ExecuteSqlInterpolatedAsync(
$""" $"""
INSERT INTO "RejectedItems" ("Id", "DialogId", "MsgId", "ChannelName", "ChannelHandle", "ChannelHue", "Text", "Stage", "Reason", "Kw", "Source", "MsgAt", "RejectedAt", "Returned", "ReturnReason") INSERT INTO "RejectedItems" ("Id", "DialogId", "MsgId", "ChannelName", "ChannelHandle", "ChannelHue", "Text", "Stage", "Reason", "Kw", "Source", "MsgAt", "RejectedAt", "Returned", "ReturnReason")
@@ -139,7 +137,6 @@ public sealed class PipelineStore(TenantDbContext dbContext) : IPipelineStore
return Array.Empty<RejectedItemDto>(); return Array.Empty<RejectedItemDto>();
} }
// plainto_tsquery со стоп-словами даёт пустой tsquery — совпадений нет (не ошибка), LIKE ниже всё
List<RejectedItemEntity> fts = await dbContext.RejectedItems List<RejectedItemEntity> fts = await dbContext.RejectedItems
.FromSqlInterpolated( .FromSqlInterpolated(
$""" $"""
@@ -154,7 +151,6 @@ public sealed class PipelineStore(TenantDbContext dbContext) : IPipelineStore
var rows = fts.Select(ToRejectedItemDto).ToList(); var rows = fts.Select(ToRejectedItemDto).ToList();
var seen = new HashSet<string>(fts.Select(item => item.Id), StringComparer.Ordinal); var seen = new HashSet<string>(fts.Select(item => item.Id), StringComparer.Ordinal);
// последнего перестроения вектора и слова, которых нет в словаре морфологии. limitLike — как
string pattern = $"%{query}%"; string pattern = $"%{query}%";
List<RejectedItemEntity> like = await dbContext.RejectedItems List<RejectedItemEntity> like = await dbContext.RejectedItems
.AsNoTracking() .AsNoTracking()
@@ -236,8 +232,6 @@ public sealed class PipelineStore(TenantDbContext dbContext) : IPipelineStore
/// <inheritdoc /> /// <inheritdoc />
public async Task<bool> ClaimAsync(string hash, CancellationToken ct) public async Task<bool> ClaimAsync(string hash, CancellationToken ct)
{ {
// NOTHING — параллельные дубли одного текста не проходят (сырой SQL: EF-вставка упала бы на PK).
// Число вставленных строк — результат: 1 = заявка за этим вызовом, 0 = хэш уже заявлен другим
int inserted = await dbContext.Database.ExecuteSqlInterpolatedAsync( int inserted = await dbContext.Database.ExecuteSqlInterpolatedAsync(
$""" $"""
INSERT INTO "DedupEntries" ("Hash", "LeadId", "CreatedAt") INSERT INTO "DedupEntries" ("Hash", "LeadId", "CreatedAt")
@@ -251,7 +245,6 @@ public sealed class PipelineStore(TenantDbContext dbContext) : IPipelineStore
/// <inheritdoc /> /// <inheritdoc />
public async Task DeleteClaimAsync(string hash, CancellationToken ct) public async Task DeleteClaimAsync(string hash, CancellationToken ct)
{ {
// (LeadId IS NOT NULL), не трогаем — иначе повторный текст прошёл бы дедуп после отсева.
await dbContext.DedupEntries await dbContext.DedupEntries
.Where(entry => entry.Hash == hash && entry.LeadId == null) .Where(entry => entry.Hash == hash && entry.LeadId == null)
.ExecuteDeleteAsync(ct); .ExecuteDeleteAsync(ct);
@@ -271,8 +264,6 @@ public sealed class PipelineStore(TenantDbContext dbContext) : IPipelineStore
/// <inheritdoc /> /// <inheritdoc />
public async Task DeleteByCardAsync(string cardId, CancellationToken ct) public async Task DeleteByCardAsync(string cardId, CancellationToken ct)
{ {
// дублируется тем же DELETE (см. KanbanStore.DeleteForeverAsync/PurgeAsync/ClearColAsync) —
// чтобы Kanban-адаптер не зависел от порта Pipeline; этот метод — для потребителей модуля.
await dbContext.DedupEntries await dbContext.DedupEntries
.Where(entry => entry.LeadId == cardId) .Where(entry => entry.LeadId == cardId)
.ExecuteDeleteAsync(ct); .ExecuteDeleteAsync(ct);
@@ -146,8 +146,6 @@ public sealed class TelegramStore(TenantDbContext dbContext) : ITelegramStore
string hue, string hue,
CancellationToken ct) CancellationToken ct)
{ {
// discovery-вступления — метаданные источника, monitor=TRUE, backfilled=FALSE (разбор подхватит
// первый фоновый Backfill). Значения нормализованы вызывающим (DialogsService).
DialogEntity? existing = await dbContext.Dialogs.FirstOrDefaultAsync(dialog => dialog.Id == dialogId, ct); DialogEntity? existing = await dbContext.Dialogs.FirstOrDefaultAsync(dialog => dialog.Id == dialogId, ct);
DateTimeOffset now = DateTimeOffset.UtcNow; DateTimeOffset now = DateTimeOffset.UtcNow;
if (existing is null) if (existing is null)
@@ -234,8 +232,6 @@ public sealed class TelegramStore(TenantDbContext dbContext) : ITelegramStore
row.Id, row.Text, row.MsgAt.ToUnixTimeMilliseconds(), row.LeadId is not null)).ToList(); row.Id, row.Text, row.MsgAt.ToUnixTimeMilliseconds(), row.LeadId is not null)).ToList();
} }
// row: Строка Dialogs.
// Возвращает: Форма §4.8: type = kind (EN-канон), on = monitor, last {text, time}.
private static TelegramDialogDto ToDialogDto(DialogEntity row) private static TelegramDialogDto ToDialogDto(DialogEntity row)
{ {
long? lastAtMs = row.LastAt?.ToUnixTimeMilliseconds(); long? lastAtMs = row.LastAt?.ToUnixTimeMilliseconds();
@@ -164,10 +164,6 @@ public sealed class TenantLimitStore : ITenantLimitStore
return true; return true;
} }
// tenantId: Тенант.
// defaults: Дефолт-параметры создаваемой строки.
// ct: Токен отмены.
// Возвращает: Отслеживаемая строка лимита.
private async Task<TenantLimitEntity> LoadOrCreateAsync( private async Task<TenantLimitEntity> LoadOrCreateAsync(
Guid tenantId, Guid tenantId,
TokenLimitDefaults defaults, TokenLimitDefaults defaults,
@@ -196,8 +192,6 @@ public sealed class TenantLimitStore : ITenantLimitStore
return created; return created;
} }
// entity: Отслеживаемая строка лимита.
// ct: Токен отмены.
private async Task ResetIfPeriodExpiredAsync(TenantLimitEntity entity, CancellationToken ct) private async Task ResetIfPeriodExpiredAsync(TenantLimitEntity entity, CancellationToken ct)
{ {
if (ResetIfExpired(entity, _utcNow())) if (ResetIfExpired(entity, _utcNow()))
@@ -249,12 +243,8 @@ public sealed class TenantLimitStore : ITenantLimitStore
return reset; return reset;
} }
// entity: Строка лимита (после ленивого reset).
// ct: Токен отмены.
// Возвращает: Состояние бюджета тенанта.
private async Task<BudgetStateDto> ToStateDtoAsync(TenantLimitEntity entity, CancellationToken ct) private async Task<BudgetStateDto> ToStateDtoAsync(TenantLimitEntity entity, CancellationToken ct)
{ {
// Статус тенанта — из public.tenants тем же контекстом; отсутствие строки трактуем suspended
string? tenantStatus = await _dbContext.Tenants string? tenantStatus = await _dbContext.Tenants
.AsNoTracking() .AsNoTracking()
.Where(t => t.Id == entity.TenantId) .Where(t => t.Id == entity.TenantId)
@@ -30,62 +30,44 @@ public static class ServiceCollectionExtensions
/// <summary> /// <summary>
/// Регистрирует EF-адаптеры портов модулей /// Регистрирует EF-адаптеры портов модулей
/// </summary> /// </summary>
/// <param name="services">Коллекция сервисов.</param>
/// <param name="tenantLimitDefaults">Дефолт-бюджет лениво создаваемых строк tenant_limits. Передаётся из конфигурации/env DEAL_DEFAULT_AI_BUDGET в Program.cs.</param> /// <param name="tenantLimitDefaults">Дефолт-бюджет лениво создаваемых строк tenant_limits. Передаётся из конфигурации/env DEAL_DEFAULT_AI_BUDGET в Program.cs.</param>
/// <returns>Коллекция сервисов для цепочки вызовов.</returns>
public static IServiceCollection AddDealPersistence(this IServiceCollection services, TokenLimitDefaults? tenantLimitDefaults = null) public static IServiceCollection AddDealPersistence(this IServiceCollection services, TokenLimitDefaults? tenantLimitDefaults = null)
{ {
services.AddScoped<IAuthStore, AuthStore>(); services.AddScoped<IAuthStore, AuthStore>();
// схемы public — отдельный порт от IAuthStore (оператор ≠ пользователь тенанта).
services.AddScoped<IOperatorAuthStore, OperatorAuthStore>(); services.AddScoped<IOperatorAuthStore, OperatorAuthStore>();
services.AddScoped<ITenantRepository, TenantRepository>(); services.AddScoped<ITenantRepository, TenantRepository>();
services.AddScoped<IAuditLogStore, AuditLogStore>(); services.AddScoped<IAuditLogStore, AuditLogStore>();
// + агрегаты для аналитики; порт без Update/Delete.
services.AddScoped<ITokenUsageEventStore, TokenUsageEventStore>(); services.AddScoped<ITokenUsageEventStore, TokenUsageEventStore>();
services.AddScoped<IInviteStore, InviteStore>(); services.AddScoped<IInviteStore, InviteStore>();
// дефолт-бюджет из конфигурации (Program.cs) — ленивый GetOrCreate новой строки использует его;
// DealDbContext уже зарегистрирован в Api (AddDbContext до AddDealPersistence).
services.AddScoped<ITenantLimitStore>(provider => new TenantLimitStore( services.AddScoped<ITenantLimitStore>(provider => new TenantLimitStore(
provider.GetRequiredService<DealDbContext>(), provider.GetRequiredService<DealDbContext>(),
tenantLimitDefaults ?? TokenBudgetDefaults.Default)); tenantLimitDefaults ?? TokenBudgetDefaults.Default));
// общее хранилище распределённого rate limiting (auth/api/gRPC) и учёта попыток входа. Scoped
// (DealDbContext); лимитер резолвит его в собственном scope на каждое приобретение.
services.AddScoped<IRateLimitCounterStore, RateLimitCounterStore>(); services.AddScoped<IRateLimitCounterStore, RateLimitCounterStore>();
// контекст — scoped TenantDbContext запроса (см. AddDbContext в Deal.Api).
services.AddScoped<ISettingsStore, SettingsStore>(); services.AddScoped<ISettingsStore, SettingsStore>();
// KV-хранилище глобальных (системных) настроек оператора (ТЗ §4.1/§8.1): таблица
// public.global_settings, контекст — системный DealDbContext (public-схема). Ключи Telegram
// задаёт оператор, ядро читает их для команд входа (TelegramKeysService).
services.AddScoped<IGlobalSettingsStore, GlobalSettingsStore>(); services.AddScoped<IGlobalSettingsStore, GlobalSettingsStore>();
services.AddScoped<ICardStore, KanbanStore>(); services.AddScoped<ICardStore, KanbanStore>();
// KanbanStore при жёстком удалении карточки чистит DedupEntries напрямую тем же TenantDbContext
services.AddScoped<IPipelineStore, PipelineStore>(); services.AddScoped<IPipelineStore, PipelineStore>();
services.AddScoped<IMlLearningStore, MlLearningStore>(); services.AddScoped<IMlLearningStore, MlLearningStore>();
services.AddScoped<ITelegramStore, TelegramStore>(); services.AddScoped<ITelegramStore, TelegramStore>();
// тенанта. Проверки «уже мониторится» читают таблицу Dialogs (владелец — Telegram) тем же TenantDbContext.
services.AddScoped<IDiscoveryStore, DiscoveryStore>(); services.AddScoped<IDiscoveryStore, DiscoveryStore>();
// ConnectionStringProvider — он регистрируется в Deal.Api (Program.cs) как singleton.
services.AddScoped<ITenantProvisioner, TenantProvisioningService>(); services.AddScoped<ITenantProvisioner, TenantProvisioningService>();
// параллелизм + логирование прогресса поверх ITenantRepository и ITenantProvisioner.
services.AddScoped<TenantSchemaMigrationService>(); services.AddScoped<TenantSchemaMigrationService>();
// Выбранных vs дашборд-контейнер» живёт в адаптере, а не в эндпоинте. Scoped —
// композирует scoped-сервис карточек в рамках tenant-запроса.
services.AddScoped<ICardMover, CardMover>(); services.AddScoped<ICardMover, CardMover>();
return services; return services;
} }
@@ -93,11 +75,9 @@ public static class ServiceCollectionExtensions
/// <summary> /// <summary>
/// Регистрирует адаптеры внешних интеграций /// Регистрирует адаптеры внешних интеграций
/// </summary> /// </summary>
/// <param name="services">Коллекция сервисов.</param>
/// <param name="mlOptions">Конфигурация секции <c>Services:Ml</c> — выбор реализации IMlClient.</param> /// <param name="mlOptions">Конфигурация секции <c>Services:Ml</c> — выбор реализации IMlClient.</param>
/// <param name="aiOptions">Конфигурация секции <c>Services:Ai</c> — выбор реализации IAiClassifier/IAiTools.</param> /// <param name="aiOptions">Конфигурация секции <c>Services:Ai</c> — выбор реализации IAiClassifier/IAiTools.</param>
/// <param name="telegramOptions">Конфигурация секции <c>Services:Telegram</c> — выбор реализации ITelegramGateway.</param> /// <param name="telegramOptions">Конфигурация секции <c>Services:Telegram</c> — выбор реализации ITelegramGateway.</param>
/// <returns>Коллекция сервисов для цепочки вызовов.</returns>
public static IServiceCollection AddDealIntegrations( public static IServiceCollection AddDealIntegrations(
this IServiceCollection services, this IServiceCollection services,
MlServiceOptions mlOptions, MlServiceOptions mlOptions,
@@ -105,9 +85,6 @@ public static class ServiceCollectionExtensions
TelegramServiceOptions telegramOptions, TelegramServiceOptions telegramOptions,
MtlsCertificates? mtlsCertificates = null) MtlsCertificates? mtlsCertificates = null)
{ {
// остаются plaintext + service-token (dev); при включённом флаге каждый транспорт подписывает запрос
// клиентским сертификатом и проверяет CA сервера (fail-fast на загрузку — в MtlsCertificates.Load).
// событий. Регистрируется независимо от AI-режима — им пользуется и gRPC ML-клиент (событие kind=ml).
services.AddScoped<TokenUsageRecorder>(); services.AddScoped<TokenUsageRecorder>();
if (mlOptions.UseLocal) if (mlOptions.UseLocal)
@@ -116,8 +93,6 @@ public static class ServiceCollectionExtensions
} }
else else
{ {
// gRPC-клиент ml-service: транспорт создаётся сразу (fail-fast: пустой endpoint/токен останавливают
// KV/таблицы тенанта (как LocalMlClient) и ходит в сервис по metadata tenant-id/service-token.
services.AddSingleton(new MlGrpcConnection(mlOptions, mtlsCertificates)); services.AddSingleton(new MlGrpcConnection(mlOptions, mtlsCertificates));
services.AddSingleton<MlStatusCache>(); services.AddSingleton<MlStatusCache>();
services.AddScoped<GrpcMlClient>(); services.AddScoped<GrpcMlClient>();
@@ -125,18 +100,8 @@ public static class ServiceCollectionExtensions
services.AddScoped<IMlTrainClient>(provider => provider.GetRequiredService<GrpcMlClient>()); services.AddScoped<IMlTrainClient>(provider => provider.GetRequiredService<GrpcMlClient>());
} }
// LocalColumnSuggester читает карточки через ICardStore, считает группы ядром SuggestHeuristics
// (модуль Kanban) и создаёт доски suggested=true через ContainersService; scoped — его зависимости
// gRPC-клиентом ai-service с тем же контрактом.
services.AddScoped<IColumnSuggester, LocalColumnSuggester>(); services.AddScoped<IColumnSuggester, LocalColumnSuggester>();
// (UseLocal=true) либо gRPC-адаптер ai-service (UseLocal=false). LocalAiClassifier разбирает сообщение
// ядром LocalFieldsParser модуля Pipeline (маркерная гипотеза типа: is_vacancy_known=false, board=null —
// «смысловые колонки до ИИ не назначаем») и всегда пропускает ИИ-фильтр {pass:true, skipped:true};
// scoped — LocalFieldsParser читает маркеры hireMarkers/levelTerms из KV-настроек тенанта (ISettingsStore).
// GrpcAiClassifier строит промпты/контекст классификации (AiClassifyContextBuilder модуля Pipeline,
// регистрируется AddPipelineModule), ходит в ai-service с ProviderConfig из настроек (расшифровка apiKey)
// и копит usage в KV aiTokenUsage; недоступность/ok=false → AiUnavailableException — воркер падает в
if (aiOptions.UseLocal) if (aiOptions.UseLocal)
{ {
services.AddScoped<IAiClassifier, LocalAiClassifier>(); services.AddScoped<IAiClassifier, LocalAiClassifier>();
@@ -144,16 +109,10 @@ public static class ServiceCollectionExtensions
} }
else else
{ {
// gRPC-клиент ai-service: транспорт создаётся сразу (fail-fast: пустой endpoint/токен останавливают
// в сервис по metadata tenant-id/service-token; usage ответов списывает TokenUsageRecorder
services.AddSingleton(new AiGrpcConnection(aiOptions, mtlsCertificates)); services.AddSingleton(new AiGrpcConnection(aiOptions, mtlsCertificates));
services.AddScoped<AiProviderConfigBuilder>(); services.AddScoped<AiProviderConfigBuilder>();
services.AddScoped<GrpcAiClassifier>(); services.AddScoped<GrpcAiClassifier>();
// исчерпанном бюджете/приостановке уводит вызов на детерминированный бесплатный локальный разбор
// (LocalAiClassifier; LocalFieldsParser регистрирует AddPipelineModule в Program.cs). Инструменты локального
services.AddScoped<LocalAiClassifier>(); services.AddScoped<LocalAiClassifier>();
// Декораторы бюджетного гейта (порядок Grpc → Budgeted → наружу): перед каждым платным вызовом
// GetStateAsync (ITenantLimitStore, scoped) — исчерпано/приостановлено → Local-классификатор либо
services.AddScoped<IAiClassifier>(provider => new BudgetedAiClassifier( services.AddScoped<IAiClassifier>(provider => new BudgetedAiClassifier(
provider.GetRequiredService<GrpcAiClassifier>(), provider.GetRequiredService<GrpcAiClassifier>(),
provider.GetRequiredService<LocalAiClassifier>(), provider.GetRequiredService<LocalAiClassifier>(),
@@ -168,10 +127,6 @@ public static class ServiceCollectionExtensions
provider.GetRequiredService<ILogger<BudgetedAiTools>>())); provider.GetRequiredService<ILogger<BudgetedAiTools>>()));
} }
// локальная заглушка dev LocalTelegramGateway (нейтральный no-op/idle — сервис не поднят); при
// UseLocal=false — gRPC-клиент GrpcTelegramClient (telegram.proto) + синглтон-транспорт
// TelegramGrpcConnection (fail-fast, как MlGrpcConnection). Scoped-адаптер: tenant-id для metadata
// берёт из ITenantContext (AsyncLocal) scope вызова (HTTP-запрос/tenant-циклы ингресса).
if (telegramOptions.UseLocal) if (telegramOptions.UseLocal)
{ {
services.AddSingleton<ITelegramGateway, LocalTelegramGateway>(); services.AddSingleton<ITelegramGateway, LocalTelegramGateway>();
@@ -189,9 +144,7 @@ public static class ServiceCollectionExtensions
/// <summary> /// <summary>
/// Регистрирует сервисы шифрования секретов /// Регистрирует сервисы шифрования секретов
/// </summary> /// </summary>
/// <param name="services">Коллекция сервисов.</param>
/// <param name="contentRootPath">ContentRoot приложения — каталог по умолчанию для файла-ключа data/encryption.key.</param> /// <param name="contentRootPath">ContentRoot приложения — каталог по умолчанию для файла-ключа data/encryption.key.</param>
/// <returns>Коллекция сервисов для цепочки вызовов.</returns>
public static IServiceCollection AddDealSecurity(this IServiceCollection services, string contentRootPath) public static IServiceCollection AddDealSecurity(this IServiceCollection services, string contentRootPath)
{ {
EncryptionKeyProvider keyProvider = new(contentRootPath); EncryptionKeyProvider keyProvider = new(contentRootPath);
@@ -19,8 +19,6 @@ public sealed class CardMover(CardsService cardsService) : ICardMover
TransitionContext ctx, TransitionContext ctx,
CancellationToken ct) CancellationToken ct)
{ {
// Контекст перехода (инициатор/обучение) учтён внутри маршрутов: дашборд-перенос обучает ML по
// цели пользователя, переход по стадии пишет историю и сбрасывает напоминание. Отдельного
CardResultDto result = CardsDefaultContainers.Contains(toContainerId) CardResultDto result = CardsDefaultContainers.Contains(toContainerId)
? await cardsService.MoveStageCardAsync(cardId, toContainerId, ct) ? await cardsService.MoveStageCardAsync(cardId, toContainerId, ct)
: await cardsService.MoveDashboardCardAsync(cardId, toContainerId, ct); : await cardsService.MoveDashboardCardAsync(cardId, toContainerId, ct);
@@ -33,7 +33,7 @@ public interface IContainerRules
public IReadOnlyList<string> Grades { get; } public IReadOnlyList<string> Grades { get; }
/// <summary> /// <summary>
/// Уровень (отдельная группа §6.3; пусто — не участвует). /// Уровень.
/// </summary> /// </summary>
public IReadOnlyList<string> Levels { get; } public IReadOnlyList<string> Levels { get; }
@@ -53,7 +53,7 @@ public interface IContainerRules
public CardBudget? Budget { get; } public CardBudget? Budget { get; }
/// <summary> /// <summary>
/// Диапазон цены /// Диапазон цены.
/// </summary> /// </summary>
public CardBudget? Prices { get; } public CardBudget? Prices { get; }
@@ -1,12 +1,12 @@
namespace Deal.Modules.Cards.Application.Abstractions; namespace Deal.Modules.Cards.Application.Abstractions;
/// <summary> /// <summary>
/// Модуль «ТЗ»: текст технического задания/заметки-задания по карточке. /// Модуль « »: текст технического задания/заметки-задания по карточке.
/// </summary> /// </summary>
public interface ITzCard public interface ITzCard
{ {
/// <summary> /// <summary>
/// Текст ТЗ (пусто — ТЗ нет). /// Текст.
/// </summary> /// </summary>
public string TzText { get; } public string TzText { get; }
} }
@@ -66,7 +66,6 @@ public sealed class Card :
/// <inheritdoc /> /// <inheritdoc />
public IReadOnlyList<CardFile> Files { get; init; } = Array.Empty<CardFile>(); public IReadOnlyList<CardFile> Files { get; init; } = Array.Empty<CardFile>();
// ── Модуль «ТЗ» ──
/// <inheritdoc /> /// <inheritdoc />
public string TzText { get; init; } = string.Empty; public string TzText { get; init; } = string.Empty;
@@ -13,8 +13,6 @@ public static class DiscoveryModuleRegistrar
/// <summary> /// <summary>
/// Регистрирует сервисы модуля Discovery в контейнере. /// Регистрирует сервисы модуля Discovery в контейнере.
/// </summary> /// </summary>
/// <param name="services">Коллекция сервисов.</param>
/// <returns>Коллекция сервисов для цепочки вызовов.</returns>
public static IServiceCollection AddDiscoveryModule(this IServiceCollection services) public static IServiceCollection AddDiscoveryModule(this IServiceCollection services)
{ {
services.AddScoped<DiscoveryPlanGuard>(); services.AddScoped<DiscoveryPlanGuard>();
@@ -23,8 +21,6 @@ public static class DiscoveryModuleRegistrar
services.AddScoped<DiscoveryBlacklistService>(); services.AddScoped<DiscoveryBlacklistService>();
services.AddScoped<DiscoveryLogService>(); services.AddScoped<DiscoveryLogService>();
// (DiscoveryWorkerScheduler); IDiscoverySearchErrorCounter — singleton (счётчик ошибок ключей живёт
// чтобы не резолвить опциональный параметр utcNow (дефолт — DateTimeOffset.UtcNow).
services.AddSingleton<DiscoverySearchErrorCounter>(); services.AddSingleton<DiscoverySearchErrorCounter>();
services.AddSingleton<IDiscoverySearchErrorCounter>(sp => sp.GetRequiredService<DiscoverySearchErrorCounter>()); services.AddSingleton<IDiscoverySearchErrorCounter>(sp => sp.GetRequiredService<DiscoverySearchErrorCounter>());
services.AddScoped<DiscoveryEvaluator>(); services.AddScoped<DiscoveryEvaluator>();
@@ -90,9 +90,6 @@ public sealed class DiscoveryBanGuard
private long StartOfDayMs() => StartOfDayUtc().ToUnixTimeMilliseconds(); private long StartOfDayMs() => StartOfDayUtc().ToUnixTimeMilliseconds();
// floodDay: Epoch-ms начала суток flood-блокировки (0 — не было).
// startOfDayMs: Начало текущих UTC-суток в epoch-ms.
// Возвращает: True — flood фиксировался сегодня.
private static bool IsFloodToday(long floodDay, long startOfDayMs) private static bool IsFloodToday(long floodDay, long startOfDayMs)
=> floodDay != 0 && floodDay == startOfDayMs; => floodDay != 0 && floodDay == startOfDayMs;
} }
@@ -81,7 +81,6 @@ public sealed class DiscoveryCandidatesService(
DiscoveryTaskDto? task = await store.GetTaskAsync(taskId, ct).ConfigureAwait(false); DiscoveryTaskDto? task = await store.GetTaskAsync(taskId, ct).ConfigureAwait(false);
if (task is null) if (task is null)
{ {
// для существующих running-задач; строки нет → источник просто не добавляется).
return null; return null;
} }
@@ -274,8 +273,6 @@ public sealed class DiscoveryCandidatesService(
return store.DeleteCandidateAsync(dialogId, ct); return store.DeleteCandidateAsync(dialogId, ct);
} }
// patch: Патч из запроса.
// Возвращает: Патч с нормализованными значениями (null-поля — «не менять»).
private static DiscoveryCandidatePatch NormalizePatch(DiscoveryCandidatePatch patch, DiscoveryCandidateDto current) private static DiscoveryCandidatePatch NormalizePatch(DiscoveryCandidatePatch patch, DiscoveryCandidateDto current)
{ {
return new DiscoveryCandidatePatch return new DiscoveryCandidatePatch
@@ -293,9 +290,6 @@ public sealed class DiscoveryCandidatesService(
}; };
} }
// value: Значение патча (null — не менялось).
// current: Текущее значение строки.
// Возвращает: Обрезанное значение или null (поле не меняется).
private static string? NormalizeKeptOr(string? value, string current) private static string? NormalizeKeptOr(string? value, string current)
{ {
if (value is null) if (value is null)
@@ -225,8 +225,6 @@ public sealed class DiscoveryTasksService(IDiscoveryStore store, DiscoveryPlanGu
return await store.AdvanceSearchAsync(taskId, nextIndex, searchDone, ct).ConfigureAwait(false); return await store.AdvanceSearchAsync(taskId, nextIndex, searchDone, ct).ConfigureAwait(false);
} }
// patch: Патч из запроса.
// Возвращает: Патч с нормализованными значениями (null-поля сохраняются как «не менять»).
private static DiscoveryTaskPatch NormalizeTaskPatch(DiscoveryTaskPatch patch) private static DiscoveryTaskPatch NormalizeTaskPatch(DiscoveryTaskPatch patch)
{ {
int? threshold = patch.Threshold is int value ? Clamp(value, min: 1, max: 100) : null; int? threshold = patch.Threshold is int value ? Clamp(value, min: 1, max: 100) : null;
@@ -249,8 +247,6 @@ public sealed class DiscoveryTasksService(IDiscoveryStore store, DiscoveryPlanGu
}; };
} }
// keywords: Сырые ключи (null — пусто).
// Возвращает: Список непустых ключей.
private static IReadOnlyList<string> CleanKeywords(IReadOnlyList<string>? keywords) private static IReadOnlyList<string> CleanKeywords(IReadOnlyList<string>? keywords)
{ {
return keywords?.Where(keyword => !string.IsNullOrWhiteSpace(keyword)) return keywords?.Where(keyword => !string.IsNullOrWhiteSpace(keyword))
@@ -1,6 +1,5 @@
namespace Deal.Modules.Discovery.Application.Services; namespace Deal.Modules.Discovery.Application.Services;
// Часть DiscoveryWorkerService: константы — действия тика, пороги/размеры воркера и метки кандидатов
public sealed partial class DiscoveryWorkerService public sealed partial class DiscoveryWorkerService
{ {
/// <summary> /// <summary>
@@ -3,13 +3,8 @@ using Deal.Modules.Discovery.Application.Models;
namespace Deal.Modules.Discovery.Application.Services; namespace Deal.Modules.Discovery.Application.Services;
// Часть DiscoveryWorkerService: шаг оценки кандидата status='new' — инфо источника, выборка, язык, объём
public sealed partial class DiscoveryWorkerService public sealed partial class DiscoveryWorkerService
{ {
// task: Running-задача кандидата.
// candidate: Самый старый кандидат статуса new.
// ct: Токен отмены.
// Возвращает: review — кандидат оценён; skip — пропущен (delete); error — сбой шага.
private async Task<DiscoveryWorkerOutcome> EvalStepAsync( private async Task<DiscoveryWorkerOutcome> EvalStepAsync(
DiscoveryTaskDto task, DiscoveryTaskDto task,
DiscoveryCandidateDto candidate, DiscoveryCandidateDto candidate,
@@ -46,7 +41,6 @@ public sealed partial class DiscoveryWorkerService
}; };
if (resolved) if (resolved)
{ {
// Имя/username обновляем только при успешном резолве: при fallback discovery_info возвращает
patch = patch with patch = patch with
{ {
Name = string.IsNullOrWhiteSpace(info.Name) ? null : info.Name.Trim(), Name = string.IsNullOrWhiteSpace(info.Name) ? null : info.Name.Trim(),
@@ -195,13 +189,6 @@ public sealed partial class DiscoveryWorkerService
return new DiscoveryWorkerOutcome(ActionSkip, taskId); return new DiscoveryWorkerOutcome(ActionSkip, taskId);
} }
// taskId: Id задачи.
// dialogId: Id источника.
// marks: Метки (пустые отфильтровываются).
// langRu: Подтверждённый язык (null — не менять).
// fitRatio: Доля подходящих (null — контент не оценён).
// topics: Темы форума (null — не менять; для не-форумов не заполняется).
// ct: Токен отмены.
private async Task FinishReviewAsync( private async Task FinishReviewAsync(
string taskId, string taskId,
string dialogId, string dialogId,
@@ -7,12 +7,6 @@ namespace Deal.Modules.Discovery.Application.Services;
// источника (KindCode) и тексты сообщений для оценки (GroupTexts). // источника (KindCode) и тексты сообщений для оценки (GroupTexts).
public sealed partial class DiscoveryWorkerService public sealed partial class DiscoveryWorkerService
{ {
// Flood-ли это исключение гейта: RpcException RESOURCE_EXHAUSTED c detail-префиксом «flood:»
// текст исключения: RpcException.Message кодирует Status как
// Status(StatusCode="ResourceExhausted", Detail="flood: FLOOD_WAIT_…") — ищем маркер «flood:»
// (префикс detail, которым сервис помечает FloodWait; обычные ошибки его не несут).
// exception: Исключение вызова гейта.
// Возвращает: True — FloodWait Telegram (стоп до конца суток, note_flood).
internal static bool IsFlood(Exception exception) internal static bool IsFlood(Exception exception)
{ {
string message = exception.Message ?? string.Empty; string message = exception.Message ?? string.Empty;
@@ -21,9 +15,6 @@ public sealed partial class DiscoveryWorkerService
|| message.Contains("flood:", StringComparison.OrdinalIgnoreCase); || message.Contains("flood:", StringComparison.OrdinalIgnoreCase);
} }
// kind: Kind из гейта (EN-канон) либо русская форма каталога.
// isForum: True — мегагруппа-форум (темы) → forum.
// Возвращает: Код кандидата: channel/group/forum (неизвестное → group).
private static string KindCode(string kind, bool isForum = false) private static string KindCode(string kind, bool isForum = false)
{ {
if (isForum) if (isForum)
@@ -3,13 +3,8 @@ using Deal.Modules.Discovery.Application.Models;
namespace Deal.Modules.Discovery.Application.Services; namespace Deal.Modules.Discovery.Application.Services;
// Часть DiscoveryWorkerService: шаг авто-вступления кандидата status='review' — повторные проверки,
public sealed partial class DiscoveryWorkerService public sealed partial class DiscoveryWorkerService
{ {
// task: Running-задача с autoJoin.
// candidate: Самый старый кандидат статуса review.
// ct: Токен отмены.
// Возвращает: join/reject/flood/error/skip — действие; none — выход без вступления (изменения за паузу).
private async Task<DiscoveryWorkerOutcome> JoinStepAsync( private async Task<DiscoveryWorkerOutcome> JoinStepAsync(
DiscoveryTaskDto task, DiscoveryTaskDto task,
DiscoveryCandidateDto candidate, DiscoveryCandidateDto candidate,
@@ -37,8 +32,6 @@ public sealed partial class DiscoveryWorkerService
await _pacer.WaitJoinDelayAsync(ct).ConfigureAwait(false); await _pacer.WaitJoinDelayAsync(ct).ConfigureAwait(false);
// За время паузы задача/кандидат/состояние BanGuard могли измениться: вступаем только если кандидат
// всё ещё есть и в review, задача ещё running с autoJoin, мы не состоим и авто-вступления разрешены
DiscoveryTaskDto? taskNow = await _tasks.GetAsync(taskId, ct).ConfigureAwait(false); DiscoveryTaskDto? taskNow = await _tasks.GetAsync(taskId, ct).ConfigureAwait(false);
DiscoveryCandidateDto? fresh = await _store.GetCandidateAsync(dialogId, ct).ConfigureAwait(false); DiscoveryCandidateDto? fresh = await _store.GetCandidateAsync(dialogId, ct).ConfigureAwait(false);
if (fresh is null if (fresh is null
@@ -71,7 +64,6 @@ public sealed partial class DiscoveryWorkerService
return new DiscoveryWorkerOutcome(ActionFlood, taskId); return new DiscoveryWorkerOutcome(ActionFlood, taskId);
} }
// Между паузой и неудачным join кандидата могли отклонить/удалить: счётчик и удаление трогаем
DiscoveryCandidateDto? rowNow = await _store.GetCandidateAsync(dialogId, ct).ConfigureAwait(false); DiscoveryCandidateDto? rowNow = await _store.GetCandidateAsync(dialogId, ct).ConfigureAwait(false);
if (rowNow is null || rowNow.Status != DiscoveryCandidateStatuses.Review) if (rowNow is null || rowNow.Status != DiscoveryCandidateStatuses.Review)
{ {
@@ -100,7 +92,6 @@ public sealed partial class DiscoveryWorkerService
return new DiscoveryWorkerOutcome(ActionError, taskId); return new DiscoveryWorkerOutcome(ActionError, taskId);
} }
// Вступление состоялось: joined(auto) + каталог/зеркало (монитор on) + фоновый разбор последних
await _candidates.MarkJoinedAsync(dialogId, auto: true, ct).ConfigureAwait(false); await _candidates.MarkJoinedAsync(dialogId, auto: true, ct).ConfigureAwait(false);
try try
@@ -129,7 +120,6 @@ public sealed partial class DiscoveryWorkerService
} }
catch (Exception exception) catch (Exception exception)
{ {
// но фиксируется в DiscLog, иначе «немое» глотание скрывает регулярные сбои.
await _log.AddAsync(taskId, DiscoveryLogEvents.Error, $"фоновый разбор {dialogId}: {exception.Message}", ct) await _log.AddAsync(taskId, DiscoveryLogEvents.Error, $"фоновый разбор {dialogId}: {exception.Message}", ct)
.ConfigureAwait(false); .ConfigureAwait(false);
} }
@@ -3,12 +3,8 @@ using Deal.Modules.Discovery.Application.Models;
namespace Deal.Modules.Discovery.Application.Services; namespace Deal.Modules.Discovery.Application.Services;
// Часть DiscoveryWorkerService: шаг поиска — один ключ keywords[SearchIdx] → кандидаты + advance_search
public sealed partial class DiscoveryWorkerService public sealed partial class DiscoveryWorkerService
{ {
// task: Running-задача с незавершённым проходом.
// ct: Токен отмены.
// Возвращает: search — ключ обработан; flood/error — сбой (индекс ключа/счётчик ошибок по сценарию).
private async Task<DiscoveryWorkerOutcome> SearchStepAsync(DiscoveryTaskDto task, CancellationToken ct) private async Task<DiscoveryWorkerOutcome> SearchStepAsync(DiscoveryTaskDto task, CancellationToken ct)
{ {
string taskId = task.Id; string taskId = task.Id;
@@ -40,7 +36,6 @@ public sealed partial class DiscoveryWorkerService
int errors = _searchErrors.Next(taskId); int errors = _searchErrors.Next(taskId);
if (errors >= SearchErrorsToSkip) if (errors >= SearchErrorsToSkip)
{ {
// 3 ошибки подряд одного ключа: пропускаем (битый ключ не должен зацикливать поиск и
_searchErrors.Reset(taskId); _searchErrors.Reset(taskId);
await _log.AddAsync( await _log.AddAsync(
taskId, taskId,
@@ -81,7 +81,6 @@ public sealed partial class DiscoveryWorkerService
return DiscoveryWorkerOutcome.None; return DiscoveryWorkerOutcome.None;
} }
// 1. План достигнут — закрываем задачу (важно до поиска/оценки/join: задачу с выполненным планом
foreach (DiscoveryTaskDto task in running) foreach (DiscoveryTaskDto task in running)
{ {
if (task.Joined >= task.PlanJoins) if (task.Joined >= task.PlanJoins)
@@ -138,9 +138,6 @@ public static class AmountParser
return result; return result;
} }
// Число из строки суммы: пробелы и неразрывные пробелы убираем, «,» — десятичный разделитель,
// raw: Сырая цифровая часть из regex (может содержать «к»/«К» на конце).
// Возвращает: Значение суммы или null при ошибке парсинга.
private static double? NormalizeAmount(string raw) private static double? NormalizeAmount(string raw)
{ {
string s = raw.Replace("\u00a0", " ").Replace(" ", string.Empty).Replace(",", "."); string s = raw.Replace("\u00a0", " ").Replace(" ", string.Empty).Replace(",", ".");
@@ -160,10 +157,6 @@ public static class AmountParser
: null; : null;
} }
// Код валюты рядом с суммой: символ/слово сразу после позиции либо символ за ≤3 символа до неё
// text: Весь текст сообщения.
// position: Позиция сразу после конца суммы (m.end).
// Возвращает: Код валюты или null, если валюты рядом нет.
private static string? CurFromTail(string text, int position) private static string? CurFromTail(string text, int position)
{ {
int tailLength = Math.Min(12, text.Length - position); int tailLength = Math.Min(12, text.Length - position);
@@ -192,8 +192,6 @@ public static class ColumnMatcher
return count; return count;
} }
// terms: Термы как сохранены.
// Возвращает: Непустые термы в нижнем регистре.
private static IReadOnlyList<string> NormalizeTerms(IReadOnlyList<string>? terms) private static IReadOnlyList<string> NormalizeTerms(IReadOnlyList<string>? terms)
{ {
var result = new List<string>(); var result = new List<string>();
@@ -18,19 +18,15 @@ public static class MatchHitBuilder
// Метка группы «грейд/уровень». // Метка группы «грейд/уровень».
private const string GradeLabel = "Грейд/уровень"; private const string GradeLabel = "Грейд/уровень";
// Метка отдельной группы «уровень» (§6.3).
private const string LevelsLabel = "Уровень"; private const string LevelsLabel = "Уровень";
// Метка группы «локация» (§6.3).
private const string LocationsLabel = "Локация"; private const string LocationsLabel = "Локация";
// Метка группы «тип» (§6.3).
private const string TypesLabel = "Тип"; private const string TypesLabel = "Тип";
// Метка группы «бюджет». // Метка группы «бюджет».
private const string BudgetLabel = "Бюджет"; private const string BudgetLabel = "Бюджет";
// Метка отдельной группы «цена» (§6.3).
private const string PricesLabel = "Цена"; private const string PricesLabel = "Цена";
/// <summary> /// <summary>
@@ -130,10 +126,6 @@ public static class MatchHitBuilder
} }
} }
// rawTerms: Термы как сохранены.
// label: Метка группы.
// lower: Текст в нижнем регистре.
// Возвращает: Hits по каждому совпавшему терму (term сохраняет регистр пользователя).
private static IEnumerable<MatchHitDto> MatchedTermHits( private static IEnumerable<MatchHitDto> MatchedTermHits(
IReadOnlyList<string>? rawTerms, IReadOnlyList<string>? rawTerms,
string label, string label,
@@ -154,8 +146,6 @@ public static class MatchHitBuilder
} }
} }
// budget: Бюджет правил.
// Возвращает: «от X до Y CUR» / «до X CUR» / «от X CUR» / «бюджет».
private static string DescribeRange(BudgetRangeDto budget) private static string DescribeRange(BudgetRangeDto budget)
{ {
string cur = (budget.Cur ?? string.Empty).ToUpperInvariant(); string cur = (budget.Cur ?? string.Empty).ToUpperInvariant();
@@ -177,8 +167,6 @@ public static class MatchHitBuilder
return "бюджет"; return "бюджет";
} }
// value: Число.
// Возвращает: Строка по инвариантной культуре.
private static string FormatAmount(double value) private static string FormatAmount(double value)
{ {
return value.ToString("G6", System.Globalization.CultureInfo.InvariantCulture).Replace('E', 'e'); return value.ToString("G6", System.Globalization.CultureInfo.InvariantCulture).Replace('E', 'e');
@@ -106,8 +106,6 @@ public static class RulesDescriber
parts.Add($"{title}: {lo}{hi} {cur}".Replace(" ", "").Replace(" ", "")); parts.Add($"{title}: {lo}{hi} {cur}".Replace(" ", "").Replace(" ", ""));
} }
// terms: Термы группы.
// limit: Максимум термов.
private static string JoinLimited(IReadOnlyList<string> terms, int limit) private static string JoinLimited(IReadOnlyList<string> terms, int limit)
{ {
int count = Math.Min(terms.Count, limit); int count = Math.Min(terms.Count, limit);
@@ -1,7 +1,7 @@
namespace Deal.Modules.Kanban.Application.ColumnRules; namespace Deal.Modules.Kanban.Application.ColumnRules;
/// <summary> /// <summary>
/// Синонимы типов заявки для группы <c>types</c> правил колонки и глобальных исключений /// Синонимы типов заявки для группы <c>types</c> правил колонки и глобальных исключений.
/// </summary> /// </summary>
public static class TypeAliases public static class TypeAliases
{ {
@@ -12,15 +12,12 @@ public static class KanbanModuleRegistrar
/// <summary> /// <summary>
/// Регистрирует сервисы модуля Kanban в контейнере. /// Регистрирует сервисы модуля Kanban в контейнере.
/// </summary> /// </summary>
/// <param name="services">Коллекция сервисов.</param>
/// <returns>Коллекция сервисов для цепочки вызовов.</returns>
public static IServiceCollection AddKanbanModule(this IServiceCollection services) public static IServiceCollection AddKanbanModule(this IServiceCollection services)
{ {
services.AddScoped<ContainersService>(); services.AddScoped<ContainersService>();
services.AddScoped<CardsService>(); services.AddScoped<CardsService>();
services.AddScoped<StorageTickService>(); services.AddScoped<StorageTickService>();
// оповещают его после записи кэша курсов / смены targetCurrency|conversionOn (список может быть пуст).
services.AddScoped<IRatesChangedListener, ConversionRecomputer>(); services.AddScoped<IRatesChangedListener, ConversionRecomputer>();
return services; return services;
} }
@@ -100,8 +100,6 @@ public static class BudgetNormalizer
return new CardBudgetDto(convFrom, convTo, target); return new CardBudgetDto(convFrom, convTo, target);
} }
// value: Значение границы из бюджета.
// Возвращает: Значение или null при 0.
private static double? NormalizeBound(double? value) private static double? NormalizeBound(double? value)
{ {
if (value is null || value.Value == 0) if (value is null || value.Value == 0)
@@ -112,8 +110,6 @@ public static class BudgetNormalizer
return value.Value; return value.Value;
} }
// raw: Валюта как пришла («рублей», «$», «usd», …).
// Возвращает: Код валюты (USD/RUB/…) или null, если не распознана.
private static string? NormalizeCurrency(string? raw) private static string? NormalizeCurrency(string? raw)
{ {
string s = (raw ?? string.Empty).Trim().ToUpperInvariant(); string s = (raw ?? string.Empty).Trim().ToUpperInvariant();
@@ -120,10 +120,6 @@ public sealed partial class CardsService
?? throw new InvalidOperationException("Карточка не прочиталась после удаления файла: " + cardId); ?? throw new InvalidOperationException("Карточка не прочиталась после удаления файла: " + cardId);
} }
// cardId: Id карточки (c_...).
// fileId: Id записи файла (pf_...; уникальный суффикс ключа).
// name: Имя файла как прислано (в ключ идёт санитизированная часть).
// Возвращает: Ключ объекта (opaque для хранилища).
private static string BuildObjectKey( private static string BuildObjectKey(
string cardId, string cardId,
string fileId, string fileId,
@@ -15,17 +15,12 @@ public sealed partial class CardsService
{ {
// ── Внутреннее ───────────────────────────────────────────────────────── // ── Внутреннее ─────────────────────────────────────────────────────────
// card: Карточка.
private static string LearningText(CardDto card) private static string LearningText(CardDto card)
{ {
string source = card.SourceMsg.Trim(); string source = card.SourceMsg.Trim();
return source.Length > 0 ? source : card.Title; return source.Length > 0 ? source : card.Title;
} }
// card: Карточка ДО переноса (для prev_col/from_col журнала).
// toCol: Новая колонка.
// action: Действие журнала: move/trash.
// ct: Токен отмены.
private async Task MoveToColumnAsync( private async Task MoveToColumnAsync(
CardDto card, CardDto card,
string toCol, string toCol,
@@ -43,11 +38,6 @@ public sealed partial class CardsService
await LogMoveAsync(card.Id, action, card.Col, toCol, ct); await LogMoveAsync(card.Id, action, card.Col, toCol, ct);
} }
// cardId: Id карточки.
// action: Действие: move/trash/restore/comment.
// fromCol: Прежняя колонка (для comment — null).
// toCol: Новая колонка (для comment — null).
// ct: Токен отмены.
private async Task LogMoveAsync( private async Task LogMoveAsync(
string cardId, string cardId,
string action, string action,
@@ -63,10 +53,6 @@ public sealed partial class CardsService
toCol), ct); toCol), ct);
} }
// boardId: Id доски (b_...).
// text: Текст карточки для правил (source_msg или title).
// ct: Токен отмены.
// Возвращает: Список совпавших критериев; доски нет/правил нет → пусто.
private async Task<IReadOnlyList<MatchHitDto>> ComputeHitsForBoardAsync( private async Task<IReadOnlyList<MatchHitDto>> ComputeHitsForBoardAsync(
string boardId, string boardId,
string text, string text,
@@ -78,11 +64,6 @@ public sealed partial class CardsService
: await ComputeHitsAsync(board.Rules, text, ct); : await ComputeHitsAsync(board.Rules, text, ct);
} }
// Совпавшие критерии правил колонки-доски (ColumnRules.ComputeHits с курсами для бюджета).
// Резолв имени — через алиас KanbanColumnRules: одноимённые класс и namespace ColumnRules в одном модуле.
// text: Текст карточки для правил.
// ct: Токен отмены.
// Возвращает: Совпавшие критерии (label/term[/word]); нет активных правил → пусто.
private async Task<IReadOnlyList<MatchHitDto>> ComputeHitsAsync( private async Task<IReadOnlyList<MatchHitDto>> ComputeHitsAsync(
ContainerRulesDto? rules, ContainerRulesDto? rules,
string text, string text,
@@ -94,9 +75,6 @@ public sealed partial class CardsService
return KanbanColumnRules.ComputeHits(rules, text, rates); return KanbanColumnRules.ComputeHits(rules, text, rates);
} }
// prevCol: Сохранённая prev_col карточки.
// ct: Токен отмены.
// Возвращает: Колонка возврата (inbox/доска).
private async Task<string> ResolveReturnColAsync(string prevCol, CancellationToken ct) private async Task<string> ResolveReturnColAsync(string prevCol, CancellationToken ct)
{ {
if (prevCol == CardIds.Inbox) if (prevCol == CardIds.Inbox)
@@ -102,8 +102,6 @@ public sealed partial class CardsService
return due; return due;
} }
// ct: Токен отмены.
// Возвращает: Значение настройки; отсутствие/повреждённый JSON → дефолт SettingsDefaults.RemindersEnabled.
private async Task<bool> ReadRemindersEnabledAsync(CancellationToken ct) private async Task<bool> ReadRemindersEnabledAsync(CancellationToken ct)
{ {
return (await TenantSettingsSnapshot.LoadAsync(_settings, ct)) return (await TenantSettingsSnapshot.LoadAsync(_settings, ct))
@@ -247,9 +247,6 @@ public sealed partial class CardsService
return _store.ClearStageAsync(RejectedStage, ct); return _store.ClearStageAsync(RejectedStage, ct);
} }
// Переводит тело PATCH в точечный патч хранилища: ключ присутствует → поле меняется;
// body: Тело PATCH (ключ → значение).
// Возвращает: Патч со значениями присутствующих ключей; остальные поля null («не менять»).
private static CardPatch ResolvePatch(IReadOnlyDictionary<string, JsonElement> body) private static CardPatch ResolvePatch(IReadOnlyDictionary<string, JsonElement> body)
{ {
string? title = null; string? title = null;

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