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

Удалены <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-ML-EXP | Экспорт/импорт ML-моделей (перенос «мозгов» между инстансами) | обсуждение этапа 12 | P3 | DEFERRED (решено не делать; вернуться при SaaS-масштабе) |
| BL-RECLASS-SSE | Стриминг-прогресс пакетной переклассификации + финальный тост (сейчас синхронный проход + событие `cards_reclassified`) | этап 12, D | P3 | BACKLOG | | BL-RECLASS-SSE | Стриминг-прогресс пакетной переклассификации + финальный тост (сейчас синхронный проход + событие `cards_reclassified`) | этап 12, D | P3 | BACKLOG |
| TD-CARD-MERGE | Полное слияние внутренних DTO карточки в единый `CardDto` (наружу уже единый) | этап 9/11 | P3 | TECHDEBT | | 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-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 | | 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>` и закрывающий `</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; namespace Deal.Ai.Tests.Ai;
/// <summary> /// <summary>
/// In-proc gRPC-тесты AiService поверх фейк-провайдера (план Task 8, Acceptance): все 4 RPC /// In-proc gRPC-тесты AiService поверх фейк-провайдера
/// (Filter/Classify/GenerateKeywords/EvaluateFit) с подменой LLM-фасада (без сети) через
/// реальный хост (Kestrel HTTP/2, интерцептор service-token). Проверяются: разбор решений и
/// usage в ответах, собранные сервисом промпты, недоступность провайдера → UNAVAILABLE с текстом
/// 1:1 Ruling 5, ответ без JSON в Classify → ok=false (не ошибка), INVALID_ARGUMENT конфига.
/// </summary> /// </summary>
public sealed class AiRpcTests public sealed class AiRpcTests
{ {
@@ -21,7 +17,6 @@ public sealed class AiRpcTests
// Usage API-ответа сценариев (проверка проброса в reply). // Usage API-ответа сценариев (проверка проброса в reply).
private static readonly ProviderUsage SampleUsage = new(11, 5, 16); private static readonly ProviderUsage SampleUsage = new(11, 5, 16);
// Текст ошибки UNAVAILABLE 1:1 Ruling 5 / ai.py L115117.
private const string UnavailableDetail = private const string UnavailableDetail =
"ИИ (DeepSeek) не ответил корректно — повторите попытку через несколько секунд"; "ИИ (DeepSeek) не ответил корректно — повторите попытку через несколько секунд";
@@ -49,7 +44,6 @@ public sealed class AiRpcTests
Assert.Equal("похоже на заявку", reply.Reason); Assert.Equal("похоже на заявку", reply.Reason);
AssertUsage(reply.Usage, SampleUsage); AssertUsage(reply.Usage, SampleUsage);
// Сервис передаёт промпт system-сообщением и оборачивает текст как ai.py L193.
FakeProviderCall call = Assert.Single(fake.Calls); FakeProviderCall call = Assert.Single(fake.Calls);
Assert.Equal("Фильтр: {domain}", call.SystemPrompt); Assert.Equal("Фильтр: {domain}", call.SystemPrompt);
Assert.Equal("Сообщение:\nИщем разработчика на проект", call.UserText); Assert.Equal("Сообщение:\nИщем разработчика на проект", call.UserText);
@@ -77,7 +71,7 @@ public sealed class AiRpcTests
} }
/// <summary> /// <summary>
/// Filter: модель не вернула pass — по умолчанию пропуск (1:1 ai.py L195: bool(get(pass, true))). /// Filter: модель не вернула pass — по умолчанию пропуск
/// </summary> /// </summary>
[Fact] [Fact]
public async Task Filter_MissingPassField_DefaultsToPass() public async Task Filter_MissingPassField_DefaultsToPass()
@@ -97,8 +91,7 @@ public sealed class AiRpcTests
} }
/// <summary> /// <summary>
/// Filter: провайдер недоступен после ретраев (3 попытки) → UNAVAILABLE с текстом 1:1 Ruling 5 /// Filter: провайдер недоступен после ретраев
/// (ядро трактует как «ИИ недоступен» и пропускает сообщение локальным путём).
/// </summary> /// </summary>
[Fact] [Fact]
public async Task Filter_ProviderUnavailable_ThrowsUnavailableWithDetail() public async Task Filter_ProviderUnavailable_ThrowsUnavailableWithDetail()
@@ -120,7 +113,7 @@ public sealed class AiRpcTests
} }
/// <summary> /// <summary>
/// Filter: ответ модели без JSON после ретраев — тоже UNAVAILABLE (у метода нет ok-поля). /// Filter: ответ модели без JSON после ретраев — тоже UNAVAILABLE
/// </summary> /// </summary>
[Fact] [Fact]
public async Task Filter_AnswerWithoutJson_ThrowsUnavailable() public async Task Filter_AnswerWithoutJson_ThrowsUnavailable()
@@ -141,8 +134,7 @@ public sealed class AiRpcTests
} }
/// <summary> /// <summary>
/// Classify: модель вернула JSON — ok=true, json = извлечённый ответ строкой (маппинг в ядре), /// Classify: модель вернула JSON — ok=true, json = извлечённый ответ строкой
/// usage пробрасывается.
/// </summary> /// </summary>
[Fact] [Fact]
public async Task Classify_ModelAnsweredJson_ReturnsOkAndJson() public async Task Classify_ModelAnsweredJson_ReturnsOkAndJson()
@@ -178,8 +170,7 @@ public sealed class AiRpcTests
} }
/// <summary> /// <summary>
/// Classify: модель отвечала, но без разбираемого JSON после ретраев → ok=false, НЕ RPC-ошибка /// Classify: модель отвечала, но без разбираемого JSON после ретраев → ok=false, НЕ RPC-ошибка; usage последней попытки в ответе
/// (README ai.proto L201204); usage последней попытки в ответе (оценка по символам).
/// </summary> /// </summary>
[Fact] [Fact]
public async Task Classify_AnswerWithoutJson_ReturnsOkFalseWithUsage() public async Task Classify_AnswerWithoutJson_ReturnsOkFalseWithUsage()
@@ -211,7 +202,7 @@ public sealed class AiRpcTests
} }
/// <summary> /// <summary>
/// Classify: провайдер недоступен — UNAVAILABLE (ядро падает в локальный разбор, aiFail). /// Classify: провайдер недоступен — UNAVAILABLE
/// </summary> /// </summary>
[Fact] [Fact]
public async Task Classify_ProviderUnavailable_ThrowsUnavailable() public async Task Classify_ProviderUnavailable_ThrowsUnavailable()
@@ -232,7 +223,7 @@ public sealed class AiRpcTests
} }
/// <summary> /// <summary>
/// GenerateKeywords: ключи из JSON-ответа + фиксированный промпт с описанием задачи. /// GenerateKeywords
/// </summary> /// </summary>
[Fact] [Fact]
public async Task GenerateKeywords_ModelReturnedKeywords_ReturnsList() public async Task GenerateKeywords_ModelReturnedKeywords_ReturnsList()
@@ -253,7 +244,6 @@ public sealed class AiRpcTests
Assert.Equal(["стройка", "ремонт квартир", "подряды"], reply.Keywords); Assert.Equal(["стройка", "ремонт квартир", "подряды"], reply.Keywords);
AssertUsage(reply.Usage, SampleUsage); AssertUsage(reply.Usage, SampleUsage);
// Фиксированный промпт (routes L36–47) и пользовательское сообщение с описанием.
FakeProviderCall call = Assert.Single(fake.Calls); FakeProviderCall call = Assert.Single(fake.Calls);
Assert.Contains("эксперт по поиску Telegram-каналов", call.SystemPrompt, StringComparison.Ordinal); Assert.Contains("эксперт по поиску Telegram-каналов", call.SystemPrompt, StringComparison.Ordinal);
Assert.Contains("Верни строго JSON", call.SystemPrompt, StringComparison.Ordinal); Assert.Contains("Верни строго JSON", call.SystemPrompt, StringComparison.Ordinal);
@@ -262,7 +252,7 @@ public sealed class AiRpcTests
} }
/// <summary> /// <summary>
/// GenerateKeywords: не-строковые элементы списка пропускаются (чистку делает ядро). /// GenerateKeywords
/// </summary> /// </summary>
[Fact] [Fact]
public async Task GenerateKeywords_NonStringItems_Skipped() public async Task GenerateKeywords_NonStringItems_Skipped()
@@ -281,7 +271,7 @@ public sealed class AiRpcTests
} }
/// <summary> /// <summary>
/// GenerateKeywords: модель не вернула ключи — пустой список (не ошибка). /// GenerateKeywords
/// </summary> /// </summary>
[Fact] [Fact]
public async Task GenerateKeywords_NoKeywordsField_ReturnsEmpty() public async Task GenerateKeywords_NoKeywordsField_ReturnsEmpty()
@@ -300,8 +290,7 @@ public sealed class AiRpcTests
} }
/// <summary> /// <summary>
/// EvaluateFit: fit=1 + причина; промпт собран сервисом из описания и ключей (discovery_eval /// EvaluateFit: fit=1 + причина; промпт собран сервисом из описания и ключей, сообщение — как «Сообщение:\n…».
/// L50–54), сообщение — как «Сообщение:\n…».
/// </summary> /// </summary>
[Fact] [Fact]
public async Task EvaluateFit_ModelFits_ReturnsFitAndReason() public async Task EvaluateFit_ModelFits_ReturnsFitAndReason()
@@ -332,7 +321,7 @@ public sealed class AiRpcTests
} }
/// <summary> /// <summary>
/// EvaluateFit: ключи задачи подставляются в промпт строкой через запятую (1:1 _ai_prompt). /// EvaluateFit: ключи задачи подставляются в промпт строкой через запятую.
/// </summary> /// </summary>
[Fact] [Fact]
public async Task EvaluateFit_KeywordsJoinedIntoPrompt() public async Task EvaluateFit_KeywordsJoinedIntoPrompt()
@@ -358,8 +347,7 @@ public sealed class AiRpcTests
} }
/// <summary> /// <summary>
/// EvaluateFit: fit=0 без причины модели — причина по умолчанию «не подходит» (1:1 _ai_reason /// EvaluateFit: fit=0 без причины модели — причина по умолчанию «не подходит»; строковое «нет» трактуется как ложь.
/// discovery_eval L167171); строковое «нет» трактуется как ложь (1:1 _ai_fit L158164).
/// </summary> /// </summary>
[Fact] [Fact]
public async Task EvaluateFit_ModelNoFit_ReturnsDefaultReason() public async Task EvaluateFit_ModelNoFit_ReturnsDefaultReason()
@@ -379,7 +367,7 @@ public sealed class AiRpcTests
} }
/// <summary> /// <summary>
/// EvaluateFit: fit строкой «нет» — false (паритет _ai_fit), причина из модели. /// EvaluateFit: fit строкой «нет» — false, причина из модели.
/// </summary> /// </summary>
[Fact] [Fact]
public async Task EvaluateFit_StringFalsyFit_ReturnsFalse() public async Task EvaluateFit_StringFalsyFit_ReturnsFalse()
@@ -399,7 +387,7 @@ public sealed class AiRpcTests
} }
/// <summary> /// <summary>
/// EvaluateFit: длинная причина модели усекается до 200 символов (1:1 _AI_REASON_LIMIT). /// EvaluateFit: длинная причина модели усекается до 200 символов.
/// </summary> /// </summary>
[Fact] [Fact]
public async Task EvaluateFit_LongReason_IsTruncatedTo200() public async Task EvaluateFit_LongReason_IsTruncatedTo200()
@@ -420,7 +408,7 @@ public sealed class AiRpcTests
} }
/// <summary> /// <summary>
/// Конфиг-валидация: запрос без конфига провайдера (пустой base_url) → INVALID_ARGUMENT. /// Конфиг-валидация
/// </summary> /// </summary>
[Fact] [Fact]
public async Task Classify_WithoutProviderConfig_IsInvalidArgument() public async Task Classify_WithoutProviderConfig_IsInvalidArgument()
@@ -441,7 +429,7 @@ public sealed class AiRpcTests
} }
/// <summary> /// <summary>
/// Конфиг-валидация: пустая model конфига → INVALID_ARGUMENT (вызов модели невозможен). /// Конфиг-валидация
/// </summary> /// </summary>
[Fact] [Fact]
public async Task GenerateKeywords_EmptyModel_IsInvalidArgument() public async Task GenerateKeywords_EmptyModel_IsInvalidArgument()
@@ -466,7 +454,7 @@ public sealed class AiRpcTests
} }
/// <summary> /// <summary>
/// Серверный лимит text (ai.proto Filter.text: core обрезает до 4000): превышение → INVALID_ARGUMENT. /// Серверный лимит text
/// </summary> /// </summary>
[Fact] [Fact]
public async Task Filter_TooLongText_IsInvalidArgument() public async Task Filter_TooLongText_IsInvalidArgument()
@@ -492,7 +480,7 @@ public sealed class AiRpcTests
} }
/// <summary> /// <summary>
/// Серверный лимит description (ai.proto GenerateKeywords.description: core обрезает до 4000). /// Серверный лимит description
/// </summary> /// </summary>
[Fact] [Fact]
public async Task GenerateKeywords_TooLongDescription_IsInvalidArgument() public async Task GenerateKeywords_TooLongDescription_IsInvalidArgument()
@@ -517,7 +505,7 @@ public sealed class AiRpcTests
} }
/// <summary> /// <summary>
/// Обязательный tenant-id в metadata (Ruling 1): отсутствует → UNAUTHENTICATED до вызова. /// Обязательный tenant-id в metadata
/// </summary> /// </summary>
[Fact] [Fact]
public async Task Classify_WithoutTenantId_IsUnauthenticated() public async Task Classify_WithoutTenantId_IsUnauthenticated()
@@ -12,7 +12,6 @@ using Microsoft.Extensions.DependencyInjection;
namespace Deal.Ai.Tests.Ai; namespace Deal.Ai.Tests.Ai;
// Общий харнесс in-proc gRPC-тестов ai-service (план Task 8): поднимает хост (AiServiceHost.Create)
// в процессе теста на эфемерном порту и через configureServices-хук подменяет LLM-фасад фейком // в процессе теста на эфемерном порту и через configureServices-хук подменяет LLM-фасад фейком
// (FakeProviderClient, без сети) и функцию паузы ретраев (мгновенная) — сценарии не // (FakeProviderClient, без сети) и функцию паузы ретраев (мгновенная) — сценарии не
// ждут 0.8/2 с между попытками. Регистрация, добавленная харнессом после дефолтных, побеждает // ждут 0.8/2 с между попытками. Регистрация, добавленная харнессом после дефолтных, побеждает
@@ -20,17 +19,17 @@ namespace Deal.Ai.Tests.Ai;
internal static class AiTestHost internal static class AiTestHost
{ {
/// <summary> /// <summary>
/// Env-ключ ожидаемого service-token (зеркало ServiceTokenInterceptor). /// Env-ключ ожидаемого service-token
/// </summary> /// </summary>
public const string ServiceTokenEnvKey = "DEAL_SERVICE_TOKEN"; public const string ServiceTokenEnvKey = "DEAL_SERVICE_TOKEN";
/// <summary> /// <summary>
/// Ключ gRPC-metadata с service-token (зеркало ServiceTokenInterceptor). /// Ключ gRPC-metadata с service-token
/// </summary> /// </summary>
public const string ServiceTokenMetadataKey = ServiceTokenInterceptor.ServiceTokenMetadataKey; public const string ServiceTokenMetadataKey = ServiceTokenInterceptor.ServiceTokenMetadataKey;
/// <summary> /// <summary>
/// Ключ gRPC-metadata с tenant-id (зеркало AiServiceImpl). /// Ключ gRPC-metadata с tenant-id
/// </summary> /// </summary>
public const string TenantIdMetadataKey = AiServiceImpl.TenantIdMetadataKey; public const string TenantIdMetadataKey = AiServiceImpl.TenantIdMetadataKey;
@@ -45,7 +44,7 @@ internal static class AiTestHost
public const string DefaultTenantId = "tenant-test"; public const string DefaultTenantId = "tenant-test";
/// <summary> /// <summary>
/// Deadline RPC-вызовов теста (сек). /// Deadline RPC-вызовов теста
/// </summary> /// </summary>
public const int RpcDeadlineSeconds = 15; public const int RpcDeadlineSeconds = 15;
@@ -100,7 +99,7 @@ internal static class AiTestHost
} }
/// <summary> /// <summary>
/// Подменяет HTTP-фасад вызовов модели фейком сценария (последняя регистрация побеждает). /// Подменяет HTTP-фасад вызовов модели фейком сценария
/// </summary> /// </summary>
/// <param name="services">Коллекция сервисов хоста.</param> /// <param name="services">Коллекция сервисов хоста.</param>
/// <param name="fake">Фейк-провайдер сценария.</param> /// <param name="fake">Фейк-провайдер сценария.</param>
@@ -108,14 +107,14 @@ internal static class AiTestHost
=> services.AddSingleton<IProviderClient>(fake); => services.AddSingleton<IProviderClient>(fake);
/// <summary> /// <summary>
/// Делает паузы ретраев мгновенными (иначе сценарии ждали бы 0.8/2 с). /// Делает паузы ретраев мгновенными
/// </summary> /// </summary>
/// <param name="services">Коллекция сервисов хоста.</param> /// <param name="services">Коллекция сервисов хоста.</param>
public static void DisableRetryDelays(IServiceCollection services) public static void DisableRetryDelays(IServiceCollection services)
=> services.AddSingleton<Func<TimeSpan, CancellationToken, Task>>(static (_, _) => Task.CompletedTask); => services.AddSingleton<Func<TimeSpan, CancellationToken, Task>>(static (_, _) => Task.CompletedTask);
/// <summary> /// <summary>
/// Строит metadata вызова: service-token (+ tenant-id, если задан). /// Строит metadata вызова
/// </summary> /// </summary>
/// <param name="serviceToken">Значение заголовка service-token.</param> /// <param name="serviceToken">Значение заголовка service-token.</param>
/// <param name="tenantId">Id тенанта (null — без заголовка tenant-id).</param> /// <param name="tenantId">Id тенанта (null — без заголовка tenant-id).</param>
@@ -136,7 +135,7 @@ internal static class AiTestHost
} }
/// <summary> /// <summary>
/// CallOptions RPC: metadata + deadline (рекомендации README src/contracts L6273). /// CallOptions RPC
/// </summary> /// </summary>
/// <param name="metadata">Metadata вызова.</param> /// <param name="metadata">Metadata вызова.</param>
public static CallOptions CallOptions(Metadata metadata) public static CallOptions CallOptions(Metadata metadata)
@@ -8,7 +8,6 @@ namespace Deal.Ai.Tests.Ai;
// Config: Конфиг провайдера вызова. // Config: Конфиг провайдера вызова.
internal sealed record FakeProviderCall(string SystemPrompt, string UserText, LlmConfig Config); internal sealed record FakeProviderCall(string SystemPrompt, string UserText, LlmConfig Config);
// Фейк-провайдер LLM-вызовов (без сети; план Task 8): поведение задаётся сценарием (ответ текстом,
// usage либо сбой), каждый вызов записывается в Calls — тесты проверяют и собранные // usage либо сбой), каждый вызов записывается в Calls — тесты проверяют и собранные
// RPC-слоем промпты, и число попыток ретраев. // RPC-слоем промпты, и число попыток ретраев.
internal sealed class FakeProviderClient : IProviderClient internal sealed class FakeProviderClient : IProviderClient
@@ -25,7 +24,7 @@ internal sealed class FakeProviderClient : IProviderClient
} }
/// <summary> /// <summary>
/// Все вызовы фейка в порядке поступления (для проверок RPC-веток). /// Все вызовы фейка в порядке поступления
/// </summary> /// </summary>
public List<FakeProviderCall> Calls { get; } = []; public List<FakeProviderCall> Calls { get; } = [];
@@ -4,14 +4,12 @@ using Microsoft.Extensions.Logging.Abstractions;
namespace Deal.Ai.Tests.Ai; namespace Deal.Ai.Tests.Ai;
/// <summary> /// <summary>
/// Unit-тесты оркестратора вызовов модели (план Task 7; ProviderCaller, 1:1 chat_json ai.py L80117): /// Unit-тесты оркестратора вызовов модели
/// ретраи 2 с паузами 0.8/2 с, извлечение JSON, различение «провайдер не ответил» и «ответ без
/// JSON», usage. Фейк-провайдер без сети; паузы ретраев мгновенные (харнесс-делегат).
/// </summary> /// </summary>
public sealed class ProviderCallerTests public sealed class ProviderCallerTests
{ {
/// <summary> /// <summary>
/// Успех с первого вызова: JSON-объект извлечён, usage API-ответа в результате. /// Успех с первого вызова
/// </summary> /// </summary>
[Fact] [Fact]
public async Task ChatJsonAsync_FirstAttemptSuccess_ReturnsJsonAndUsage() public async Task ChatJsonAsync_FirstAttemptSuccess_ReturnsJsonAndUsage()
@@ -29,7 +27,7 @@ public sealed class ProviderCallerTests
} }
/// <summary> /// <summary>
/// Марdown-обёртка ответа модели снимается фасадом (extract_json L175183). /// Марdown-обёртка ответа модели снимается фасадом.
/// </summary> /// </summary>
[Fact] [Fact]
public async Task ChatJsonAsync_MarkdownWrappedJson_Extracts() public async Task ChatJsonAsync_MarkdownWrappedJson_Extracts()
@@ -45,7 +43,7 @@ public sealed class ProviderCallerTests
} }
/// <summary> /// <summary>
/// Без usage API-ответа фасад оценивает токены по символам (≈chars/4). /// Без usage API-ответа фасад оценивает токены по символам
/// </summary> /// </summary>
[Fact] [Fact]
public async Task ChatJsonAsync_WithoutProviderUsage_EstimatesTokens() public async Task ChatJsonAsync_WithoutProviderUsage_EstimatesTokens()
@@ -63,7 +61,7 @@ public sealed class ProviderCallerTests
} }
/// <summary> /// <summary>
/// Сбой на первых двух попытках и успех на третьей: итог успешен, попыток — 3. /// Сбой на первых двух попытках и успех на третьей
/// </summary> /// </summary>
[Fact] [Fact]
public async Task ChatJsonAsync_TwoFailuresThenSuccess_RetriesAndSucceeds() public async Task ChatJsonAsync_TwoFailuresThenSuccess_RetriesAndSucceeds()
@@ -86,8 +84,7 @@ public sealed class ProviderCallerTests
} }
/// <summary> /// <summary>
/// Все попытки — транспортный сбой: LlmCallException ProviderUnavailable с текстом 1:1 Ruling 5 /// Все попытки — транспортный сбой
/// (ai.py L115117); usage отсутствует (модель не отвечала).
/// </summary> /// </summary>
[Fact] [Fact]
public async Task ChatJsonAsync_AllTransportFailures_ThrowsProviderUnavailable() public async Task ChatJsonAsync_AllTransportFailures_ThrowsProviderUnavailable()
@@ -108,8 +105,7 @@ public sealed class ProviderCallerTests
} }
/// <summary> /// <summary>
/// Модель отвечала, но ни одна попытка не дала разбираемый JSON: AnswerNotJson с usage последней /// Модель отвечала, но ни одна попытка не дала разбираемый JSON
/// попытки (Classify вернёт ok=false; README ai.proto L201204).
/// </summary> /// </summary>
[Fact] [Fact]
public async Task ChatJsonAsync_AllAnswersNotJson_ThrowsAnswerNotJsonWithUsage() public async Task ChatJsonAsync_AllAnswersNotJson_ThrowsAnswerNotJsonWithUsage()
@@ -128,8 +124,7 @@ public sealed class ProviderCallerTests
} }
/// <summary> /// <summary>
/// Отмена (deadline RPC) прерывает вызов без «упаковки» в ошибку провайдера: исключение /// Отмена (deadline RPC) прерывает вызов без «упаковки» в ошибку провайдера
/// отмены из попытки не перехватывается как сбой (ретрятся только LlmHttpException).
/// </summary> /// </summary>
[Fact] [Fact]
public async Task ChatJsonAsync_Cancelled_PropagatesCancellation() public async Task ChatJsonAsync_Cancelled_PropagatesCancellation()
@@ -3,14 +3,12 @@ using Deal.Ai.Llm;
namespace Deal.Ai.Tests.Ai; namespace Deal.Ai.Tests.Ai;
/// <summary> /// <summary>
/// Unit-тесты оценки токенов (план Task 7; TokenEstimator, Ruling 5): usage API-ответа провайдера /// Unit-тесты оценки токенов
/// проходит как есть (total «берём как есть»), при отсутствии — оценка по символам ≈ceil(chars/4).
/// Без сети.
/// </summary> /// </summary>
public sealed class TokenEstimatorTests public sealed class TokenEstimatorTests
{ {
/// <summary> /// <summary>
/// Usage провайдера проходит без изменений, включая total ≠ сумме (как отдал API). /// Usage провайдера проходит без изменений, включая total ≠ сумме
/// </summary> /// </summary>
[Fact] [Fact]
public void Resolve_WithProviderUsage_PassesThrough() public void Resolve_WithProviderUsage_PassesThrough()
@@ -25,7 +23,7 @@ public sealed class TokenEstimatorTests
} }
/// <summary> /// <summary>
/// Нет usage провайдера — оценка по символам: prompt из system+user, completion из текста. /// Нет usage провайдера — оценка по символам
/// </summary> /// </summary>
[Fact] [Fact]
public void Resolve_WithoutProviderUsage_EstimatesByChars() public void Resolve_WithoutProviderUsage_EstimatesByChars()
@@ -39,7 +37,7 @@ public sealed class TokenEstimatorTests
} }
/// <summary> /// <summary>
/// Округление вверх: ровно 4 символа — 1 токен, 5 символов — 2 токена. /// Округление вверх
/// </summary> /// </summary>
[Fact] [Fact]
public void Resolve_WithoutProviderUsage_RoundsUp() public void Resolve_WithoutProviderUsage_RoundsUp()
@@ -52,7 +50,7 @@ public sealed class TokenEstimatorTests
} }
/// <summary> /// <summary>
/// Пустые тексты дают нулевую оценку (не отрицательную). /// Пустые тексты дают нулевую оценку
/// </summary> /// </summary>
[Fact] [Fact]
public void Resolve_EmptyTexts_ReturnsZeroTokens() public void Resolve_EmptyTexts_ReturnsZeroTokens()
@@ -9,18 +9,7 @@ using Microsoft.AspNetCore.Builder;
namespace Deal.Ai.Tests.Grpc; namespace Deal.Ai.Tests.Grpc;
/// <summary> /// <summary>
/// Интеграционные тесты хоста ai-service (каркас Task 4 + логика Task 8). /// Интеграционные тесты хоста ai-service.
///
/// Хост поднимается В процессе теста (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 исполняет методы класса последовательно).
/// </summary> /// </summary>
public sealed class AiServiceHostTests public sealed class AiServiceHostTests
{ {
@@ -33,15 +22,13 @@ public sealed class AiServiceHostTests
// Токен сценариев теста. // Токен сценариев теста.
private const string ValidToken = "task4-test-token"; private const string ValidToken = "task4-test-token";
// Id тенанта запросов (доходит до метода при верном токене; Ruling 1).
private const string TenantId = "tenant-test"; private const string TenantId = "tenant-test";
// Deadline RPC-вызовов теста (сек). // Deadline RPC-вызовов теста (сек).
private const int RpcDeadlineSeconds = 10; private const int RpcDeadlineSeconds = 10;
/// <summary> /// <summary>
/// HealthCheck (grpc.health.v1.Health/Check) отвечает SERVING — хост поднялся, gRPC-инфраструктура /// HealthCheck (grpc.health.v1.Health/Check) отвечает SERVING — хост поднялся, gRPC-инфраструктура и health-сервис работают.
/// и health-сервис работают (Ruling 12; health освобождён от service-token).
/// </summary> /// </summary>
[Fact] [Fact]
public async Task HealthCheck_ReturnsServing() public async Task HealthCheck_ReturnsServing()
@@ -60,7 +47,7 @@ public sealed class AiServiceHostTests
} }
/// <summary> /// <summary>
/// Запрос без metadata «service-token» → UNAUTHENTICATED (Ruling 1). /// Запрос без metadata «service-token» → UNAUTHENTICATED.
/// </summary> /// </summary>
[Fact] [Fact]
public async Task Classify_WithoutToken_IsUnauthenticated() public async Task Classify_WithoutToken_IsUnauthenticated()
@@ -72,7 +59,7 @@ public sealed class AiServiceHostTests
} }
/// <summary> /// <summary>
/// Запрос с неверным токеном → UNAUTHENTICATED (Ruling 1). /// Запрос с неверным токеном → UNAUTHENTICATED.
/// </summary> /// </summary>
[Fact] [Fact]
public async Task Classify_WithWrongToken_IsUnauthenticated() public async Task Classify_WithWrongToken_IsUnauthenticated()
@@ -84,9 +71,7 @@ public sealed class AiServiceHostTests
} }
/// <summary> /// <summary>
/// Верный токен проходит интерцептор к методу — кодогенерация и маппинг сервиса работают: /// Верный токен проходит интерцептор к методу — кодогенерация и маппинг сервиса работают
/// пустой запрос (без конфига провайдера) доходит до реализации Classify (Task 8) и отклоняется
/// валидацией INVALID_ARGUMENT, а не UNIMPLEMENTED.
/// </summary> /// </summary>
[Fact] [Fact]
public async Task Classify_WithValidToken_ReachesServiceAndValidatesConfig() public async Task Classify_WithValidToken_ReachesServiceAndValidatesConfig()
@@ -98,9 +83,7 @@ public sealed class AiServiceHostTests
} }
/// <summary> /// <summary>
/// Fail-closed (шаблон Task 3): DEAL_SERVICE_TOKEN не задан — Deal-RPC отклоняется всегда, /// Fail-closed: DEAL_SERVICE_TOKEN не задан — Deal-RPC отклоняется всегда, в т.ч.
/// в т.ч. запрос с «пустым» значением metadata (без гарда сравнение «» == «» пропустило бы его);
/// health при этом продолжает отвечать SERVING (инфраструктурный liveness не ломается).
/// </summary> /// </summary>
[Fact] [Fact]
public async Task WithoutConfiguredToken_DealRpcFailsClosed_HealthStillServing() public async Task WithoutConfiguredToken_DealRpcFailsClosed_HealthStillServing()
@@ -138,7 +121,6 @@ public sealed class AiServiceHostTests
// Вызывает Classify и проверяет, что сервер ответил ожидаемым кодом статуса. // Вызывает Classify и проверяет, что сервер ответил ожидаемым кодом статуса.
// Запрос несёт полный metadata (service-token + tenant-id), чтобы верный токен доходил // Запрос несёт полный metadata (service-token + tenant-id), чтобы верный токен доходил
// до реализации метода (заглушки больше нет — Task 8), а не падал на tenant-проверке.
// channel: Канал к хосту ai-service. // channel: Канал к хосту ai-service.
// tokenHeader: Значение metadata «service-token» либо null (нет заголовка). // tokenHeader: Значение metadata «service-token» либо null (нет заголовка).
// expected: Ожидаемый StatusCode. // expected: Ожидаемый StatusCode.
@@ -4,8 +4,7 @@ using Deal.Ai.Llm;
namespace Deal.Ai.Tests.Support; namespace Deal.Ai.Tests.Support;
/// <summary> /// <summary>
/// Unit-тесты извлечения JSON из ответа модели (план Task 7; JsonExtractor, 1:1 extract_json /// Unit-тесты извлечения JSON из ответа модели
/// ai.py L175183): чистая строка JSON, markdown-обёртки, проза вокруг, отказы. Без сети.
/// </summary> /// </summary>
public sealed class JsonExtractorTests public sealed class JsonExtractorTests
{ {
@@ -22,7 +21,7 @@ public sealed class JsonExtractorTests
} }
/// <summary> /// <summary>
/// Markdown-обёртка ```json снимается (типичный ответ DeepSeek). /// Markdown-обёртка ```json снимается
/// </summary> /// </summary>
[Fact] [Fact]
public void TryExtractObject_JsonFence_Unwraps() public void TryExtractObject_JsonFence_Unwraps()
@@ -59,7 +58,7 @@ public sealed class JsonExtractorTests
} }
/// <summary> /// <summary>
/// Текст без JSON — null (попытка неудачна, фасад повторяет вызов). /// Текст без JSON — null
/// </summary> /// </summary>
[Fact] [Fact]
public void TryExtractObject_NoJson_ReturnsNull() public void TryExtractObject_NoJson_ReturnsNull()
@@ -78,7 +77,7 @@ public sealed class JsonExtractorTests
} }
/// <summary> /// <summary>
/// JSON верхнего уровня не объект (массив/строка) — null (схемы ответов всегда объект). /// JSON верхнего уровня не объект
/// </summary> /// </summary>
[Fact] [Fact]
public void TryExtractObject_NonObjectRoot_ReturnsNull() public void TryExtractObject_NonObjectRoot_ReturnsNull()
@@ -5,10 +5,7 @@ using Deal.Ai.Llm;
namespace Deal.Ai.Tests.Support; namespace Deal.Ai.Tests.Support;
/// <summary> /// <summary>
/// HTTP-тесты LlmHttpClient на заглушке HttpMessageHandler (план Task 7 Acceptance; без сети): /// HTTP-тесты LlmHttpClient на заглушке HttpMessageHandler
/// форма OpenAI-совместимого запроса ({base}/chat/completions, Bearer, temperature 0.2,
/// max_tokens 8000), форма Anthropic ({base}/v1/messages, x-api-key + anthropic-version),
/// разбор ответов/usage, отказ без ключа, reasoning-без-ответа, HTTP-ошибка и таймаут.
/// </summary> /// </summary>
public sealed class LlmHttpClientTests public sealed class LlmHttpClientTests
{ {
@@ -50,7 +47,7 @@ public sealed class LlmHttpClientTests
"""; """;
/// <summary> /// <summary>
/// OpenAI-совместимый вызов: URL, Bearer, форма тела (model/messages/temperature/max_tokens). /// OpenAI-совместимый вызов
/// </summary> /// </summary>
[Fact] [Fact]
public async Task ChatAsync_OpenAiStyle_BuildsWireRequest() public async Task ChatAsync_OpenAiStyle_BuildsWireRequest()
@@ -84,7 +81,7 @@ public sealed class LlmHttpClientTests
} }
/// <summary> /// <summary>
/// Локальный OpenAI-совместимый провайдер без ключа: заголовок Authorization не шлётся. /// Локальный OpenAI-совместимый провайдер без ключа
/// </summary> /// </summary>
[Fact] [Fact]
public async Task ChatAsync_OpenAiStyleWithoutApiKey_SkipsAuthorization() public async Task ChatAsync_OpenAiStyleWithoutApiKey_SkipsAuthorization()
@@ -98,7 +95,7 @@ public sealed class LlmHttpClientTests
} }
/// <summary> /// <summary>
/// Модель вернула только reasoning без ответа — сбой попытки (ai.py L149151). /// Модель вернула только reasoning без ответа — сбой попытки.
/// </summary> /// </summary>
[Fact] [Fact]
public async Task ChatAsync_OpenAiReasoningOnly_Throws() public async Task ChatAsync_OpenAiReasoningOnly_Throws()
@@ -115,7 +112,7 @@ public sealed class LlmHttpClientTests
} }
/// <summary> /// <summary>
/// Anthropic-вызов: URL /v1/messages, x-api-key + anthropic-version, форма тела Messages API. /// Anthropic-вызов
/// </summary> /// </summary>
[Fact] [Fact]
public async Task ChatAsync_AnthropicStyle_BuildsWireRequest() public async Task ChatAsync_AnthropicStyle_BuildsWireRequest()
@@ -145,13 +142,12 @@ public sealed class LlmHttpClientTests
Assert.Equal("user", (string?)messages[0]!["role"]); Assert.Equal("user", (string?)messages[0]!["role"]);
Assert.Equal("Сообщение", (string?)messages[0]!["content"]); Assert.Equal("Сообщение", (string?)messages[0]!["content"]);
// Склейка text-блоков content[] + usage (input/output → total = сумма; ai.py L168172).
Assert.Equal("{\"fit\": 1} ещё текст", result.Text); Assert.Equal("{\"fit\": 1} ещё текст", result.Text);
Assert.Equal(new ProviderUsage(4, 6, 10), result.Usage); Assert.Equal(new ProviderUsage(4, 6, 10), result.Usage);
} }
/// <summary> /// <summary>
/// HTTP-ошибка провайдера — сбой попытки с кодом статуса (повод для ретрая). /// HTTP-ошибка провайдера — сбой попытки с кодом статуса
/// </summary> /// </summary>
[Fact] [Fact]
public async Task ChatAsync_HttpError_Throws() public async Task ChatAsync_HttpError_Throws()
@@ -166,7 +162,7 @@ public sealed class LlmHttpClientTests
} }
/// <summary> /// <summary>
/// Неожиданная форма ответа (не JSON) — сбой попытки, а не падение. /// Неожиданная форма ответа
/// </summary> /// </summary>
[Fact] [Fact]
public async Task ChatAsync_UnexpectedBody_Throws() public async Task ChatAsync_UnexpectedBody_Throws()
@@ -182,7 +178,7 @@ public sealed class LlmHttpClientTests
} }
/// <summary> /// <summary>
/// Таймаут попытки (90/60 с в проде; в тесте — 60 мс) → LlmHttpException. /// Таймаут попытки
/// </summary> /// </summary>
[Fact] [Fact]
public async Task ChatAsync_Timeout_Throws() public async Task ChatAsync_Timeout_Throws()
@@ -13,7 +13,6 @@ internal sealed record CapturedHttpRequest(
IReadOnlyDictionary<string, string> Headers, IReadOnlyDictionary<string, string> Headers,
string? Body); string? Body);
// Заглушка HttpMessageHandler для HTTP-тестов LlmHttpClient (план Task 7; без сети): записывает
// запросы (URL/заголовки/тело) и отвечает по сценарию; опциональная задержка — для теста таймаута. // запросы (URL/заголовки/тело) и отвечает по сценарию; опциональная задержка — для теста таймаута.
internal sealed class StubHttpMessageHandler : HttpMessageHandler internal sealed class StubHttpMessageHandler : HttpMessageHandler
{ {
@@ -93,14 +92,14 @@ internal sealed class StubHttpMessageHandler : HttpMessageHandler
=> _ => new HttpResponseMessage(statusCode); => _ => new HttpResponseMessage(statusCode);
/// <summary> /// <summary>
/// Разбирает тело запроса как JSON-объект (для проверок формы). /// Разбирает тело запроса как JSON-объект
/// </summary> /// </summary>
/// <param name="request">Снимок запроса.</param> /// <param name="request">Снимок запроса.</param>
public static JsonObject BodyOf(CapturedHttpRequest request) public static JsonObject BodyOf(CapturedHttpRequest request)
=> JsonNode.Parse(request.Body!)!.AsObject(); => JsonNode.Parse(request.Body!)!.AsObject();
/// <summary> /// <summary>
/// Снимает заголовок авторизации Bearer (null — заголовка нет). /// Снимает заголовок авторизации Bearer
/// </summary> /// </summary>
/// <param name="request">Снимок запроса.</param> /// <param name="request">Снимок запроса.</param>
public static string? BearerOf(CapturedHttpRequest request) public static string? BearerOf(CapturedHttpRequest request)
+4 -31
View File
@@ -6,41 +6,17 @@ using Deal.Grpc.Hosting.Services;
namespace Deal.Ai; namespace Deal.Ai;
/// <summary> /// <summary>
/// Собирает WebApplication gRPC-хоста ai-service (план Task 4/7/8; Ruling 1/2/5/12). /// Собирает WebApplication gRPC-хоста ai-service.
///
/// Продакшн-точка входа вызывает <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 и функции паузы ретраев).
/// </summary> /// </summary>
public static class AiServiceHost public static class AiServiceHost
{ {
/// <summary> /// <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> /// </summary>
/// <param name="grpcPort">TCP-порт Kestrel.</param> /// <param name="grpcPort">TCP-порт Kestrel.</param>
/// <param name="args">Аргументы командной строки (Program.cs); в тестах не нужны.</param> /// <param name="args">Аргументы командной строки (Program.cs); в тестах не нужны.</param>
/// <param name="configureServices"> /// <param name="configureServices">Опциональный хук DI для тестов (подмена LLM-фасада фейками: регистрация, добавленная здесь, побеждает — DI резолвит последнюю; см. AiServiceHostTests/AiRpcTests).</param>
/// Опциональный хук DI для тестов (подмена LLM-фасада фейками: регистрация, добавленная здесь, /// <param name="configureBuilder">Опциональный хук конфигурации билдера для production-точки входа (Program.cs): Serilog. Тесты хост поднимают БЕЗ этого хука — логирование файлов/консоли тестам не нужно.</param>
/// побеждает — DI резолвит последнюю; см. AiServiceHostTests/AiRpcTests).
/// </param>
/// <param name="configureBuilder">
/// Опциональный хук конфигурации билдера для production-точки входа (Program.cs): Serilog
/// (DealLogging.Configure, Ruling 7/Task 14). Тесты хост поднимают БЕЗ этого хука — логирование
/// файлов/консоли тестам не нужно.
/// </param>
/// <returns>Собранный хост; запуск — StartAsync/RunAsync у вызывающего.</returns> /// <returns>Собранный хост; запуск — StartAsync/RunAsync у вызывающего.</returns>
public static WebApplication Create( public static WebApplication Create(
int grpcPort, int grpcPort,
@@ -52,14 +28,11 @@ public static class AiServiceHost
// Общая серверная обвязка (Deal.Grpc.Hosting, C31): mTLS env DEAL_MTLS_* — загрузка // Общая серверная обвязка (Deal.Grpc.Hosting, C31): mTLS env DEAL_MTLS_* — загрузка
// сертификатов сразу с fail-fast (compose-prod монтирует deploy/certs, scripts/mtls-certs.sh); // сертификатов сразу с 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); MtlsCertificates? mtlsCertificates = GrpcServer.LoadMtlsCertificates(builder);
GrpcServer.ConfigureKestrelHttp2Endpoint(builder, grpcPort, mtlsCertificates); GrpcServer.ConfigureKestrelHttp2Endpoint(builder, grpcPort, mtlsCertificates);
builder.Services.AddDealGrpcServer(); builder.Services.AddDealGrpcServer();
builder.Services.AddReadyHealthCheck("хост ai-service готов"); builder.Services.AddReadyHealthCheck("хост ai-service готов");
// LLM-фасад провайдеров (план Task 7, Ruling 5): HTTP-клиент одной попытки вызова (таймаут
// попытки управляется внутри — 90 с OpenAI / 60 с Anthropic; клиент без общего таймаута) и // попытки управляется внутри — 90 с OpenAI / 60 с Anthropic; клиент без общего таймаута) и
// оркестратор ретраев/JSON/usage поверх него. Ключи API — в конфиге запроса, не в DI/логах. // оркестратор ретраев/JSON/usage поверх него. Ключи API — в конфиге запроса, не в DI/логах.
builder.Services.AddHttpClient<Llm.LlmHttpClient>(static httpClient => builder.Services.AddHttpClient<Llm.LlmHttpClient>(static httpClient =>
+7 -53
View File
@@ -7,23 +7,12 @@ using Grpc.Core;
namespace Deal.Ai; namespace Deal.Ai;
/// <summary> /// <summary>
/// Реализация серверной стороны Deal.Grpc.Ai.AiService — команды ядра в ai-service /// Реализация серверной стороны 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).
/// </summary> /// </summary>
public sealed class AiServiceImpl : AiService.AiServiceBase public sealed class AiServiceImpl : AiService.AiServiceBase
{ {
/// <summary> /// <summary>
/// Ключ gRPC-metadata с id тенанта (обязателен на всех RPC — Ruling 1). /// Ключ gRPC-metadata с id тенанта.
/// </summary> /// </summary>
public const string TenantIdMetadataKey = "tenant-id"; public const string TenantIdMetadataKey = "tenant-id";
@@ -57,10 +46,8 @@ public sealed class AiServiceImpl : AiService.AiServiceBase
// Деталь отказа: ключ задачи длиннее лимита (INVALID_ARGUMENT). // Деталь отказа: ключ задачи длиннее лимита (INVALID_ARGUMENT).
private const string KeywordTooLongDetail = "Слишком длинный ключ задачи"; private const string KeywordTooLongDetail = "Слишком длинный ключ задачи";
// Потолок длины текста сообщения (1:1: core обрезает до 4000 — ai.proto Filter.text/EvaluateFit.text).
private const int MaxTextLength = 4000; private const int MaxTextLength = 4000;
// Потолок длины описания ниши/задачи (1:1: core обрезает до 4000 — ai.proto GenerateKeywords.description).
private const int MaxDescriptionLength = 4000; private const int MaxDescriptionLength = 4000;
// Защитный потолок длины промпта (Filter.prompt/Classify.system_prompt; лимит не декларирован). // Защитный потолок длины промпта (Filter.prompt/Classify.system_prompt; лимит не декларирован).
@@ -69,20 +56,14 @@ public sealed class AiServiceImpl : AiService.AiServiceBase
// Защитный потолок длины user-контекста Classify (лимит не декларирован). // Защитный потолок длины user-контекста Classify (лимит не декларирован).
private const int MaxUserContextLength = 20000; private const int MaxUserContextLength = 20000;
// Потолок числа ключей задачи EvaluateFit (после _clean_keywords ядро шлёт ≤30 — запас на рост).
private const int MaxKeywordsCount = 200; private const int MaxKeywordsCount = 200;
// Потолок длины одного ключа задачи EvaluateFit (_clean_keywords: ≤60 симв. — запас на рост).
private const int MaxKeywordLength = 200; private const int MaxKeywordLength = 200;
// Префикс пользовательского сообщения фильтра (1:1 ai.py filter_incoming L193).
private const string FilterUserPrefix = "Сообщение:\n"; private const string FilterUserPrefix = "Сообщение:\n";
// Префикс пользовательского сообщения генератора ключей (1:1 discovery_routes L206).
private const string KeywordsUserPrefix = "Описание ниши/задачи:\n"; private const string KeywordsUserPrefix = "Описание ниши/задачи:\n";
// Фиксированный системный промпт генерации ключевых слов (1:1 _KEYWORDS_PROMPT
// discovery_routes L3647; пользовательское сообщение — описание задачи).
private const string GenerateKeywordsSystemPrompt = private const string GenerateKeywordsSystemPrompt =
"Ты — эксперт по поиску Telegram-каналов и групп. По описанию ниши/задачи " "Ты — эксперт по поиску Telegram-каналов и групп. По описанию ниши/задачи "
+ "составь поисковые ключевые слова, по которым в глобальном поиске Telegram " + "составь поисковые ключевые слова, по которым в глобальном поиске Telegram "
@@ -95,35 +76,26 @@ public sealed class AiServiceImpl : AiService.AiServiceBase
+ "- конкретные для ниши, включая сленг заказчиков и подрядчиков;\n" + "- конкретные для ниши, включая сленг заказчиков и подрядчиков;\n"
+ "- без дублей и близких по смыслу повторов."; + "- без дублей и близких по смыслу повторов.";
// Шаблон системного промпта оценки fit (1:1 _AI_PROMPT discovery_eval L5054): подставляются
// описание и ключи задачи (строкой через запятую, как _ai_prompt L153155).
private const string EvaluateFitSystemPromptTemplate = private const string EvaluateFitSystemPromptTemplate =
"Оцени, относится ли сообщение к сфере/задаче. Описание: {0}. Ключи: {1}. " "Оцени, относится ли сообщение к сфере/задаче. Описание: {0}. Ключи: {1}. "
+ "Верни JSON {{\"fit\": 0|1, \"reason\": \"краткая причина\"}}."; + "Верни JSON {{\"fit\": 0|1, \"reason\": \"краткая причина\"}}.";
// Потолок длины причины решения модели (1:1 _AI_REASON_LIMIT discovery_eval L43).
private const int MaxEvalReasonLength = 200; private const int MaxEvalReasonLength = 200;
// Причина по умолчанию при fit=true, если модель причину не дала (1:1 _ai_reason L170).
private const string FitReasonDefault = "подходит"; private const string FitReasonDefault = "подходит";
// Причина по умолчанию при fit=false, если модель причину не дала (1:1 _ai_reason L170).
private const string NotFitReasonDefault = "не подходит"; private const string NotFitReasonDefault = "не подходит";
// Имя поля решения фильтра в JSON-ответе модели (1:1 ai.py L195).
private const string PassFieldName = "pass"; private const string PassFieldName = "pass";
// Имя поля причины в JSON-ответе модели. // Имя поля причины в JSON-ответе модели.
private const string ReasonFieldName = "reason"; private const string ReasonFieldName = "reason";
// Имя поля решения fit в JSON-ответе модели (1:1 discovery_eval _ai_fit).
private const string FitFieldName = "fit"; private const string FitFieldName = "fit";
// Имя поля списка ключевых слов в JSON-ответе модели. // Имя поля списка ключевых слов в JSON-ответе модели.
private const string KeywordsFieldName = "keywords"; 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) private static readonly IReadOnlySet<string> FalsyAnswerValues = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{ {
"0", "0",
@@ -141,7 +113,7 @@ public sealed class AiServiceImpl : AiService.AiServiceBase
/// Создаёт gRPC-сервис команд ядра поверх LLM-фасада. /// Создаёт gRPC-сервис команд ядра поверх LLM-фасада.
/// </summary> /// </summary>
/// <param name="caller">Оркестратор вызовов модели (ретраи + извлечение JSON + usage).</param> /// <param name="caller">Оркестратор вызовов модели (ретраи + извлечение JSON + usage).</param>
/// <param name="logger">Логгер аудита (Ruling 13; ключи API не логируются).</param> /// <param name="logger">Логгер аудита.</param>
public AiServiceImpl(ProviderCaller caller, ILogger<AiServiceImpl> logger) public AiServiceImpl(ProviderCaller caller, ILogger<AiServiceImpl> logger)
{ {
_caller = caller; _caller = caller;
@@ -149,10 +121,7 @@ public sealed class AiServiceImpl : AiService.AiServiceBase
} }
/// <summary> /// <summary>
/// Filter — ИИ-фильтр входящих сообщений (ai.py filter_incoming L188198): решение {pass, reason} /// Filter — ИИ-фильтр входящих сообщений
/// по заполненному ядром aiFilterPrompt (system) и тексту. Ветку «фильтр не применялся»
/// (aiFilterEnabled/недоступность) ядро обрабатывает до вызова; недоступность провайдера —
/// UNAVAILABLE (ядро пропускает сообщение).
/// </summary> /// </summary>
public override async Task<FilterReply> Filter(FilterRequest request, ServerCallContext context) public override async Task<FilterReply> Filter(FilterRequest request, ServerCallContext context)
{ {
@@ -190,11 +159,7 @@ public sealed class AiServiceImpl : AiService.AiServiceBase
} }
/// <summary> /// <summary>
/// Classify — полный разбор лида (ai.py classify L218258): ответ {ok, json}, где json — строка /// Classify — полный разбор лида
/// с извлечённым ответом модели (типовую схему задаёт промпт); строгий маппинг json → карточку
/// делает ядро (1:1 normalize_stack/clean_budget/build_contacts). Модель отвечала без
/// разбираемого JSON после ретраев → ok=false (не RPC-ошибка; ядро падает в локальный разбор);
/// провайдер недоступен → UNAVAILABLE.
/// </summary> /// </summary>
public override async Task<ClassifyReply> Classify(ClassifyRequest request, ServerCallContext context) public override async Task<ClassifyReply> Classify(ClassifyRequest request, ServerCallContext context)
{ {
@@ -235,9 +200,7 @@ public sealed class AiServiceImpl : AiService.AiServiceBase
} }
/// <summary> /// <summary>
/// GenerateKeywords — ключевые слова discovery-задачи по описанию (фикс. промпт discovery_routes /// GenerateKeywords — ключевые слова discovery-задачи по описанию
/// L36–47 + описание): ответ {keywords}. Очистку (_clean_keywords: ≤30, ≤60 симв., дедуп) и мягкую
/// ошибку для UI делает ядро (Ruling 11); недоступность провайдера — UNAVAILABLE.
/// </summary> /// </summary>
public override async Task<GenerateKeywordsReply> GenerateKeywords(GenerateKeywordsRequest request, ServerCallContext context) public override async Task<GenerateKeywordsReply> GenerateKeywords(GenerateKeywordsRequest request, ServerCallContext context)
{ {
@@ -272,9 +235,7 @@ public sealed class AiServiceImpl : AiService.AiServiceBase
} }
/// <summary> /// <summary>
/// EvaluateFit — оценка соответствия сообщения задаче поиска (промпт discovery_eval L5054; /// EvaluateFit — оценка соответствия сообщения задаче поиска
/// текст + описание + ключи задачи): ответ {fit, reason}. Ядро зовёт только при aiEnabled;
/// сбой — фолбэк на эвристику (Ruling 10).
/// </summary> /// </summary>
public override async Task<EvaluateFitReply> EvaluateFit(EvaluateFitRequest request, ServerCallContext context) 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: Контекст вызова. // context: Контекст вызова.
private static string RequireTenantId(ServerCallContext context) private static string RequireTenantId(ServerCallContext context)
{ {
@@ -389,7 +349,6 @@ public sealed class AiServiceImpl : AiService.AiServiceBase
providerConfig.ApiStyle); providerConfig.ApiStyle);
} }
// Пишет предупреждение аудита о недоступности ИИ (Ruling 13; без ключей и текстов).
// method: Имя RPC для аудита. // method: Имя RPC для аудита.
// tenantId: Id тенанта. // tenantId: Id тенанта.
// config: Конфиг провайдера вызова. // config: Конфиг провайдера вызова.
@@ -406,14 +365,11 @@ public sealed class AiServiceImpl : AiService.AiServiceBase
config.DisplayName, config.DisplayName,
callError.Kind); callError.Kind);
// Превращает ошибку фасада в RPC-ошибку UNAVAILABLE с detail 1:1 Ruling 5.
// callError: Итоговая ошибка фасада. // callError: Итоговая ошибка фасада.
private static RpcException ToUnavailable(LlmCallException callError) private static RpcException ToUnavailable(LlmCallException callError)
=> new(new Status(StatusCode.Unavailable, callError.Message)); => new(new Status(StatusCode.Unavailable, callError.Message));
// Читает булево поле JSON-ответа модели: bool как есть; строка — ложь только для значений из // Читает булево поле JSON-ответа модели: bool как есть; строка — ложь только для значений из
// FalsyAnswerValues (1:1 _ai_fit discovery_eval L158164); число — ненулевое = true;
// поля нет/не разбирается — defaultValue (фильтр: true, ai.py L195; fit: false, discovery_eval).
// json: Корневой объект ответа модели. // json: Корневой объект ответа модели.
// fieldName: Имя поля. // fieldName: Имя поля.
// defaultValue: Значение при отсутствии/неразбираемости поля. // defaultValue: Значение при отсутствии/неразбираемости поля.
@@ -461,7 +417,6 @@ public sealed class AiServiceImpl : AiService.AiServiceBase
} }
// Причина решения fit: поле reason модели, при отсутствии — «подходит»/«не подходит» // Причина решения fit: поле reason модели, при отсутствии — «подходит»/«не подходит»
// (1:1 _ai_reason discovery_eval L167171), потолок длины MaxEvalReasonLength.
// json: Корневой объект ответа модели. // json: Корневой объект ответа модели.
// fit: Решение модели. // fit: Решение модели.
private static string ReadFitReason(JsonObject json, bool 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: Ключи задачи. // keywords: Ключи задачи.
private static string JoinKeywords(IEnumerable<string> keywords) private static string JoinKeywords(IEnumerable<string> keywords)
=> string.Join( => string.Join(
@@ -1,10 +1,7 @@
namespace Deal.Ai.Llm; namespace Deal.Ai.Llm;
/// <summary> /// <summary>
/// Абстракция одного HTTP-вызова LLM-провайдера (план Task 7; seam для фейков тестов RPC-веток). /// Абстракция одного HTTP-вызова LLM-провайдера.
/// Реализация по конфигу выбирает схему вызова: 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"/>).
/// </summary> /// </summary>
public interface IProviderClient public interface IProviderClient
{ {
@@ -14,7 +11,6 @@ public interface IProviderClient
/// <param name="config">Конфиг активного провайдера (стиль API выбирается по <c>ApiStyle</c>).</param> /// <param name="config">Конфиг активного провайдера (стиль API выбирается по <c>ApiStyle</c>).</param>
/// <param name="systemPrompt">Системный промпт (заполненный ядром либо фиксированный сервиса).</param> /// <param name="systemPrompt">Системный промпт (заполненный ядром либо фиксированный сервиса).</param>
/// <param name="userText">Пользовательское сообщение/контекст.</param> /// <param name="userText">Пользовательское сообщение/контекст.</param>
/// <param name="cancellationToken">Токен отмены (deadline RPC).</param>
/// <returns>Текст ответа модели и usage API-ответа (null — провайдер usage не вернул).</returns> /// <returns>Текст ответа модели и usage API-ответа (null — провайдер usage не вернул).</returns>
public Task<ProviderChatResult> ChatAsync( public Task<ProviderChatResult> ChatAsync(
LlmConfig config, LlmConfig config,
+1 -4
View File
@@ -4,9 +4,7 @@ using System.Text.Json.Nodes;
namespace Deal.Ai.Llm; namespace Deal.Ai.Llm;
/// <summary> /// <summary>
/// Извлечение JSON-объекта из ответа модели (план Task 7; 1:1 extract_json ai.py L175183): /// Извлечение JSON-объекта из ответа модели
/// снимается markdown-обёртка ```json … ```, затем берётся срез между первой «{» и последней «}»,
/// результат парсится как объект. Любая аномалия — null (попытка считается неудачной и повторяется).
/// </summary> /// </summary>
public static class JsonExtractor public static class JsonExtractor
{ {
@@ -48,7 +46,6 @@ public static class JsonExtractor
} }
} }
// Снимает markdown-обёртку ```json … ``` (как в extract_json L177179): возвращает содержимое
// между открывающей и закрывающей обёртками; обёртки нет/незакрыта — null. // между открывающей и закрывающей обёртками; обёртки нет/незакрыта — null.
// raw: Текст ответа (уже обрезанный). // raw: Текст ответа (уже обрезанный).
private static string? UnwrapFence(string raw) private static string? UnwrapFence(string raw)
@@ -1,9 +1,7 @@
namespace Deal.Ai.Llm; namespace Deal.Ai.Llm;
/// <summary> /// <summary>
/// Итоговая ошибка вызова после исчерпания ретраев (план Task 7: «ошибки → исключение с кодом»). /// Итоговая ошибка вызова после исчерпания ретраев.
/// Текст сообщения — 1:1 Ruling 5 / ai.py L115117: «ИИ (имя) не ответил корректно — повторите
/// попытку через несколько секунд»; RPC-слой отдаёт его как detail статуса UNAVAILABLE.
/// </summary> /// </summary>
public sealed class LlmCallException : Exception public sealed class LlmCallException : Exception
{ {
@@ -32,8 +30,7 @@ public sealed class LlmCallException : Exception
public LlmCallFailureKind Kind { get; } public LlmCallFailureKind Kind { get; }
/// <summary> /// <summary>
/// Usage последней попытки, вернувшей текст модели: заполнен при <see cref="LlmCallFailureKind.AnswerNotJson"/> /// Usage последней попытки, вернувшей текст модели
/// (Classify отвечает ok=false и всё равно несёт usage; Ruling 5).
/// </summary> /// </summary>
public LlmUsage? Usage { get; } public LlmUsage? Usage { get; }
} }
@@ -1,19 +1,17 @@
namespace Deal.Ai.Llm; namespace Deal.Ai.Llm;
/// <summary> /// <summary>
/// Причина исчерпания попыток вызова (Ruling 5 / README ai.proto L201204): провайдер не ответил /// Причина исчерпания попыток вызова
/// корректно (UNAVAILABLE) либо модель отвечала, но ни один ответ не разобран как JSON
/// (Classify — ok=false, не RPC-ошибка).
/// </summary> /// </summary>
public enum LlmCallFailureKind public enum LlmCallFailureKind
{ {
/// <summary> /// <summary>
/// Провайдер не ответил после ретраев (сеть/таймаут/HTTP/пустой ответ) → UNAVAILABLE. /// Провайдер не ответил после ретраев
/// </summary> /// </summary>
ProviderUnavailable, ProviderUnavailable,
/// <summary> /// <summary>
/// Модель отвечала текстом, но JSON не извлечён после ретраев (Classify → ok=false). /// Модель отвечала текстом, но JSON не извлечён после ретраев
/// </summary> /// </summary>
AnswerNotJson, AnswerNotJson,
} }
+2 -3
View File
@@ -3,15 +3,14 @@ using System.Text.Json.Nodes;
namespace Deal.Ai.Llm; namespace Deal.Ai.Llm;
/// <summary> /// <summary>
/// Успешный результат вызова модели: извлечённый JSON-объект (схему задаёт промпт) и итоговая /// Успешный результат вызова модели
/// оценка токенов (usage API или оценка по символам).
/// </summary> /// </summary>
/// <param name="Json">Корневой объект JSON-ответа модели.</param> /// <param name="Json">Корневой объект JSON-ответа модели.</param>
/// <param name="Usage">Итоговая оценка токенов вызова.</param> /// <param name="Usage">Итоговая оценка токенов вызова.</param>
public sealed record LlmCallResult(JsonObject Json, LlmUsage Usage) public sealed record LlmCallResult(JsonObject Json, LlmUsage Usage)
{ {
/// <summary> /// <summary>
/// Извлечённый ответ модели компактной json-строкой (ClassifyReply.json — маппинг в ядре). /// Извлечённый ответ модели компактной json-строкой
/// </summary> /// </summary>
public string JsonText => Json.ToJsonString(); public string JsonText => Json.ToJsonString();
} }
+4 -9
View File
@@ -1,10 +1,7 @@
namespace Deal.Ai.Llm; namespace Deal.Ai.Llm;
/// <summary> /// <summary>
/// Эффективный конфиг LLM-провайдера на один вызов (план Task 7, Ruling 5): зеркало /// Эффективный конфиг LLM-провайдера на один вызов
/// <c>ProviderConfig</c> из ai.proto. Ядро передаёт заполненный конфиг в теле каждого запроса
/// (base_url/model/api_key расшифрованы, api_style — из каталога AiProviders); сервис настроек
/// тенанта не хранит и не знает.
/// </summary> /// </summary>
public sealed record LlmConfig( public sealed record LlmConfig(
string ProviderId, string ProviderId,
@@ -14,12 +11,10 @@ public sealed record LlmConfig(
string? ApiStyle) string? ApiStyle)
{ {
/// <summary> /// <summary>
/// Значение api_style для Anthropic Messages API (пусто/иное — OpenAI-совместимый). /// Значение api_style для Anthropic Messages API
/// </summary> /// </summary>
public const string AnthropicApiStyle = "anthropic"; public const string AnthropicApiStyle = "anthropic";
// Отображаемые имена известных провайдеров (1:1 каталог AiProviders констант python) — для
// текста ошибки «ИИ (имя) …» (Ruling 5, ai.py L115117). Неизвестный id — как есть.
private static readonly IReadOnlyDictionary<string, string> KnownProviderNames = private static readonly IReadOnlyDictionary<string, string> KnownProviderNames =
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase) new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{ {
@@ -33,12 +28,12 @@ public sealed record LlmConfig(
}; };
/// <summary> /// <summary>
/// Истинно, когда конфиг задаёт Anthropic Messages API (x-api-key + anthropic-version). /// Истинно, когда конфиг задаёт Anthropic Messages API
/// </summary> /// </summary>
public bool IsAnthropic => string.Equals(ApiStyle, AnthropicApiStyle, StringComparison.OrdinalIgnoreCase); public bool IsAnthropic => string.Equals(ApiStyle, AnthropicApiStyle, StringComparison.OrdinalIgnoreCase);
/// <summary> /// <summary>
/// Имя провайдера для сообщений об ошибках и логов (ключ API в него не входит). /// Имя провайдера для сообщений об ошибках и логов
/// </summary> /// </summary>
public string DisplayName => KnownProviderNames.TryGetValue(ProviderId, out string? name) public string DisplayName => KnownProviderNames.TryGetValue(ProviderId, out string? name)
? name ? name
+3 -21
View File
@@ -5,12 +5,7 @@ using System.Text.Json.Nodes;
namespace Deal.Ai.Llm; namespace Deal.Ai.Llm;
/// <summary> /// <summary>
/// HTTP-реализация <see cref="IProviderClient"/> (план Task 7; 1:1 ai.py _call_openai/_call_anthropic /// HTTP-реализация <see cref="IProviderClient"/>
/// 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).
/// </summary> /// </summary>
public sealed class LlmHttpClient : IProviderClient public sealed class LlmHttpClient : IProviderClient
{ {
@@ -23,22 +18,17 @@ public sealed class LlmHttpClient : IProviderClient
// Заголовок версии Anthropic API. // Заголовок версии Anthropic API.
private const string AnthropicVersionHeader = "anthropic-version"; private const string AnthropicVersionHeader = "anthropic-version";
// Значение версии Anthropic API (фиксированное, как в python).
private const string AnthropicVersionValue = "2023-06-01"; private const string AnthropicVersionValue = "2023-06-01";
// Заголовок ключа Anthropic API. // Заголовок ключа Anthropic API.
private const string AnthropicApiKeyHeader = "x-api-key"; private const string AnthropicApiKeyHeader = "x-api-key";
// Температура вызовов OpenAI-совместимых API (Ruling 5; как в ai.py L137).
private const double Temperature = 0.2; private const double Temperature = 0.2;
// Потолок токенов ответа (max_tokens; как в python для обоих стилей).
private const int MaxResponseTokens = 8000; private const int MaxResponseTokens = 8000;
// Таймаут одной попытки OpenAI-совместимого вызова (Ruling 5; 90 с).
private static readonly TimeSpan OpenAiCallTimeout = TimeSpan.FromSeconds(90); private static readonly TimeSpan OpenAiCallTimeout = TimeSpan.FromSeconds(90);
// Таймаут одной попытки Anthropic-вызова (Ruling 5; 60 с).
private static readonly TimeSpan AnthropicCallTimeout = TimeSpan.FromSeconds(60); private static readonly TimeSpan AnthropicCallTimeout = TimeSpan.FromSeconds(60);
private readonly HttpClient _httpClient; private readonly HttpClient _httpClient;
@@ -46,7 +36,7 @@ public sealed class LlmHttpClient : IProviderClient
private readonly TimeSpan _anthropicCallTimeout; private readonly TimeSpan _anthropicCallTimeout;
/// <summary> /// <summary>
/// Создаёт HTTP-клиент провайдеров с типовыми таймаутами (90/60 с). /// Создаёт HTTP-клиент провайдеров с типовыми таймаутами
/// </summary> /// </summary>
/// <param name="httpClient">HttpClient (регистрируется в DI; таймаут управляется на попытку).</param> /// <param name="httpClient">HttpClient (регистрируется в DI; таймаут управляется на попытку).</param>
public LlmHttpClient(HttpClient httpClient) public LlmHttpClient(HttpClient httpClient)
@@ -69,12 +59,11 @@ public sealed class LlmHttpClient : IProviderClient
} }
/// <summary> /// <summary>
/// Выполняет один вызов модели по выбранной схеме API (Ruling 5). /// Выполняет один вызов модели по выбранной схеме API.
/// </summary> /// </summary>
/// <param name="config">Конфиг провайдера (стиль — <c>ApiStyle</c>).</param> /// <param name="config">Конфиг провайдера (стиль — <c>ApiStyle</c>).</param>
/// <param name="systemPrompt">Системный промпт.</param> /// <param name="systemPrompt">Системный промпт.</param>
/// <param name="userText">Пользовательское сообщение/контекст.</param> /// <param name="userText">Пользовательское сообщение/контекст.</param>
/// <param name="cancellationToken">Токен отмены (deadline RPC).</param>
/// <returns>Текст ответа и usage API-ответа (null при его отсутствии).</returns> /// <returns>Текст ответа и usage API-ответа (null при его отсутствии).</returns>
public async Task<ProviderChatResult> ChatAsync( public async Task<ProviderChatResult> ChatAsync(
LlmConfig config, LlmConfig config,
@@ -109,7 +98,6 @@ public sealed class LlmHttpClient : IProviderClient
} }
// Собирает запрос OpenAI-совместимого чата: {base}/chat/completions, Bearer при заданном ключе, // Собирает запрос OpenAI-совместимого чата: {base}/chat/completions, Bearer при заданном ключе,
// тело 1:1 ai.py L126139 (messages system/user, temperature 0.2, max_tokens 8000).
// config: Конфиг провайдера. // config: Конфиг провайдера.
// systemPrompt: Системный промпт. // systemPrompt: Системный промпт.
// userText: Пользовательское сообщение. // userText: Пользовательское сообщение.
@@ -139,7 +127,6 @@ public sealed class LlmHttpClient : IProviderClient
} }
// Собирает запрос Anthropic Messages API: {base}/v1/messages, x-api-key + anthropic-version, // Собирает запрос Anthropic Messages API: {base}/v1/messages, x-api-key + anthropic-version,
// тело 1:1 ai.py L155167 (system отдельным полем, messages=[user]).
// config: Конфиг провайдера. // config: Конфиг провайдера.
// systemPrompt: Системный промпт. // systemPrompt: Системный промпт.
// userText: Пользовательское сообщение. // userText: Пользовательское сообщение.
@@ -184,7 +171,6 @@ public sealed class LlmHttpClient : IProviderClient
return isAnthropic ? ReadAnthropicBody(body) : ReadOpenAiBody(body); return isAnthropic ? ReadAnthropicBody(body) : ReadOpenAiBody(body);
} }
// Разбирает OpenAI-совместимый ответ: choices[0].message.content (+ usage; ai.py L143152).
// body: Тело ответа. // body: Тело ответа.
private static ProviderChatResult ReadOpenAiBody(string body) private static ProviderChatResult ReadOpenAiBody(string body)
{ {
@@ -201,14 +187,12 @@ public sealed class LlmHttpClient : IProviderClient
string? content = ReadStringField(message, "content"); string? content = ReadStringField(message, "content");
if (string.IsNullOrEmpty(content) && !string.IsNullOrEmpty(ReadStringField(message, "reasoning_content"))) if (string.IsNullOrEmpty(content) && !string.IsNullOrEmpty(ReadStringField(message, "reasoning_content")))
{ {
// Модель «подумала», но ответа не дала (переполнение/обрыв) — сбой, пробуем ещё раз (ai.py L149151).
throw new LlmHttpException("Модель вернула только reasoning без ответа"); throw new LlmHttpException("Модель вернула только reasoning без ответа");
} }
return new ProviderChatResult(content ?? string.Empty, ReadOpenAiUsage(payload["usage"])); return new ProviderChatResult(content ?? string.Empty, ReadOpenAiUsage(payload["usage"]));
} }
// Разбирает Anthropic-ответ: склейка text блоков content[] (+ usage input/output; ai.py L168172).
// body: Тело ответа. // body: Тело ответа.
private static ProviderChatResult ReadAnthropicBody(string body) private static ProviderChatResult ReadAnthropicBody(string body)
{ {
@@ -281,7 +265,6 @@ public sealed class LlmHttpClient : IProviderClient
throw UnexpectedApiResponse("тело не является JSON-объектом"); throw UnexpectedApiResponse("тело не является JSON-объектом");
} }
// Собирает текст ошибки неожиданного ответа: только тип/причина, без содержимого тела (Ruling 13).
// failureKind: Короткая причина (без тела ответа и секретов). // failureKind: Короткая причина (без тела ответа и секретов).
private static LlmHttpException UnexpectedApiResponse(string failureKind) private static LlmHttpException UnexpectedApiResponse(string failureKind)
=> new($"Неожиданный ответ ИИ-провайдера: {failureKind}"); => new($"Неожиданный ответ ИИ-провайдера: {failureKind}");
@@ -327,7 +310,6 @@ public sealed class LlmHttpClient : IProviderClient
return result; return result;
} }
// Убирает хвостовые «/» базового URL (как ai.py L90: rstrip("/")).
// baseUrl: Базовый URL из конфига. // baseUrl: Базовый URL из конфига.
private static string NormalizeBaseUrl(string baseUrl) => baseUrl.TrimEnd('/'); private static string NormalizeBaseUrl(string baseUrl) => baseUrl.TrimEnd('/');
} }
@@ -1,9 +1,7 @@
namespace Deal.Ai.Llm; namespace Deal.Ai.Llm;
/// <summary> /// <summary>
/// Ошибка одной HTTP-попытки вызова провайдера (план Task 7): сетевой сбой, таймаут, HTTP-ошибка /// Ошибка одной HTTP-попытки вызова провайдера
/// или неожиданная форма ответа API. Обрабатывается в <see cref="ProviderCaller"/> как повод для
/// ретрая; текст внутренний (ключи/секреты и тело ответа в него не попадают — Ruling 13).
/// </summary> /// </summary>
public sealed class LlmHttpException : Exception public sealed class LlmHttpException : Exception
{ {
+3 -5
View File
@@ -1,14 +1,12 @@
namespace Deal.Ai.Llm; namespace Deal.Ai.Llm;
/// <summary> /// <summary>
/// Политика ретраев вызова LLM (план Task 7; ai.py chat_json L96117): max_retries=2 → всего 3 /// Политика ретраев вызова LLM
/// попытки с нарастающими паузами 0.8 с и 2 с между ними. Разовый сбой (перегрузка API, пустой/
/// не-JSON ответ) не должен превращаться в «ИИ недоступен» без повторных попыток.
/// </summary> /// </summary>
public static class LlmRetryPolicy public static class LlmRetryPolicy
{ {
/// <summary> /// <summary>
/// Число дополнительных попыток после первой (всего — <see cref="AttemptCount"/>). /// Число дополнительных попыток после первой
/// </summary> /// </summary>
public const int RetryCount = 2; public const int RetryCount = 2;
@@ -18,7 +16,7 @@ public static class LlmRetryPolicy
public const int AttemptCount = RetryCount + 1; public const int AttemptCount = RetryCount + 1;
/// <summary> /// <summary>
/// Паузы между попытками: 0.8 с (после 1-й) и 2 с (после 2-й). /// Паузы между попытками
/// </summary> /// </summary>
public static readonly IReadOnlyList<TimeSpan> RetryDelays = public static readonly IReadOnlyList<TimeSpan> RetryDelays =
[ [
+1 -3
View File
@@ -1,9 +1,7 @@
namespace Deal.Ai.Llm; namespace Deal.Ai.Llm;
/// <summary> /// <summary>
/// Итоговая оценка токенов вызова для gRPC-ответа (Ruling 5): берётся из usage API-ответа /// Итоговая оценка токенов вызова для gRPC-ответа
/// провайдера, при его отсутствии оценивается по символам (≈chars/4). Ядро копит значения
/// в tenant-KV aiTokenUsage.
/// </summary> /// </summary>
/// <param name="PromptTokens">Токены запроса (система + пользователь).</param> /// <param name="PromptTokens">Токены запроса (система + пользователь).</param>
/// <param name="CompletionTokens">Токены ответа модели.</param> /// <param name="CompletionTokens">Токены ответа модели.</param>
+1 -8
View File
@@ -3,12 +3,7 @@ using System.Text.Json.Nodes;
namespace Deal.Ai.Llm; namespace Deal.Ai.Llm;
/// <summary> /// <summary>
/// Оркестратор вызова модели с ретраями и извлечением JSON (план Task 7; 1:1 chat_json ai.py /// Оркестратор вызова модели с ретраями и извлечением JSON
/// 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).
/// </summary> /// </summary>
public sealed class ProviderCaller public sealed class ProviderCaller
{ {
@@ -38,7 +33,6 @@ public sealed class ProviderCaller
/// <param name="config">Конфиг активного провайдера.</param> /// <param name="config">Конфиг активного провайдера.</param>
/// <param name="systemPrompt">Системный промпт (заполненный ядром или фиксированный сервиса).</param> /// <param name="systemPrompt">Системный промпт (заполненный ядром или фиксированный сервиса).</param>
/// <param name="userText">Пользовательское сообщение/контекст.</param> /// <param name="userText">Пользовательское сообщение/контекст.</param>
/// <param name="cancellationToken">Токен отмены (deadline RPC).</param>
/// <returns>Извлечённый JSON-объект и итоговую оценку токенов.</returns> /// <returns>Извлечённый JSON-объект и итоговую оценку токенов.</returns>
/// <exception cref="LlmCallException">Все попытки исчерпаны (см. <see cref="LlmCallFailureKind"/>).</exception> /// <exception cref="LlmCallException">Все попытки исчерпаны (см. <see cref="LlmCallFailureKind"/>).</exception>
public async Task<LlmCallResult> ChatJsonAsync( public async Task<LlmCallResult> ChatJsonAsync(
@@ -82,7 +76,6 @@ public sealed class ProviderCaller
if (lastModelText is not null) if (lastModelText is not null)
{ {
// Модель отвечала текстом, но ни одна попытка не дала разбираемый JSON (README ai.proto L201204).
LlmUsage usage = TokenEstimator.Resolve(lastUsage, promptText, lastModelText); LlmUsage usage = TokenEstimator.Resolve(lastUsage, promptText, lastModelText);
throw new LlmCallException(LlmCallFailureKind.AnswerNotJson, config.DisplayName, usage); throw new LlmCallException(LlmCallFailureKind.AnswerNotJson, config.DisplayName, usage);
} }
@@ -1,7 +1,7 @@
namespace Deal.Ai.Llm; namespace Deal.Ai.Llm;
/// <summary> /// <summary>
/// Результат одной успешной HTTP-попытки вызова модели (текст ответа + usage API). /// Результат одной успешной HTTP-попытки вызова модели
/// </summary> /// </summary>
/// <param name="Text">Текст ответа модели (может быть не-JSON — разбор в <see cref="JsonExtractor"/>).</param> /// <param name="Text">Текст ответа модели (может быть не-JSON — разбор в <see cref="JsonExtractor"/>).</param>
/// <param name="Usage">Usage из API-ответа провайдера; null — провайдер его не вернул (оценка по символам).</param> /// <param name="Usage">Usage из API-ответа провайдера; null — провайдер его не вернул (оценка по символам).</param>
+1 -3
View File
@@ -1,9 +1,7 @@
namespace Deal.Ai.Llm; namespace Deal.Ai.Llm;
/// <summary> /// <summary>
/// Usage токенов из API-ответа провайдера (Ruling 5). Поля не путать с <see cref="LlmUsage"/>: /// Usage токенов из API-ответа провайдера.
/// здесь «как отдал провайдер» (total берём как есть — у провайдера он может отличаться от суммы),
/// финальную оценку/подстановку делает <see cref="TokenEstimator"/>.
/// </summary> /// </summary>
/// <param name="PromptTokens">Токены запроса (система + пользователь).</param> /// <param name="PromptTokens">Токены запроса (система + пользователь).</param>
/// <param name="CompletionTokens">Токены ответа модели.</param> /// <param name="CompletionTokens">Токены ответа модели.</param>
+2 -5
View File
@@ -1,17 +1,14 @@
namespace Deal.Ai.Llm; namespace Deal.Ai.Llm;
/// <summary> /// <summary>
/// Оценка токенов вызова (план Task 7, Ruling 5): при отсутствии usage в API-ответе токены /// Оценка токенов вызова
/// оцениваются по символам ≈ chars/4 (округление вверх). Запрос = system + user, ответ = текст модели.
/// </summary> /// </summary>
public static class TokenEstimator public static class TokenEstimator
{ {
// Примерное число символов на один токен (Ruling 5: «≈chars/4»).
private const int EstimatedCharsPerToken = 4; private const int EstimatedCharsPerToken = 4;
/// <summary> /// <summary>
/// Сводит usage вызова: usage провайдера как есть (total «берём как есть»), при отсутствии — /// Сводит usage вызова
/// оценка по длинам промпта и ответа.
/// </summary> /// </summary>
/// <param name="providerUsage">Usage из API-ответа (null — провайдер его не вернул).</param> /// <param name="providerUsage">Usage из API-ответа (null — провайдер его не вернул).</param>
/// <param name="promptText">Полный текст запроса (система + пользователь) для оценки.</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 // Kestrel HTTP/2 на порту 5102 (env GRPC_PORT, затем PORT) + AddGrpc с интерцепторами service-token
// и access-лога + стандартный gRPC-health (grpc.health.v1.Health). Транспорт: dev — plaintext // и 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). // Production без mTLS не стартует (GrpcHostEnvironment.RequireMtlsInProduction).
// Серверная обвязка (Kestrel/AddGrpc/health) — общий Deal.Grpc.Hosting (C31): хост-фабрика // Серверная обвязка (Kestrel/AddGrpc/health) — общий Deal.Grpc.Hosting (C31): хост-фабрика
// AiServiceHost.Create используется и интеграционными тестами (Deal.Ai.Tests), которые поднимают // AiServiceHost.Create используется и интеграционными тестами (Deal.Ai.Tests), которые поднимают
// его в своём процессе на эфемерном порту. Методы AiService (Filter/Classify/GenerateKeywords/ // его в своём процессе на эфемерном порту. Методы AiService (Filter/Classify/GenerateKeywords/
// EvaluateFit) реализованы поверх LLM-фасада (OpenAI-совместимые + Anthropic, без БД — Ruling 5;
// задачи 7–8): фасад живёт в Deal.Ai/Llm. // задачи 7–8): фасад живёт в Deal.Ai/Llm.
using Deal.Ai; using Deal.Ai;
@@ -17,17 +13,13 @@ using Deal.Grpc.Hosting.Models;
using Deal.Grpc.Hosting.Options; using Deal.Grpc.Hosting.Options;
using Deal.Grpc.Hosting.Services; using Deal.Grpc.Hosting.Services;
// Порт по умолчанию — 5102 (Ruling 12, compose.dev.yml); переопределяется env GRPC_PORT (контейнер)
// или PORT (общий конвенциональный env хостинг-платформ) — см. GrpcHostEnvironment.ResolveGrpcPort. // или PORT (общий конвенциональный env хостинг-платформ) — см. GrpcHostEnvironment.ResolveGrpcPort.
const int defaultGrpcPort = 5102; const int defaultGrpcPort = 5102;
// Имя процесса для rolling-файла логов (Ruling 7, Task 14): data/logs/deal-ai-<дата>.json.
const string aiProcessName = "ai"; const string aiProcessName = "ai";
int grpcPort = GrpcHostEnvironment.ResolveGrpcPort(defaultGrpcPort); int grpcPort = GrpcHostEnvironment.ResolveGrpcPort(defaultGrpcPort);
// Порт эндпоинта метрик /metrics (HTTP/1.1, отдельно от gRPC HTTP/2; этап 12, пакет A).
int metricsPort = DealMetricsHosting.ResolveMetricsPort(DealMetricsHosting.DefaultMetricsPort); int metricsPort = DealMetricsHosting.ResolveMetricsPort(DealMetricsHosting.DefaultMetricsPort);
// Serilog (Ruling 7, план Task 14): JSON-консоль + rolling-файл data/logs/deal-ai-*.json —
// конфигурируется production-точкой входа через configureBuilder-хук хоста (тесты хост поднимают // конфигурируется production-точкой входа через configureBuilder-хук хоста (тесты хост поднимают
// без Serilog, DealLogging.Configure в AiServiceHost/Create вызывается только здесь). Метрики // без Serilog, DealLogging.Configure в AiServiceHost/Create вызывается только здесь). Метрики
// (OTel → Prometheus, /metrics) — тем же хуком до builder.Build(). // (OTel → Prometheus, /metrics) — тем же хуком до builder.Build().
@@ -39,10 +31,8 @@ WebApplication app = AiServiceHost.Create(
DealMetricsHosting.AddDealMetrics(builder, metricsPort); DealMetricsHosting.AddDealMetrics(builder, metricsPort);
}); });
// Эндпоинт метрик /metrics (HTTP/1.1 на отдельном порту): формат Prometheus (этап 12, пакет A).
DealMetricsHosting.MapDealMetrics(app); DealMetricsHosting.MapDealMetrics(app);
// Режим транспорта — из тех же env, что читал хост (Ruling 6, Task 13): mTLS при DEAL_MTLS_ENABLED=1.
MtlsOptions mtlsOptions = MtlsOptions.FromConfiguration(app.Configuration); MtlsOptions mtlsOptions = MtlsOptions.FromConfiguration(app.Configuration);
// Fail-closed (замечание code-review): отсутствие/опечатка DEAL_MTLS_ENABLED не должны давать // 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
// текст + конфиг активного провайдера (ProviderConfig); сервис вызывает // POST {base}/v1/messages; temperature 0.2, таймауты 90/60 с, retry 2 с
// провайдера (OpenAI-совместимые POST {base}/chat/completions, Anthropic // паузами 0.8/2 с) и возвращает ответ + оценку токенов. Конфиг провайдера
// POST {base}/v1/messages; temperature 0.2, таймауты 90/60 с, retry 2 с // (id/base/model/apiKey/api_style) ядро кладёт в поле provider_config каждого
// паузами 0.8/2 с) и возвращает ответ + оценку токенов. Конфиг провайдера // запроса — сервис настроек тенанта не знает и не хранит.
// (id/base/model/apiKey/api_style) ядро кладёт в поле provider_config каждого //
// запроса — сервис настроек тенанта не знает и не хранит. // tenant-id — id тенанта (строка; учёт токенов в ядре по нему);
// // service-token — общий токен сервисов (env DEAL_SERVICE_TOKEN); неверный/
// Каждый RPC обязан нести в gRPC-metadata два заголовка (Ruling 1): // пустой → UNAUTHENTICATED.
// tenant-id — id тенанта (строка; учёт токенов в ядре по нему); //
// service-token — общий токен сервисов (env DEAL_SERVICE_TOKEN); неверный/ // INVALID_ARGUMENT — невалидный запрос (пустой текст/промпт и т.п.);
// пустой → UNAUTHENTICATED. // UNAVAILABLE — провайдер не ответил корректно после ретраев; detail =
// // «ИИ (имя) не ответил корректно — повторите попытку через
// Ошибки домена — gRPC-статусы (Ruling 1/5): // несколько секунд» (ядро падает в локальный разбор).
// INVALID_ARGUMENT — невалидный запрос (пустой текст/промпт и т.п.); //
// UNAVAILABLE — провайдер не ответил корректно после ретраев; detail = // Берётся из usage API-ответа провайдера; при отсутствии оценивается по
// «ИИ (имя) не ответил корректно — повторите попытку через // символам (≈chars/4). Ядро копит значения в tenant-KV aiTokenUsage.
// несколько секунд» (ядро падает в локальный разбор). //
// // Deadlines (клиент ядра): все RPC — 120 с (90 с провайдер + ретраи 0.8/2 с;
// Учёт токенов (Ruling 5): каждый reply несёт usage{prompt/completion/total}. syntax = "proto3";
// Берётся из usage API-ответа провайдера; при отсутствии оценивается по
// символам (≈chars/4). Ядро копит значения в tenant-KV aiTokenUsage. package deal.ai.v1;
//
// Deadlines (клиент ядра): все RPC — 120 с (90 с провайдер + ретраи 0.8/2 с; option csharp_namespace = "Deal.Grpc.Ai";
// при недоступности ядро не ждёт повторно — Ruling 6 кэш/фолбэк).
syntax = "proto3"; service AiService {
// ядро шлёт заполненный aiFilterPrompt (fill_prompt делает ядро) и текст;
package deal.ai.v1; // решение {pass, reason}. Ветку «фильтр не применялся» (aiFilterEnabled/
// недоступность) обрабатывает ядро до вызова — сервис всегда отвечает.
option csharp_namespace = "Deal.Grpc.Ai"; rpc Filter(FilterRequest) returns (FilterReply);
service AiService { // «Доски + примеры разметки + Сообщение»; сервис возвращает извлечённый
// ИИ-фильтр входящих сообщений (ai.py filter_incoming L188198, Ruling 5): // ответ модели как json-строку (типовую схему задаёт промпт). Строгий
// ядро шлёт заполненный aiFilterPrompt (fill_prompt делает ядро) и текст; // clean_budget/build_contacts).
// решение {pass, reason}. Ветку «фильтр не применялся» (aiFilterEnabled/ rpc Classify(ClassifyRequest) returns (ClassifyReply);
// недоступность) обрабатывает ядро до вызова — сервис всегда отвечает.
rpc Filter(FilterRequest) returns (FilterReply); // Генерация ключевых слов для discovery-задачи по описанию (фикс. промпт
rpc GenerateKeywords(GenerateKeywordsRequest) returns (GenerateKeywordsReply);
// Полный разбор лида (ai.py classify L218258, Ruling 5): ядро собирает
// system_prompt = заполненные aiPrompt + cardPrompt и user-контекст // Оценка соответствия сообщения задаче поиска (промпт discovery_eval
// «Доски + примеры разметки + Сообщение»; сервис возвращает извлечённый // Ядро зовёт только при aiEnabled; сбой/не-JSON — фолбэк на эвристику.
// ответ модели как json-строку (типовую схему задаёт промпт). Строгий rpc EvaluateFit(EvaluateFitRequest) returns (EvaluateFitReply);
// маппинг json → AiParsedCardDto делает ядро (1:1 normalize_stack/ }
// clean_budget/build_contacts).
rpc Classify(ClassifyRequest) returns (ClassifyReply); // --- Запросы/ответы AiService ---
// Генерация ключевых слов для discovery-задачи по описанию (фикс. промпт // aiConfigs и передаёт в теле каждого запроса; сервис не хранит настроек).
// discovery_routes L3647 + описание; Ruling 5): ответ {keywords}. Очистку // api_style — из каталога AiProviders (Settings); HTTP-клиенту провайдера
// (_clean_keywords: ≤30, ≤60 симв., дедуп) и мягкие ошибки делает ядро. // нужны baseUrl+model+apiKey для запроса и api_style для выбора схемы вызова
rpc GenerateKeywords(GenerateKeywordsRequest) returns (GenerateKeywordsReply); // (OpenAI-совместимые chat/completions vs Anthropic Messages API).
message ProviderConfig {
// Оценка соответствия сообщения задаче поиска (промпт discovery_eval // Id провайдера (ключ aiConfigs / каталога AiProviders: deepseek, openai,
// L5054; Ruling 5/10): текст + описание + ключи задачи → {fit, reason}. // anthropic, ollama, lmstudio, custom…).
// Ядро зовёт только при aiEnabled; сбой/не-JSON — фолбэк на эвристику. string provider_id = 1;
rpc EvaluateFit(EvaluateFitRequest) returns (EvaluateFitReply); // Эффективный базовый URL API (aiConfigs.baseUrl или дефолт каталога).
} string base_url = 2;
// API-ключ открытым текстом (расшифрован ядром); пуст для локальных
// --- Запросы/ответы AiService --- // провайдеров (ollama/lmstudio) — заголовок авторизации не шлётся.
optional string api_key = 3;
// Конфиг активного LLM-провайдера на запрос (Ruling 5: ядро расшифровывает // Активная модель (aiConfigs.model или первая из каталога провайдера).
// aiConfigs и передаёт в теле каждого запроса; сервис не хранит настроек). string model = 4;
// Форма 1:1 с эффективным конфигом core: настройка aiConfigs тенанта хранит // Стиль API: пуст — OpenAI-совместимый (POST {base}/chat/completions,
// {apiKey, baseUrl, model} (camelCase; apiKey шифруется AES-GCM этапа 2), // Bearer); "anthropic" — Messages API (POST {base}/v1/messages,
// api_style — из каталога AiProviders (Settings); HTTP-клиенту провайдера // x-api-key + anthropic-version).
// нужны baseUrl+model+apiKey для запроса и api_style для выбора схемы вызова optional string api_style = 5;
// (OpenAI-совместимые chat/completions vs Anthropic Messages API). }
message ProviderConfig {
// Id провайдера (ключ aiConfigs / каталога AiProviders: deepseek, openai, message FilterRequest {
// anthropic, ollama, lmstudio, custom…). // Заполненный промпт фильтра (настройка aiFilterPrompt с подстановкой
string provider_id = 1; string prompt = 1;
// Эффективный базовый URL API (aiConfigs.baseUrl или дефолт каталога). string text = 2;
string base_url = 2; ProviderConfig provider_config = 3;
// API-ключ открытым текстом (расшифрован ядром); пуст для локальных }
// провайдеров (ollama/lmstudio) — заголовок авторизации не шлётся.
optional string api_key = 3; message FilterReply {
// Активная модель (aiConfigs.model или первая из каталога провайдера). // True — сообщение проходит фильтр (не спам/реклама/служебное).
string model = 4; bool pass = 1;
// Стиль API: пуст — OpenAI-совместимый (POST {base}/chat/completions, // Причина отказа при pass=false (текст ветки filter_ai; пуст при пропуске).
// Bearer); "anthropic" — Messages API (POST {base}/v1/messages, optional string reason = 2;
// x-api-key + anthropic-version). Usage usage = 3;
optional string api_style = 5; }
}
message ClassifyRequest {
message FilterRequest { string system_prompt = 1;
// Заполненный промпт фильтра (настройка aiFilterPrompt с подстановкой // user-контекст: «Доски + примеры разметки + Новое сообщение» (собирает
// {domain}/{keywords} — делает ядро; Ruling 5). string user_context = 2;
string prompt = 1; ProviderConfig provider_config = 3;
// Текст сообщения (ядро обрезает до 4000, как ai.py L193). }
string text = 2;
// Конфиг активного провайдера (Ruling 5; все запросы ai-сервиса). message ClassifyReply {
ProviderConfig provider_config = 3; // True — модель вернула разбираемый JSON (ok=false — ответ без JSON после
} // ретраев; ядро трактует как «не разобрано» и падает в локальный путь).
bool ok = 1;
message FilterReply { // Сырой JSON-ответ модели (строкой; маппинг в карточку — в ядре).
// True — сообщение проходит фильтр (не спам/реклама/служебное). optional string json = 2;
bool pass = 1; Usage usage = 3;
// Причина отказа при pass=false (текст ветки filter_ai; пуст при пропуске). }
optional string reason = 2;
// Оценка токенов вызова (Ruling 5). message GenerateKeywordsRequest {
Usage usage = 3; string description = 1;
} ProviderConfig provider_config = 2;
}
message ClassifyRequest {
// system_prompt = заполненные aiPrompt + cardPrompt (структура карточки, message GenerateKeywordsReply {
// «О заявке»; собирает ядро — Ruling 5). // Сгенерированные ключи (пустой список — модель не выделила ключи;
string system_prompt = 1; repeated string keywords = 1;
// user-контекст: «Доски + примеры разметки + Новое сообщение» (собирает Usage usage = 2;
// ядро, 1:1 classify L243251). }
string user_context = 2;
// Конфиг активного провайдера (Ruling 5; все запросы ai-сервиса). message EvaluateFitRequest {
ProviderConfig provider_config = 3; // Текст сообщения для оценки (выборка кандидата; ядро ограничивает 4000).
} string text = 1;
string description = 2;
message ClassifyReply { repeated string keywords = 3;
// True — модель вернула разбираемый JSON (ok=false — ответ без JSON после ProviderConfig provider_config = 4;
// ретраев; ядро трактует как «не разобрано» и падает в локальный путь). }
bool ok = 1;
// Сырой JSON-ответ модели (строкой; маппинг в карточку — в ядре). message EvaluateFitReply {
optional string json = 2; // True — сообщение относится к сфере/задаче (JSON {"fit": 0|1}).
// Оценка токенов вызова (Ruling 5). bool fit = 1;
Usage usage = 3; // Краткая причина решения модели (пуст, если модель её не дала).
} optional string reason = 2;
Usage usage = 3;
message GenerateKeywordsRequest { }
// Описание ниши/задачи (ядро обрезает до 4000, discovery_routes L29).
string description = 1; // из usage API-ответа, при отсутствии — по символам ≈chars/4).
// Конфиг активного провайдера (Ruling 5; все запросы ai-сервиса). message Usage {
ProviderConfig provider_config = 2; // Токены запроса (system + user).
} uint32 prompt = 1;
// Токены ответа модели.
message GenerateKeywordsReply { uint32 completion = 2;
// Сгенерированные ключи (пустой список — модель не выделила ключи; // Суммарно (prompt + completion; может отличаться от суммы при подсчёте
// чистку/дедуп и мягкую ошибку для UI делает ядро — Ruling 11). // провайдером — берём как есть).
repeated string keywords = 1; uint32 total = 3;
// Оценка токенов вызова (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;
}
+127 -147
View File
@@ -1,147 +1,127 @@
// ml.proto — контракт между ядром Deal и ml-service (этап 6). //
// // (MlPredictResultDto/MlServiceStatusDto/MlEvalDto/MlResetResultDto).
// Инкрементальная наивно-байесовская модель по терминам, 1:1 с python // Модель per-tenant: пул в ml-service, файл SQLite data/ml/<tenantId>.sqlite
// mlservice/model.py (predict L184293, status L325345, reset L348354, //
// learn_batch L147173) и DTO ядра Deal.Contracts.Integrations.Models // tenant-id — id тенанта (строка; модель тенанта — в пуле сервиса);
// (MlPredictResultDto/MlServiceStatusDto/MlEvalDto/MlResetResultDto). // service-token — общий токен сервисов (env DEAL_SERVICE_TOKEN); неверный/
// Модель per-tenant: пул в ml-service, файл SQLite data/ml/<tenantId>.sqlite // пустой → UNAUTHENTICATED.
// (Ruling 4). Обучение ядро шлёт батчами из очереди ml_outbox //
// (MlOutboxFlushScheduler, Ruling 6). // INVALID_ARGUMENT — невалидный запрос (пустой text и т.п.);
// // UNAVAILABLE — хранилище модели недоступно (ядро отвечает «не уверен»,
// Каждый RPC обязан нести в gRPC-metadata два заголовка (Ruling 1): //
// tenant-id — id тенанта (строка; модель тенанта — в пуле сервиса); // Семантика неготовой модели: Predict НЕ ошибка — модель без опыта отвечает
// service-token — общий токен сервисов (env DEAL_SERVICE_TOKEN); неверный/ // фиксированным «не уверен»: take=false, label пуст, scores пуст, hits=0,
// пустой → UNAUTHENTICATED. //
// // Deadlines (клиент ядра): Predict — 5 с; Status/Reset — 10 с; TrainBatch — 30 с
// Ошибки домена — gRPC-статусы с detail = текст причины 1:1 (Ruling 1): // (батч ≤100 примеров, одна транзакция).
// INVALID_ARGUMENT — невалидный запрос (пустой text и т.п.); syntax = "proto3";
// UNAVAILABLE — хранилище модели недоступно (ядро отвечает «не уверен»,
// Ruling 6 — кэш reachable 15 с). package deal.ml.v1;
//
// Семантика неготовой модели: Predict НЕ ошибка — модель без опыта отвечает option csharp_namespace = "Deal.Grpc.Ml";
// фиксированным «не уверен»: take=false, label пуст, scores пуст, hits=0,
// ready=false, margin пуст, terms пуст, type пуст (Ruling 5 этапа 2, 1:1). service MlService {
// // take/label/scores/hits/margin/terms/type осмысленны только при take=true;
// Deadlines (клиент ядра): Predict — 5 с; Status/Reset — 10 с; TrainBatch — 30 с // scores — до 5 лучших «класс → вес» (round 3), margin — адаптивный порог
// (батч ≤100 примеров, одна транзакция). // (0.9/0.7/0.5/0.35 после 0/60/150/400 примеров), terms — узнанные термины
syntax = "proto3"; // класса-победителя, type — решение о типе заявки (t:hire/t:order).
rpc Predict(PredictRequest) returns (PredictReply);
package deal.ml.v1;
// classes — «label → вес» (round 2, по убыванию); eval — самооценка по окну
option csharp_namespace = "Deal.Grpc.Ml"; // последних подтверждённых решений (EVAL_WINDOW). Модель создаётся лениво
rpc Status(StatusRequest) returns (StatusReply);
service MlService {
// Предсказание по тексту сообщения (model.py predict L184293). // терминов и журнала самооценки + пересоздание файла. Ok=true; мягкая
// take/label/scores/hits/margin/terms/type осмысленны только при take=true; // ошибка — Ok=false + error (ядро чистит свою ml_outbox только при успехе).
// scores — до 5 лучших «класс → вес» (round 3), margin — адаптивный порог rpc Reset(ResetRequest) returns (ResetReply);
// (0.9/0.7/0.5/0.35 после 0/60/150/400 примеров), terms — узнанные термины
// класса-победителя, type — решение о типе заявки (t:hire/t:order). // пакетные вставки терминов; самооценка по действиям пользователя (delta=1,
rpc Predict(PredictRequest) returns (PredictReply); // не t:*) до применения. Ответ — число применённых примеров.
rpc TrainBatch(TrainBatchRequest) returns (TrainBatchReply);
// Статус модели тенанта (model.py status L325345): ready/classes/learned/eval. }
// classes — «label → вес» (round 2, по убыванию); eval — самооценка по окну
// последних подтверждённых решений (EVAL_WINDOW). Модель создаётся лениво message PredictRequest {
// по первому обращению (Ruling 4) — отсутствие опыта это НЕ ошибка. // Текст сообщения (ядро передаёт уже обрезанный/нормализованный, как
rpc Status(StatusRequest) returns (StatusReply); string text = 1;
}
// Полный сброс модели тенанта (model.py reset L348354): очистка классов,
// терминов и журнала самооценки + пересоздание файла. Ok=true; мягкая message PredictReply {
// ошибка — Ok=false + error (ядро чистит свою ml_outbox только при успехе). // True — модель уверена (take) и решение можно использовать без ИИ.
rpc Reset(ResetRequest) returns (ResetReply); bool take = 1;
// Класс решения: id колонки канбана (b_…) или "spam"; пуст, если не уверена.
// Пакетное обучение (model.py learn_batch L147173): одна транзакция + optional string label = 2;
// пакетные вставки терминов; самооценка по действиям пользователя (delta=1, // Веса классов: «label → вес» (до 5 лучших; пуст у неготовой модели).
// не t:*) до применения. Ответ — число применённых примеров. map<string, double> scores = 3;
rpc TrainBatch(TrainBatchRequest) returns (TrainBatchReply); // Сколько терминов класса-победителя модель узнала в тексте.
} int32 hits = 4;
// Модель обучена (набрала MIN_TOTAL/MIN_WINNER/MIN_WINNER_SPAM) и может
message PredictRequest { // принимать решения.
// Текст сообщения (ядро передаёт уже обрезанный/нормализованный, как bool ready = 5;
// ml_routes.py L8690; пустой/пробельный — не ошибка: ответ «не уверен»). // Порог уверенности решения (адаптивный margin, 2 знака); пуст — нет решения.
string text = 1; optional double margin = 6;
} // Узнанные термины класса-победителя (подсказка структуры карточки, ≤8).
repeated string terms = 7;
message PredictReply { // Решение о типе заявки (hire/order); пуст — модель тип не определила.
// True — модель уверена (take) и решение можно использовать без ИИ. TypeDecision type = 8;
bool take = 1; }
// Класс решения: id колонки канбана (b_…) или "spam"; пуст, если не уверена.
optional string label = 2; message TypeDecision {
// Веса классов: «label → вес» (до 5 лучших; пуст у неготовой модели). // True — модель уверена в типе.
map<string, double> scores = 3; bool take = 1;
// Сколько терминов класса-победителя модель узнала в тексте. // Тип: "hire" | "order".
int32 hits = 4; string label = 2;
// Модель обучена (набрала MIN_TOTAL/MIN_WINNER/MIN_WINNER_SPAM) и может // Внутренний класс ML: "t:hire" | "t:order" (не показывается UI).
// принимать решения. string value = 3;
bool ready = 5; // Запас уверенности (margin, 2 знака).
// Порог уверенности решения (адаптивный margin, 2 знака); пуст — нет решения. double margin = 4;
optional double margin = 6; }
// Узнанные термины класса-победителя (подсказка структуры карточки, ≤8).
repeated string terms = 7; message StatusRequest {}
// Решение о типе заявки (hire/order); пуст — модель тип не определила.
TypeDecision type = 8; message StatusReply {
} // Модель готова принимать решения.
bool ready = 1;
// Решение ML о типе заявки (predict L233238; MlTypeDecisionDto). // Классы модели: «label → вес» (round 2; пуст, пока нет обучения).
message TypeDecision { map<string, double> classes = 2;
// True — модель уверена в типе. // Всего примеров, на которых модель обучалась (сумма по классам).
bool take = 1; int32 learned = 3;
// Тип: "hire" | "order". // Самооценка модели по последним подтверждённым решениям.
string label = 2; ModelEval eval = 4;
// Внутренний класс ML: "t:hire" | "t:order" (не показывается UI). }
string value = 3;
// Запас уверенности (margin, 2 знака). message ModelEval {
double margin = 4; // Решений в окне самооценки (последние EVAL_WINDOW).
} int32 count = 1;
// Из них совпавших с действием пользователя.
message StatusRequest {} int32 correct = 2;
// Доля верных (correct/count, 0..1; 0 при пустом окне).
message StatusReply { double accuracy = 3;
// Модель готова принимать решения. }
bool ready = 1;
// Классы модели: «label → вес» (round 2; пуст, пока нет обучения). message ResetRequest {}
map<string, double> classes = 2;
// Всего примеров, на которых модель обучалась (сумма по классам). message ResetReply {
int32 learned = 3; // True — модель сброшена (и ядро очищает свою очередь обучения).
// Самооценка модели по последним подтверждённым решениям. bool ok = 1;
ModelEval eval = 4; // Текст ошибки при сбое сброса (пуст при успехе) — мягкая ошибка.
} optional string error = 2;
}
// Окно самооценки модели (model.py status L329339; MlEvalDto).
message ModelEval { message TrainBatchRequest {
// Решений в окне самооценки (последние EVAL_WINDOW). repeated TrainExample items = 1;
int32 count = 1; }
// Из них совпавших с действием пользователя.
int32 correct = 2; // Один обучающий пример (строка ml_outbox ядра: text/label/delta).
// Доля верных (correct/count, 0..1; 0 при пустом окне). message TrainExample {
double accuracy = 3; // Текст примера (source_msg карточки или title).
} string text = 1;
// Метка: id доски (b_…), "spam" либо тип "t:hire"/"t:order".
message ResetRequest {} string label = 2;
// Вес сигнала: 1.0 — действие пользователя; −1.0 — снять метку;
message ResetReply { double delta = 3;
// True — модель сброшена (и ядро очищает свою очередь обучения). }
bool ok = 1;
// Текст ошибки при сбое сброса (пуст при успехе) — мягкая ошибка. message TrainBatchReply {
optional string error = 2; // Число применённых примеров (= len(items) при успехе).
} int32 learned = 1;
}
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;
}
+401 -453
View File
@@ -1,453 +1,401 @@
// telegram.proto — контракт между ядром Deal и telegram-service (этап 6). //
// // * TelegramService — команды ядра к telegram-service (порт-гейт ITelegramGateway):
// Два сервиса в одном файле (дизайн-док §6.2, план Task 1, Ruling 1/7): // подключение/отключение аккаунта, каталог диалогов, мониторинг, backfill,
// * TelegramService — команды ядра к telegram-service (порт-гейт ITelegramGateway): // превью, discovery-операции (поиск/инфо/чтение/вступление/выход);
// подключение/отключение аккаунта, каталог диалогов, мониторинг, backfill, // * IngressService — исходящий поток telegram-service → ядро: сырые сообщения
// превью, discovery-операции (поиск/инфо/чтение/вступление/выход); // (PushMessage), синхронизация каталога (SyncDialogs), статус аккаунта
// * IngressService — исходящий поток telegram-service → ядро: сырые сообщения //
// (PushMessage), синхронизация каталога (SyncDialogs), статус аккаунта //
// (ReportStatus). Сервер ингресса живёт в Deal.Api (:5082, Ruling 7). // tenant-id — id тенанта (строка; единственный источник принадлежности,
// // полю в теле не доверяем);
// Семантика методов 1:1 с python-прототипом backend/app/services/telegram.py // service-token — общий токен сервисов (env DEAL_SERVICE_TOKEN); неверный/
// (имена L134873) и api-map §3.3/§4.8/§4.9; хранение диалогов/статуса — только // пустой → UNAUTHENTICATED.
// в ядре (модуль Deal.Modules.Telegram, Ruling 7), сервис БД тенантов не знает. //
// // INVALID_ARGUMENT — неверный ввод/неверный код/неверный пароль и т.п.;
// Каждый RPC обязан нести в gRPC-metadata два заголовка (Ruling 1): // NOT_FOUND — диалог/сущность не найдены (нет сессии тенанта и т.п.);
// tenant-id — id тенанта (строка; единственный источник принадлежности, // FAILED_PRECONDITION— операция невозможна в текущей фазе (нет сессии и т.п.);
// полю в теле не доверяем); // RESOURCE_EXHAUSTED — FloodWait Telegram (detail начинается с префикса "flood");
// service-token — общий токен сервисов (env DEAL_SERVICE_TOKEN); неверный/ // UNAVAILABLE — недоступность Telegram/сети (безопасный повтор).
// пустой → UNAUTHENTICATED. //
// // Значения строк (канон контракта, .NET-код обеих сторон — новый):
// Ошибки домена — gRPC-статусы с detail = текст причины 1:1 с прототипом: // * kind: channel (канал) | group (группа/супергруппа) | forum (форум) |
// INVALID_ARGUMENT — неверный ввод/неверный код/неверный пароль и т.п.; // channel, megagroup/gigagroup/group → group, остальное → chat.
// NOT_FOUND — диалог/сущность не найдены (нет сессии тенанта и т.п.); // Forum выставляется отдельным флагом is_forum (GetInfo); в
// FAILED_PRECONDITION— операция невозможна в текущей фазе (нет сессии и т.п.); // каталоге (RefreshDialogs) форум приходит как group.
// RESOURCE_EXHAUSTED — FloodWait Telegram (detail начинается с префикса "flood"); //
// UNAVAILABLE — недоступность Telegram/сети (безопасный повтор). // * быстрые команды статуса/мониторинга — 10 с;
// // * сетевые операции Telegram (QR/код/поиск/инфо/чтение/вступление) — 60 с;
// Значения строк (канон контракта, .NET-код обеих сторон — новый): // * Backfill/RefreshDialogs (паузы анти-бана 1.5–3 с/сообщение) — 120 с;
// * phase: idle|phone|code|password|qr|ready (как status() прототипа L85); // * IngressService (локальная сеть core) — 10 с (сбой догоняет sweep).
// * kind: channel (канал) | group (группа/супергруппа) | forum (форум) | syntax = "proto3";
// chat (личный чат/бот). 1:1 с _kind_of (L461466): broadcast →
// channel, megagroup/gigagroup/group → group, остальное → chat. package deal.telegram.v1;
// Forum выставляется отдельным флагом is_forum (GetInfo); в
// каталоге (RefreshDialogs) форум приходит как group. option csharp_namespace = "Deal.Grpc.Telegram";
//
// Deadlines (клиент ядра; уточняются адаптерами T2+): // ---------------------------------------------------------------------------
// * быстрые команды статуса/мониторинга — 10 с; // TelegramService — команды ядра → telegram-service (клиентская сторона в core)
// * сетевые операции Telegram (QR/код/поиск/инфо/чтение/вступление) — 60 с; // ---------------------------------------------------------------------------
// * Backfill/RefreshDialogs (паузы анти-бана 1.5–3 с/сообщение) — 120 с;
// * IngressService (локальная сеть core) — 10 с (сбой догоняет sweep). service TelegramService {
syntax = "proto3"; // само. Нет сессии — FAILED_PRECONDITION «Telegram не подключён».
rpc GetStatus(GetStatusRequest) returns (GetStatusReply);
package deal.telegram.v1;
// api_id/api_hash — глобальные ключи приложения Telegram, задаёт оператор (ТЗ §4.1/§8.1),
option csharp_namespace = "Deal.Grpc.Telegram"; // не заданы оператором» до вызова. Ответ: новая фаза ("code").
rpc StartPhone(StartPhoneRequest) returns (StartPhoneReply);
// ---------------------------------------------------------------------------
// TelegramService — команды ядра → telegram-service (клиентская сторона в core) // если аккаунт уже авторизован — фаза "ready", qrUrl пуст.
// --------------------------------------------------------------------------- rpc StartQr(StartQrRequest) returns (StartQrReply);
service TelegramService { // «Код истёк — запросите новый» → INVALID_ARGUMENT. Нужен 2FA — фаза
// Текущий статус аккаунта/фазы входа тенанта (status() прототипа L103119). // "password", иначе "ready". Фазы вне "code" → FAILED_PRECONDITION.
// live-поля для GET /api/tg/status (Ruling 8); monitored/keysSet ядро считает rpc SendCode(SendCodeRequest) returns (SendCodeReply);
// само. Нет сессии — FAILED_PRECONDITION «Telegram не подключён».
rpc GetStatus(GetStatusRequest) returns (GetStatusReply); // пароль» → INVALID_ARGUMENT. Ответ: фаза ("ready").
rpc SendPassword(SendPasswordRequest) returns (SendPasswordReply);
// Вход по номеру телефона: запросить код (start_phone L134147).
// api_id/api_hash — глобальные ключи приложения Telegram, задаёт оператор (ТЗ §4.1/§8.1), rpc Logout(LogoutRequest) returns (LogoutReply);
// передаёт ядро в теле (Ruling 3); нет ключей — ядро отвечает 400 «Ключи Telegram
// не заданы оператором» до вызова. Ответ: новая фаза ("code"). // актуальный список sources диалогов аккаунта (entries). Удаление/обновление
rpc StartPhone(StartPhoneRequest) returns (StartPhoneReply); rpc RefreshDialogs(RefreshDialogsRequest) returns (RefreshDialogsReply);
// Начать QR-вход (qr_start L286300). Ответ: фаза + qrUrl (t.me/qr/...); // зеркало monitored в сервисе. Первый backfill (force=false) запускает ядро
// если аккаунт уже авторизован — фаза "ready", qrUrl пуст. // отдельным RPC Backfill. Ответ: ok/enabled.
rpc StartQr(StartQrRequest) returns (StartQrReply); rpc SetMonitor(SetMonitorRequest) returns (SetMonitorReply);
// Отправить SMS-код (submit_code L149166). Ошибки: «Неверный код», // ok/count/enabled (count — сколько диалогов в каталоге тенанта).
// «Код истёк — запросите новый» → INVALID_ARGUMENT. Нужен 2FA — фаза rpc SetMonitorAll(SetMonitorAllRequest) returns (SetMonitorAllReply);
// "password", иначе "ready". Фазы вне "code" → FAILED_PRECONDITION.
rpc SendCode(SendCodeRequest) returns (SendCodeReply); // Перечитать последние ~10 сообщений диалога и отправить их в ядро потоком
// mark-as-read). force=true — «Перечитать» по кнопке даже для разобранных.
// Облачный пароль 2FA (submit_password L168176). Ошибка «Неверный облачный // Ответ: сколько сообщений отправлено (processed).
// пароль» → INVALID_ARGUMENT. Ответ: фаза ("ready"). rpc Backfill(BackfillRequest) returns (BackfillReply);
rpc SendPassword(SendPasswordRequest) returns (SendPasswordReply);
// свежие из Telegram; признак lead и фолбэк на БД добавляет ядро
// Отключить аккаунт, удалить сессию тенанта (disconnect L189207). // (у сервиса нет БД тенанта). limit 1..50 (api-map /dialogs/preview).
rpc Logout(LogoutRequest) returns (LogoutReply); rpc ReadRecent(ReadRecentRequest) returns (ReadRecentReply);
// Синхронизировать каталог диалогов из Telegram (refresh_dialogs L505519): // Пауза анти-бана после поиска — внутри сервиса. Личные чаты/боты ядро
// актуальный список sources диалогов аккаунта (entries). Удаление/обновление rpc Search(SearchRequest) returns (SearchReply);
// каталога и авто-мониторинг новых делает ядро (SyncFromTelegram, Ruling 7).
rpc RefreshDialogs(RefreshDialogsRequest) returns (RefreshDialogsReply); // hue + participants и is_forum (полный чат). Сбои определения не роняют
// RPC: participants пуст, остальные поля — из entity/каталога.
// Включить/выключить мониторинг диалога (set_monitor L536546): обновляет rpc GetInfo(GetInfoRequest) returns (GetInfoReply);
// зеркало monitored в сервисе. Первый backfill (force=false) запускает ядро
// отдельным RPC Backfill. Ответ: ok/enabled. // Выборка последних сообщений источника для оценки кандидата
rpc SetMonitor(SetMonitorRequest) returns (SetMonitorReply); // недоступна (приватный/закрытый источник) — ok=false, error="no_history",
// это НЕ ошибка RPC. limit ≥ 1; topic_id/topic_title заполнены для форумов.
// Мониторинг всех диалогов сразу (set_monitor_all L548567). Ответ: rpc ReadForEval(ReadForEvalRequest) returns (ReadForEvalReply);
// ok/count/enabled (count — сколько диалогов в каталоге тенанта).
rpc SetMonitorAll(SetMonitorAllRequest) returns (SetMonitorAllReply); // FloodWait → RESOURCE_EXHAUSTED (detail с префиксом "flood").
rpc Join(JoinRequest) returns (JoinReply);
// Перечитать последние ~10 сообщений диалога и отправить их в ядро потоком
// PushMessage (backfill_dialog L349390; паузы анти-бана 1.5–3 с/сообщение, // диалога/членства.
// mark-as-read). force=true — «Перечитать» по кнопке даже для разобранных. rpc Leave(LeaveRequest) returns (LeaveReply);
// Ответ: сколько сообщений отправлено (processed). }
rpc Backfill(BackfillRequest) returns (BackfillReply);
// --- Запросы/ответы TelegramService ---
// Последние сообщения диалога для превью (dialog_messages L583620):
// свежие из Telegram; признак lead и фолбэк на БД добавляет ядро message GetStatusRequest {}
// (у сервиса нет БД тенанта). limit 1..50 (api-map /dialogs/preview).
rpc ReadRecent(ReadRecentRequest) returns (ReadRecentReply); message GetStatusReply {
// Фаза входа: idle|phone|code|password|qr|ready.
// Глобальный поиск каналов/групп по ключу (discovery_search L624664). string phase = 1;
// Пауза анти-бана после поиска — внутри сервиса. Личные чаты/боты ядро // Клиент Telegram подключён и авторизован.
// отсеивает само (Ruling 10). Результат — entries канала/группы. bool connected = 2;
rpc Search(SearchRequest) returns (SearchReply); // Жив ли realtime-listener (поток новых сообщений → PushMessage).
bool listener = 3;
// Инфо об источнике для оценки (discovery_info L666716): имя/username/kind/ // Аккаунт "@username" (для справки; источник истины — KV tgAccount по
// hue + participants и is_forum (полный чат). Сбои определения не роняют string account = 4;
// RPC: participants пуст, остальные поля — из entity/каталога. // Текст последней ошибки (null, если ошибки нет).
rpc GetInfo(GetInfoRequest) returns (GetInfoReply); optional string error = 5;
// URL QR-входа (заполнен только при phase == "qr").
// Выборка последних сообщений источника для оценки кандидата optional string qr_url = 6;
// (discovery_read L718760): форумы читаются по активным темам. История }
// недоступна (приватный/закрытый источник) — ok=false, error="no_history",
// это НЕ ошибка RPC. limit ≥ 1; topic_id/topic_title заполнены для форумов. message StartPhoneRequest {
rpc ReadForEval(ReadForEvalRequest) returns (ReadForEvalReply); // Номер телефона в международном формате (как ввёл пользователь).
string phone = 1;
// Вступить в канал/группу по @username (discovery_join L818839; ручной // api_id приложения Telegram (глобальные ключи, задаёт оператор; 5..9 цифр).
// join вне квот — паузу перед авто-join делает воркер ядра, Ruling 10). int32 api_id = 2;
// FloodWait → RESOURCE_EXHAUSTED (detail с префиксом "flood"). // api_hash приложения Telegram (глобальные ключи, задаёт оператор).
rpc Join(JoinRequest) returns (JoinReply); string api_hash = 3;
}
// Выйти из канала/группы (discovery_leave L841848). NOT_FOUND — нет
// диалога/членства. message StartPhoneReply {
rpc Leave(LeaveRequest) returns (LeaveReply); // Фаза после запроса кода ("code"); при ошибке — RPC-статус.
} string phase = 1;
}
// --- Запросы/ответы TelegramService ---
message StartQrRequest {
message GetStatusRequest {} // api_id/api_hash приложения Telegram (см. StartPhoneRequest).
int32 api_id = 1;
// Статус аккаунта/фазы входа (shape прототипа status() L110118; monitored и string api_hash = 2;
// keysSet ядро добавляет само из своей БД/настроек — Ruling 8). }
message GetStatusReply {
// Фаза входа: idle|phone|code|password|qr|ready. message StartQrReply {
string phase = 1; // Фаза после запуска: "qr" (ждём сканирования) либо "ready" (уже вошли).
// Клиент Telegram подключён и авторизован. string phase = 1;
bool connected = 2; // URL вида https://t.me/qr/... для отрисовки QR; пуст при phase != "qr".
// Жив ли realtime-listener (поток новых сообщений → PushMessage). string qr_url = 2;
bool listener = 3; }
// Аккаунт "@username" (для справки; источник истины — KV tgAccount по
// ReportStatus, ядро использует KV — Ruling 8). message SendCodeRequest {
string account = 4; // Код из SMS/Telegram-сообщения.
// Текст последней ошибки (null, если ошибки нет). string code = 1;
optional string error = 5; }
// URL QR-входа (заполнен только при phase == "qr").
optional string qr_url = 6; message SendCodeReply {
} // Фаза после проверки кода: "password" (нужен 2FA) или "ready".
string phase = 1;
// Подключение по телефону: ключи API передаёт ядро (Ruling 3). }
message StartPhoneRequest {
// Номер телефона в международном формате (как ввёл пользователь). message SendPasswordRequest {
string phone = 1; // Облачный пароль 2FA.
// api_id приложения Telegram (глобальные ключи, задаёт оператор; 5..9 цифр). string password = 1;
int32 api_id = 2; }
// api_hash приложения Telegram (глобальные ключи, задаёт оператор).
string api_hash = 3; message SendPasswordReply {
} // Фаза после входа ("ready").
string phase = 1;
message StartPhoneReply { }
// Фаза после запроса кода ("code"); при ошибке — RPC-статус.
string phase = 1; message LogoutRequest {}
}
message LogoutReply {
message StartQrRequest { // True — аккаунт отключён, сессия тенанта удалена.
// api_id/api_hash приложения Telegram (см. StartPhoneRequest). bool ok = 1;
int32 api_id = 1; }
string api_hash = 2;
} message RefreshDialogsRequest {}
message StartQrReply { message RefreshDialogsReply {
// Фаза после запуска: "qr" (ждём сканирования) либо "ready" (уже вошли). // Актуальный каталог диалогов аккаунта (id/name/username/kind/hue).
string phase = 1; repeated DialogEntry entries = 1;
// URL вида https://t.me/qr/... для отрисовки QR; пуст при phase != "qr". }
string qr_url = 2;
} message DialogEntry {
// Подписанный id диалога: каналы "-100…", группы "-…", личные "+…".
message SendCodeRequest { string id = 1;
// Код из SMS/Telegram-сообщения. // Отображаемое имя (title/first_name) или id, если имени нет.
string code = 1; string name = 2;
} // Username (handle) источника; пуст, если нет публичного username.
string username = 3;
message SendCodeReply { // Тип: channel|group|forum|chat (канон контракта, см. шапку файла).
// Фаза после проверки кода: "password" (нужен 2FA) или "ready". string kind = 4;
string phase = 1; // Цвет источника из палитры DIALOG_HUES (hex, "#rrggbb") — считает сервис.
} string hue = 5;
}
message SendPasswordRequest {
// Облачный пароль 2FA. message SetMonitorRequest {
string password = 1; // Id диалога каталога.
} string dialog_id = 1;
// True — мониторить (сообщения → PushMessage в ядро), false — выключить.
message SendPasswordReply { bool enabled = 2;
// Фаза после входа ("ready"). }
string phase = 1;
} message SetMonitorReply {
bool ok = 1;
message LogoutRequest {} // Зеркальное значение enabled (для ответов эндпоинтов {ok, enabled}).
bool enabled = 2;
message LogoutReply { }
// True — аккаунт отключён, сессия тенанта удалена.
bool ok = 1; message SetMonitorAllRequest {
} // True — мониторить все диалоги каталога, false — снять мониторинг со всех.
bool enabled = 1;
message RefreshDialogsRequest {} }
message RefreshDialogsReply { message SetMonitorAllReply {
// Актуальный каталог диалогов аккаунта (id/name/username/kind/hue). bool ok = 1;
// Ядро применяет его через SyncFromTelegram (Ruling 7). // Сколько диалогов в каталоге тенанта (api-map /monitor-all → count).
repeated DialogEntry entries = 1; int32 count = 2;
} bool enabled = 3;
}
// Один диалог/канал каталога или результат поиска (shape refresh L516 и
// discovery_search L653660: tuple id/name/handle/kind/hue; handle == username). message BackfillRequest {
message DialogEntry { // Id диалога для перечитывания.
// Подписанный id диалога: каналы "-100…", группы "-…", личные "+…". string dialog_id = 1;
string id = 1; // True — перечитать, даже если диалог уже разобран (кнопка «Перечитать»).
// Отображаемое имя (title/first_name) или id, если имени нет. bool force = 2;
string name = 2; }
// Username (handle) источника; пуст, если нет публичного username.
string username = 3; message BackfillReply {
// Тип: channel|group|forum|chat (канон контракта, см. шапку файла). // Сколько сообщений отправлено в ядро потоком PushMessage.
string kind = 4; int32 processed = 1;
// Цвет источника из палитры DIALOG_HUES (hex, "#rrggbb") — считает сервис. }
string hue = 5;
} message ReadRecentRequest {
// Id диалога.
message SetMonitorRequest { string dialog_id = 1;
// Id диалога каталога. // Сколько последних сообщений (1..50; api-map /dialogs/preview limit 1..50).
string dialog_id = 1; int32 limit = 2;
// True — мониторить (сообщения → PushMessage в ядро), false — выключить. }
bool enabled = 2;
} message ReadRecentReply {
// Последние сообщения (от новых к старым). lead/фолбэк на БД — в ядре.
message SetMonitorReply { repeated PreviewMessage messages = 1;
bool ok = 1; }
// Зеркальное значение enabled (для ответов эндпоинтов {ok, enabled}).
bool enabled = 2; message PreviewMessage {
} // Id сообщения в Telegram (int); фолбэк-сообщения из БД ядра — строки
// "m_<dialog>_<msg>", поэтому значение передаётся строкой.
message SetMonitorAllRequest { string id = 1;
// True — мониторить все диалоги каталога, false — снять мониторинг со всех. // Текст сообщения.
bool enabled = 1; string text = 2;
} // Время сообщения, epoch-ms.
int64 time = 3;
message SetMonitorAllReply { }
bool ok = 1;
// Сколько диалогов в каталоге тенанта (api-map /monitor-all → count). message SearchRequest {
int32 count = 2; // Поисковый запрос (ключ задачи discovery).
bool enabled = 3; string query = 1;
} int32 limit = 2;
}
message BackfillRequest {
// Id диалога для перечитывания. message SearchReply {
string dialog_id = 1; // Найденные источники (каналы/группы; личные чаты/ботов отсеивает ядро).
// True — перечитать, даже если диалог уже разобран (кнопка «Перечитать»). repeated DialogEntry results = 1;
bool force = 2; }
}
message GetInfoRequest {
message BackfillReply { // Id источника (подписанный; из каталога или результата поиска).
// Сколько сообщений отправлено в ядро потоком PushMessage. string dialog_id = 1;
int32 processed = 1; }
}
message ChannelInfo {
message ReadRecentRequest { string id = 1;
// Id диалога. string name = 2;
string dialog_id = 1; string username = 3;
// Сколько последних сообщений (1..50; api-map /dialogs/preview limit 1..50). // Тип: channel|group|forum|chat.
int32 limit = 2; string kind = 4;
} string hue = 5;
// Число участников (full_chat); пусто — определить не удалось.
message ReadRecentReply { optional int32 participants = 6;
// Последние сообщения (от новых к старым). lead/фолбэк на БД — в ядре. bool is_forum = 7;
repeated PreviewMessage messages = 1; }
}
message GetInfoReply {
// Сообщение превью диалога (api-map §4.8 L351: {id, text, time, lead}). ChannelInfo info = 1;
message PreviewMessage { }
// Id сообщения в Telegram (int); фолбэк-сообщения из БД ядра — строки
// "m_<dialog>_<msg>", поэтому значение передаётся строкой. message ReadForEvalRequest {
string id = 1; // Id источника.
// Текст сообщения. string dialog_id = 1;
string text = 2; int32 limit = 2;
// Время сообщения, epoch-ms. }
int64 time = 3;
} message ReadForEvalReply {
// True — выборка получена; false — история недоступна без членства.
message SearchRequest { bool ok = 1;
// Поисковый запрос (ключ задачи discovery). // Код причины при ok=false: "no_history" (остальные поля пусты).
string query = 1; optional string error = 2;
// Верхняя граница результатов (прототип: default 30). // Сообщения выборки (форумы — по активным темам, topic_id/topic_title
int32 limit = 2; // заполнены; для обычных источников — null).
} repeated EvalMessage messages = 3;
}
message SearchReply {
// Найденные источники (каналы/группы; личные чаты/ботов отсеивает ядро). message EvalMessage {
repeated DialogEntry results = 1; // Id сообщения в Telegram.
} int64 id = 1;
// Текст сообщения (непустой; пустые тексты отбрасывает сервис).
message GetInfoRequest { string text = 2;
// Id источника (подписанный; из каталога или результата поиска). // Время сообщения, epoch-ms.
string dialog_id = 1; int64 date_ms = 3;
} // Id темы форума (для обычных источников пусто).
optional int64 topic_id = 4;
// Инфо об источнике для оценки кандидата discovery (discovery_info L674682). // Название темы форума (для обычных источников пусто).
message ChannelInfo { optional string topic_title = 5;
string id = 1; }
string name = 2;
string username = 3; message JoinRequest {
// Тип: channel|group|forum|chat. // @username источника (без "@"; пусто → INVALID_ARGUMENT).
string kind = 4; string username = 1;
string hue = 5; }
// Число участников (full_chat); пусто — определить не удалось.
optional int32 participants = 6; message JoinReply {
// True — мегагруппа-форум (темы); ядро трактует kind как "forum" (Ruling 10). bool ok = 1;
bool is_forum = 7; }
}
message LeaveRequest {
message GetInfoReply { // Id диалога для выхода.
ChannelInfo info = 1; string dialog_id = 1;
} }
message ReadForEvalRequest { message LeaveReply {
// Id источника. bool ok = 1;
string dialog_id = 1; }
// Размер выборки (прототип discovery_read: limit сообщений/тем).
int32 limit = 2; // ---------------------------------------------------------------------------
} // IngressService — исходящий поток telegram-service → ядро
// tenantId из metadata → собственный scope с ITenantContext.SetTenant)
message ReadForEvalReply { // ---------------------------------------------------------------------------
// True — выборка получена; false — история недоступна без членства.
bool ok = 1; service IngressService {
// Код причины при ok=false: "no_history" (остальные поля пусты). // Новое/догоняющее сообщение мониторящегося диалога → очередь пайплайна
optional string error = 2; // ядра (PipelineIngestService.EnqueueAsync, контракт demo-ingest; + превью в
// Сообщения выборки (форумы — по активным темам, topic_id/topic_title // TgMessages). Дубль dialog+msgId уже в очереди — не растёт (duplicate=true).
// заполнены; для обычных источников — null). rpc PushMessage(PushMessageRequest) returns (PushMessageReply);
repeated EvalMessage messages = 3;
} // Синхронизация каталога диалогов: ядро применяет entries (SyncFromTelegram:
// авто-мониторинг по autoMonitorNew, обновление, удаление отсутствующих) и
// Сообщение выборки discovery_read (_discovery_message_item L803816). // отвечает актуальным списком monitored id — сервис держит зеркало
message EvalMessage { rpc SyncDialogs(SyncDialogsRequest) returns (SyncDialogsReply);
// Id сообщения в Telegram.
int64 id = 1; // Периодический/событийный статус аккаунта: ядро пишет KV tgStatus/tgAccount
// Текст сообщения (непустой; пустые тексты отбрасывает сервис). rpc ReportStatus(ReportStatusRequest) returns (ReportStatusReply);
string text = 2; }
// Время сообщения, epoch-ms.
int64 date_ms = 3; // дубль-гвард; msg_at — время исходного сообщения, без него ядро подставит now.
// Id темы форума (для обычных источников пусто). message PushMessageRequest {
optional int64 topic_id = 4; // Id диалога-источника (подписанный; пуст — приём no-op).
// Название темы форума (для обычных источников пусто). string dialog_id = 1;
optional string topic_title = 5; // Имя канала/диалога (title/first_name или id).
} string channel_name = 2;
// Username канала/диалога (пуст, если нет).
message JoinRequest { string channel_handle = 3;
// @username источника (без "@"; пусто → INVALID_ARGUMENT). string channel_hue = 4;
string username = 1; // Id исходного сообщения в Telegram (дубль-гвард dialog+msgId).
} optional int64 msg_id = 5;
// Текст сообщения (сервис шлёт как есть; приём обрежет до 6000).
message JoinReply { string text = 6;
bool ok = 1; // Время исходного сообщения, epoch-ms; пусто — ядро подставит now.
} optional int64 msg_at = 7;
}
message LeaveRequest {
// Id диалога для выхода. message PushMessageReply {
string dialog_id = 1; // True — сообщение принято (no-op с пустым текстом/диалогом — accepted=false).
} bool accepted = 1;
// True — дубль dialog_id+msg_id уже в очереди (очередь не выросла).
message LeaveReply { bool duplicate = 2;
bool ok = 1; }
}
message SyncDialogsRequest {
// --------------------------------------------------------------------------- // Актуальный каталог диалогов (собирает сервис, как refresh_dialogs).
// IngressService — исходящий поток telegram-service → ядро repeated DialogEntry entries = 1;
// (gRPC-сервер в Deal.Api :5082; Ruling 7; интерцептор service-token; }
// tenantId из metadata → собственный scope с ITenantContext.SetTenant)
// --------------------------------------------------------------------------- message SyncDialogsReply {
// Id диалогов с включённым мониторингом (зеркало сервиса после синка).
service IngressService { repeated string monitored_ids = 1;
// Новое/догоняющее сообщение мониторящегося диалога → очередь пайплайна }
// ядра (PipelineIngestService.EnqueueAsync, контракт demo-ingest; + превью в
// TgMessages). Дубль dialog+msgId уже в очереди — не растёт (duplicate=true). message ReportStatusRequest {
rpc PushMessage(PushMessageRequest) returns (PushMessageReply); // Фаза: idle|phone|code|password|qr|ready.
string phase = 1;
// Синхронизация каталога диалогов: ядро применяет entries (SyncFromTelegram: // Клиент подключён и авторизован.
// авто-мониторинг по autoMonitorNew, обновление, удаление отсутствующих) и bool connected = 2;
// отвечает актуальным списком monitored id — сервис держит зеркало // Realtime-listener жив.
// мониторинга в памяти (Ruling 7), по нему фильтрует события realtime. bool listener = 3;
rpc SyncDialogs(SyncDialogsRequest) returns (SyncDialogsReply); // Аккаунт "@username" (пуст после выхода) → KV tgAccount.
string account = 4;
// Периодический/событийный статус аккаунта: ядро пишет KV tgStatus/tgAccount // Текст ошибки (пуст, если нет) → KV tgStatus.error.
// и публикует SSE system_status + тосты на переходах фаз (Ruling 7). optional string error = 5;
rpc ReportStatus(ReportStatusRequest) returns (ReportStatusReply); // URL QR-входа при phase == "qr".
} optional string qr_url = 6;
}
// Сообщение из потока в ядро. Поля 1:1 с QueuedMessage/PipelineIngestRequest
// (Ruling 7, demo-ingest L759): dialog_id + канальные поля плоские; msg_id — message ReportStatusReply {
// дубль-гвард; msg_at — время исходного сообщения, без него ядро подставит now. // True — статус принят и сохранён ядром.
message PushMessageRequest { bool ok = 1;
// 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;
}
@@ -3,31 +3,22 @@ using Deal.Modules.Tenants.Application.Services;
namespace Deal.Api.Configuration; namespace Deal.Api.Configuration;
/// <summary> /// <summary>
/// Настройки httpOnly-куки сессии. Привязываются из секции "Cookies" конфигурации (IOptions). /// Настройки httpOnly-куки сессии.
/// </summary> /// </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 public sealed class CookieOptions
{ {
/// <summary> /// <summary>
/// Имя куки (Ruling 6: <c>deal_session</c>). /// Имя куки.
/// </summary> /// </summary>
public string Name { get; set; } = "deal_session"; public string Name { get; set; } = "deal_session";
/// <summary> /// <summary>
/// Срок жизни куки в днях; совпадает со сроком жизни сессии (Ruling 6). /// Срок жизни куки в днях; совпадает со сроком жизни сессии.
/// </summary> /// </summary>
public int Days { get; set; } = AuthService.SessionLifetimeDays; public int Days { get; set; } = AuthService.SessionLifetimeDays;
/// <summary> /// <summary>
/// Флаг Secure куки (dev=false; включается при HTTPS-проксировании, Ruling 6). /// Флаг Secure куки.
/// </summary> /// </summary>
public bool Secure { get; set; } public bool Secure { get; set; }
} }
@@ -1,24 +1,17 @@
namespace Deal.Api.Configuration; namespace Deal.Api.Configuration;
/// <summary> /// <summary>
/// Настройки авто-очистки данных (этап 12, пакет B): секция <c>DataRetention</c> конфигурации /// Настройки авто-очистки данных
/// (appsettings.json + переменные окружения <c>DataRetention__*</c>).
/// </summary> /// </summary>
/// <remarks>
/// Управляет фоновым циклом <c>DataRetentionScheduler</c>: удаление записей аудита старше
/// <see cref="AuditRetentionDays"/> (по умолчанию 180 дней — разумный операционный срок) и очистка
/// накопительных полей лимитов/счётчиков прошедших окон. <see cref="Enabled"/>=false полностью
/// выключает фоновую очистку (например, при внешнем управлении retention).
/// </remarks>
public sealed class DataRetentionOptions public sealed class DataRetentionOptions
{ {
/// <summary> /// <summary>
/// Включён ли фоновый цикл авто-очистки (по умолчанию — да). /// Включён ли фоновый цикл авто-очистки
/// </summary> /// </summary>
public bool Enabled { get; set; } = true; public bool Enabled { get; set; } = true;
/// <summary> /// <summary>
/// Срок хранения записей аудита в днях (по умолчанию 180); неположительное значение — дефолт. /// Срок хранения записей аудита в днях
/// </summary> /// </summary>
public int AuditRetentionDays { get; set; } = 180; public int AuditRetentionDays { get; set; } = 180;
} }
@@ -1,39 +1,22 @@
namespace Deal.Api.Configuration; namespace Deal.Api.Configuration;
/// <summary> /// <summary>
/// Настройки доверия прокси-заголовкам (план Task 12; замечание ревью T4/T11 к Ruling 5/10): секция /// Настройки доверия прокси-заголовкам
/// <c>ForwardedHeaders</c> конфигурации (appsettings.json + переменные окружения <c>ForwardedHeaders__*</c>).
/// </summary> /// </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 public sealed class ForwardedHeadersConfig
{ {
/// <summary> /// <summary>
/// Включена ли обработка прокси-заголовков (dev/тесты — false; PROD за Caddy — true). /// Включена ли обработка прокси-заголовков
/// </summary> /// </summary>
public bool Enabled { get; set; } public bool Enabled { get; set; }
/// <summary> /// <summary>
/// Доверенные прокси-адреса: IP клиентов, которым можно верить в X-Forwarded-For/Proto. /// Доверенные прокси-адреса
/// </summary> /// </summary>
public string[] KnownProxies { get; set; } = []; public string[] KnownProxies { get; set; } = [];
/// <summary> /// <summary>
/// Доверенные подсети прокси в CIDR (например "172.16.0.0/12" — compose-сеть PROD). /// Доверенные подсети прокси в CIDR
/// </summary> /// </summary>
public string[] KnownNetworks { get; set; } = []; public string[] KnownNetworks { get; set; } = [];
} }
@@ -3,33 +3,22 @@ using Deal.Modules.Tenants.Application.Services;
namespace Deal.Api.Configuration; namespace Deal.Api.Configuration;
/// <summary> /// <summary>
/// Настройки httpOnly-куки сессии оператора. Привязываются из секции "OperatorCookies" конфигурации (IOptions). /// Настройки httpOnly-куки сессии оператора.
/// </summary> /// </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 public sealed class OperatorCookieOptions
{ {
/// <summary> /// <summary>
/// Имя куки (Ruling 1: <c>deal_operator_session</c>). /// Имя куки.
/// </summary> /// </summary>
public string Name { get; set; } = "deal_operator_session"; public string Name { get; set; } = "deal_operator_session";
/// <summary> /// <summary>
/// Срок жизни куки в часах; совпадает со сроком жизни сессии оператора (Ruling 1: 12). /// Срок жизни куки в часах; совпадает со сроком жизни сессии оператора.
/// </summary> /// </summary>
public int Hours { get; set; } = OperatorAuthService.SessionLifetimeHours; public int Hours { get; set; } = OperatorAuthService.SessionLifetimeHours;
/// <summary> /// <summary>
/// Флаг Secure куки (dev=false; включается при HTTPS-проксировании, Ruling 1). /// Флаг Secure куки.
/// </summary> /// </summary>
public bool Secure { get; set; } public bool Secure { get; set; }
} }
@@ -1,43 +1,37 @@
namespace Deal.Api.Configuration; namespace Deal.Api.Configuration;
/// <summary> /// <summary>
/// Настройки rate limiting и защиты входа (план Task 11, Ruling 5): секция <c>RateLimit</c> конфигурации /// Настройки rate limiting и защиты входа
/// (appsettings.json + переменные окружения <c>RateLimit__*</c>).
/// </summary> /// </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 public sealed class RateLimitOptions
{ {
/// <summary> /// <summary>
/// Включены ли rate limiting и LoginAttemptGuard (dev/тесты — false, Ruling 5). /// Включены ли rate limiting и LoginAttemptGuard.
/// </summary> /// </summary>
public bool Enabled { get; set; } public bool Enabled { get; set; }
/// <summary> /// <summary>
/// Лимит политики "auth" (фиксированное окно в минуту на IP) для /api/auth/login и /api/operator/auth/login. /// Лимит политики "auth"
/// </summary> /// </summary>
public int AuthPerMinute { get; set; } = 10; public int AuthPerMinute { get; set; } = 10;
/// <summary> /// <summary>
/// Лимит политики "api" (в минуту на тенанта либо IP анонима) для остальных /api-эндпоинтов. /// Лимит политики "api"
/// </summary> /// </summary>
public int ApiPerMinute { get; set; } = 600; public int ApiPerMinute { get; set; } = 600;
/// <summary> /// <summary>
/// Лимит gRPC-ингресса (в минуту на tenant-id из metadata; интерцептор IngressRateLimitInterceptor). /// Лимит gRPC-ингресса
/// </summary> /// </summary>
public int GrpcIngressPerMinute { get; set; } = 600; public int GrpcIngressPerMinute { get; set; } = 600;
/// <summary> /// <summary>
/// Порог неудачных попыток входа ключа ip|login до блокировки (LoginAttemptGuard). /// Порог неудачных попыток входа ключа ip|login до блокировки
/// </summary> /// </summary>
public int LoginAttemptsMax { get; set; } = 5; public int LoginAttemptsMax { get; set; } = 5;
/// <summary> /// <summary>
/// Окно учёта неудачных попыток входа в минутах (LoginAttemptGuard; текст 429 — фиксированный «15 минут»). /// Окно учёта неудачных попыток входа в минутах
/// </summary> /// </summary>
public int LoginAttemptWindowMin { get; set; } = 15; public int LoginAttemptWindowMin { get; set; } = 15;
} }
@@ -1,25 +1,12 @@
namespace Deal.Api.Configuration; namespace Deal.Api.Configuration;
/// <summary> /// <summary>
/// Настройки безопасности HTTP (план Task 12, Ruling 10(2)/9): секция <c>Security</c> конфигурации /// Настройки безопасности HTTP
/// (appsettings.json + переменные окружения <c>Security__*</c>).
/// </summary> /// </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 public sealed class SecurityOptions
{ {
/// <summary> /// <summary>
/// Явный allowlist Origin/CORS (схема://хост[:порт]); пусто — dev-режим «любой origin + свой Host». /// Явный allowlist Origin/CORS
/// </summary> /// </summary>
public string[] AllowedOrigins { get; set; } = []; public string[] AllowedOrigins { get; set; } = [];
} }
+2 -12
View File
@@ -3,21 +3,11 @@ using Deal.Modules.Kanban.Application.Models;
namespace Deal.Api.Dtos; namespace Deal.Api.Dtos;
/// <summary> /// <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> /// </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="Storage">Статистика тика правил хранения (включая purgedRejected — очистку отсева 3 суток).</param>
/// <param name="Reminders">«Выстрелившие» напоминания {id,title,containerId} — список SSE reminder_due тика.</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> /// <param name="Queue">Строк очереди входящих после прохода pump (queue_len).</param>
public sealed record AdminTickResultDto( public sealed record AdminTickResultDto(
StorageTickStatsDto Storage, StorageTickStatsDto Storage,
+1 -15
View File
@@ -7,26 +7,15 @@ using Deal.Modules.Settings.Application.Models;
namespace Deal.Api.Endpoints; namespace Deal.Api.Endpoints;
/// <summary> /// <summary>
/// Эндпоинт проверки подключения AI-провайдера: POST /api/ai/check (Ruling 7/8, api-map §4.10). /// Эндпоинт проверки подключения AI-провайдера
/// </summary> /// </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 public static class AiCheckEndpoint
{ {
// Префикс группы API (общий для эндпоинтов этапа, Ruling 8).
private const string ApiGroupPrefix = "/api"; private const string ApiGroupPrefix = "/api";
// Путь проверки подключения AI-провайдера. // Путь проверки подключения AI-провайдера.
private const string AiCheckPath = "/ai/check"; private const string AiCheckPath = "/ai/check";
// OpenAPI-тег группы (в прототипе роутер settings — settings_routes.py).
private const string OpenApiTag = "settings"; private const string OpenApiTag = "settings";
/// <summary> /// <summary>
@@ -59,14 +48,11 @@ public static class AiCheckEndpoint
return Results.Ok(result); return Results.Ok(result);
} }
// Собирает запрос проверки из активной конфигурации провайдера (1:1 с ai_svc._cfg()).
// store: KV-хранилище настроек тенанта. // store: KV-хранилище настроек тенанта.
// secretCipher: Шифр секретов (расшифровка apiKey). // secretCipher: Шифр секретов (расшифровка apiKey).
// ct: Токен отмены. // ct: Токен отмены.
// Возвращает: Запрос проверки: id провайдера + эффективные base/model + расшифрованный ключ. // Возвращает: Запрос проверки: id провайдера + эффективные base/model + расшифрованный ключ.
// Эффективные значения = дефолты SettingsDefaults, перекрытые сохранёнными // Эффективные значения = дефолты SettingsDefaults, перекрытые сохранёнными
// переопределениями (Ruling 1); пустое переопределение base/model → дефолт каталога
// (семантика «cfg.get(...) or meta[...]» прототипа).
private static async Task<AiCheckRequest> BuildActiveCheckRequestAsync( private static async Task<AiCheckRequest> BuildActiveCheckRequestAsync(
ISettingsStore store, ISettingsStore store,
ISecretCipher secretCipher, ISecretCipher secretCipher,
@@ -7,40 +7,22 @@ using Deal.Contracts.Integrations.Models;
namespace Deal.Api.Endpoints; namespace Deal.Api.Endpoints;
/// <summary> /// <summary>
/// Эндпоинты ИИ-предложений: POST /api/ai/suggest-columns и POST /api/ai/suggest-keywords /// Эндпоинты ИИ-предложений
/// (план Task 14 L476479; прототип dashboard_routes.py L395409).
/// </summary> /// </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 public static class AiSuggestEndpoints
{ {
// Префикс группы AI-эндпоинтов этапа (роутер dashboard, prefix="/api"; пути L395/L406).
private const string AiGroupPrefix = "/api/ai"; private const string AiGroupPrefix = "/api/ai";
// Путь предложения колонок (dashboard_routes.py L395).
private const string SuggestColumnsPath = "/suggest-columns"; private const string SuggestColumnsPath = "/suggest-columns";
// Путь предложения ключевых слов (dashboard_routes.py L404).
private const string SuggestKeywordsPath = "/suggest-keywords"; private const string SuggestKeywordsPath = "/suggest-keywords";
// OpenAPI-тег группы (в прототипе роутер dashboard — dashboard_routes.py).
private const string OpenApiTag = "dashboard"; private const string OpenApiTag = "dashboard";
// SSE-тип события тоста (Ruling 5; api.js слушает 'toast').
private const string ToastEventType = "toast"; private const string ToastEventType = "toast";
// Текст тоста после успешных предложений колонок (suggest.py L162, 1:1).
private const string SuggestToastTextFormat = "ИИ предложил колонок: {0} — откройте и решите"; private const string SuggestToastTextFormat = "ИИ предложил колонок: {0} — откройте и решите";
// Иконка тоста предложений колонок (sparkles, 1:1 с прототипом).
private const string SparklesIcon = "sparkles"; private const string SparklesIcon = "sparkles";
/// <summary> /// <summary>
@@ -57,9 +39,7 @@ public static class AiSuggestEndpoints
} }
// POST /api/ai/suggest-columns: анализ «Неразобранного» и создание колонок-предложений. // POST /api/ai/suggest-columns: анализ «Неразобранного» и создание колонок-предложений.
// Ответ — результат порта 1:1: {ok:true, created:N} — доски suggested=true созданы (эндпоинт шлёт
// SSE-toast), {ok:false, reason} (+ cooldown) — мягкая причина (HTTP 200). Кулдаун/«мало карточек»/ // SSE-toast), {ok:false, reason} (+ cooldown) — мягкая причина (HTTP 200). Кулдаун/«мало карточек»/
// «похожие колонки уже есть» — за адаптером LocalColumnSuggester (Ruling 3).
private static async Task<IResult> SuggestColumnsAsync(HttpContext context, CancellationToken ct) private static async Task<IResult> SuggestColumnsAsync(HttpContext context, CancellationToken ct)
{ {
if (!context.HasUser()) if (!context.HasUser())
@@ -82,7 +62,6 @@ public static class AiSuggestEndpoints
} }
// POST /api/ai/suggest-keywords: слова-маркеры сферы по карточкам (настройки «Сфера и ключи»). // 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) private static async Task<IResult> SuggestKeywordsAsync(HttpContext context, CancellationToken ct)
{ {
if (!context.HasUser()) if (!context.HasUser())
+2 -21
View File
@@ -11,13 +11,8 @@ using CookieOptions = Deal.Api.Configuration.CookieOptions;
namespace Deal.Api.Endpoints; namespace Deal.Api.Endpoints;
/// <summary> /// <summary>
/// HTTP-эндпоинты аутентификации (группа /api/auth). Контракт 1:1 с прототипом auth_routes.py. /// HTTP-эндпоинты аутентификации
/// </summary> /// </summary>
/// <remarks>
/// Успех-ответы — <c>{ok:true,...}</c>, ошибки — HTTP-код + <c>{"detail":"..."}</c> (Ruling 10).
/// Кука сессии выставляется на login и change-password (свежий токен). Сообщения об ошибках —
/// фиксированные строки прототипа.
/// </remarks>
public static class AuthEndpoints public static class AuthEndpoints
{ {
private const string InvalidCredentialsDetail = "Неверный логин или пароль"; private const string InvalidCredentialsDetail = "Неверный логин или пароль";
@@ -28,7 +23,7 @@ public static class AuthEndpoints
private const string AuthOpenApiTag = "auth"; private const string AuthOpenApiTag = "auth";
/// <summary> /// <summary>
/// Регистрирует группу /api/auth: login, logout, me, change-password. /// Регистрирует группу /api/auth
/// </summary> /// </summary>
/// <param name="app">Построитель маршрутов приложения.</param> /// <param name="app">Построитель маршрутов приложения.</param>
/// <returns>Построитель маршрутов для цепочки вызовов.</returns> /// <returns>Построитель маршрутов для цепочки вызовов.</returns>
@@ -36,7 +31,6 @@ public static class AuthEndpoints
{ {
var group = app.MapGroup(AuthGroupPrefix).WithTags(AuthOpenApiTag); var group = app.MapGroup(AuthGroupPrefix).WithTags(AuthOpenApiTag);
// Политика "auth" rate limiter (план Task 11, Ruling 5): фиксированное окно 10/мин на IP
// ручки входа; остальные ручки группы — под глобальной API-политикой (по тенанту/IP). // ручки входа; остальные ручки группы — под глобальной API-политикой (по тенанту/IP).
group.MapPost("/login", LoginAsync).RequireRateLimiting(RateLimitPolicies.AuthPolicy); group.MapPost("/login", LoginAsync).RequireRateLimiting(RateLimitPolicies.AuthPolicy);
group.MapPost("/logout", LogoutAsync); group.MapPost("/logout", LogoutAsync);
@@ -46,8 +40,6 @@ public static class AuthEndpoints
return app; return app;
} }
// POST /api/auth/login: проверка учётных данных, выдача куки сессии; результат пишется в аудит (Task 4/7).
// До AuthService отрабатывает LoginAttemptGuard (5 неудач ip|login за 15 мин → 429, Ruling 5).
private static async Task<IResult> LoginAsync( private static async Task<IResult> LoginAsync(
LoginRequest body, LoginRequest body,
AuthService authService, AuthService authService,
@@ -59,7 +51,6 @@ public static class AuthEndpoints
{ {
string? attemptedLogin = NormalizeLogin(body.Login); string? attemptedLogin = NormalizeLogin(body.Login);
// Защита входа (план Task 11, Ruling 5): блокировка ключа ip|login до проверки учётных данных —
// 429 «Слишком много попыток входа…» (в dev при RateLimit:Enabled=false гвард выключен). // 429 «Слишком много попыток входа…» (в dev при RateLimit:Enabled=false гвард выключен).
if (await loginAttemptGuard.IsBlockedAsync(ClientIp(context), attemptedLogin, ct)) 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); 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) if (result.Error == LoginResultDto.ErrorTenantSuspended)
{ {
await auditService.AppendAsync(new AuditRecordDto( await auditService.AppendAsync(new AuditRecordDto(
@@ -87,8 +74,6 @@ public static class AuthEndpoints
if (result.Login is null || result.Token is null) if (result.Login is null || result.Token is null)
{ {
// Пустой/пробельный login и неверные учётные данные — одно сообщение (семантика прототипа).
// Аудит tenant_login_failed пишем только для реальной попытки (непустой логин), без пароля (Ruling 4);
// счётчик неудач гварда растёт там же — пустые логины ключа не имеют (блокирует только auth-политика). // счётчик неудач гварда растёт там же — пустые логины ключа не имеют (блокирует только auth-политика).
if (attemptedLogin is not null) if (attemptedLogin is not null)
{ {
@@ -105,7 +90,6 @@ public static class AuthEndpoints
return EndpointResults.Unauthorized(InvalidCredentialsDetail); return EndpointResults.Unauthorized(InvalidCredentialsDetail);
} }
// Успешный вход сбрасывает счётчик неудач ключа ip|login (Ruling 5).
await loginAttemptGuard.ResetAsync(ClientIp(context), result.Login, ct); await loginAttemptGuard.ResetAsync(ClientIp(context), result.Login, ct);
await auditService.AppendAsync(new AuditRecordDto( await auditService.AppendAsync(new AuditRecordDto(
@@ -121,7 +105,6 @@ public static class AuthEndpoints
} }
// POST /api/auth/logout: удаление сессии по токену из куки и очистка куки (всегда ok). // POST /api/auth/logout: удаление сессии по токену из куки и очистка куки (всегда ok).
// Если удалённая сессия была impersonation — пишется аудит impersonation_stopped (Task 7, ревью: полный аудит).
private static async Task<IResult> LogoutAsync( private static async Task<IResult> LogoutAsync(
AuthService authService, AuthService authService,
AuditService auditService, AuditService auditService,
@@ -146,7 +129,6 @@ public static class AuthEndpoints
DetailJson: AuditService.ToDetailJson(new { login = logout.Login })), ct); DetailJson: AuditService.ToDetailJson(new { login = logout.Login })), ct);
} }
// Выход пользователя тенанта (этап 10, T1): событие пишется при живой разрешённой сессии.
if (user is not null) if (user is not null)
{ {
await AuditAppender.AppendTenantAsync(context, AuditEvents.TenantLogout, new { login = user.Login }, ct); 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); var result = await authService.ChangePasswordAsync(user.Login, body.OldPassword, body.NewPassword, ct);
if (!result.Ok || result.NewToken is null) if (!result.Ok || result.NewToken is null)
{ {
// Семантика прототипа: код ошибки различает «старый пароль неверен» и «слишком короткий».
var detail = result.Error == ChangePasswordResultDto.ErrorTooShort var detail = result.Error == ChangePasswordResultDto.ErrorTooShort
? PasswordTooShortDetail ? PasswordTooShortDetail
: WrongOldPasswordDetail; : WrongOldPasswordDetail;
@@ -11,15 +11,8 @@ using Deal.Modules.Tenants.Application.Models;
namespace Deal.Api.Endpoints; namespace Deal.Api.Endpoints;
/// <summary> /// <summary>
/// Детальные операции карточки: создание локальной, «взять в работу», патч, ссылки, файлы, /// Детальные операции карточки
/// напоминания, очистка «Отклонено» — продолжение группы /api/cards (этап 9, T6).
/// </summary> /// </summary>
/// <remarks>
/// Единый контракт /api/cards (R5): операции проектной карточки (патч полей, ссылки, файлы, напоминания)
/// теперь живут на том же ресурсе карточки. Список/чтение/перенос/комментарии/корзина — в
/// <see cref="CardsEndpoints"/>; здесь — уникальные подпути. Все мутации возвращают обновлённую
/// единую карточку (чтение после записи через <see cref="CardsService"/>). Все эндпоинты требуют сессию.
/// </remarks>
public static class CardDetailsEndpoints public static class CardDetailsEndpoints
{ {
// Префикс группы (общий с CardsEndpoints). // Префикс группы (общий с CardsEndpoints).
@@ -83,7 +76,7 @@ public static class CardDetailsEndpoints
private const string FileNameQuoteCharacter = "\""; private const string FileNameQuoteCharacter = "\"";
/// <summary> /// <summary>
/// Регистрирует уникальные подпути /api/cards (создание, take, патч, ссылки, файлы, напоминания). /// Регистрирует уникальные подпути /api/cards
/// </summary> /// </summary>
/// <param name="app">Построитель маршрутов приложения.</param> /// <param name="app">Построитель маршрутов приложения.</param>
/// <returns>Построитель маршрутов для цепочки вызовов.</returns> /// <returns>Построитель маршрутов для цепочки вызовов.</returns>
@@ -122,7 +115,6 @@ public static class CardDetailsEndpoints
CardsService service = context.RequestServices.GetRequiredService<CardsService>(); CardsService service = context.RequestServices.GetRequiredService<CardsService>();
CardDto created = await service.CreateLocalCardAsync(ToCreateLocalDto(body), ct); CardDto created = await service.CreateLocalCardAsync(ToCreateLocalDto(body), ct);
// Аудит создания карточки (этап 10, T1).
await AuditAppender.AppendTenantAsync(context, AuditEvents.CardCreated, new { cardId = created.Id }, ct); await AuditAppender.AppendTenantAsync(context, AuditEvents.CardCreated, new { cardId = created.Id }, ct);
return await ReadCardAsync(context, 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); private static string ToDownloadFileName(string name) => name.Replace(FileNameQuoteCharacter, string.Empty);
// Переводит тело POST /api/cards в начальные поля сервиса (поля 1:1 с CardLocalCreateDto).
// body: Тело запроса. // body: Тело запроса.
// Возвращает: DTO модуля для CardsService.CreateLocalCardAsync. // Возвращает: DTO модуля для CardsService.CreateLocalCardAsync.
private static CardLocalCreateDto ToCreateLocalDto(CreateCardRequest body) private static CardLocalCreateDto ToCreateLocalDto(CreateCardRequest body)
+2 -38
View File
@@ -14,21 +14,8 @@ using Deal.Modules.Tenants.Application.Models;
namespace Deal.Api.Endpoints; namespace Deal.Api.Endpoints;
/// <summary> /// <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> /// </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 public static class CardsEndpoints
{ {
// Префикс группы карточек. // Префикс группы карточек.
@@ -40,26 +27,22 @@ public static class CardsEndpoints
// Путь поиска (GET). // Путь поиска (GET).
private const string SearchPath = "/search"; private const string SearchPath = "/search";
// OpenAPI-тег группы (в прототипе роутер dashboard — dashboard_routes.py).
private const string OpenApiTag = "dashboard"; private const string OpenApiTag = "dashboard";
// 404: карточка не найдена (dashboard_routes.py _lead_or_404 L9296).
private const string CardNotFoundDetail = "Карточка не найдена"; private const string CardNotFoundDetail = "Карточка не найдена";
// 400 GET /cards: containerId не существует. // 400 GET /cards: containerId не существует.
private const string UnknownColumnDetail = "Неизвестный контейнер"; private const string UnknownColumnDetail = "Неизвестный контейнер";
// Инициатор перехода при ручном переносе — действие пользователя (R4 этапа 9).
private const string UserActor = "user"; private const string UserActor = "user";
// SSE-тип события завершения переклассификации (этап 12, остаток 2; api.js слушает 'cards_reclassified').
private const string ReclassifiedEventType = "cards_reclassified"; private const string ReclassifiedEventType = "cards_reclassified";
// Контекст ручного перехода карточки: пользователь, обучение ML по цели переноса. // Контекст ручного перехода карточки: пользователь, обучение ML по цели переноса.
private static readonly TransitionContext UserMoveContext = new() { Actor = UserActor, Learn = true }; private static readonly TransitionContext UserMoveContext = new() { Actor = UserActor, Learn = true };
/// <summary> /// <summary>
/// Регистрирует группы /api/cards и /api (карточки + поиск). Статические сегменты — до /cards/{cardId}. /// Регистрирует группы /api/cards и /api
/// </summary> /// </summary>
/// <param name="app">Построитель маршрутов приложения.</param> /// <param name="app">Построитель маршрутов приложения.</param>
/// <returns>Построитель маршрутов для цепочки вызовов.</returns> /// <returns>Построитель маршрутов для цепочки вызовов.</returns>
@@ -112,7 +95,6 @@ public static class CardsEndpoints
return Results.Ok(new { items = cards }); 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) private static async Task<IResult> CountsAsync(HttpContext context, CancellationToken ct)
{ {
if (!context.HasUser()) if (!context.HasUser())
@@ -123,7 +105,6 @@ public static class CardsEndpoints
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>(); CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
CardCountsDto counts = await cardsService.CountsAsync(ct); CardCountsDto counts = await cardsService.CountsAsync(ct);
// Разворачивание CardCountsDto: колонки — корневые ключи (counts L268–279), служебные — фиксированные.
var wire = new Dictionary<string, object> { ["new"] = counts.New }; var wire = new Dictionary<string, object> { ["new"] = counts.New };
foreach ((string col, CardColumnCountDto column) in counts.Columns) foreach ((string col, CardColumnCountDto column) in counts.Columns)
{ {
@@ -136,7 +117,6 @@ public static class CardsEndpoints
return Results.Ok(wire); return Results.Ok(wire);
} }
// GET /api/cards/{cardId}: одна карточка; 404 «Карточка не найдена» (L166–168).
private static async Task<IResult> GetCardAsync( private static async Task<IResult> GetCardAsync(
string cardId, string cardId,
HttpContext context, HttpContext context,
@@ -154,7 +134,6 @@ public static class CardsEndpoints
: Results.Ok(card); : Results.Ok(card);
} }
// POST /api/cards/mark-all-seen: снять «новое» со всех карточек (L177–180); ответ {ok:true}.
private static async Task<IResult> MarkAllSeenAsync(HttpContext context, CancellationToken ct) private static async Task<IResult> MarkAllSeenAsync(HttpContext context, CancellationToken ct)
{ {
if (!context.HasUser()) if (!context.HasUser())
@@ -167,7 +146,6 @@ public static class CardsEndpoints
return Results.Ok(new { ok = true }); return Results.Ok(new { ok = true });
} }
// POST /api/cards/mark-col-seen: снять «новое» с колонки (L187–191); ответ {ok:true}.
private static async Task<IResult> MarkColSeenAsync( private static async Task<IResult> MarkColSeenAsync(
MarkColBody body, MarkColBody body,
HttpContext context, HttpContext context,
@@ -181,7 +159,6 @@ public static class CardsEndpoints
if (body.Col is null) if (body.Col is null)
{ {
// Пустая/отсутствующая col попала бы в mark_seen как «не задана» и сняла бы «новое» со ВСЕХ // Пустая/отсутствующая col попала бы в mark_seen как «не задана» и сняла бы «новое» со ВСЕХ
// карточек (truthiness python, L250256) — эндпоинт защищает от вызова с null (прототип: 422).
return EndpointResults.BadRequest(UnknownColumnDetail); return EndpointResults.BadRequest(UnknownColumnDetail);
} }
@@ -190,7 +167,6 @@ public static class CardsEndpoints
return Results.Ok(new { ok = true }); return Results.Ok(new { ok = true });
} }
// POST /api/cards/{cardId}/move {to}: перенос карточки между контейнерами (этап 9, R4).
// Маршрутизацию цели (стадия «Выбранных» vs дашборд-контейнер) и побочные эффекты выполняет единый // Маршрутизацию цели (стадия «Выбранных» vs дашборд-контейнер) и побочные эффекты выполняет единый
// доменный механизм перехода ICardMover: стадия — запись истории и сброс напоминания // доменный механизм перехода ICardMover: стадия — запись истории и сброс напоминания
// (move_stage), дашборд-контейнер — журнал/обучение ML. Ответ — обновлённая карточка; 400 при // (move_stage), дашборд-контейнер — журнал/обучение ML. Ответ — обновлённая карточка; 400 при
@@ -218,7 +194,6 @@ public static class CardsEndpoints
return EndpointResults.NotFound(CardNotFoundDetail); return EndpointResults.NotFound(CardNotFoundDetail);
} }
// Аудит переноса карточки (этап 10, T1): цель — минимальный безопасный идентификатор.
await AuditAppender.AppendTenantAsync(context, AuditEvents.CardMoved, new { cardId, to = body.To }, ct); await AuditAppender.AppendTenantAsync(context, AuditEvents.CardMoved, new { cardId, to = body.To }, ct);
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>(); CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
@@ -228,7 +203,6 @@ public static class CardsEndpoints
: Results.Ok(unified); : Results.Ok(unified);
} }
// POST /api/cards/{cardId}/trash: в корзину + обучение ML spam (L203207); ответ {ok:true}; 404.
private static async Task<IResult> TrashAsync( private static async Task<IResult> TrashAsync(
string cardId, string cardId,
HttpContext context, HttpContext context,
@@ -246,12 +220,10 @@ public static class CardsEndpoints
return EndpointResults.NotFound(CardNotFoundDetail); return EndpointResults.NotFound(CardNotFoundDetail);
} }
// Аудит отправки карточки в корзину (этап 10, T1).
await AuditAppender.AppendTenantAsync(context, AuditEvents.CardTrashed, new { cardId }, ct); await AuditAppender.AppendTenantAsync(context, AuditEvents.CardTrashed, new { cardId }, ct);
return Results.Ok(new { ok = true }); return Results.Ok(new { ok = true });
} }
// POST /api/cards/{cardId}/restore: возврат из архив/корзины на канбан (L210–214); ответ {ok, col}; 404.
private static async Task<IResult> RestoreAsync( private static async Task<IResult> RestoreAsync(
string cardId, string cardId,
HttpContext context, HttpContext context,
@@ -269,12 +241,10 @@ public static class CardsEndpoints
return EndpointResults.NotFound(CardNotFoundDetail); return EndpointResults.NotFound(CardNotFoundDetail);
} }
// Аудит возврата карточки из корзины/архива (этап 10, T1).
await AuditAppender.AppendTenantAsync(context, AuditEvents.CardRestored, new { cardId, col }, ct); await AuditAppender.AppendTenantAsync(context, AuditEvents.CardRestored, new { cardId, col }, ct);
return Results.Ok(new { ok = true, col }); return Results.Ok(new { ok = true, col });
} }
// DELETE /api/cards/{cardId}: удалить навсегда (Cards + комментарии; L217221); ответ {ok:true}; 404.
private static async Task<IResult> DeleteAsync( private static async Task<IResult> DeleteAsync(
string cardId, string cardId,
HttpContext context, HttpContext context,
@@ -292,12 +262,10 @@ public static class CardsEndpoints
return EndpointResults.NotFound(CardNotFoundDetail); return EndpointResults.NotFound(CardNotFoundDetail);
} }
// Аудит удаления карточки навсегда (этап 10, T1).
await AuditAppender.AppendTenantAsync(context, AuditEvents.CardDeleted, new { cardId }, ct); await AuditAppender.AppendTenantAsync(context, AuditEvents.CardDeleted, new { cardId }, ct);
return Results.Ok(new { ok = true }); return Results.Ok(new { ok = true });
} }
// POST /api/cards/clear-col {col}: очистить корзину/архив (L228–235); ответ {ok, cleared}; 400.
private static async Task<IResult> ClearColAsync( private static async Task<IResult> ClearColAsync(
ClearColBody body, ClearColBody body,
HttpContext context, HttpContext context,
@@ -315,7 +283,6 @@ public static class CardsEndpoints
: Results.Ok(new { ok = true, cleared = result.Cleared }); : 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( private static async Task<IResult> AddCommentAsync(
string cardId, string cardId,
CommentBody body, CommentBody body,
@@ -339,7 +306,6 @@ public static class CardsEndpoints
return EndpointResults.NotFound(CardNotFoundDetail); return EndpointResults.NotFound(CardNotFoundDetail);
} }
// Аудит добавления комментария (этап 10, T1): текст комментария в детали не пишется.
await AuditAppender.AppendTenantAsync(context, AuditEvents.CardCommentAdded, new { cardId }, ct); await AuditAppender.AppendTenantAsync(context, AuditEvents.CardCommentAdded, new { cardId }, ct);
return Results.Ok(new { comments = result.Comments }); return Results.Ok(new { comments = result.Comments });
} }
@@ -414,7 +380,6 @@ public static class CardsEndpoints
reason = result.Reason, reason = result.Reason,
}; };
// Публикует SSE cards_reclassified после успешного прохода (Ruling 5: публикации — из Api).
// Публикуется только когда проход реально выполнен и что-то изменил (started и // Публикуется только когда проход реально выполнен и что-то изменил (started и
// reclassified &gt; 0): пустой inbox/всё пропущено не меняют доску — событие не шлём. Нагрузка // reclassified &gt; 0): пустой inbox/всё пропущено не меняют доску — событие не шлём. Нагрузка
// минимальная: сколько обработано и перемещено (фронт перечитывает доску). Без подписчиков — no-op. // минимальная: сколько обработано и перемещено (фронт перечитывает доску). Без подписчиков — no-op.
@@ -455,7 +420,6 @@ public static class CardsEndpoints
ct); ct);
} }
// GET /api/search?q=: поиск карточек (FTS + LIKE, Ruling 6/Task 12; dashboard_routes L254256). Ответ {leads, messages: []}.
private static async Task<IResult> SearchAsync( private static async Task<IResult> SearchAsync(
string? q, string? q,
HttpContext context, HttpContext context,
@@ -1,7 +1,7 @@
namespace Deal.Api.Endpoints; namespace Deal.Api.Endpoints;
/// <summary> /// <summary>
/// Тело POST /api/auth/change-password. Входящий JSON — camelCase (oldPassword, newPassword). /// Тело POST /api/auth/change-password.
/// </summary> /// </summary>
/// <param name="OldPassword">Текущий пароль.</param> /// <param name="OldPassword">Текущий пароль.</param>
/// <param name="NewPassword">Новый пароль (минимум 8 символов).</param> /// <param name="NewPassword">Новый пароль (минимум 8 символов).</param>
@@ -1,7 +1,7 @@
namespace Deal.Api.Endpoints; namespace Deal.Api.Endpoints;
/// <summary> /// <summary>
/// Тело POST /api/admin/check-message (1:1 с CheckMessageBody, dashboard_routes.py L7273). /// Тело POST /api/admin/check-message.
/// </summary> /// </summary>
/// <param name="Text">Текст сообщения для проверки фильтром (этап 1 + этап 2 тестера).</param> /// <param name="Text">Текст сообщения для проверки фильтром.</param>
public sealed record CheckMessageRequest(string Text); public sealed record CheckMessageRequest(string Text);
@@ -9,15 +9,8 @@ using Deal.Modules.Tenants.Application.Models;
namespace Deal.Api.Endpoints; namespace Deal.Api.Endpoints;
/// <summary> /// <summary>
/// Эндпоинты контейнеров (колонок/стадий/зон) и состояния колонок: GET/POST /api/containers, /// Эндпоинты контейнеров
/// PATCH /{id}/accept, PATCH/DELETE /{id}, POST /reorder, GET/PATCH state (этап 9, T4/T6).
/// </summary> /// </summary>
/// <remarks>
/// Единый реестр контейнеров приходит на смену /api/boards + /api/columns (R5): список, создание,
/// частичное обновление, принятие ИИ-предложения, удаление с переносом карточек в inbox, reorder и
/// состояние колонок (colState). Все эндпоинты требуют сессию: 401 {detail}. ContainersService
/// резолвится из RequestServices ПОСЛЕ проверки сессии.
/// </remarks>
public static class ContainersEndpoints public static class ContainersEndpoints
{ {
// Префикс группы контейнеров. // Префикс группы контейнеров.
@@ -42,7 +35,7 @@ public static class ContainersEndpoints
private static readonly JsonSerializerOptions RequestJsonOptions = new(JsonSerializerDefaults.Web); private static readonly JsonSerializerOptions RequestJsonOptions = new(JsonSerializerDefaults.Web);
/// <summary> /// <summary>
/// Регистрирует группы /api/containers (контейнеры + состояние колонок). /// Регистрирует группы /api/containers
/// </summary> /// </summary>
/// <param name="app">Построитель маршрутов приложения.</param> /// <param name="app">Построитель маршрутов приложения.</param>
/// <returns>Построитель маршрутов для цепочки вызовов.</returns> /// <returns>Построитель маршрутов для цепочки вызовов.</returns>
@@ -104,7 +97,6 @@ public static class ContainersEndpoints
Note: body.Note ?? string.Empty), Note: body.Note ?? string.Empty),
ct); ct);
// Аудит создания контейнера (этап 10, T1).
await AuditAppender.AppendTenantAsync(context, AuditEvents.ContainerCreated, new { id = created.Id, name = created.Name }, ct); await AuditAppender.AppendTenantAsync(context, AuditEvents.ContainerCreated, new { id = created.Id, name = created.Name }, ct);
return Results.Ok(new { id = created.Id }); return Results.Ok(new { id = created.Id });
} }
@@ -168,7 +160,6 @@ public static class ContainersEndpoints
return EndpointResults.NotFound(ContainerNotFoundDetail); return EndpointResults.NotFound(ContainerNotFoundDetail);
} }
// Аудит изменения контейнера (этап 10, T1).
await AuditAppender.AppendTenantAsync(context, AuditEvents.ContainerUpdated, new { id = updated.Id }, ct); await AuditAppender.AppendTenantAsync(context, AuditEvents.ContainerUpdated, new { id = updated.Id }, ct);
return Results.Ok(new { id = updated.Id }); return Results.Ok(new { id = updated.Id });
} }
@@ -191,7 +182,6 @@ public static class ContainersEndpoints
return EndpointResults.NotFound(ContainerNotFoundDetail); return EndpointResults.NotFound(ContainerNotFoundDetail);
} }
// Аудит изменения контейнера (принятие ИИ-предложения) — этап 10, T1.
await AuditAppender.AppendTenantAsync(context, AuditEvents.ContainerUpdated, new { id = accepted.Id }, ct); await AuditAppender.AppendTenantAsync(context, AuditEvents.ContainerUpdated, new { id = accepted.Id }, ct);
return Results.Ok(accepted); return Results.Ok(accepted);
} }
@@ -210,7 +200,6 @@ public static class ContainersEndpoints
ContainersService containers = context.RequestServices.GetRequiredService<ContainersService>(); ContainersService containers = context.RequestServices.GetRequiredService<ContainersService>();
int moved = await containers.DeleteAsync(containerId, ct); int moved = await containers.DeleteAsync(containerId, ct);
// Аудит удаления контейнера (этап 10, T1).
await AuditAppender.AppendTenantAsync(context, AuditEvents.ContainerDeleted, new { id = containerId }, ct); await AuditAppender.AppendTenantAsync(context, AuditEvents.ContainerDeleted, new { id = containerId }, ct);
return Results.Ok(new { ok = true, movedToInbox = moved }); return Results.Ok(new { ok = true, movedToInbox = moved });
} }
@@ -14,27 +14,12 @@ using Deal.Modules.Telegram.Application;
namespace Deal.Api.Endpoints; namespace Deal.Api.Endpoints;
/// <summary> /// <summary>
/// Эндпоинты /api/discovery: задачи поиска, кандидаты, чёрный список, лог, генерация ключей (Ruling 11, api-map §3.8). /// Эндпоинты /api/discovery
/// </summary> /// </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 public static class DiscoveryEndpoints
{ {
// Префикс группы /api/discovery (python: router prefix, discovery_routes.py L26).
private const string DiscoveryGroupPrefix = "/api/discovery"; private const string DiscoveryGroupPrefix = "/api/discovery";
// OpenAPI-тег группы (в прототипе роутер discovery — discovery_routes.py).
private const string DiscoveryOpenApiTag = "discovery"; private const string DiscoveryOpenApiTag = "discovery";
// Путь списка задач (GET). // Путь списка задач (GET).
@@ -70,38 +55,28 @@ public static class DiscoveryEndpoints
// Путь лога задачи (GET). // Путь лога задачи (GET).
private const string TaskLogPath = "/tasks/{task_id}/log"; private const string TaskLogPath = "/tasks/{task_id}/log";
// 404 create/start/patch/candidates/log: задачи нет (python _task_or_404 L8387).
private const string TaskNotFoundDetail = "Задача не найдена"; private const string TaskNotFoundDetail = "Задача не найдена";
// 404 join/reject: кандидата нет (python _candidate_or_404 L9094).
private const string CandidateNotFoundDetail = "Кандидат не найден"; private const string CandidateNotFoundDetail = "Кандидат не найден";
// 400 join: уже вступили (python L235237).
private const string AlreadyJoinedDetail = "Уже вступили в этот источник"; private const string AlreadyJoinedDetail = "Уже вступили в этот источник";
// 400 reject: источник уже вступили (python L256258).
private const string JoinedRejectDetail = "Уже вступили — удалите источник из каналов"; private const string JoinedRejectDetail = "Уже вступили — удалите источник из каналов";
// 400 join: ошибка Telegram при вступлении (python L240242, текст с @username).
private const string JoinFailedFormat = "Не удалось вступить в @{0}: {1}"; private const string JoinFailedFormat = "Не удалось вступить в @{0}: {1}";
// Причина отклонения вручную для чёрного списка/лога (python L260: reason="отклонено вручную").
private const string ManualRejectReason = "отклонено вручную"; private const string ManualRejectReason = "отклонено вручную";
// Мягкая ошибка generate-keywords: ИИ выключен (python _ai_unavailable_reason L99100).
private const string AiDisabledDetail = "ИИ выключен в настройках (aiEnabled)"; private const string AiDisabledDetail = "ИИ выключен в настройках (aiEnabled)";
// Мягкая ошибка generate-keywords: описания нет (python L201202).
private const string NoDescriptionDetail = "У задачи нет описания — по нему генерируются ключи"; private const string NoDescriptionDetail = "У задачи нет описания — по нему генерируются ключи";
// Страховочный потолок числа сгенерированных ключей (python _KEYWORDS_LIMIT L31: промпт просит 10–16).
private const int KeywordsLimit = 30; private const int KeywordsLimit = 30;
// Потолок длины одного ключа (python _KEYWORD_LENGTH_LIMIT L33: короткие фразы для поиска Telegram).
private const int KeywordLengthLimit = 60; private const int KeywordLengthLimit = 60;
/// <summary> /// <summary>
/// Регистрирует группу /api/discovery: 13 эндпоинтов (tasks + generate-keywords + candidates + join/reject + blacklist + log). /// Регистрирует группу /api/discovery
/// </summary> /// </summary>
/// <param name="app">Построитель маршрутов приложения.</param> /// <param name="app">Построитель маршрутов приложения.</param>
/// <returns>Построитель маршрутов для цепочки вызовов.</returns> /// <returns>Построитель маршрутов для цепочки вызовов.</returns>
@@ -126,7 +101,6 @@ public static class DiscoveryEndpoints
return app; return app;
} }
// GET /api/discovery/tasks: список задач, старые первыми (list_tasks L141143).
private static async Task<IResult> ListTasksAsync(HttpContext context, CancellationToken ct) private static async Task<IResult> ListTasksAsync(HttpContext context, CancellationToken ct)
{ {
if (!context.HasUser()) if (!context.HasUser())
@@ -139,7 +113,6 @@ public static class DiscoveryEndpoints
return Results.Ok(new { items }); return Results.Ok(new { items });
} }
// POST /api/discovery/tasks: создать задачу поиска (create_task L146151; дефолты — в сервисе).
private static async Task<IResult> CreateTaskAsync( private static async Task<IResult> CreateTaskAsync(
DiscoveryTaskCreateBody body, DiscoveryTaskCreateBody body,
HttpContext context, 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( private static async Task<IResult> PatchTaskAsync(
string task_id, string task_id,
DiscoveryTaskPatchBody body, 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( private static async Task<IResult> DeleteTaskAsync(
string task_id, string task_id,
HttpContext context, HttpContext context,
@@ -202,7 +173,6 @@ public static class DiscoveryEndpoints
return deleted ? Results.Ok(new { ok = true }) : EndpointResults.NotFound(TaskNotFoundDetail); 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( private static async Task<IResult> StartTaskAsync(
string task_id, string task_id,
HttpContext context, 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( private static async Task<IResult> PauseTaskAsync(
string task_id, string task_id,
HttpContext context, HttpContext context,
@@ -242,8 +211,6 @@ public static class DiscoveryEndpoints
} }
// POST /api/discovery/tasks/{task_id}/generate-keywords: ИИ-ключи по описанию задачи // 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). // символов, дедуп casefold); описание режет до 4000 сам адаптер (GrpcAiTools.MaxDescriptionCodePoints).
// Локальный режим (LocalAiTools, UseLocal=true) — NotSupportedException → та же мягкая ветка с текстом причины. // Локальный режим (LocalAiTools, UseLocal=true) — NotSupportedException → та же мягкая ветка с текстом причины.
private static async Task<IResult> GenerateKeywordsAsync( private static async Task<IResult> GenerateKeywordsAsync(
@@ -284,18 +251,15 @@ public static class DiscoveryEndpoints
return Results.Ok(new { keywords = CleanKeywords(result.Keywords) }); 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 }); return Results.Ok(new { keywords = Array.Empty<string>(), error = result.Error ?? ServiceUnavailableText });
} }
catch (NotSupportedException exception) catch (NotSupportedException exception)
{ {
// Локальный режим: ai-service не подключён — инструменты недоступны (LocalAiTools, Task 15).
return Results.Ok(new { keywords = Array.Empty<string>(), error = exception.Message }); return Results.Ok(new { keywords = Array.Empty<string>(), error = exception.Message });
} }
} }
// GET /api/discovery/tasks/{task_id}/candidates?status=: кандидаты задачи с фильтром // GET /api/discovery/tasks/{task_id}/candidates?status=: кандидаты задачи с фильтром
// new|review|joined|rejected (list_candidates L216224; невалидный статус — пустой список).
private static async Task<IResult> ListCandidatesAsync( private static async Task<IResult> ListCandidatesAsync(
string task_id, string task_id,
string? status, string? status,
@@ -319,7 +283,6 @@ public static class DiscoveryEndpoints
return Results.Ok(new { items }); return Results.Ok(new { items });
} }
// POST /api/discovery/candidates/{dialog_id}/join: ручное вступление вне квот (join_candidate L226251).
// RPC Join → строка каталога Dialogs (монитор on) + зеркало → фоновый первый разбор → снятие чёрного списка → // RPC Join → строка каталога Dialogs (монитор on) + зеркало → фоновый первый разбор → снятие чёрного списка →
// mark_joined(auto:false). Ошибка Telegram → 400 с текстом причины. // mark_joined(auto:false). Ошибка Telegram → 400 с текстом причины.
private static async Task<IResult> JoinCandidateAsync( private static async Task<IResult> JoinCandidateAsync(
@@ -357,12 +320,10 @@ public static class DiscoveryEndpoints
} }
// Источник в каталоге (монитор on, backfilled=false) + монитор-зеркало telegram-service // Источник в каталоге (монитор on, backfilled=false) + монитор-зеркало telegram-service
// (python add_dialog_monitored L242; решение T18: локальную строку пишет Api-слой).
DialogsService dialogs = context.RequestServices.GetRequiredService<DialogsService>(); DialogsService dialogs = context.RequestServices.GetRequiredService<DialogsService>();
await dialogs.AddDiscoveredMonitoredAsync(dialog_id, row.Name, username, row.Kind, row.Hue, ct); await dialogs.AddDiscoveredMonitoredAsync(dialog_id, row.Name, username, row.Kind, row.Hue, ct);
// Догон последних сообщений — в фоне: join из UI не должен висеть на паузах backfill // Догон последних сообщений — в фоне: join из UI не должен висеть на паузах backfill
// (python _spawn(_backfill_quiet) L244245; источник уже в каталоге и мониторится).
context.RequestServices.GetRequiredService<TelegramBackfillScheduler>().ScheduleFirstBackfill(dialog_id); context.RequestServices.GetRequiredService<TelegramBackfillScheduler>().ScheduleFirstBackfill(dialog_id);
DiscoveryBlacklistService blacklist = context.RequestServices.GetRequiredService<DiscoveryBlacklistService>(); DiscoveryBlacklistService blacklist = context.RequestServices.GetRequiredService<DiscoveryBlacklistService>();
@@ -380,7 +341,6 @@ public static class DiscoveryEndpoints
} }
// POST /api/discovery/candidates/{dialog_id}/reject: отклонить кандидата — в чёрный список // POST /api/discovery/candidates/{dialog_id}/reject: отклонить кандидата — в чёрный список
// (reject_candidate L253264; уже вступившего — нельзя, 400).
private static async Task<IResult> RejectCandidateAsync( private static async Task<IResult> RejectCandidateAsync(
string dialog_id, string dialog_id,
HttpContext context, 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) private static async Task<IResult> ListBlacklistAsync(HttpContext context, CancellationToken ct)
{ {
if (!context.HasUser()) if (!context.HasUser())
@@ -427,7 +386,6 @@ public static class DiscoveryEndpoints
return Results.Ok(new { items }); return Results.Ok(new { items });
} }
// DELETE /api/discovery/blacklist/{dialog_id}: снять источник с чёрного списка (remove_blacklist L274277).
private static async Task<IResult> RemoveBlacklistAsync( private static async Task<IResult> RemoveBlacklistAsync(
string dialog_id, string dialog_id,
HttpContext context, HttpContext context,
@@ -443,7 +401,6 @@ public static class DiscoveryEndpoints
return Results.Ok(new { ok = true }); return Results.Ok(new { ok = true });
} }
// GET /api/discovery/tasks/{task_id}/log: лог задачи (task_log L282285), события от новых к старым.
private static async Task<IResult> TaskLogAsync( private static async Task<IResult> TaskLogAsync(
string task_id, string task_id,
HttpContext context, HttpContext context,
@@ -467,10 +424,8 @@ public static class DiscoveryEndpoints
} }
/// <summary> /// <summary>
/// Ключи из ответа ИИ: строки без пустых/длинных и повторов (python _clean_keywords L111128). /// Ключи из ответа ИИ
/// </summary> /// </summary>
/// <remarks>Повтор считается по <c>casefold</c> python: здесь — регистронезависимое сравнение
/// (RU/EN-ключи; StringComparer.OrdinalIgnoreCase). Потолок списка — <see cref="KeywordsLimit"/>.</remarks>
/// <param name="raw">Сырые ключи ответа модели (null — пусто).</param> /// <param name="raw">Сырые ключи ответа модели (null — пусто).</param>
/// <returns>Очищенные ключи (не более 30, каждый ≤60 символов).</returns> /// <returns>Очищенные ключи (не более 30, каждый ≤60 символов).</returns>
public static IReadOnlyList<string> CleanKeywords(IEnumerable<string>? raw) public static IReadOnlyList<string> CleanKeywords(IEnumerable<string>? raw)
@@ -505,13 +460,11 @@ public static class DiscoveryEndpoints
return outList; return outList;
} }
// Фолбэк-текст недоступного ИИ, если адаптер причину не вернул (мягкая ошибка, Ruling 11).
private const string ServiceUnavailableText = "ИИ недоступен — повторите попытку через несколько секунд"; private const string ServiceUnavailableText = "ИИ недоступен — повторите попытку через несколько секунд";
// Читает настройку aiEnabled (KV; отсутствие строки — дефолт SettingsDefaults). // Читает настройку aiEnabled (KV; отсутствие строки — дефолт SettingsDefaults).
// settings: KV-хранилище настроек тенанта. // settings: KV-хранилище настроек тенанта.
// ct: Токен отмены. // ct: Токен отмены.
// Возвращает: True — ИИ включён (ветки выключателя отрабатывает вызывающий, Ruling 10/11).
private static async Task<bool> ReadAiEnabledAsync(ISettingsStore settings, CancellationToken ct) private static async Task<bool> ReadAiEnabledAsync(ISettingsStore settings, CancellationToken ct)
{ {
SettingValue? row = await settings.GetAsync(SettingsKeys.AiEnabled, ct); SettingValue? row = await settings.GetAsync(SettingsKeys.AiEnabled, ct);
@@ -552,7 +505,6 @@ public static class DiscoveryEndpoints
}; };
} }
// Маппит тело патча в сервисный патч (не-null значения; как python model_dump(exclude_none=True)).
// body: Тело запроса (wire-поля camelCase). // body: Тело запроса (wire-поля camelCase).
// Возвращает: Патч задачи (DiscoveryTaskPatch). // Возвращает: Патч задачи (DiscoveryTaskPatch).
private static DiscoveryTaskPatch ToPatch(DiscoveryTaskPatchBody body) private static DiscoveryTaskPatch ToPatch(DiscoveryTaskPatchBody body)
+1 -15
View File
@@ -6,37 +6,23 @@ using Deal.Api.Services;
namespace Deal.Api.Endpoints; namespace Deal.Api.Endpoints;
/// <summary> /// <summary>
/// SSE-поток событий канбана: GET /api/events (Ruling 5; прототип events_routes.py L1538). /// SSE-поток событий канбана
/// </summary> /// </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 public static class EventsEndpoint
{ {
// Путь потока (роутер events, events_routes.py L12: prefix="/api").
private const string EventsPath = "/api/events"; private const string EventsPath = "/api/events";
// OpenAPI-тег группы (в прототипе — роутер events_routes.py).
private const string OpenApiTag = "events"; private const string OpenApiTag = "events";
// Тип контента потока (events_routes.py L32).
private const string EventStreamContentType = "text/event-stream"; private const string EventStreamContentType = "text/event-stream";
// Директива кеширования: поток не кешируется (events_routes.py L34).
private const string NoCacheHeaderValue = "no-cache"; private const string NoCacheHeaderValue = "no-cache";
// Отключение буферизации ответа nginx-прокси (events_routes.py L35).
private const string NoBufferingHeaderValue = "no"; private const string NoBufferingHeaderValue = "no";
// Ping-комментарий: строки протокола SSE, начинающиеся с ':', клиент игнорирует. // Ping-комментарий: строки протокола SSE, начинающиеся с ':', клиент игнорирует.
private const string PingComment = ": ping\n\n"; private const string PingComment = ": ping\n\n";
// Интервал ping при тишине: держим соединение (events_routes.py L24: timeout=15).
private static readonly TimeSpan PingInterval = TimeSpan.FromSeconds(15); private static readonly TimeSpan PingInterval = TimeSpan.FromSeconds(15);
/// <summary> /// <summary>
@@ -5,29 +5,14 @@ using Deal.Modules.Settings.Application.Models;
namespace Deal.Api.Endpoints; namespace Deal.Api.Endpoints;
/// <summary> /// <summary>
/// Тестер фильтра входящих: POST /api/admin/check-message (Ruling 8, api-map §3.2 L109, §4.10 L364). /// Тестер фильтра входящих
/// </summary> /// </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 public static class FilterTesterEndpoints
{ {
// Префикс группы API (общий для эндпоинтов этапа, Ruling 8).
private const string ApiGroupPrefix = "/api"; private const string ApiGroupPrefix = "/api";
// Путь тестера фильтра входящих (dashboard_routes.py L267).
private const string CheckMessagePath = "/admin/check-message"; private const string CheckMessagePath = "/admin/check-message";
// OpenAPI-тег группы (эндпоинт Settings-экрана, Ruling 8).
private const string OpenApiTag = "settings"; private const string OpenApiTag = "settings";
/// <summary> /// <summary>
@@ -42,7 +27,6 @@ public static class FilterTesterEndpoints
return app; return app;
} }
// POST /api/admin/check-message: этап-1 правила + этап-2 (skipped) для тестера (dashboard_routes.py L267284).
private static async Task<IResult> CheckAsync( private static async Task<IResult> CheckAsync(
CheckMessageRequest body, CheckMessageRequest body,
HttpContext context, HttpContext context,
@@ -57,7 +41,6 @@ public static class FilterTesterEndpoints
IncomingRules incomingRules = context.RequestServices.GetRequiredService<IncomingRules>(); IncomingRules incomingRules = context.RequestServices.GetRequiredService<IncomingRules>();
IncomingRulesResult stage1 = await incomingRules.CheckAsync(body.Text, ct); IncomingRulesResult stage1 = await incomingRules.CheckAsync(body.Text, ct);
// Ответ 1:1 с прототипом: stage2 на этапе 2 всегда skipped (Ruling 4/8, план L377380).
if (!stage1.Pass) if (!stage1.Pass)
{ {
return Results.Ok(new return Results.Ok(new
+1 -15
View File
@@ -5,17 +5,8 @@ using Deal.Modules.Tenants.Application.Services;
namespace Deal.Api.Endpoints; namespace Deal.Api.Endpoints;
/// <summary> /// <summary>
/// Публичный эндпоинт активации инвайта: POST /api/join (Ruling 2/11 этапа 7). /// Публичный эндпоинт активации инвайта
/// </summary> /// </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 public static class JoinEndpoint
{ {
// Путь ручки (вне группы /api/operator — публичная). // Путь ручки (вне группы /api/operator — публичная).
@@ -27,7 +18,6 @@ public static class JoinEndpoint
// Текст 400: приглашение с таким кодом не найдено. // Текст 400: приглашение с таким кодом не найдено.
private const string InviteNotFoundDetail = "Приглашение не найдено"; private const string InviteNotFoundDetail = "Приглашение не найдено";
// Текст 400: срок действия приглашения истёк (план Task 6, Ruling 2).
private const string InviteExpiredDetail = "Срок действия приглашения истёк"; private const string InviteExpiredDetail = "Срок действия приглашения истёк";
// Текст 400: приглашение уже активировано (повторная активация тем же кодом). // Текст 400: приглашение уже активировано (повторная активация тем же кодом).
@@ -36,13 +26,10 @@ public static class JoinEndpoint
// Текст 400: приглашение отозвано оператором. // Текст 400: приглашение отозвано оператором.
private const string InviteRevokedDetail = "Приглашение отозвано"; private const string InviteRevokedDetail = "Приглашение отозвано";
// Текст 400: email запроса не совпадает с email приглашения (Ruling 2).
private const string EmailMismatchDetail = "Email не совпадает с приглашением"; private const string EmailMismatchDetail = "Email не совпадает с приглашением";
// Текст 400: пользователь с таким email уже зарегистрирован (users.login unique, Ruling 2).
private const string EmailTakenDetail = "Этот email уже зарегистрирован"; private const string EmailTakenDetail = "Этот email уже зарегистрирован";
// Текст 400: пароль короче минимума (текст как в AuthEndpoints, план Task 6).
private const string PasswordTooShortDetail = "Пароль слишком короткий (минимум 8 символов)"; private const string PasswordTooShortDetail = "Пароль слишком короткий (минимум 8 символов)";
// Текст 400: целевой тенант инвайта не существует (Security review). // Текст 400: целевой тенант инвайта не существует (Security review).
@@ -62,7 +49,6 @@ public static class JoinEndpoint
return app; return app;
} }
// POST /api/join: активация инвайта; успех пишется в аудит (invite_activated, Task 6/Ruling 4).
private static async Task<IResult> JoinAsync( private static async Task<IResult> JoinAsync(
JoinRequest body, JoinRequest body,
JoinService joinService, JoinService joinService,
+1 -1
View File
@@ -1,7 +1,7 @@
namespace Deal.Api.Endpoints; namespace Deal.Api.Endpoints;
/// <summary> /// <summary>
/// Тело POST /api/join — активация инвайта (Ruling 2, Task 6 этапа 7). Входящий JSON — camelCase (code, email, name?, password). /// Тело POST /api/join — активация инвайта.
/// </summary> /// </summary>
/// <param name="Code">Код приглашения (16 url-safe символов).</param> /// <param name="Code">Код приглашения (16 url-safe символов).</param>
/// <param name="Email">Email активирующего; обязан совпасть с email приглашения (нормализует JoinService).</param> /// <param name="Email">Email активирующего; обязан совпасть с email приглашения (нормализует JoinService).</param>
+1 -1
View File
@@ -1,7 +1,7 @@
namespace Deal.Api.Endpoints; namespace Deal.Api.Endpoints;
/// <summary> /// <summary>
/// Тело POST /api/auth/login. Входящий JSON — camelCase (login, password). /// Тело POST /api/auth/login.
/// </summary> /// </summary>
/// <param name="Login">Логин пользователя.</param> /// <param name="Login">Логин пользователя.</param>
/// <param name="Password">Пароль в открытом виде.</param> /// <param name="Password">Пароль в открытом виде.</param>
@@ -1,9 +1,9 @@
namespace Deal.Api.Endpoints; namespace Deal.Api.Endpoints;
/// <summary> /// <summary>
/// Тело POST /api/ml/apply. Входящий JSON — camelCase (dialogId, msgId, action). /// Тело POST /api/ml/apply.
/// </summary> /// </summary>
/// <param name="DialogId">Id диалога/канала Telegram, где лежит исходное сообщение.</param> /// <param name="DialogId">Id диалога/канала Telegram, где лежит исходное сообщение.</param>
/// <param name="MsgId">Id сообщения внутри диалога.</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); public sealed record MlApplyRequest(string DialogId, int MsgId, string Action);
@@ -1,7 +1,7 @@
namespace Deal.Api.Endpoints; namespace Deal.Api.Endpoints;
/// <summary> /// <summary>
/// Тело POST /api/ml/candidates. Входящий JSON — camelCase (dialogId, limit). /// Тело POST /api/ml/candidates.
/// </summary> /// </summary>
/// <param name="DialogId">Id диалога/канала Telegram; пусто — выборка по всем источникам тенанта (§8).</param> /// <param name="DialogId">Id диалога/канала Telegram; пусто — выборка по всем источникам тенанта (§8).</param>
/// <param name="Limit">Сколько последних сообщений вернуть (кламп 1..60, дефолт 10).</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; namespace Deal.Api.Endpoints;
/// <summary> /// <summary>
/// Эндпоинты ML-панели: GET /api/ml/status, POST /api/ml/reset, /predict, /candidates, /apply (Ruling 8, api-map §3.7). /// Эндпоинты ML-панели
/// </summary> /// </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 public static class MlEndpoints
{ {
// Префикс группы /api/ml (Ruling 8: MapMlEndpoints).
private const string MlGroupPrefix = "/api/ml"; private const string MlGroupPrefix = "/api/ml";
// OpenAPI-тег группы (в прототипе роутер ml — ml_routes.py).
private const string MlOpenApiTag = "ml"; private const string MlOpenApiTag = "ml";
// Путь статуса ML (GET). // Путь статуса ML (GET).
@@ -46,20 +31,16 @@ public static class MlEndpoints
// Путь ручного решения по сообщению (POST). // Путь ручного решения по сообщению (POST).
private const string ApplyPath = "/apply"; private const string ApplyPath = "/apply";
// Минимальная длина текста для проверки (ml_routes.py L87: len(text) &lt; 2 → 400).
private const int MinPredictTextLength = 2; private const int MinPredictTextLength = 2;
// Длина текста в ответе predict: первые 200 символов (ml_routes.py L90 text[:200]).
private const int PredictTextPreviewLength = 200; private const int PredictTextPreviewLength = 200;
// Сообщение 400 для слишком короткого текста (ml_routes.py L88, план Task 9 L348).
private const string EnterTextDetail = "Введите текст"; private const string EnterTextDetail = "Введите текст";
// Сообщение 404 apply: исходное сообщение не найдено (ml_routes.py L142, план Task 9 L352).
private const string MessageNotFoundDetail = "Исходное сообщение не найдено"; private const string MessageNotFoundDetail = "Исходное сообщение не найдено";
/// <summary> /// <summary>
/// Регистрирует группу /api/ml: status/reset/predict/candidates/apply. /// Регистрирует группу /api/ml
/// </summary> /// </summary>
/// <param name="app">Построитель маршрутов приложения.</param> /// <param name="app">Построитель маршрутов приложения.</param>
/// <returns>Построитель маршрутов для цепочки вызовов.</returns> /// <returns>Построитель маршрутов для цепочки вызовов.</returns>
@@ -76,7 +57,6 @@ public static class MlEndpoints
return app; return app;
} }
// GET /api/ml/status: статус ML-сервиса + локальная статистика (ml_routes.py L6675).
private static async Task<IResult> StatusAsync(HttpContext context, CancellationToken ct) private static async Task<IResult> StatusAsync(HttpContext context, CancellationToken ct)
{ {
if (!context.HasUser()) if (!context.HasUser())
@@ -88,7 +68,6 @@ public static class MlEndpoints
return Results.Ok(await mlClient.StatusAsync(ct)); 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) private static async Task<IResult> ResetAsync(HttpContext context, CancellationToken ct)
{ {
if (!context.HasUser()) if (!context.HasUser())
@@ -100,7 +79,6 @@ public static class MlEndpoints
return Results.Ok(await mlClient.ResetAsync(ct)); return Results.Ok(await mlClient.ResetAsync(ct));
} }
// POST /api/ml/predict: проверка ML на тексте (ml_routes.py L8490).
private static async Task<IResult> PredictAsync( private static async Task<IResult> PredictAsync(
MlPredictRequest body, MlPredictRequest body,
HttpContext context, HttpContext context,
@@ -120,7 +98,6 @@ public static class MlEndpoints
IMlClient mlClient = context.RequestServices.GetRequiredService<IMlClient>(); IMlClient mlClient = context.RequestServices.GetRequiredService<IMlClient>();
MlPredictResultDto result = await mlClient.PredictAsync(text, ct); MlPredictResultDto result = await mlClient.PredictAsync(text, ct);
// Ответ 1:1 с ml_routes.py L90: {"text": <первые 200>, **результат предсказания}.
string preview = text.Length <= PredictTextPreviewLength string preview = text.Length <= PredictTextPreviewLength
? text ? text
: text[..PredictTextPreviewLength]; : 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( private static async Task<IResult> CandidatesAsync(
MlCandidatesRequest body, MlCandidatesRequest body,
HttpContext context, HttpContext context,
@@ -154,7 +130,6 @@ public static class MlEndpoints
return Results.Ok(new { items }); return Results.Ok(new { items });
} }
// POST /api/ml/apply: ручное решение по сообщению (ml_routes.py L137171).
private static async Task<IResult> ApplyAsync( private static async Task<IResult> ApplyAsync(
MlApplyRequest body, MlApplyRequest body,
HttpContext context, HttpContext context,
@@ -1,7 +1,7 @@
namespace Deal.Api.Endpoints; namespace Deal.Api.Endpoints;
/// <summary> /// <summary>
/// Тело POST /api/ml/predict. Входящий JSON — camelCase (text). /// Тело POST /api/ml/predict.
/// </summary> /// </summary>
/// <param name="Text">Текст сообщения для проверки ML (обрезается/тримится обработчиком, как ml_routes.py L86).</param> /// <param name="Text">Текст сообщения для проверки ML.</param>
public sealed record MlPredictRequest(string Text); public sealed record MlPredictRequest(string Text);
@@ -6,17 +6,10 @@ using Deal.Modules.Tenants.Application.Services;
namespace Deal.Api.Endpoints; namespace Deal.Api.Endpoints;
/// <summary> /// <summary>
/// Операторские read-only эндпоинты аналитики: /api/operator/analytics/{overview,tokens,activity} (этап 10, T3). /// Операторские read-only эндпоинты аналитики
/// </summary> /// </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 public static class OperatorAnalyticsEndpoints
{ {
// Префикс группы аналитики (Ruling 4 этапа 10).
private const string AnalyticsGroupPrefix = "/api/operator/analytics"; private const string AnalyticsGroupPrefix = "/api/operator/analytics";
// OpenAPI-тег группы. // OpenAPI-тег группы.
@@ -29,7 +22,7 @@ public static class OperatorAnalyticsEndpoints
private const string InvalidGroupByDetail = "Неизвестная группировка (day|tenant|provider|model)"; private const string InvalidGroupByDetail = "Неизвестная группировка (day|tenant|provider|model)";
/// <summary> /// <summary>
/// Регистрирует группу /api/operator/analytics: overview/tokens/activity. /// Регистрирует группу /api/operator/analytics
/// </summary> /// </summary>
/// <param name="app">Построитель маршрутов приложения.</param> /// <param name="app">Построитель маршрутов приложения.</param>
/// <returns>Построитель маршрутов для цепочки вызовов.</returns> /// <returns>Построитель маршрутов для цепочки вызовов.</returns>
@@ -159,7 +152,7 @@ public static class OperatorAnalyticsEndpoints
} }
/// <summary> /// <summary>
/// Известна ли группировка расхода токенов (day|tenant|provider|model). /// Известна ли группировка расхода токенов
/// </summary> /// </summary>
/// <param name="groupBy">Значение группировки.</param> /// <param name="groupBy">Значение группировки.</param>
/// <returns>True — поддерживаемая группировка.</returns> /// <returns>True — поддерживаемая группировка.</returns>
@@ -6,28 +6,19 @@ using Deal.Modules.Tenants.Application.Services;
namespace Deal.Api.Endpoints; namespace Deal.Api.Endpoints;
/// <summary> /// <summary>
/// Операторский эндпоинт чтения аудита: GET /api/operator/audit (Ruling 4 этапа 7). /// Операторский эндпоинт чтения аудита
/// </summary> /// </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 public static class OperatorAuditEndpoints
{ {
// Префикс группы операторских ручек /api/operator (Ruling 11).
private const string OperatorGroupPrefix = "/api/operator"; private const string OperatorGroupPrefix = "/api/operator";
// Путь ленты аудита относительно группы. // Путь ленты аудита относительно группы.
private const string AuditPath = "/audit"; private const string AuditPath = "/audit";
// OpenAPI-тег группы (Ruling 11: операторская админка — API-only).
private const string OperatorOpenApiTag = "operator"; private const string OperatorOpenApiTag = "operator";
/// <summary> /// <summary>
/// Регистрирует группу /api/operator: GET /audit (лента аудита; другие ручки — задачи 5/7/10). /// Регистрирует группу /api/operator
/// </summary> /// </summary>
/// <param name="app">Построитель маршрутов приложения.</param> /// <param name="app">Построитель маршрутов приложения.</param>
/// <returns>Построитель маршрутов для цепочки вызовов.</returns> /// <returns>Построитель маршрутов для цепочки вызовов.</returns>
@@ -45,7 +36,6 @@ public static class OperatorAuditEndpoints
// from: Нижняя граница At (включительно; ISO-8601). // from: Нижняя граница At (включительно; ISO-8601).
// to: Верхняя граница At (включительно; ISO-8601). // to: Верхняя граница At (включительно; ISO-8601).
// limit: Размер выборки (дефолт 100, клампится 1..500). // limit: Размер выборки (дефолт 100, клампится 1..500).
// offset: Смещение страницы (≥0; этап 10, T3).
// context: Контекст запроса. // context: Контекст запроса.
// auditService: Сервис аудита (scoped). // auditService: Сервис аудита (scoped).
// ct: Токен отмены. // ct: Токен отмены.
@@ -77,7 +67,7 @@ public static class OperatorAuditEndpoints
} }
/// <summary> /// <summary>
/// Нормализует limit запроса: дефолт <see cref="AuditService.DefaultQueryLimit"/>, кламп 1..500 (Ruling 4). /// Нормализует limit запроса
/// </summary> /// </summary>
/// <param name="limit">Запрошенный размер выборки (null — не задан).</param> /// <param name="limit">Запрошенный размер выборки (null — не задан).</param>
/// <returns>Значение для фильтра.</returns> /// <returns>Значение для фильтра.</returns>
@@ -87,7 +77,7 @@ public static class OperatorAuditEndpoints
: Math.Max(1, Math.Min(AuditService.MaxQueryLimit, limit.Value)); : Math.Max(1, Math.Min(AuditService.MaxQueryLimit, limit.Value));
/// <summary> /// <summary>
/// Нормализует offset запроса: отрицательное/отсутствующее — 0 (этап 10, T3). /// Нормализует offset запроса
/// </summary> /// </summary>
/// <param name="offset">Запрошенное смещение (null — не задано).</param> /// <param name="offset">Запрошенное смещение (null — не задано).</param>
/// <returns>Неотрицательное смещение.</returns> /// <returns>Неотрицательное смещение.</returns>
@@ -12,15 +12,8 @@ using OperatorCookieOptions = Deal.Api.Configuration.OperatorCookieOptions;
namespace Deal.Api.Endpoints; namespace Deal.Api.Endpoints;
/// <summary> /// <summary>
/// HTTP-эндпоинты аутентификации оператора (группа /api/operator/auth). Зеркало AuthEndpoints для операторов (Ruling 1). /// HTTP-эндпоинты аутентификации оператора
/// </summary> /// </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 public static class OperatorAuthEndpoints
{ {
private const string InvalidCredentialsDetail = "Неверный логин или пароль оператора"; private const string InvalidCredentialsDetail = "Неверный логин или пароль оператора";
@@ -28,7 +21,7 @@ public static class OperatorAuthEndpoints
private const string OperatorAuthOpenApiTag = "operator-auth"; private const string OperatorAuthOpenApiTag = "operator-auth";
/// <summary> /// <summary>
/// Регистрирует группу /api/operator/auth: login, logout, me. /// Регистрирует группу /api/operator/auth
/// </summary> /// </summary>
/// <param name="app">Построитель маршрутов приложения.</param> /// <param name="app">Построитель маршрутов приложения.</param>
/// <returns>Построитель маршрутов для цепочки вызовов.</returns> /// <returns>Построитель маршрутов для цепочки вызовов.</returns>
@@ -36,7 +29,6 @@ public static class OperatorAuthEndpoints
{ {
var group = app.MapGroup(OperatorAuthGroupPrefix).WithTags(OperatorAuthOpenApiTag); var group = app.MapGroup(OperatorAuthGroupPrefix).WithTags(OperatorAuthOpenApiTag);
// Политика "auth" rate limiter (план Task 11, Ruling 5): фиксированное окно 10/мин на IP ручки
// входа оператора; остальные ручки группы — под глобальной API-политикой (по тенанту/IP). // входа оператора; остальные ручки группы — под глобальной API-политикой (по тенанту/IP).
group.MapPost("/login", LoginAsync).RequireRateLimiting(RateLimitPolicies.AuthPolicy); group.MapPost("/login", LoginAsync).RequireRateLimiting(RateLimitPolicies.AuthPolicy);
group.MapPost("/logout", LogoutAsync); group.MapPost("/logout", LogoutAsync);
@@ -45,8 +37,6 @@ public static class OperatorAuthEndpoints
return app; return app;
} }
// POST /api/operator/auth/login: проверка учётных данных оператора, выдача куки сессии; результат пишется в аудит (Task 4).
// До OperatorAuthService отрабатывает LoginAttemptGuard (5 неудач ip|login за 15 мин → 429, Ruling 5).
private static async Task<IResult> LoginAsync( private static async Task<IResult> LoginAsync(
LoginRequest body, LoginRequest body,
OperatorAuthService operatorAuthService, OperatorAuthService operatorAuthService,
@@ -58,7 +48,6 @@ public static class OperatorAuthEndpoints
{ {
string? attemptedLogin = NormalizeLogin(body.Login); string? attemptedLogin = NormalizeLogin(body.Login);
// Защита входа оператора (план Task 11, Ruling 5): зеркало AuthEndpoints — блокировка ключа
// ip|login до проверки учётных данных (в dev при RateLimit:Enabled=false гвард выключен). // ip|login до проверки учётных данных (в dev при RateLimit:Enabled=false гвард выключен).
if (await loginAttemptGuard.IsBlockedAsync(ClientIp(context), attemptedLogin, ct)) 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) if (result.Login is null || result.Token is null)
{ {
// Неверные учётные данные оператора — одно сообщение (зеркало AuthEndpoints). // Неверные учётные данные оператора — одно сообщение (зеркало AuthEndpoints).
// Аудит operator_login_failed — только для реальной попытки (непустой логин), без пароля (Ruling 4);
// счётчик неудач гварда растёт там же (пустые логины ключа не имеют). // счётчик неудач гварда растёт там же (пустые логины ключа не имеют).
if (attemptedLogin is not null) if (attemptedLogin is not null)
{ {
@@ -86,7 +74,6 @@ public static class OperatorAuthEndpoints
return EndpointResults.Unauthorized(InvalidCredentialsDetail); return EndpointResults.Unauthorized(InvalidCredentialsDetail);
} }
// Успешный вход оператора сбрасывает счётчик неудач ключа ip|login (Ruling 5).
await loginAttemptGuard.ResetAsync(ClientIp(context), result.Login, ct); await loginAttemptGuard.ResetAsync(ClientIp(context), result.Login, ct);
await auditService.AppendAsync(new AuditRecordDto( await auditService.AppendAsync(new AuditRecordDto(
@@ -115,7 +102,6 @@ public static class OperatorAuthEndpoints
await operatorAuthService.LogoutAsync(rawToken, ct); await operatorAuthService.LogoutAsync(rawToken, ct);
context.Response.Cookies.Delete(cookieName); context.Response.Cookies.Delete(cookieName);
// Выход оператора (этап 10, T1): событие пишется при живой разрешённой сессии.
if (operatorIdentity is not null) if (operatorIdentity is not null)
{ {
await AuditAppender.AppendOperatorAsync(context, AuditEvents.OperatorLogout, new { login = operatorIdentity.Login }, ct); 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 }); return Results.Ok(new { ok = true });
} }
// GET /api/operator/auth/me: проверка живой операторской сессии (401 без неё, Ruling 1).
private static IResult MeAsync(HttpContext context) private static IResult MeAsync(HttpContext context)
{ {
var operatorIdentity = context.GetCurrentOperator(); var operatorIdentity = context.GetCurrentOperator();
@@ -10,25 +10,10 @@ using Microsoft.EntityFrameworkCore;
namespace Deal.Api.Endpoints; namespace Deal.Api.Endpoints;
/// <summary> /// <summary>
/// Операторский health: GET /api/operator/health (план Task 10, Ruling 3/6/9/11) — ядро/БД и /// Операторский health
/// автономные сервисы ml/ai/telegram.
/// </summary> /// </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 public static class OperatorHealthEndpoints
{ {
// Префикс группы операторских ручек health (Ruling 11).
private const string OperatorGroupPrefix = "/api/operator"; private const string OperatorGroupPrefix = "/api/operator";
// Путь health-ручки. // Путь health-ручки.
@@ -46,7 +31,6 @@ public static class OperatorHealthEndpoints
// Статус сервиса: ответил, но не SERVING (grpc NOT_SERVING/SERVICE_UNKNOWN). // Статус сервиса: ответил, но не SERVING (grpc NOT_SERVING/SERVICE_UNKNOWN).
private const string StatusUnhealthy = "unhealthy"; private const string StatusUnhealthy = "unhealthy";
// Статус сервиса в Local-режиме: реальный сервис не подключён (UseLocal=true, Ruling 6).
private const string StatusLocal = "local"; private const string StatusLocal = "local";
// Режим сервиса: Local-адаптеры (UseLocal=true). // Режим сервиса: Local-адаптеры (UseLocal=true).
@@ -68,7 +52,7 @@ public static class OperatorHealthEndpoints
private const int DatabaseProbeTimeoutMilliseconds = 5000; private const int DatabaseProbeTimeoutMilliseconds = 5000;
/// <summary> /// <summary>
/// Регистрирует GET /api/operator/health (health ядра/БД и автономных сервисов). /// Регистрирует GET /api/operator/health
/// </summary> /// </summary>
/// <param name="app">Построитель маршрутов приложения.</param> /// <param name="app">Построитель маршрутов приложения.</param>
/// <returns>Построитель маршрутов для цепочки вызовов.</returns> /// <returns>Построитель маршрутов для цепочки вызовов.</returns>
@@ -1,8 +1,8 @@
namespace Deal.Api.Endpoints; namespace Deal.Api.Endpoints;
/// <summary> /// <summary>
/// Тело POST /api/operator/invites: email приглашённого и опциональный целевой тенант (Ruling 2 этапа 7). /// Тело POST /api/operator/invites
/// </summary> /// </summary>
/// <param name="Email">Email приглашённого (регистр/пробелы не важны — нормализует InvitesService).</param> /// <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); public sealed record OperatorInviteCreateRequest(string? Email, Guid? TenantId);
@@ -6,21 +6,13 @@ using Deal.Modules.Tenants.Application.Services;
namespace Deal.Api.Endpoints; namespace Deal.Api.Endpoints;
/// <summary> /// <summary>
/// Операторские эндпоинты приглашений: GET /api/operator/invites, POST (создание), POST {code}/revoke (Ruling 2/11 этапа 7). /// Операторские эндпоинты приглашений
/// </summary> /// </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 public static class OperatorInvitesEndpoints
{ {
// Текст 400: email пустой/некорректного формата. // Текст 400: email пустой/некорректного формата.
private const string InvalidEmailDetail = "Некорректный email"; private const string InvalidEmailDetail = "Некорректный email";
// Текст 400: на email уже есть активное приглашение (план Task 5, Ruling 2).
private const string DuplicateActiveDetail = "Для этого email уже есть активное приглашение"; private const string DuplicateActiveDetail = "Для этого email уже есть активное приглашение";
// Текст 404: приглашение с таким кодом не найдено. // Текст 404: приглашение с таким кодом не найдено.
@@ -29,7 +21,6 @@ public static class OperatorInvitesEndpoints
// Текст 400: отзыв приглашения не в статусе pending (уже отозвано/использовано/истекло). // Текст 400: отзыв приглашения не в статусе pending (уже отозвано/использовано/истекло).
private const string InviteNotPendingDetail = "Отозвать можно только ожидающее активации приглашение"; private const string InviteNotPendingDetail = "Отозвать можно только ожидающее активации приглашение";
// Префикс группы операторских ручек приглашений (Ruling 11).
private const string InvitesGroupPrefix = "/api/operator/invites"; private const string InvitesGroupPrefix = "/api/operator/invites";
// Относительный путь отзыва приглашения. // Относительный путь отзыва приглашения.
@@ -39,7 +30,7 @@ public static class OperatorInvitesEndpoints
private const string InvitesOpenApiTag = "operator-invites"; private const string InvitesOpenApiTag = "operator-invites";
/// <summary> /// <summary>
/// Регистрирует группу /api/operator/invites: GET (список), POST (создание), POST {code}/revoke (отзыв). /// Регистрирует группу /api/operator/invites
/// </summary> /// </summary>
/// <param name="app">Построитель маршрутов приложения.</param> /// <param name="app">Построитель маршрутов приложения.</param>
/// <returns>Построитель маршрутов для цепочки вызовов.</returns> /// <returns>Построитель маршрутов для цепочки вызовов.</returns>
@@ -1,9 +1,7 @@
namespace Deal.Api.Endpoints; namespace Deal.Api.Endpoints;
/// <summary> /// <summary>
/// Тело PATCH /api/operator/tenants/{id}/limit: смена лимитов ИИ-бюджета тенанта (план Task 10, Ruling 3). /// Тело PATCH /api/operator/tenants/{id}/limit
/// Оба поля опциональны — меняется только заданное; смена бюджета/периода сбрасывает флаги Warned80/
/// NotifiedExhausted (новый период открывает пороги тостов, один тост на период на порог, Ruling 3).
/// </summary> /// </summary>
/// <param name="Budget">Новый бюджет периода в токенах (≥0; 0 — ИИ запрещён); null — оставить текущий.</param> /// <param name="Budget">Новый бюджет периода в токенах (≥0; 0 — ИИ запрещён); null — оставить текущий.</param>
/// <param name="Period">Новый тип периода (константа <c>TenantLimitPeriods</c>: month|day); 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; namespace Deal.Api.Endpoints;
/// <summary> /// <summary>
/// Операторские эндпоинты лимитов ИИ-бюджета: сводка по всем тенантам и просмотр/смена лимита тенанта /// Операторские эндпоинты лимитов ИИ-бюджета
/// (план Task 10, Ruling 3/11).
/// </summary> /// </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 public static class OperatorLimitsEndpoints
{ {
// Текст 400: PATCH без полей (null-тело/пустой объект). // Текст 400: PATCH без полей (null-тело/пустой объект).
@@ -39,10 +26,8 @@ public static class OperatorLimitsEndpoints
// Верхняя граница процента расхода (диапазон 0..100) — константа расчёта CalculatePercent. // Верхняя граница процента расхода (диапазон 0..100) — константа расчёта CalculatePercent.
private const int PercentMax = 100; private const int PercentMax = 100;
// Префикс сводки лимитов (Ruling 11: /api/operator/*).
private const string OperatorGroupPrefix = "/api/operator"; private const string OperatorGroupPrefix = "/api/operator";
// Префикс группы операторских ручек тенантов (общий с Task 7).
private const string TenantsGroupPrefix = "/api/operator/tenants"; private const string TenantsGroupPrefix = "/api/operator/tenants";
// Путь сводки лимитов по всем тенантам. // Путь сводки лимитов по всем тенантам.
@@ -54,12 +39,10 @@ public static class OperatorLimitsEndpoints
// OpenAPI-тег группы сводки лимитов. // OpenAPI-тег группы сводки лимитов.
private const string LimitsOpenApiTag = "operator-limits"; private const string LimitsOpenApiTag = "operator-limits";
// Без состояния, поэтому безопасен как статический экземпляр (период-математика Task 8).
private static readonly TokenBudgetService BudgetService = new(); private static readonly TokenBudgetService BudgetService = new();
/// <summary> /// <summary>
/// Регистрирует ручки лимитов: GET /api/operator/limits (сводка) и GET/PATCH /// Регистрирует ручки лимитов
/// /api/operator/tenants/{id}/limit (детали/смена).
/// </summary> /// </summary>
/// <param name="app">Построитель маршрутов приложения.</param> /// <param name="app">Построитель маршрутов приложения.</param>
/// <returns>Построитель маршрутов для цепочки вызовов.</returns> /// <returns>Построитель маршрутов для цепочки вызовов.</returns>
@@ -72,7 +55,6 @@ public static class OperatorLimitsEndpoints
return app; return app;
} }
// GET /api/operator/limits: сводка бюджета/расхода по всем тенантам (план Task 10).
private static async Task<IResult> ListSummaryAsync( private static async Task<IResult> ListSummaryAsync(
HttpContext context, HttpContext context,
ITenantRepository tenantRepository, ITenantRepository tenantRepository,
@@ -89,7 +71,6 @@ public static class OperatorLimitsEndpoints
var items = new List<object>(tenants.Count); var items = new List<object>(tenants.Count);
foreach (TenantRecordDto tenant in tenants) foreach (TenantRecordDto tenant in tenants)
{ {
// Ленивый reset периода внутри GetStateAsync (Ruling 3): сводка всегда про текущий период.
BudgetStateDto state = await limitStore.GetStateAsync(tenant.Id, ct); BudgetStateDto state = await limitStore.GetStateAsync(tenant.Id, ct);
items.Add(new items.Add(new
{ {
@@ -199,14 +180,8 @@ public static class OperatorLimitsEndpoints
} }
/// <summary> /// <summary>
/// Процент расхода бюджета для операторской сводки/деталей (0..100, floor). /// Процент расхода бюджета для операторской сводки/деталей
/// </summary> /// </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="usedTokens">Использовано токенов с начала периода.</param>
/// <param name="budgetTokens">Бюджет периода.</param> /// <param name="budgetTokens">Бюджет периода.</param>
/// <returns>Процент в диапазоне 0..100 (расход сверх бюджета показывается как 100).</returns> /// <returns>Процент в диапазоне 0..100 (расход сверх бюджета показывается как 100).</returns>
@@ -5,16 +5,8 @@ using Deal.Infrastructure.Tenancy;
namespace Deal.Api.Endpoints; namespace Deal.Api.Endpoints;
/// <summary> /// <summary>
/// Операторские maintenance-ручки (этап 12, пакет C): пакетная миграция схем всех тенантов. /// Операторские maintenance-ручки
/// </summary> /// </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 public static class OperatorMaintenanceEndpoints
{ {
// Префикс группы операторских maintenance-ручек. // Префикс группы операторских maintenance-ручек.
@@ -27,7 +19,7 @@ public static class OperatorMaintenanceEndpoints
private const string MaintenanceOpenApiTag = "operator-maintenance"; private const string MaintenanceOpenApiTag = "operator-maintenance";
/// <summary> /// <summary>
/// Регистрирует группу /api/operator/maintenance: пакетная миграция схем тенантов. /// Регистрирует группу /api/operator/maintenance
/// </summary> /// </summary>
/// <param name="app">Построитель маршрутов приложения.</param> /// <param name="app">Построитель маршрутов приложения.</param>
/// <returns>Построитель маршрутов для цепочки вызовов.</returns> /// <returns>Построитель маршрутов для цепочки вызовов.</returns>
@@ -8,21 +8,8 @@ using Deal.Modules.Tenants.Application.Services;
namespace Deal.Api.Endpoints; namespace Deal.Api.Endpoints;
/// <summary> /// <summary>
/// Операторские ручки глобальных (системных) настроек: ключи приложения Telegram /// Операторские ручки глобальных
/// (ТЗ §4.1/§8.1).
/// </summary> /// </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 public static class OperatorSettingsEndpoints
{ {
// Префикс группы операторских настроек. // Префикс группы операторских настроек.
@@ -47,7 +34,7 @@ public static class OperatorSettingsEndpoints
private const string InvalidApiHashDetail = "Укажите непустой api_hash"; private const string InvalidApiHashDetail = "Укажите непустой api_hash";
/// <summary> /// <summary>
/// Регистрирует группу /api/operator/settings: telegram-keys (GET/PUT). /// Регистрирует группу /api/operator/settings
/// </summary> /// </summary>
/// <param name="app">Построитель маршрутов приложения.</param> /// <param name="app">Построитель маршрутов приложения.</param>
/// <returns>Построитель маршрутов для цепочки вызовов.</returns> /// <returns>Построитель маршрутов для цепочки вызовов.</returns>
@@ -135,7 +122,6 @@ public static class OperatorSettingsEndpoints
await keys.SaveAsync(effectiveApiId, effectiveApiHash, ct); await keys.SaveAsync(effectiveApiId, effectiveApiHash, ct);
// Аудит смены глобальных ключей: apiId — не секрет, apiHash в детали не пишется (Ruling 4).
await auditService.AppendAsync(new AuditRecordDto( await auditService.AppendAsync(new AuditRecordDto(
AuditEvents.TelegramKeysChanged, AuditEvents.TelegramKeysChanged,
AuditActorTypes.Operator, AuditActorTypes.Operator,
@@ -1,9 +1,8 @@
namespace Deal.Api.Endpoints; namespace Deal.Api.Endpoints;
/// <summary> /// <summary>
/// Тело POST /api/operator/tenants: создание тенанта оператором (план Task 7, Ruling 11). /// Тело POST /api/operator/tenants
/// </summary> /// </summary>
/// <param name="Name">Имя тенанта (обязательно; пробелы по краям обрезаются).</param> /// <param name="Name">Имя тенанта (обязательно; пробелы по краям обрезаются).</param>
/// <param name="Email">Email владельца (опционально): создаёт сразу пользователя-владельца с одноразовым /// <param name="Email">Email владельца (опционально): создаёт сразу пользователя-владельца с одноразовым паролем.</param>
/// паролем (иначе владелец заводится инвайтом, Ruling 2).</param>
public sealed record OperatorTenantCreateRequest(string? Name, string? Email); public sealed record OperatorTenantCreateRequest(string? Name, string? Email);
@@ -1,8 +1,7 @@
namespace Deal.Api.Endpoints; namespace Deal.Api.Endpoints;
/// <summary> /// <summary>
/// Тело POST /api/operator/tenants/{id}/impersonate: опциональный логин пользователя тенанта (план Task 7). /// Тело POST /api/operator/tenants/{id}/impersonate
/// </summary> /// </summary>
/// <param name="Login">Логин пользователя, под которым оператор входит (impersonation); null/пустой — /// <param name="Login">Логин пользователя, под которым оператор входит (impersonation); null/пустой — берётся первый пользователь тенанта (по времени создания).</param>
/// берётся первый пользователь тенанта (по времени создания).</param>
public sealed record OperatorTenantImpersonateRequest(string? Login); public sealed record OperatorTenantImpersonateRequest(string? Login);
@@ -8,24 +8,8 @@ using CookieOptions = Deal.Api.Configuration.CookieOptions;
namespace Deal.Api.Endpoints; namespace Deal.Api.Endpoints;
/// <summary> /// <summary>
/// Операторские эндпоинты тенантов: create/список/детали, suspend/unsuspend, impersonation (план Task 7, Ruling 1/4/10/11). /// Операторские эндпоинты тенантов
/// </summary> /// </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 public static class OperatorTenantsEndpoints
{ {
// Текст 400: имя тенанта пустое/пробельное (create). // Текст 400: имя тенанта пустое/пробельное (create).
@@ -46,7 +30,6 @@ public static class OperatorTenantsEndpoints
// Текст 400: в тенанте нет пользователей, а login не указан (impersonation без выбора). // Текст 400: в тенанте нет пользователей, а login не указан (impersonation без выбора).
private const string TenantHasNoUsersDetail = "В тенанте нет пользователей для входа"; private const string TenantHasNoUsersDetail = "В тенанте нет пользователей для входа";
// Префикс группы операторских ручек тенантов (Ruling 11).
private const string TenantsGroupPrefix = "/api/operator/tenants"; private const string TenantsGroupPrefix = "/api/operator/tenants";
// Относительный путь деталей тенанта. // Относительный путь деталей тенанта.
@@ -65,7 +48,7 @@ public static class OperatorTenantsEndpoints
private const string TenantsOpenApiTag = "operator-tenants"; private const string TenantsOpenApiTag = "operator-tenants";
/// <summary> /// <summary>
/// Регистрирует группу /api/operator/tenants: список, create, детали, suspend/unsuspend, impersonate. /// Регистрирует группу /api/operator/tenants
/// </summary> /// </summary>
/// <param name="app">Построитель маршрутов приложения.</param> /// <param name="app">Построитель маршрутов приложения.</param>
/// <returns>Построитель маршрутов для цепочки вызовов.</returns> /// <returns>Построитель маршрутов для цепочки вызовов.</returns>
@@ -83,7 +66,6 @@ public static class OperatorTenantsEndpoints
return app; return app;
} }
// GET /api/operator/tenants: список тенантов со счётчиками пользователей (план Task 7).
private static async Task<IResult> ListAsync( private static async Task<IResult> ListAsync(
HttpContext context, HttpContext context,
TenantAdminService tenantAdminService, TenantAdminService tenantAdminService,
@@ -100,8 +82,6 @@ public static class OperatorTenantsEndpoints
} }
// POST /api/operator/tenants: создание тенанта (Status active + провижининг схемы); аудит tenant_created. // 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( private static async Task<IResult> CreateAsync(
OperatorTenantCreateRequest body, OperatorTenantCreateRequest body,
HttpContext context, HttpContext context,
@@ -7,28 +7,12 @@ using Deal.Modules.Pipeline.Application.Services;
namespace Deal.Api.Endpoints; namespace Deal.Api.Endpoints;
/// <summary> /// <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> /// </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 public static class PipelineEndpoints
{ {
// Префикс группы (роутер processing, prefix="/api/pipeline" — processing_routes.py L10).
private const string PipelineGroupPrefix = "/api/pipeline"; private const string PipelineGroupPrefix = "/api/pipeline";
// OpenAPI-тег группы (в прототипе роутер processing — processing_routes.py L10).
private const string OpenApiTag = "processing"; private const string OpenApiTag = "processing";
// Путь сводки вкладки «Обработка» (GET). // Путь сводки вкладки «Обработка» (GET).
@@ -49,14 +33,12 @@ public static class PipelineEndpoints
// Путь возврата записи отсева в обработку (POST). // Путь возврата записи отсева в обработку (POST).
private const string RejectedReturnPath = "/rejected/{rejId}/return"; private const string RejectedReturnPath = "/rejected/{rejId}/return";
// 404 return: записи отсева нет (processing_routes.py L71: KeyError → 404, Ruling 10).
private const string RejectedNotFoundDetail = "Запись не найдена"; private const string RejectedNotFoundDetail = "Запись не найдена";
// Размер страницы по умолчанию списков очереди/отсева (processing.DEFAULT_LIMIT L48; фронт шлёт 120/80).
private const int DefaultPageSize = 100; private const int DefaultPageSize = 100;
/// <summary> /// <summary>
/// Регистрирует группу /api/pipeline: stats/queue/rejected/clear/{rejId}/return. /// Регистрирует группу /api/pipeline
/// </summary> /// </summary>
/// <param name="app">Построитель маршрутов приложения.</param> /// <param name="app">Построитель маршрутов приложения.</param>
/// <returns>Построитель маршрутов для цепочки вызовов.</returns> /// <returns>Построитель маршрутов для цепочки вызовов.</returns>
@@ -64,7 +46,6 @@ public static class PipelineEndpoints
{ {
var pipeline = app.MapGroup(PipelineGroupPrefix).WithTags(OpenApiTag); var pipeline = app.MapGroup(PipelineGroupPrefix).WithTags(OpenApiTag);
// Статические сегменты до /rejected/{rejId} (Ruling 10, api-map L19; порядок 1:1 с прототипом).
pipeline.MapGet(StatsPath, StatsAsync); pipeline.MapGet(StatsPath, StatsAsync);
pipeline.MapGet(QueuePath, QueueAsync); pipeline.MapGet(QueuePath, QueueAsync);
pipeline.MapGet(RejectedPath, RejectedAsync); pipeline.MapGet(RejectedPath, RejectedAsync);
@@ -75,7 +56,6 @@ public static class PipelineEndpoints
return app; 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) private static async Task<IResult> StatsAsync(HttpContext context, CancellationToken ct)
{ {
if (!context.HasUser()) if (!context.HasUser())
@@ -87,9 +67,6 @@ public static class PipelineEndpoints
return Results.Ok(await processing.StatsAsync(ct)); 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( private static async Task<IResult> QueueAsync(
int? limit, int? limit,
HttpContext context, HttpContext context,
@@ -107,8 +84,6 @@ public static class PipelineEndpoints
return Results.Ok(new { items, counts, rejected }); 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}. // первыми; offset ≥ 0, limit 1..500 (clamp в сервисе), значения эхом в ответе {items,total,offset,limit}.
private static async Task<IResult> RejectedAsync( private static async Task<IResult> RejectedAsync(
string? q, string? q,
@@ -127,7 +102,6 @@ public static class PipelineEndpoints
return Results.Ok(page); 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) private static async Task<IResult> ClearAsync(HttpContext context, CancellationToken ct)
{ {
if (!context.HasUser()) if (!context.HasUser())
@@ -140,8 +114,6 @@ public static class PipelineEndpoints
return Results.Ok(new { ok = true, cleared }); 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( private static async Task<IResult> DeleteAsync(
string rejId, string rejId,
HttpContext context, HttpContext context,
@@ -157,10 +129,6 @@ public static class PipelineEndpoints
return Results.Ok(new { ok = true }); 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( private static async Task<IResult> ReturnAsync(
string rejId, string rejId,
ReturnReasonRequest body, ReturnReasonRequest body,
+1 -15
View File
@@ -6,21 +6,10 @@ using Deal.Modules.Settings.Application.Services;
namespace Deal.Api.Endpoints; namespace Deal.Api.Endpoints;
/// <summary> /// <summary>
/// HTTP-эндпоинты курсов валют: GET /api/rates, POST /api/rates/refresh (Ruling 8, api-map §3.4 L149150). /// HTTP-эндпоинты курсов валют
/// </summary> /// </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 public static class RatesEndpoints
{ {
// Префикс группы API (общий для эндпоинтов этапа, Ruling 8).
private const string ApiGroupPrefix = "/api"; private const string ApiGroupPrefix = "/api";
// Путь текущих курсов (GET). // Путь текущих курсов (GET).
@@ -29,7 +18,6 @@ public static class RatesEndpoints
// Путь принудительного обновления (POST). // Путь принудительного обновления (POST).
private const string RatesRefreshPath = "/rates/refresh"; private const string RatesRefreshPath = "/rates/refresh";
// OpenAPI-тег группы (в прототипе роутер settings — settings_routes.py).
private const string OpenApiTag = "settings"; private const string OpenApiTag = "settings";
/// <summary> /// <summary>
@@ -58,7 +46,6 @@ public static class RatesEndpoints
RatesDto current = await ratesService.GetAsync(ct); RatesDto current = await ratesService.GetAsync(ct);
// Ленивое обновление (Ruling 6, план Task 8): протухший кэш / смена источника / нет кэша —
// фоновый RefreshAsync в отдельном scope; ответ — текущий кэш. // фоновый RefreshAsync в отдельном scope; ответ — текущий кэш.
if (await ratesService.ShouldFetchAsync(ct)) if (await ratesService.ShouldFetchAsync(ct))
{ {
@@ -68,7 +55,6 @@ public static class RatesEndpoints
return Results.Ok(current); 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) private static async Task<IResult> RefreshRatesAsync(HttpContext context, CancellationToken ct)
{ {
if (!context.HasUser()) if (!context.HasUser())
@@ -1,13 +1,8 @@
namespace Deal.Api.Endpoints.RequestModels; namespace Deal.Api.Endpoints.RequestModels;
/// <summary> /// <summary>
/// Тело POST /api/cards/{cardId}/links — добавление ссылки карточке (add_link L133143). /// Тело POST /api/cards/{cardId}/links — добавление ссылки карточке.
/// </summary> /// </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="Name">Название ссылки; пустое → name = url.</param>
/// <param name="Url">URL ссылки (без схемы — добавится https://).</param> /// <param name="Url">URL ссылки (без схемы — добавится https://).</param>
public sealed record CardLinkRequest(string? Name = null, string? Url = null); public sealed record CardLinkRequest(string? Name = null, string? Url = null);
@@ -1,10 +1,6 @@
namespace Deal.Api.Endpoints.RequestModels; namespace Deal.Api.Endpoints.RequestModels;
/// <summary> /// <summary>
/// Тело POST /api/cards/clear-col — полная очистка служебной колонки (dashboard_routes.py ClearColBody L224225, api-map §3.2 L93). /// Тело POST /api/cards/clear-col — полная очистка служебной колонки.
/// </summary> /// </summary>
/// <remarks>
/// Wire-имя — camelCase: col — "trash" | "archive" (другие колонки/отсутствие значения → 400
/// «Очищать можно только корзину или архив», валидация CardsService.ClearColAsync L237247).
/// </remarks>
public sealed record ClearColBody(string? Col); public sealed record ClearColBody(string? Col);
@@ -1,11 +1,6 @@
namespace Deal.Api.Endpoints.RequestModels; namespace Deal.Api.Endpoints.RequestModels;
/// <summary> /// <summary>
/// Тело PATCH /api/containers/{containerId}/state — смена состояния колонки (этап 9, T4). /// Тело PATCH /api/containers/{containerId}/state — смена состояния колонки.
/// </summary> /// </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); public sealed record ColStateBody(bool? Collapsed, string? Width);
@@ -1,10 +1,6 @@
namespace Deal.Api.Endpoints.RequestModels; namespace Deal.Api.Endpoints.RequestModels;
/// <summary> /// <summary>
/// Тело POST /api/cards/{cardId}/comments — добавление комментария (dashboard_routes.py CommentBody L6061). /// Тело POST /api/cards/{cardId}/comments — добавление комментария.
/// </summary> /// </summary>
/// <remarks>
/// Wire-имя — camelCase: text. Пустой/пробельный текст либо явный null → 400 «Пустой комментарий»
/// (валидация CardsService.AddCommentAsync, 1:1 с dashboard_routes L240241).
/// </remarks>
public sealed record CommentBody(string? Text); public sealed record CommentBody(string? Text);
@@ -3,14 +3,8 @@ using Deal.Modules.Kanban.Application.Models;
namespace Deal.Api.Endpoints.RequestModels; namespace Deal.Api.Endpoints.RequestModels;
/// <summary> /// <summary>
/// Тело POST /api/containers — создание контейнера (этап 9, T4). /// Тело POST /api/containers — создание контейнера.
/// </summary> /// </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="Name">Имя контейнера (обязательно).</param>
/// <param name="Description">Описание (опционально).</param> /// <param name="Description">Описание (опционально).</param>
/// <param name="Color">Цвет (опционально; null — палитра).</param> /// <param name="Color">Цвет (опционально; null — палитра).</param>
@@ -3,13 +3,8 @@ using Deal.Modules.Kanban.Application.Models;
namespace Deal.Api.Endpoints.RequestModels; namespace Deal.Api.Endpoints.RequestModels;
/// <summary> /// <summary>
/// Тело PATCH /api/containers/{id} — частичное обновление контейнера (этап 9, T4). /// Тело PATCH /api/containers/{id} — частичное обновление контейнера.
/// </summary> /// </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="Name">Новое имя (null — не менять).</param>
/// <param name="Description">Новое описание (null — не менять).</param> /// <param name="Description">Новое описание (null — не менять).</param>
/// <param name="Color">Новый цвет (null — не менять).</param> /// <param name="Color">Новый цвет (null — не менять).</param>
@@ -3,14 +3,9 @@ using Deal.Modules.Kanban.Application.Models;
namespace Deal.Api.Endpoints.RequestModels; namespace Deal.Api.Endpoints.RequestModels;
/// <summary> /// <summary>
/// Тело POST /api/cards — ручное («локальное») создание карточки (этап 9, T6). /// Тело POST /api/cards — ручное
/// </summary> /// </summary>
/// <remarks> /// <param name="Title">Заголовок карточки (Trim в сервисе; пустой допустим).</param>
/// 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="Summary">Краткое содержание карточки.</param> /// <param name="Summary">Краткое содержание карточки.</param>
/// <param name="Stack">Стек/направления (null — пустой стек).</param> /// <param name="Stack">Стек/направления (null — пустой стек).</param>
/// <param name="Budget">Бюджет (from/to/cur); null — бюджета нет.</param> /// <param name="Budget">Бюджет (from/to/cur); null — бюджета нет.</param>
@@ -1,28 +1,22 @@
namespace Deal.Api.Endpoints.RequestModels; namespace Deal.Api.Endpoints.RequestModels;
/// <summary> /// <summary>
/// Тело POST /api/discovery/tasks (TaskCreate discovery_routes.py L5060; api-map §3.8 L204). /// Тело POST /api/discovery/tasks.
/// </summary> /// </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 public sealed record DiscoveryTaskCreateBody
{ {
/// <summary> /// <summary>
/// Название задачи (обязательное; Trim, пустое → 400). /// Название задачи
/// </summary> /// </summary>
public string Name { get; init; } = string.Empty; public string Name { get; init; } = string.Empty;
/// <summary> /// <summary>
/// Описание ниши/цели (источник для generate-keywords); null → пустая строка. /// Описание ниши/цели
/// </summary> /// </summary>
public string? Description { get; init; } public string? Description { get; init; }
/// <summary> /// <summary>
/// Ключевые слова поиска; null/пусто — список пуст (start до добавления ключей → 400). /// Ключевые слова поиска; null/пусто — список пуст
/// </summary> /// </summary>
public IReadOnlyList<string>? Keywords { get; init; } public IReadOnlyList<string>? Keywords { get; init; }
@@ -32,22 +26,22 @@ public sealed record DiscoveryTaskCreateBody
public int? MinSubscribers { get; init; } public int? MinSubscribers { get; init; }
/// <summary> /// <summary>
/// Язык источников: «ru»|«any»; null/иное → «ru» (нормализует сервис). /// Язык источников
/// </summary> /// </summary>
public string? Lang { get; init; } public string? Lang { get; init; }
/// <summary> /// <summary>
/// Порог подходящих сообщений оценки, % (кламп 1..100); null → discEvalThreshold. /// Порог подходящих сообщений оценки, %
/// </summary> /// </summary>
public int? Threshold { get; init; } public int? Threshold { get; init; }
/// <summary> /// <summary>
/// Размер выборки сообщений оценки (кламп ≥1); null → discEvalSample. /// Размер выборки сообщений оценки
/// </summary> /// </summary>
public int? SampleSize { get; init; } public int? SampleSize { get; init; }
/// <summary> /// <summary>
/// План авто-вступлений (1..discJoinLimit + бюджет); null → 1. /// План авто-вступлений
/// </summary> /// </summary>
public int? PlanJoins { get; init; } public int? PlanJoins { get; init; }
@@ -1,57 +1,52 @@
namespace Deal.Api.Endpoints.RequestModels; namespace Deal.Api.Endpoints.RequestModels;
/// <summary> /// <summary>
/// Тело PATCH /api/discovery/tasks/{task_id} (TaskPatch discovery_routes.py L6271; api-map §3.8 L205). /// Тело PATCH /api/discovery/tasks/{task_id}.
/// </summary> /// </summary>
/// <remarks>
/// Wire-имена — camelCase; все поля optional: null/отсутствующее поле не меняется (в DiscoveryTaskPatch
/// пробрасываются только не-null значения, как python model_dump(exclude_none=True)). keywords — полная замена
/// списка (пустой список очищает ключи); увеличение planJoins проверяется план-бюджетом.
/// </remarks>
public sealed record DiscoveryTaskPatchBody public sealed record DiscoveryTaskPatchBody
{ {
/// <summary> /// <summary>
/// Новое название (после Trim; пустое допустимо на patch — 1:1 прототип). /// Новое название.
/// </summary> /// </summary>
public string? Name { get; init; } public string? Name { get; init; }
/// <summary> /// <summary>
/// Новое описание (пустая строка очищает). /// Новое описание
/// </summary> /// </summary>
public string? Description { get; init; } public string? Description { get; init; }
/// <summary> /// <summary>
/// Новые ключевые слова (полная замена; null — не менять). /// Новые ключевые слова
/// </summary> /// </summary>
public IReadOnlyList<string>? Keywords { get; init; } public IReadOnlyList<string>? Keywords { get; init; }
/// <summary> /// <summary>
/// Новый минимум участников (кламп ≥0). /// Новый минимум участников
/// </summary> /// </summary>
public int? MinSubscribers { get; init; } public int? MinSubscribers { get; init; }
/// <summary> /// <summary>
/// Новый язык: «ru»|«any» (иное → «ru»). /// Новый язык: «ru»|«any»
/// </summary> /// </summary>
public string? Lang { get; init; } public string? Lang { get; init; }
/// <summary> /// <summary>
/// Новый порог оценки, % (кламп 1..100). /// Новый порог оценки, %
/// </summary> /// </summary>
public int? Threshold { get; init; } public int? Threshold { get; init; }
/// <summary> /// <summary>
/// Новый размер выборки (кламп ≥1). /// Новый размер выборки
/// </summary> /// </summary>
public int? SampleSize { get; init; } public int? SampleSize { get; init; }
/// <summary> /// <summary>
/// Новый план авто-вступлений (рост — с проверкой бюджета). /// Новый план авто-вступлений
/// </summary> /// </summary>
public int? PlanJoins { get; init; } public int? PlanJoins { get; init; }
/// <summary> /// <summary>
/// Новый флаг авто-вступлений (false — выключить). /// Новый флаг авто-вступлений
/// </summary> /// </summary>
public bool? AutoJoin { get; init; } public bool? AutoJoin { get; init; }
} }
@@ -1,12 +1,6 @@
namespace Deal.Api.Endpoints.RequestModels; namespace Deal.Api.Endpoints.RequestModels;
/// <summary> /// <summary>
/// Тело POST /api/cards/mark-col-seen — снять «новое» с колонки (dashboard_routes.py MarkColBody L183184, api-map §3.2 L88). /// Тело POST /api/cards/mark-col-seen — снять «новое» с колонки.
/// </summary> /// </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); public sealed record MarkColBody(string? Col);
@@ -1,11 +1,6 @@
namespace Deal.Api.Endpoints.RequestModels; namespace Deal.Api.Endpoints.RequestModels;
/// <summary> /// <summary>
/// Тело POST /api/cards/{lead_id}/move — перенос карточки (dashboard_routes.py MoveBody L5657, api-map §3.2 L89). /// Тело POST /api/cards/{lead_id}/move — перенос карточки.
/// </summary> /// </summary>
/// <remarks>
/// Wire-имя — camelCase: to — колонка назначения: "inbox" либо id доски (<c>b_...</c>). Цель валидирует
/// CardsService (400 «Переносить можно только на доски или в «Неразобранное»»); отсутствующий/null to
/// трактуются той же валидацией (прототип — pydantic required 422).
/// </remarks>
public sealed record MoveBody(string? To); public sealed record MoveBody(string? To);
@@ -1,10 +1,7 @@
namespace Deal.Api.Endpoints.RequestModels; namespace Deal.Api.Endpoints.RequestModels;
/// <summary> /// <summary>
/// Тело PUT /api/operator/settings/telegram-keys: глобальные ключи приложения Telegram, /// Тело PUT /api/operator/settings/telegram-keys
/// задаваемые оператором (ТЗ §4.1/§8.1). Поддерживается частичное обновление: непереданное поле
/// (<c>null</c>) сохраняет текущее значение, явное значение валидируется. Если ключей ещё нет,
/// оба поля обязательны.
/// </summary> /// </summary>
/// <param name="ApiId">api_id приложения Telegram (5..9 цифр); null — не менялось.</param> /// <param name="ApiId">api_id приложения Telegram (5..9 цифр); null — не менялось.</param>
/// <param name="ApiHash">api_hash приложения Telegram (непустой секрет; хранится зашифрованным); null — не менялось.</param> /// <param name="ApiHash">api_hash приложения Telegram (непустой секрет; хранится зашифрованным); null — не менялось.</param>
@@ -1,12 +1,8 @@
namespace Deal.Api.Endpoints.RequestModels; namespace Deal.Api.Endpoints.RequestModels;
/// <summary> /// <summary>
/// Тело POST /api/containers/reorder — новый порядок контейнеров пространства (этап 9, T4). /// Тело POST /api/containers/reorder — новый порядок контейнеров пространства.
/// </summary> /// </summary>
/// <remarks>
/// Wire-имена — camelCase: space (пространство dashboard/selected; отсутствие — dashboard) и order
/// (список id контейнеров в новом порядке). Отсутствие/явный null у order — 400 «Не указан порядок колонок».
/// </remarks>
/// <param name="Space">Пространство (dashboard/selected); null — dashboard.</param> /// <param name="Space">Пространство (dashboard/selected); null — dashboard.</param>
/// <param name="Order">Id контейнеров в новом порядке.</param> /// <param name="Order">Id контейнеров в новом порядке.</param>
public sealed record OrderBody(string? Space, IReadOnlyList<string>? Order); public sealed record OrderBody(string? Space, IReadOnlyList<string>? Order);
@@ -1,11 +1,6 @@
namespace Deal.Api.Endpoints.RequestModels; namespace Deal.Api.Endpoints.RequestModels;
/// <summary> /// <summary>
/// Тело POST /api/cards/reclassify — ИИ-переклассификация «Неразобранного» (dashboard_routes.py ReclassifyBody L6465, api-map §3.2 L95). /// Тело POST /api/cards/reclassify — ИИ-переклассификация «Неразобранного».
/// </summary> /// </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); public sealed record ReclassifyBody(IReadOnlyList<string>? Ids);
@@ -1,17 +1,7 @@
namespace Deal.Api.Endpoints.RequestModels; namespace Deal.Api.Endpoints.RequestModels;
/// <summary> /// <summary>
/// Тело POST /api/cards/{cardId}/reminder — установка напоминания hold-карточке /// Тело POST /api/cards/{cardId}/reminder — установка напоминания hold-карточке.
/// (projects_routes.py ReminderBody L5253, api-map §3.5 L172; Ruling 3).
/// </summary> /// </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> /// <param name="At">Время напоминания, epoch-ms.</param>
public sealed record ReminderSetRequest(long? At); public sealed record ReminderSetRequest(long? At);
@@ -1,11 +1,6 @@
namespace Deal.Api.Endpoints.RequestModels; namespace Deal.Api.Endpoints.RequestModels;
/// <summary> /// <summary>
/// Тело POST /api/pipeline/rejected/{rejId}/return — причина возврата в обработку (processing_routes.py ReturnBody L1315, api-map §3.6 L185). /// Тело POST /api/pipeline/rejected/{rejId}/return — причина возврата в обработку.
/// </summary> /// </summary>
/// <remarks>
/// Wire-имя — camelCase: reason (опциональна, дефолт "" — как pydantic reason: str = ""; фронт шлёт
/// {reason} всегда). Пустая причина допустима: сервис тримит и кладёт на запись для аудита
/// (return_to_queue L158, Ruling 10).
/// </remarks>
public sealed record ReturnReasonRequest(string? Reason); public sealed record ReturnReasonRequest(string? Reason);
@@ -1,13 +1,8 @@
namespace Deal.Api.Endpoints.RequestModels; namespace Deal.Api.Endpoints.RequestModels;
/// <summary> /// <summary>
/// Тело POST /api/cards/take — «взять в работу» карточки с дашборда (этап 9, T6). /// Тело POST /api/cards/take — «взять в работу» карточки с дашборда.
/// </summary> /// </summary>
/// <remarks>
/// Wire-имена — camelCase: cardId — id карточки; leadId — алиас (совместимость со старым фронтом).
/// Карточка не клонируется: она переносится в контейнер planned. Отсутствующий/несуществующий id →
/// 404 «Карточка не найдена».
/// </remarks>
/// <param name="CardId">Id карточки, берущейся в работу.</param> /// <param name="CardId">Id карточки, берущейся в работу.</param>
/// <param name="LeadId">Алиас cardId.</param> /// <param name="LeadId">Алиас cardId.</param>
public sealed record TakeCardRequest(string? CardId = null, string? LeadId = null); public sealed record TakeCardRequest(string? CardId = null, string? LeadId = null);
@@ -1,7 +1,7 @@
namespace Deal.Api.Endpoints.RequestModels; namespace Deal.Api.Endpoints.RequestModels;
/// <summary> /// <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> /// </summary>
/// <param name="Enabled">True — мониторить (сообщения → PushMessage в ядро), false — выключить.</param> /// <param name="Enabled">True — мониторить (сообщения → PushMessage в ядро), false — выключить.</param>
public sealed record TgMonitorBody(bool Enabled); public sealed record TgMonitorBody(bool Enabled);
@@ -1,8 +1,8 @@
namespace Deal.Api.Endpoints.RequestModels; namespace Deal.Api.Endpoints.RequestModels;
/// <summary> /// <summary>
/// Тело POST /api/tg/dialogs/preview — последние сообщения диалога (tg_routes.py PreviewBody L5860). /// Тело POST /api/tg/dialogs/preview — последние сообщения диалога.
/// </summary> /// </summary>
/// <param name="DialogId">Id диалога (подписанный).</param> /// <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); public sealed record TgPreviewBody(string DialogId, int? Limit);
@@ -1,7 +1,7 @@
namespace Deal.Api.Endpoints.RequestModels; namespace Deal.Api.Endpoints.RequestModels;
/// <summary> /// <summary>
/// Тело POST /api/tg/send-code — SMS-код входа (tg_routes.py CodeBody L4648). /// Тело POST /api/tg/send-code — SMS-код входа.
/// </summary> /// </summary>
/// <param name="Code">Код из SMS/Telegram-сообщения (trim перед отправкой, python L89).</param> /// <param name="Code">Код из SMS/Telegram-сообщения.</param>
public sealed record TgSendCodeRequest(string Code); public sealed record TgSendCodeRequest(string Code);
@@ -1,7 +1,7 @@
namespace Deal.Api.Endpoints.RequestModels; namespace Deal.Api.Endpoints.RequestModels;
/// <summary> /// <summary>
/// Тело POST /api/tg/send-password — облачный пароль 2FA (tg_routes.py PasswordBody L5052). /// Тело POST /api/tg/send-password — облачный пароль 2FA.
/// </summary> /// </summary>
/// <param name="Password">Пароль облачной защиты (как ввёл пользователь, без trim — python L100).</param> /// <param name="Password">Пароль облачной защиты.</param>
public sealed record TgSendPasswordRequest(string Password); public sealed record TgSendPasswordRequest(string Password);
@@ -1,7 +1,7 @@
namespace Deal.Api.Endpoints.RequestModels; namespace Deal.Api.Endpoints.RequestModels;
/// <summary> /// <summary>
/// Тело POST /api/tg/start-phone — вход по номеру телефона (tg_routes.py PhoneBody L4244). /// Тело POST /api/tg/start-phone — вход по номеру телефона.
/// </summary> /// </summary>
/// <param name="Phone">Номер в международном формате (как ввёл пользователь; обрезается обработчиком, python L71).</param> /// <param name="Phone">Номер в международном формате.</param>
public sealed record TgStartPhoneRequest(string Phone); public sealed record TgStartPhoneRequest(string Phone);
@@ -9,26 +9,8 @@ using Deal.Modules.Tenants.Application.Models;
namespace Deal.Api.Endpoints; namespace Deal.Api.Endpoints;
/// <summary> /// <summary>
/// HTTP-эндпоинты настроек тенанта: GET/PATCH /api/settings (api-map §3.4 L146147, §4.6). /// HTTP-эндпоинты настроек тенанта
/// </summary> /// </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 public static class SettingsEndpoints
{ {
private const string ApiGroupPrefix = "/api"; private const string ApiGroupPrefix = "/api";
@@ -83,7 +65,6 @@ public static class SettingsEndpoints
catch (JsonException) catch (JsonException)
{ {
// Не-JSON или не-объект целиком — ошибка запроса: 400 + detail // Не-JSON или не-объект целиком — ошибка запроса: 400 + detail
// (в прототипе FastAPI на такое тело — 422).
return EndpointResults.BadRequest(InvalidBodyDetail); return EndpointResults.BadRequest(InvalidBodyDetail);
} }
@@ -95,11 +76,8 @@ public static class SettingsEndpoints
SettingsService settingsService = context.RequestServices.GetRequiredService<SettingsService>(); SettingsService settingsService = context.RequestServices.GetRequiredService<SettingsService>();
PublicSettingsDto result = await settingsService.ApplyPatchAsync(body, ct); PublicSettingsDto result = await settingsService.ApplyPatchAsync(body, ct);
// Аудит сохранения настроек (этап 10, T1): только имена полей — значения (в т.ч. секреты) не пишутся.
await AuditAppender.AppendTenantAsync(context, AuditEvents.SettingsUpdated, new { fields = body.Keys }, ct); 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)) if (ShouldScheduleRatesRefresh(body))
{ {
context.RequestServices.GetRequiredService<RatesRefreshScheduler>().Schedule(); context.RequestServices.GetRequiredService<RatesRefreshScheduler>().Schedule();
@@ -108,7 +86,6 @@ public static class SettingsEndpoints
return Results.Ok(result); return Results.Ok(result);
} }
// Запускать ли фоновый refresh курсов после PATCH (семантика if body.get("rateSource") L188).
// body: Тело PATCH — публичные ключи §4.6. // body: Тело PATCH — публичные ключи §4.6.
// Возвращает: True — поле rateSource передано «правдивым» значением (не null/пустая строка). // Возвращает: True — поле rateSource передано «правдивым» значением (не null/пустая строка).
private static bool ShouldScheduleRatesRefresh(Dictionary<string, JsonElement> body) private static bool ShouldScheduleRatesRefresh(Dictionary<string, JsonElement> body)
@@ -118,7 +95,6 @@ public static class SettingsEndpoints
return false; return false;
} }
// JSON-булево/число в python «правдивы» и запускают refresh; пустая строка/null — нет.
return element.ValueKind switch return element.ValueKind switch
{ {
JsonValueKind.String => !string.IsNullOrEmpty(element.GetString()), JsonValueKind.String => !string.IsNullOrEmpty(element.GetString()),
@@ -5,37 +5,16 @@ using Deal.Infrastructure.Services;
namespace Deal.Api.Endpoints; namespace Deal.Api.Endpoints;
/// <summary> /// <summary>
/// Служебные storage-эндпоинты: POST /api/admin/tick и POST /api/admin/fts/rebuild (план Tasks 1011, /// Служебные storage-эндпоинты
/// Rulings 6/8/11; прототип dashboard_routes.py L261264, L327337).
/// </summary> /// </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 public static class StorageEndpoints
{ {
// Префикс группы (роутер dashboard, prefix="/api"; admin-пути прототипа L261/L327).
private const string AdminGroupPrefix = "/api"; private const string AdminGroupPrefix = "/api";
// Путь ручного тика правил хранения (dashboard_routes.py L327).
private const string TickPath = "/admin/tick"; private const string TickPath = "/admin/tick";
// Путь пересборки поискового индекса (dashboard_routes.py L261; Ruling 6).
private const string FtsRebuildPath = "/admin/fts/rebuild"; private const string FtsRebuildPath = "/admin/fts/rebuild";
// OpenAPI-тег группы (в прототипе роутер dashboard — dashboard_routes.py).
private const string OpenApiTag = "dashboard"; private const string OpenApiTag = "dashboard";
/// <summary> /// <summary>
@@ -51,12 +30,9 @@ public static class StorageEndpoints
return app; return app;
} }
// POST /api/admin/tick: правила хранения + очистка отсева + напоминания + pump + SSE-тосты/new_card/reminder_due (admin_tick L327337).
// Весь состав тика — AdminTickOrchestrator (вынесен из эндпоинта для unit-тестов логики и // Весь состав тика — AdminTickOrchestrator (вынесен из эндпоинта для unit-тестов логики и
// переиспользования): тик StorageTickService (Kanban) → PurgeExpiredAsync (отсев 3 суток, merge в // переиспользования): тик StorageTickService (Kanban) → PurgeExpiredAsync (отсев 3 суток, merge в
// storage.purgedRejected) → тосты статистики (включая «Отсев очищен: N записей (3 дн.)») → CheckDueAsync // 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) private static async Task<IResult> AdminTickAsync(HttpContext context, CancellationToken ct)
{ {
if (!context.HasUser()) if (!context.HasUser())
@@ -69,11 +45,8 @@ public static class StorageEndpoints
return Results.Ok(await orchestrator.TickAsync(tenantId, ct)); return Results.Ok(await orchestrator.TickAsync(tenantId, ct));
} }
// POST /api/admin/fts/rebuild: пересборка FTS-индексов тенанта; ответ {ok:true, ready:true} (Ruling 6).
// SearchTsv — генерируемые STORED-колонки Cards/RejectedItems: авто-актуальны, «пересборка» = создание // SearchTsv — генерируемые STORED-колонки Cards/RejectedItems: авто-актуальны, «пересборка» = создание
// отсутствующих GIN-индексов (CREATE INDEX IF NOT EXISTS) + ANALYZE таблиц (FtsMaintenance.RebuildAsync). // отсутствующих 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) private static async Task<IResult> FtsRebuildAsync(HttpContext context, CancellationToken ct)
{ {
if (!context.HasUser()) if (!context.HasUser())
@@ -11,25 +11,12 @@ using Deal.Modules.Tenants.Application.Models;
namespace Deal.Api.Endpoints; namespace Deal.Api.Endpoints;
/// <summary> /// <summary>
/// Эндпоинты /api/tg: статус, веб-авторизация (phone/QR/код/2FA/logout), диалоги и мониторинг (Ruling 8, api-map §3.3). /// Эндпоинты /api/tg
/// </summary> /// </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 public static class TelegramEndpoints
{ {
// Префикс группы /api/tg (python: router prefix, tg_routes.py L12).
private const string TgGroupPrefix = "/api/tg"; private const string TgGroupPrefix = "/api/tg";
// OpenAPI-тег группы (в прототипе роутер tg — tg_routes.py).
private const string TgOpenApiTag = "telegram"; private const string TgOpenApiTag = "telegram";
// Путь статуса аккаунта/фазы входа (GET). // Путь статуса аккаунта/фазы входа (GET).
@@ -80,24 +67,18 @@ public static class TelegramEndpoints
// Деталь недоступного telegram-service/неподключённого аккаунта (глобальная строка контракта). // Деталь недоступного telegram-service/неподключённого аккаунта (глобальная строка контракта).
private const string NotConnectedDetail = "Telegram не подключён"; private const string NotConnectedDetail = "Telegram не подключён";
// Reason мягкой ветки refresh: аккаунт не подключён (tg_routes.py L120).
private const string NotConnectedReason = "not-connected"; private const string NotConnectedReason = "not-connected";
// Фаза успешной привязки аккаунта Telegram (telegram_linked — этап 10, T1).
private const string ReadyPhase = "ready"; private const string ReadyPhase = "ready";
// Дефолт limit превью (PreviewBody L60: limit = 24).
private const int PreviewDefaultLimit = 24; private const int PreviewDefaultLimit = 24;
// Нижняя граница limit превью (python L153: min(…, 1)).
private const int PreviewLimitMin = 1; private const int PreviewLimitMin = 1;
// Верхняя граница limit превью (python L153: max(…, 50); api-map /dialogs/preview).
private const int PreviewLimitMax = 50; private const int PreviewLimitMax = 50;
/// <summary> /// <summary>
/// Регистрирует группу /api/tg: status/start-phone/start-qr/send-code/send-password/logout/dialogs/refresh/ /// Регистрирует группу /api/tg
/// monitor-all/backfill-all/preview/{dialog_id}/monitor/{dialog_id}/backfill (qr-image — TelegramQrImageEndpoint).
/// </summary> /// </summary>
/// <param name="app">Построитель маршрутов приложения.</param> /// <param name="app">Построитель маршрутов приложения.</param>
/// <returns>Построитель маршрутов для цепочки вызовов.</returns> /// <returns>Построитель маршрутов для цепочки вызовов.</returns>
@@ -122,7 +103,6 @@ public static class TelegramEndpoints
return app; return app;
} }
// GET /api/tg/status: статус аккаунта/фазы входа (tg_routes.py L6365; форма §4.9).
private static async Task<IResult> StatusAsync(HttpContext context, CancellationToken ct) private static async Task<IResult> StatusAsync(HttpContext context, CancellationToken ct)
{ {
if (!context.HasUser()) if (!context.HasUser())
@@ -134,7 +114,6 @@ public static class TelegramEndpoints
return Results.Ok(await statusService.GetAsync(ct)); return Results.Ok(await statusService.GetAsync(ct));
} }
// POST /api/tg/start-phone: запросить код по номеру (tg_routes.py L6874; python L134147).
private static async Task<IResult> StartPhoneAsync( private static async Task<IResult> StartPhoneAsync(
TgStartPhoneRequest body, TgStartPhoneRequest body,
HttpContext context, 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) private static async Task<IResult> StartQrAsync(HttpContext context, CancellationToken ct)
{ {
if (!context.HasUser()) if (!context.HasUser())
@@ -194,7 +172,6 @@ public static class TelegramEndpoints
TelegramAuthResultDto result = await gateway.StartQrAsync(apiId, keys.ApiHash, ct); TelegramAuthResultDto result = await gateway.StartQrAsync(apiId, keys.ApiHash, ct);
if (result.Phase == ReadyPhase) if (result.Phase == ReadyPhase)
{ {
// Аудит привязки Telegram (этап 10, T1): аккаунт уже авторизован — фаза ready.
await AuditAppender.AppendTenantAsync(context, AuditEvents.TelegramLinked, new { phase = result.Phase }, ct); 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( private static async Task<IResult> SendCodeAsync(
TgSendCodeRequest body, TgSendCodeRequest body,
HttpContext context, HttpContext context,
@@ -223,7 +199,6 @@ public static class TelegramEndpoints
string phase = await gateway.SendCodeAsync((body.Code ?? string.Empty).Trim(), ct); string phase = await gateway.SendCodeAsync((body.Code ?? string.Empty).Trim(), ct);
if (phase == ReadyPhase) if (phase == ReadyPhase)
{ {
// Аудит привязки Telegram (этап 10, T1): вход завершён без 2FA — фаза ready.
await AuditAppender.AppendTenantAsync(context, AuditEvents.TelegramLinked, new { phase }, ct); 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( private static async Task<IResult> SendPasswordAsync(
TgSendPasswordRequest body, TgSendPasswordRequest body,
HttpContext context, HttpContext context,
@@ -252,7 +226,6 @@ public static class TelegramEndpoints
string phase = await gateway.SendPasswordAsync(body.Password ?? string.Empty, ct); string phase = await gateway.SendPasswordAsync(body.Password ?? string.Empty, ct);
if (phase == ReadyPhase) if (phase == ReadyPhase)
{ {
// Аудит привязки Telegram (этап 10, T1): 2FA пройдена — фаза ready.
await AuditAppender.AppendTenantAsync(context, AuditEvents.TelegramLinked, new { phase }, ct); 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) private static async Task<IResult> LogoutAsync(HttpContext context, CancellationToken ct)
{ {
if (!context.HasUser()) 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) private static async Task<IResult> DialogsAsync(HttpContext context, CancellationToken ct)
{ {
if (!context.HasUser()) if (!context.HasUser())
@@ -295,8 +266,6 @@ public static class TelegramEndpoints
DialogsService dialogs = context.RequestServices.GetRequiredService<DialogsService>(); DialogsService dialogs = context.RequestServices.GetRequiredService<DialogsService>();
IReadOnlyList<TelegramDialogDto> rows = await dialogs.ListAsync(ct); 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); var items = new List<object?>(rows.Count);
foreach (TelegramDialogDto dialog in rows) foreach (TelegramDialogDto dialog in rows)
{ {
@@ -317,7 +286,6 @@ public static class TelegramEndpoints
return Results.Ok(new { items }); 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) private static async Task<IResult> DialogsRefreshAsync(HttpContext context, CancellationToken ct)
{ {
if (!context.HasUser()) if (!context.HasUser())
@@ -328,7 +296,6 @@ public static class TelegramEndpoints
DialogsService dialogs = context.RequestServices.GetRequiredService<DialogsService>(); DialogsService dialogs = context.RequestServices.GetRequiredService<DialogsService>();
ITelegramGateway gateway = context.RequestServices.GetRequiredService<ITelegramGateway>(); ITelegramGateway gateway = context.RequestServices.GetRequiredService<ITelegramGateway>();
// 1:1 tg_routes.py L119120: аккаунт не подключён (или сервис недоступен — Ruling 7) → мягкая ветка
// {ok:false, reason:"not-connected", count:0} HTTP 200 — refresh не ошибка запроса. // {ok:false, reason:"not-connected", count:0} HTTP 200 — refresh не ошибка запроса.
bool connected; bool connected;
try 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( private static async Task<IResult> MonitorAllAsync(
TgMonitorBody body, TgMonitorBody body,
HttpContext context, HttpContext context,
@@ -375,13 +341,11 @@ public static class TelegramEndpoints
TelegramMonitorAllDto result = await dialogs.SetMonitorAllAsync(body.Enabled, ct); TelegramMonitorAllDto result = await dialogs.SetMonitorAllAsync(body.Enabled, ct);
if (body.Enabled && result.BackfillNeededIds.Count > 0) if (body.Enabled && result.BackfillNeededIds.Count > 0)
{ {
// Первое включение неразобранных: фоновый разбор списком (python L566: _spawn(_backfill_dialogs)).
context.RequestServices.GetRequiredService<TelegramBackfillScheduler>().ScheduleFirstBackfills(result.BackfillNeededIds); context.RequestServices.GetRequiredService<TelegramBackfillScheduler>().ScheduleFirstBackfills(result.BackfillNeededIds);
} }
if (body.Enabled) if (body.Enabled)
{ {
// Аудит включения каналов (этап 10, T1): без имён/содержимого.
await AuditAppender.AppendTenantAsync(context, AuditEvents.ChannelEnabled, new { all = true, count = result.Count }, ct); 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) private static async Task<IResult> BackfillAllAsync(HttpContext context, CancellationToken ct)
{ {
if (!context.HasUser()) if (!context.HasUser())
@@ -405,14 +368,12 @@ public static class TelegramEndpoints
int count = (await dialogs.ListMonitoredIdsAsync(ct)).Count; int count = (await dialogs.ListMonitoredIdsAsync(ct)).Count;
if (count > 0) if (count > 0)
{ {
// Ответ — сразу {ok, count}, разбор идёт в фоне (python L580: _spawn(backfill_monitored)).
context.RequestServices.GetRequiredService<TelegramBackfillScheduler>().ScheduleReadRecent(); context.RequestServices.GetRequiredService<TelegramBackfillScheduler>().ScheduleReadRecent();
} }
return Results.Ok(new { ok = true, count }); 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( private static async Task<IResult> SetMonitorAsync(
string dialog_id, string dialog_id,
TgMonitorBody body, TgMonitorBody body,
@@ -430,13 +391,11 @@ public static class TelegramEndpoints
TelegramMonitorToggleDto result = await dialogs.SetMonitorAsync(dialog_id, body.Enabled, ct); TelegramMonitorToggleDto result = await dialogs.SetMonitorAsync(dialog_id, body.Enabled, ct);
if (result.BackfillNeeded) if (result.BackfillNeeded)
{ {
// Первое включение неразобранного канала: фоновый разбор (python L546: _spawn(backfill_dialog)).
context.RequestServices.GetRequiredService<TelegramBackfillScheduler>().ScheduleFirstBackfill(dialog_id); context.RequestServices.GetRequiredService<TelegramBackfillScheduler>().ScheduleFirstBackfill(dialog_id);
} }
if (result.Enabled) if (result.Enabled)
{ {
// Аудит включения канала (этап 10, T1).
await AuditAppender.AppendTenantAsync(context, AuditEvents.ChannelEnabled, new { dialogId = dialog_id }, ct); 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( private static async Task<IResult> BackfillDialogAsync(
string dialog_id, string dialog_id,
HttpContext context, 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( private static async Task<IResult> PreviewAsync(
TgPreviewBody body, TgPreviewBody body,
HttpContext context, HttpContext context,
@@ -500,7 +456,6 @@ public static class TelegramEndpoints
return keys.KeysSet ? keys : null; return keys.KeysSet ? keys : null;
} }
// 400 {detail} по ошибке гейта: канонический detail RPC либо «Telegram не подключён» (Ruling 7/8).
// exception: Исключение вызова гейта (RpcException домена/транспорта, прочее). // exception: Исключение вызова гейта (RpcException домена/транспорта, прочее).
// Возвращает: 400-ответ с текстом причины. // Возвращает: 400-ответ с текстом причины.
private static IResult GatewayError(Exception exception) private static IResult GatewayError(Exception exception)
@@ -509,7 +464,7 @@ public static class TelegramEndpoints
} }
/// <summary> /// <summary>
/// Текст причины ошибки гейта для {detail} (канонические тексты telegram-service 1:1, Ruling 7). /// Текст причины ошибки гейта для {detail}.
/// </summary> /// </summary>
/// <param name="exception">Исключение вызова гейта.</param> /// <param name="exception">Исключение вызова гейта.</param>
/// <returns>Текст причины.</returns> /// <returns>Текст причины.</returns>
@@ -519,16 +474,13 @@ public static class TelegramEndpoints
{ {
// Доменная RPC-ошибка: detail от telegram-service («Неверный код», «Telegram не подключён», …). // Доменная RPC-ошибка: detail от telegram-service («Неверный код», «Telegram не подключён», …).
global::Grpc.Core.RpcException rpc when !string.IsNullOrEmpty(rpc.Status.Detail) => rpc.Status.Detail, global::Grpc.Core.RpcException rpc when !string.IsNullOrEmpty(rpc.Status.Detail) => rpc.Status.Detail,
// Недоступность/прочий транспорт — «не подключён» (GrpcTelegramClient нормализует, Ruling 7).
_ => NotConnectedDetail, _ => NotConnectedDetail,
}; };
} }
/// <summary> /// <summary>
/// Русская форма типа источника на границе эндпоинта (заметка Task 1, api-map §4.8 L349). /// Русская форма типа источника на границе эндпоинта.
/// </summary> /// </summary>
/// <remarks>Каталог ядра хранит EN-канон (channel/group/forum/chat); наружу (вкладка «Каналы», фильтр по типу)
/// — русские подписи python (_kind_of L461466: «канал»/«группа»/«чат»; форум отображается как группа).</remarks>
/// <param name="kind">Тип источника (EN-канон каталога либо уже русская подпись).</param> /// <param name="kind">Тип источника (EN-канон каталога либо уже русская подпись).</param>
/// <returns>Русская подпись: channel→«канал», group/forum→«группа», chat→«чат»; иное — как есть.</returns> /// <returns>Русская подпись: channel→«канал», group/forum→«группа», chat→«чат»; иное — как есть.</returns>
public static string ToRussianDialogType(string kind) public static string ToRussianDialogType(string kind)
@@ -7,16 +7,8 @@ using Net.Codecrete.QrCodeGenerator;
namespace Deal.Api.Endpoints; namespace Deal.Api.Endpoints;
/// <summary> /// <summary>
/// GET /api/tg/qr-image: SVG QR-кода входа (tg_routes.py L3039; Ruling 8). /// GET /api/tg/qr-image
/// </summary> /// </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 public static class TelegramQrImageEndpoint
{ {
// Префикс группы /api/tg (общий с TelegramEndpoints). // Префикс группы /api/tg (общий с TelegramEndpoints).
@@ -25,16 +17,12 @@ public static class TelegramQrImageEndpoint
// Путь SVG QR-кода (GET; фронт добавляет ?t=N от кэша). // Путь SVG QR-кода (GET; фронт добавляет ?t=N от кэша).
private const string QrImagePath = "/qr-image"; private const string QrImagePath = "/qr-image";
// OpenAPI-тег группы (в прототипе роутер tg — tg_routes.py).
private const string QrImageOpenApiTag = "telegram"; private const string QrImageOpenApiTag = "telegram";
// Деталь 404: QR не активен (python L34, глобальная строка контракта).
private const string QrNotActiveDetail = "QR не активен — начните вход по QR"; private const string QrNotActiveDetail = "QR не активен — начните вход по QR";
// Media-type SVG-ответа (python L37: image/svg+xml).
private const string SvgMediaType = "image/svg+xml"; private const string SvgMediaType = "image/svg+xml";
// Ширина рамки (quiet zone) QR в модулях (python L22: qrcode border=1).
private const int QrBorderModules = 1; private const int QrBorderModules = 1;
/// <summary> /// <summary>
@@ -49,7 +37,6 @@ public static class TelegramQrImageEndpoint
return app; return app;
} }
// GET /api/tg/qr-image: SVG QR-кода фазы входа «qr» (tg_routes.py L3039).
private static async Task<IResult> QrImageAsync(HttpContext context, CancellationToken ct) private static async Task<IResult> QrImageAsync(HttpContext context, CancellationToken ct)
{ {
if (!context.HasUser()) if (!context.HasUser())
@@ -57,7 +44,6 @@ public static class TelegramQrImageEndpoint
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
} }
// Активен только в фазе «qr» живого статуса telegram-service; сервис недоступен/фаза иная — 404 (python L3334).
TelegramAccountStatusDto live; TelegramAccountStatusDto live;
try try
{ {
@@ -77,7 +63,6 @@ public static class TelegramQrImageEndpoint
QrCode qr = QrCode.EncodeText(live.QrUrl, QrCode.Ecc.Medium); QrCode qr = QrCode.EncodeText(live.QrUrl, QrCode.Ecc.Medium);
string svg = qr.ToSvgString(QrBorderModules); string svg = qr.ToSvgString(QrBorderModules);
// Свежий QR на каждый запрос (не кэшировать); inline — как python L3738.
context.Response.Headers.CacheControl = "no-store"; context.Response.Headers.CacheControl = "no-store";
context.Response.Headers.ContentDisposition = "inline"; context.Response.Headers.ContentDisposition = "inline";
return Results.Text(svg, SvgMediaType); return Results.Text(svg, SvgMediaType);
+8 -18
View File
@@ -5,21 +5,12 @@ using System.Threading.Channels;
namespace Deal.Api.Events; namespace Deal.Api.Events;
/// <summary> /// <summary>
/// Singleton SSE-брокер этапа: per-tenant каналы событий (Ruling 5, план Task 9). /// Singleton SSE-брокер
/// </summary> /// </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 public sealed class SseBroker
{ {
/// <summary> /// <summary>
/// Ёмкость очереди подписчика (sse.py L20: <c>asyncio.Queue(maxsize=200)</c>). /// Ёмкость очереди подписчика
/// </summary> /// </summary>
public const int SubscriberQueueCapacity = 200; public const int SubscriberQueueCapacity = 200;
@@ -36,15 +27,14 @@ public sealed class SseBroker
private readonly Dictionary<Guid, Dictionary<Guid, Channel<SseEvent>>> _subscribersByTenant = new(); private readonly Dictionary<Guid, Dictionary<Guid, Channel<SseEvent>>> _subscribersByTenant = new();
/// <summary> /// <summary>
/// Подписывает клиента на канал тенанта: новая bounded-очередь (≤200, DropOldest). /// Подписывает клиента на канал тенанта
/// </summary> /// </summary>
/// <param name="tenantId">Тенант сессии запроса (Ruling 5: канал по TenantId при подписке).</param> /// <param name="tenantId">Тенант сессии запроса.</param>
/// <returns>Подписка: идентификатор для отписки и читатель канала событий.</returns> /// <returns>Подписка: идентификатор для отписки и читатель канала событий.</returns>
public SseSubscription Subscribe(Guid tenantId) public SseSubscription Subscribe(Guid tenantId)
{ {
var channel = Channel.CreateBounded<SseEvent>(new BoundedChannelOptions(SubscriberQueueCapacity) var channel = Channel.CreateBounded<SseEvent>(new BoundedChannelOptions(SubscriberQueueCapacity)
{ {
// Вытеснение старых при переполнении (sse.py L3644): TryWrite не блокирует и не падает.
FullMode = BoundedChannelFullMode.DropOldest, FullMode = BoundedChannelFullMode.DropOldest,
SingleReader = true, SingleReader = true,
SingleWriter = false, SingleWriter = false,
@@ -66,7 +56,7 @@ public sealed class SseBroker
} }
/// <summary> /// <summary>
/// Отписывает клиента по завершении SSE-соединения (events_routes.py L2728). /// Отписывает клиента по завершении SSE-соединения.
/// </summary> /// </summary>
/// <param name="tenantId">Тенант канала подписки.</param> /// <param name="tenantId">Тенант канала подписки.</param>
/// <param name="subscriptionId">Идентификатор подписки из <see cref="Subscribe"/>.</param> /// <param name="subscriptionId">Идентификатор подписки из <see cref="Subscribe"/>.</param>
@@ -89,10 +79,10 @@ public sealed class SseBroker
} }
/// <summary> /// <summary>
/// Публикует событие в канал тенанта (Ruling 5: публикации — из эндпоинтов Api). /// Публикует событие в канал тенанта.
/// </summary> /// </summary>
/// <param name="tenantId">Тенант-получатель; без подписчиков — no-op, не падает.</param> /// <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> /// <param name="payload">Полезная нагрузка — сериализуется в JSON (camelCase, без \u).</param>
public void Publish( public void Publish(
Guid tenantId, Guid tenantId,
@@ -101,7 +91,7 @@ public sealed class SseBroker
Publish(tenantId, new SseEvent(eventType, JsonSerializer.Serialize(payload, PublishJsonOptions))); Publish(tenantId, new SseEvent(eventType, JsonSerializer.Serialize(payload, PublishJsonOptions)));
/// <summary> /// <summary>
/// Публикует готовое событие (тип + JSON) в канал тенанта. /// Публикует готовое событие
/// </summary> /// </summary>
/// <param name="tenantId">Тенант-получатель; без подписчиков — no-op, не падает.</param> /// <param name="tenantId">Тенант-получатель; без подписчиков — no-op, не падает.</param>
/// <param name="sseEvent">Событие с уже сериализованной нагрузкой.</param> /// <param name="sseEvent">Событие с уже сериализованной нагрузкой.</param>
+3 -4
View File
@@ -1,15 +1,14 @@
namespace Deal.Api.Events; namespace Deal.Api.Events;
/// <summary> /// <summary>
/// Событие SSE-потока: тип + JSON-полезная нагрузка (Ruling 5; прототип sse.py L2930). /// Событие SSE-потока
/// </summary> /// </summary>
/// <param name="Type">Тип события — фронт слушает <c>addEventListener</c> по имени /// <param name="Type">Тип события — фронт слушает <c>addEventListener</c> по имени: на — <c>new_card</c> (полный объект карточки) и <c>toast</c> {text, icon}.</param>
/// (api.js L7881): на этапе 3 — <c>new_card</c> (полный объект карточки) и <c>toast</c> {text, icon}.</param>
/// <param name="Json">Полезная нагрузка, сериализованная в JSON (camelCase, без \u-экранирования).</param> /// <param name="Json">Полезная нагрузка, сериализованная в JSON (camelCase, без \u-экранирования).</param>
public sealed record SseEvent(string Type, string Json) public sealed record SseEvent(string Type, string Json)
{ {
/// <summary> /// <summary>
/// Отрисовывает frame протокола SSE: <c>event: &lt;type&gt;\ndata: &lt;json&gt;\n\n</c> (sse.py L30). /// Отрисовывает frame протокола SSE
/// </summary> /// </summary>
/// <returns>Готовый frame для отправки в поток ответа.</returns> /// <returns>Готовый frame для отправки в поток ответа.</returns>
public string RenderFrame() => $"event: {Type}\ndata: {Json}\n\n"; 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