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

Удаление целых //-блоков со ссылками (Task/Ruling/этап/ТЗ/§/
дизайн-док/api-map/python/прототип) вместо построчного вырезания —
без обрывков фраз; снят боилерплейт <param>/<returns>; то же в
.proto.
This commit is contained in:
Rustam Khalimov
2026-09-11 13:49:26 +03:00
parent b053d58335
commit 79c931d88e
220 changed files with 32 additions and 1259 deletions
@@ -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);