Почистить комментарии от ссылок на ТЗ и обрывков
Удаление целых //-блоков со ссылками (Task/Ruling/этап/ТЗ/§/ дизайн-док/api-map/python/прототип) вместо построчного вырезания — без обрывков фраз; снят боилерплейт <param>/<returns>; то же в .proto.
This commit is contained in:
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;
|
||||
|
||||
@@ -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>(
|
||||
|
||||
@@ -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('/');
|
||||
}
|
||||
|
||||
@@ -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 =>
|
||||
|
||||
@@ -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 > 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=&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=&actorType=&actorId=&tenantId=&from=&to=&limit=&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_<N>).
|
||||
// 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_<N>).
|
||||
// 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);
|
||||
}
|
||||
|
||||
@@ -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-<процесс>.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
|
||||
|
||||
@@ -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
@@ -1,6 +1,5 @@
|
||||
namespace Deal.Modules.Discovery.Application.Services;
|
||||
|
||||
// Часть DiscoveryWorkerService: константы — действия тика, пороги/размеры воркера и метки кандидатов
|
||||
public sealed partial class DiscoveryWorkerService
|
||||
{
|
||||
/// <summary>
|
||||
|
||||
-13
@@ -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
Reference in New Issue
Block a user