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

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

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