Почистить комментарии от упоминаний процесса

Удалены <remarks>, <summary> сжаты до короткой фразы, вырезаны
ссылки на Task/Ruling/этап/python/прототип; //-комментарии со ссылками
на процесс удалены; то же в .proto. Правила обновлены в
docs/spec/Код-стайл-Дейл.md. Строк комментариев 27210 -> ~19100.
This commit is contained in:
Rustam Khalimov
2026-09-11 13:39:39 +03:00
parent 5f5538d33b
commit b053d58335
902 changed files with 3902 additions and 12074 deletions
+1 -1
View File
@@ -15,7 +15,7 @@
| BL-ML-EXP | Экспорт/импорт ML-моделей (перенос «мозгов» между инстансами) | обсуждение этапа 12 | P3 | DEFERRED (решено не делать; вернуться при SaaS-масштабе) |
| BL-RECLASS-SSE | Стриминг-прогресс пакетной переклассификации + финальный тост (сейчас синхронный проход + событие `cards_reclassified`) | этап 12, D | P3 | BACKLOG |
| TD-CARD-MERGE | Полное слияние внутренних DTO карточки в единый `CardDto` (наружу уже единый) | этап 9/11 | P3 | TECHDEBT |
| TD-PROTO-COMMENTS | Убрать из XML/обычных комментариев ссылки на прототип (`backend/app/*.py`, `LEADRADAR_*`, «прототип», номера строк python) и **переписать комментарии с нуля** — описывать текущее поведение и контракт, а не происхождение. Масштаб: ~**374 файла** (core 302, telegram 27, ml 15, ai 11). Делать **после окончательного перехода на новый стек**; правки только в комментариях (логику не трогать), с проверкой build+тестов | запрос владельца 2026-09-11 | P2 | TECHDEBT |
| TD-PROTO-COMMENTS | **Сделано (2026-09-11):** из комментариев убраны ссылки на процесс/прототип (`Task/Ruling/этап/python L…/main.py/прототип/LEADRADAR_*`), удалены блоки `<remarks>`, `<summary>` сжаты до короткой фразы; `//`-комментарии со ссылками удалены, в `.proto` — тоже. Строк комментариев 27 210 → ~19 100 | запрос владельца 2026-09-11 | P2 | DONE |
| TD-COMMENTS-IFACE | Привести код к правилам код-стайла (`docs/spec/Код-стайл-Дейл.md`). **Сделано (2026-09-11):** (1) `<summary>` только блочно — исправлено 5286 шт. в 833 файлах; (2) комментарии только на public/protected — понижено 2028 XML-доков с private/internal (359 файлов). **Осталось:** (3) не дублировать `<summary>` интерфейса в реализации (нужен Roslyn-анализ); (4) явная реализация интерфейсов там, где возможно (61 интерфейс, точечный ревью). Скрипты: `scripts/fix_summary_blocks.py`, `scripts/fix_private_docs.py`. Детали — `docs/spec/Код-стайл-аудит-2026-09-11.md` | запрос владельца 2026-09-11 | P2 | TECHDEBT (1,2 — DONE; 3,4 — BACKLOG) |
| TD-STYLE-ANALYZERS | Остаток мягких правил код-стайла: `var` для встроенных/неочевидных типов (1529, сейчас `silent`), дедупликация `<summary>``<inheritdoc/>` (Roslyn), решение по переводам строк (`.editorconfig` = CRLF, фактически 231 CRLF / 697 LF). Уже закрыто в `.editorconfig` (+build-проверка): запрет `this.` и именование приватных полей (`_camelCase`; `const`/`static readonly` — Pascal). Детали — `docs/spec/Код-стайл-аудит-2026-09-11.md` | аудит 2026-09-11 | P3 | BACKLOG |
+9
View File
@@ -112,6 +112,15 @@
там, где неочевидна причина/ограничение (короткий обычный комментарий).
- **Кратко.** Комментарий объясняет **зачем и что**, а не пересказывает код. Не дублировать имя и
сигнатуру словами.
- **`<summary>` — короткое описание (одна фраза).** Это назначение типа/члена, а **не** «как оно
работает» и не пояснения/детали реализации. Несколько предложений в summary не пишем.
- **`<remarks>` не используем** — подробные пояснения «как устроено» не нужны; rationale — только если
поведение действительно неочевидно, коротким обычным комментарием.
- **Никаких упоминаний процесса:** в комментариях запрещены ссылки на таски/этапы/рулинги/планы и
прототип (`Task N`, `Ruling N`, `этап N`, `python L…`, `main.py`, `прототип`, `LEADRADAR_*` и т.п.).
- **Внутренние `//`-комментарии — только для неочевидного поведения** (причина, ограничение, подвох).
Пересказ кода, пошаговая навигация и «что делает следующая строка» — удалять.
- **`<param>`/`<returns>`** — только если смысл не очевиден из имени/типа; не переписывать сигнатуру.
- **`<summary>` — только блочный.** Открывающий `<summary>` и закрывающий `</summary>` — **каждый на
своей строке**; запись в одну строку (`/// <summary>текст</summary>`) **не допускается**. **[изм.]**
Binary file not shown.
+20 -32
View File
@@ -7,11 +7,7 @@ using Microsoft.Extensions.DependencyInjection;
namespace Deal.Ai.Tests.Ai;
/// <summary>
/// In-proc gRPC-тесты AiService поверх фейк-провайдера (план Task 8, Acceptance): все 4 RPC
/// (Filter/Classify/GenerateKeywords/EvaluateFit) с подменой LLM-фасада (без сети) через
/// реальный хост (Kestrel HTTP/2, интерцептор service-token). Проверяются: разбор решений и
/// usage в ответах, собранные сервисом промпты, недоступность провайдера → UNAVAILABLE с текстом
/// 1:1 Ruling 5, ответ без JSON в Classify → ok=false (не ошибка), INVALID_ARGUMENT конфига.
/// In-proc gRPC-тесты AiService поверх фейк-провайдера
/// </summary>
public sealed class AiRpcTests
{
@@ -21,7 +17,6 @@ public sealed class AiRpcTests
// Usage API-ответа сценариев (проверка проброса в reply).
private static readonly ProviderUsage SampleUsage = new(11, 5, 16);
// Текст ошибки UNAVAILABLE 1:1 Ruling 5 / ai.py L115117.
private const string UnavailableDetail =
"ИИ (DeepSeek) не ответил корректно — повторите попытку через несколько секунд";
@@ -49,7 +44,6 @@ public sealed class AiRpcTests
Assert.Equal("похоже на заявку", reply.Reason);
AssertUsage(reply.Usage, SampleUsage);
// Сервис передаёт промпт system-сообщением и оборачивает текст как ai.py L193.
FakeProviderCall call = Assert.Single(fake.Calls);
Assert.Equal("Фильтр: {domain}", call.SystemPrompt);
Assert.Equal("Сообщение:\nИщем разработчика на проект", call.UserText);
@@ -77,7 +71,7 @@ public sealed class AiRpcTests
}
/// <summary>
/// Filter: модель не вернула pass — по умолчанию пропуск (1:1 ai.py L195: bool(get(pass, true))).
/// Filter: модель не вернула pass — по умолчанию пропуск
/// </summary>
[Fact]
public async Task Filter_MissingPassField_DefaultsToPass()
@@ -97,8 +91,7 @@ public sealed class AiRpcTests
}
/// <summary>
/// Filter: провайдер недоступен после ретраев (3 попытки) → UNAVAILABLE с текстом 1:1 Ruling 5
/// (ядро трактует как «ИИ недоступен» и пропускает сообщение локальным путём).
/// Filter: провайдер недоступен после ретраев
/// </summary>
[Fact]
public async Task Filter_ProviderUnavailable_ThrowsUnavailableWithDetail()
@@ -120,7 +113,7 @@ public sealed class AiRpcTests
}
/// <summary>
/// Filter: ответ модели без JSON после ретраев — тоже UNAVAILABLE (у метода нет ok-поля).
/// Filter: ответ модели без JSON после ретраев — тоже UNAVAILABLE
/// </summary>
[Fact]
public async Task Filter_AnswerWithoutJson_ThrowsUnavailable()
@@ -141,8 +134,7 @@ public sealed class AiRpcTests
}
/// <summary>
/// Classify: модель вернула JSON — ok=true, json = извлечённый ответ строкой (маппинг в ядре),
/// usage пробрасывается.
/// Classify: модель вернула JSON — ok=true, json = извлечённый ответ строкой
/// </summary>
[Fact]
public async Task Classify_ModelAnsweredJson_ReturnsOkAndJson()
@@ -178,8 +170,7 @@ public sealed class AiRpcTests
}
/// <summary>
/// Classify: модель отвечала, но без разбираемого JSON после ретраев → ok=false, НЕ RPC-ошибка
/// (README ai.proto L201204); usage последней попытки в ответе (оценка по символам).
/// Classify: модель отвечала, но без разбираемого JSON после ретраев → ok=false, НЕ RPC-ошибка; usage последней попытки в ответе
/// </summary>
[Fact]
public async Task Classify_AnswerWithoutJson_ReturnsOkFalseWithUsage()
@@ -211,7 +202,7 @@ public sealed class AiRpcTests
}
/// <summary>
/// Classify: провайдер недоступен — UNAVAILABLE (ядро падает в локальный разбор, aiFail).
/// Classify: провайдер недоступен — UNAVAILABLE
/// </summary>
[Fact]
public async Task Classify_ProviderUnavailable_ThrowsUnavailable()
@@ -232,7 +223,7 @@ public sealed class AiRpcTests
}
/// <summary>
/// GenerateKeywords: ключи из JSON-ответа + фиксированный промпт с описанием задачи.
/// GenerateKeywords
/// </summary>
[Fact]
public async Task GenerateKeywords_ModelReturnedKeywords_ReturnsList()
@@ -253,7 +244,6 @@ public sealed class AiRpcTests
Assert.Equal(["стройка", "ремонт квартир", "подряды"], reply.Keywords);
AssertUsage(reply.Usage, SampleUsage);
// Фиксированный промпт (routes L36–47) и пользовательское сообщение с описанием.
FakeProviderCall call = Assert.Single(fake.Calls);
Assert.Contains("эксперт по поиску Telegram-каналов", call.SystemPrompt, StringComparison.Ordinal);
Assert.Contains("Верни строго JSON", call.SystemPrompt, StringComparison.Ordinal);
@@ -262,7 +252,7 @@ public sealed class AiRpcTests
}
/// <summary>
/// GenerateKeywords: не-строковые элементы списка пропускаются (чистку делает ядро).
/// GenerateKeywords
/// </summary>
[Fact]
public async Task GenerateKeywords_NonStringItems_Skipped()
@@ -281,7 +271,7 @@ public sealed class AiRpcTests
}
/// <summary>
/// GenerateKeywords: модель не вернула ключи — пустой список (не ошибка).
/// GenerateKeywords
/// </summary>
[Fact]
public async Task GenerateKeywords_NoKeywordsField_ReturnsEmpty()
@@ -300,8 +290,7 @@ public sealed class AiRpcTests
}
/// <summary>
/// EvaluateFit: fit=1 + причина; промпт собран сервисом из описания и ключей (discovery_eval
/// L50–54), сообщение — как «Сообщение:\n…».
/// EvaluateFit: fit=1 + причина; промпт собран сервисом из описания и ключей, сообщение — как «Сообщение:\n…».
/// </summary>
[Fact]
public async Task EvaluateFit_ModelFits_ReturnsFitAndReason()
@@ -332,7 +321,7 @@ public sealed class AiRpcTests
}
/// <summary>
/// EvaluateFit: ключи задачи подставляются в промпт строкой через запятую (1:1 _ai_prompt).
/// EvaluateFit: ключи задачи подставляются в промпт строкой через запятую.
/// </summary>
[Fact]
public async Task EvaluateFit_KeywordsJoinedIntoPrompt()
@@ -358,8 +347,7 @@ public sealed class AiRpcTests
}
/// <summary>
/// EvaluateFit: fit=0 без причины модели — причина по умолчанию «не подходит» (1:1 _ai_reason
/// discovery_eval L167171); строковое «нет» трактуется как ложь (1:1 _ai_fit L158164).
/// EvaluateFit: fit=0 без причины модели — причина по умолчанию «не подходит»; строковое «нет» трактуется как ложь.
/// </summary>
[Fact]
public async Task EvaluateFit_ModelNoFit_ReturnsDefaultReason()
@@ -379,7 +367,7 @@ public sealed class AiRpcTests
}
/// <summary>
/// EvaluateFit: fit строкой «нет» — false (паритет _ai_fit), причина из модели.
/// EvaluateFit: fit строкой «нет» — false, причина из модели.
/// </summary>
[Fact]
public async Task EvaluateFit_StringFalsyFit_ReturnsFalse()
@@ -399,7 +387,7 @@ public sealed class AiRpcTests
}
/// <summary>
/// EvaluateFit: длинная причина модели усекается до 200 символов (1:1 _AI_REASON_LIMIT).
/// EvaluateFit: длинная причина модели усекается до 200 символов.
/// </summary>
[Fact]
public async Task EvaluateFit_LongReason_IsTruncatedTo200()
@@ -420,7 +408,7 @@ public sealed class AiRpcTests
}
/// <summary>
/// Конфиг-валидация: запрос без конфига провайдера (пустой base_url) → INVALID_ARGUMENT.
/// Конфиг-валидация
/// </summary>
[Fact]
public async Task Classify_WithoutProviderConfig_IsInvalidArgument()
@@ -441,7 +429,7 @@ public sealed class AiRpcTests
}
/// <summary>
/// Конфиг-валидация: пустая model конфига → INVALID_ARGUMENT (вызов модели невозможен).
/// Конфиг-валидация
/// </summary>
[Fact]
public async Task GenerateKeywords_EmptyModel_IsInvalidArgument()
@@ -466,7 +454,7 @@ public sealed class AiRpcTests
}
/// <summary>
/// Серверный лимит text (ai.proto Filter.text: core обрезает до 4000): превышение → INVALID_ARGUMENT.
/// Серверный лимит text
/// </summary>
[Fact]
public async Task Filter_TooLongText_IsInvalidArgument()
@@ -492,7 +480,7 @@ public sealed class AiRpcTests
}
/// <summary>
/// Серверный лимит description (ai.proto GenerateKeywords.description: core обрезает до 4000).
/// Серверный лимит description
/// </summary>
[Fact]
public async Task GenerateKeywords_TooLongDescription_IsInvalidArgument()
@@ -517,7 +505,7 @@ public sealed class AiRpcTests
}
/// <summary>
/// Обязательный tenant-id в metadata (Ruling 1): отсутствует → UNAUTHENTICATED до вызова.
/// Обязательный tenant-id в metadata
/// </summary>
[Fact]
public async Task Classify_WithoutTenantId_IsUnauthenticated()
@@ -12,7 +12,6 @@ using Microsoft.Extensions.DependencyInjection;
namespace Deal.Ai.Tests.Ai;
// Общий харнесс in-proc gRPC-тестов ai-service (план Task 8): поднимает хост (AiServiceHost.Create)
// в процессе теста на эфемерном порту и через configureServices-хук подменяет LLM-фасад фейком
// (FakeProviderClient, без сети) и функцию паузы ретраев (мгновенная) — сценарии не
// ждут 0.8/2 с между попытками. Регистрация, добавленная харнессом после дефолтных, побеждает
@@ -20,17 +19,17 @@ namespace Deal.Ai.Tests.Ai;
internal static class AiTestHost
{
/// <summary>
/// Env-ключ ожидаемого service-token (зеркало ServiceTokenInterceptor).
/// Env-ключ ожидаемого service-token
/// </summary>
public const string ServiceTokenEnvKey = "DEAL_SERVICE_TOKEN";
/// <summary>
/// Ключ gRPC-metadata с service-token (зеркало ServiceTokenInterceptor).
/// Ключ gRPC-metadata с service-token
/// </summary>
public const string ServiceTokenMetadataKey = ServiceTokenInterceptor.ServiceTokenMetadataKey;
/// <summary>
/// Ключ gRPC-metadata с tenant-id (зеркало AiServiceImpl).
/// Ключ gRPC-metadata с tenant-id
/// </summary>
public const string TenantIdMetadataKey = AiServiceImpl.TenantIdMetadataKey;
@@ -45,7 +44,7 @@ internal static class AiTestHost
public const string DefaultTenantId = "tenant-test";
/// <summary>
/// Deadline RPC-вызовов теста (сек).
/// Deadline RPC-вызовов теста
/// </summary>
public const int RpcDeadlineSeconds = 15;
@@ -100,7 +99,7 @@ internal static class AiTestHost
}
/// <summary>
/// Подменяет HTTP-фасад вызовов модели фейком сценария (последняя регистрация побеждает).
/// Подменяет HTTP-фасад вызовов модели фейком сценария
/// </summary>
/// <param name="services">Коллекция сервисов хоста.</param>
/// <param name="fake">Фейк-провайдер сценария.</param>
@@ -108,14 +107,14 @@ internal static class AiTestHost
=> services.AddSingleton<IProviderClient>(fake);
/// <summary>
/// Делает паузы ретраев мгновенными (иначе сценарии ждали бы 0.8/2 с).
/// Делает паузы ретраев мгновенными
/// </summary>
/// <param name="services">Коллекция сервисов хоста.</param>
public static void DisableRetryDelays(IServiceCollection services)
=> services.AddSingleton<Func<TimeSpan, CancellationToken, Task>>(static (_, _) => Task.CompletedTask);
/// <summary>
/// Строит metadata вызова: service-token (+ tenant-id, если задан).
/// Строит metadata вызова
/// </summary>
/// <param name="serviceToken">Значение заголовка service-token.</param>
/// <param name="tenantId">Id тенанта (null — без заголовка tenant-id).</param>
@@ -136,7 +135,7 @@ internal static class AiTestHost
}
/// <summary>
/// CallOptions RPC: metadata + deadline (рекомендации README src/contracts L6273).
/// CallOptions RPC
/// </summary>
/// <param name="metadata">Metadata вызова.</param>
public static CallOptions CallOptions(Metadata metadata)
@@ -8,7 +8,6 @@ namespace Deal.Ai.Tests.Ai;
// Config: Конфиг провайдера вызова.
internal sealed record FakeProviderCall(string SystemPrompt, string UserText, LlmConfig Config);
// Фейк-провайдер LLM-вызовов (без сети; план Task 8): поведение задаётся сценарием (ответ текстом,
// usage либо сбой), каждый вызов записывается в Calls — тесты проверяют и собранные
// RPC-слоем промпты, и число попыток ретраев.
internal sealed class FakeProviderClient : IProviderClient
@@ -25,7 +24,7 @@ internal sealed class FakeProviderClient : IProviderClient
}
/// <summary>
/// Все вызовы фейка в порядке поступления (для проверок RPC-веток).
/// Все вызовы фейка в порядке поступления
/// </summary>
public List<FakeProviderCall> Calls { get; } = [];
@@ -4,14 +4,12 @@ using Microsoft.Extensions.Logging.Abstractions;
namespace Deal.Ai.Tests.Ai;
/// <summary>
/// Unit-тесты оркестратора вызовов модели (план Task 7; ProviderCaller, 1:1 chat_json ai.py L80117):
/// ретраи 2 с паузами 0.8/2 с, извлечение JSON, различение «провайдер не ответил» и «ответ без
/// JSON», usage. Фейк-провайдер без сети; паузы ретраев мгновенные (харнесс-делегат).
/// Unit-тесты оркестратора вызовов модели
/// </summary>
public sealed class ProviderCallerTests
{
/// <summary>
/// Успех с первого вызова: JSON-объект извлечён, usage API-ответа в результате.
/// Успех с первого вызова
/// </summary>
[Fact]
public async Task ChatJsonAsync_FirstAttemptSuccess_ReturnsJsonAndUsage()
@@ -29,7 +27,7 @@ public sealed class ProviderCallerTests
}
/// <summary>
/// Марdown-обёртка ответа модели снимается фасадом (extract_json L175183).
/// Марdown-обёртка ответа модели снимается фасадом.
/// </summary>
[Fact]
public async Task ChatJsonAsync_MarkdownWrappedJson_Extracts()
@@ -45,7 +43,7 @@ public sealed class ProviderCallerTests
}
/// <summary>
/// Без usage API-ответа фасад оценивает токены по символам (≈chars/4).
/// Без usage API-ответа фасад оценивает токены по символам
/// </summary>
[Fact]
public async Task ChatJsonAsync_WithoutProviderUsage_EstimatesTokens()
@@ -63,7 +61,7 @@ public sealed class ProviderCallerTests
}
/// <summary>
/// Сбой на первых двух попытках и успех на третьей: итог успешен, попыток — 3.
/// Сбой на первых двух попытках и успех на третьей
/// </summary>
[Fact]
public async Task ChatJsonAsync_TwoFailuresThenSuccess_RetriesAndSucceeds()
@@ -86,8 +84,7 @@ public sealed class ProviderCallerTests
}
/// <summary>
/// Все попытки — транспортный сбой: LlmCallException ProviderUnavailable с текстом 1:1 Ruling 5
/// (ai.py L115117); usage отсутствует (модель не отвечала).
/// Все попытки — транспортный сбой
/// </summary>
[Fact]
public async Task ChatJsonAsync_AllTransportFailures_ThrowsProviderUnavailable()
@@ -108,8 +105,7 @@ public sealed class ProviderCallerTests
}
/// <summary>
/// Модель отвечала, но ни одна попытка не дала разбираемый JSON: AnswerNotJson с usage последней
/// попытки (Classify вернёт ok=false; README ai.proto L201204).
/// Модель отвечала, но ни одна попытка не дала разбираемый JSON
/// </summary>
[Fact]
public async Task ChatJsonAsync_AllAnswersNotJson_ThrowsAnswerNotJsonWithUsage()
@@ -128,8 +124,7 @@ public sealed class ProviderCallerTests
}
/// <summary>
/// Отмена (deadline RPC) прерывает вызов без «упаковки» в ошибку провайдера: исключение
/// отмены из попытки не перехватывается как сбой (ретрятся только LlmHttpException).
/// Отмена (deadline RPC) прерывает вызов без «упаковки» в ошибку провайдера
/// </summary>
[Fact]
public async Task ChatJsonAsync_Cancelled_PropagatesCancellation()
@@ -3,14 +3,12 @@ using Deal.Ai.Llm;
namespace Deal.Ai.Tests.Ai;
/// <summary>
/// Unit-тесты оценки токенов (план Task 7; TokenEstimator, Ruling 5): usage API-ответа провайдера
/// проходит как есть (total «берём как есть»), при отсутствии — оценка по символам ≈ceil(chars/4).
/// Без сети.
/// Unit-тесты оценки токенов
/// </summary>
public sealed class TokenEstimatorTests
{
/// <summary>
/// Usage провайдера проходит без изменений, включая total ≠ сумме (как отдал API).
/// Usage провайдера проходит без изменений, включая total ≠ сумме
/// </summary>
[Fact]
public void Resolve_WithProviderUsage_PassesThrough()
@@ -25,7 +23,7 @@ public sealed class TokenEstimatorTests
}
/// <summary>
/// Нет usage провайдера — оценка по символам: prompt из system+user, completion из текста.
/// Нет usage провайдера — оценка по символам
/// </summary>
[Fact]
public void Resolve_WithoutProviderUsage_EstimatesByChars()
@@ -39,7 +37,7 @@ public sealed class TokenEstimatorTests
}
/// <summary>
/// Округление вверх: ровно 4 символа — 1 токен, 5 символов — 2 токена.
/// Округление вверх
/// </summary>
[Fact]
public void Resolve_WithoutProviderUsage_RoundsUp()
@@ -52,7 +50,7 @@ public sealed class TokenEstimatorTests
}
/// <summary>
/// Пустые тексты дают нулевую оценку (не отрицательную).
/// Пустые тексты дают нулевую оценку
/// </summary>
[Fact]
public void Resolve_EmptyTexts_ReturnsZeroTokens()
@@ -9,18 +9,7 @@ using Microsoft.AspNetCore.Builder;
namespace Deal.Ai.Tests.Grpc;
/// <summary>
/// Интеграционные тесты хоста ai-service (каркас Task 4 + логика Task 8).
///
/// Хост поднимается В процессе теста (Kestrel HTTP/2, эфемерный порт) через AiServiceHost.Create —
/// ту же сборку хоста, что использует Program.cs, поэтому тесты покрывают реальную настройку
/// Kestrel/AddGrpc/health, а не её копию. Проверки: gRPC-health → SERVING; ServiceTokenInterceptor
/// (Ruling 1): запрос без токена и с неверным токеном → UNAUTHENTICATED; верный токен проходит к
/// методу (реализация Task 8: пустой запрос без конфига провайдера → INVALID_ARGUMENT); при
/// незаданном DEAL_SERVICE_TOKEN — fail-closed.
///
/// Токен интерцептор читает из конфигурации (env DEAL_SERVICE_TOKEN) — тесты выставляют env на время
/// сценария и восстанавливают исходное значение. Все тесты класса живут в одном процессе/классе,
/// чтобы env и свободные порты не конфликтовали (xunit исполняет методы класса последовательно).
/// Интеграционные тесты хоста ai-service.
/// </summary>
public sealed class AiServiceHostTests
{
@@ -33,15 +22,13 @@ public sealed class AiServiceHostTests
// Токен сценариев теста.
private const string ValidToken = "task4-test-token";
// Id тенанта запросов (доходит до метода при верном токене; Ruling 1).
private const string TenantId = "tenant-test";
// Deadline RPC-вызовов теста (сек).
private const int RpcDeadlineSeconds = 10;
/// <summary>
/// HealthCheck (grpc.health.v1.Health/Check) отвечает SERVING — хост поднялся, gRPC-инфраструктура
/// и health-сервис работают (Ruling 12; health освобождён от service-token).
/// HealthCheck (grpc.health.v1.Health/Check) отвечает SERVING — хост поднялся, gRPC-инфраструктура и health-сервис работают.
/// </summary>
[Fact]
public async Task HealthCheck_ReturnsServing()
@@ -60,7 +47,7 @@ public sealed class AiServiceHostTests
}
/// <summary>
/// Запрос без metadata «service-token» → UNAUTHENTICATED (Ruling 1).
/// Запрос без metadata «service-token» → UNAUTHENTICATED.
/// </summary>
[Fact]
public async Task Classify_WithoutToken_IsUnauthenticated()
@@ -72,7 +59,7 @@ public sealed class AiServiceHostTests
}
/// <summary>
/// Запрос с неверным токеном → UNAUTHENTICATED (Ruling 1).
/// Запрос с неверным токеном → UNAUTHENTICATED.
/// </summary>
[Fact]
public async Task Classify_WithWrongToken_IsUnauthenticated()
@@ -84,9 +71,7 @@ public sealed class AiServiceHostTests
}
/// <summary>
/// Верный токен проходит интерцептор к методу — кодогенерация и маппинг сервиса работают:
/// пустой запрос (без конфига провайдера) доходит до реализации Classify (Task 8) и отклоняется
/// валидацией INVALID_ARGUMENT, а не UNIMPLEMENTED.
/// Верный токен проходит интерцептор к методу — кодогенерация и маппинг сервиса работают
/// </summary>
[Fact]
public async Task Classify_WithValidToken_ReachesServiceAndValidatesConfig()
@@ -98,9 +83,7 @@ public sealed class AiServiceHostTests
}
/// <summary>
/// Fail-closed (шаблон Task 3): DEAL_SERVICE_TOKEN не задан — Deal-RPC отклоняется всегда,
/// в т.ч. запрос с «пустым» значением metadata (без гарда сравнение «» == «» пропустило бы его);
/// health при этом продолжает отвечать SERVING (инфраструктурный liveness не ломается).
/// Fail-closed: DEAL_SERVICE_TOKEN не задан — Deal-RPC отклоняется всегда, в т.ч.
/// </summary>
[Fact]
public async Task WithoutConfiguredToken_DealRpcFailsClosed_HealthStillServing()
@@ -138,7 +121,6 @@ public sealed class AiServiceHostTests
// Вызывает Classify и проверяет, что сервер ответил ожидаемым кодом статуса.
// Запрос несёт полный metadata (service-token + tenant-id), чтобы верный токен доходил
// до реализации метода (заглушки больше нет — Task 8), а не падал на tenant-проверке.
// channel: Канал к хосту ai-service.
// tokenHeader: Значение metadata «service-token» либо null (нет заголовка).
// expected: Ожидаемый StatusCode.
@@ -4,8 +4,7 @@ using Deal.Ai.Llm;
namespace Deal.Ai.Tests.Support;
/// <summary>
/// Unit-тесты извлечения JSON из ответа модели (план Task 7; JsonExtractor, 1:1 extract_json
/// ai.py L175183): чистая строка JSON, markdown-обёртки, проза вокруг, отказы. Без сети.
/// Unit-тесты извлечения JSON из ответа модели
/// </summary>
public sealed class JsonExtractorTests
{
@@ -22,7 +21,7 @@ public sealed class JsonExtractorTests
}
/// <summary>
/// Markdown-обёртка ```json снимается (типичный ответ DeepSeek).
/// Markdown-обёртка ```json снимается
/// </summary>
[Fact]
public void TryExtractObject_JsonFence_Unwraps()
@@ -59,7 +58,7 @@ public sealed class JsonExtractorTests
}
/// <summary>
/// Текст без JSON — null (попытка неудачна, фасад повторяет вызов).
/// Текст без JSON — null
/// </summary>
[Fact]
public void TryExtractObject_NoJson_ReturnsNull()
@@ -78,7 +77,7 @@ public sealed class JsonExtractorTests
}
/// <summary>
/// JSON верхнего уровня не объект (массив/строка) — null (схемы ответов всегда объект).
/// JSON верхнего уровня не объект
/// </summary>
[Fact]
public void TryExtractObject_NonObjectRoot_ReturnsNull()
@@ -5,10 +5,7 @@ using Deal.Ai.Llm;
namespace Deal.Ai.Tests.Support;
/// <summary>
/// HTTP-тесты LlmHttpClient на заглушке HttpMessageHandler (план Task 7 Acceptance; без сети):
/// форма OpenAI-совместимого запроса ({base}/chat/completions, Bearer, temperature 0.2,
/// max_tokens 8000), форма Anthropic ({base}/v1/messages, x-api-key + anthropic-version),
/// разбор ответов/usage, отказ без ключа, reasoning-без-ответа, HTTP-ошибка и таймаут.
/// HTTP-тесты LlmHttpClient на заглушке HttpMessageHandler
/// </summary>
public sealed class LlmHttpClientTests
{
@@ -50,7 +47,7 @@ public sealed class LlmHttpClientTests
""";
/// <summary>
/// OpenAI-совместимый вызов: URL, Bearer, форма тела (model/messages/temperature/max_tokens).
/// OpenAI-совместимый вызов
/// </summary>
[Fact]
public async Task ChatAsync_OpenAiStyle_BuildsWireRequest()
@@ -84,7 +81,7 @@ public sealed class LlmHttpClientTests
}
/// <summary>
/// Локальный OpenAI-совместимый провайдер без ключа: заголовок Authorization не шлётся.
/// Локальный OpenAI-совместимый провайдер без ключа
/// </summary>
[Fact]
public async Task ChatAsync_OpenAiStyleWithoutApiKey_SkipsAuthorization()
@@ -98,7 +95,7 @@ public sealed class LlmHttpClientTests
}
/// <summary>
/// Модель вернула только reasoning без ответа — сбой попытки (ai.py L149151).
/// Модель вернула только reasoning без ответа — сбой попытки.
/// </summary>
[Fact]
public async Task ChatAsync_OpenAiReasoningOnly_Throws()
@@ -115,7 +112,7 @@ public sealed class LlmHttpClientTests
}
/// <summary>
/// Anthropic-вызов: URL /v1/messages, x-api-key + anthropic-version, форма тела Messages API.
/// Anthropic-вызов
/// </summary>
[Fact]
public async Task ChatAsync_AnthropicStyle_BuildsWireRequest()
@@ -145,13 +142,12 @@ public sealed class LlmHttpClientTests
Assert.Equal("user", (string?)messages[0]!["role"]);
Assert.Equal("Сообщение", (string?)messages[0]!["content"]);
// Склейка text-блоков content[] + usage (input/output → total = сумма; ai.py L168172).
Assert.Equal("{\"fit\": 1} ещё текст", result.Text);
Assert.Equal(new ProviderUsage(4, 6, 10), result.Usage);
}
/// <summary>
/// HTTP-ошибка провайдера — сбой попытки с кодом статуса (повод для ретрая).
/// HTTP-ошибка провайдера — сбой попытки с кодом статуса
/// </summary>
[Fact]
public async Task ChatAsync_HttpError_Throws()
@@ -166,7 +162,7 @@ public sealed class LlmHttpClientTests
}
/// <summary>
/// Неожиданная форма ответа (не JSON) — сбой попытки, а не падение.
/// Неожиданная форма ответа
/// </summary>
[Fact]
public async Task ChatAsync_UnexpectedBody_Throws()
@@ -182,7 +178,7 @@ public sealed class LlmHttpClientTests
}
/// <summary>
/// Таймаут попытки (90/60 с в проде; в тесте — 60 мс) → LlmHttpException.
/// Таймаут попытки
/// </summary>
[Fact]
public async Task ChatAsync_Timeout_Throws()
@@ -13,7 +13,6 @@ internal sealed record CapturedHttpRequest(
IReadOnlyDictionary<string, string> Headers,
string? Body);
// Заглушка HttpMessageHandler для HTTP-тестов LlmHttpClient (план Task 7; без сети): записывает
// запросы (URL/заголовки/тело) и отвечает по сценарию; опциональная задержка — для теста таймаута.
internal sealed class StubHttpMessageHandler : HttpMessageHandler
{
@@ -93,14 +92,14 @@ internal sealed class StubHttpMessageHandler : HttpMessageHandler
=> _ => new HttpResponseMessage(statusCode);
/// <summary>
/// Разбирает тело запроса как JSON-объект (для проверок формы).
/// Разбирает тело запроса как JSON-объект
/// </summary>
/// <param name="request">Снимок запроса.</param>
public static JsonObject BodyOf(CapturedHttpRequest request)
=> JsonNode.Parse(request.Body!)!.AsObject();
/// <summary>
/// Снимает заголовок авторизации Bearer (null — заголовка нет).
/// Снимает заголовок авторизации Bearer
/// </summary>
/// <param name="request">Снимок запроса.</param>
public static string? BearerOf(CapturedHttpRequest request)
+4 -31
View File
@@ -6,41 +6,17 @@ using Deal.Grpc.Hosting.Services;
namespace Deal.Ai;
/// <summary>
/// Собирает WebApplication gRPC-хоста ai-service (план Task 4/7/8; Ruling 1/2/5/12).
///
/// Продакшн-точка входа вызывает <see cref="Create"/> из Program.cs (порт из env GRPC_PORT/PORT);
/// интеграционные тесты (Deal.Ai.Tests) — из своего процесса на эфемерном порту, поэтому
/// конфигурация хоста живёт здесь один раз и не дублируется в тестах.
/// Транспорт/AddGrpc/health — общая серверная обвязка <see cref="GrpcServer"/> (Deal.Grpc.Hosting,
/// C31): mTLS (env DEAL_MTLS_*, Ruling 6/Task 13), Kestrel HTTP/2, интерцепторы service-token и
/// access-лога, gRPC-health; здесь — только регистрации логики ai-service.
/// Регистрации логики (план Task 7/8, Ruling 5): LLM-фасад провайдеров — HTTP-клиент
/// (<see cref="Llm.LlmHttpClient"/>, OpenAI-совместимые chat/completions + Anthropic Messages API,
/// таймауты 90/60 с) как <see cref="Llm.IProviderClient"/> и оркестратор вызовов
/// (<see cref="Llm.ProviderCaller"/>: ретраи 2 с паузами 0.8/2 с, извлечение JSON из markdown,
/// usage API или оценка по символам). Сервис без БД и настроек (ядро передаёт заполненные промпты
/// и конфиг провайдера в теле запроса); configureServices-хук — seam для фейков тестов
/// (подмена IProviderClient и функции паузы ретраев).
/// Собирает WebApplication gRPC-хоста ai-service.
/// </summary>
public static class AiServiceHost
{
/// <summary>
/// Создаёт (не запускает) хост: общая обвязка GrpcServer (Kestrel HTTP/2 на 0.0.0.0:grpcPort,
/// dev — plaintext + service-token, Ruling 2; при DEAL_MTLS_ENABLED=1 — HTTPS с серверным
/// сертификатом и требованием клиентского, Ruling 6/Task 13), затем LLM-фасад (Task 7)
/// и маппинг <see cref="AiServiceImpl"/> (Task 8).
/// Создаёт (не запускает) хост
/// </summary>
/// <param name="grpcPort">TCP-порт Kestrel.</param>
/// <param name="args">Аргументы командной строки (Program.cs); в тестах не нужны.</param>
/// <param name="configureServices">
/// Опциональный хук DI для тестов (подмена LLM-фасада фейками: регистрация, добавленная здесь,
/// побеждает — DI резолвит последнюю; см. AiServiceHostTests/AiRpcTests).
/// </param>
/// <param name="configureBuilder">
/// Опциональный хук конфигурации билдера для production-точки входа (Program.cs): Serilog
/// (DealLogging.Configure, Ruling 7/Task 14). Тесты хост поднимают БЕЗ этого хука — логирование
/// файлов/консоли тестам не нужно.
/// </param>
/// <param name="configureServices">Опциональный хук DI для тестов (подмена LLM-фасада фейками: регистрация, добавленная здесь, побеждает — DI резолвит последнюю; см. AiServiceHostTests/AiRpcTests).</param>
/// <param name="configureBuilder">Опциональный хук конфигурации билдера для production-точки входа (Program.cs): Serilog. Тесты хост поднимают БЕЗ этого хука — логирование файлов/консоли тестам не нужно.</param>
/// <returns>Собранный хост; запуск — StartAsync/RunAsync у вызывающего.</returns>
public static WebApplication Create(
int grpcPort,
@@ -52,14 +28,11 @@ public static class AiServiceHost
// Общая серверная обвязка (Deal.Grpc.Hosting, C31): mTLS env DEAL_MTLS_* — загрузка
// сертификатов сразу с fail-fast (compose-prod монтирует deploy/certs, scripts/mtls-certs.sh);
// Kestrel HTTP/2 (dev — plaintext + обязательный service-token, Ruling 2); AddGrpc
// (access-лог первым, затем service-token, потолок сообщения) и gRPC-health (Ruling 12).
MtlsCertificates? mtlsCertificates = GrpcServer.LoadMtlsCertificates(builder);
GrpcServer.ConfigureKestrelHttp2Endpoint(builder, grpcPort, mtlsCertificates);
builder.Services.AddDealGrpcServer();
builder.Services.AddReadyHealthCheck("хост ai-service готов");
// LLM-фасад провайдеров (план Task 7, Ruling 5): HTTP-клиент одной попытки вызова (таймаут
// попытки управляется внутри — 90 с OpenAI / 60 с Anthropic; клиент без общего таймаута) и
// оркестратор ретраев/JSON/usage поверх него. Ключи API — в конфиге запроса, не в DI/логах.
builder.Services.AddHttpClient<Llm.LlmHttpClient>(static httpClient =>
+7 -53
View File
@@ -7,23 +7,12 @@ using Grpc.Core;
namespace Deal.Ai;
/// <summary>
/// Реализация серверной стороны Deal.Grpc.Ai.AiService — команды ядра в ai-service
/// (ai.proto, контракты Task 1; Ruling 1/5).
///
/// Логика (план Task 8, поверх LLM-фасада Task 7): Filter/Classify/GenerateKeywords/EvaluateFit
/// вызывают модель через <see cref="ProviderCaller"/> по конфигу ProviderConfig из тела запроса;
/// сервис без БД, настроек и большинства промптов не знает (ядро передаёт заполненные промпты).
/// Сервисные промпты только там, где прототип держит их фиксированными: генерация ключевых слов
/// (discovery_routes L3647) и оценка fit (discovery_eval L5054). Каждый ответ несёт usage
/// (Ruling 5). Недоступность провайдера после ретраев → UNAVAILABLE с detail
/// «ИИ (имя) не ответил корректно — повторите попытку через несколько секунд» (ядро падает в
/// локальный разбор); ответ модели без разбираемого JSON в Classify — ok=false, не RPC-ошибка
/// (README ai.proto L201204).
/// Реализация серверной стороны Deal.Grpc.Ai.AiService — команды ядра в ai-service.
/// </summary>
public sealed class AiServiceImpl : AiService.AiServiceBase
{
/// <summary>
/// Ключ gRPC-metadata с id тенанта (обязателен на всех RPC — Ruling 1).
/// Ключ gRPC-metadata с id тенанта.
/// </summary>
public const string TenantIdMetadataKey = "tenant-id";
@@ -57,10 +46,8 @@ public sealed class AiServiceImpl : AiService.AiServiceBase
// Деталь отказа: ключ задачи длиннее лимита (INVALID_ARGUMENT).
private const string KeywordTooLongDetail = "Слишком длинный ключ задачи";
// Потолок длины текста сообщения (1:1: core обрезает до 4000 — ai.proto Filter.text/EvaluateFit.text).
private const int MaxTextLength = 4000;
// Потолок длины описания ниши/задачи (1:1: core обрезает до 4000 — ai.proto GenerateKeywords.description).
private const int MaxDescriptionLength = 4000;
// Защитный потолок длины промпта (Filter.prompt/Classify.system_prompt; лимит не декларирован).
@@ -69,20 +56,14 @@ public sealed class AiServiceImpl : AiService.AiServiceBase
// Защитный потолок длины user-контекста Classify (лимит не декларирован).
private const int MaxUserContextLength = 20000;
// Потолок числа ключей задачи EvaluateFit (после _clean_keywords ядро шлёт ≤30 — запас на рост).
private const int MaxKeywordsCount = 200;
// Потолок длины одного ключа задачи EvaluateFit (_clean_keywords: ≤60 симв. — запас на рост).
private const int MaxKeywordLength = 200;
// Префикс пользовательского сообщения фильтра (1:1 ai.py filter_incoming L193).
private const string FilterUserPrefix = "Сообщение:\n";
// Префикс пользовательского сообщения генератора ключей (1:1 discovery_routes L206).
private const string KeywordsUserPrefix = "Описание ниши/задачи:\n";
// Фиксированный системный промпт генерации ключевых слов (1:1 _KEYWORDS_PROMPT
// discovery_routes L3647; пользовательское сообщение — описание задачи).
private const string GenerateKeywordsSystemPrompt =
"Ты — эксперт по поиску Telegram-каналов и групп. По описанию ниши/задачи "
+ "составь поисковые ключевые слова, по которым в глобальном поиске Telegram "
@@ -95,35 +76,26 @@ public sealed class AiServiceImpl : AiService.AiServiceBase
+ "- конкретные для ниши, включая сленг заказчиков и подрядчиков;\n"
+ "- без дублей и близких по смыслу повторов.";
// Шаблон системного промпта оценки fit (1:1 _AI_PROMPT discovery_eval L5054): подставляются
// описание и ключи задачи (строкой через запятую, как _ai_prompt L153155).
private const string EvaluateFitSystemPromptTemplate =
"Оцени, относится ли сообщение к сфере/задаче. Описание: {0}. Ключи: {1}. "
+ "Верни JSON {{\"fit\": 0|1, \"reason\": \"краткая причина\"}}.";
// Потолок длины причины решения модели (1:1 _AI_REASON_LIMIT discovery_eval L43).
private const int MaxEvalReasonLength = 200;
// Причина по умолчанию при fit=true, если модель причину не дала (1:1 _ai_reason L170).
private const string FitReasonDefault = "подходит";
// Причина по умолчанию при fit=false, если модель причину не дала (1:1 _ai_reason L170).
private const string NotFitReasonDefault = "не подходит";
// Имя поля решения фильтра в JSON-ответе модели (1:1 ai.py L195).
private const string PassFieldName = "pass";
// Имя поля причины в JSON-ответе модели.
private const string ReasonFieldName = "reason";
// Имя поля решения fit в JSON-ответе модели (1:1 discovery_eval _ai_fit).
private const string FitFieldName = "fit";
// Имя поля списка ключевых слов в JSON-ответе модели.
private const string KeywordsFieldName = "keywords";
// Значения, которые строковый ответ модели трактует как «ложь» (bool() в python: fit/filter —
// 1:1 _ai_fit discovery_eval L158164; для фильтра отсутствие поля = true, 1:1 ai.py L195).
private static readonly IReadOnlySet<string> FalsyAnswerValues = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"0",
@@ -141,7 +113,7 @@ public sealed class AiServiceImpl : AiService.AiServiceBase
/// Создаёт gRPC-сервис команд ядра поверх LLM-фасада.
/// </summary>
/// <param name="caller">Оркестратор вызовов модели (ретраи + извлечение JSON + usage).</param>
/// <param name="logger">Логгер аудита (Ruling 13; ключи API не логируются).</param>
/// <param name="logger">Логгер аудита.</param>
public AiServiceImpl(ProviderCaller caller, ILogger<AiServiceImpl> logger)
{
_caller = caller;
@@ -149,10 +121,7 @@ public sealed class AiServiceImpl : AiService.AiServiceBase
}
/// <summary>
/// Filter — ИИ-фильтр входящих сообщений (ai.py filter_incoming L188198): решение {pass, reason}
/// по заполненному ядром aiFilterPrompt (system) и тексту. Ветку «фильтр не применялся»
/// (aiFilterEnabled/недоступность) ядро обрабатывает до вызова; недоступность провайдера —
/// UNAVAILABLE (ядро пропускает сообщение).
/// Filter — ИИ-фильтр входящих сообщений
/// </summary>
public override async Task<FilterReply> Filter(FilterRequest request, ServerCallContext context)
{
@@ -190,11 +159,7 @@ public sealed class AiServiceImpl : AiService.AiServiceBase
}
/// <summary>
/// Classify — полный разбор лида (ai.py classify L218258): ответ {ok, json}, где json — строка
/// с извлечённым ответом модели (типовую схему задаёт промпт); строгий маппинг json → карточку
/// делает ядро (1:1 normalize_stack/clean_budget/build_contacts). Модель отвечала без
/// разбираемого JSON после ретраев → ok=false (не RPC-ошибка; ядро падает в локальный разбор);
/// провайдер недоступен → UNAVAILABLE.
/// Classify — полный разбор лида
/// </summary>
public override async Task<ClassifyReply> Classify(ClassifyRequest request, ServerCallContext context)
{
@@ -235,9 +200,7 @@ public sealed class AiServiceImpl : AiService.AiServiceBase
}
/// <summary>
/// GenerateKeywords — ключевые слова discovery-задачи по описанию (фикс. промпт discovery_routes
/// L36–47 + описание): ответ {keywords}. Очистку (_clean_keywords: ≤30, ≤60 симв., дедуп) и мягкую
/// ошибку для UI делает ядро (Ruling 11); недоступность провайдера — UNAVAILABLE.
/// GenerateKeywords — ключевые слова discovery-задачи по описанию
/// </summary>
public override async Task<GenerateKeywordsReply> GenerateKeywords(GenerateKeywordsRequest request, ServerCallContext context)
{
@@ -272,9 +235,7 @@ public sealed class AiServiceImpl : AiService.AiServiceBase
}
/// <summary>
/// EvaluateFit — оценка соответствия сообщения задаче поиска (промпт discovery_eval L5054;
/// текст + описание + ключи задачи): ответ {fit, reason}. Ядро зовёт только при aiEnabled;
/// сбой — фолбэк на эвристику (Ruling 10).
/// EvaluateFit — оценка соответствия сообщения задаче поиска
/// </summary>
public override async Task<EvaluateFitReply> EvaluateFit(EvaluateFitRequest request, ServerCallContext context)
{
@@ -314,7 +275,6 @@ public sealed class AiServiceImpl : AiService.AiServiceBase
}
}
// Читает tenant-id из metadata (обязателен; отсутствие — UNAUTHENTICATED, Ruling 1).
// context: Контекст вызова.
private static string RequireTenantId(ServerCallContext context)
{
@@ -389,7 +349,6 @@ public sealed class AiServiceImpl : AiService.AiServiceBase
providerConfig.ApiStyle);
}
// Пишет предупреждение аудита о недоступности ИИ (Ruling 13; без ключей и текстов).
// method: Имя RPC для аудита.
// tenantId: Id тенанта.
// config: Конфиг провайдера вызова.
@@ -406,14 +365,11 @@ public sealed class AiServiceImpl : AiService.AiServiceBase
config.DisplayName,
callError.Kind);
// Превращает ошибку фасада в RPC-ошибку UNAVAILABLE с detail 1:1 Ruling 5.
// callError: Итоговая ошибка фасада.
private static RpcException ToUnavailable(LlmCallException callError)
=> new(new Status(StatusCode.Unavailable, callError.Message));
// Читает булево поле JSON-ответа модели: bool как есть; строка — ложь только для значений из
// FalsyAnswerValues (1:1 _ai_fit discovery_eval L158164); число — ненулевое = true;
// поля нет/не разбирается — defaultValue (фильтр: true, ai.py L195; fit: false, discovery_eval).
// json: Корневой объект ответа модели.
// fieldName: Имя поля.
// defaultValue: Значение при отсутствии/неразбираемости поля.
@@ -461,7 +417,6 @@ public sealed class AiServiceImpl : AiService.AiServiceBase
}
// Причина решения fit: поле reason модели, при отсутствии — «подходит»/«не подходит»
// (1:1 _ai_reason discovery_eval L167171), потолок длины MaxEvalReasonLength.
// json: Корневой объект ответа модели.
// fit: Решение модели.
private static string ReadFitReason(JsonObject json, bool fit)
@@ -492,7 +447,6 @@ public sealed class AiServiceImpl : AiService.AiServiceBase
}
}
// Склеивает ключи задачи для промпта оценки (1:1 _ai_prompt discovery_eval L153155).
// keywords: Ключи задачи.
private static string JoinKeywords(IEnumerable<string> keywords)
=> string.Join(
@@ -1,10 +1,7 @@
namespace Deal.Ai.Llm;
/// <summary>
/// Абстракция одного HTTP-вызова LLM-провайдера (план Task 7; seam для фейков тестов RPC-веток).
/// Реализация по конфигу выбирает схему вызова: OpenAI-совместимые <c>POST {base}/chat/completions</c>
/// (Bearer) либо Anthropic <c>POST {base}/v1/messages</c> (x-api-key + anthropic-version). Сетевые
/// сбои/неожиданные ответы — <see cref="LlmHttpException"/> (одна попытка; ретраи — <see cref="ProviderCaller"/>).
/// Абстракция одного HTTP-вызова LLM-провайдера.
/// </summary>
public interface IProviderClient
{
@@ -14,7 +11,6 @@ public interface IProviderClient
/// <param name="config">Конфиг активного провайдера (стиль API выбирается по <c>ApiStyle</c>).</param>
/// <param name="systemPrompt">Системный промпт (заполненный ядром либо фиксированный сервиса).</param>
/// <param name="userText">Пользовательское сообщение/контекст.</param>
/// <param name="cancellationToken">Токен отмены (deadline RPC).</param>
/// <returns>Текст ответа модели и usage API-ответа (null — провайдер usage не вернул).</returns>
public Task<ProviderChatResult> ChatAsync(
LlmConfig config,
+1 -4
View File
@@ -4,9 +4,7 @@ using System.Text.Json.Nodes;
namespace Deal.Ai.Llm;
/// <summary>
/// Извлечение JSON-объекта из ответа модели (план Task 7; 1:1 extract_json ai.py L175183):
/// снимается markdown-обёртка ```json … ```, затем берётся срез между первой «{» и последней «}»,
/// результат парсится как объект. Любая аномалия — null (попытка считается неудачной и повторяется).
/// Извлечение JSON-объекта из ответа модели
/// </summary>
public static class JsonExtractor
{
@@ -48,7 +46,6 @@ public static class JsonExtractor
}
}
// Снимает markdown-обёртку ```json … ``` (как в extract_json L177179): возвращает содержимое
// между открывающей и закрывающей обёртками; обёртки нет/незакрыта — null.
// raw: Текст ответа (уже обрезанный).
private static string? UnwrapFence(string raw)
@@ -1,9 +1,7 @@
namespace Deal.Ai.Llm;
/// <summary>
/// Итоговая ошибка вызова после исчерпания ретраев (план Task 7: «ошибки → исключение с кодом»).
/// Текст сообщения — 1:1 Ruling 5 / ai.py L115117: «ИИ (имя) не ответил корректно — повторите
/// попытку через несколько секунд»; RPC-слой отдаёт его как detail статуса UNAVAILABLE.
/// Итоговая ошибка вызова после исчерпания ретраев.
/// </summary>
public sealed class LlmCallException : Exception
{
@@ -32,8 +30,7 @@ public sealed class LlmCallException : Exception
public LlmCallFailureKind Kind { get; }
/// <summary>
/// Usage последней попытки, вернувшей текст модели: заполнен при <see cref="LlmCallFailureKind.AnswerNotJson"/>
/// (Classify отвечает ok=false и всё равно несёт usage; Ruling 5).
/// Usage последней попытки, вернувшей текст модели
/// </summary>
public LlmUsage? Usage { get; }
}
@@ -1,19 +1,17 @@
namespace Deal.Ai.Llm;
/// <summary>
/// Причина исчерпания попыток вызова (Ruling 5 / README ai.proto L201204): провайдер не ответил
/// корректно (UNAVAILABLE) либо модель отвечала, но ни один ответ не разобран как JSON
/// (Classify — ok=false, не RPC-ошибка).
/// Причина исчерпания попыток вызова
/// </summary>
public enum LlmCallFailureKind
{
/// <summary>
/// Провайдер не ответил после ретраев (сеть/таймаут/HTTP/пустой ответ) → UNAVAILABLE.
/// Провайдер не ответил после ретраев
/// </summary>
ProviderUnavailable,
/// <summary>
/// Модель отвечала текстом, но JSON не извлечён после ретраев (Classify → ok=false).
/// Модель отвечала текстом, но JSON не извлечён после ретраев
/// </summary>
AnswerNotJson,
}
+2 -3
View File
@@ -3,15 +3,14 @@ using System.Text.Json.Nodes;
namespace Deal.Ai.Llm;
/// <summary>
/// Успешный результат вызова модели: извлечённый JSON-объект (схему задаёт промпт) и итоговая
/// оценка токенов (usage API или оценка по символам).
/// Успешный результат вызова модели
/// </summary>
/// <param name="Json">Корневой объект JSON-ответа модели.</param>
/// <param name="Usage">Итоговая оценка токенов вызова.</param>
public sealed record LlmCallResult(JsonObject Json, LlmUsage Usage)
{
/// <summary>
/// Извлечённый ответ модели компактной json-строкой (ClassifyReply.json — маппинг в ядре).
/// Извлечённый ответ модели компактной json-строкой
/// </summary>
public string JsonText => Json.ToJsonString();
}
+4 -9
View File
@@ -1,10 +1,7 @@
namespace Deal.Ai.Llm;
/// <summary>
/// Эффективный конфиг LLM-провайдера на один вызов (план Task 7, Ruling 5): зеркало
/// <c>ProviderConfig</c> из ai.proto. Ядро передаёт заполненный конфиг в теле каждого запроса
/// (base_url/model/api_key расшифрованы, api_style — из каталога AiProviders); сервис настроек
/// тенанта не хранит и не знает.
/// Эффективный конфиг LLM-провайдера на один вызов
/// </summary>
public sealed record LlmConfig(
string ProviderId,
@@ -14,12 +11,10 @@ public sealed record LlmConfig(
string? ApiStyle)
{
/// <summary>
/// Значение api_style для Anthropic Messages API (пусто/иное — OpenAI-совместимый).
/// Значение api_style для Anthropic Messages API
/// </summary>
public const string AnthropicApiStyle = "anthropic";
// Отображаемые имена известных провайдеров (1:1 каталог AiProviders констант python) — для
// текста ошибки «ИИ (имя) …» (Ruling 5, ai.py L115117). Неизвестный id — как есть.
private static readonly IReadOnlyDictionary<string, string> KnownProviderNames =
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
@@ -33,12 +28,12 @@ public sealed record LlmConfig(
};
/// <summary>
/// Истинно, когда конфиг задаёт Anthropic Messages API (x-api-key + anthropic-version).
/// Истинно, когда конфиг задаёт Anthropic Messages API
/// </summary>
public bool IsAnthropic => string.Equals(ApiStyle, AnthropicApiStyle, StringComparison.OrdinalIgnoreCase);
/// <summary>
/// Имя провайдера для сообщений об ошибках и логов (ключ API в него не входит).
/// Имя провайдера для сообщений об ошибках и логов
/// </summary>
public string DisplayName => KnownProviderNames.TryGetValue(ProviderId, out string? name)
? name
+3 -21
View File
@@ -5,12 +5,7 @@ using System.Text.Json.Nodes;
namespace Deal.Ai.Llm;
/// <summary>
/// HTTP-реализация <see cref="IProviderClient"/> (план Task 7; 1:1 ai.py _call_openai/_call_anthropic
/// L126172): по api_style конфига выбирается схема вызова — OpenAI-совместимые
/// <c>POST {base}/chat/completions</c> (Bearer; temperature 0.2; max_tokens 8000) либо Anthropic
/// <c>POST {base}/v1/messages</c> (x-api-key + anthropic-version). Таймауты 90 с (OpenAI) / 60 с
/// (Anthropic) на попытку; usage берётся из API-ответа (null — оценит TokenEstimator).
/// Ключи API в логи и исключения не попадают (Ruling 13).
/// HTTP-реализация <see cref="IProviderClient"/>
/// </summary>
public sealed class LlmHttpClient : IProviderClient
{
@@ -23,22 +18,17 @@ public sealed class LlmHttpClient : IProviderClient
// Заголовок версии Anthropic API.
private const string AnthropicVersionHeader = "anthropic-version";
// Значение версии Anthropic API (фиксированное, как в python).
private const string AnthropicVersionValue = "2023-06-01";
// Заголовок ключа Anthropic API.
private const string AnthropicApiKeyHeader = "x-api-key";
// Температура вызовов OpenAI-совместимых API (Ruling 5; как в ai.py L137).
private const double Temperature = 0.2;
// Потолок токенов ответа (max_tokens; как в python для обоих стилей).
private const int MaxResponseTokens = 8000;
// Таймаут одной попытки OpenAI-совместимого вызова (Ruling 5; 90 с).
private static readonly TimeSpan OpenAiCallTimeout = TimeSpan.FromSeconds(90);
// Таймаут одной попытки Anthropic-вызова (Ruling 5; 60 с).
private static readonly TimeSpan AnthropicCallTimeout = TimeSpan.FromSeconds(60);
private readonly HttpClient _httpClient;
@@ -46,7 +36,7 @@ public sealed class LlmHttpClient : IProviderClient
private readonly TimeSpan _anthropicCallTimeout;
/// <summary>
/// Создаёт HTTP-клиент провайдеров с типовыми таймаутами (90/60 с).
/// Создаёт HTTP-клиент провайдеров с типовыми таймаутами
/// </summary>
/// <param name="httpClient">HttpClient (регистрируется в DI; таймаут управляется на попытку).</param>
public LlmHttpClient(HttpClient httpClient)
@@ -69,12 +59,11 @@ public sealed class LlmHttpClient : IProviderClient
}
/// <summary>
/// Выполняет один вызов модели по выбранной схеме API (Ruling 5).
/// Выполняет один вызов модели по выбранной схеме API.
/// </summary>
/// <param name="config">Конфиг провайдера (стиль — <c>ApiStyle</c>).</param>
/// <param name="systemPrompt">Системный промпт.</param>
/// <param name="userText">Пользовательское сообщение/контекст.</param>
/// <param name="cancellationToken">Токен отмены (deadline RPC).</param>
/// <returns>Текст ответа и usage API-ответа (null при его отсутствии).</returns>
public async Task<ProviderChatResult> ChatAsync(
LlmConfig config,
@@ -109,7 +98,6 @@ public sealed class LlmHttpClient : IProviderClient
}
// Собирает запрос OpenAI-совместимого чата: {base}/chat/completions, Bearer при заданном ключе,
// тело 1:1 ai.py L126139 (messages system/user, temperature 0.2, max_tokens 8000).
// config: Конфиг провайдера.
// systemPrompt: Системный промпт.
// userText: Пользовательское сообщение.
@@ -139,7 +127,6 @@ public sealed class LlmHttpClient : IProviderClient
}
// Собирает запрос Anthropic Messages API: {base}/v1/messages, x-api-key + anthropic-version,
// тело 1:1 ai.py L155167 (system отдельным полем, messages=[user]).
// config: Конфиг провайдера.
// systemPrompt: Системный промпт.
// userText: Пользовательское сообщение.
@@ -184,7 +171,6 @@ public sealed class LlmHttpClient : IProviderClient
return isAnthropic ? ReadAnthropicBody(body) : ReadOpenAiBody(body);
}
// Разбирает OpenAI-совместимый ответ: choices[0].message.content (+ usage; ai.py L143152).
// body: Тело ответа.
private static ProviderChatResult ReadOpenAiBody(string body)
{
@@ -201,14 +187,12 @@ public sealed class LlmHttpClient : IProviderClient
string? content = ReadStringField(message, "content");
if (string.IsNullOrEmpty(content) && !string.IsNullOrEmpty(ReadStringField(message, "reasoning_content")))
{
// Модель «подумала», но ответа не дала (переполнение/обрыв) — сбой, пробуем ещё раз (ai.py L149151).
throw new LlmHttpException("Модель вернула только reasoning без ответа");
}
return new ProviderChatResult(content ?? string.Empty, ReadOpenAiUsage(payload["usage"]));
}
// Разбирает Anthropic-ответ: склейка text блоков content[] (+ usage input/output; ai.py L168172).
// body: Тело ответа.
private static ProviderChatResult ReadAnthropicBody(string body)
{
@@ -281,7 +265,6 @@ public sealed class LlmHttpClient : IProviderClient
throw UnexpectedApiResponse("тело не является JSON-объектом");
}
// Собирает текст ошибки неожиданного ответа: только тип/причина, без содержимого тела (Ruling 13).
// failureKind: Короткая причина (без тела ответа и секретов).
private static LlmHttpException UnexpectedApiResponse(string failureKind)
=> new($"Неожиданный ответ ИИ-провайдера: {failureKind}");
@@ -327,7 +310,6 @@ public sealed class LlmHttpClient : IProviderClient
return result;
}
// Убирает хвостовые «/» базового URL (как ai.py L90: rstrip("/")).
// baseUrl: Базовый URL из конфига.
private static string NormalizeBaseUrl(string baseUrl) => baseUrl.TrimEnd('/');
}
@@ -1,9 +1,7 @@
namespace Deal.Ai.Llm;
/// <summary>
/// Ошибка одной HTTP-попытки вызова провайдера (план Task 7): сетевой сбой, таймаут, HTTP-ошибка
/// или неожиданная форма ответа API. Обрабатывается в <see cref="ProviderCaller"/> как повод для
/// ретрая; текст внутренний (ключи/секреты и тело ответа в него не попадают — Ruling 13).
/// Ошибка одной HTTP-попытки вызова провайдера
/// </summary>
public sealed class LlmHttpException : Exception
{
+3 -5
View File
@@ -1,14 +1,12 @@
namespace Deal.Ai.Llm;
/// <summary>
/// Политика ретраев вызова LLM (план Task 7; ai.py chat_json L96117): max_retries=2 → всего 3
/// попытки с нарастающими паузами 0.8 с и 2 с между ними. Разовый сбой (перегрузка API, пустой/
/// не-JSON ответ) не должен превращаться в «ИИ недоступен» без повторных попыток.
/// Политика ретраев вызова LLM
/// </summary>
public static class LlmRetryPolicy
{
/// <summary>
/// Число дополнительных попыток после первой (всего — <see cref="AttemptCount"/>).
/// Число дополнительных попыток после первой
/// </summary>
public const int RetryCount = 2;
@@ -18,7 +16,7 @@ public static class LlmRetryPolicy
public const int AttemptCount = RetryCount + 1;
/// <summary>
/// Паузы между попытками: 0.8 с (после 1-й) и 2 с (после 2-й).
/// Паузы между попытками
/// </summary>
public static readonly IReadOnlyList<TimeSpan> RetryDelays =
[
+1 -3
View File
@@ -1,9 +1,7 @@
namespace Deal.Ai.Llm;
/// <summary>
/// Итоговая оценка токенов вызова для gRPC-ответа (Ruling 5): берётся из usage API-ответа
/// провайдера, при его отсутствии оценивается по символам (≈chars/4). Ядро копит значения
/// в tenant-KV aiTokenUsage.
/// Итоговая оценка токенов вызова для gRPC-ответа
/// </summary>
/// <param name="PromptTokens">Токены запроса (система + пользователь).</param>
/// <param name="CompletionTokens">Токены ответа модели.</param>
+1 -8
View File
@@ -3,12 +3,7 @@ using System.Text.Json.Nodes;
namespace Deal.Ai.Llm;
/// <summary>
/// Оркестратор вызова модели с ретраями и извлечением JSON (план Task 7; 1:1 chat_json ai.py
/// L80117): до <see cref="LlmRetryPolicy.AttemptCount"/> попыток с паузами 0.8/2 с; каждая попытка —
/// HTTP-вызов (<see cref="IProviderClient"/>) + извлечение JSON (<see cref="JsonExtractor"/>). После
/// исчерпания попыток — <see cref="LlmCallException"/>: «провайдер не ответил» (Kind=ProviderUnavailable)
/// либо «модель отвечала, но не JSON» (Kind=AnswerNotJson — Classify отвечает ok=false, Ruling 5).
/// Usage итога — из usage API-ответа последней попытки или оценка по символам (TokenEstimator).
/// Оркестратор вызова модели с ретраями и извлечением JSON
/// </summary>
public sealed class ProviderCaller
{
@@ -38,7 +33,6 @@ public sealed class ProviderCaller
/// <param name="config">Конфиг активного провайдера.</param>
/// <param name="systemPrompt">Системный промпт (заполненный ядром или фиксированный сервиса).</param>
/// <param name="userText">Пользовательское сообщение/контекст.</param>
/// <param name="cancellationToken">Токен отмены (deadline RPC).</param>
/// <returns>Извлечённый JSON-объект и итоговую оценку токенов.</returns>
/// <exception cref="LlmCallException">Все попытки исчерпаны (см. <see cref="LlmCallFailureKind"/>).</exception>
public async Task<LlmCallResult> ChatJsonAsync(
@@ -82,7 +76,6 @@ public sealed class ProviderCaller
if (lastModelText is not null)
{
// Модель отвечала текстом, но ни одна попытка не дала разбираемый JSON (README ai.proto L201204).
LlmUsage usage = TokenEstimator.Resolve(lastUsage, promptText, lastModelText);
throw new LlmCallException(LlmCallFailureKind.AnswerNotJson, config.DisplayName, usage);
}
@@ -1,7 +1,7 @@
namespace Deal.Ai.Llm;
/// <summary>
/// Результат одной успешной HTTP-попытки вызова модели (текст ответа + usage API).
/// Результат одной успешной HTTP-попытки вызова модели
/// </summary>
/// <param name="Text">Текст ответа модели (может быть не-JSON — разбор в <see cref="JsonExtractor"/>).</param>
/// <param name="Usage">Usage из API-ответа провайдера; null — провайдер его не вернул (оценка по символам).</param>
+1 -3
View File
@@ -1,9 +1,7 @@
namespace Deal.Ai.Llm;
/// <summary>
/// Usage токенов из API-ответа провайдера (Ruling 5). Поля не путать с <see cref="LlmUsage"/>:
/// здесь «как отдал провайдер» (total берём как есть — у провайдера он может отличаться от суммы),
/// финальную оценку/подстановку делает <see cref="TokenEstimator"/>.
/// Usage токенов из API-ответа провайдера.
/// </summary>
/// <param name="PromptTokens">Токены запроса (система + пользователь).</param>
/// <param name="CompletionTokens">Токены ответа модели.</param>
+2 -5
View File
@@ -1,17 +1,14 @@
namespace Deal.Ai.Llm;
/// <summary>
/// Оценка токенов вызова (план Task 7, Ruling 5): при отсутствии usage в API-ответе токены
/// оцениваются по символам ≈ chars/4 (округление вверх). Запрос = system + user, ответ = текст модели.
/// Оценка токенов вызова
/// </summary>
public static class TokenEstimator
{
// Примерное число символов на один токен (Ruling 5: «≈chars/4»).
private const int EstimatedCharsPerToken = 4;
/// <summary>
/// Сводит usage вызова: usage провайдера как есть (total «берём как есть»), при отсутствии —
/// оценка по длинам промпта и ответа.
/// Сводит usage вызова
/// </summary>
/// <param name="providerUsage">Usage из API-ответа (null — провайдер его не вернул).</param>
/// <param name="promptText">Полный текст запроса (система + пользователь) для оценки.</param>
-10
View File
@@ -1,14 +1,10 @@
// ai-service — точка входа gRPC-хоста (план Task 4/7/8; Ruling 1/2/5/12).
//
// Kestrel HTTP/2 на порту 5102 (env GRPC_PORT, затем PORT) + AddGrpc с интерцепторами service-token
// и access-лога + стандартный gRPC-health (grpc.health.v1.Health). Транспорт: dev — plaintext
// (Ruling 2); mTLS (TLS + клиентский сертификат) — при DEAL_MTLS_ENABLED=1 (Ruling 6, план Task 13;
// сертификаты deploy/certs — scripts/mtls-certs.sh, env передаёт compose-prod Task 14); fail-closed:
// Production без mTLS не стартует (GrpcHostEnvironment.RequireMtlsInProduction).
// Серверная обвязка (Kestrel/AddGrpc/health) — общий Deal.Grpc.Hosting (C31): хост-фабрика
// AiServiceHost.Create используется и интеграционными тестами (Deal.Ai.Tests), которые поднимают
// его в своём процессе на эфемерном порту. Методы AiService (Filter/Classify/GenerateKeywords/
// EvaluateFit) реализованы поверх LLM-фасада (OpenAI-совместимые + Anthropic, без БД — Ruling 5;
// задачи 7–8): фасад живёт в Deal.Ai/Llm.
using Deal.Ai;
@@ -17,17 +13,13 @@ using Deal.Grpc.Hosting.Models;
using Deal.Grpc.Hosting.Options;
using Deal.Grpc.Hosting.Services;
// Порт по умолчанию — 5102 (Ruling 12, compose.dev.yml); переопределяется env GRPC_PORT (контейнер)
// или PORT (общий конвенциональный env хостинг-платформ) — см. GrpcHostEnvironment.ResolveGrpcPort.
const int defaultGrpcPort = 5102;
// Имя процесса для rolling-файла логов (Ruling 7, Task 14): data/logs/deal-ai-<дата>.json.
const string aiProcessName = "ai";
int grpcPort = GrpcHostEnvironment.ResolveGrpcPort(defaultGrpcPort);
// Порт эндпоинта метрик /metrics (HTTP/1.1, отдельно от gRPC HTTP/2; этап 12, пакет A).
int metricsPort = DealMetricsHosting.ResolveMetricsPort(DealMetricsHosting.DefaultMetricsPort);
// Serilog (Ruling 7, план Task 14): JSON-консоль + rolling-файл data/logs/deal-ai-*.json —
// конфигурируется production-точкой входа через configureBuilder-хук хоста (тесты хост поднимают
// без Serilog, DealLogging.Configure в AiServiceHost/Create вызывается только здесь). Метрики
// (OTel → Prometheus, /metrics) — тем же хуком до builder.Build().
@@ -39,10 +31,8 @@ WebApplication app = AiServiceHost.Create(
DealMetricsHosting.AddDealMetrics(builder, metricsPort);
});
// Эндпоинт метрик /metrics (HTTP/1.1 на отдельном порту): формат Prometheus (этап 12, пакет A).
DealMetricsHosting.MapDealMetrics(app);
// Режим транспорта — из тех же env, что читал хост (Ruling 6, Task 13): mTLS при DEAL_MTLS_ENABLED=1.
MtlsOptions mtlsOptions = MtlsOptions.FromConfiguration(app.Configuration);
// Fail-closed (замечание code-review): отсутствие/опечатка DEAL_MTLS_ENABLED не должны давать
+138 -172
View File
@@ -1,172 +1,138 @@
// ai.proto — контракт между ядром Deal и ai-service (этап 6).
//
// ai-service — фасад LLM-провайдеров без БД (Ruling 5, дизайн-док §7.3):
// ядро передаёт в теле каждого запроса готовые (заполненные) промпты и/или
// текст + конфиг активного провайдера (ProviderConfig); сервис вызывает
// провайдера (OpenAI-совместимые POST {base}/chat/completions, Anthropic
// POST {base}/v1/messages; temperature 0.2, таймауты 90/60 с, retry 2 с
// паузами 0.8/2 с) и возвращает ответ + оценку токенов. Конфиг провайдера
// (id/base/model/apiKey/api_style) ядро кладёт в поле provider_config каждого
// запроса — сервис настроек тенанта не знает и не хранит.
//
// Каждый RPC обязан нести в gRPC-metadata два заголовка (Ruling 1):
// tenant-id — id тенанта (строка; учёт токенов в ядре по нему);
// service-token — общий токен сервисов (env DEAL_SERVICE_TOKEN); неверный/
// пустой → UNAUTHENTICATED.
//
// Ошибки домена — gRPC-статусы (Ruling 1/5):
// INVALID_ARGUMENT — невалидный запрос (пустой текст/промпт и т.п.);
// UNAVAILABLE — провайдер не ответил корректно после ретраев; detail =
// «ИИ (имя) не ответил корректно — повторите попытку через
// несколько секунд» (ядро падает в локальный разбор).
//
// Учёт токенов (Ruling 5): каждый reply несёт usage{prompt/completion/total}.
// Берётся из usage API-ответа провайдера; при отсутствии оценивается по
// символам (≈chars/4). Ядро копит значения в tenant-KV aiTokenUsage.
//
// Deadlines (клиент ядра): все RPC — 120 с (90 с провайдер + ретраи 0.8/2 с;
// при недоступности ядро не ждёт повторно — Ruling 6 кэш/фолбэк).
syntax = "proto3";
package deal.ai.v1;
option csharp_namespace = "Deal.Grpc.Ai";
service AiService {
// ИИ-фильтр входящих сообщений (ai.py filter_incoming L188198, Ruling 5):
// ядро шлёт заполненный aiFilterPrompt (fill_prompt делает ядро) и текст;
// решение {pass, reason}. Ветку «фильтр не применялся» (aiFilterEnabled/
// недоступность) обрабатывает ядро до вызова — сервис всегда отвечает.
rpc Filter(FilterRequest) returns (FilterReply);
// Полный разбор лида (ai.py classify L218258, Ruling 5): ядро собирает
// system_prompt = заполненные aiPrompt + cardPrompt и user-контекст
// «Доски + примеры разметки + Сообщение»; сервис возвращает извлечённый
// ответ модели как json-строку (типовую схему задаёт промпт). Строгий
// маппинг json → AiParsedCardDto делает ядро (1:1 normalize_stack/
// clean_budget/build_contacts).
rpc Classify(ClassifyRequest) returns (ClassifyReply);
// Генерация ключевых слов для discovery-задачи по описанию (фикс. промпт
// discovery_routes L3647 + описание; Ruling 5): ответ {keywords}. Очистку
// (_clean_keywords: ≤30, ≤60 симв., дедуп) и мягкие ошибки делает ядро.
rpc GenerateKeywords(GenerateKeywordsRequest) returns (GenerateKeywordsReply);
// Оценка соответствия сообщения задаче поиска (промпт discovery_eval
// L5054; Ruling 5/10): текст + описание + ключи задачи → {fit, reason}.
// Ядро зовёт только при aiEnabled; сбой/не-JSON — фолбэк на эвристику.
rpc EvaluateFit(EvaluateFitRequest) returns (EvaluateFitReply);
}
// --- Запросы/ответы AiService ---
// Конфиг активного LLM-провайдера на запрос (Ruling 5: ядро расшифровывает
// aiConfigs и передаёт в теле каждого запроса; сервис не хранит настроек).
// Форма 1:1 с эффективным конфигом core: настройка aiConfigs тенанта хранит
// {apiKey, baseUrl, model} (camelCase; apiKey шифруется AES-GCM этапа 2),
// api_style — из каталога AiProviders (Settings); HTTP-клиенту провайдера
// нужны baseUrl+model+apiKey для запроса и api_style для выбора схемы вызова
// (OpenAI-совместимые chat/completions vs Anthropic Messages API).
message ProviderConfig {
// Id провайдера (ключ aiConfigs / каталога AiProviders: deepseek, openai,
// anthropic, ollama, lmstudio, custom…).
string provider_id = 1;
// Эффективный базовый URL API (aiConfigs.baseUrl или дефолт каталога).
string base_url = 2;
// API-ключ открытым текстом (расшифрован ядром); пуст для локальных
// провайдеров (ollama/lmstudio) — заголовок авторизации не шлётся.
optional string api_key = 3;
// Активная модель (aiConfigs.model или первая из каталога провайдера).
string model = 4;
// Стиль API: пуст — OpenAI-совместимый (POST {base}/chat/completions,
// Bearer); "anthropic" — Messages API (POST {base}/v1/messages,
// x-api-key + anthropic-version).
optional string api_style = 5;
}
message FilterRequest {
// Заполненный промпт фильтра (настройка aiFilterPrompt с подстановкой
// {domain}/{keywords} — делает ядро; Ruling 5).
string prompt = 1;
// Текст сообщения (ядро обрезает до 4000, как ai.py L193).
string text = 2;
// Конфиг активного провайдера (Ruling 5; все запросы ai-сервиса).
ProviderConfig provider_config = 3;
}
message FilterReply {
// True — сообщение проходит фильтр (не спам/реклама/служебное).
bool pass = 1;
// Причина отказа при pass=false (текст ветки filter_ai; пуст при пропуске).
optional string reason = 2;
// Оценка токенов вызова (Ruling 5).
Usage usage = 3;
}
message ClassifyRequest {
// system_prompt = заполненные aiPrompt + cardPrompt (структура карточки,
// «О заявке»; собирает ядро — Ruling 5).
string system_prompt = 1;
// user-контекст: «Доски + примеры разметки + Новое сообщение» (собирает
// ядро, 1:1 classify L243251).
string user_context = 2;
// Конфиг активного провайдера (Ruling 5; все запросы ai-сервиса).
ProviderConfig provider_config = 3;
}
message ClassifyReply {
// True — модель вернула разбираемый JSON (ok=false — ответ без JSON после
// ретраев; ядро трактует как «не разобрано» и падает в локальный путь).
bool ok = 1;
// Сырой JSON-ответ модели (строкой; маппинг в карточку — в ядре).
optional string json = 2;
// Оценка токенов вызова (Ruling 5).
Usage usage = 3;
}
message GenerateKeywordsRequest {
// Описание ниши/задачи (ядро обрезает до 4000, discovery_routes L29).
string description = 1;
// Конфиг активного провайдера (Ruling 5; все запросы ai-сервиса).
ProviderConfig provider_config = 2;
}
message GenerateKeywordsReply {
// Сгенерированные ключи (пустой список — модель не выделила ключи;
// чистку/дедуп и мягкую ошибку для UI делает ядро — Ruling 11).
repeated string keywords = 1;
// Оценка токенов вызова (Ruling 5).
Usage usage = 2;
}
message EvaluateFitRequest {
// Текст сообщения для оценки (выборка кандидата; ядро ограничивает 4000).
string text = 1;
// Описание задачи поиска (discovery_eval L51).
string description = 2;
// Ключи задачи (discovery_eval L52; подставляются в промпт сервисом).
repeated string keywords = 3;
// Конфиг активного провайдера (Ruling 5; все запросы ai-сервиса).
ProviderConfig provider_config = 4;
}
message EvaluateFitReply {
// True — сообщение относится к сфере/задаче (JSON {"fit": 0|1}).
bool fit = 1;
// Краткая причина решения модели (пуст, если модель её не дала).
optional string reason = 2;
// Оценка токенов вызова (Ruling 5).
Usage usage = 3;
}
// Оценка токенов вызова провайдера (Ruling 5: usage{prompt/completion/total};
// из usage API-ответа, при отсутствии — по символам ≈chars/4).
message Usage {
// Токены запроса (system + user).
uint32 prompt = 1;
// Токены ответа модели.
uint32 completion = 2;
// Суммарно (prompt + completion; может отличаться от суммы при подсчёте
// провайдером — берём как есть).
uint32 total = 3;
}
//
// ядро передаёт в теле каждого запроса готовые (заполненные) промпты и/или
// текст + конфиг активного провайдера (ProviderConfig); сервис вызывает
// провайдера (OpenAI-совместимые POST {base}/chat/completions, Anthropic
// POST {base}/v1/messages; temperature 0.2, таймауты 90/60 с, retry 2 с
// паузами 0.8/2 с) и возвращает ответ + оценку токенов. Конфиг провайдера
// (id/base/model/apiKey/api_style) ядро кладёт в поле provider_config каждого
// запроса — сервис настроек тенанта не знает и не хранит.
//
// tenant-id — id тенанта (строка; учёт токенов в ядре по нему);
// service-token — общий токен сервисов (env DEAL_SERVICE_TOKEN); неверный/
// пустой → UNAUTHENTICATED.
//
// INVALID_ARGUMENT — невалидный запрос (пустой текст/промпт и т.п.);
// UNAVAILABLE — провайдер не ответил корректно после ретраев; detail =
// «ИИ (имя) не ответил корректно — повторите попытку через
// несколько секунд» (ядро падает в локальный разбор).
//
// Берётся из usage API-ответа провайдера; при отсутствии оценивается по
// символам (≈chars/4). Ядро копит значения в tenant-KV aiTokenUsage.
//
// Deadlines (клиент ядра): все RPC — 120 с (90 с провайдер + ретраи 0.8/2 с;
syntax = "proto3";
package deal.ai.v1;
option csharp_namespace = "Deal.Grpc.Ai";
service AiService {
// ядро шлёт заполненный aiFilterPrompt (fill_prompt делает ядро) и текст;
// решение {pass, reason}. Ветку «фильтр не применялся» (aiFilterEnabled/
// недоступность) обрабатывает ядро до вызова — сервис всегда отвечает.
rpc Filter(FilterRequest) returns (FilterReply);
// «Доски + примеры разметки + Сообщение»; сервис возвращает извлечённый
// ответ модели как json-строку (типовую схему задаёт промпт). Строгий
// clean_budget/build_contacts).
rpc Classify(ClassifyRequest) returns (ClassifyReply);
// Генерация ключевых слов для discovery-задачи по описанию (фикс. промпт
rpc GenerateKeywords(GenerateKeywordsRequest) returns (GenerateKeywordsReply);
// Оценка соответствия сообщения задаче поиска (промпт discovery_eval
// Ядро зовёт только при aiEnabled; сбой/не-JSON — фолбэк на эвристику.
rpc EvaluateFit(EvaluateFitRequest) returns (EvaluateFitReply);
}
// --- Запросы/ответы AiService ---
// aiConfigs и передаёт в теле каждого запроса; сервис не хранит настроек).
// api_style — из каталога AiProviders (Settings); HTTP-клиенту провайдера
// нужны baseUrl+model+apiKey для запроса и api_style для выбора схемы вызова
// (OpenAI-совместимые chat/completions vs Anthropic Messages API).
message ProviderConfig {
// Id провайдера (ключ aiConfigs / каталога AiProviders: deepseek, openai,
// anthropic, ollama, lmstudio, custom…).
string provider_id = 1;
// Эффективный базовый URL API (aiConfigs.baseUrl или дефолт каталога).
string base_url = 2;
// API-ключ открытым текстом (расшифрован ядром); пуст для локальных
// провайдеров (ollama/lmstudio) — заголовок авторизации не шлётся.
optional string api_key = 3;
// Активная модель (aiConfigs.model или первая из каталога провайдера).
string model = 4;
// Стиль API: пуст — OpenAI-совместимый (POST {base}/chat/completions,
// Bearer); "anthropic" — Messages API (POST {base}/v1/messages,
// x-api-key + anthropic-version).
optional string api_style = 5;
}
message FilterRequest {
// Заполненный промпт фильтра (настройка aiFilterPrompt с подстановкой
string prompt = 1;
string text = 2;
ProviderConfig provider_config = 3;
}
message FilterReply {
// True — сообщение проходит фильтр (не спам/реклама/служебное).
bool pass = 1;
// Причина отказа при pass=false (текст ветки filter_ai; пуст при пропуске).
optional string reason = 2;
Usage usage = 3;
}
message ClassifyRequest {
string system_prompt = 1;
// user-контекст: «Доски + примеры разметки + Новое сообщение» (собирает
string user_context = 2;
ProviderConfig provider_config = 3;
}
message ClassifyReply {
// True — модель вернула разбираемый JSON (ok=false — ответ без JSON после
// ретраев; ядро трактует как «не разобрано» и падает в локальный путь).
bool ok = 1;
// Сырой JSON-ответ модели (строкой; маппинг в карточку — в ядре).
optional string json = 2;
Usage usage = 3;
}
message GenerateKeywordsRequest {
string description = 1;
ProviderConfig provider_config = 2;
}
message GenerateKeywordsReply {
// Сгенерированные ключи (пустой список — модель не выделила ключи;
repeated string keywords = 1;
Usage usage = 2;
}
message EvaluateFitRequest {
// Текст сообщения для оценки (выборка кандидата; ядро ограничивает 4000).
string text = 1;
string description = 2;
repeated string keywords = 3;
ProviderConfig provider_config = 4;
}
message EvaluateFitReply {
// True — сообщение относится к сфере/задаче (JSON {"fit": 0|1}).
bool fit = 1;
// Краткая причина решения модели (пуст, если модель её не дала).
optional string reason = 2;
Usage usage = 3;
}
// из usage API-ответа, при отсутствии — по символам ≈chars/4).
message Usage {
// Токены запроса (system + user).
uint32 prompt = 1;
// Токены ответа модели.
uint32 completion = 2;
// Суммарно (prompt + completion; может отличаться от суммы при подсчёте
// провайдером — берём как есть).
uint32 total = 3;
}
+127 -147
View File
@@ -1,147 +1,127 @@
// ml.proto — контракт между ядром Deal и ml-service (этап 6).
//
// Инкрементальная наивно-байесовская модель по терминам, 1:1 с python
// mlservice/model.py (predict L184293, status L325345, reset L348354,
// learn_batch L147173) и DTO ядра Deal.Contracts.Integrations.Models
// (MlPredictResultDto/MlServiceStatusDto/MlEvalDto/MlResetResultDto).
// Модель per-tenant: пул в ml-service, файл SQLite data/ml/<tenantId>.sqlite
// (Ruling 4). Обучение ядро шлёт батчами из очереди ml_outbox
// (MlOutboxFlushScheduler, Ruling 6).
//
// Каждый RPC обязан нести в gRPC-metadata два заголовка (Ruling 1):
// tenant-id — id тенанта (строка; модель тенанта — в пуле сервиса);
// service-token — общий токен сервисов (env DEAL_SERVICE_TOKEN); неверный/
// пустой → UNAUTHENTICATED.
//
// Ошибки домена — gRPC-статусы с detail = текст причины 1:1 (Ruling 1):
// INVALID_ARGUMENT — невалидный запрос (пустой text и т.п.);
// UNAVAILABLE — хранилище модели недоступно (ядро отвечает «не уверен»,
// Ruling 6 — кэш reachable 15 с).
//
// Семантика неготовой модели: Predict НЕ ошибка — модель без опыта отвечает
// фиксированным «не уверен»: take=false, label пуст, scores пуст, hits=0,
// ready=false, margin пуст, terms пуст, type пуст (Ruling 5 этапа 2, 1:1).
//
// Deadlines (клиент ядра): Predict — 5 с; Status/Reset — 10 с; TrainBatch — 30 с
// (батч ≤100 примеров, одна транзакция).
syntax = "proto3";
package deal.ml.v1;
option csharp_namespace = "Deal.Grpc.Ml";
service MlService {
// Предсказание по тексту сообщения (model.py predict L184293).
// take/label/scores/hits/margin/terms/type осмысленны только при take=true;
// scores — до 5 лучших «класс → вес» (round 3), margin — адаптивный порог
// (0.9/0.7/0.5/0.35 после 0/60/150/400 примеров), terms — узнанные термины
// класса-победителя, type — решение о типе заявки (t:hire/t:order).
rpc Predict(PredictRequest) returns (PredictReply);
// Статус модели тенанта (model.py status L325345): ready/classes/learned/eval.
// classes — «label → вес» (round 2, по убыванию); eval — самооценка по окну
// последних подтверждённых решений (EVAL_WINDOW). Модель создаётся лениво
// по первому обращению (Ruling 4) — отсутствие опыта это НЕ ошибка.
rpc Status(StatusRequest) returns (StatusReply);
// Полный сброс модели тенанта (model.py reset L348354): очистка классов,
// терминов и журнала самооценки + пересоздание файла. Ok=true; мягкая
// ошибка — Ok=false + error (ядро чистит свою ml_outbox только при успехе).
rpc Reset(ResetRequest) returns (ResetReply);
// Пакетное обучение (model.py learn_batch L147173): одна транзакция +
// пакетные вставки терминов; самооценка по действиям пользователя (delta=1,
// не t:*) до применения. Ответ — число применённых примеров.
rpc TrainBatch(TrainBatchRequest) returns (TrainBatchReply);
}
message PredictRequest {
// Текст сообщения (ядро передаёт уже обрезанный/нормализованный, как
// ml_routes.py L8690; пустой/пробельный — не ошибка: ответ «не уверен»).
string text = 1;
}
message PredictReply {
// True — модель уверена (take) и решение можно использовать без ИИ.
bool take = 1;
// Класс решения: id колонки канбана (b_…) или "spam"; пуст, если не уверена.
optional string label = 2;
// Веса классов: «label → вес» (до 5 лучших; пуст у неготовой модели).
map<string, double> scores = 3;
// Сколько терминов класса-победителя модель узнала в тексте.
int32 hits = 4;
// Модель обучена (набрала MIN_TOTAL/MIN_WINNER/MIN_WINNER_SPAM) и может
// принимать решения.
bool ready = 5;
// Порог уверенности решения (адаптивный margin, 2 знака); пуст — нет решения.
optional double margin = 6;
// Узнанные термины класса-победителя (подсказка структуры карточки, ≤8).
repeated string terms = 7;
// Решение о типе заявки (hire/order); пуст — модель тип не определила.
TypeDecision type = 8;
}
// Решение ML о типе заявки (predict L233238; MlTypeDecisionDto).
message TypeDecision {
// True — модель уверена в типе.
bool take = 1;
// Тип: "hire" | "order".
string label = 2;
// Внутренний класс ML: "t:hire" | "t:order" (не показывается UI).
string value = 3;
// Запас уверенности (margin, 2 знака).
double margin = 4;
}
message StatusRequest {}
message StatusReply {
// Модель готова принимать решения.
bool ready = 1;
// Классы модели: «label → вес» (round 2; пуст, пока нет обучения).
map<string, double> classes = 2;
// Всего примеров, на которых модель обучалась (сумма по классам).
int32 learned = 3;
// Самооценка модели по последним подтверждённым решениям.
ModelEval eval = 4;
}
// Окно самооценки модели (model.py status L329339; MlEvalDto).
message ModelEval {
// Решений в окне самооценки (последние EVAL_WINDOW).
int32 count = 1;
// Из них совпавших с действием пользователя.
int32 correct = 2;
// Доля верных (correct/count, 0..1; 0 при пустом окне).
double accuracy = 3;
}
message ResetRequest {}
message ResetReply {
// True — модель сброшена (и ядро очищает свою очередь обучения).
bool ok = 1;
// Текст ошибки при сбое сброса (пуст при успехе) — мягкая ошибка.
optional string error = 2;
}
message TrainBatchRequest {
// Примеры обучения (1 транзакция на батч; ядро шлёт ≤100 за цикл, Ruling 6).
repeated TrainExample items = 1;
}
// Один обучающий пример (строка ml_outbox ядра: text/label/delta).
message TrainExample {
// Текст примера (source_msg карточки или title).
string text = 1;
// Метка: id доски (b_…), "spam" либо тип "t:hire"/"t:order".
string label = 2;
// Вес сигнала: 1.0 — действие пользователя; −1.0 — снять метку;
// 0.4/0.6 — сигналы ИИ/правил (этапы 4/6).
double delta = 3;
}
message TrainBatchReply {
// Число применённых примеров (= len(items) при успехе).
int32 learned = 1;
}
//
// (MlPredictResultDto/MlServiceStatusDto/MlEvalDto/MlResetResultDto).
// Модель per-tenant: пул в ml-service, файл SQLite data/ml/<tenantId>.sqlite
//
// tenant-id — id тенанта (строка; модель тенанта — в пуле сервиса);
// service-token — общий токен сервисов (env DEAL_SERVICE_TOKEN); неверный/
// пустой → UNAUTHENTICATED.
//
// INVALID_ARGUMENT — невалидный запрос (пустой text и т.п.);
// UNAVAILABLE — хранилище модели недоступно (ядро отвечает «не уверен»,
//
// Семантика неготовой модели: Predict НЕ ошибка — модель без опыта отвечает
// фиксированным «не уверен»: take=false, label пуст, scores пуст, hits=0,
//
// Deadlines (клиент ядра): Predict — 5 с; Status/Reset — 10 с; TrainBatch — 30 с
// (батч ≤100 примеров, одна транзакция).
syntax = "proto3";
package deal.ml.v1;
option csharp_namespace = "Deal.Grpc.Ml";
service MlService {
// take/label/scores/hits/margin/terms/type осмысленны только при take=true;
// scores — до 5 лучших «класс → вес» (round 3), margin — адаптивный порог
// (0.9/0.7/0.5/0.35 после 0/60/150/400 примеров), terms — узнанные термины
// класса-победителя, type — решение о типе заявки (t:hire/t:order).
rpc Predict(PredictRequest) returns (PredictReply);
// classes — «label → вес» (round 2, по убыванию); eval — самооценка по окну
// последних подтверждённых решений (EVAL_WINDOW). Модель создаётся лениво
rpc Status(StatusRequest) returns (StatusReply);
// терминов и журнала самооценки + пересоздание файла. Ok=true; мягкая
// ошибка — Ok=false + error (ядро чистит свою ml_outbox только при успехе).
rpc Reset(ResetRequest) returns (ResetReply);
// пакетные вставки терминов; самооценка по действиям пользователя (delta=1,
// не t:*) до применения. Ответ — число применённых примеров.
rpc TrainBatch(TrainBatchRequest) returns (TrainBatchReply);
}
message PredictRequest {
// Текст сообщения (ядро передаёт уже обрезанный/нормализованный, как
string text = 1;
}
message PredictReply {
// True — модель уверена (take) и решение можно использовать без ИИ.
bool take = 1;
// Класс решения: id колонки канбана (b_…) или "spam"; пуст, если не уверена.
optional string label = 2;
// Веса классов: «label → вес» (до 5 лучших; пуст у неготовой модели).
map<string, double> scores = 3;
// Сколько терминов класса-победителя модель узнала в тексте.
int32 hits = 4;
// Модель обучена (набрала MIN_TOTAL/MIN_WINNER/MIN_WINNER_SPAM) и может
// принимать решения.
bool ready = 5;
// Порог уверенности решения (адаптивный margin, 2 знака); пуст — нет решения.
optional double margin = 6;
// Узнанные термины класса-победителя (подсказка структуры карточки, ≤8).
repeated string terms = 7;
// Решение о типе заявки (hire/order); пуст — модель тип не определила.
TypeDecision type = 8;
}
message TypeDecision {
// True — модель уверена в типе.
bool take = 1;
// Тип: "hire" | "order".
string label = 2;
// Внутренний класс ML: "t:hire" | "t:order" (не показывается UI).
string value = 3;
// Запас уверенности (margin, 2 знака).
double margin = 4;
}
message StatusRequest {}
message StatusReply {
// Модель готова принимать решения.
bool ready = 1;
// Классы модели: «label → вес» (round 2; пуст, пока нет обучения).
map<string, double> classes = 2;
// Всего примеров, на которых модель обучалась (сумма по классам).
int32 learned = 3;
// Самооценка модели по последним подтверждённым решениям.
ModelEval eval = 4;
}
message ModelEval {
// Решений в окне самооценки (последние EVAL_WINDOW).
int32 count = 1;
// Из них совпавших с действием пользователя.
int32 correct = 2;
// Доля верных (correct/count, 0..1; 0 при пустом окне).
double accuracy = 3;
}
message ResetRequest {}
message ResetReply {
// True — модель сброшена (и ядро очищает свою очередь обучения).
bool ok = 1;
// Текст ошибки при сбое сброса (пуст при успехе) — мягкая ошибка.
optional string error = 2;
}
message TrainBatchRequest {
repeated TrainExample items = 1;
}
// Один обучающий пример (строка ml_outbox ядра: text/label/delta).
message TrainExample {
// Текст примера (source_msg карточки или title).
string text = 1;
// Метка: id доски (b_…), "spam" либо тип "t:hire"/"t:order".
string label = 2;
// Вес сигнала: 1.0 — действие пользователя; −1.0 — снять метку;
double delta = 3;
}
message TrainBatchReply {
// Число применённых примеров (= len(items) при успехе).
int32 learned = 1;
}
+401 -453
View File
@@ -1,453 +1,401 @@
// telegram.proto — контракт между ядром Deal и telegram-service (этап 6).
//
// Два сервиса в одном файле (дизайн-док §6.2, план Task 1, Ruling 1/7):
// * TelegramService — команды ядра к telegram-service (порт-гейт ITelegramGateway):
// подключение/отключение аккаунта, каталог диалогов, мониторинг, backfill,
// превью, discovery-операции (поиск/инфо/чтение/вступление/выход);
// * IngressService — исходящий поток telegram-service → ядро: сырые сообщения
// (PushMessage), синхронизация каталога (SyncDialogs), статус аккаунта
// (ReportStatus). Сервер ингресса живёт в Deal.Api (:5082, Ruling 7).
//
// Семантика методов 1:1 с python-прототипом backend/app/services/telegram.py
// (имена L134873) и api-map §3.3/§4.8/§4.9; хранение диалогов/статуса — только
// в ядре (модуль Deal.Modules.Telegram, Ruling 7), сервис БД тенантов не знает.
//
// Каждый RPC обязан нести в gRPC-metadata два заголовка (Ruling 1):
// tenant-id — id тенанта (строка; единственный источник принадлежности,
// полю в теле не доверяем);
// service-token — общий токен сервисов (env DEAL_SERVICE_TOKEN); неверный/
// пустой → UNAUTHENTICATED.
//
// Ошибки домена — gRPC-статусы с detail = текст причины 1:1 с прототипом:
// INVALID_ARGUMENT — неверный ввод/неверный код/неверный пароль и т.п.;
// NOT_FOUND — диалог/сущность не найдены (нет сессии тенанта и т.п.);
// FAILED_PRECONDITION— операция невозможна в текущей фазе (нет сессии и т.п.);
// RESOURCE_EXHAUSTED — FloodWait Telegram (detail начинается с префикса "flood");
// UNAVAILABLE — недоступность Telegram/сети (безопасный повтор).
//
// Значения строк (канон контракта, .NET-код обеих сторон — новый):
// * phase: idle|phone|code|password|qr|ready (как status() прототипа L85);
// * kind: channel (канал) | group (группа/супергруппа) | forum (форум) |
// chat (личный чат/бот). 1:1 с _kind_of (L461466): broadcast →
// channel, megagroup/gigagroup/group → group, остальное → chat.
// Forum выставляется отдельным флагом is_forum (GetInfo); в
// каталоге (RefreshDialogs) форум приходит как group.
//
// Deadlines (клиент ядра; уточняются адаптерами T2+):
// * быстрые команды статуса/мониторинга — 10 с;
// * сетевые операции Telegram (QR/код/поиск/инфо/чтение/вступление) — 60 с;
// * Backfill/RefreshDialogs (паузы анти-бана 1.5–3 с/сообщение) — 120 с;
// * IngressService (локальная сеть core) — 10 с (сбой догоняет sweep).
syntax = "proto3";
package deal.telegram.v1;
option csharp_namespace = "Deal.Grpc.Telegram";
// ---------------------------------------------------------------------------
// TelegramService — команды ядра → telegram-service (клиентская сторона в core)
// ---------------------------------------------------------------------------
service TelegramService {
// Текущий статус аккаунта/фазы входа тенанта (status() прототипа L103119).
// live-поля для GET /api/tg/status (Ruling 8); monitored/keysSet ядро считает
// само. Нет сессии — FAILED_PRECONDITION «Telegram не подключён».
rpc GetStatus(GetStatusRequest) returns (GetStatusReply);
// Вход по номеру телефона: запросить код (start_phone L134147).
// api_id/api_hash — глобальные ключи приложения Telegram, задаёт оператор (ТЗ §4.1/§8.1),
// передаёт ядро в теле (Ruling 3); нет ключей — ядро отвечает 400 «Ключи Telegram
// не заданы оператором» до вызова. Ответ: новая фаза ("code").
rpc StartPhone(StartPhoneRequest) returns (StartPhoneReply);
// Начать QR-вход (qr_start L286300). Ответ: фаза + qrUrl (t.me/qr/...);
// если аккаунт уже авторизован — фаза "ready", qrUrl пуст.
rpc StartQr(StartQrRequest) returns (StartQrReply);
// Отправить SMS-код (submit_code L149166). Ошибки: «Неверный код»,
// «Код истёк — запросите новый» → INVALID_ARGUMENT. Нужен 2FA — фаза
// "password", иначе "ready". Фазы вне "code" → FAILED_PRECONDITION.
rpc SendCode(SendCodeRequest) returns (SendCodeReply);
// Облачный пароль 2FA (submit_password L168176). Ошибка «Неверный облачный
// пароль» → INVALID_ARGUMENT. Ответ: фаза ("ready").
rpc SendPassword(SendPasswordRequest) returns (SendPasswordReply);
// Отключить аккаунт, удалить сессию тенанта (disconnect L189207).
rpc Logout(LogoutRequest) returns (LogoutReply);
// Синхронизировать каталог диалогов из Telegram (refresh_dialogs L505519):
// актуальный список sources диалогов аккаунта (entries). Удаление/обновление
// каталога и авто-мониторинг новых делает ядро (SyncFromTelegram, Ruling 7).
rpc RefreshDialogs(RefreshDialogsRequest) returns (RefreshDialogsReply);
// Включить/выключить мониторинг диалога (set_monitor L536546): обновляет
// зеркало monitored в сервисе. Первый backfill (force=false) запускает ядро
// отдельным RPC Backfill. Ответ: ok/enabled.
rpc SetMonitor(SetMonitorRequest) returns (SetMonitorReply);
// Мониторинг всех диалогов сразу (set_monitor_all L548567). Ответ:
// ok/count/enabled (count — сколько диалогов в каталоге тенанта).
rpc SetMonitorAll(SetMonitorAllRequest) returns (SetMonitorAllReply);
// Перечитать последние ~10 сообщений диалога и отправить их в ядро потоком
// PushMessage (backfill_dialog L349390; паузы анти-бана 1.5–3 с/сообщение,
// mark-as-read). force=true — «Перечитать» по кнопке даже для разобранных.
// Ответ: сколько сообщений отправлено (processed).
rpc Backfill(BackfillRequest) returns (BackfillReply);
// Последние сообщения диалога для превью (dialog_messages L583620):
// свежие из Telegram; признак lead и фолбэк на БД добавляет ядро
// (у сервиса нет БД тенанта). limit 1..50 (api-map /dialogs/preview).
rpc ReadRecent(ReadRecentRequest) returns (ReadRecentReply);
// Глобальный поиск каналов/групп по ключу (discovery_search L624664).
// Пауза анти-бана после поиска — внутри сервиса. Личные чаты/боты ядро
// отсеивает само (Ruling 10). Результат — entries канала/группы.
rpc Search(SearchRequest) returns (SearchReply);
// Инфо об источнике для оценки (discovery_info L666716): имя/username/kind/
// hue + participants и is_forum (полный чат). Сбои определения не роняют
// RPC: participants пуст, остальные поля — из entity/каталога.
rpc GetInfo(GetInfoRequest) returns (GetInfoReply);
// Выборка последних сообщений источника для оценки кандидата
// (discovery_read L718760): форумы читаются по активным темам. История
// недоступна (приватный/закрытый источник) — ok=false, error="no_history",
// это НЕ ошибка RPC. limit ≥ 1; topic_id/topic_title заполнены для форумов.
rpc ReadForEval(ReadForEvalRequest) returns (ReadForEvalReply);
// Вступить в канал/группу по @username (discovery_join L818839; ручной
// join вне квот — паузу перед авто-join делает воркер ядра, Ruling 10).
// FloodWait → RESOURCE_EXHAUSTED (detail с префиксом "flood").
rpc Join(JoinRequest) returns (JoinReply);
// Выйти из канала/группы (discovery_leave L841848). NOT_FOUND — нет
// диалога/членства.
rpc Leave(LeaveRequest) returns (LeaveReply);
}
// --- Запросы/ответы TelegramService ---
message GetStatusRequest {}
// Статус аккаунта/фазы входа (shape прототипа status() L110118; monitored и
// keysSet ядро добавляет само из своей БД/настроек — Ruling 8).
message GetStatusReply {
// Фаза входа: idle|phone|code|password|qr|ready.
string phase = 1;
// Клиент Telegram подключён и авторизован.
bool connected = 2;
// Жив ли realtime-listener (поток новых сообщений → PushMessage).
bool listener = 3;
// Аккаунт "@username" (для справки; источник истины — KV tgAccount по
// ReportStatus, ядро использует KV — Ruling 8).
string account = 4;
// Текст последней ошибки (null, если ошибки нет).
optional string error = 5;
// URL QR-входа (заполнен только при phase == "qr").
optional string qr_url = 6;
}
// Подключение по телефону: ключи API передаёт ядро (Ruling 3).
message StartPhoneRequest {
// Номер телефона в международном формате (как ввёл пользователь).
string phone = 1;
// api_id приложения Telegram (глобальные ключи, задаёт оператор; 5..9 цифр).
int32 api_id = 2;
// api_hash приложения Telegram (глобальные ключи, задаёт оператор).
string api_hash = 3;
}
message StartPhoneReply {
// Фаза после запроса кода ("code"); при ошибке — RPC-статус.
string phase = 1;
}
message StartQrRequest {
// api_id/api_hash приложения Telegram (см. StartPhoneRequest).
int32 api_id = 1;
string api_hash = 2;
}
message StartQrReply {
// Фаза после запуска: "qr" (ждём сканирования) либо "ready" (уже вошли).
string phase = 1;
// URL вида https://t.me/qr/... для отрисовки QR; пуст при phase != "qr".
string qr_url = 2;
}
message SendCodeRequest {
// Код из SMS/Telegram-сообщения.
string code = 1;
}
message SendCodeReply {
// Фаза после проверки кода: "password" (нужен 2FA) или "ready".
string phase = 1;
}
message SendPasswordRequest {
// Облачный пароль 2FA.
string password = 1;
}
message SendPasswordReply {
// Фаза после входа ("ready").
string phase = 1;
}
message LogoutRequest {}
message LogoutReply {
// True — аккаунт отключён, сессия тенанта удалена.
bool ok = 1;
}
message RefreshDialogsRequest {}
message RefreshDialogsReply {
// Актуальный каталог диалогов аккаунта (id/name/username/kind/hue).
// Ядро применяет его через SyncFromTelegram (Ruling 7).
repeated DialogEntry entries = 1;
}
// Один диалог/канал каталога или результат поиска (shape refresh L516 и
// discovery_search L653660: tuple id/name/handle/kind/hue; handle == username).
message DialogEntry {
// Подписанный id диалога: каналы "-100…", группы "-…", личные "+…".
string id = 1;
// Отображаемое имя (title/first_name) или id, если имени нет.
string name = 2;
// Username (handle) источника; пуст, если нет публичного username.
string username = 3;
// Тип: channel|group|forum|chat (канон контракта, см. шапку файла).
string kind = 4;
// Цвет источника из палитры DIALOG_HUES (hex, "#rrggbb") — считает сервис.
string hue = 5;
}
message SetMonitorRequest {
// Id диалога каталога.
string dialog_id = 1;
// True — мониторить (сообщения → PushMessage в ядро), false — выключить.
bool enabled = 2;
}
message SetMonitorReply {
bool ok = 1;
// Зеркальное значение enabled (для ответов эндпоинтов {ok, enabled}).
bool enabled = 2;
}
message SetMonitorAllRequest {
// True — мониторить все диалоги каталога, false — снять мониторинг со всех.
bool enabled = 1;
}
message SetMonitorAllReply {
bool ok = 1;
// Сколько диалогов в каталоге тенанта (api-map /monitor-all → count).
int32 count = 2;
bool enabled = 3;
}
message BackfillRequest {
// Id диалога для перечитывания.
string dialog_id = 1;
// True — перечитать, даже если диалог уже разобран (кнопка «Перечитать»).
bool force = 2;
}
message BackfillReply {
// Сколько сообщений отправлено в ядро потоком PushMessage.
int32 processed = 1;
}
message ReadRecentRequest {
// Id диалога.
string dialog_id = 1;
// Сколько последних сообщений (1..50; api-map /dialogs/preview limit 1..50).
int32 limit = 2;
}
message ReadRecentReply {
// Последние сообщения (от новых к старым). lead/фолбэк на БД — в ядре.
repeated PreviewMessage messages = 1;
}
// Сообщение превью диалога (api-map §4.8 L351: {id, text, time, lead}).
message PreviewMessage {
// Id сообщения в Telegram (int); фолбэк-сообщения из БД ядра — строки
// "m_<dialog>_<msg>", поэтому значение передаётся строкой.
string id = 1;
// Текст сообщения.
string text = 2;
// Время сообщения, epoch-ms.
int64 time = 3;
}
message SearchRequest {
// Поисковый запрос (ключ задачи discovery).
string query = 1;
// Верхняя граница результатов (прототип: default 30).
int32 limit = 2;
}
message SearchReply {
// Найденные источники (каналы/группы; личные чаты/ботов отсеивает ядро).
repeated DialogEntry results = 1;
}
message GetInfoRequest {
// Id источника (подписанный; из каталога или результата поиска).
string dialog_id = 1;
}
// Инфо об источнике для оценки кандидата discovery (discovery_info L674682).
message ChannelInfo {
string id = 1;
string name = 2;
string username = 3;
// Тип: channel|group|forum|chat.
string kind = 4;
string hue = 5;
// Число участников (full_chat); пусто — определить не удалось.
optional int32 participants = 6;
// True — мегагруппа-форум (темы); ядро трактует kind как "forum" (Ruling 10).
bool is_forum = 7;
}
message GetInfoReply {
ChannelInfo info = 1;
}
message ReadForEvalRequest {
// Id источника.
string dialog_id = 1;
// Размер выборки (прототип discovery_read: limit сообщений/тем).
int32 limit = 2;
}
message ReadForEvalReply {
// True — выборка получена; false — история недоступна без членства.
bool ok = 1;
// Код причины при ok=false: "no_history" (остальные поля пусты).
optional string error = 2;
// Сообщения выборки (форумы — по активным темам, topic_id/topic_title
// заполнены; для обычных источников — null).
repeated EvalMessage messages = 3;
}
// Сообщение выборки discovery_read (_discovery_message_item L803816).
message EvalMessage {
// Id сообщения в Telegram.
int64 id = 1;
// Текст сообщения (непустой; пустые тексты отбрасывает сервис).
string text = 2;
// Время сообщения, epoch-ms.
int64 date_ms = 3;
// Id темы форума (для обычных источников пусто).
optional int64 topic_id = 4;
// Название темы форума (для обычных источников пусто).
optional string topic_title = 5;
}
message JoinRequest {
// @username источника (без "@"; пусто → INVALID_ARGUMENT).
string username = 1;
}
message JoinReply {
bool ok = 1;
}
message LeaveRequest {
// Id диалога для выхода.
string dialog_id = 1;
}
message LeaveReply {
bool ok = 1;
}
// ---------------------------------------------------------------------------
// IngressService — исходящий поток telegram-service → ядро
// (gRPC-сервер в Deal.Api :5082; Ruling 7; интерцептор service-token;
// tenantId из metadata → собственный scope с ITenantContext.SetTenant)
// ---------------------------------------------------------------------------
service IngressService {
// Новое/догоняющее сообщение мониторящегося диалога → очередь пайплайна
// ядра (PipelineIngestService.EnqueueAsync, контракт demo-ingest; + превью в
// TgMessages). Дубль dialog+msgId уже в очереди — не растёт (duplicate=true).
rpc PushMessage(PushMessageRequest) returns (PushMessageReply);
// Синхронизация каталога диалогов: ядро применяет entries (SyncFromTelegram:
// авто-мониторинг по autoMonitorNew, обновление, удаление отсутствующих) и
// отвечает актуальным списком monitored id — сервис держит зеркало
// мониторинга в памяти (Ruling 7), по нему фильтрует события realtime.
rpc SyncDialogs(SyncDialogsRequest) returns (SyncDialogsReply);
// Периодический/событийный статус аккаунта: ядро пишет KV tgStatus/tgAccount
// и публикует SSE system_status + тосты на переходах фаз (Ruling 7).
rpc ReportStatus(ReportStatusRequest) returns (ReportStatusReply);
}
// Сообщение из потока в ядро. Поля 1:1 с QueuedMessage/PipelineIngestRequest
// (Ruling 7, demo-ingest L759): dialog_id + канальные поля плоские; msg_id —
// дубль-гвард; msg_at — время исходного сообщения, без него ядро подставит now.
message PushMessageRequest {
// Id диалога-источника (подписанный; пуст — приём no-op).
string dialog_id = 1;
// Имя канала/диалога (title/first_name или id).
string channel_name = 2;
// Username канала/диалога (пуст, если нет).
string channel_handle = 3;
// Цвет канала из палитры DIALOG_HUES (hex; считает сервис — Ruling 7).
string channel_hue = 4;
// Id исходного сообщения в Telegram (дубль-гвард dialog+msgId).
optional int64 msg_id = 5;
// Текст сообщения (сервис шлёт как есть; приём обрежет до 6000).
string text = 6;
// Время исходного сообщения, epoch-ms; пусто — ядро подставит now.
optional int64 msg_at = 7;
}
message PushMessageReply {
// True — сообщение принято (no-op с пустым текстом/диалогом — accepted=false).
bool accepted = 1;
// True — дубль dialog_id+msg_id уже в очереди (очередь не выросла).
bool duplicate = 2;
}
message SyncDialogsRequest {
// Актуальный каталог диалогов (собирает сервис, как refresh_dialogs).
repeated DialogEntry entries = 1;
}
message SyncDialogsReply {
// Id диалогов с включённым мониторингом (зеркало сервиса после синка).
repeated string monitored_ids = 1;
}
// Статус аккаунта для ядра (shape прототипа _publish_status L315316/status()).
message ReportStatusRequest {
// Фаза: idle|phone|code|password|qr|ready.
string phase = 1;
// Клиент подключён и авторизован.
bool connected = 2;
// Realtime-listener жив.
bool listener = 3;
// Аккаунт "@username" (пуст после выхода) → KV tgAccount.
string account = 4;
// Текст ошибки (пуст, если нет) → KV tgStatus.error.
optional string error = 5;
// URL QR-входа при phase == "qr".
optional string qr_url = 6;
}
message ReportStatusReply {
// True — статус принят и сохранён ядром.
bool ok = 1;
}
//
// * TelegramService — команды ядра к telegram-service (порт-гейт ITelegramGateway):
// подключение/отключение аккаунта, каталог диалогов, мониторинг, backfill,
// превью, discovery-операции (поиск/инфо/чтение/вступление/выход);
// * IngressService — исходящий поток telegram-service → ядро: сырые сообщения
// (PushMessage), синхронизация каталога (SyncDialogs), статус аккаунта
//
//
// tenant-id — id тенанта (строка; единственный источник принадлежности,
// полю в теле не доверяем);
// service-token — общий токен сервисов (env DEAL_SERVICE_TOKEN); неверный/
// пустой → UNAUTHENTICATED.
//
// INVALID_ARGUMENT — неверный ввод/неверный код/неверный пароль и т.п.;
// NOT_FOUND — диалог/сущность не найдены (нет сессии тенанта и т.п.);
// FAILED_PRECONDITION— операция невозможна в текущей фазе (нет сессии и т.п.);
// RESOURCE_EXHAUSTED — FloodWait Telegram (detail начинается с префикса "flood");
// UNAVAILABLE — недоступность Telegram/сети (безопасный повтор).
//
// Значения строк (канон контракта, .NET-код обеих сторон — новый):
// * kind: channel (канал) | group (группа/супергруппа) | forum (форум) |
// channel, megagroup/gigagroup/group → group, остальное → chat.
// Forum выставляется отдельным флагом is_forum (GetInfo); в
// каталоге (RefreshDialogs) форум приходит как group.
//
// * быстрые команды статуса/мониторинга — 10 с;
// * сетевые операции Telegram (QR/код/поиск/инфо/чтение/вступление) — 60 с;
// * Backfill/RefreshDialogs (паузы анти-бана 1.5–3 с/сообщение) — 120 с;
// * IngressService (локальная сеть core) — 10 с (сбой догоняет sweep).
syntax = "proto3";
package deal.telegram.v1;
option csharp_namespace = "Deal.Grpc.Telegram";
// ---------------------------------------------------------------------------
// TelegramService — команды ядра → telegram-service (клиентская сторона в core)
// ---------------------------------------------------------------------------
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);
// если аккаунт уже авторизован — фаза "ready", qrUrl пуст.
rpc StartQr(StartQrRequest) returns (StartQrReply);
// «Код истёк — запросите новый» → INVALID_ARGUMENT. Нужен 2FA — фаза
// "password", иначе "ready". Фазы вне "code" → FAILED_PRECONDITION.
rpc SendCode(SendCodeRequest) returns (SendCodeReply);
// пароль» → INVALID_ARGUMENT. Ответ: фаза ("ready").
rpc SendPassword(SendPasswordRequest) returns (SendPasswordReply);
rpc Logout(LogoutRequest) returns (LogoutReply);
// актуальный список sources диалогов аккаунта (entries). Удаление/обновление
rpc RefreshDialogs(RefreshDialogsRequest) returns (RefreshDialogsReply);
// зеркало monitored в сервисе. Первый backfill (force=false) запускает ядро
// отдельным RPC Backfill. Ответ: ok/enabled.
rpc SetMonitor(SetMonitorRequest) returns (SetMonitorReply);
// ok/count/enabled (count — сколько диалогов в каталоге тенанта).
rpc SetMonitorAll(SetMonitorAllRequest) returns (SetMonitorAllReply);
// Перечитать последние ~10 сообщений диалога и отправить их в ядро потоком
// mark-as-read). force=true — «Перечитать» по кнопке даже для разобранных.
// Ответ: сколько сообщений отправлено (processed).
rpc Backfill(BackfillRequest) returns (BackfillReply);
// свежие из Telegram; признак lead и фолбэк на БД добавляет ядро
// (у сервиса нет БД тенанта). limit 1..50 (api-map /dialogs/preview).
rpc ReadRecent(ReadRecentRequest) returns (ReadRecentReply);
// Пауза анти-бана после поиска — внутри сервиса. Личные чаты/боты ядро
rpc Search(SearchRequest) returns (SearchReply);
// hue + participants и is_forum (полный чат). Сбои определения не роняют
// RPC: participants пуст, остальные поля — из entity/каталога.
rpc GetInfo(GetInfoRequest) returns (GetInfoReply);
// Выборка последних сообщений источника для оценки кандидата
// недоступна (приватный/закрытый источник) — ok=false, error="no_history",
// это НЕ ошибка RPC. limit ≥ 1; topic_id/topic_title заполнены для форумов.
rpc ReadForEval(ReadForEvalRequest) returns (ReadForEvalReply);
// FloodWait → RESOURCE_EXHAUSTED (detail с префиксом "flood").
rpc Join(JoinRequest) returns (JoinReply);
// диалога/членства.
rpc Leave(LeaveRequest) returns (LeaveReply);
}
// --- Запросы/ответы TelegramService ---
message GetStatusRequest {}
message GetStatusReply {
// Фаза входа: idle|phone|code|password|qr|ready.
string phase = 1;
// Клиент Telegram подключён и авторизован.
bool connected = 2;
// Жив ли realtime-listener (поток новых сообщений → PushMessage).
bool listener = 3;
// Аккаунт "@username" (для справки; источник истины — KV tgAccount по
string account = 4;
// Текст последней ошибки (null, если ошибки нет).
optional string error = 5;
// URL QR-входа (заполнен только при phase == "qr").
optional string qr_url = 6;
}
message StartPhoneRequest {
// Номер телефона в международном формате (как ввёл пользователь).
string phone = 1;
// api_id приложения Telegram (глобальные ключи, задаёт оператор; 5..9 цифр).
int32 api_id = 2;
// api_hash приложения Telegram (глобальные ключи, задаёт оператор).
string api_hash = 3;
}
message StartPhoneReply {
// Фаза после запроса кода ("code"); при ошибке — RPC-статус.
string phase = 1;
}
message StartQrRequest {
// api_id/api_hash приложения Telegram (см. StartPhoneRequest).
int32 api_id = 1;
string api_hash = 2;
}
message StartQrReply {
// Фаза после запуска: "qr" (ждём сканирования) либо "ready" (уже вошли).
string phase = 1;
// URL вида https://t.me/qr/... для отрисовки QR; пуст при phase != "qr".
string qr_url = 2;
}
message SendCodeRequest {
// Код из SMS/Telegram-сообщения.
string code = 1;
}
message SendCodeReply {
// Фаза после проверки кода: "password" (нужен 2FA) или "ready".
string phase = 1;
}
message SendPasswordRequest {
// Облачный пароль 2FA.
string password = 1;
}
message SendPasswordReply {
// Фаза после входа ("ready").
string phase = 1;
}
message LogoutRequest {}
message LogoutReply {
// True — аккаунт отключён, сессия тенанта удалена.
bool ok = 1;
}
message RefreshDialogsRequest {}
message RefreshDialogsReply {
// Актуальный каталог диалогов аккаунта (id/name/username/kind/hue).
repeated DialogEntry entries = 1;
}
message DialogEntry {
// Подписанный id диалога: каналы "-100…", группы "-…", личные "+…".
string id = 1;
// Отображаемое имя (title/first_name) или id, если имени нет.
string name = 2;
// Username (handle) источника; пуст, если нет публичного username.
string username = 3;
// Тип: channel|group|forum|chat (канон контракта, см. шапку файла).
string kind = 4;
// Цвет источника из палитры DIALOG_HUES (hex, "#rrggbb") — считает сервис.
string hue = 5;
}
message SetMonitorRequest {
// Id диалога каталога.
string dialog_id = 1;
// True — мониторить (сообщения → PushMessage в ядро), false — выключить.
bool enabled = 2;
}
message SetMonitorReply {
bool ok = 1;
// Зеркальное значение enabled (для ответов эндпоинтов {ok, enabled}).
bool enabled = 2;
}
message SetMonitorAllRequest {
// True — мониторить все диалоги каталога, false — снять мониторинг со всех.
bool enabled = 1;
}
message SetMonitorAllReply {
bool ok = 1;
// Сколько диалогов в каталоге тенанта (api-map /monitor-all → count).
int32 count = 2;
bool enabled = 3;
}
message BackfillRequest {
// Id диалога для перечитывания.
string dialog_id = 1;
// True — перечитать, даже если диалог уже разобран (кнопка «Перечитать»).
bool force = 2;
}
message BackfillReply {
// Сколько сообщений отправлено в ядро потоком PushMessage.
int32 processed = 1;
}
message ReadRecentRequest {
// Id диалога.
string dialog_id = 1;
// Сколько последних сообщений (1..50; api-map /dialogs/preview limit 1..50).
int32 limit = 2;
}
message ReadRecentReply {
// Последние сообщения (от новых к старым). lead/фолбэк на БД — в ядре.
repeated PreviewMessage messages = 1;
}
message PreviewMessage {
// Id сообщения в Telegram (int); фолбэк-сообщения из БД ядра — строки
// "m_<dialog>_<msg>", поэтому значение передаётся строкой.
string id = 1;
// Текст сообщения.
string text = 2;
// Время сообщения, epoch-ms.
int64 time = 3;
}
message SearchRequest {
// Поисковый запрос (ключ задачи discovery).
string query = 1;
int32 limit = 2;
}
message SearchReply {
// Найденные источники (каналы/группы; личные чаты/ботов отсеивает ядро).
repeated DialogEntry results = 1;
}
message GetInfoRequest {
// Id источника (подписанный; из каталога или результата поиска).
string dialog_id = 1;
}
message ChannelInfo {
string id = 1;
string name = 2;
string username = 3;
// Тип: channel|group|forum|chat.
string kind = 4;
string hue = 5;
// Число участников (full_chat); пусто — определить не удалось.
optional int32 participants = 6;
bool is_forum = 7;
}
message GetInfoReply {
ChannelInfo info = 1;
}
message ReadForEvalRequest {
// Id источника.
string dialog_id = 1;
int32 limit = 2;
}
message ReadForEvalReply {
// True — выборка получена; false — история недоступна без членства.
bool ok = 1;
// Код причины при ok=false: "no_history" (остальные поля пусты).
optional string error = 2;
// Сообщения выборки (форумы — по активным темам, topic_id/topic_title
// заполнены; для обычных источников — null).
repeated EvalMessage messages = 3;
}
message EvalMessage {
// Id сообщения в Telegram.
int64 id = 1;
// Текст сообщения (непустой; пустые тексты отбрасывает сервис).
string text = 2;
// Время сообщения, epoch-ms.
int64 date_ms = 3;
// Id темы форума (для обычных источников пусто).
optional int64 topic_id = 4;
// Название темы форума (для обычных источников пусто).
optional string topic_title = 5;
}
message JoinRequest {
// @username источника (без "@"; пусто → INVALID_ARGUMENT).
string username = 1;
}
message JoinReply {
bool ok = 1;
}
message LeaveRequest {
// Id диалога для выхода.
string dialog_id = 1;
}
message LeaveReply {
bool ok = 1;
}
// ---------------------------------------------------------------------------
// IngressService — исходящий поток telegram-service → ядро
// tenantId из metadata → собственный scope с ITenantContext.SetTenant)
// ---------------------------------------------------------------------------
service IngressService {
// Новое/догоняющее сообщение мониторящегося диалога → очередь пайплайна
// ядра (PipelineIngestService.EnqueueAsync, контракт demo-ingest; + превью в
// TgMessages). Дубль dialog+msgId уже в очереди — не растёт (duplicate=true).
rpc PushMessage(PushMessageRequest) returns (PushMessageReply);
// Синхронизация каталога диалогов: ядро применяет entries (SyncFromTelegram:
// авто-мониторинг по autoMonitorNew, обновление, удаление отсутствующих) и
// отвечает актуальным списком monitored id — сервис держит зеркало
rpc SyncDialogs(SyncDialogsRequest) returns (SyncDialogsReply);
// Периодический/событийный статус аккаунта: ядро пишет KV tgStatus/tgAccount
rpc ReportStatus(ReportStatusRequest) returns (ReportStatusReply);
}
// дубль-гвард; msg_at — время исходного сообщения, без него ядро подставит now.
message PushMessageRequest {
// Id диалога-источника (подписанный; пуст — приём no-op).
string dialog_id = 1;
// Имя канала/диалога (title/first_name или id).
string channel_name = 2;
// Username канала/диалога (пуст, если нет).
string channel_handle = 3;
string channel_hue = 4;
// Id исходного сообщения в Telegram (дубль-гвард dialog+msgId).
optional int64 msg_id = 5;
// Текст сообщения (сервис шлёт как есть; приём обрежет до 6000).
string text = 6;
// Время исходного сообщения, epoch-ms; пусто — ядро подставит now.
optional int64 msg_at = 7;
}
message PushMessageReply {
// True — сообщение принято (no-op с пустым текстом/диалогом — accepted=false).
bool accepted = 1;
// True — дубль dialog_id+msg_id уже в очереди (очередь не выросла).
bool duplicate = 2;
}
message SyncDialogsRequest {
// Актуальный каталог диалогов (собирает сервис, как refresh_dialogs).
repeated DialogEntry entries = 1;
}
message SyncDialogsReply {
// Id диалогов с включённым мониторингом (зеркало сервиса после синка).
repeated string monitored_ids = 1;
}
message ReportStatusRequest {
// Фаза: idle|phone|code|password|qr|ready.
string phase = 1;
// Клиент подключён и авторизован.
bool connected = 2;
// Realtime-listener жив.
bool listener = 3;
// Аккаунт "@username" (пуст после выхода) → KV tgAccount.
string account = 4;
// Текст ошибки (пуст, если нет) → KV tgStatus.error.
optional string error = 5;
// URL QR-входа при phase == "qr".
optional string qr_url = 6;
}
message ReportStatusReply {
// True — статус принят и сохранён ядром.
bool ok = 1;
}
@@ -3,31 +3,22 @@ using Deal.Modules.Tenants.Application.Services;
namespace Deal.Api.Configuration;
/// <summary>
/// Настройки httpOnly-куки сессии. Привязываются из секции "Cookies" конфигурации (IOptions).
/// Настройки httpOnly-куки сессии.
/// </summary>
/// <remarks>
/// Источник значений — конфигурация: секция <c>Cookies</c> в appsettings.json /
/// appsettings.Development.json и переменные окружения <c>Cookies__*</c> (имя, срок, Secure).
/// <para>
/// Срок жизни по умолчанию — единый источник числа «30 дней»: константа модуля
/// <see cref="AuthService.SessionLifetimeDays"/>, на которую ссылается код-дефолт свойства
/// <see cref="Days"/>. Значение из конфигурации (<c>Cookies__Days</c>) при необходимости перекрывает его.
/// </para>
/// </remarks>
public sealed class CookieOptions
{
/// <summary>
/// Имя куки (Ruling 6: <c>deal_session</c>).
/// Имя куки.
/// </summary>
public string Name { get; set; } = "deal_session";
/// <summary>
/// Срок жизни куки в днях; совпадает со сроком жизни сессии (Ruling 6).
/// Срок жизни куки в днях; совпадает со сроком жизни сессии.
/// </summary>
public int Days { get; set; } = AuthService.SessionLifetimeDays;
/// <summary>
/// Флаг Secure куки (dev=false; включается при HTTPS-проксировании, Ruling 6).
/// Флаг Secure куки.
/// </summary>
public bool Secure { get; set; }
}
@@ -1,24 +1,17 @@
namespace Deal.Api.Configuration;
/// <summary>
/// Настройки авто-очистки данных (этап 12, пакет B): секция <c>DataRetention</c> конфигурации
/// (appsettings.json + переменные окружения <c>DataRetention__*</c>).
/// Настройки авто-очистки данных
/// </summary>
/// <remarks>
/// Управляет фоновым циклом <c>DataRetentionScheduler</c>: удаление записей аудита старше
/// <see cref="AuditRetentionDays"/> (по умолчанию 180 дней — разумный операционный срок) и очистка
/// накопительных полей лимитов/счётчиков прошедших окон. <see cref="Enabled"/>=false полностью
/// выключает фоновую очистку (например, при внешнем управлении retention).
/// </remarks>
public sealed class DataRetentionOptions
{
/// <summary>
/// Включён ли фоновый цикл авто-очистки (по умолчанию — да).
/// Включён ли фоновый цикл авто-очистки
/// </summary>
public bool Enabled { get; set; } = true;
/// <summary>
/// Срок хранения записей аудита в днях (по умолчанию 180); неположительное значение — дефолт.
/// Срок хранения записей аудита в днях
/// </summary>
public int AuditRetentionDays { get; set; } = 180;
}
@@ -1,39 +1,22 @@
namespace Deal.Api.Configuration;
/// <summary>
/// Настройки доверия прокси-заголовкам (план Task 12; замечание ревью T4/T11 к Ruling 5/10): секция
/// <c>ForwardedHeaders</c> конфигурации (appsettings.json + переменные окружения <c>ForwardedHeaders__*</c>).
/// Настройки доверия прокси-заголовкам
/// </summary>
/// <remarks>
/// В PROD наружу смотрит только Caddy (compose-prod, Task 14), core принимает соединения от него:
/// без обработки X-Forwarded-For/X-Forwarded-Proto RemoteIpAddress (HttpContext.Connection)
/// всех запросов — адрес Caddy, и audit-IP (ClientIp эндпоинтов, Ruling 4) и rate-limit-по-IP
/// (политики Ruling 5, LoginAttemptGuard) схлопываются в один бакет прокси. UseForwardedHeaders
/// доверяет заголовкам только клиентов из <see cref="KnownProxies"/> (IP-адреса) и
/// <see cref="KnownNetworks"/> (подсети CIDR).
/// <para>
/// <c>Enabled=false</c> — код-дефолт и значение dev/тестов: прокси в dev-стеке нет (compose.dev —
/// core наружу напрямую :5080), curl-приёмки от заголовков не зависят. PROD включает env
/// (<c>ForwardedHeaders__Enabled=true</c>) и перечисляет Caddy: KnownProxies (его IP) либо KnownNetworks
/// (узкий CIDR compose-сети). Пустые KnownProxies/KnownNetworks у ForwardedHeadersMiddleware означают
/// «доверять любому клиенту» — Program.BuildForwardedHeadersOptions не допускает пустоту и добавляет
/// loopback-фолбэк (dev-прокси на хосте: vite/локальный Caddy); явное перечисление в конфиге замещает его.
/// </para>
/// </remarks>
public sealed class ForwardedHeadersConfig
{
/// <summary>
/// Включена ли обработка прокси-заголовков (dev/тесты — false; PROD за Caddy — true).
/// Включена ли обработка прокси-заголовков
/// </summary>
public bool Enabled { get; set; }
/// <summary>
/// Доверенные прокси-адреса: IP клиентов, которым можно верить в X-Forwarded-For/Proto.
/// Доверенные прокси-адреса
/// </summary>
public string[] KnownProxies { get; set; } = [];
/// <summary>
/// Доверенные подсети прокси в CIDR (например "172.16.0.0/12" — compose-сеть PROD).
/// Доверенные подсети прокси в CIDR
/// </summary>
public string[] KnownNetworks { get; set; } = [];
}
@@ -3,33 +3,22 @@ using Deal.Modules.Tenants.Application.Services;
namespace Deal.Api.Configuration;
/// <summary>
/// Настройки httpOnly-куки сессии оператора. Привязываются из секции "OperatorCookies" конфигурации (IOptions).
/// Настройки httpOnly-куки сессии оператора.
/// </summary>
/// <remarks>
/// Источник значений — конфигурация: секция <c>OperatorCookies</c> в appsettings.json и переменные
/// окружения <c>OperatorCookies__*</c> (имя, срок, Secure). Имя по умолчанию — <c>deal_operator_session</c>:
/// отдельная от тенантной <c>deal_session</c> кука (Ruling 1 этапа 7) — операторская сессия не может быть
/// подменена тенантной и наоборот (сессии разрешаются разными middleware).
/// <para>
/// Срок жизни по умолчанию — единый источник числа «12 часов»: константа модуля
/// <see cref="OperatorAuthService.SessionLifetimeHours"/>, на которую ссылается код-дефолт свойства
/// <see cref="Hours"/>. Значение из конфигурации (<c>OperatorCookies__Hours</c>) при необходимости перекрывает его.
/// </para>
/// </remarks>
public sealed class OperatorCookieOptions
{
/// <summary>
/// Имя куки (Ruling 1: <c>deal_operator_session</c>).
/// Имя куки.
/// </summary>
public string Name { get; set; } = "deal_operator_session";
/// <summary>
/// Срок жизни куки в часах; совпадает со сроком жизни сессии оператора (Ruling 1: 12).
/// Срок жизни куки в часах; совпадает со сроком жизни сессии оператора.
/// </summary>
public int Hours { get; set; } = OperatorAuthService.SessionLifetimeHours;
/// <summary>
/// Флаг Secure куки (dev=false; включается при HTTPS-проксировании, Ruling 1).
/// Флаг Secure куки.
/// </summary>
public bool Secure { get; set; }
}
@@ -1,43 +1,37 @@
namespace Deal.Api.Configuration;
/// <summary>
/// Настройки rate limiting и защиты входа (план Task 11, Ruling 5): секция <c>RateLimit</c> конфигурации
/// (appsettings.json + переменные окружения <c>RateLimit__*</c>).
/// Настройки rate limiting и защиты входа
/// </summary>
/// <remarks>
/// <c>Enabled=false</c> — код-дефолт и значение dev/тестов: политики и middleware не регистрируются вовсе,
/// curl-приёмки и unit-хосты не режутся. PROD включает env-переопределением (<c>RateLimit__Enabled=true</c>
/// в compose-prod, Task 14). Все окна политик — фиксированные, 1 минута (имена свойств — «PerMinute»).
/// </remarks>
public sealed class RateLimitOptions
{
/// <summary>
/// Включены ли rate limiting и LoginAttemptGuard (dev/тесты — false, Ruling 5).
/// Включены ли rate limiting и LoginAttemptGuard.
/// </summary>
public bool Enabled { get; set; }
/// <summary>
/// Лимит политики "auth" (фиксированное окно в минуту на IP) для /api/auth/login и /api/operator/auth/login.
/// Лимит политики "auth"
/// </summary>
public int AuthPerMinute { get; set; } = 10;
/// <summary>
/// Лимит политики "api" (в минуту на тенанта либо IP анонима) для остальных /api-эндпоинтов.
/// Лимит политики "api"
/// </summary>
public int ApiPerMinute { get; set; } = 600;
/// <summary>
/// Лимит gRPC-ингресса (в минуту на tenant-id из metadata; интерцептор IngressRateLimitInterceptor).
/// Лимит gRPC-ингресса
/// </summary>
public int GrpcIngressPerMinute { get; set; } = 600;
/// <summary>
/// Порог неудачных попыток входа ключа ip|login до блокировки (LoginAttemptGuard).
/// Порог неудачных попыток входа ключа ip|login до блокировки
/// </summary>
public int LoginAttemptsMax { get; set; } = 5;
/// <summary>
/// Окно учёта неудачных попыток входа в минутах (LoginAttemptGuard; текст 429 — фиксированный «15 минут»).
/// Окно учёта неудачных попыток входа в минутах
/// </summary>
public int LoginAttemptWindowMin { get; set; } = 15;
}
@@ -1,25 +1,12 @@
namespace Deal.Api.Configuration;
/// <summary>
/// Настройки безопасности HTTP (план Task 12, Ruling 10(2)/9): секция <c>Security</c> конфигурации
/// (appsettings.json + переменные окружения <c>Security__*</c>).
/// Настройки безопасности HTTP
/// </summary>
/// <remarks>
/// <see cref="AllowedOrigins"/> — единый явный allowlist для Origin-проверки мутаций
/// (<see cref="Deal.Api.Middleware.OriginGuardMiddleware"/>) и CORS-политики. Пустой список — dev-режим:
/// OriginGuard принимает только «свой» origin запроса (схема + Host, для не-GET запросов /api),
/// CORS разрешает любой origin (текущее поведение «как в прототипе»). Непустой список (PROD,
/// compose-prod, Ruling 9) — CORS становится строгим allowlist + credentials; OriginGuard дополнительно
/// принимает перечисленные origin'ы (в т.ч. когда запрос идёт не от «своего» Host — фронт за прокси).
/// <para>
/// Записи — полные origin'ы в том виде, в каком их шлёт браузер: схема://хост[:порт], без завершающего
/// слэша (например <c>https://deal.example</c>, <c>http://localhost:5173</c>). Сравнение регистронезависимо.
/// </para>
/// </remarks>
public sealed class SecurityOptions
{
/// <summary>
/// Явный allowlist Origin/CORS (схема://хост[:порт]); пусто — dev-режим «любой origin + свой Host».
/// Явный allowlist Origin/CORS
/// </summary>
public string[] AllowedOrigins { get; set; } = [];
}
+2 -12
View File
@@ -3,21 +3,11 @@ using Deal.Modules.Kanban.Application.Models;
namespace Deal.Api.Dtos;
/// <summary>
/// Ответ POST /api/admin/tick — форма {storage, reminders, pipeline, queue} (dashboard_routes.py admin_tick L327337, план Task 10).
/// Ответ POST /api/admin/tick — форма {storage, reminders, pipeline, queue}.
/// </summary>
/// <remarks>
/// Поля 1:1 с прототипом: storage — статистика тика правил хранения с очисткой отсева
/// (<see cref="StorageTickStatsDto"/>, purgedRejected объединяет purge пайплайна — Ruling 9, tick_storage
/// L485493); reminders — «выстрелившие» напоминания «Отложено» этапа 5 (план Task 11, Ruling 3/8): те же
/// записи {id,title,containerId}, что ушли SSE-событиями reminder_due (check_reminders admin_tick L334/L337),
/// пусто — сработавших нет либо проверка недоступна; pipeline — счётчики одного прохода pump (ключи словаря
/// python L921: staged/rulesStored/mlStored/mlDrop/typeDrop/aiStored/aiDrop/aiFail/noBudget; пусто — pump не
/// выполнялся/сбой, как {} при занятом локе прототипа); queue — число строк очереди входящих после pump
/// (queue_len L337). Наружу сериализуется в camelCase (storage/reminders/pipeline/queue).
/// </remarks>
/// <param name="Storage">Статистика тика правил хранения (включая purgedRejected — очистку отсева 3 суток).</param>
/// <param name="Reminders">«Выстрелившие» напоминания {id,title,containerId} — список SSE reminder_due тика.</param>
/// <param name="Pipeline">Счётчики pump: словарь ключей прототипа; пуст, если pump не дал результата.</param>
/// <param name="Pipeline">Счётчики pump: словарь ключей; пуст, если pump не дал результата.</param>
/// <param name="Queue">Строк очереди входящих после прохода pump (queue_len).</param>
public sealed record AdminTickResultDto(
StorageTickStatsDto Storage,
+1 -15
View File
@@ -7,26 +7,15 @@ using Deal.Modules.Settings.Application.Models;
namespace Deal.Api.Endpoints;
/// <summary>
/// Эндпоинт проверки подключения AI-провайдера: POST /api/ai/check (Ruling 7/8, api-map §4.10).
/// Эндпоинт проверки подключения AI-провайдера
/// </summary>
/// <remarks>
/// «Только для Settings-экрана» (Ruling 8): фронт жмёт «Проверить подключение» (store.js
/// checkAiConnection) БЕЗ тела — сервер читает АКТИВНУЮ конфигурацию провайдера тенанта
/// (настройки <c>aiProvider</c> + <c>aiConfigs</c> с расшифровкой ключа через <see cref="ISecretCipher"/>;
/// 1:1 с ai_svc._cfg(), ai.py L2533), вызывает порт <see cref="IAiConnectionChecker"/> и отдаёт
/// {ok, message} + статус провайдера. Требует сессию: 401 {detail} (формат прототипа).
/// Резолв scoped-зависимостей — через RequestServices ПОСЛЕ проверки сессии (как SettingsEndpoints:
/// DI-биндинг параметров выполняется до тела, а ISettingsStore требует tenant-контекст запроса).
/// </remarks>
public static class AiCheckEndpoint
{
// Префикс группы API (общий для эндпоинтов этапа, Ruling 8).
private const string ApiGroupPrefix = "/api";
// Путь проверки подключения AI-провайдера.
private const string AiCheckPath = "/ai/check";
// OpenAPI-тег группы (в прототипе роутер settings — settings_routes.py).
private const string OpenApiTag = "settings";
/// <summary>
@@ -59,14 +48,11 @@ public static class AiCheckEndpoint
return Results.Ok(result);
}
// Собирает запрос проверки из активной конфигурации провайдера (1:1 с ai_svc._cfg()).
// store: KV-хранилище настроек тенанта.
// secretCipher: Шифр секретов (расшифровка apiKey).
// ct: Токен отмены.
// Возвращает: Запрос проверки: id провайдера + эффективные base/model + расшифрованный ключ.
// Эффективные значения = дефолты SettingsDefaults, перекрытые сохранёнными
// переопределениями (Ruling 1); пустое переопределение base/model → дефолт каталога
// (семантика «cfg.get(...) or meta[...]» прототипа).
private static async Task<AiCheckRequest> BuildActiveCheckRequestAsync(
ISettingsStore store,
ISecretCipher secretCipher,
@@ -7,40 +7,22 @@ using Deal.Contracts.Integrations.Models;
namespace Deal.Api.Endpoints;
/// <summary>
/// Эндпоинты ИИ-предложений: POST /api/ai/suggest-columns и POST /api/ai/suggest-keywords
/// (план Task 14 L476479; прототип dashboard_routes.py L395409).
/// Эндпоинты ИИ-предложений
/// </summary>
/// <remarks>
/// Контракт 1:1 с прототипом и api-map §3.2 L120121: suggest-columns → <c>{ok:true, created:N}</c>
/// либо <c>{ok:false, reason, cooldown?}</c>; suggest-keywords → <c>{ok:true, keywords:[…]}</c> либо
/// <c>{ok:false, reason}</c>. Причины — мягкие ошибки (HTTP 200 с ok:false + reason), статусы 4xx/5xx
/// не мапятся. Оба требуют сессию: 401 {detail} без куки (как остальные эндпоинты контейнеров); порт
/// IColumnSuggester резолвится из RequestServices ПОСЛЕ проверки сессии (scoped на tenant-контекст
/// запроса). При успехе suggest-columns публикуется SSE-toast «ИИ предложил колонок: N — откройте и
/// решите» (sparkles, 1:1 с suggest.py L162); boards_changed НЕ шлём (Ruling 5: фронт перечитывает
/// доски сам после ok). Публикации — из эндпоинта (Ruling 5): без подписчиков — no-op.
/// </remarks>
public static class AiSuggestEndpoints
{
// Префикс группы AI-эндпоинтов этапа (роутер dashboard, prefix="/api"; пути L395/L406).
private const string AiGroupPrefix = "/api/ai";
// Путь предложения колонок (dashboard_routes.py L395).
private const string SuggestColumnsPath = "/suggest-columns";
// Путь предложения ключевых слов (dashboard_routes.py L404).
private const string SuggestKeywordsPath = "/suggest-keywords";
// OpenAPI-тег группы (в прототипе роутер dashboard — dashboard_routes.py).
private const string OpenApiTag = "dashboard";
// SSE-тип события тоста (Ruling 5; api.js слушает 'toast').
private const string ToastEventType = "toast";
// Текст тоста после успешных предложений колонок (suggest.py L162, 1:1).
private const string SuggestToastTextFormat = "ИИ предложил колонок: {0} — откройте и решите";
// Иконка тоста предложений колонок (sparkles, 1:1 с прототипом).
private const string SparklesIcon = "sparkles";
/// <summary>
@@ -57,9 +39,7 @@ public static class AiSuggestEndpoints
}
// POST /api/ai/suggest-columns: анализ «Неразобранного» и создание колонок-предложений.
// Ответ — результат порта 1:1: {ok:true, created:N} — доски suggested=true созданы (эндпоинт шлёт
// SSE-toast), {ok:false, reason} (+ cooldown) — мягкая причина (HTTP 200). Кулдаун/«мало карточек»/
// «похожие колонки уже есть» — за адаптером LocalColumnSuggester (Ruling 3).
private static async Task<IResult> SuggestColumnsAsync(HttpContext context, CancellationToken ct)
{
if (!context.HasUser())
@@ -82,7 +62,6 @@ public static class AiSuggestEndpoints
}
// POST /api/ai/suggest-keywords: слова-маркеры сферы по карточкам (настройки «Сфера и ключи»).
// Ответ — результат порта 1:1: {ok:true, keywords:[…]} (≤60) либо {ok:false, reason} (HTTP 200).
private static async Task<IResult> SuggestKeywordsAsync(HttpContext context, CancellationToken ct)
{
if (!context.HasUser())
+2 -21
View File
@@ -11,13 +11,8 @@ using CookieOptions = Deal.Api.Configuration.CookieOptions;
namespace Deal.Api.Endpoints;
/// <summary>
/// HTTP-эндпоинты аутентификации (группа /api/auth). Контракт 1:1 с прототипом auth_routes.py.
/// HTTP-эндпоинты аутентификации
/// </summary>
/// <remarks>
/// Успех-ответы — <c>{ok:true,...}</c>, ошибки — HTTP-код + <c>{"detail":"..."}</c> (Ruling 10).
/// Кука сессии выставляется на login и change-password (свежий токен). Сообщения об ошибках —
/// фиксированные строки прототипа.
/// </remarks>
public static class AuthEndpoints
{
private const string InvalidCredentialsDetail = "Неверный логин или пароль";
@@ -28,7 +23,7 @@ public static class AuthEndpoints
private const string AuthOpenApiTag = "auth";
/// <summary>
/// Регистрирует группу /api/auth: login, logout, me, change-password.
/// Регистрирует группу /api/auth
/// </summary>
/// <param name="app">Построитель маршрутов приложения.</param>
/// <returns>Построитель маршрутов для цепочки вызовов.</returns>
@@ -36,7 +31,6 @@ public static class AuthEndpoints
{
var group = app.MapGroup(AuthGroupPrefix).WithTags(AuthOpenApiTag);
// Политика "auth" rate limiter (план Task 11, Ruling 5): фиксированное окно 10/мин на IP
// ручки входа; остальные ручки группы — под глобальной API-политикой (по тенанту/IP).
group.MapPost("/login", LoginAsync).RequireRateLimiting(RateLimitPolicies.AuthPolicy);
group.MapPost("/logout", LogoutAsync);
@@ -46,8 +40,6 @@ public static class AuthEndpoints
return app;
}
// POST /api/auth/login: проверка учётных данных, выдача куки сессии; результат пишется в аудит (Task 4/7).
// До AuthService отрабатывает LoginAttemptGuard (5 неудач ip|login за 15 мин → 429, Ruling 5).
private static async Task<IResult> LoginAsync(
LoginRequest body,
AuthService authService,
@@ -59,7 +51,6 @@ public static class AuthEndpoints
{
string? attemptedLogin = NormalizeLogin(body.Login);
// Защита входа (план Task 11, Ruling 5): блокировка ключа ip|login до проверки учётных данных —
// 429 «Слишком много попыток входа…» (в dev при RateLimit:Enabled=false гвард выключен).
if (await loginAttemptGuard.IsBlockedAsync(ClientIp(context), attemptedLogin, ct))
{
@@ -68,10 +59,6 @@ public static class AuthEndpoints
var result = await authService.LoginAsync(body.Login, body.Password, ct);
// Приостановленный тенант: вход заблокирован (Ruling 10(5)). Отдельный текст от «неверных учётных
// данных»; tenant_login_failed пишется с tenantId и ActorId (ревью Task 4: failed-логины suspended-тенанта).
// Решение Task 7: HTTP-код 403 (а не 401) — учётка существует, доступ запрещён; приёмочный текст плана
// Task 16 формулирует «login 401» — отклонение зафиксировано для api-map/техдок в task-7-report.md.
if (result.Error == LoginResultDto.ErrorTenantSuspended)
{
await auditService.AppendAsync(new AuditRecordDto(
@@ -87,8 +74,6 @@ public static class AuthEndpoints
if (result.Login is null || result.Token is null)
{
// Пустой/пробельный login и неверные учётные данные — одно сообщение (семантика прототипа).
// Аудит tenant_login_failed пишем только для реальной попытки (непустой логин), без пароля (Ruling 4);
// счётчик неудач гварда растёт там же — пустые логины ключа не имеют (блокирует только auth-политика).
if (attemptedLogin is not null)
{
@@ -105,7 +90,6 @@ public static class AuthEndpoints
return EndpointResults.Unauthorized(InvalidCredentialsDetail);
}
// Успешный вход сбрасывает счётчик неудач ключа ip|login (Ruling 5).
await loginAttemptGuard.ResetAsync(ClientIp(context), result.Login, ct);
await auditService.AppendAsync(new AuditRecordDto(
@@ -121,7 +105,6 @@ public static class AuthEndpoints
}
// POST /api/auth/logout: удаление сессии по токену из куки и очистка куки (всегда ok).
// Если удалённая сессия была impersonation — пишется аудит impersonation_stopped (Task 7, ревью: полный аудит).
private static async Task<IResult> LogoutAsync(
AuthService authService,
AuditService auditService,
@@ -146,7 +129,6 @@ public static class AuthEndpoints
DetailJson: AuditService.ToDetailJson(new { login = logout.Login })), ct);
}
// Выход пользователя тенанта (этап 10, T1): событие пишется при живой разрешённой сессии.
if (user is not null)
{
await AuditAppender.AppendTenantAsync(context, AuditEvents.TenantLogout, new { login = user.Login }, ct);
@@ -185,7 +167,6 @@ public static class AuthEndpoints
var result = await authService.ChangePasswordAsync(user.Login, body.OldPassword, body.NewPassword, ct);
if (!result.Ok || result.NewToken is null)
{
// Семантика прототипа: код ошибки различает «старый пароль неверен» и «слишком короткий».
var detail = result.Error == ChangePasswordResultDto.ErrorTooShort
? PasswordTooShortDetail
: WrongOldPasswordDetail;
@@ -11,15 +11,8 @@ using Deal.Modules.Tenants.Application.Models;
namespace Deal.Api.Endpoints;
/// <summary>
/// Детальные операции карточки: создание локальной, «взять в работу», патч, ссылки, файлы,
/// напоминания, очистка «Отклонено» — продолжение группы /api/cards (этап 9, T6).
/// Детальные операции карточки
/// </summary>
/// <remarks>
/// Единый контракт /api/cards (R5): операции проектной карточки (патч полей, ссылки, файлы, напоминания)
/// теперь живут на том же ресурсе карточки. Список/чтение/перенос/комментарии/корзина — в
/// <see cref="CardsEndpoints"/>; здесь — уникальные подпути. Все мутации возвращают обновлённую
/// единую карточку (чтение после записи через <see cref="CardsService"/>). Все эндпоинты требуют сессию.
/// </remarks>
public static class CardDetailsEndpoints
{
// Префикс группы (общий с CardsEndpoints).
@@ -83,7 +76,7 @@ public static class CardDetailsEndpoints
private const string FileNameQuoteCharacter = "\"";
/// <summary>
/// Регистрирует уникальные подпути /api/cards (создание, take, патч, ссылки, файлы, напоминания).
/// Регистрирует уникальные подпути /api/cards
/// </summary>
/// <param name="app">Построитель маршрутов приложения.</param>
/// <returns>Построитель маршрутов для цепочки вызовов.</returns>
@@ -122,7 +115,6 @@ public static class CardDetailsEndpoints
CardsService service = context.RequestServices.GetRequiredService<CardsService>();
CardDto created = await service.CreateLocalCardAsync(ToCreateLocalDto(body), ct);
// Аудит создания карточки (этап 10, T1).
await AuditAppender.AppendTenantAsync(context, AuditEvents.CardCreated, new { cardId = created.Id }, ct);
return await ReadCardAsync(context, created.Id, ct);
}
@@ -433,7 +425,6 @@ public static class CardDetailsEndpoints
// Возвращает: Имя, безопасное для заголовка.
private static string ToDownloadFileName(string name) => name.Replace(FileNameQuoteCharacter, string.Empty);
// Переводит тело POST /api/cards в начальные поля сервиса (поля 1:1 с CardLocalCreateDto).
// body: Тело запроса.
// Возвращает: DTO модуля для CardsService.CreateLocalCardAsync.
private static CardLocalCreateDto ToCreateLocalDto(CreateCardRequest body)
+2 -38
View File
@@ -14,21 +14,8 @@ using Deal.Modules.Tenants.Application.Models;
namespace Deal.Api.Endpoints;
/// <summary>
/// Эндпоинты карточек и поиска: GET /api/cards[?containerId=], /cards/counts, /cards/{id},
/// mark-all-seen/mark-col-seen, move/trash/restore/DELETE, clear-col, comments, reclassify (batch + {id}),
/// GET /api/search (этап 9, T6).
/// Эндпоинты карточек и поиска
/// </summary>
/// <remarks>
/// Единый контракт /api/cards (R5): старые ручки /api/leads, /api/projects, /api/boards и /api/columns
/// упразднены. GET /cards →
/// {items}; counts — плоская wire-форма {new, &lt;col&gt;: {count, new}, learning, ml, ai}; move → обновлённая
/// карточка; restore → {ok, col}; clear-col → {ok, cleared}; comments → {comments}; search →
/// {cards, messages: []}. 400 «Неизвестный контейнер» при несуществующем containerId; 404 «Карточка не
/// найдена» — null-результаты сервисов, 400-тексты — константы CardsService.
/// ⚠ Статические сегменты (counts, mark-all-seen, mark-col-seen, clear-col, reclassify) регистрируются ДО
/// /cards/{cardId}. Все эндпоинты требуют сессию: 401 {detail}; сервисы резолвятся из RequestServices
/// ПОСЛЕ проверки сессии.
/// </remarks>
public static class CardsEndpoints
{
// Префикс группы карточек.
@@ -40,26 +27,22 @@ public static class CardsEndpoints
// Путь поиска (GET).
private const string SearchPath = "/search";
// OpenAPI-тег группы (в прототипе роутер dashboard — dashboard_routes.py).
private const string OpenApiTag = "dashboard";
// 404: карточка не найдена (dashboard_routes.py _lead_or_404 L9296).
private const string CardNotFoundDetail = "Карточка не найдена";
// 400 GET /cards: containerId не существует.
private const string UnknownColumnDetail = "Неизвестный контейнер";
// Инициатор перехода при ручном переносе — действие пользователя (R4 этапа 9).
private const string UserActor = "user";
// SSE-тип события завершения переклассификации (этап 12, остаток 2; api.js слушает 'cards_reclassified').
private const string ReclassifiedEventType = "cards_reclassified";
// Контекст ручного перехода карточки: пользователь, обучение ML по цели переноса.
private static readonly TransitionContext UserMoveContext = new() { Actor = UserActor, Learn = true };
/// <summary>
/// Регистрирует группы /api/cards и /api (карточки + поиск). Статические сегменты — до /cards/{cardId}.
/// Регистрирует группы /api/cards и /api
/// </summary>
/// <param name="app">Построитель маршрутов приложения.</param>
/// <returns>Построитель маршрутов для цепочки вызовов.</returns>
@@ -112,7 +95,6 @@ public static class CardsEndpoints
return Results.Ok(new { items = cards });
}
// GET /api/cards/counts: плоская wire-форма счётчиков {new, &lt;col&gt;:{count,new}, learning, ml, ai} (L161163, §4.1 L257).
private static async Task<IResult> CountsAsync(HttpContext context, CancellationToken ct)
{
if (!context.HasUser())
@@ -123,7 +105,6 @@ public static class CardsEndpoints
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
CardCountsDto counts = await cardsService.CountsAsync(ct);
// Разворачивание CardCountsDto: колонки — корневые ключи (counts L268–279), служебные — фиксированные.
var wire = new Dictionary<string, object> { ["new"] = counts.New };
foreach ((string col, CardColumnCountDto column) in counts.Columns)
{
@@ -136,7 +117,6 @@ public static class CardsEndpoints
return Results.Ok(wire);
}
// GET /api/cards/{cardId}: одна карточка; 404 «Карточка не найдена» (L166–168).
private static async Task<IResult> GetCardAsync(
string cardId,
HttpContext context,
@@ -154,7 +134,6 @@ public static class CardsEndpoints
: Results.Ok(card);
}
// POST /api/cards/mark-all-seen: снять «новое» со всех карточек (L177–180); ответ {ok:true}.
private static async Task<IResult> MarkAllSeenAsync(HttpContext context, CancellationToken ct)
{
if (!context.HasUser())
@@ -167,7 +146,6 @@ public static class CardsEndpoints
return Results.Ok(new { ok = true });
}
// POST /api/cards/mark-col-seen: снять «новое» с колонки (L187–191); ответ {ok:true}.
private static async Task<IResult> MarkColSeenAsync(
MarkColBody body,
HttpContext context,
@@ -181,7 +159,6 @@ public static class CardsEndpoints
if (body.Col is null)
{
// Пустая/отсутствующая col попала бы в mark_seen как «не задана» и сняла бы «новое» со ВСЕХ
// карточек (truthiness python, L250256) — эндпоинт защищает от вызова с null (прототип: 422).
return EndpointResults.BadRequest(UnknownColumnDetail);
}
@@ -190,7 +167,6 @@ public static class CardsEndpoints
return Results.Ok(new { ok = true });
}
// POST /api/cards/{cardId}/move {to}: перенос карточки между контейнерами (этап 9, R4).
// Маршрутизацию цели (стадия «Выбранных» vs дашборд-контейнер) и побочные эффекты выполняет единый
// доменный механизм перехода ICardMover: стадия — запись истории и сброс напоминания
// (move_stage), дашборд-контейнер — журнал/обучение ML. Ответ — обновлённая карточка; 400 при
@@ -218,7 +194,6 @@ public static class CardsEndpoints
return EndpointResults.NotFound(CardNotFoundDetail);
}
// Аудит переноса карточки (этап 10, T1): цель — минимальный безопасный идентификатор.
await AuditAppender.AppendTenantAsync(context, AuditEvents.CardMoved, new { cardId, to = body.To }, ct);
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
@@ -228,7 +203,6 @@ public static class CardsEndpoints
: Results.Ok(unified);
}
// POST /api/cards/{cardId}/trash: в корзину + обучение ML spam (L203207); ответ {ok:true}; 404.
private static async Task<IResult> TrashAsync(
string cardId,
HttpContext context,
@@ -246,12 +220,10 @@ public static class CardsEndpoints
return EndpointResults.NotFound(CardNotFoundDetail);
}
// Аудит отправки карточки в корзину (этап 10, T1).
await AuditAppender.AppendTenantAsync(context, AuditEvents.CardTrashed, new { cardId }, ct);
return Results.Ok(new { ok = true });
}
// POST /api/cards/{cardId}/restore: возврат из архив/корзины на канбан (L210–214); ответ {ok, col}; 404.
private static async Task<IResult> RestoreAsync(
string cardId,
HttpContext context,
@@ -269,12 +241,10 @@ public static class CardsEndpoints
return EndpointResults.NotFound(CardNotFoundDetail);
}
// Аудит возврата карточки из корзины/архива (этап 10, T1).
await AuditAppender.AppendTenantAsync(context, AuditEvents.CardRestored, new { cardId, col }, ct);
return Results.Ok(new { ok = true, col });
}
// DELETE /api/cards/{cardId}: удалить навсегда (Cards + комментарии; L217221); ответ {ok:true}; 404.
private static async Task<IResult> DeleteAsync(
string cardId,
HttpContext context,
@@ -292,12 +262,10 @@ public static class CardsEndpoints
return EndpointResults.NotFound(CardNotFoundDetail);
}
// Аудит удаления карточки навсегда (этап 10, T1).
await AuditAppender.AppendTenantAsync(context, AuditEvents.CardDeleted, new { cardId }, ct);
return Results.Ok(new { ok = true });
}
// POST /api/cards/clear-col {col}: очистить корзину/архив (L228–235); ответ {ok, cleared}; 400.
private static async Task<IResult> ClearColAsync(
ClearColBody body,
HttpContext context,
@@ -315,7 +283,6 @@ public static class CardsEndpoints
: Results.Ok(new { ok = true, cleared = result.Cleared });
}
// POST /api/cards/{cardId}/comments {text}: добавить комментарий (L238–242); ответ {comments}; 400 «Пустой комментарий»; 404.
private static async Task<IResult> AddCommentAsync(
string cardId,
CommentBody body,
@@ -339,7 +306,6 @@ public static class CardsEndpoints
return EndpointResults.NotFound(CardNotFoundDetail);
}
// Аудит добавления комментария (этап 10, T1): текст комментария в детали не пишется.
await AuditAppender.AppendTenantAsync(context, AuditEvents.CardCommentAdded, new { cardId }, ct);
return Results.Ok(new { comments = result.Comments });
}
@@ -414,7 +380,6 @@ public static class CardsEndpoints
reason = result.Reason,
};
// Публикует SSE cards_reclassified после успешного прохода (Ruling 5: публикации — из Api).
// Публикуется только когда проход реально выполнен и что-то изменил (started и
// reclassified &gt; 0): пустой inbox/всё пропущено не меняют доску — событие не шлём. Нагрузка
// минимальная: сколько обработано и перемещено (фронт перечитывает доску). Без подписчиков — no-op.
@@ -455,7 +420,6 @@ public static class CardsEndpoints
ct);
}
// GET /api/search?q=: поиск карточек (FTS + LIKE, Ruling 6/Task 12; dashboard_routes L254256). Ответ {leads, messages: []}.
private static async Task<IResult> SearchAsync(
string? q,
HttpContext context,
@@ -1,7 +1,7 @@
namespace Deal.Api.Endpoints;
/// <summary>
/// Тело POST /api/auth/change-password. Входящий JSON — camelCase (oldPassword, newPassword).
/// Тело POST /api/auth/change-password.
/// </summary>
/// <param name="OldPassword">Текущий пароль.</param>
/// <param name="NewPassword">Новый пароль (минимум 8 символов).</param>
@@ -1,7 +1,7 @@
namespace Deal.Api.Endpoints;
/// <summary>
/// Тело POST /api/admin/check-message (1:1 с CheckMessageBody, dashboard_routes.py L7273).
/// Тело POST /api/admin/check-message.
/// </summary>
/// <param name="Text">Текст сообщения для проверки фильтром (этап 1 + этап 2 тестера).</param>
/// <param name="Text">Текст сообщения для проверки фильтром.</param>
public sealed record CheckMessageRequest(string Text);
@@ -9,15 +9,8 @@ using Deal.Modules.Tenants.Application.Models;
namespace Deal.Api.Endpoints;
/// <summary>
/// Эндпоинты контейнеров (колонок/стадий/зон) и состояния колонок: GET/POST /api/containers,
/// PATCH /{id}/accept, PATCH/DELETE /{id}, POST /reorder, GET/PATCH state (этап 9, T4/T6).
/// Эндпоинты контейнеров
/// </summary>
/// <remarks>
/// Единый реестр контейнеров приходит на смену /api/boards + /api/columns (R5): список, создание,
/// частичное обновление, принятие ИИ-предложения, удаление с переносом карточек в inbox, reorder и
/// состояние колонок (colState). Все эндпоинты требуют сессию: 401 {detail}. ContainersService
/// резолвится из RequestServices ПОСЛЕ проверки сессии.
/// </remarks>
public static class ContainersEndpoints
{
// Префикс группы контейнеров.
@@ -42,7 +35,7 @@ public static class ContainersEndpoints
private static readonly JsonSerializerOptions RequestJsonOptions = new(JsonSerializerDefaults.Web);
/// <summary>
/// Регистрирует группы /api/containers (контейнеры + состояние колонок).
/// Регистрирует группы /api/containers
/// </summary>
/// <param name="app">Построитель маршрутов приложения.</param>
/// <returns>Построитель маршрутов для цепочки вызовов.</returns>
@@ -104,7 +97,6 @@ public static class ContainersEndpoints
Note: body.Note ?? string.Empty),
ct);
// Аудит создания контейнера (этап 10, T1).
await AuditAppender.AppendTenantAsync(context, AuditEvents.ContainerCreated, new { id = created.Id, name = created.Name }, ct);
return Results.Ok(new { id = created.Id });
}
@@ -168,7 +160,6 @@ public static class ContainersEndpoints
return EndpointResults.NotFound(ContainerNotFoundDetail);
}
// Аудит изменения контейнера (этап 10, T1).
await AuditAppender.AppendTenantAsync(context, AuditEvents.ContainerUpdated, new { id = updated.Id }, ct);
return Results.Ok(new { id = updated.Id });
}
@@ -191,7 +182,6 @@ public static class ContainersEndpoints
return EndpointResults.NotFound(ContainerNotFoundDetail);
}
// Аудит изменения контейнера (принятие ИИ-предложения) — этап 10, T1.
await AuditAppender.AppendTenantAsync(context, AuditEvents.ContainerUpdated, new { id = accepted.Id }, ct);
return Results.Ok(accepted);
}
@@ -210,7 +200,6 @@ public static class ContainersEndpoints
ContainersService containers = context.RequestServices.GetRequiredService<ContainersService>();
int moved = await containers.DeleteAsync(containerId, ct);
// Аудит удаления контейнера (этап 10, T1).
await AuditAppender.AppendTenantAsync(context, AuditEvents.ContainerDeleted, new { id = containerId }, ct);
return Results.Ok(new { ok = true, movedToInbox = moved });
}
@@ -14,27 +14,12 @@ using Deal.Modules.Telegram.Application;
namespace Deal.Api.Endpoints;
/// <summary>
/// Эндпоинты /api/discovery: задачи поиска, кандидаты, чёрный список, лог, генерация ключей (Ruling 11, api-map §3.8).
/// Эндпоинты /api/discovery
/// </summary>
/// <remarks>
/// 13 эндпоинтов 1:1 с <c>backend/app/routers/discovery_routes.py</c> (prefix /api/discovery): tasks
/// (list/create/patch/delete/start/pause), generate-keywords (мягкая ошибка HTTP 200 {keywords: [], error},
/// Ruling 11), candidates (статус-фильтр), join/reject (ручные, вне квот воркера), blacklist, log. Тела ответов —
/// DTO модуля Discovery (camelCase, §4.8 L353355) и {items: [...]} для списков (конвенция api-map §1);
/// 404-семантика сервисов (null/KeyError python) — «Задача не найдена»/«Кандидат не найден»,
/// 400-семантика — <see cref="DiscoveryValidationException"/> (ValueError python) с текстом причины 1:1.
/// Ручной join — как worker-авто-join (Task 18): RPC Join через <see cref="ITelegramGateway"/> → строка каталога
/// Dialogs (монитор on) + зеркало через <see cref="DialogsService.AddDiscoveredMonitoredAsync"/> → фоновый первый
/// разбор (<see cref="TelegramBackfillScheduler"/>, python-_spawn) → снятие чёрного списка → mark_joined(auto:false);
/// ошибка Telegram → 400 с текстом причины. Все эндпоинты требуют сессию: 401 {detail} (Ruling 10); сервисы
/// резолвятся из RequestServices ПОСЛЕ проверки сессии (scoped — TenantDbContext схемы тенанта, паттерн SettingsEndpoints).
/// </remarks>
public static class DiscoveryEndpoints
{
// Префикс группы /api/discovery (python: router prefix, discovery_routes.py L26).
private const string DiscoveryGroupPrefix = "/api/discovery";
// OpenAPI-тег группы (в прототипе роутер discovery — discovery_routes.py).
private const string DiscoveryOpenApiTag = "discovery";
// Путь списка задач (GET).
@@ -70,38 +55,28 @@ public static class DiscoveryEndpoints
// Путь лога задачи (GET).
private const string TaskLogPath = "/tasks/{task_id}/log";
// 404 create/start/patch/candidates/log: задачи нет (python _task_or_404 L8387).
private const string TaskNotFoundDetail = "Задача не найдена";
// 404 join/reject: кандидата нет (python _candidate_or_404 L9094).
private const string CandidateNotFoundDetail = "Кандидат не найден";
// 400 join: уже вступили (python L235237).
private const string AlreadyJoinedDetail = "Уже вступили в этот источник";
// 400 reject: источник уже вступили (python L256258).
private const string JoinedRejectDetail = "Уже вступили — удалите источник из каналов";
// 400 join: ошибка Telegram при вступлении (python L240242, текст с @username).
private const string JoinFailedFormat = "Не удалось вступить в @{0}: {1}";
// Причина отклонения вручную для чёрного списка/лога (python L260: reason="отклонено вручную").
private const string ManualRejectReason = "отклонено вручную";
// Мягкая ошибка generate-keywords: ИИ выключен (python _ai_unavailable_reason L99100).
private const string AiDisabledDetail = "ИИ выключен в настройках (aiEnabled)";
// Мягкая ошибка generate-keywords: описания нет (python L201202).
private const string NoDescriptionDetail = "У задачи нет описания — по нему генерируются ключи";
// Страховочный потолок числа сгенерированных ключей (python _KEYWORDS_LIMIT L31: промпт просит 10–16).
private const int KeywordsLimit = 30;
// Потолок длины одного ключа (python _KEYWORD_LENGTH_LIMIT L33: короткие фразы для поиска Telegram).
private const int KeywordLengthLimit = 60;
/// <summary>
/// Регистрирует группу /api/discovery: 13 эндпоинтов (tasks + generate-keywords + candidates + join/reject + blacklist + log).
/// Регистрирует группу /api/discovery
/// </summary>
/// <param name="app">Построитель маршрутов приложения.</param>
/// <returns>Построитель маршрутов для цепочки вызовов.</returns>
@@ -126,7 +101,6 @@ public static class DiscoveryEndpoints
return app;
}
// GET /api/discovery/tasks: список задач, старые первыми (list_tasks L141143).
private static async Task<IResult> ListTasksAsync(HttpContext context, CancellationToken ct)
{
if (!context.HasUser())
@@ -139,7 +113,6 @@ public static class DiscoveryEndpoints
return Results.Ok(new { items });
}
// POST /api/discovery/tasks: создать задачу поиска (create_task L146151; дефолты — в сервисе).
private static async Task<IResult> CreateTaskAsync(
DiscoveryTaskCreateBody body,
HttpContext context,
@@ -162,7 +135,6 @@ public static class DiscoveryEndpoints
}
}
// PATCH /api/discovery/tasks/{task_id}: обновить задачу (patch_task L154161; 404/400).
private static async Task<IResult> PatchTaskAsync(
string task_id,
DiscoveryTaskPatchBody body,
@@ -186,7 +158,6 @@ public static class DiscoveryEndpoints
}
}
// DELETE /api/discovery/tasks/{task_id}: удалить задачу с кандидатами и логом (delete_task L164168).
private static async Task<IResult> DeleteTaskAsync(
string task_id,
HttpContext context,
@@ -202,7 +173,6 @@ public static class DiscoveryEndpoints
return deleted ? Results.Ok(new { ok = true }) : EndpointResults.NotFound(TaskNotFoundDetail);
}
// POST /api/discovery/tasks/{task_id}/start: запуск поиска (start_task L171179; пустые ключи → 400).
private static async Task<IResult> StartTaskAsync(
string task_id,
HttpContext context,
@@ -225,7 +195,6 @@ public static class DiscoveryEndpoints
}
}
// POST /api/discovery/tasks/{task_id}/pause: пауза поиска (pause_task L181187).
private static async Task<IResult> PauseTaskAsync(
string task_id,
HttpContext context,
@@ -242,8 +211,6 @@ public static class DiscoveryEndpoints
}
// POST /api/discovery/tasks/{task_id}/generate-keywords: ИИ-ключи по описанию задачи
// (generate_keywords L189211). ИИ выключен/недоступен/нет описания → HTTP 200 {keywords: [], error}.
// Очистка ключей ответа — CleanKeywords (python _clean_keywords L111128: ≤30, ≤60
// символов, дедуп casefold); описание режет до 4000 сам адаптер (GrpcAiTools.MaxDescriptionCodePoints).
// Локальный режим (LocalAiTools, UseLocal=true) — NotSupportedException → та же мягкая ветка с текстом причины.
private static async Task<IResult> GenerateKeywordsAsync(
@@ -284,18 +251,15 @@ public static class DiscoveryEndpoints
return Results.Ok(new { keywords = CleanKeywords(result.Keywords) });
}
// Недоступность провайдера/сервиса — мягкая ошибка для UI (Ruling 11), HTTP 200.
return Results.Ok(new { keywords = Array.Empty<string>(), error = result.Error ?? ServiceUnavailableText });
}
catch (NotSupportedException exception)
{
// Локальный режим: ai-service не подключён — инструменты недоступны (LocalAiTools, Task 15).
return Results.Ok(new { keywords = Array.Empty<string>(), error = exception.Message });
}
}
// GET /api/discovery/tasks/{task_id}/candidates?status=: кандидаты задачи с фильтром
// new|review|joined|rejected (list_candidates L216224; невалидный статус — пустой список).
private static async Task<IResult> ListCandidatesAsync(
string task_id,
string? status,
@@ -319,7 +283,6 @@ public static class DiscoveryEndpoints
return Results.Ok(new { items });
}
// POST /api/discovery/candidates/{dialog_id}/join: ручное вступление вне квот (join_candidate L226251).
// RPC Join → строка каталога Dialogs (монитор on) + зеркало → фоновый первый разбор → снятие чёрного списка →
// mark_joined(auto:false). Ошибка Telegram → 400 с текстом причины.
private static async Task<IResult> JoinCandidateAsync(
@@ -357,12 +320,10 @@ public static class DiscoveryEndpoints
}
// Источник в каталоге (монитор on, backfilled=false) + монитор-зеркало telegram-service
// (python add_dialog_monitored L242; решение T18: локальную строку пишет Api-слой).
DialogsService dialogs = context.RequestServices.GetRequiredService<DialogsService>();
await dialogs.AddDiscoveredMonitoredAsync(dialog_id, row.Name, username, row.Kind, row.Hue, ct);
// Догон последних сообщений — в фоне: join из UI не должен висеть на паузах backfill
// (python _spawn(_backfill_quiet) L244245; источник уже в каталоге и мониторится).
context.RequestServices.GetRequiredService<TelegramBackfillScheduler>().ScheduleFirstBackfill(dialog_id);
DiscoveryBlacklistService blacklist = context.RequestServices.GetRequiredService<DiscoveryBlacklistService>();
@@ -380,7 +341,6 @@ public static class DiscoveryEndpoints
}
// POST /api/discovery/candidates/{dialog_id}/reject: отклонить кандидата — в чёрный список
// (reject_candidate L253264; уже вступившего — нельзя, 400).
private static async Task<IResult> RejectCandidateAsync(
string dialog_id,
HttpContext context,
@@ -414,7 +374,6 @@ public static class DiscoveryEndpoints
}
}
// GET /api/discovery/blacklist: чёрный список источников (list_blacklist L269271).
private static async Task<IResult> ListBlacklistAsync(HttpContext context, CancellationToken ct)
{
if (!context.HasUser())
@@ -427,7 +386,6 @@ public static class DiscoveryEndpoints
return Results.Ok(new { items });
}
// DELETE /api/discovery/blacklist/{dialog_id}: снять источник с чёрного списка (remove_blacklist L274277).
private static async Task<IResult> RemoveBlacklistAsync(
string dialog_id,
HttpContext context,
@@ -443,7 +401,6 @@ public static class DiscoveryEndpoints
return Results.Ok(new { ok = true });
}
// GET /api/discovery/tasks/{task_id}/log: лог задачи (task_log L282285), события от новых к старым.
private static async Task<IResult> TaskLogAsync(
string task_id,
HttpContext context,
@@ -467,10 +424,8 @@ public static class DiscoveryEndpoints
}
/// <summary>
/// Ключи из ответа ИИ: строки без пустых/длинных и повторов (python _clean_keywords L111128).
/// Ключи из ответа ИИ
/// </summary>
/// <remarks>Повтор считается по <c>casefold</c> python: здесь — регистронезависимое сравнение
/// (RU/EN-ключи; StringComparer.OrdinalIgnoreCase). Потолок списка — <see cref="KeywordsLimit"/>.</remarks>
/// <param name="raw">Сырые ключи ответа модели (null — пусто).</param>
/// <returns>Очищенные ключи (не более 30, каждый ≤60 символов).</returns>
public static IReadOnlyList<string> CleanKeywords(IEnumerable<string>? raw)
@@ -505,13 +460,11 @@ public static class DiscoveryEndpoints
return outList;
}
// Фолбэк-текст недоступного ИИ, если адаптер причину не вернул (мягкая ошибка, Ruling 11).
private const string ServiceUnavailableText = "ИИ недоступен — повторите попытку через несколько секунд";
// Читает настройку aiEnabled (KV; отсутствие строки — дефолт SettingsDefaults).
// settings: KV-хранилище настроек тенанта.
// ct: Токен отмены.
// Возвращает: True — ИИ включён (ветки выключателя отрабатывает вызывающий, Ruling 10/11).
private static async Task<bool> ReadAiEnabledAsync(ISettingsStore settings, CancellationToken ct)
{
SettingValue? row = await settings.GetAsync(SettingsKeys.AiEnabled, ct);
@@ -552,7 +505,6 @@ public static class DiscoveryEndpoints
};
}
// Маппит тело патча в сервисный патч (не-null значения; как python model_dump(exclude_none=True)).
// body: Тело запроса (wire-поля camelCase).
// Возвращает: Патч задачи (DiscoveryTaskPatch).
private static DiscoveryTaskPatch ToPatch(DiscoveryTaskPatchBody body)
+1 -15
View File
@@ -6,37 +6,23 @@ using Deal.Api.Services;
namespace Deal.Api.Endpoints;
/// <summary>
/// SSE-поток событий канбана: GET /api/events (Ruling 5; прототип events_routes.py L1538).
/// SSE-поток событий канбана
/// </summary>
/// <remarks>
/// Открывает <c>text/event-stream</c> с подпиской на канал тенанта сессии (singleton SseBroker).
/// События пишутся по мере поступления; при тишине 15 с отправляется ping-комментарий ": ping" —
/// соединение держится (переподключение EventSource, api.js L62104). Завершение — по отвалу клиента
/// (CancellationToken = RequestAborted); отписка — в finally. Без сессии — 401 {detail} (Ruling 10,
/// паттерн остальных эндпоинтов). Заголовки: Content-Type text/event-stream, Cache-Control: no-cache,
/// X-Accel-Buffering: no (запрет буферизации прокси, иначе ping/события задерживаются).
/// </remarks>
public static class EventsEndpoint
{
// Путь потока (роутер events, events_routes.py L12: prefix="/api").
private const string EventsPath = "/api/events";
// OpenAPI-тег группы (в прототипе — роутер events_routes.py).
private const string OpenApiTag = "events";
// Тип контента потока (events_routes.py L32).
private const string EventStreamContentType = "text/event-stream";
// Директива кеширования: поток не кешируется (events_routes.py L34).
private const string NoCacheHeaderValue = "no-cache";
// Отключение буферизации ответа nginx-прокси (events_routes.py L35).
private const string NoBufferingHeaderValue = "no";
// Ping-комментарий: строки протокола SSE, начинающиеся с ':', клиент игнорирует.
private const string PingComment = ": ping\n\n";
// Интервал ping при тишине: держим соединение (events_routes.py L24: timeout=15).
private static readonly TimeSpan PingInterval = TimeSpan.FromSeconds(15);
/// <summary>
@@ -5,29 +5,14 @@ using Deal.Modules.Settings.Application.Models;
namespace Deal.Api.Endpoints;
/// <summary>
/// Тестер фильтра входящих: POST /api/admin/check-message (Ruling 8, api-map §3.2 L109, §4.10 L364).
/// Тестер фильтра входящих
/// </summary>
/// <remarks>
/// Имитация этапов пайплайна для тестера в настройках — 1:1 с <c>dashboard_routes.py</c> L267284:
/// этап 1 считают детерминированные правила <see cref="IncomingRules"/> (<c>stage1_plain</c>, pipeline.py
/// L94–124) по настройкам тенанта; ответ — <c>{stage1:{pass,reason}, stage2:{pass,reason,skipped}, passed}</c>.
/// ИИ-фильтр этапа 2 на этапе 2 ВСЕГДА skipped (Ruling 4/8, план Task 10 L377380): если этап-1 не прошёл —
/// <c>stage2={pass:false,reason:null,skipped:true}, passed:false</c>; иначе — <c>stage2={pass:true,reason:null,
/// skipped:true}, passed:true</c> (реальный ИИ-фильтр — этап 6, ветка ошибки ИИ прототипа L281 к skipped
/// не относится — там ИИ реально зовётся). kind/kw результата правил наружу НЕ отдаются (в ответе только
/// pass/reason — как в прототипе); они нужны мониторингу отсева этапа 4.
/// Эндпоинт требует сессию: 401 {detail} (Ruling 10). IncomingRules резолвится из RequestServices ПОСЛЕ
/// проверки сессии (scoped на TenantDbContext — паттерн SettingsEndpoints/MlEndpoints).
/// </remarks>
public static class FilterTesterEndpoints
{
// Префикс группы API (общий для эндпоинтов этапа, Ruling 8).
private const string ApiGroupPrefix = "/api";
// Путь тестера фильтра входящих (dashboard_routes.py L267).
private const string CheckMessagePath = "/admin/check-message";
// OpenAPI-тег группы (эндпоинт Settings-экрана, Ruling 8).
private const string OpenApiTag = "settings";
/// <summary>
@@ -42,7 +27,6 @@ public static class FilterTesterEndpoints
return app;
}
// POST /api/admin/check-message: этап-1 правила + этап-2 (skipped) для тестера (dashboard_routes.py L267284).
private static async Task<IResult> CheckAsync(
CheckMessageRequest body,
HttpContext context,
@@ -57,7 +41,6 @@ public static class FilterTesterEndpoints
IncomingRules incomingRules = context.RequestServices.GetRequiredService<IncomingRules>();
IncomingRulesResult stage1 = await incomingRules.CheckAsync(body.Text, ct);
// Ответ 1:1 с прототипом: stage2 на этапе 2 всегда skipped (Ruling 4/8, план L377380).
if (!stage1.Pass)
{
return Results.Ok(new
+1 -15
View File
@@ -5,17 +5,8 @@ using Deal.Modules.Tenants.Application.Services;
namespace Deal.Api.Endpoints;
/// <summary>
/// Публичный эндпоинт активации инвайта: POST /api/join (Ruling 2/11 этапа 7).
/// Публичный эндпоинт активации инвайта
/// </summary>
/// <remarks>
/// Ручка не требует сессии (публичная; фронт её не вызывает — API-only, curl/будущий UI). Тело
/// {code, email, name?, password} → JoinService: валидация кода/email/пароля, CAS-резервирование инвайта,
/// создание тенанта (при пустом TenantId — с провижинингом схемы) и пользователя. Кука НЕ ставится: после
/// активации клиент входит обычным /api/auth/login (план Task 6). Успех — {ok:true, login}; ошибки — 400
/// {detail} с фиксированным текстом причины (все отказы активации — 400, включая истёкший инвайт: слой
/// эндпоинта, см. Task 6; Ruling 2 называет это «410-семантикой» — ресурс больше недоступен). Результат
/// пишется в аудит — invite_joined (актор — новый пользователь тенанта, детали email+codeHash).
/// </remarks>
public static class JoinEndpoint
{
// Путь ручки (вне группы /api/operator — публичная).
@@ -27,7 +18,6 @@ public static class JoinEndpoint
// Текст 400: приглашение с таким кодом не найдено.
private const string InviteNotFoundDetail = "Приглашение не найдено";
// Текст 400: срок действия приглашения истёк (план Task 6, Ruling 2).
private const string InviteExpiredDetail = "Срок действия приглашения истёк";
// Текст 400: приглашение уже активировано (повторная активация тем же кодом).
@@ -36,13 +26,10 @@ public static class JoinEndpoint
// Текст 400: приглашение отозвано оператором.
private const string InviteRevokedDetail = "Приглашение отозвано";
// Текст 400: email запроса не совпадает с email приглашения (Ruling 2).
private const string EmailMismatchDetail = "Email не совпадает с приглашением";
// Текст 400: пользователь с таким email уже зарегистрирован (users.login unique, Ruling 2).
private const string EmailTakenDetail = "Этот email уже зарегистрирован";
// Текст 400: пароль короче минимума (текст как в AuthEndpoints, план Task 6).
private const string PasswordTooShortDetail = "Пароль слишком короткий (минимум 8 символов)";
// Текст 400: целевой тенант инвайта не существует (Security review).
@@ -62,7 +49,6 @@ public static class JoinEndpoint
return app;
}
// POST /api/join: активация инвайта; успех пишется в аудит (invite_activated, Task 6/Ruling 4).
private static async Task<IResult> JoinAsync(
JoinRequest body,
JoinService joinService,
+1 -1
View File
@@ -1,7 +1,7 @@
namespace Deal.Api.Endpoints;
/// <summary>
/// Тело POST /api/join — активация инвайта (Ruling 2, Task 6 этапа 7). Входящий JSON — camelCase (code, email, name?, password).
/// Тело POST /api/join — активация инвайта.
/// </summary>
/// <param name="Code">Код приглашения (16 url-safe символов).</param>
/// <param name="Email">Email активирующего; обязан совпасть с email приглашения (нормализует JoinService).</param>
+1 -1
View File
@@ -1,7 +1,7 @@
namespace Deal.Api.Endpoints;
/// <summary>
/// Тело POST /api/auth/login. Входящий JSON — camelCase (login, password).
/// Тело POST /api/auth/login.
/// </summary>
/// <param name="Login">Логин пользователя.</param>
/// <param name="Password">Пароль в открытом виде.</param>
@@ -1,9 +1,9 @@
namespace Deal.Api.Endpoints;
/// <summary>
/// Тело POST /api/ml/apply. Входящий JSON — camelCase (dialogId, msgId, action).
/// Тело POST /api/ml/apply.
/// </summary>
/// <param name="DialogId">Id диалога/канала Telegram, где лежит исходное сообщение.</param>
/// <param name="MsgId">Id сообщения внутри диалога.</param>
/// <param name="Action">Ручное решение: spam | board:&lt;id&gt; | skip (api-map §3.7 L197).</param>
/// <param name="Action">Ручное решение: spam | board:&lt;id&gt; | skip.</param>
public sealed record MlApplyRequest(string DialogId, int MsgId, string Action);
@@ -1,7 +1,7 @@
namespace Deal.Api.Endpoints;
/// <summary>
/// Тело POST /api/ml/candidates. Входящий JSON — camelCase (dialogId, limit).
/// Тело POST /api/ml/candidates.
/// </summary>
/// <param name="DialogId">Id диалога/канала Telegram; пусто — выборка по всем источникам тенанта (§8).</param>
/// <param name="Limit">Сколько последних сообщений вернуть (кламп 1..60, дефолт 10).</param>
+2 -27
View File
@@ -8,27 +8,12 @@ using Deal.Modules.Pipeline.Application.Services;
namespace Deal.Api.Endpoints;
/// <summary>
/// Эндпоинты ML-панели: GET /api/ml/status, POST /api/ml/reset, /predict, /candidates, /apply (Ruling 8, api-map §3.7).
/// Эндпоинты ML-панели
/// </summary>
/// <remarks>
/// Тела ответов 1:1 с прототипом <c>backend/app/routers/ml_routes.py</c> (L6691, L112171):
/// <c>status</c> — MlStatusResponseDto (enabled/service/reachable/stats, §4.10 L363); <c>reset</c> —
/// <c>{ok:true}</c> (мягкая ошибка {ok:false,error} зарезервирована — заглушка всегда успешна);
/// <c>predict</c> — <c>{text: первые 200, take, label, scores, hits, ready, margin, terms, type}</c>
/// (текст короче 2 символов после trim → 400 «Введите текст»); <c>candidates</c> — <c>{items}</c> реальных
/// сообщений-кандидатов канала/выборки (очередь/отсев/карточки + мнение ML, §8; MlReviewService);
/// <c>apply</c> — 404 «Исходное сообщение не найдено» либо результат ручной разметки
/// <c>{ok, learned, moved, leadId}</c> (обучение ML + перенос/корзина/отсев). ml/learn и ml/flush
/// НЕ реализуются (фронт не вызывает, api-map п.9 L399). Все эндпоинты требуют сессию: 401 {detail}
/// (Ruling 10). IMlClient/MlReviewService резолвятся из RequestServices ПОСЛЕ проверки сессии (scoped
/// на tenant-запрос — вне него не разрешимы, паттерн SettingsEndpoints/AiCheckEndpoint).
/// </remarks>
public static class MlEndpoints
{
// Префикс группы /api/ml (Ruling 8: MapMlEndpoints).
private const string MlGroupPrefix = "/api/ml";
// OpenAPI-тег группы (в прототипе роутер ml — ml_routes.py).
private const string MlOpenApiTag = "ml";
// Путь статуса ML (GET).
@@ -46,20 +31,16 @@ public static class MlEndpoints
// Путь ручного решения по сообщению (POST).
private const string ApplyPath = "/apply";
// Минимальная длина текста для проверки (ml_routes.py L87: len(text) &lt; 2 → 400).
private const int MinPredictTextLength = 2;
// Длина текста в ответе predict: первые 200 символов (ml_routes.py L90 text[:200]).
private const int PredictTextPreviewLength = 200;
// Сообщение 400 для слишком короткого текста (ml_routes.py L88, план Task 9 L348).
private const string EnterTextDetail = "Введите текст";
// Сообщение 404 apply: исходное сообщение не найдено (ml_routes.py L142, план Task 9 L352).
private const string MessageNotFoundDetail = "Исходное сообщение не найдено";
/// <summary>
/// Регистрирует группу /api/ml: status/reset/predict/candidates/apply.
/// Регистрирует группу /api/ml
/// </summary>
/// <param name="app">Построитель маршрутов приложения.</param>
/// <returns>Построитель маршрутов для цепочки вызовов.</returns>
@@ -76,7 +57,6 @@ public static class MlEndpoints
return app;
}
// GET /api/ml/status: статус ML-сервиса + локальная статистика (ml_routes.py L6675).
private static async Task<IResult> StatusAsync(HttpContext context, CancellationToken ct)
{
if (!context.HasUser())
@@ -88,7 +68,6 @@ public static class MlEndpoints
return Results.Ok(await mlClient.StatusAsync(ct));
}
// POST /api/ml/reset: полный сброс модели + очистка очереди обучения (ml_routes.py L7881).
private static async Task<IResult> ResetAsync(HttpContext context, CancellationToken ct)
{
if (!context.HasUser())
@@ -100,7 +79,6 @@ public static class MlEndpoints
return Results.Ok(await mlClient.ResetAsync(ct));
}
// POST /api/ml/predict: проверка ML на тексте (ml_routes.py L8490).
private static async Task<IResult> PredictAsync(
MlPredictRequest body,
HttpContext context,
@@ -120,7 +98,6 @@ public static class MlEndpoints
IMlClient mlClient = context.RequestServices.GetRequiredService<IMlClient>();
MlPredictResultDto result = await mlClient.PredictAsync(text, ct);
// Ответ 1:1 с ml_routes.py L90: {"text": <первые 200>, **результат предсказания}.
string preview = text.Length <= PredictTextPreviewLength
? text
: text[..PredictTextPreviewLength];
@@ -138,7 +115,6 @@ public static class MlEndpoints
});
}
// POST /api/ml/candidates: последние сообщения канала + мнение ML (ml_routes.py L112134).
private static async Task<IResult> CandidatesAsync(
MlCandidatesRequest body,
HttpContext context,
@@ -154,7 +130,6 @@ public static class MlEndpoints
return Results.Ok(new { items });
}
// POST /api/ml/apply: ручное решение по сообщению (ml_routes.py L137171).
private static async Task<IResult> ApplyAsync(
MlApplyRequest body,
HttpContext context,
@@ -1,7 +1,7 @@
namespace Deal.Api.Endpoints;
/// <summary>
/// Тело POST /api/ml/predict. Входящий JSON — camelCase (text).
/// Тело POST /api/ml/predict.
/// </summary>
/// <param name="Text">Текст сообщения для проверки ML (обрезается/тримится обработчиком, как ml_routes.py L86).</param>
/// <param name="Text">Текст сообщения для проверки ML.</param>
public sealed record MlPredictRequest(string Text);
@@ -6,17 +6,10 @@ using Deal.Modules.Tenants.Application.Services;
namespace Deal.Api.Endpoints;
/// <summary>
/// Операторские read-only эндпоинты аналитики: /api/operator/analytics/{overview,tokens,activity} (этап 10, T3).
/// Операторские read-only эндпоинты аналитики
/// </summary>
/// <remarks>
/// Только под операторской сессией: без неё 401 «Требуется вход оператора» (как прочие /api/operator/*).
/// Ничего не меняет (read-only). groupBy — day|tenant|provider|model (неизвестное — 400 {detail}); from/to —
/// ISO-8601 (включительно), как у аудита; activity поддерживает фильтры eventType/actorType/actorId/tenantId,
/// limit (1..500) и offset. Все ответы — camelCase (контракт: docs/architecture/2026-09-10-operator-analytics-contract.md).
/// </remarks>
public static class OperatorAnalyticsEndpoints
{
// Префикс группы аналитики (Ruling 4 этапа 10).
private const string AnalyticsGroupPrefix = "/api/operator/analytics";
// OpenAPI-тег группы.
@@ -29,7 +22,7 @@ public static class OperatorAnalyticsEndpoints
private const string InvalidGroupByDetail = "Неизвестная группировка (day|tenant|provider|model)";
/// <summary>
/// Регистрирует группу /api/operator/analytics: overview/tokens/activity.
/// Регистрирует группу /api/operator/analytics
/// </summary>
/// <param name="app">Построитель маршрутов приложения.</param>
/// <returns>Построитель маршрутов для цепочки вызовов.</returns>
@@ -159,7 +152,7 @@ public static class OperatorAnalyticsEndpoints
}
/// <summary>
/// Известна ли группировка расхода токенов (day|tenant|provider|model).
/// Известна ли группировка расхода токенов
/// </summary>
/// <param name="groupBy">Значение группировки.</param>
/// <returns>True — поддерживаемая группировка.</returns>
@@ -6,28 +6,19 @@ using Deal.Modules.Tenants.Application.Services;
namespace Deal.Api.Endpoints;
/// <summary>
/// Операторский эндпоинт чтения аудита: GET /api/operator/audit (Ruling 4 этапа 7).
/// Операторский эндпоинт чтения аудита
/// </summary>
/// <remarks>
/// Чтение — только оператору: без операторской сессии 401 «Требуется вход оператора» (как /api/operator/auth/me).
/// Фильтры-query: eventType, actorType, tenantId, from, to, limit (At DESC, limit клампится в
/// 1..<see cref="AuditService.MaxQueryLimit"/>, дефолт — <see cref="AuditService.DefaultQueryLimit"/>).
/// Ответ — {items: [...], total}: total — полное число записей по фильтру (без учёта limit). Запись событий —
/// только через <see cref="AuditService"/> (append-only); этот эндпоинт лишь читает.
/// </remarks>
public static class OperatorAuditEndpoints
{
// Префикс группы операторских ручек /api/operator (Ruling 11).
private const string OperatorGroupPrefix = "/api/operator";
// Путь ленты аудита относительно группы.
private const string AuditPath = "/audit";
// OpenAPI-тег группы (Ruling 11: операторская админка — API-only).
private const string OperatorOpenApiTag = "operator";
/// <summary>
/// Регистрирует группу /api/operator: GET /audit (лента аудита; другие ручки — задачи 5/7/10).
/// Регистрирует группу /api/operator
/// </summary>
/// <param name="app">Построитель маршрутов приложения.</param>
/// <returns>Построитель маршрутов для цепочки вызовов.</returns>
@@ -45,7 +36,6 @@ public static class OperatorAuditEndpoints
// from: Нижняя граница At (включительно; ISO-8601).
// to: Верхняя граница At (включительно; ISO-8601).
// limit: Размер выборки (дефолт 100, клампится 1..500).
// offset: Смещение страницы (≥0; этап 10, T3).
// context: Контекст запроса.
// auditService: Сервис аудита (scoped).
// ct: Токен отмены.
@@ -77,7 +67,7 @@ public static class OperatorAuditEndpoints
}
/// <summary>
/// Нормализует limit запроса: дефолт <see cref="AuditService.DefaultQueryLimit"/>, кламп 1..500 (Ruling 4).
/// Нормализует limit запроса
/// </summary>
/// <param name="limit">Запрошенный размер выборки (null — не задан).</param>
/// <returns>Значение для фильтра.</returns>
@@ -87,7 +77,7 @@ public static class OperatorAuditEndpoints
: Math.Max(1, Math.Min(AuditService.MaxQueryLimit, limit.Value));
/// <summary>
/// Нормализует offset запроса: отрицательное/отсутствующее — 0 (этап 10, T3).
/// Нормализует offset запроса
/// </summary>
/// <param name="offset">Запрошенное смещение (null — не задано).</param>
/// <returns>Неотрицательное смещение.</returns>
@@ -12,15 +12,8 @@ using OperatorCookieOptions = Deal.Api.Configuration.OperatorCookieOptions;
namespace Deal.Api.Endpoints;
/// <summary>
/// HTTP-эндпоинты аутентификации оператора (группа /api/operator/auth). Зеркало AuthEndpoints для операторов (Ruling 1).
/// HTTP-эндпоинты аутентификации оператора
/// </summary>
/// <remarks>
/// Оператор ≠ пользователь тенанта: вход по отдельным public-таблицам (OperatorAuthService/IOperatorAuthStore),
/// сессия — в куке deal_operator_session (отдельная от deal_session; 12 ч, httpOnly, SameSite=Lax).
/// Успех-ответы — <c>{ok:true,...}</c>, ошибки — HTTP-код + <c>{"detail":"..."}</c> (Ruling 10). Защищённые
/// ручки (me) требуют операторскую сессию (401 «Требуется вход оператора») — тенантная кука не проходит.
/// Результаты входа пишутся в аудит (operator_login_ok/failed, Task 4/Ruling 4).
/// </remarks>
public static class OperatorAuthEndpoints
{
private const string InvalidCredentialsDetail = "Неверный логин или пароль оператора";
@@ -28,7 +21,7 @@ public static class OperatorAuthEndpoints
private const string OperatorAuthOpenApiTag = "operator-auth";
/// <summary>
/// Регистрирует группу /api/operator/auth: login, logout, me.
/// Регистрирует группу /api/operator/auth
/// </summary>
/// <param name="app">Построитель маршрутов приложения.</param>
/// <returns>Построитель маршрутов для цепочки вызовов.</returns>
@@ -36,7 +29,6 @@ public static class OperatorAuthEndpoints
{
var group = app.MapGroup(OperatorAuthGroupPrefix).WithTags(OperatorAuthOpenApiTag);
// Политика "auth" rate limiter (план Task 11, Ruling 5): фиксированное окно 10/мин на IP ручки
// входа оператора; остальные ручки группы — под глобальной API-политикой (по тенанту/IP).
group.MapPost("/login", LoginAsync).RequireRateLimiting(RateLimitPolicies.AuthPolicy);
group.MapPost("/logout", LogoutAsync);
@@ -45,8 +37,6 @@ public static class OperatorAuthEndpoints
return app;
}
// POST /api/operator/auth/login: проверка учётных данных оператора, выдача куки сессии; результат пишется в аудит (Task 4).
// До OperatorAuthService отрабатывает LoginAttemptGuard (5 неудач ip|login за 15 мин → 429, Ruling 5).
private static async Task<IResult> LoginAsync(
LoginRequest body,
OperatorAuthService operatorAuthService,
@@ -58,7 +48,6 @@ public static class OperatorAuthEndpoints
{
string? attemptedLogin = NormalizeLogin(body.Login);
// Защита входа оператора (план Task 11, Ruling 5): зеркало AuthEndpoints — блокировка ключа
// ip|login до проверки учётных данных (в dev при RateLimit:Enabled=false гвард выключен).
if (await loginAttemptGuard.IsBlockedAsync(ClientIp(context), attemptedLogin, ct))
{
@@ -69,7 +58,6 @@ public static class OperatorAuthEndpoints
if (result.Login is null || result.Token is null)
{
// Неверные учётные данные оператора — одно сообщение (зеркало AuthEndpoints).
// Аудит operator_login_failed — только для реальной попытки (непустой логин), без пароля (Ruling 4);
// счётчик неудач гварда растёт там же (пустые логины ключа не имеют).
if (attemptedLogin is not null)
{
@@ -86,7 +74,6 @@ public static class OperatorAuthEndpoints
return EndpointResults.Unauthorized(InvalidCredentialsDetail);
}
// Успешный вход оператора сбрасывает счётчик неудач ключа ip|login (Ruling 5).
await loginAttemptGuard.ResetAsync(ClientIp(context), result.Login, ct);
await auditService.AppendAsync(new AuditRecordDto(
@@ -115,7 +102,6 @@ public static class OperatorAuthEndpoints
await operatorAuthService.LogoutAsync(rawToken, ct);
context.Response.Cookies.Delete(cookieName);
// Выход оператора (этап 10, T1): событие пишется при живой разрешённой сессии.
if (operatorIdentity is not null)
{
await AuditAppender.AppendOperatorAsync(context, AuditEvents.OperatorLogout, new { login = operatorIdentity.Login }, ct);
@@ -124,7 +110,6 @@ public static class OperatorAuthEndpoints
return Results.Ok(new { ok = true });
}
// GET /api/operator/auth/me: проверка живой операторской сессии (401 без неё, Ruling 1).
private static IResult MeAsync(HttpContext context)
{
var operatorIdentity = context.GetCurrentOperator();
@@ -10,25 +10,10 @@ using Microsoft.EntityFrameworkCore;
namespace Deal.Api.Endpoints;
/// <summary>
/// Операторский health: GET /api/operator/health (план Task 10, Ruling 3/6/9/11) — ядро/БД и
/// автономные сервисы ml/ai/telegram.
/// Операторский health
/// </summary>
/// <remarks>
/// Ручка — только оператору (401 «Требуется вход оператора» без операторской сессии). Ответ всегда 200
/// (информационный операторский обзор, как /api/health) с полями состояния:
/// <c>{ok, core:{db:"ok"|"down"}, services:[{name, mode:"grpc"|"local", status, reachable}],
/// queues:{pipeline, mlOutbox}, sessions:{active}}</c>. Глубины очередей обработки/ML-outbox и число
/// активных сессий (§10.2) собирает общий <see cref="RuntimeDepthsCollector"/> (тот же путь, что метрики).
/// Проверка БД — <c>SELECT 1</c> через DealDbContext (public-схема) с таймаутом 5 с; сбой (контейнер не поднят/
/// сеть) → core.db=down без падения ручки. Сервисы: при <c>Services:*:UseLocal=true</c> — <c>{mode:"local",
/// reachable:false, status:"local"}</c> (Local-адаптеры, реальный сервис не поднят — Ruling 6; dev-приёмка);
/// в gRPC-режиме — <see cref="ServiceHealthProbe"/> к <c>Services:*:Endpoint</c> (grpc.health.v1, таймаут 3 с):
/// SERVING → status=ok, иной статус → unhealthy, недоступен → down. <c>ok</c> сводки — БД доступна и все
/// сервисы в порядке (Local-режим не считается сбоем).
/// </remarks>
public static class OperatorHealthEndpoints
{
// Префикс группы операторских ручек health (Ruling 11).
private const string OperatorGroupPrefix = "/api/operator";
// Путь health-ручки.
@@ -46,7 +31,6 @@ public static class OperatorHealthEndpoints
// Статус сервиса: ответил, но не SERVING (grpc NOT_SERVING/SERVICE_UNKNOWN).
private const string StatusUnhealthy = "unhealthy";
// Статус сервиса в Local-режиме: реальный сервис не подключён (UseLocal=true, Ruling 6).
private const string StatusLocal = "local";
// Режим сервиса: Local-адаптеры (UseLocal=true).
@@ -68,7 +52,7 @@ public static class OperatorHealthEndpoints
private const int DatabaseProbeTimeoutMilliseconds = 5000;
/// <summary>
/// Регистрирует GET /api/operator/health (health ядра/БД и автономных сервисов).
/// Регистрирует GET /api/operator/health
/// </summary>
/// <param name="app">Построитель маршрутов приложения.</param>
/// <returns>Построитель маршрутов для цепочки вызовов.</returns>
@@ -1,8 +1,8 @@
namespace Deal.Api.Endpoints;
/// <summary>
/// Тело POST /api/operator/invites: email приглашённого и опциональный целевой тенант (Ruling 2 этапа 7).
/// Тело POST /api/operator/invites
/// </summary>
/// <param name="Email">Email приглашённого (регистр/пробелы не важны — нормализует InvitesService).</param>
/// <param name="TenantId">Целевой тенант; null — при активации будет создан новый тенант (Task 6).</param>
/// <param name="TenantId">Целевой тенант; null — при активации будет создан новый тенант.</param>
public sealed record OperatorInviteCreateRequest(string? Email, Guid? TenantId);
@@ -6,21 +6,13 @@ using Deal.Modules.Tenants.Application.Services;
namespace Deal.Api.Endpoints;
/// <summary>
/// Операторские эндпоинты приглашений: GET /api/operator/invites, POST (создание), POST {code}/revoke (Ruling 2/11 этапа 7).
/// Операторские эндпоинты приглашений
/// </summary>
/// <remarks>
/// Создание/отзыв/чтение — только оператор: без операторской сессии 401 «Требуется вход оператора»
/// (как /api/operator/auth/me). Создание возвращает {code, email, tenantId, expiresAt, status} (план Task 5),
/// список — {items:[...]} (полные строки; форма как у GET /api/operator/audit), отзыв — {ok:true}. Результаты
/// пишутся в аудит — invite_created/invite_revoked с email и codeHash в DetailJson (Ruling 4; операторские события,
/// TenantId null; хэш кода — Security review). Тексты ошибок — фиксированные строки HTTP-слоя (паттерн AuthEndpoints).
/// </remarks>
public static class OperatorInvitesEndpoints
{
// Текст 400: email пустой/некорректного формата.
private const string InvalidEmailDetail = "Некорректный email";
// Текст 400: на email уже есть активное приглашение (план Task 5, Ruling 2).
private const string DuplicateActiveDetail = "Для этого email уже есть активное приглашение";
// Текст 404: приглашение с таким кодом не найдено.
@@ -29,7 +21,6 @@ public static class OperatorInvitesEndpoints
// Текст 400: отзыв приглашения не в статусе pending (уже отозвано/использовано/истекло).
private const string InviteNotPendingDetail = "Отозвать можно только ожидающее активации приглашение";
// Префикс группы операторских ручек приглашений (Ruling 11).
private const string InvitesGroupPrefix = "/api/operator/invites";
// Относительный путь отзыва приглашения.
@@ -39,7 +30,7 @@ public static class OperatorInvitesEndpoints
private const string InvitesOpenApiTag = "operator-invites";
/// <summary>
/// Регистрирует группу /api/operator/invites: GET (список), POST (создание), POST {code}/revoke (отзыв).
/// Регистрирует группу /api/operator/invites
/// </summary>
/// <param name="app">Построитель маршрутов приложения.</param>
/// <returns>Построитель маршрутов для цепочки вызовов.</returns>
@@ -1,9 +1,7 @@
namespace Deal.Api.Endpoints;
/// <summary>
/// Тело PATCH /api/operator/tenants/{id}/limit: смена лимитов ИИ-бюджета тенанта (план Task 10, Ruling 3).
/// Оба поля опциональны — меняется только заданное; смена бюджета/периода сбрасывает флаги Warned80/
/// NotifiedExhausted (новый период открывает пороги тостов, один тост на период на порог, Ruling 3).
/// Тело PATCH /api/operator/tenants/{id}/limit
/// </summary>
/// <param name="Budget">Новый бюджет периода в токенах (≥0; 0 — ИИ запрещён); null — оставить текущий.</param>
/// <param name="Period">Новый тип периода (константа <c>TenantLimitPeriods</c>: month|day); null — оставить текущий.</param>
@@ -7,21 +7,8 @@ using Deal.Modules.Tenants.Application.Services;
namespace Deal.Api.Endpoints;
/// <summary>
/// Операторские эндпоинты лимитов ИИ-бюджета: сводка по всем тенантам и просмотр/смена лимита тенанта
/// (план Task 10, Ruling 3/11).
/// Операторские эндпоинты лимитов ИИ-бюджета
/// </summary>
/// <remarks>
/// Все ручки — только оператору: без операторской сессии 401 «Требуется вход оператора» (как остальные
/// /api/operator/*). GET /api/operator/limits — сводка {items:[{tenantId, name, budget, period, used, percent,
/// status}]} по реестру тенантов (Ruling 3: строка лимита на путь чтения заводится лениво с дефолт-бюджетом —
/// тенант без расхода виден как «дефолт, 0»). GET/PATCH /api/operator/tenants/{id}/limit — детали/смена лимита:
/// PATCH принимает {budget?, period?} (оба опциональны — меняется только заданное; null-тело/без полей → 400),
/// сбрасывает Warned80/NotifiedExhausted через UpdateBudgetAsync (Ruling 3: смена бюджета открывает пороги
/// тостов заново) и пишет аудит tenant_limit_changed (только при реальном изменении — повторный PATCH с теми же
/// значениями идемпотентен, аудит не дублируется). Отрицательный бюджет/чужой период отсекаются 400 до вызова
/// хранилища; тенант проверяется по реестру (404 «Тенант не найден»). Ответы деталей — единая форма
/// (см. <see cref="BuildDetailDto"/>) — статус тенанта, флаги порогов и процент расхода.
/// </remarks>
public static class OperatorLimitsEndpoints
{
// Текст 400: PATCH без полей (null-тело/пустой объект).
@@ -39,10 +26,8 @@ public static class OperatorLimitsEndpoints
// Верхняя граница процента расхода (диапазон 0..100) — константа расчёта CalculatePercent.
private const int PercentMax = 100;
// Префикс сводки лимитов (Ruling 11: /api/operator/*).
private const string OperatorGroupPrefix = "/api/operator";
// Префикс группы операторских ручек тенантов (общий с Task 7).
private const string TenantsGroupPrefix = "/api/operator/tenants";
// Путь сводки лимитов по всем тенантам.
@@ -54,12 +39,10 @@ public static class OperatorLimitsEndpoints
// OpenAPI-тег группы сводки лимитов.
private const string LimitsOpenApiTag = "operator-limits";
// Без состояния, поэтому безопасен как статический экземпляр (период-математика Task 8).
private static readonly TokenBudgetService BudgetService = new();
/// <summary>
/// Регистрирует ручки лимитов: GET /api/operator/limits (сводка) и GET/PATCH
/// /api/operator/tenants/{id}/limit (детали/смена).
/// Регистрирует ручки лимитов
/// </summary>
/// <param name="app">Построитель маршрутов приложения.</param>
/// <returns>Построитель маршрутов для цепочки вызовов.</returns>
@@ -72,7 +55,6 @@ public static class OperatorLimitsEndpoints
return app;
}
// GET /api/operator/limits: сводка бюджета/расхода по всем тенантам (план Task 10).
private static async Task<IResult> ListSummaryAsync(
HttpContext context,
ITenantRepository tenantRepository,
@@ -89,7 +71,6 @@ public static class OperatorLimitsEndpoints
var items = new List<object>(tenants.Count);
foreach (TenantRecordDto tenant in tenants)
{
// Ленивый reset периода внутри GetStateAsync (Ruling 3): сводка всегда про текущий период.
BudgetStateDto state = await limitStore.GetStateAsync(tenant.Id, ct);
items.Add(new
{
@@ -199,14 +180,8 @@ public static class OperatorLimitsEndpoints
}
/// <summary>
/// Процент расхода бюджета для операторской сводки/деталей (0..100, floor).
/// Процент расхода бюджета для операторской сводки/деталей
/// </summary>
/// <remarks>
/// Бюджет ≤0 трактуется как исчерпанный (лимит 0 запрещает ИИ, Ruling 3) → 100%; расход ≥ бюджета также
/// показывается как 100 (потолок индикатора). Расчёт — в double: диапазон long (до ~9.2·10¹⁸ токенов)
/// не переполняет double, floor-ошибка возможна только на границе целого при масштабах, нереальных для
/// бюджета токенов (целочисленный used·100/budget переполнялся бы при used &gt; ~9.2·10¹⁶).
/// </remarks>
/// <param name="usedTokens">Использовано токенов с начала периода.</param>
/// <param name="budgetTokens">Бюджет периода.</param>
/// <returns>Процент в диапазоне 0..100 (расход сверх бюджета показывается как 100).</returns>
@@ -5,16 +5,8 @@ using Deal.Infrastructure.Tenancy;
namespace Deal.Api.Endpoints;
/// <summary>
/// Операторские maintenance-ручки (этап 12, пакет C): пакетная миграция схем всех тенантов.
/// Операторские maintenance-ручки
/// </summary>
/// <remarks>
/// Ручка — только оператору (401 «Требуется вход оператора» без операторской сессии).
/// POST /api/operator/maintenance/tenants/migrate — идемпотентно проводит провижининг/миграции схем ВСЕХ
/// тенантов реестра (CREATE SCHEMA IF NOT EXISTS + EF Migrate, применяющий только неприменённые миграции)
/// с ограниченным параллелизмом и логированием прогресса (<see cref="TenantSchemaMigrationService"/>).
/// Ответ <c>{ok, total, migrated, failed, failedSchemas, durationMs}</c>; ok=false, если хотя бы одна схема
/// не мигрирована (сбой одной не прерывает остальные — оператор видит список проблемных схем).
/// </remarks>
public static class OperatorMaintenanceEndpoints
{
// Префикс группы операторских maintenance-ручек.
@@ -27,7 +19,7 @@ public static class OperatorMaintenanceEndpoints
private const string MaintenanceOpenApiTag = "operator-maintenance";
/// <summary>
/// Регистрирует группу /api/operator/maintenance: пакетная миграция схем тенантов.
/// Регистрирует группу /api/operator/maintenance
/// </summary>
/// <param name="app">Построитель маршрутов приложения.</param>
/// <returns>Построитель маршрутов для цепочки вызовов.</returns>
@@ -8,21 +8,8 @@ using Deal.Modules.Tenants.Application.Services;
namespace Deal.Api.Endpoints;
/// <summary>
/// Операторские ручки глобальных (системных) настроек: ключи приложения Telegram
/// (ТЗ §4.1/§8.1).
/// Операторские ручки глобальных
/// </summary>
/// <remarks>
/// Все ручки — только под операторской сессией: без неё 401 «Требуется вход оператора». Ключи Telegram
/// задаёт оператор глобально (едины для всех тенантов), тенант их не видит и не задаёт.
/// <list type="bullet">
/// <item>GET /api/operator/settings/telegram-keys — маскированный снимок: apiId (не секрет, открыт),
/// apiHash (маска) и keysSet;</item>
/// <item>PUT /api/operator/settings/telegram-keys {apiId?, apiHash?} — частичное сохранение (можно
/// передать только одно поле, второе сохраняется); валидация (api_id 5..9 цифр, api_hash непустой),
/// шифрование секрета и аудит telegram_keys_changed (без секретов в деталях).</item>
/// </list>
/// Ошибки — 400/401 <c>{detail}</c> (формат прототипа, Ruling 10).
/// </remarks>
public static class OperatorSettingsEndpoints
{
// Префикс группы операторских настроек.
@@ -47,7 +34,7 @@ public static class OperatorSettingsEndpoints
private const string InvalidApiHashDetail = "Укажите непустой api_hash";
/// <summary>
/// Регистрирует группу /api/operator/settings: telegram-keys (GET/PUT).
/// Регистрирует группу /api/operator/settings
/// </summary>
/// <param name="app">Построитель маршрутов приложения.</param>
/// <returns>Построитель маршрутов для цепочки вызовов.</returns>
@@ -135,7 +122,6 @@ public static class OperatorSettingsEndpoints
await keys.SaveAsync(effectiveApiId, effectiveApiHash, ct);
// Аудит смены глобальных ключей: apiId — не секрет, apiHash в детали не пишется (Ruling 4).
await auditService.AppendAsync(new AuditRecordDto(
AuditEvents.TelegramKeysChanged,
AuditActorTypes.Operator,
@@ -1,9 +1,8 @@
namespace Deal.Api.Endpoints;
/// <summary>
/// Тело POST /api/operator/tenants: создание тенанта оператором (план Task 7, Ruling 11).
/// Тело POST /api/operator/tenants
/// </summary>
/// <param name="Name">Имя тенанта (обязательно; пробелы по краям обрезаются).</param>
/// <param name="Email">Email владельца (опционально): создаёт сразу пользователя-владельца с одноразовым
/// паролем (иначе владелец заводится инвайтом, Ruling 2).</param>
/// <param name="Email">Email владельца (опционально): создаёт сразу пользователя-владельца с одноразовым паролем.</param>
public sealed record OperatorTenantCreateRequest(string? Name, string? Email);
@@ -1,8 +1,7 @@
namespace Deal.Api.Endpoints;
/// <summary>
/// Тело POST /api/operator/tenants/{id}/impersonate: опциональный логин пользователя тенанта (план Task 7).
/// Тело POST /api/operator/tenants/{id}/impersonate
/// </summary>
/// <param name="Login">Логин пользователя, под которым оператор входит (impersonation); null/пустой —
/// берётся первый пользователь тенанта (по времени создания).</param>
/// <param name="Login">Логин пользователя, под которым оператор входит (impersonation); null/пустой — берётся первый пользователь тенанта (по времени создания).</param>
public sealed record OperatorTenantImpersonateRequest(string? Login);
@@ -8,24 +8,8 @@ using CookieOptions = Deal.Api.Configuration.CookieOptions;
namespace Deal.Api.Endpoints;
/// <summary>
/// Операторские эндпоинты тенантов: create/список/детали, suspend/unsuspend, impersonation (план Task 7, Ruling 1/4/10/11).
/// Операторские эндпоинты тенантов
/// </summary>
/// <remarks>
/// Все ручки — только оператору: без операторской сессии 401 «Требуется вход оператора» (как остальные
/// /api/operator/*). POST "" (create {name, email?}) — тенант (Status active) + провижининг схемы + аудит
/// tenant_created; список — {items:[...]} (реестр + счётчик пользователей; поля лимитов добавит Task 8),
/// детали — {id,name,status,createdAt,users:[...]}. Suspend/unsuspend меняют Status тенанта
/// (TenantAdminService) и пишут аудит tenant_status_changed (только при реальном изменении — повторный
/// suspend идемпотентен). Impersonation выпускает tenant-сессию целевого пользователя
/// (AuthService, механизм обычного входа; пароль не меняется) и возвращает {sessionToken, expiresAt,
/// tenantId, login} — токен используется как значение куки deal_session; аудит impersonation_started
/// (DetailJson: targetLogin, tenantId), завершение — logout'ом пользователя (impersonation_stopped в
/// AuthEndpoints). Зафиксированные решения Task 7: suspend-гейт отвечает 403 (не 401; см. AuthEndpoints),
/// impersonation suspended-тенанта разрешён (аудируется; ИИ заморожен гейтом Task 9), PATCH {status} плана
/// заменён на явные POST /suspend|/unsuspend, budget? при create не принимается до Task 8/10 — отклонения
/// для api-map/техдок Task 16 зафиксированы в task-7-report.md. Тексты ошибок — фиксированные строки
/// HTTP-слоя (паттерн OperatorInvitesEndpoints).
/// </remarks>
public static class OperatorTenantsEndpoints
{
// Текст 400: имя тенанта пустое/пробельное (create).
@@ -46,7 +30,6 @@ public static class OperatorTenantsEndpoints
// Текст 400: в тенанте нет пользователей, а login не указан (impersonation без выбора).
private const string TenantHasNoUsersDetail = "В тенанте нет пользователей для входа";
// Префикс группы операторских ручек тенантов (Ruling 11).
private const string TenantsGroupPrefix = "/api/operator/tenants";
// Относительный путь деталей тенанта.
@@ -65,7 +48,7 @@ public static class OperatorTenantsEndpoints
private const string TenantsOpenApiTag = "operator-tenants";
/// <summary>
/// Регистрирует группу /api/operator/tenants: список, create, детали, suspend/unsuspend, impersonate.
/// Регистрирует группу /api/operator/tenants
/// </summary>
/// <param name="app">Построитель маршрутов приложения.</param>
/// <returns>Построитель маршрутов для цепочки вызовов.</returns>
@@ -83,7 +66,6 @@ public static class OperatorTenantsEndpoints
return app;
}
// GET /api/operator/tenants: список тенантов со счётчиками пользователей (план Task 7).
private static async Task<IResult> ListAsync(
HttpContext context,
TenantAdminService tenantAdminService,
@@ -100,8 +82,6 @@ public static class OperatorTenantsEndpoints
}
// POST /api/operator/tenants: создание тенанта (Status active + провижининг схемы); аудит tenant_created.
// Решение Task 7: PATCH {status} заменён на явные POST /suspend и /unsuspend — create принимает только
// {name, email?}; budget?/лимиты — зона Task 8/10 (прецедент: join-строка лимитов отложена в Task 6).
private static async Task<IResult> CreateAsync(
OperatorTenantCreateRequest body,
HttpContext context,
@@ -7,28 +7,12 @@ using Deal.Modules.Pipeline.Application.Services;
namespace Deal.Api.Endpoints;
/// <summary>
/// Эндпоинты вкладки «Обработка»: GET /api/pipeline/stats|queue|rejected, POST /api/pipeline/rejected/clear,
/// DELETE /api/pipeline/rejected/{rejId}, POST /api/pipeline/rejected/{rejId}/return (план Task 9 L437460,
/// Rulings 6/10; прототип processing_routes.py L1774).
/// Эндпоинты вкладки «Обработка»
/// </summary>
/// <remarks>
/// Контракт 1:1 с прототипом и api-map §3.6 L178186, §4.5: /stats → {queue:{new,ai,total}, rejected};
/// /queue?limit= → {items, counts:{new,ai,total}, rejected} (limit ≤500, дефолт 100, фронт шлёт 120);
/// /rejected?q=&amp;offset=&amp;limit= → {items, total, offset, limit} (q — FTS LIKE-поиск, Ruling 6);
/// /rejected/clear → {ok, cleared}; DELETE /rejected/{rejId} → {ok:true} всегда (delete_one L196198, 404 не
/// шлём — Ruling 10); /rejected/{rejId}/return {reason=""} → {id, returned:true, returnedAt} | 400 (строки
/// Ruling 10) | 404 «Запись не найдена» (текст 404 — слой эндпоинтов, паттерн CardsService → LeadsEndpoints).
/// Все эндпоинты требуют сессию: 401 {detail} без куки (Ruling 10); сервисы модуля резолвятся из
/// RequestServices ПОСЛЕ проверки сессии (scoped на tenant-контекст запроса, паттерн SettingsEndpoints).
/// Статические сегменты (/stats, /queue, /rejected/clear) до параметризованного /rejected/{rejId} — порядок
/// как в прототипе (api-map L19), хотя литералы имеют приоритет в ASP.NET Core.
/// </remarks>
public static class PipelineEndpoints
{
// Префикс группы (роутер processing, prefix="/api/pipeline" — processing_routes.py L10).
private const string PipelineGroupPrefix = "/api/pipeline";
// OpenAPI-тег группы (в прототипе роутер processing — processing_routes.py L10).
private const string OpenApiTag = "processing";
// Путь сводки вкладки «Обработка» (GET).
@@ -49,14 +33,12 @@ public static class PipelineEndpoints
// Путь возврата записи отсева в обработку (POST).
private const string RejectedReturnPath = "/rejected/{rejId}/return";
// 404 return: записи отсева нет (processing_routes.py L71: KeyError → 404, Ruling 10).
private const string RejectedNotFoundDetail = "Запись не найдена";
// Размер страницы по умолчанию списков очереди/отсева (processing.DEFAULT_LIMIT L48; фронт шлёт 120/80).
private const int DefaultPageSize = 100;
/// <summary>
/// Регистрирует группу /api/pipeline: stats/queue/rejected/clear/{rejId}/return.
/// Регистрирует группу /api/pipeline
/// </summary>
/// <param name="app">Построитель маршрутов приложения.</param>
/// <returns>Построитель маршрутов для цепочки вызовов.</returns>
@@ -64,7 +46,6 @@ public static class PipelineEndpoints
{
var pipeline = app.MapGroup(PipelineGroupPrefix).WithTags(OpenApiTag);
// Статические сегменты до /rejected/{rejId} (Ruling 10, api-map L19; порядок 1:1 с прототипом).
pipeline.MapGet(StatsPath, StatsAsync);
pipeline.MapGet(QueuePath, QueueAsync);
pipeline.MapGet(RejectedPath, RejectedAsync);
@@ -75,7 +56,6 @@ public static class PipelineEndpoints
return app;
}
// GET /api/pipeline/stats: сводка вкладки {queue:{new,ai,total}, rejected} (processing_routes.py L1720, stats L315320).
private static async Task<IResult> StatsAsync(HttpContext context, CancellationToken ct)
{
if (!context.HasUser())
@@ -87,9 +67,6 @@ public static class PipelineEndpoints
return Results.Ok(await processing.StatsAsync(ct));
}
// GET /api/pipeline/queue?limit=: сырые сообщения очереди + счётчики + число отсева (processing_routes.py L2331).
// Ответ {items, counts:{new,ai,total}, rejected} 1:1 с list_queue L218241 + queue_counts L207215 +
// rejected_count L201202. limit — дефолт 100, clamp 1..500 делает сервис (ListQueueAsync).
private static async Task<IResult> QueueAsync(
int? limit,
HttpContext context,
@@ -107,8 +84,6 @@ public static class PipelineEndpoints
return Results.Ok(new { items, counts, rejected });
}
// GET /api/pipeline/rejected?q=&amp;offset=&amp;limit=: страница отсева (processing_routes.py L3442, list_rejected L246312).
// q — поиск по тексту/причине/фразе/имени канала (FTS LIKE, Ruling 6), пустой q — весь отсев свежими
// первыми; offset ≥ 0, limit 1..500 (clamp в сервисе), значения эхом в ответе {items,total,offset,limit}.
private static async Task<IResult> RejectedAsync(
string? q,
@@ -127,7 +102,6 @@ public static class PipelineEndpoints
return Results.Ok(page);
}
// POST /api/pipeline/rejected/clear: полная безвозвратная очистка отсева (processing_routes.py L4549, clear_all L120125).
private static async Task<IResult> ClearAsync(HttpContext context, CancellationToken ct)
{
if (!context.HasUser())
@@ -140,8 +114,6 @@ public static class PipelineEndpoints
return Results.Ok(new { ok = true, cleared });
}
// DELETE /api/pipeline/rejected/{rejId}: удалить запись отсева; ответ {ok:true} всегда (delete_one L196198, Ruling 10).
// Прототип не проверяет наличие записи — 404 не шлём (план Task 9 L444; Ruling 10 «always ok»).
private static async Task<IResult> DeleteAsync(
string rejId,
HttpContext context,
@@ -157,10 +129,6 @@ public static class PipelineEndpoints
return Results.Ok(new { ok = true });
}
// POST /api/pipeline/rejected/{rejId}/return {reason=""}: вернуть отсеянное в обработку (return_to_queue L128193).
// Успех — {id, returned:true, returnedAt} (запись помечается returned, НЕ удаляется — аудит Ruling 10);
// причины 400 (уже возвращено/повтор-dup/нет текста) — константы PipelineProcessingService (строки 1:1 с
// прототипом); записи нет — 404 «Запись не найдена» (текст 404 — слой эндпоинтов).
private static async Task<IResult> ReturnAsync(
string rejId,
ReturnReasonRequest body,
+1 -15
View File
@@ -6,21 +6,10 @@ using Deal.Modules.Settings.Application.Services;
namespace Deal.Api.Endpoints;
/// <summary>
/// HTTP-эндпоинты курсов валют: GET /api/rates, POST /api/rates/refresh (Ruling 8, api-map §3.4 L149150).
/// HTTP-эндпоинты курсов валют
/// </summary>
/// <remarks>
/// «Только для Settings-экрана» (Ruling 8): фронт читает курсы на boot (store.js L571581) и обновляет
/// по кнопке (refreshRates L18431848). GET — текущий кэш (ratesCache) или дефолт-мок; при протухании/
/// смене источника/отсутствии кэша (Ruling 6) фоново запускает RefreshAsync через
/// <see cref="RatesRefreshScheduler"/> и отвечает текущим кэшем (план Task 8 L317318). POST — синхронный
/// refresh 1:1 с прототипом: <c>{ok: bool, rates: {base, rates, source, updatedAt}}</c> (ok=false при сбое
/// ЦБ, кэш не тронут). Оба требуют сессию: 401 {detail} (Ruling 10). Резолв scoped-зависимостей — через
/// RequestServices ПОСЛЕ проверки сессии (как SettingsEndpoints/AiCheckEndpoint: ISettingsStore требует
/// tenant-контекст запроса).
/// </remarks>
public static class RatesEndpoints
{
// Префикс группы API (общий для эндпоинтов этапа, Ruling 8).
private const string ApiGroupPrefix = "/api";
// Путь текущих курсов (GET).
@@ -29,7 +18,6 @@ public static class RatesEndpoints
// Путь принудительного обновления (POST).
private const string RatesRefreshPath = "/rates/refresh";
// OpenAPI-тег группы (в прототипе роутер settings — settings_routes.py).
private const string OpenApiTag = "settings";
/// <summary>
@@ -58,7 +46,6 @@ public static class RatesEndpoints
RatesDto current = await ratesService.GetAsync(ct);
// Ленивое обновление (Ruling 6, план Task 8): протухший кэш / смена источника / нет кэша —
// фоновый RefreshAsync в отдельном scope; ответ — текущий кэш.
if (await ratesService.ShouldFetchAsync(ct))
{
@@ -68,7 +55,6 @@ public static class RatesEndpoints
return Results.Ok(current);
}
// POST /api/rates/refresh: принудительное обновление; ответ {ok, rates} (1:1 settings_routes.py L229232).
private static async Task<IResult> RefreshRatesAsync(HttpContext context, CancellationToken ct)
{
if (!context.HasUser())
@@ -1,13 +1,8 @@
namespace Deal.Api.Endpoints.RequestModels;
/// <summary>
/// Тело POST /api/cards/{cardId}/links — добавление ссылки карточке (add_link L133143).
/// Тело POST /api/cards/{cardId}/links — добавление ссылки карточке.
/// </summary>
/// <remarks>
/// Wire-имена — camelCase: name (пустой по умолчанию) и url. url Trim'ится; пустой url → 400 «Пустая
/// ссылка» (валидация CardsService.AddLinkAsync); url без схемы http://https:// получает префикс https://.
/// Пустое name → ссылка называется url. Ответ — карточка после мутации.
/// </remarks>
/// <param name="Name">Название ссылки; пустое → name = url.</param>
/// <param name="Url">URL ссылки (без схемы — добавится https://).</param>
public sealed record CardLinkRequest(string? Name = null, string? Url = null);
@@ -1,10 +1,6 @@
namespace Deal.Api.Endpoints.RequestModels;
/// <summary>
/// Тело POST /api/cards/clear-col — полная очистка служебной колонки (dashboard_routes.py ClearColBody L224225, api-map §3.2 L93).
/// Тело POST /api/cards/clear-col — полная очистка служебной колонки.
/// </summary>
/// <remarks>
/// Wire-имя — camelCase: col — "trash" | "archive" (другие колонки/отсутствие значения → 400
/// «Очищать можно только корзину или архив», валидация CardsService.ClearColAsync L237247).
/// </remarks>
public sealed record ClearColBody(string? Col);
@@ -1,11 +1,6 @@
namespace Deal.Api.Endpoints.RequestModels;
/// <summary>
/// Тело PATCH /api/containers/{containerId}/state — смена состояния колонки (этап 9, T4).
/// Тело PATCH /api/containers/{containerId}/state — смена состояния колонки.
/// </summary>
/// <remarks>
/// Wire-имена — camelCase: collapsed (bool) / width ("sm"|"md"|"lg"). Поле со значением null (либо
/// отсутствующее) текущее значение не меняет (прототип model_dump(exclude_none=True) + merge в текущее
/// состояние колонки, L146–149). Пустой патч {} сохраняет текущее состояние (для новой колонки — {}).
/// </remarks>
public sealed record ColStateBody(bool? Collapsed, string? Width);
@@ -1,10 +1,6 @@
namespace Deal.Api.Endpoints.RequestModels;
/// <summary>
/// Тело POST /api/cards/{cardId}/comments — добавление комментария (dashboard_routes.py CommentBody L6061).
/// Тело POST /api/cards/{cardId}/comments — добавление комментария.
/// </summary>
/// <remarks>
/// Wire-имя — camelCase: text. Пустой/пробельный текст либо явный null → 400 «Пустой комментарий»
/// (валидация CardsService.AddCommentAsync, 1:1 с dashboard_routes L240241).
/// </remarks>
public sealed record CommentBody(string? Text);
@@ -3,14 +3,8 @@ using Deal.Modules.Kanban.Application.Models;
namespace Deal.Api.Endpoints.RequestModels;
/// <summary>
/// Тело POST /api/containers — создание контейнера (этап 9, T4).
/// Тело POST /api/containers — создание контейнера.
/// </summary>
/// <remarks>
/// Wire-имена — camelCase: name/description/color/space/kind/suggested/note/rules. Name — обязательное:
/// отсутствие либо явный null → 400 «Укажите название колонки»; пустая строка допустима — сервис
/// подставит «Новая колонка». Description/Note имеют дефолт "". Отсутствующие группы правил
/// трактуются как пустые (null-устойчивость).
/// </remarks>
/// <param name="Name">Имя контейнера (обязательно).</param>
/// <param name="Description">Описание (опционально).</param>
/// <param name="Color">Цвет (опционально; null — палитра).</param>
@@ -3,13 +3,8 @@ using Deal.Modules.Kanban.Application.Models;
namespace Deal.Api.Endpoints.RequestModels;
/// <summary>
/// Тело PATCH /api/containers/{id} — частичное обновление контейнера (этап 9, T4).
/// Тело PATCH /api/containers/{id} — частичное обновление контейнера.
/// </summary>
/// <remarks>
/// Wire-имена — camelCase: name/description/color/collapsed/suggested/note/rules/policy. Поле со
/// значением null означает «не менять»; исключение — ЯВНЫЙ null у name → 400 «Укажите название колонки».
/// JSON-объекты rules/policy заменяются целиком.
/// </remarks>
/// <param name="Name">Новое имя (null — не менять).</param>
/// <param name="Description">Новое описание (null — не менять).</param>
/// <param name="Color">Новый цвет (null — не менять).</param>
@@ -3,14 +3,9 @@ using Deal.Modules.Kanban.Application.Models;
namespace Deal.Api.Endpoints.RequestModels;
/// <summary>
/// Тело POST /api/cards — ручное («локальное») создание карточки (этап 9, T6).
/// Тело POST /api/cards — ручное
/// </summary>
/// <remarks>
/// Wire-имена — camelCase: title/summary/stack/budget/contact/tzText/containerId (алиас stage).
/// containerId — id контейнера (стадии/доски); неизвестный/отсутствующий не отвергается: карточка
/// создаётся в planned. budget — объект {from,to,cur} либо null.
/// </remarks>
/// <param name="Title">Заголовок карточки (Trim() в сервисе; пустой допустим).</param>
/// <param name="Title">Заголовок карточки (Trim в сервисе; пустой допустим).</param>
/// <param name="Summary">Краткое содержание карточки.</param>
/// <param name="Stack">Стек/направления (null — пустой стек).</param>
/// <param name="Budget">Бюджет (from/to/cur); null — бюджета нет.</param>
@@ -1,28 +1,22 @@
namespace Deal.Api.Endpoints.RequestModels;
/// <summary>
/// Тело POST /api/discovery/tasks (TaskCreate discovery_routes.py L5060; api-map §3.8 L204).
/// Тело POST /api/discovery/tasks.
/// </summary>
/// <remarks>
/// Wire-имена — camelCase: name/description/keywords/minSubscribers/lang/threshold/sampleSize/planJoins/autoJoin.
/// Значения-дефолты pydantic повторяет сервис DiscoveryTasksService: description/keywords — пустые, lang — «ru»,
/// minSubscribers — 0, planJoins — 1, autoJoin — false; threshold/sampleSize — из настроек (null → дефолт).
/// name — единственное поле без дефолта: пустое/пробельное значение → 400 «Укажите название задачи».
/// </remarks>
public sealed record DiscoveryTaskCreateBody
{
/// <summary>
/// Название задачи (обязательное; Trim, пустое → 400).
/// Название задачи
/// </summary>
public string Name { get; init; } = string.Empty;
/// <summary>
/// Описание ниши/цели (источник для generate-keywords); null → пустая строка.
/// Описание ниши/цели
/// </summary>
public string? Description { get; init; }
/// <summary>
/// Ключевые слова поиска; null/пусто — список пуст (start до добавления ключей → 400).
/// Ключевые слова поиска; null/пусто — список пуст
/// </summary>
public IReadOnlyList<string>? Keywords { get; init; }
@@ -32,22 +26,22 @@ public sealed record DiscoveryTaskCreateBody
public int? MinSubscribers { get; init; }
/// <summary>
/// Язык источников: «ru»|«any»; null/иное → «ru» (нормализует сервис).
/// Язык источников
/// </summary>
public string? Lang { get; init; }
/// <summary>
/// Порог подходящих сообщений оценки, % (кламп 1..100); null → discEvalThreshold.
/// Порог подходящих сообщений оценки, %
/// </summary>
public int? Threshold { get; init; }
/// <summary>
/// Размер выборки сообщений оценки (кламп ≥1); null → discEvalSample.
/// Размер выборки сообщений оценки
/// </summary>
public int? SampleSize { get; init; }
/// <summary>
/// План авто-вступлений (1..discJoinLimit + бюджет); null → 1.
/// План авто-вступлений
/// </summary>
public int? PlanJoins { get; init; }
@@ -1,57 +1,52 @@
namespace Deal.Api.Endpoints.RequestModels;
/// <summary>
/// Тело PATCH /api/discovery/tasks/{task_id} (TaskPatch discovery_routes.py L6271; api-map §3.8 L205).
/// Тело PATCH /api/discovery/tasks/{task_id}.
/// </summary>
/// <remarks>
/// Wire-имена — camelCase; все поля optional: null/отсутствующее поле не меняется (в DiscoveryTaskPatch
/// пробрасываются только не-null значения, как python model_dump(exclude_none=True)). keywords — полная замена
/// списка (пустой список очищает ключи); увеличение planJoins проверяется план-бюджетом.
/// </remarks>
public sealed record DiscoveryTaskPatchBody
{
/// <summary>
/// Новое название (после Trim; пустое допустимо на patch — 1:1 прототип).
/// Новое название.
/// </summary>
public string? Name { get; init; }
/// <summary>
/// Новое описание (пустая строка очищает).
/// Новое описание
/// </summary>
public string? Description { get; init; }
/// <summary>
/// Новые ключевые слова (полная замена; null — не менять).
/// Новые ключевые слова
/// </summary>
public IReadOnlyList<string>? Keywords { get; init; }
/// <summary>
/// Новый минимум участников (кламп ≥0).
/// Новый минимум участников
/// </summary>
public int? MinSubscribers { get; init; }
/// <summary>
/// Новый язык: «ru»|«any» (иное → «ru»).
/// Новый язык: «ru»|«any»
/// </summary>
public string? Lang { get; init; }
/// <summary>
/// Новый порог оценки, % (кламп 1..100).
/// Новый порог оценки, %
/// </summary>
public int? Threshold { get; init; }
/// <summary>
/// Новый размер выборки (кламп ≥1).
/// Новый размер выборки
/// </summary>
public int? SampleSize { get; init; }
/// <summary>
/// Новый план авто-вступлений (рост — с проверкой бюджета).
/// Новый план авто-вступлений
/// </summary>
public int? PlanJoins { get; init; }
/// <summary>
/// Новый флаг авто-вступлений (false — выключить).
/// Новый флаг авто-вступлений
/// </summary>
public bool? AutoJoin { get; init; }
}
@@ -1,12 +1,6 @@
namespace Deal.Api.Endpoints.RequestModels;
/// <summary>
/// Тело POST /api/cards/mark-col-seen — снять «новое» с колонки (dashboard_routes.py MarkColBody L183184, api-map §3.2 L88).
/// Тело POST /api/cards/mark-col-seen — снять «новое» с колонки.
/// </summary>
/// <remarks>
/// Wire-имя — camelCase: col — колонка (inbox/archive/trash/доска). Отсутствие/null col → 400
/// «Неизвестная колонка»: иначе пустой col попал бы в CardsService.MarkSeenAsync и снял «новое» со ВСЕХ
/// карточек (truthiness-семантика прототипа: пустая строка = параметр не задан, mark_seen L250256) —
/// эндпоинт защищает от такого вызова (прототип: pydantic required 422).
/// </remarks>
public sealed record MarkColBody(string? Col);
@@ -1,11 +1,6 @@
namespace Deal.Api.Endpoints.RequestModels;
/// <summary>
/// Тело POST /api/cards/{lead_id}/move — перенос карточки (dashboard_routes.py MoveBody L5657, api-map §3.2 L89).
/// Тело POST /api/cards/{lead_id}/move — перенос карточки.
/// </summary>
/// <remarks>
/// Wire-имя — camelCase: to — колонка назначения: "inbox" либо id доски (<c>b_...</c>). Цель валидирует
/// CardsService (400 «Переносить можно только на доски или в «Неразобранное»»); отсутствующий/null to
/// трактуются той же валидацией (прототип — pydantic required 422).
/// </remarks>
public sealed record MoveBody(string? To);
@@ -1,10 +1,7 @@
namespace Deal.Api.Endpoints.RequestModels;
/// <summary>
/// Тело PUT /api/operator/settings/telegram-keys: глобальные ключи приложения Telegram,
/// задаваемые оператором (ТЗ §4.1/§8.1). Поддерживается частичное обновление: непереданное поле
/// (<c>null</c>) сохраняет текущее значение, явное значение валидируется. Если ключей ещё нет,
/// оба поля обязательны.
/// Тело PUT /api/operator/settings/telegram-keys
/// </summary>
/// <param name="ApiId">api_id приложения Telegram (5..9 цифр); null — не менялось.</param>
/// <param name="ApiHash">api_hash приложения Telegram (непустой секрет; хранится зашифрованным); null — не менялось.</param>
@@ -1,12 +1,8 @@
namespace Deal.Api.Endpoints.RequestModels;
/// <summary>
/// Тело POST /api/containers/reorder — новый порядок контейнеров пространства (этап 9, T4).
/// Тело POST /api/containers/reorder — новый порядок контейнеров пространства.
/// </summary>
/// <remarks>
/// Wire-имена — camelCase: space (пространство dashboard/selected; отсутствие — dashboard) и order
/// (список id контейнеров в новом порядке). Отсутствие/явный null у order — 400 «Не указан порядок колонок».
/// </remarks>
/// <param name="Space">Пространство (dashboard/selected); null — dashboard.</param>
/// <param name="Order">Id контейнеров в новом порядке.</param>
public sealed record OrderBody(string? Space, IReadOnlyList<string>? Order);
@@ -1,11 +1,6 @@
namespace Deal.Api.Endpoints.RequestModels;
/// <summary>
/// Тело POST /api/cards/reclassify — ИИ-переклассификация «Неразобранного» (dashboard_routes.py ReclassifyBody L6465, api-map §3.2 L95).
/// Тело POST /api/cards/reclassify — ИИ-переклассификация «Неразобранного».
/// </summary>
/// <remarks>
/// Wire-имя — camelCase: ids (опциональный список id карточек). Тело опционально (фронт вызывает без
/// тела — reclassifyInbox, store.js L11151133; параметр эндпоинта nullable). В этапе 3 — заглушка
/// Ruling 11: тело не используется, ответ всегда {started:false, busy:false, attempted:0, reason}.
/// </remarks>
public sealed record ReclassifyBody(IReadOnlyList<string>? Ids);
@@ -1,17 +1,7 @@
namespace Deal.Api.Endpoints.RequestModels;
/// <summary>
/// Тело POST /api/cards/{cardId}/reminder — установка напоминания hold-карточке
/// (projects_routes.py ReminderBody L5253, api-map §3.5 L172; Ruling 3).
/// Тело POST /api/cards/{cardId}/reminder — установка напоминания hold-карточке.
/// </summary>
/// <remarks>
/// Wire-имя — camelCase: at — время напоминания в epoch-мс (рассчитывает фронт HoldReminderDialog:
/// «через N дней (1–30)» или «дата+время» локального времени; store.js setHoldReminder L20602086).
/// Стадия карточки/будущность at сервисом НЕ проверяются (1:1 прототип: фронт шлёт только для hold;
/// прошлое at допустимо — приёмка Tasks 11/13 «выстреливает» его ручным тиком). Ответ — полная карточка
/// с напоминанием {at}; 400 «Напоминания об отложенных выключены в настройках» при выключенном
/// remindersEnabled; 404 «Карточка не найдена». Отсутствующий/JSON-null at (клиентский баг; pydantic на
/// такое — 422) эндпоинт отвергает 400 — у напоминания без времени нет осмысленной семантики.
/// </remarks>
/// <param name="At">Время напоминания, epoch-ms.</param>
public sealed record ReminderSetRequest(long? At);
@@ -1,11 +1,6 @@
namespace Deal.Api.Endpoints.RequestModels;
/// <summary>
/// Тело POST /api/pipeline/rejected/{rejId}/return — причина возврата в обработку (processing_routes.py ReturnBody L1315, api-map §3.6 L185).
/// Тело POST /api/pipeline/rejected/{rejId}/return — причина возврата в обработку.
/// </summary>
/// <remarks>
/// Wire-имя — camelCase: reason (опциональна, дефолт "" — как pydantic reason: str = ""; фронт шлёт
/// {reason} всегда). Пустая причина допустима: сервис тримит и кладёт на запись для аудита
/// (return_to_queue L158, Ruling 10).
/// </remarks>
public sealed record ReturnReasonRequest(string? Reason);
@@ -1,13 +1,8 @@
namespace Deal.Api.Endpoints.RequestModels;
/// <summary>
/// Тело POST /api/cards/take — «взять в работу» карточки с дашборда (этап 9, T6).
/// Тело POST /api/cards/take — «взять в работу» карточки с дашборда.
/// </summary>
/// <remarks>
/// Wire-имена — camelCase: cardId — id карточки; leadId — алиас (совместимость со старым фронтом).
/// Карточка не клонируется: она переносится в контейнер planned. Отсутствующий/несуществующий id →
/// 404 «Карточка не найдена».
/// </remarks>
/// <param name="CardId">Id карточки, берущейся в работу.</param>
/// <param name="LeadId">Алиас cardId.</param>
public sealed record TakeCardRequest(string? CardId = null, string? LeadId = null);
@@ -1,7 +1,7 @@
namespace Deal.Api.Endpoints.RequestModels;
/// <summary>
/// Тело POST /api/tg/dialogs/{dialog_id}/monitor и /monitor-all (tg_routes.py MonitorBody L5456).
/// Тело POST /api/tg/dialogs/{dialog_id}/monitor и /monitor-all.
/// </summary>
/// <param name="Enabled">True — мониторить (сообщения → PushMessage в ядро), false — выключить.</param>
public sealed record TgMonitorBody(bool Enabled);
@@ -1,8 +1,8 @@
namespace Deal.Api.Endpoints.RequestModels;
/// <summary>
/// Тело POST /api/tg/dialogs/preview — последние сообщения диалога (tg_routes.py PreviewBody L5860).
/// Тело POST /api/tg/dialogs/preview — последние сообщения диалога.
/// </summary>
/// <param name="DialogId">Id диалога (подписанный).</param>
/// <param name="Limit">Сколько последних сообщений; дефолт 24, кламп 1..50 (python L153).</param>
/// <param name="Limit">Сколько последних сообщений; дефолт 24, кламп 1..50.</param>
public sealed record TgPreviewBody(string DialogId, int? Limit);
@@ -1,7 +1,7 @@
namespace Deal.Api.Endpoints.RequestModels;
/// <summary>
/// Тело POST /api/tg/send-code — SMS-код входа (tg_routes.py CodeBody L4648).
/// Тело POST /api/tg/send-code — SMS-код входа.
/// </summary>
/// <param name="Code">Код из SMS/Telegram-сообщения (trim перед отправкой, python L89).</param>
/// <param name="Code">Код из SMS/Telegram-сообщения.</param>
public sealed record TgSendCodeRequest(string Code);
@@ -1,7 +1,7 @@
namespace Deal.Api.Endpoints.RequestModels;
/// <summary>
/// Тело POST /api/tg/send-password — облачный пароль 2FA (tg_routes.py PasswordBody L5052).
/// Тело POST /api/tg/send-password — облачный пароль 2FA.
/// </summary>
/// <param name="Password">Пароль облачной защиты (как ввёл пользователь, без trim — python L100).</param>
/// <param name="Password">Пароль облачной защиты.</param>
public sealed record TgSendPasswordRequest(string Password);
@@ -1,7 +1,7 @@
namespace Deal.Api.Endpoints.RequestModels;
/// <summary>
/// Тело POST /api/tg/start-phone — вход по номеру телефона (tg_routes.py PhoneBody L4244).
/// Тело POST /api/tg/start-phone — вход по номеру телефона.
/// </summary>
/// <param name="Phone">Номер в международном формате (как ввёл пользователь; обрезается обработчиком, python L71).</param>
/// <param name="Phone">Номер в международном формате.</param>
public sealed record TgStartPhoneRequest(string Phone);
@@ -9,26 +9,8 @@ using Deal.Modules.Tenants.Application.Models;
namespace Deal.Api.Endpoints;
/// <summary>
/// HTTP-эндпоинты настроек тенанта: GET/PATCH /api/settings (api-map §3.4 L146147, §4.6).
/// HTTP-эндпоинты настроек тенанта
/// </summary>
/// <remarks>
/// GET — публичный снимок настроек (дефолты + переопределения, маски секретов, providers — Ruling 3);
/// PATCH — произвольный JSON-объект публичных полей §4.6, ответ — полный снимок после применения
/// (фронт затирает локальный state ответом — store.js). Оба эндпоинта требуют сессию:
/// 401 {"detail":"Требуется авторизация"} (Ruling 10). Мягкая семантика: невалидное поле PATCH
/// просто не применяется; жёсткая ошибка — только тело не JSON-объект (400).
/// Побочные эффекты прототипа L186–192: PATCH с полем rateSource запускает фоновое
/// обновление кэша курсов (<see cref="RatesRefreshScheduler"/>, Ruling 6); пересчёт карточек при смене
/// targetCurrency/conversionOn выполняет сам SettingsService через порт <see cref="IRatesChangedListener"/>
/// (реализация — ConversionRecomputer модуля Kanban, Ruling 7, Task 12).
/// <para>
/// SettingsService резолвится из RequestServices ВНУТРИ обработчика после проверки сессии, а не
/// параметром эндпоинта: DI-биндинг параметров выполняется до тела обработчика, а зависимость
/// сервиса — scoped TenantDbContext, опции которого строятся по tenant-контексту запроса
/// (без сессии контекст не разрешим — ошибка конфигурации). Так запрос без сессии получает 401,
/// а не 500 при резолве.
/// </para>
/// </remarks>
public static class SettingsEndpoints
{
private const string ApiGroupPrefix = "/api";
@@ -83,7 +65,6 @@ public static class SettingsEndpoints
catch (JsonException)
{
// Не-JSON или не-объект целиком — ошибка запроса: 400 + detail
// (в прототипе FastAPI на такое тело — 422).
return EndpointResults.BadRequest(InvalidBodyDetail);
}
@@ -95,11 +76,8 @@ public static class SettingsEndpoints
SettingsService settingsService = context.RequestServices.GetRequiredService<SettingsService>();
PublicSettingsDto result = await settingsService.ApplyPatchAsync(body, ct);
// Аудит сохранения настроек (этап 10, T1): только имена полей — значения (в т.ч. секреты) не пишутся.
await AuditAppender.AppendTenantAsync(context, AuditEvents.SettingsUpdated, new { fields = body.Keys }, ct);
// Смена источника курсов в PATCH (settings_routes.py L188189) — фоновое обновление кэша
// курсов (Ruling 6, Task 8). RefreshAsync читает уже сохранённую настройку rateSource.
if (ShouldScheduleRatesRefresh(body))
{
context.RequestServices.GetRequiredService<RatesRefreshScheduler>().Schedule();
@@ -108,7 +86,6 @@ public static class SettingsEndpoints
return Results.Ok(result);
}
// Запускать ли фоновый refresh курсов после PATCH (семантика if body.get("rateSource") L188).
// body: Тело PATCH — публичные ключи §4.6.
// Возвращает: True — поле rateSource передано «правдивым» значением (не null/пустая строка).
private static bool ShouldScheduleRatesRefresh(Dictionary<string, JsonElement> body)
@@ -118,7 +95,6 @@ public static class SettingsEndpoints
return false;
}
// JSON-булево/число в python «правдивы» и запускают refresh; пустая строка/null — нет.
return element.ValueKind switch
{
JsonValueKind.String => !string.IsNullOrEmpty(element.GetString()),
@@ -5,37 +5,16 @@ using Deal.Infrastructure.Services;
namespace Deal.Api.Endpoints;
/// <summary>
/// Служебные storage-эндпоинты: POST /api/admin/tick и POST /api/admin/fts/rebuild (план Tasks 1011,
/// Rulings 6/8/11; прототип dashboard_routes.py L261264, L327337).
/// Служебные storage-эндпоинты
/// </summary>
/// <remarks>
/// Контракт 1:1 с прототипом и api-map §3.2 L103112: POST /admin/tick = тик правил хранения текущего
/// тенанта + очистка отсева пайплайна (3 суток) + проверка напоминаний «Отложено» (план Task 11, Ruling 3/8)
/// + один проход pump очереди входящих (этап 4, Ruling 8/9);
/// ответ {storage, reminders, pipeline: {…}, queue: N} (dashboard_routes.py L327337; storage.purgedRejected
/// объединяет очистку отсева — Ruling 9; reminders — «выстрелившие» напоминания {id,title,stage}, пусто —
/// сработавших нет). SSE-публикации (тосты статистики notify_tick_stats L496504, new_card по созданным
/// карточкам и reminder_due по «выстрелившим» напоминаниям) выполняет <see cref="AdminTickOrchestrator"/> из
/// Api-слоя — модули остаются чистыми (Ruling 5/8); без подписчиков публикация — no-op. Сбой проверки
/// напоминаний/pump не роняет тик: reminders/pipeline ответа пусты, очередь ждёт следующего тика/фонового
/// цикла (Task 11). POST /admin/fts/rebuild —
/// реальная идемпотентная пересборка FTS-индексов <see cref="FtsMaintenance"/> (CREATE INDEX IF NOT EXISTS +
/// ANALYZE, Ruling 6), ответ {ok:true, ready:true} (при сбое {ok:false, ready:false} — 1:1 с fts_rebuild L261264,
/// кнопка Settings «Пересобрать индекс» store.js L18831889). Оба эндпоинта требуют сессию: 401 {detail} без
/// куки (Ruling 10); сервисы резолвятся из RequestServices ПОСЛЕ проверки сессии (паттерн BoardsEndpoints).
/// </remarks>
public static class StorageEndpoints
{
// Префикс группы (роутер dashboard, prefix="/api"; admin-пути прототипа L261/L327).
private const string AdminGroupPrefix = "/api";
// Путь ручного тика правил хранения (dashboard_routes.py L327).
private const string TickPath = "/admin/tick";
// Путь пересборки поискового индекса (dashboard_routes.py L261; Ruling 6).
private const string FtsRebuildPath = "/admin/fts/rebuild";
// OpenAPI-тег группы (в прототипе роутер dashboard — dashboard_routes.py).
private const string OpenApiTag = "dashboard";
/// <summary>
@@ -51,12 +30,9 @@ public static class StorageEndpoints
return app;
}
// POST /api/admin/tick: правила хранения + очистка отсева + напоминания + pump + SSE-тосты/new_card/reminder_due (admin_tick L327337).
// Весь состав тика — AdminTickOrchestrator (вынесен из эндпоинта для unit-тестов логики и
// переиспользования): тик StorageTickService (Kanban) → PurgeExpiredAsync (отсев 3 суток, merge в
// storage.purgedRejected) → тосты статистики (включая «Отсев очищен: N записей (3 дн.)») → CheckDueAsync
// (напоминания «Отложено»: reminders ответа + SSE reminder_due, план Task 11; сбой не роняет тик) →
// PumpOnceAsync (сбой не роняет тик) → SSE new_card по созданным карточкам → queue. Формы — 1:1 с прототипом.
private static async Task<IResult> AdminTickAsync(HttpContext context, CancellationToken ct)
{
if (!context.HasUser())
@@ -69,11 +45,8 @@ public static class StorageEndpoints
return Results.Ok(await orchestrator.TickAsync(tenantId, ct));
}
// POST /api/admin/fts/rebuild: пересборка FTS-индексов тенанта; ответ {ok:true, ready:true} (Ruling 6).
// SearchTsv — генерируемые STORED-колонки Cards/RejectedItems: авто-актуальны, «пересборка» = создание
// отсутствующих GIN-индексов (CREATE INDEX IF NOT EXISTS) + ANALYZE таблиц (FtsMaintenance.RebuildAsync).
// Сбой обслуживания возвращает {ok:false, ready:false} (прототип fts_rebuild L261264: rebuild() → ok,
// is_ready() → ready) — кнопка Settings фронта показывает ошибку по ready (store.js L18831889).
private static async Task<IResult> FtsRebuildAsync(HttpContext context, CancellationToken ct)
{
if (!context.HasUser())
@@ -11,25 +11,12 @@ using Deal.Modules.Tenants.Application.Models;
namespace Deal.Api.Endpoints;
/// <summary>
/// Эндпоинты /api/tg: статус, веб-авторизация (phone/QR/код/2FA/logout), диалоги и мониторинг (Ruling 8, api-map §3.3).
/// Эндпоинты /api/tg
/// </summary>
/// <remarks>
/// Тела ответов 1:1 с прототипом <c>backend/app/routers/tg_routes.py</c>:
/// <c>status</c> — §4.9 (собирает <see cref="TgStatusService"/>); start-phone/send-code/send-password — <c>{phase}</c>;
/// start-qr — <c>{phase, qrUrl}</c>; logout — <c>{ok:true}</c>; dialogs — <c>{items:[диалог §4.8]}</c> (type — русская
/// форма на границе: канал/группа/чат, заметка Task 1); refresh — <c>{ok, count}</c> либо <c>{ok:false,
/// reason:"not-connected", count:0}</c> (мягкая ветка L119120); monitor/monitor-all/backfill-all/preview — как в
/// §3.3. Ошибки гейта (недоступный сервис/доменный отказ RPC) — 400 <c>{detail}</c> с канонической причиной
/// (Ruling 7/8). Фоновый первый разбор при включении мониторинга и «Перечитать» — <see cref="TelegramBackfillScheduler"/>
/// (python-_spawn L546/L566/L580). Все эндпоинты требуют сессию: 401 {detail} (Ruling 10). Сервисы резолвятся
/// из RequestServices ПОСЛЕ проверки сессии (scoped — TenantDbContext схемы тенанта, паттерн SettingsEndpoints).
/// </remarks>
public static class TelegramEndpoints
{
// Префикс группы /api/tg (python: router prefix, tg_routes.py L12).
private const string TgGroupPrefix = "/api/tg";
// OpenAPI-тег группы (в прототипе роутер tg — tg_routes.py).
private const string TgOpenApiTag = "telegram";
// Путь статуса аккаунта/фазы входа (GET).
@@ -80,24 +67,18 @@ public static class TelegramEndpoints
// Деталь недоступного telegram-service/неподключённого аккаунта (глобальная строка контракта).
private const string NotConnectedDetail = "Telegram не подключён";
// Reason мягкой ветки refresh: аккаунт не подключён (tg_routes.py L120).
private const string NotConnectedReason = "not-connected";
// Фаза успешной привязки аккаунта Telegram (telegram_linked — этап 10, T1).
private const string ReadyPhase = "ready";
// Дефолт limit превью (PreviewBody L60: limit = 24).
private const int PreviewDefaultLimit = 24;
// Нижняя граница limit превью (python L153: min(…, 1)).
private const int PreviewLimitMin = 1;
// Верхняя граница limit превью (python L153: max(…, 50); api-map /dialogs/preview).
private const int PreviewLimitMax = 50;
/// <summary>
/// Регистрирует группу /api/tg: status/start-phone/start-qr/send-code/send-password/logout/dialogs/refresh/
/// monitor-all/backfill-all/preview/{dialog_id}/monitor/{dialog_id}/backfill (qr-image — TelegramQrImageEndpoint).
/// Регистрирует группу /api/tg
/// </summary>
/// <param name="app">Построитель маршрутов приложения.</param>
/// <returns>Построитель маршрутов для цепочки вызовов.</returns>
@@ -122,7 +103,6 @@ public static class TelegramEndpoints
return app;
}
// GET /api/tg/status: статус аккаунта/фазы входа (tg_routes.py L6365; форма §4.9).
private static async Task<IResult> StatusAsync(HttpContext context, CancellationToken ct)
{
if (!context.HasUser())
@@ -134,7 +114,6 @@ public static class TelegramEndpoints
return Results.Ok(await statusService.GetAsync(ct));
}
// POST /api/tg/start-phone: запросить код по номеру (tg_routes.py L6874; python L134147).
private static async Task<IResult> StartPhoneAsync(
TgStartPhoneRequest body,
HttpContext context,
@@ -169,7 +148,6 @@ public static class TelegramEndpoints
}
}
// POST /api/tg/start-qr: начать QR-вход (tg_routes.py L7783; python qr_start L286300).
private static async Task<IResult> StartQrAsync(HttpContext context, CancellationToken ct)
{
if (!context.HasUser())
@@ -194,7 +172,6 @@ public static class TelegramEndpoints
TelegramAuthResultDto result = await gateway.StartQrAsync(apiId, keys.ApiHash, ct);
if (result.Phase == ReadyPhase)
{
// Аудит привязки Telegram (этап 10, T1): аккаунт уже авторизован — фаза ready.
await AuditAppender.AppendTenantAsync(context, AuditEvents.TelegramLinked, new { phase = result.Phase }, ct);
}
@@ -206,7 +183,6 @@ public static class TelegramEndpoints
}
}
// POST /api/tg/send-code: отправить SMS-код (tg_routes.py L8694; python submit_code L149166).
private static async Task<IResult> SendCodeAsync(
TgSendCodeRequest body,
HttpContext context,
@@ -223,7 +199,6 @@ public static class TelegramEndpoints
string phase = await gateway.SendCodeAsync((body.Code ?? string.Empty).Trim(), ct);
if (phase == ReadyPhase)
{
// Аудит привязки Telegram (этап 10, T1): вход завершён без 2FA — фаза ready.
await AuditAppender.AppendTenantAsync(context, AuditEvents.TelegramLinked, new { phase }, ct);
}
@@ -235,7 +210,6 @@ public static class TelegramEndpoints
}
}
// POST /api/tg/send-password: облачный пароль 2FA (tg_routes.py L97103; python submit_password L168176).
private static async Task<IResult> SendPasswordAsync(
TgSendPasswordRequest body,
HttpContext context,
@@ -252,7 +226,6 @@ public static class TelegramEndpoints
string phase = await gateway.SendPasswordAsync(body.Password ?? string.Empty, ct);
if (phase == ReadyPhase)
{
// Аудит привязки Telegram (этап 10, T1): 2FA пройдена — фаза ready.
await AuditAppender.AppendTenantAsync(context, AuditEvents.TelegramLinked, new { phase }, ct);
}
@@ -264,7 +237,6 @@ public static class TelegramEndpoints
}
}
// POST /api/tg/logout: отключить аккаунт, удалить сессию (tg_routes.py L106109; python disconnect L189207).
private static async Task<IResult> LogoutAsync(HttpContext context, CancellationToken ct)
{
if (!context.HasUser())
@@ -284,7 +256,6 @@ public static class TelegramEndpoints
}
}
// GET /api/tg/dialogs: список диалогов из БД (tg_routes.py L112114; list_dialogs L521534).
private static async Task<IResult> DialogsAsync(HttpContext context, CancellationToken ct)
{
if (!context.HasUser())
@@ -295,8 +266,6 @@ public static class TelegramEndpoints
DialogsService dialogs = context.RequestServices.GetRequiredService<DialogsService>();
IReadOnlyList<TelegramDialogDto> rows = await dialogs.ListAsync(ct);
// Форма §4.8 L349: {id, name, handle, type, hue, on, last:{text,time}}; type — русская форма на границе
// (EN-канон каталога channel/group/forum/chat → «канал»/«группа»/«чат», заметка Task 1/«кривое место» п.4).
var items = new List<object?>(rows.Count);
foreach (TelegramDialogDto dialog in rows)
{
@@ -317,7 +286,6 @@ public static class TelegramEndpoints
return Results.Ok(new { items });
}
// POST /api/tg/dialogs/refresh: синхронизировать каталог диалогов из Telegram (tg_routes.py L117122).
private static async Task<IResult> DialogsRefreshAsync(HttpContext context, CancellationToken ct)
{
if (!context.HasUser())
@@ -328,7 +296,6 @@ public static class TelegramEndpoints
DialogsService dialogs = context.RequestServices.GetRequiredService<DialogsService>();
ITelegramGateway gateway = context.RequestServices.GetRequiredService<ITelegramGateway>();
// 1:1 tg_routes.py L119120: аккаунт не подключён (или сервис недоступен — Ruling 7) → мягкая ветка
// {ok:false, reason:"not-connected", count:0} HTTP 200 — refresh не ошибка запроса.
bool connected;
try
@@ -358,7 +325,6 @@ public static class TelegramEndpoints
}
}
// POST /api/tg/dialogs/monitor-all: мониторинг всех каналов (tg_routes.py L125129; L548567).
private static async Task<IResult> MonitorAllAsync(
TgMonitorBody body,
HttpContext context,
@@ -375,13 +341,11 @@ public static class TelegramEndpoints
TelegramMonitorAllDto result = await dialogs.SetMonitorAllAsync(body.Enabled, ct);
if (body.Enabled && result.BackfillNeededIds.Count > 0)
{
// Первое включение неразобранных: фоновый разбор списком (python L566: _spawn(_backfill_dialogs)).
context.RequestServices.GetRequiredService<TelegramBackfillScheduler>().ScheduleFirstBackfills(result.BackfillNeededIds);
}
if (body.Enabled)
{
// Аудит включения каналов (этап 10, T1): без имён/содержимого.
await AuditAppender.AppendTenantAsync(context, AuditEvents.ChannelEnabled, new { all = true, count = result.Count }, ct);
}
@@ -393,7 +357,6 @@ public static class TelegramEndpoints
}
}
// POST /api/tg/dialogs/backfill-all: «Перечитать» включённые каналы в фоне (tg_routes.py L132136).
private static async Task<IResult> BackfillAllAsync(HttpContext context, CancellationToken ct)
{
if (!context.HasUser())
@@ -405,14 +368,12 @@ public static class TelegramEndpoints
int count = (await dialogs.ListMonitoredIdsAsync(ct)).Count;
if (count > 0)
{
// Ответ — сразу {ok, count}, разбор идёт в фоне (python L580: _spawn(backfill_monitored)).
context.RequestServices.GetRequiredService<TelegramBackfillScheduler>().ScheduleReadRecent();
}
return Results.Ok(new { ok = true, count });
}
// POST /api/tg/dialogs/{dialog_id}/monitor: вкл/выкл мониторинг канала (tg_routes.py L139142; L536546).
private static async Task<IResult> SetMonitorAsync(
string dialog_id,
TgMonitorBody body,
@@ -430,13 +391,11 @@ public static class TelegramEndpoints
TelegramMonitorToggleDto result = await dialogs.SetMonitorAsync(dialog_id, body.Enabled, ct);
if (result.BackfillNeeded)
{
// Первое включение неразобранного канала: фоновый разбор (python L546: _spawn(backfill_dialog)).
context.RequestServices.GetRequiredService<TelegramBackfillScheduler>().ScheduleFirstBackfill(dialog_id);
}
if (result.Enabled)
{
// Аудит включения канала (этап 10, T1).
await AuditAppender.AppendTenantAsync(context, AuditEvents.ChannelEnabled, new { dialogId = dialog_id }, ct);
}
@@ -448,8 +407,6 @@ public static class TelegramEndpoints
}
}
// POST /api/tg/dialogs/{dialog_id}/backfill: разбор одного диалога (tg_routes.py L145148; L349390).
// Сервер-only эндпоинт (фронт не вызывает, api-map §3.3 L139/п.9): первый разбор/догон одного канала.
private static async Task<IResult> BackfillDialogAsync(
string dialog_id,
HttpContext context,
@@ -472,7 +429,6 @@ public static class TelegramEndpoints
}
}
// POST /api/tg/dialogs/preview: последние сообщения диалога (tg_routes.py L151153; dialog_messages L583620).
private static async Task<IResult> PreviewAsync(
TgPreviewBody body,
HttpContext context,
@@ -500,7 +456,6 @@ public static class TelegramEndpoints
return keys.KeysSet ? keys : null;
}
// 400 {detail} по ошибке гейта: канонический detail RPC либо «Telegram не подключён» (Ruling 7/8).
// exception: Исключение вызова гейта (RpcException домена/транспорта, прочее).
// Возвращает: 400-ответ с текстом причины.
private static IResult GatewayError(Exception exception)
@@ -509,7 +464,7 @@ public static class TelegramEndpoints
}
/// <summary>
/// Текст причины ошибки гейта для {detail} (канонические тексты telegram-service 1:1, Ruling 7).
/// Текст причины ошибки гейта для {detail}.
/// </summary>
/// <param name="exception">Исключение вызова гейта.</param>
/// <returns>Текст причины.</returns>
@@ -519,16 +474,13 @@ public static class TelegramEndpoints
{
// Доменная RPC-ошибка: detail от telegram-service («Неверный код», «Telegram не подключён», …).
global::Grpc.Core.RpcException rpc when !string.IsNullOrEmpty(rpc.Status.Detail) => rpc.Status.Detail,
// Недоступность/прочий транспорт — «не подключён» (GrpcTelegramClient нормализует, Ruling 7).
_ => NotConnectedDetail,
};
}
/// <summary>
/// Русская форма типа источника на границе эндпоинта (заметка Task 1, api-map §4.8 L349).
/// Русская форма типа источника на границе эндпоинта.
/// </summary>
/// <remarks>Каталог ядра хранит EN-канон (channel/group/forum/chat); наружу (вкладка «Каналы», фильтр по типу)
/// — русские подписи python (_kind_of L461466: «канал»/«группа»/«чат»; форум отображается как группа).</remarks>
/// <param name="kind">Тип источника (EN-канон каталога либо уже русская подпись).</param>
/// <returns>Русская подпись: channel→«канал», group/forum→«группа», chat→«чат»; иное — как есть.</returns>
public static string ToRussianDialogType(string kind)
@@ -7,16 +7,8 @@ using Net.Codecrete.QrCodeGenerator;
namespace Deal.Api.Endpoints;
/// <summary>
/// GET /api/tg/qr-image: SVG QR-кода входа (tg_routes.py L3039; Ruling 8).
/// GET /api/tg/qr-image
/// </summary>
/// <remarks>
/// Фронт рисует QR картинкой: <c>&lt;img src="/api/tg/qr-image?t=N"&gt;</c> (store.js tg-флоу; api-map §3.3 L133).
/// Активен только в фазе входа «qr» (живой статус гейта); иначе — 404 «QR не активен — начните вход по QR»
/// (глобальная строка контракта, python L34). SVG генерирует Net.Codecrete.QrCodeGenerator (SVG-first, без
/// внешних растровых зависимостей — план Task 14/Tech Stack); border=1 как python (border=1, L22). Заголовки —
/// no-store + Content-Disposition: inline (python L3738: свежий QR на каждый запрос, не кэшировать). Сессия
/// обязательна: 401 {detail} (Ruling 10).
/// </remarks>
public static class TelegramQrImageEndpoint
{
// Префикс группы /api/tg (общий с TelegramEndpoints).
@@ -25,16 +17,12 @@ public static class TelegramQrImageEndpoint
// Путь SVG QR-кода (GET; фронт добавляет ?t=N от кэша).
private const string QrImagePath = "/qr-image";
// OpenAPI-тег группы (в прототипе роутер tg — tg_routes.py).
private const string QrImageOpenApiTag = "telegram";
// Деталь 404: QR не активен (python L34, глобальная строка контракта).
private const string QrNotActiveDetail = "QR не активен — начните вход по QR";
// Media-type SVG-ответа (python L37: image/svg+xml).
private const string SvgMediaType = "image/svg+xml";
// Ширина рамки (quiet zone) QR в модулях (python L22: qrcode border=1).
private const int QrBorderModules = 1;
/// <summary>
@@ -49,7 +37,6 @@ public static class TelegramQrImageEndpoint
return app;
}
// GET /api/tg/qr-image: SVG QR-кода фазы входа «qr» (tg_routes.py L3039).
private static async Task<IResult> QrImageAsync(HttpContext context, CancellationToken ct)
{
if (!context.HasUser())
@@ -57,7 +44,6 @@ public static class TelegramQrImageEndpoint
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
}
// Активен только в фазе «qr» живого статуса telegram-service; сервис недоступен/фаза иная — 404 (python L3334).
TelegramAccountStatusDto live;
try
{
@@ -77,7 +63,6 @@ public static class TelegramQrImageEndpoint
QrCode qr = QrCode.EncodeText(live.QrUrl, QrCode.Ecc.Medium);
string svg = qr.ToSvgString(QrBorderModules);
// Свежий QR на каждый запрос (не кэшировать); inline — как python L3738.
context.Response.Headers.CacheControl = "no-store";
context.Response.Headers.ContentDisposition = "inline";
return Results.Text(svg, SvgMediaType);
+8 -18
View File
@@ -5,21 +5,12 @@ using System.Threading.Channels;
namespace Deal.Api.Events;
/// <summary>
/// Singleton SSE-брокер этапа: per-tenant каналы событий (Ruling 5, план Task 9).
/// Singleton SSE-брокер
/// </summary>
/// <remarks>
/// Канал заводится на тенанта при подписке (тенант сессии — CurrentUser.TenantId), публикация идёт
/// в канал тенанта по явному идентификатору — её делают ТОЛЬКО эндпоинты Api после вызова сервисов
/// модулей (Task 10/13/14); фоновые задачи вне tenant-запроса публикуют со своим scope + ITenantContext
/// (Task 11). Публикация без подписчиков канала — no-op, не падает (Ruling 5). Очередь подписчика —
/// bounded ≤200 с вытеснением старых, как прототип sse.py (maxsize=200, при переполнении get_nowait →
/// put_nowait текущего события). Потокобезопасен: словарь защищён гейтом; запись в каналы —
/// неблокирующий TryWrite (DropOldest) вне гейта, подписки/отписки конкурентны публикациям.
/// </remarks>
public sealed class SseBroker
{
/// <summary>
/// Ёмкость очереди подписчика (sse.py L20: <c>asyncio.Queue(maxsize=200)</c>).
/// Ёмкость очереди подписчика
/// </summary>
public const int SubscriberQueueCapacity = 200;
@@ -36,15 +27,14 @@ public sealed class SseBroker
private readonly Dictionary<Guid, Dictionary<Guid, Channel<SseEvent>>> _subscribersByTenant = new();
/// <summary>
/// Подписывает клиента на канал тенанта: новая bounded-очередь (≤200, DropOldest).
/// Подписывает клиента на канал тенанта
/// </summary>
/// <param name="tenantId">Тенант сессии запроса (Ruling 5: канал по TenantId при подписке).</param>
/// <param name="tenantId">Тенант сессии запроса.</param>
/// <returns>Подписка: идентификатор для отписки и читатель канала событий.</returns>
public SseSubscription Subscribe(Guid tenantId)
{
var channel = Channel.CreateBounded<SseEvent>(new BoundedChannelOptions(SubscriberQueueCapacity)
{
// Вытеснение старых при переполнении (sse.py L3644): TryWrite не блокирует и не падает.
FullMode = BoundedChannelFullMode.DropOldest,
SingleReader = true,
SingleWriter = false,
@@ -66,7 +56,7 @@ public sealed class SseBroker
}
/// <summary>
/// Отписывает клиента по завершении SSE-соединения (events_routes.py L2728).
/// Отписывает клиента по завершении SSE-соединения.
/// </summary>
/// <param name="tenantId">Тенант канала подписки.</param>
/// <param name="subscriptionId">Идентификатор подписки из <see cref="Subscribe"/>.</param>
@@ -89,10 +79,10 @@ public sealed class SseBroker
}
/// <summary>
/// Публикует событие в канал тенанта (Ruling 5: публикации — из эндпоинтов Api).
/// Публикует событие в канал тенанта.
/// </summary>
/// <param name="tenantId">Тенант-получатель; без подписчиков — no-op, не падает.</param>
/// <param name="eventType">Тип события (new_card/toast этапа 3; api.js L7879).</param>
/// <param name="eventType">Тип события.</param>
/// <param name="payload">Полезная нагрузка — сериализуется в JSON (camelCase, без \u).</param>
public void Publish(
Guid tenantId,
@@ -101,7 +91,7 @@ public sealed class SseBroker
Publish(tenantId, new SseEvent(eventType, JsonSerializer.Serialize(payload, PublishJsonOptions)));
/// <summary>
/// Публикует готовое событие (тип + JSON) в канал тенанта.
/// Публикует готовое событие
/// </summary>
/// <param name="tenantId">Тенант-получатель; без подписчиков — no-op, не падает.</param>
/// <param name="sseEvent">Событие с уже сериализованной нагрузкой.</param>
+3 -4
View File
@@ -1,15 +1,14 @@
namespace Deal.Api.Events;
/// <summary>
/// Событие SSE-потока: тип + JSON-полезная нагрузка (Ruling 5; прототип sse.py L2930).
/// Событие SSE-потока
/// </summary>
/// <param name="Type">Тип события — фронт слушает <c>addEventListener</c> по имени
/// (api.js L7881): на этапе 3 — <c>new_card</c> (полный объект карточки) и <c>toast</c> {text, icon}.</param>
/// <param name="Type">Тип события — фронт слушает <c>addEventListener</c> по имени: на — <c>new_card</c> (полный объект карточки) и <c>toast</c> {text, icon}.</param>
/// <param name="Json">Полезная нагрузка, сериализованная в JSON (camelCase, без \u-экранирования).</param>
public sealed record SseEvent(string Type, string Json)
{
/// <summary>
/// Отрисовывает frame протокола SSE: <c>event: &lt;type&gt;\ndata: &lt;json&gt;\n\n</c> (sse.py L30).
/// Отрисовывает frame протокола SSE
/// </summary>
/// <returns>Готовый frame для отправки в поток ответа.</returns>
public string RenderFrame() => $"event: {Type}\ndata: {Json}\n\n";

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