Почистить комментарии от упоминаний процесса
Удалены <remarks>, <summary> сжаты до короткой фразы, вырезаны ссылки на Task/Ruling/этап/python/прототип; //-комментарии со ссылками на процесс удалены; то же в .proto. Правила обновлены в docs/spec/Код-стайл-Дейл.md. Строк комментариев 27210 -> ~19100.
This commit is contained in:
+1
-1
@@ -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 |
|
||||||
|
|
||||||
|
|||||||
@@ -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.
@@ -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 L115–117.
|
|
||||||
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 L201–204); 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 L167–171); строковое «нет» трактуется как ложь (1:1 _ai_fit L158–164).
|
|
||||||
/// </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 L62–73).
|
/// 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 L80–117):
|
/// 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 L175–183).
|
/// Мар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 L115–117); 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 L201–204).
|
|
||||||
/// </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 L175–183): чистая строка 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 L149–151).
|
/// Модель вернула только 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 L168–172).
|
|
||||||
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)
|
||||||
|
|||||||
@@ -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,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 L36–47) и оценка fit (discovery_eval L50–54). Каждый ответ несёт usage
|
|
||||||
/// (Ruling 5). Недоступность провайдера после ретраев → UNAVAILABLE с detail
|
|
||||||
/// «ИИ (имя) не ответил корректно — повторите попытку через несколько секунд» (ядро падает в
|
|
||||||
/// локальный разбор); ответ модели без разбираемого JSON в Classify — ok=false, не RPC-ошибка
|
|
||||||
/// (README ai.proto L201–204).
|
|
||||||
/// </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 L36–47; пользовательское сообщение — описание задачи).
|
|
||||||
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 L50–54): подставляются
|
|
||||||
// описание и ключи задачи (строкой через запятую, как _ai_prompt L153–155).
|
|
||||||
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 L158–164; для фильтра отсутствие поля = 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 L188–198): решение {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 L218–258): ответ {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 L50–54;
|
/// 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 L158–164); число — ненулевое = 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 L167–171), потолок длины 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 L153–155).
|
|
||||||
// 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,
|
||||||
|
|||||||
@@ -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 L175–183):
|
/// Извлечение 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 L177–179): возвращает содержимое
|
|
||||||
// между открывающей и закрывающей обёртками; обёртки нет/незакрыта — 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 L115–117: «ИИ (имя) не ответил корректно — повторите
|
|
||||||
/// попытку через несколько секунд»; 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 L201–204): провайдер не ответил
|
/// Причина исчерпания попыток вызова
|
||||||
/// корректно (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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 L115–117). Неизвестный 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
|
||||||
|
|||||||
@@ -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"/>
|
||||||
/// L126–172): по 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 L126–139 (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 L155–167 (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 L143–152).
|
|
||||||
// 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 L149–151).
|
|
||||||
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 L168–172).
|
|
||||||
// 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
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,14 +1,12 @@
|
|||||||
namespace Deal.Ai.Llm;
|
namespace Deal.Ai.Llm;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Политика ретраев вызова LLM (план Task 7; ai.py chat_json L96–117): 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,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>
|
||||||
|
|||||||
@@ -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
|
||||||
/// L80–117): до <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 L201–204).
|
|
||||||
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,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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 не должны давать
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
// ai.proto — контракт между ядром Deal и ai-service (этап 6).
|
|
||||||
//
|
//
|
||||||
// ai-service — фасад LLM-провайдеров без БД (Ruling 5, дизайн-док §7.3):
|
|
||||||
// ядро передаёт в теле каждого запроса готовые (заполненные) промпты и/или
|
// ядро передаёт в теле каждого запроса готовые (заполненные) промпты и/или
|
||||||
// текст + конфиг активного провайдера (ProviderConfig); сервис вызывает
|
// текст + конфиг активного провайдера (ProviderConfig); сервис вызывает
|
||||||
// провайдера (OpenAI-совместимые POST {base}/chat/completions, Anthropic
|
// провайдера (OpenAI-совместимые POST {base}/chat/completions, Anthropic
|
||||||
@@ -9,23 +7,19 @@
|
|||||||
// (id/base/model/apiKey/api_style) ядро кладёт в поле provider_config каждого
|
// (id/base/model/apiKey/api_style) ядро кладёт в поле provider_config каждого
|
||||||
// запроса — сервис настроек тенанта не знает и не хранит.
|
// запроса — сервис настроек тенанта не знает и не хранит.
|
||||||
//
|
//
|
||||||
// Каждый RPC обязан нести в gRPC-metadata два заголовка (Ruling 1):
|
|
||||||
// tenant-id — id тенанта (строка; учёт токенов в ядре по нему);
|
// tenant-id — id тенанта (строка; учёт токенов в ядре по нему);
|
||||||
// service-token — общий токен сервисов (env DEAL_SERVICE_TOKEN); неверный/
|
// service-token — общий токен сервисов (env DEAL_SERVICE_TOKEN); неверный/
|
||||||
// пустой → UNAUTHENTICATED.
|
// пустой → UNAUTHENTICATED.
|
||||||
//
|
//
|
||||||
// Ошибки домена — gRPC-статусы (Ruling 1/5):
|
|
||||||
// INVALID_ARGUMENT — невалидный запрос (пустой текст/промпт и т.п.);
|
// INVALID_ARGUMENT — невалидный запрос (пустой текст/промпт и т.п.);
|
||||||
// UNAVAILABLE — провайдер не ответил корректно после ретраев; detail =
|
// UNAVAILABLE — провайдер не ответил корректно после ретраев; detail =
|
||||||
// «ИИ (имя) не ответил корректно — повторите попытку через
|
// «ИИ (имя) не ответил корректно — повторите попытку через
|
||||||
// несколько секунд» (ядро падает в локальный разбор).
|
// несколько секунд» (ядро падает в локальный разбор).
|
||||||
//
|
//
|
||||||
// Учёт токенов (Ruling 5): каждый reply несёт usage{prompt/completion/total}.
|
|
||||||
// Берётся из usage API-ответа провайдера; при отсутствии оценивается по
|
// Берётся из usage API-ответа провайдера; при отсутствии оценивается по
|
||||||
// символам (≈chars/4). Ядро копит значения в tenant-KV aiTokenUsage.
|
// символам (≈chars/4). Ядро копит значения в tenant-KV aiTokenUsage.
|
||||||
//
|
//
|
||||||
// Deadlines (клиент ядра): все RPC — 120 с (90 с провайдер + ретраи 0.8/2 с;
|
// Deadlines (клиент ядра): все RPC — 120 с (90 с провайдер + ретраи 0.8/2 с;
|
||||||
// при недоступности ядро не ждёт повторно — Ruling 6 кэш/фолбэк).
|
|
||||||
syntax = "proto3";
|
syntax = "proto3";
|
||||||
|
|
||||||
package deal.ai.v1;
|
package deal.ai.v1;
|
||||||
@@ -33,37 +27,27 @@ package deal.ai.v1;
|
|||||||
option csharp_namespace = "Deal.Grpc.Ai";
|
option csharp_namespace = "Deal.Grpc.Ai";
|
||||||
|
|
||||||
service AiService {
|
service AiService {
|
||||||
// ИИ-фильтр входящих сообщений (ai.py filter_incoming L188–198, Ruling 5):
|
|
||||||
// ядро шлёт заполненный aiFilterPrompt (fill_prompt делает ядро) и текст;
|
// ядро шлёт заполненный aiFilterPrompt (fill_prompt делает ядро) и текст;
|
||||||
// решение {pass, reason}. Ветку «фильтр не применялся» (aiFilterEnabled/
|
// решение {pass, reason}. Ветку «фильтр не применялся» (aiFilterEnabled/
|
||||||
// недоступность) обрабатывает ядро до вызова — сервис всегда отвечает.
|
// недоступность) обрабатывает ядро до вызова — сервис всегда отвечает.
|
||||||
rpc Filter(FilterRequest) returns (FilterReply);
|
rpc Filter(FilterRequest) returns (FilterReply);
|
||||||
|
|
||||||
// Полный разбор лида (ai.py classify L218–258, Ruling 5): ядро собирает
|
|
||||||
// system_prompt = заполненные aiPrompt + cardPrompt и user-контекст
|
|
||||||
// «Доски + примеры разметки + Сообщение»; сервис возвращает извлечённый
|
// «Доски + примеры разметки + Сообщение»; сервис возвращает извлечённый
|
||||||
// ответ модели как json-строку (типовую схему задаёт промпт). Строгий
|
// ответ модели как json-строку (типовую схему задаёт промпт). Строгий
|
||||||
// маппинг json → AiParsedCardDto делает ядро (1:1 normalize_stack/
|
|
||||||
// clean_budget/build_contacts).
|
// clean_budget/build_contacts).
|
||||||
rpc Classify(ClassifyRequest) returns (ClassifyReply);
|
rpc Classify(ClassifyRequest) returns (ClassifyReply);
|
||||||
|
|
||||||
// Генерация ключевых слов для discovery-задачи по описанию (фикс. промпт
|
// Генерация ключевых слов для discovery-задачи по описанию (фикс. промпт
|
||||||
// discovery_routes L36–47 + описание; Ruling 5): ответ {keywords}. Очистку
|
|
||||||
// (_clean_keywords: ≤30, ≤60 симв., дедуп) и мягкие ошибки делает ядро.
|
|
||||||
rpc GenerateKeywords(GenerateKeywordsRequest) returns (GenerateKeywordsReply);
|
rpc GenerateKeywords(GenerateKeywordsRequest) returns (GenerateKeywordsReply);
|
||||||
|
|
||||||
// Оценка соответствия сообщения задаче поиска (промпт discovery_eval
|
// Оценка соответствия сообщения задаче поиска (промпт discovery_eval
|
||||||
// L50–54; Ruling 5/10): текст + описание + ключи задачи → {fit, reason}.
|
|
||||||
// Ядро зовёт только при aiEnabled; сбой/не-JSON — фолбэк на эвристику.
|
// Ядро зовёт только при aiEnabled; сбой/не-JSON — фолбэк на эвристику.
|
||||||
rpc EvaluateFit(EvaluateFitRequest) returns (EvaluateFitReply);
|
rpc EvaluateFit(EvaluateFitRequest) returns (EvaluateFitReply);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Запросы/ответы AiService ---
|
// --- Запросы/ответы AiService ---
|
||||||
|
|
||||||
// Конфиг активного LLM-провайдера на запрос (Ruling 5: ядро расшифровывает
|
|
||||||
// aiConfigs и передаёт в теле каждого запроса; сервис не хранит настроек).
|
// aiConfigs и передаёт в теле каждого запроса; сервис не хранит настроек).
|
||||||
// Форма 1:1 с эффективным конфигом core: настройка aiConfigs тенанта хранит
|
|
||||||
// {apiKey, baseUrl, model} (camelCase; apiKey шифруется AES-GCM этапа 2),
|
|
||||||
// api_style — из каталога AiProviders (Settings); HTTP-клиенту провайдера
|
// api_style — из каталога AiProviders (Settings); HTTP-клиенту провайдера
|
||||||
// нужны baseUrl+model+apiKey для запроса и api_style для выбора схемы вызова
|
// нужны baseUrl+model+apiKey для запроса и api_style для выбора схемы вызова
|
||||||
// (OpenAI-совместимые chat/completions vs Anthropic Messages API).
|
// (OpenAI-совместимые chat/completions vs Anthropic Messages API).
|
||||||
@@ -86,11 +70,8 @@ message ProviderConfig {
|
|||||||
|
|
||||||
message FilterRequest {
|
message FilterRequest {
|
||||||
// Заполненный промпт фильтра (настройка aiFilterPrompt с подстановкой
|
// Заполненный промпт фильтра (настройка aiFilterPrompt с подстановкой
|
||||||
// {domain}/{keywords} — делает ядро; Ruling 5).
|
|
||||||
string prompt = 1;
|
string prompt = 1;
|
||||||
// Текст сообщения (ядро обрезает до 4000, как ai.py L193).
|
|
||||||
string text = 2;
|
string text = 2;
|
||||||
// Конфиг активного провайдера (Ruling 5; все запросы ai-сервиса).
|
|
||||||
ProviderConfig provider_config = 3;
|
ProviderConfig provider_config = 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,18 +80,13 @@ message FilterReply {
|
|||||||
bool pass = 1;
|
bool pass = 1;
|
||||||
// Причина отказа при pass=false (текст ветки filter_ai; пуст при пропуске).
|
// Причина отказа при pass=false (текст ветки filter_ai; пуст при пропуске).
|
||||||
optional string reason = 2;
|
optional string reason = 2;
|
||||||
// Оценка токенов вызова (Ruling 5).
|
|
||||||
Usage usage = 3;
|
Usage usage = 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
message ClassifyRequest {
|
message ClassifyRequest {
|
||||||
// system_prompt = заполненные aiPrompt + cardPrompt (структура карточки,
|
|
||||||
// «О заявке»; собирает ядро — Ruling 5).
|
|
||||||
string system_prompt = 1;
|
string system_prompt = 1;
|
||||||
// user-контекст: «Доски + примеры разметки + Новое сообщение» (собирает
|
// user-контекст: «Доски + примеры разметки + Новое сообщение» (собирает
|
||||||
// ядро, 1:1 classify L243–251).
|
|
||||||
string user_context = 2;
|
string user_context = 2;
|
||||||
// Конфиг активного провайдера (Ruling 5; все запросы ai-сервиса).
|
|
||||||
ProviderConfig provider_config = 3;
|
ProviderConfig provider_config = 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,33 +96,25 @@ message ClassifyReply {
|
|||||||
bool ok = 1;
|
bool ok = 1;
|
||||||
// Сырой JSON-ответ модели (строкой; маппинг в карточку — в ядре).
|
// Сырой JSON-ответ модели (строкой; маппинг в карточку — в ядре).
|
||||||
optional string json = 2;
|
optional string json = 2;
|
||||||
// Оценка токенов вызова (Ruling 5).
|
|
||||||
Usage usage = 3;
|
Usage usage = 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
message GenerateKeywordsRequest {
|
message GenerateKeywordsRequest {
|
||||||
// Описание ниши/задачи (ядро обрезает до 4000, discovery_routes L29).
|
|
||||||
string description = 1;
|
string description = 1;
|
||||||
// Конфиг активного провайдера (Ruling 5; все запросы ai-сервиса).
|
|
||||||
ProviderConfig provider_config = 2;
|
ProviderConfig provider_config = 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
message GenerateKeywordsReply {
|
message GenerateKeywordsReply {
|
||||||
// Сгенерированные ключи (пустой список — модель не выделила ключи;
|
// Сгенерированные ключи (пустой список — модель не выделила ключи;
|
||||||
// чистку/дедуп и мягкую ошибку для UI делает ядро — Ruling 11).
|
|
||||||
repeated string keywords = 1;
|
repeated string keywords = 1;
|
||||||
// Оценка токенов вызова (Ruling 5).
|
|
||||||
Usage usage = 2;
|
Usage usage = 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
message EvaluateFitRequest {
|
message EvaluateFitRequest {
|
||||||
// Текст сообщения для оценки (выборка кандидата; ядро ограничивает 4000).
|
// Текст сообщения для оценки (выборка кандидата; ядро ограничивает 4000).
|
||||||
string text = 1;
|
string text = 1;
|
||||||
// Описание задачи поиска (discovery_eval L51).
|
|
||||||
string description = 2;
|
string description = 2;
|
||||||
// Ключи задачи (discovery_eval L52; подставляются в промпт сервисом).
|
|
||||||
repeated string keywords = 3;
|
repeated string keywords = 3;
|
||||||
// Конфиг активного провайдера (Ruling 5; все запросы ai-сервиса).
|
|
||||||
ProviderConfig provider_config = 4;
|
ProviderConfig provider_config = 4;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,11 +123,9 @@ message EvaluateFitReply {
|
|||||||
bool fit = 1;
|
bool fit = 1;
|
||||||
// Краткая причина решения модели (пуст, если модель её не дала).
|
// Краткая причина решения модели (пуст, если модель её не дала).
|
||||||
optional string reason = 2;
|
optional string reason = 2;
|
||||||
// Оценка токенов вызова (Ruling 5).
|
|
||||||
Usage usage = 3;
|
Usage usage = 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Оценка токенов вызова провайдера (Ruling 5: usage{prompt/completion/total};
|
|
||||||
// из usage API-ответа, при отсутствии — по символам ≈chars/4).
|
// из usage API-ответа, при отсутствии — по символам ≈chars/4).
|
||||||
message Usage {
|
message Usage {
|
||||||
// Токены запроса (system + user).
|
// Токены запроса (system + user).
|
||||||
|
|||||||
@@ -1,26 +1,16 @@
|
|||||||
// ml.proto — контракт между ядром Deal и ml-service (этап 6).
|
|
||||||
//
|
//
|
||||||
// Инкрементальная наивно-байесовская модель по терминам, 1:1 с python
|
|
||||||
// mlservice/model.py (predict L184–293, status L325–345, reset L348–354,
|
|
||||||
// learn_batch L147–173) и DTO ядра Deal.Contracts.Integrations.Models
|
|
||||||
// (MlPredictResultDto/MlServiceStatusDto/MlEvalDto/MlResetResultDto).
|
// (MlPredictResultDto/MlServiceStatusDto/MlEvalDto/MlResetResultDto).
|
||||||
// Модель per-tenant: пул в ml-service, файл SQLite data/ml/<tenantId>.sqlite
|
// Модель per-tenant: пул в ml-service, файл SQLite data/ml/<tenantId>.sqlite
|
||||||
// (Ruling 4). Обучение ядро шлёт батчами из очереди ml_outbox
|
|
||||||
// (MlOutboxFlushScheduler, Ruling 6).
|
|
||||||
//
|
//
|
||||||
// Каждый RPC обязан нести в gRPC-metadata два заголовка (Ruling 1):
|
|
||||||
// tenant-id — id тенанта (строка; модель тенанта — в пуле сервиса);
|
// tenant-id — id тенанта (строка; модель тенанта — в пуле сервиса);
|
||||||
// service-token — общий токен сервисов (env DEAL_SERVICE_TOKEN); неверный/
|
// service-token — общий токен сервисов (env DEAL_SERVICE_TOKEN); неверный/
|
||||||
// пустой → UNAUTHENTICATED.
|
// пустой → UNAUTHENTICATED.
|
||||||
//
|
//
|
||||||
// Ошибки домена — gRPC-статусы с detail = текст причины 1:1 (Ruling 1):
|
|
||||||
// INVALID_ARGUMENT — невалидный запрос (пустой text и т.п.);
|
// INVALID_ARGUMENT — невалидный запрос (пустой text и т.п.);
|
||||||
// UNAVAILABLE — хранилище модели недоступно (ядро отвечает «не уверен»,
|
// UNAVAILABLE — хранилище модели недоступно (ядро отвечает «не уверен»,
|
||||||
// Ruling 6 — кэш reachable 15 с).
|
|
||||||
//
|
//
|
||||||
// Семантика неготовой модели: Predict НЕ ошибка — модель без опыта отвечает
|
// Семантика неготовой модели: Predict НЕ ошибка — модель без опыта отвечает
|
||||||
// фиксированным «не уверен»: take=false, label пуст, scores пуст, hits=0,
|
// фиксированным «не уверен»: take=false, label пуст, scores пуст, hits=0,
|
||||||
// ready=false, margin пуст, terms пуст, type пуст (Ruling 5 этапа 2, 1:1).
|
|
||||||
//
|
//
|
||||||
// Deadlines (клиент ядра): Predict — 5 с; Status/Reset — 10 с; TrainBatch — 30 с
|
// Deadlines (клиент ядра): Predict — 5 с; Status/Reset — 10 с; TrainBatch — 30 с
|
||||||
// (батч ≤100 примеров, одна транзакция).
|
// (батч ≤100 примеров, одна транзакция).
|
||||||
@@ -31,25 +21,20 @@ package deal.ml.v1;
|
|||||||
option csharp_namespace = "Deal.Grpc.Ml";
|
option csharp_namespace = "Deal.Grpc.Ml";
|
||||||
|
|
||||||
service MlService {
|
service MlService {
|
||||||
// Предсказание по тексту сообщения (model.py predict L184–293).
|
|
||||||
// take/label/scores/hits/margin/terms/type осмысленны только при take=true;
|
// take/label/scores/hits/margin/terms/type осмысленны только при take=true;
|
||||||
// scores — до 5 лучших «класс → вес» (round 3), margin — адаптивный порог
|
// scores — до 5 лучших «класс → вес» (round 3), margin — адаптивный порог
|
||||||
// (0.9/0.7/0.5/0.35 после 0/60/150/400 примеров), terms — узнанные термины
|
// (0.9/0.7/0.5/0.35 после 0/60/150/400 примеров), terms — узнанные термины
|
||||||
// класса-победителя, type — решение о типе заявки (t:hire/t:order).
|
// класса-победителя, type — решение о типе заявки (t:hire/t:order).
|
||||||
rpc Predict(PredictRequest) returns (PredictReply);
|
rpc Predict(PredictRequest) returns (PredictReply);
|
||||||
|
|
||||||
// Статус модели тенанта (model.py status L325–345): ready/classes/learned/eval.
|
|
||||||
// classes — «label → вес» (round 2, по убыванию); eval — самооценка по окну
|
// classes — «label → вес» (round 2, по убыванию); eval — самооценка по окну
|
||||||
// последних подтверждённых решений (EVAL_WINDOW). Модель создаётся лениво
|
// последних подтверждённых решений (EVAL_WINDOW). Модель создаётся лениво
|
||||||
// по первому обращению (Ruling 4) — отсутствие опыта это НЕ ошибка.
|
|
||||||
rpc Status(StatusRequest) returns (StatusReply);
|
rpc Status(StatusRequest) returns (StatusReply);
|
||||||
|
|
||||||
// Полный сброс модели тенанта (model.py reset L348–354): очистка классов,
|
|
||||||
// терминов и журнала самооценки + пересоздание файла. Ok=true; мягкая
|
// терминов и журнала самооценки + пересоздание файла. Ok=true; мягкая
|
||||||
// ошибка — Ok=false + error (ядро чистит свою ml_outbox только при успехе).
|
// ошибка — Ok=false + error (ядро чистит свою ml_outbox только при успехе).
|
||||||
rpc Reset(ResetRequest) returns (ResetReply);
|
rpc Reset(ResetRequest) returns (ResetReply);
|
||||||
|
|
||||||
// Пакетное обучение (model.py learn_batch L147–173): одна транзакция +
|
|
||||||
// пакетные вставки терминов; самооценка по действиям пользователя (delta=1,
|
// пакетные вставки терминов; самооценка по действиям пользователя (delta=1,
|
||||||
// не t:*) до применения. Ответ — число применённых примеров.
|
// не t:*) до применения. Ответ — число применённых примеров.
|
||||||
rpc TrainBatch(TrainBatchRequest) returns (TrainBatchReply);
|
rpc TrainBatch(TrainBatchRequest) returns (TrainBatchReply);
|
||||||
@@ -57,7 +42,6 @@ service MlService {
|
|||||||
|
|
||||||
message PredictRequest {
|
message PredictRequest {
|
||||||
// Текст сообщения (ядро передаёт уже обрезанный/нормализованный, как
|
// Текст сообщения (ядро передаёт уже обрезанный/нормализованный, как
|
||||||
// ml_routes.py L86–90; пустой/пробельный — не ошибка: ответ «не уверен»).
|
|
||||||
string text = 1;
|
string text = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,7 +65,6 @@ message PredictReply {
|
|||||||
TypeDecision type = 8;
|
TypeDecision type = 8;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Решение ML о типе заявки (predict L233–238; MlTypeDecisionDto).
|
|
||||||
message TypeDecision {
|
message TypeDecision {
|
||||||
// True — модель уверена в типе.
|
// True — модель уверена в типе.
|
||||||
bool take = 1;
|
bool take = 1;
|
||||||
@@ -106,7 +89,6 @@ message StatusReply {
|
|||||||
ModelEval eval = 4;
|
ModelEval eval = 4;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Окно самооценки модели (model.py status L329–339; MlEvalDto).
|
|
||||||
message ModelEval {
|
message ModelEval {
|
||||||
// Решений в окне самооценки (последние EVAL_WINDOW).
|
// Решений в окне самооценки (последние EVAL_WINDOW).
|
||||||
int32 count = 1;
|
int32 count = 1;
|
||||||
@@ -126,7 +108,6 @@ message ResetReply {
|
|||||||
}
|
}
|
||||||
|
|
||||||
message TrainBatchRequest {
|
message TrainBatchRequest {
|
||||||
// Примеры обучения (1 транзакция на батч; ядро шлёт ≤100 за цикл, Ruling 6).
|
|
||||||
repeated TrainExample items = 1;
|
repeated TrainExample items = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,7 +118,6 @@ message TrainExample {
|
|||||||
// Метка: id доски (b_…), "spam" либо тип "t:hire"/"t:order".
|
// Метка: id доски (b_…), "spam" либо тип "t:hire"/"t:order".
|
||||||
string label = 2;
|
string label = 2;
|
||||||
// Вес сигнала: 1.0 — действие пользователя; −1.0 — снять метку;
|
// Вес сигнала: 1.0 — действие пользователя; −1.0 — снять метку;
|
||||||
// 0.4/0.6 — сигналы ИИ/правил (этапы 4/6).
|
|
||||||
double delta = 3;
|
double delta = 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,24 +1,16 @@
|
|||||||
// telegram.proto — контракт между ядром Deal и telegram-service (этап 6).
|
|
||||||
//
|
//
|
||||||
// Два сервиса в одном файле (дизайн-док §6.2, план Task 1, Ruling 1/7):
|
|
||||||
// * TelegramService — команды ядра к telegram-service (порт-гейт ITelegramGateway):
|
// * TelegramService — команды ядра к telegram-service (порт-гейт ITelegramGateway):
|
||||||
// подключение/отключение аккаунта, каталог диалогов, мониторинг, backfill,
|
// подключение/отключение аккаунта, каталог диалогов, мониторинг, backfill,
|
||||||
// превью, discovery-операции (поиск/инфо/чтение/вступление/выход);
|
// превью, discovery-операции (поиск/инфо/чтение/вступление/выход);
|
||||||
// * IngressService — исходящий поток telegram-service → ядро: сырые сообщения
|
// * IngressService — исходящий поток telegram-service → ядро: сырые сообщения
|
||||||
// (PushMessage), синхронизация каталога (SyncDialogs), статус аккаунта
|
// (PushMessage), синхронизация каталога (SyncDialogs), статус аккаунта
|
||||||
// (ReportStatus). Сервер ингресса живёт в Deal.Api (:5082, Ruling 7).
|
|
||||||
//
|
//
|
||||||
// Семантика методов 1:1 с python-прототипом backend/app/services/telegram.py
|
|
||||||
// (имена L134–873) и api-map §3.3/§4.8/§4.9; хранение диалогов/статуса — только
|
|
||||||
// в ядре (модуль Deal.Modules.Telegram, Ruling 7), сервис БД тенантов не знает.
|
|
||||||
//
|
//
|
||||||
// Каждый RPC обязан нести в gRPC-metadata два заголовка (Ruling 1):
|
|
||||||
// tenant-id — id тенанта (строка; единственный источник принадлежности,
|
// tenant-id — id тенанта (строка; единственный источник принадлежности,
|
||||||
// полю в теле не доверяем);
|
// полю в теле не доверяем);
|
||||||
// service-token — общий токен сервисов (env DEAL_SERVICE_TOKEN); неверный/
|
// service-token — общий токен сервисов (env DEAL_SERVICE_TOKEN); неверный/
|
||||||
// пустой → UNAUTHENTICATED.
|
// пустой → UNAUTHENTICATED.
|
||||||
//
|
//
|
||||||
// Ошибки домена — gRPC-статусы с detail = текст причины 1:1 с прототипом:
|
|
||||||
// INVALID_ARGUMENT — неверный ввод/неверный код/неверный пароль и т.п.;
|
// INVALID_ARGUMENT — неверный ввод/неверный код/неверный пароль и т.п.;
|
||||||
// NOT_FOUND — диалог/сущность не найдены (нет сессии тенанта и т.п.);
|
// NOT_FOUND — диалог/сущность не найдены (нет сессии тенанта и т.п.);
|
||||||
// FAILED_PRECONDITION— операция невозможна в текущей фазе (нет сессии и т.п.);
|
// FAILED_PRECONDITION— операция невозможна в текущей фазе (нет сессии и т.п.);
|
||||||
@@ -26,14 +18,11 @@
|
|||||||
// UNAVAILABLE — недоступность Telegram/сети (безопасный повтор).
|
// UNAVAILABLE — недоступность Telegram/сети (безопасный повтор).
|
||||||
//
|
//
|
||||||
// Значения строк (канон контракта, .NET-код обеих сторон — новый):
|
// Значения строк (канон контракта, .NET-код обеих сторон — новый):
|
||||||
// * phase: idle|phone|code|password|qr|ready (как status() прототипа L85);
|
|
||||||
// * kind: channel (канал) | group (группа/супергруппа) | forum (форум) |
|
// * kind: channel (канал) | group (группа/супергруппа) | forum (форум) |
|
||||||
// chat (личный чат/бот). 1:1 с _kind_of (L461–466): broadcast →
|
|
||||||
// channel, megagroup/gigagroup/group → group, остальное → chat.
|
// channel, megagroup/gigagroup/group → group, остальное → chat.
|
||||||
// Forum выставляется отдельным флагом is_forum (GetInfo); в
|
// Forum выставляется отдельным флагом is_forum (GetInfo); в
|
||||||
// каталоге (RefreshDialogs) форум приходит как group.
|
// каталоге (RefreshDialogs) форум приходит как group.
|
||||||
//
|
//
|
||||||
// Deadlines (клиент ядра; уточняются адаптерами T2+):
|
|
||||||
// * быстрые команды статуса/мониторинга — 10 с;
|
// * быстрые команды статуса/мониторинга — 10 с;
|
||||||
// * сетевые операции Telegram (QR/код/поиск/инфо/чтение/вступление) — 60 с;
|
// * сетевые операции Telegram (QR/код/поиск/инфо/чтение/вступление) — 60 с;
|
||||||
// * Backfill/RefreshDialogs (паузы анти-бана 1.5–3 с/сообщение) — 120 с;
|
// * Backfill/RefreshDialogs (паузы анти-бана 1.5–3 с/сообщение) — 120 с;
|
||||||
@@ -49,80 +38,59 @@ option csharp_namespace = "Deal.Grpc.Telegram";
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
service TelegramService {
|
service TelegramService {
|
||||||
// Текущий статус аккаунта/фазы входа тенанта (status() прототипа L103–119).
|
|
||||||
// live-поля для GET /api/tg/status (Ruling 8); monitored/keysSet ядро считает
|
|
||||||
// само. Нет сессии — FAILED_PRECONDITION «Telegram не подключён».
|
// само. Нет сессии — FAILED_PRECONDITION «Telegram не подключён».
|
||||||
rpc GetStatus(GetStatusRequest) returns (GetStatusReply);
|
rpc GetStatus(GetStatusRequest) returns (GetStatusReply);
|
||||||
|
|
||||||
// Вход по номеру телефона: запросить код (start_phone L134–147).
|
|
||||||
// api_id/api_hash — глобальные ключи приложения Telegram, задаёт оператор (ТЗ §4.1/§8.1),
|
// api_id/api_hash — глобальные ключи приложения Telegram, задаёт оператор (ТЗ §4.1/§8.1),
|
||||||
// передаёт ядро в теле (Ruling 3); нет ключей — ядро отвечает 400 «Ключи Telegram
|
|
||||||
// не заданы оператором» до вызова. Ответ: новая фаза ("code").
|
// не заданы оператором» до вызова. Ответ: новая фаза ("code").
|
||||||
rpc StartPhone(StartPhoneRequest) returns (StartPhoneReply);
|
rpc StartPhone(StartPhoneRequest) returns (StartPhoneReply);
|
||||||
|
|
||||||
// Начать QR-вход (qr_start L286–300). Ответ: фаза + qrUrl (t.me/qr/...);
|
|
||||||
// если аккаунт уже авторизован — фаза "ready", qrUrl пуст.
|
// если аккаунт уже авторизован — фаза "ready", qrUrl пуст.
|
||||||
rpc StartQr(StartQrRequest) returns (StartQrReply);
|
rpc StartQr(StartQrRequest) returns (StartQrReply);
|
||||||
|
|
||||||
// Отправить SMS-код (submit_code L149–166). Ошибки: «Неверный код»,
|
|
||||||
// «Код истёк — запросите новый» → INVALID_ARGUMENT. Нужен 2FA — фаза
|
// «Код истёк — запросите новый» → INVALID_ARGUMENT. Нужен 2FA — фаза
|
||||||
// "password", иначе "ready". Фазы вне "code" → FAILED_PRECONDITION.
|
// "password", иначе "ready". Фазы вне "code" → FAILED_PRECONDITION.
|
||||||
rpc SendCode(SendCodeRequest) returns (SendCodeReply);
|
rpc SendCode(SendCodeRequest) returns (SendCodeReply);
|
||||||
|
|
||||||
// Облачный пароль 2FA (submit_password L168–176). Ошибка «Неверный облачный
|
|
||||||
// пароль» → INVALID_ARGUMENT. Ответ: фаза ("ready").
|
// пароль» → INVALID_ARGUMENT. Ответ: фаза ("ready").
|
||||||
rpc SendPassword(SendPasswordRequest) returns (SendPasswordReply);
|
rpc SendPassword(SendPasswordRequest) returns (SendPasswordReply);
|
||||||
|
|
||||||
// Отключить аккаунт, удалить сессию тенанта (disconnect L189–207).
|
|
||||||
rpc Logout(LogoutRequest) returns (LogoutReply);
|
rpc Logout(LogoutRequest) returns (LogoutReply);
|
||||||
|
|
||||||
// Синхронизировать каталог диалогов из Telegram (refresh_dialogs L505–519):
|
|
||||||
// актуальный список sources диалогов аккаунта (entries). Удаление/обновление
|
// актуальный список sources диалогов аккаунта (entries). Удаление/обновление
|
||||||
// каталога и авто-мониторинг новых делает ядро (SyncFromTelegram, Ruling 7).
|
|
||||||
rpc RefreshDialogs(RefreshDialogsRequest) returns (RefreshDialogsReply);
|
rpc RefreshDialogs(RefreshDialogsRequest) returns (RefreshDialogsReply);
|
||||||
|
|
||||||
// Включить/выключить мониторинг диалога (set_monitor L536–546): обновляет
|
|
||||||
// зеркало monitored в сервисе. Первый backfill (force=false) запускает ядро
|
// зеркало monitored в сервисе. Первый backfill (force=false) запускает ядро
|
||||||
// отдельным RPC Backfill. Ответ: ok/enabled.
|
// отдельным RPC Backfill. Ответ: ok/enabled.
|
||||||
rpc SetMonitor(SetMonitorRequest) returns (SetMonitorReply);
|
rpc SetMonitor(SetMonitorRequest) returns (SetMonitorReply);
|
||||||
|
|
||||||
// Мониторинг всех диалогов сразу (set_monitor_all L548–567). Ответ:
|
|
||||||
// ok/count/enabled (count — сколько диалогов в каталоге тенанта).
|
// ok/count/enabled (count — сколько диалогов в каталоге тенанта).
|
||||||
rpc SetMonitorAll(SetMonitorAllRequest) returns (SetMonitorAllReply);
|
rpc SetMonitorAll(SetMonitorAllRequest) returns (SetMonitorAllReply);
|
||||||
|
|
||||||
// Перечитать последние ~10 сообщений диалога и отправить их в ядро потоком
|
// Перечитать последние ~10 сообщений диалога и отправить их в ядро потоком
|
||||||
// PushMessage (backfill_dialog L349–390; паузы анти-бана 1.5–3 с/сообщение,
|
|
||||||
// mark-as-read). force=true — «Перечитать» по кнопке даже для разобранных.
|
// mark-as-read). force=true — «Перечитать» по кнопке даже для разобранных.
|
||||||
// Ответ: сколько сообщений отправлено (processed).
|
// Ответ: сколько сообщений отправлено (processed).
|
||||||
rpc Backfill(BackfillRequest) returns (BackfillReply);
|
rpc Backfill(BackfillRequest) returns (BackfillReply);
|
||||||
|
|
||||||
// Последние сообщения диалога для превью (dialog_messages L583–620):
|
|
||||||
// свежие из Telegram; признак lead и фолбэк на БД добавляет ядро
|
// свежие из Telegram; признак lead и фолбэк на БД добавляет ядро
|
||||||
// (у сервиса нет БД тенанта). limit 1..50 (api-map /dialogs/preview).
|
// (у сервиса нет БД тенанта). limit 1..50 (api-map /dialogs/preview).
|
||||||
rpc ReadRecent(ReadRecentRequest) returns (ReadRecentReply);
|
rpc ReadRecent(ReadRecentRequest) returns (ReadRecentReply);
|
||||||
|
|
||||||
// Глобальный поиск каналов/групп по ключу (discovery_search L624–664).
|
|
||||||
// Пауза анти-бана после поиска — внутри сервиса. Личные чаты/боты ядро
|
// Пауза анти-бана после поиска — внутри сервиса. Личные чаты/боты ядро
|
||||||
// отсеивает само (Ruling 10). Результат — entries канала/группы.
|
|
||||||
rpc Search(SearchRequest) returns (SearchReply);
|
rpc Search(SearchRequest) returns (SearchReply);
|
||||||
|
|
||||||
// Инфо об источнике для оценки (discovery_info L666–716): имя/username/kind/
|
|
||||||
// hue + participants и is_forum (полный чат). Сбои определения не роняют
|
// hue + participants и is_forum (полный чат). Сбои определения не роняют
|
||||||
// RPC: participants пуст, остальные поля — из entity/каталога.
|
// RPC: participants пуст, остальные поля — из entity/каталога.
|
||||||
rpc GetInfo(GetInfoRequest) returns (GetInfoReply);
|
rpc GetInfo(GetInfoRequest) returns (GetInfoReply);
|
||||||
|
|
||||||
// Выборка последних сообщений источника для оценки кандидата
|
// Выборка последних сообщений источника для оценки кандидата
|
||||||
// (discovery_read L718–760): форумы читаются по активным темам. История
|
|
||||||
// недоступна (приватный/закрытый источник) — ok=false, error="no_history",
|
// недоступна (приватный/закрытый источник) — ok=false, error="no_history",
|
||||||
// это НЕ ошибка RPC. limit ≥ 1; topic_id/topic_title заполнены для форумов.
|
// это НЕ ошибка RPC. limit ≥ 1; topic_id/topic_title заполнены для форумов.
|
||||||
rpc ReadForEval(ReadForEvalRequest) returns (ReadForEvalReply);
|
rpc ReadForEval(ReadForEvalRequest) returns (ReadForEvalReply);
|
||||||
|
|
||||||
// Вступить в канал/группу по @username (discovery_join L818–839; ручной
|
|
||||||
// join вне квот — паузу перед авто-join делает воркер ядра, Ruling 10).
|
|
||||||
// FloodWait → RESOURCE_EXHAUSTED (detail с префиксом "flood").
|
// FloodWait → RESOURCE_EXHAUSTED (detail с префиксом "flood").
|
||||||
rpc Join(JoinRequest) returns (JoinReply);
|
rpc Join(JoinRequest) returns (JoinReply);
|
||||||
|
|
||||||
// Выйти из канала/группы (discovery_leave L841–848). NOT_FOUND — нет
|
|
||||||
// диалога/членства.
|
// диалога/членства.
|
||||||
rpc Leave(LeaveRequest) returns (LeaveReply);
|
rpc Leave(LeaveRequest) returns (LeaveReply);
|
||||||
}
|
}
|
||||||
@@ -131,8 +99,6 @@ service TelegramService {
|
|||||||
|
|
||||||
message GetStatusRequest {}
|
message GetStatusRequest {}
|
||||||
|
|
||||||
// Статус аккаунта/фазы входа (shape прототипа status() L110–118; monitored и
|
|
||||||
// keysSet ядро добавляет само из своей БД/настроек — Ruling 8).
|
|
||||||
message GetStatusReply {
|
message GetStatusReply {
|
||||||
// Фаза входа: idle|phone|code|password|qr|ready.
|
// Фаза входа: idle|phone|code|password|qr|ready.
|
||||||
string phase = 1;
|
string phase = 1;
|
||||||
@@ -141,7 +107,6 @@ message GetStatusReply {
|
|||||||
// Жив ли realtime-listener (поток новых сообщений → PushMessage).
|
// Жив ли realtime-listener (поток новых сообщений → PushMessage).
|
||||||
bool listener = 3;
|
bool listener = 3;
|
||||||
// Аккаунт "@username" (для справки; источник истины — KV tgAccount по
|
// Аккаунт "@username" (для справки; источник истины — KV tgAccount по
|
||||||
// ReportStatus, ядро использует KV — Ruling 8).
|
|
||||||
string account = 4;
|
string account = 4;
|
||||||
// Текст последней ошибки (null, если ошибки нет).
|
// Текст последней ошибки (null, если ошибки нет).
|
||||||
optional string error = 5;
|
optional string error = 5;
|
||||||
@@ -149,7 +114,6 @@ message GetStatusReply {
|
|||||||
optional string qr_url = 6;
|
optional string qr_url = 6;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Подключение по телефону: ключи API передаёт ядро (Ruling 3).
|
|
||||||
message StartPhoneRequest {
|
message StartPhoneRequest {
|
||||||
// Номер телефона в международном формате (как ввёл пользователь).
|
// Номер телефона в международном формате (как ввёл пользователь).
|
||||||
string phone = 1;
|
string phone = 1;
|
||||||
@@ -208,12 +172,9 @@ message RefreshDialogsRequest {}
|
|||||||
|
|
||||||
message RefreshDialogsReply {
|
message RefreshDialogsReply {
|
||||||
// Актуальный каталог диалогов аккаунта (id/name/username/kind/hue).
|
// Актуальный каталог диалогов аккаунта (id/name/username/kind/hue).
|
||||||
// Ядро применяет его через SyncFromTelegram (Ruling 7).
|
|
||||||
repeated DialogEntry entries = 1;
|
repeated DialogEntry entries = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Один диалог/канал каталога или результат поиска (shape refresh L516 и
|
|
||||||
// discovery_search L653–660: tuple id/name/handle/kind/hue; handle == username).
|
|
||||||
message DialogEntry {
|
message DialogEntry {
|
||||||
// Подписанный id диалога: каналы "-100…", группы "-…", личные "+…".
|
// Подписанный id диалога: каналы "-100…", группы "-…", личные "+…".
|
||||||
string id = 1;
|
string id = 1;
|
||||||
@@ -276,7 +237,6 @@ message ReadRecentReply {
|
|||||||
repeated PreviewMessage messages = 1;
|
repeated PreviewMessage messages = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Сообщение превью диалога (api-map §4.8 L351: {id, text, time, lead}).
|
|
||||||
message PreviewMessage {
|
message PreviewMessage {
|
||||||
// Id сообщения в Telegram (int); фолбэк-сообщения из БД ядра — строки
|
// Id сообщения в Telegram (int); фолбэк-сообщения из БД ядра — строки
|
||||||
// "m_<dialog>_<msg>", поэтому значение передаётся строкой.
|
// "m_<dialog>_<msg>", поэтому значение передаётся строкой.
|
||||||
@@ -290,7 +250,6 @@ message PreviewMessage {
|
|||||||
message SearchRequest {
|
message SearchRequest {
|
||||||
// Поисковый запрос (ключ задачи discovery).
|
// Поисковый запрос (ключ задачи discovery).
|
||||||
string query = 1;
|
string query = 1;
|
||||||
// Верхняя граница результатов (прототип: default 30).
|
|
||||||
int32 limit = 2;
|
int32 limit = 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -304,7 +263,6 @@ message GetInfoRequest {
|
|||||||
string dialog_id = 1;
|
string dialog_id = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Инфо об источнике для оценки кандидата discovery (discovery_info L674–682).
|
|
||||||
message ChannelInfo {
|
message ChannelInfo {
|
||||||
string id = 1;
|
string id = 1;
|
||||||
string name = 2;
|
string name = 2;
|
||||||
@@ -314,7 +272,6 @@ message ChannelInfo {
|
|||||||
string hue = 5;
|
string hue = 5;
|
||||||
// Число участников (full_chat); пусто — определить не удалось.
|
// Число участников (full_chat); пусто — определить не удалось.
|
||||||
optional int32 participants = 6;
|
optional int32 participants = 6;
|
||||||
// True — мегагруппа-форум (темы); ядро трактует kind как "forum" (Ruling 10).
|
|
||||||
bool is_forum = 7;
|
bool is_forum = 7;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -325,7 +282,6 @@ message GetInfoReply {
|
|||||||
message ReadForEvalRequest {
|
message ReadForEvalRequest {
|
||||||
// Id источника.
|
// Id источника.
|
||||||
string dialog_id = 1;
|
string dialog_id = 1;
|
||||||
// Размер выборки (прототип discovery_read: limit сообщений/тем).
|
|
||||||
int32 limit = 2;
|
int32 limit = 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -339,7 +295,6 @@ message ReadForEvalReply {
|
|||||||
repeated EvalMessage messages = 3;
|
repeated EvalMessage messages = 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Сообщение выборки discovery_read (_discovery_message_item L803–816).
|
|
||||||
message EvalMessage {
|
message EvalMessage {
|
||||||
// Id сообщения в Telegram.
|
// Id сообщения в Telegram.
|
||||||
int64 id = 1;
|
int64 id = 1;
|
||||||
@@ -373,7 +328,6 @@ message LeaveReply {
|
|||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// IngressService — исходящий поток telegram-service → ядро
|
// IngressService — исходящий поток telegram-service → ядро
|
||||||
// (gRPC-сервер в Deal.Api :5082; Ruling 7; интерцептор service-token;
|
|
||||||
// tenantId из metadata → собственный scope с ITenantContext.SetTenant)
|
// tenantId из metadata → собственный scope с ITenantContext.SetTenant)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
@@ -386,16 +340,12 @@ service IngressService {
|
|||||||
// Синхронизация каталога диалогов: ядро применяет entries (SyncFromTelegram:
|
// Синхронизация каталога диалогов: ядро применяет entries (SyncFromTelegram:
|
||||||
// авто-мониторинг по autoMonitorNew, обновление, удаление отсутствующих) и
|
// авто-мониторинг по autoMonitorNew, обновление, удаление отсутствующих) и
|
||||||
// отвечает актуальным списком monitored id — сервис держит зеркало
|
// отвечает актуальным списком monitored id — сервис держит зеркало
|
||||||
// мониторинга в памяти (Ruling 7), по нему фильтрует события realtime.
|
|
||||||
rpc SyncDialogs(SyncDialogsRequest) returns (SyncDialogsReply);
|
rpc SyncDialogs(SyncDialogsRequest) returns (SyncDialogsReply);
|
||||||
|
|
||||||
// Периодический/событийный статус аккаунта: ядро пишет KV tgStatus/tgAccount
|
// Периодический/событийный статус аккаунта: ядро пишет KV tgStatus/tgAccount
|
||||||
// и публикует SSE system_status + тосты на переходах фаз (Ruling 7).
|
|
||||||
rpc ReportStatus(ReportStatusRequest) returns (ReportStatusReply);
|
rpc ReportStatus(ReportStatusRequest) returns (ReportStatusReply);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Сообщение из потока в ядро. Поля 1:1 с QueuedMessage/PipelineIngestRequest
|
|
||||||
// (Ruling 7, demo-ingest L7–59): dialog_id + канальные поля плоские; msg_id —
|
|
||||||
// дубль-гвард; msg_at — время исходного сообщения, без него ядро подставит now.
|
// дубль-гвард; msg_at — время исходного сообщения, без него ядро подставит now.
|
||||||
message PushMessageRequest {
|
message PushMessageRequest {
|
||||||
// Id диалога-источника (подписанный; пуст — приём no-op).
|
// Id диалога-источника (подписанный; пуст — приём no-op).
|
||||||
@@ -404,7 +354,6 @@ message PushMessageRequest {
|
|||||||
string channel_name = 2;
|
string channel_name = 2;
|
||||||
// Username канала/диалога (пуст, если нет).
|
// Username канала/диалога (пуст, если нет).
|
||||||
string channel_handle = 3;
|
string channel_handle = 3;
|
||||||
// Цвет канала из палитры DIALOG_HUES (hex; считает сервис — Ruling 7).
|
|
||||||
string channel_hue = 4;
|
string channel_hue = 4;
|
||||||
// Id исходного сообщения в Telegram (дубль-гвард dialog+msgId).
|
// Id исходного сообщения в Telegram (дубль-гвард dialog+msgId).
|
||||||
optional int64 msg_id = 5;
|
optional int64 msg_id = 5;
|
||||||
@@ -431,7 +380,6 @@ message SyncDialogsReply {
|
|||||||
repeated string monitored_ids = 1;
|
repeated string monitored_ids = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Статус аккаунта для ядра (shape прототипа _publish_status L315–316/status()).
|
|
||||||
message ReportStatusRequest {
|
message ReportStatusRequest {
|
||||||
// Фаза: idle|phone|code|password|qr|ready.
|
// Фаза: idle|phone|code|password|qr|ready.
|
||||||
string phase = 1;
|
string phase = 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; } = [];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 L327–337, план 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
|
|
||||||
/// L485–493); 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,
|
||||||
|
|||||||
@@ -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 L25–33), вызывает порт <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 L476–479; прототип dashboard_routes.py L395–409).
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
|
||||||
/// Контракт 1:1 с прототипом и api-map §3.2 L120–121: 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())
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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, <col>: {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 L92–96).
|
|
||||||
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, <col>:{count,new}, learning, ml, ai} (L161–163, §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, L250–256) — эндпоинт защищает от вызова с 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 (L203–207); ответ {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 + комментарии; L217–221); ответ {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 > 0): пустой inbox/всё пропущено не меняют доску — событие не шлём. Нагрузка
|
// reclassified > 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 L254–256). Ответ {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 L72–73).
|
/// Тело 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 L353–355) и {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 L83–87).
|
|
||||||
private const string TaskNotFoundDetail = "Задача не найдена";
|
private const string TaskNotFoundDetail = "Задача не найдена";
|
||||||
|
|
||||||
// 404 join/reject: кандидата нет (python _candidate_or_404 L90–94).
|
|
||||||
private const string CandidateNotFoundDetail = "Кандидат не найден";
|
private const string CandidateNotFoundDetail = "Кандидат не найден";
|
||||||
|
|
||||||
// 400 join: уже вступили (python L235–237).
|
|
||||||
private const string AlreadyJoinedDetail = "Уже вступили в этот источник";
|
private const string AlreadyJoinedDetail = "Уже вступили в этот источник";
|
||||||
|
|
||||||
// 400 reject: источник уже вступили (python L256–258).
|
|
||||||
private const string JoinedRejectDetail = "Уже вступили — удалите источник из каналов";
|
private const string JoinedRejectDetail = "Уже вступили — удалите источник из каналов";
|
||||||
|
|
||||||
// 400 join: ошибка Telegram при вступлении (python L240–242, текст с @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 L99–100).
|
|
||||||
private const string AiDisabledDetail = "ИИ выключен в настройках (aiEnabled)";
|
private const string AiDisabledDetail = "ИИ выключен в настройках (aiEnabled)";
|
||||||
|
|
||||||
// Мягкая ошибка generate-keywords: описания нет (python L201–202).
|
|
||||||
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 L141–143).
|
|
||||||
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 L146–151; дефолты — в сервисе).
|
|
||||||
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 L154–161; 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 L164–168).
|
|
||||||
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 L171–179; пустые ключи → 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 L181–187).
|
|
||||||
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 L189–211). ИИ выключен/недоступен/нет описания → HTTP 200 {keywords: [], error}.
|
|
||||||
// Очистка ключей ответа — CleanKeywords (python _clean_keywords L111–128: ≤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 L216–224; невалидный статус — пустой список).
|
|
||||||
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 L226–251).
|
|
||||||
// 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) L244–245; источник уже в каталоге и мониторится).
|
|
||||||
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 L253–264; уже вступившего — нельзя, 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 L269–271).
|
|
||||||
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 L274–277).
|
|
||||||
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 L282–285), события от новых к старым.
|
|
||||||
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 L111–128).
|
/// Ключи из ответа ИИ
|
||||||
/// </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)
|
||||||
|
|||||||
@@ -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 L15–38).
|
/// SSE-поток событий канбана
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
|
||||||
/// Открывает <c>text/event-stream</c> с подпиской на канал тенанта сессии (singleton SseBroker).
|
|
||||||
/// События пишутся по мере поступления; при тишине 15 с отправляется ping-комментарий ": ping" —
|
|
||||||
/// соединение держится (переподключение EventSource, api.js L62–104). Завершение — по отвалу клиента
|
|
||||||
/// (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> L267–284:
|
|
||||||
/// этап 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 L377–380): если этап-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 L267–284).
|
|
||||||
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, план L377–380).
|
|
||||||
if (!stage1.Pass)
|
if (!stage1.Pass)
|
||||||
{
|
{
|
||||||
return Results.Ok(new
|
return Results.Ok(new
|
||||||
|
|||||||
@@ -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,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,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:<id> | skip (api-map §3.7 L197).</param>
|
/// <param name="Action">Ручное решение: spam | board:<id> | 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>
|
||||||
|
|||||||
@@ -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> (L66–91, L112–171):
|
|
||||||
/// <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) < 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 L66–75).
|
|
||||||
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 L78–81).
|
|
||||||
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 L84–90).
|
|
||||||
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 L112–134).
|
|
||||||
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 L137–171).
|
|
||||||
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 > ~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 L437–460,
|
|
||||||
/// Rulings 6/10; прототип processing_routes.py L17–74).
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
|
||||||
/// Контракт 1:1 с прототипом и api-map §3.6 L178–186, §4.5: /stats → {queue:{new,ai,total}, rejected};
|
|
||||||
/// /queue?limit= → {items, counts:{new,ai,total}, rejected} (limit ≤500, дефолт 100, фронт шлёт 120);
|
|
||||||
/// /rejected?q=&offset=&limit= → {items, total, offset, limit} (q — FTS ∪ LIKE-поиск, Ruling 6);
|
|
||||||
/// /rejected/clear → {ok, cleared}; DELETE /rejected/{rejId} → {ok:true} всегда (delete_one L196–198, 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 L17–20, stats L315–320).
|
|
||||||
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 L23–31).
|
|
||||||
// Ответ {items, counts:{new,ai,total}, rejected} 1:1 с list_queue L218–241 + queue_counts L207–215 +
|
|
||||||
// rejected_count L201–202. 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=&offset=&limit=: страница отсева (processing_routes.py L34–42, list_rejected L246–312).
|
|
||||||
// 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 L45–49, clear_all L120–125).
|
|
||||||
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 L196–198, 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 L128–193).
|
|
||||||
// Успех — {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,
|
||||||
|
|||||||
@@ -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 L149–150).
|
/// HTTP-эндпоинты курсов валют
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
|
||||||
/// «Только для Settings-экрана» (Ruling 8): фронт читает курсы на boot (store.js L571–581) и обновляет
|
|
||||||
/// по кнопке (refreshRates L1843–1848). GET — текущий кэш (ratesCache) или дефолт-мок; при протухании/
|
|
||||||
/// смене источника/отсутствии кэша (Ruling 6) фоново запускает RefreshAsync через
|
|
||||||
/// <see cref="RatesRefreshScheduler"/> и отвечает текущим кэшем (план Task 8 L317–318). 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 L229–232).
|
|
||||||
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 L133–143).
|
/// Тело 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 L224–225, api-map §3.2 L93).
|
/// Тело POST /api/cards/clear-col — полная очистка служебной колонки.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
|
||||||
/// Wire-имя — camelCase: col — "trash" | "archive" (другие колонки/отсутствие значения → 400
|
|
||||||
/// «Очищать можно только корзину или архив», валидация CardsService.ClearColAsync L237–247).
|
|
||||||
/// </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 L60–61).
|
/// Тело POST /api/cards/{cardId}/comments — добавление комментария.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
|
||||||
/// Wire-имя — camelCase: text. Пустой/пробельный текст либо явный null → 400 «Пустой комментарий»
|
|
||||||
/// (валидация CardsService.AddCommentAsync, 1:1 с dashboard_routes L240–241).
|
|
||||||
/// </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 L50–60; 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 L62–71; 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 L183–184, 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 L250–256) —
|
|
||||||
/// эндпоинт защищает от такого вызова (прототип: 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 L56–57, 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 L64–65, api-map §3.2 L95).
|
/// Тело POST /api/cards/reclassify — ИИ-переклассификация «Неразобранного».
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
|
||||||
/// Wire-имя — camelCase: ids (опциональный список id карточек). Тело опционально (фронт вызывает без
|
|
||||||
/// тела — reclassifyInbox, store.js L1115–1133; параметр эндпоинта 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 L52–53, api-map §3.5 L172; Ruling 3).
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
|
||||||
/// Wire-имя — camelCase: at — время напоминания в epoch-мс (рассчитывает фронт HoldReminderDialog:
|
|
||||||
/// «через N дней (1–30)» или «дата+время» локального времени; store.js setHoldReminder L2060–2086).
|
|
||||||
/// Стадия карточки/будущность 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 L13–15, 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 L54–56).
|
/// Тело 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 L58–60).
|
/// Тело 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 L46–48).
|
/// Тело 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 L50–52).
|
/// Тело 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 L42–44).
|
/// Тело 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 L146–147, §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 L188–189) — фоновое обновление кэша
|
|
||||||
// курсов (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 10–11,
|
/// Служебные storage-эндпоинты
|
||||||
/// Rulings 6/8/11; прототип dashboard_routes.py L261–264, L327–337).
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
|
||||||
/// Контракт 1:1 с прототипом и api-map §3.2 L103–112: POST /admin/tick = тик правил хранения текущего
|
|
||||||
/// тенанта + очистка отсева пайплайна (3 суток) + проверка напоминаний «Отложено» (план Task 11, Ruling 3/8)
|
|
||||||
/// + один проход pump очереди входящих (этап 4, Ruling 8/9);
|
|
||||||
/// ответ {storage, reminders, pipeline: {…}, queue: N} (dashboard_routes.py L327–337; storage.purgedRejected
|
|
||||||
/// объединяет очистку отсева — Ruling 9; reminders — «выстрелившие» напоминания {id,title,stage}, пусто —
|
|
||||||
/// сработавших нет). SSE-публикации (тосты статистики notify_tick_stats L496–504, 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 L261–264,
|
|
||||||
/// кнопка Settings «Пересобрать индекс» store.js L1883–1889). Оба эндпоинта требуют сессию: 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 L327–337).
|
|
||||||
// Весь состав тика — 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 L261–264: rebuild() → ok,
|
|
||||||
// is_ready() → ready) — кнопка Settings фронта показывает ошибку по ready (store.js L1883–1889).
|
|
||||||
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> (мягкая ветка L119–120); 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 L63–65; форма §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 L68–74; python L134–147).
|
|
||||||
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 L77–83; python qr_start L286–300).
|
|
||||||
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 L86–94; python submit_code L149–166).
|
|
||||||
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 L97–103; python submit_password L168–176).
|
|
||||||
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 L106–109; python disconnect L189–207).
|
|
||||||
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 L112–114; list_dialogs L521–534).
|
|
||||||
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 L117–122).
|
|
||||||
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 L119–120: аккаунт не подключён (или сервис недоступен — 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 L125–129; L548–567).
|
|
||||||
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 L132–136).
|
|
||||||
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 L139–142; L536–546).
|
|
||||||
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 L145–148; L349–390).
|
|
||||||
// Сервер-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 L151–153; dialog_messages L583–620).
|
|
||||||
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 L461–466: «канал»/«группа»/«чат»; форум отображается как группа).</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 L30–39; Ruling 8).
|
/// GET /api/tg/qr-image
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
|
||||||
/// Фронт рисует QR картинкой: <c><img src="/api/tg/qr-image?t=N"></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 L37–38: свежий 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 L30–39).
|
|
||||||
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 L33–34).
|
|
||||||
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 L37–38.
|
|
||||||
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);
|
||||||
|
|||||||
@@ -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 L36–44): 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 L27–28).
|
/// Отписывает клиента по завершении 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 L78–79).</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>
|
||||||
|
|||||||
@@ -1,15 +1,14 @@
|
|||||||
namespace Deal.Api.Events;
|
namespace Deal.Api.Events;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Событие SSE-потока: тип + JSON-полезная нагрузка (Ruling 5; прототип sse.py L29–30).
|
/// Событие 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 L78–81): на этапе 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: <type>\ndata: <json>\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
Reference in New Issue
Block a user