Почистить комментарии от упоминаний процесса
Удалены <remarks>, <summary> сжаты до короткой фразы, вырезаны ссылки на Task/Ruling/этап/python/прототип; //-комментарии со ссылками на процесс удалены; то же в .proto. Правила обновлены в docs/spec/Код-стайл-Дейл.md. Строк комментариев 27210 -> ~19100.
This commit is contained in:
@@ -7,11 +7,7 @@ using Microsoft.Extensions.DependencyInjection;
|
||||
namespace Deal.Ai.Tests.Ai;
|
||||
|
||||
/// <summary>
|
||||
/// In-proc gRPC-тесты AiService поверх фейк-провайдера (план Task 8, Acceptance): все 4 RPC
|
||||
/// (Filter/Classify/GenerateKeywords/EvaluateFit) с подменой LLM-фасада (без сети) через
|
||||
/// реальный хост (Kestrel HTTP/2, интерцептор service-token). Проверяются: разбор решений и
|
||||
/// usage в ответах, собранные сервисом промпты, недоступность провайдера → UNAVAILABLE с текстом
|
||||
/// 1:1 Ruling 5, ответ без JSON в Classify → ok=false (не ошибка), INVALID_ARGUMENT конфига.
|
||||
/// In-proc gRPC-тесты AiService поверх фейк-провайдера
|
||||
/// </summary>
|
||||
public sealed class AiRpcTests
|
||||
{
|
||||
@@ -21,7 +17,6 @@ public sealed class AiRpcTests
|
||||
// Usage API-ответа сценариев (проверка проброса в reply).
|
||||
private static readonly ProviderUsage SampleUsage = new(11, 5, 16);
|
||||
|
||||
// Текст ошибки UNAVAILABLE 1:1 Ruling 5 / ai.py L115–117.
|
||||
private const string UnavailableDetail =
|
||||
"ИИ (DeepSeek) не ответил корректно — повторите попытку через несколько секунд";
|
||||
|
||||
@@ -49,7 +44,6 @@ public sealed class AiRpcTests
|
||||
Assert.Equal("похоже на заявку", reply.Reason);
|
||||
AssertUsage(reply.Usage, SampleUsage);
|
||||
|
||||
// Сервис передаёт промпт system-сообщением и оборачивает текст как ai.py L193.
|
||||
FakeProviderCall call = Assert.Single(fake.Calls);
|
||||
Assert.Equal("Фильтр: {domain}", call.SystemPrompt);
|
||||
Assert.Equal("Сообщение:\nИщем разработчика на проект", call.UserText);
|
||||
@@ -77,7 +71,7 @@ public sealed class AiRpcTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Filter: модель не вернула pass — по умолчанию пропуск (1:1 ai.py L195: bool(get(pass, true))).
|
||||
/// Filter: модель не вернула pass — по умолчанию пропуск
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Filter_MissingPassField_DefaultsToPass()
|
||||
@@ -97,8 +91,7 @@ public sealed class AiRpcTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Filter: провайдер недоступен после ретраев (3 попытки) → UNAVAILABLE с текстом 1:1 Ruling 5
|
||||
/// (ядро трактует как «ИИ недоступен» и пропускает сообщение локальным путём).
|
||||
/// Filter: провайдер недоступен после ретраев
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Filter_ProviderUnavailable_ThrowsUnavailableWithDetail()
|
||||
@@ -120,7 +113,7 @@ public sealed class AiRpcTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Filter: ответ модели без JSON после ретраев — тоже UNAVAILABLE (у метода нет ok-поля).
|
||||
/// Filter: ответ модели без JSON после ретраев — тоже UNAVAILABLE
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Filter_AnswerWithoutJson_ThrowsUnavailable()
|
||||
@@ -141,8 +134,7 @@ public sealed class AiRpcTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Classify: модель вернула JSON — ok=true, json = извлечённый ответ строкой (маппинг в ядре),
|
||||
/// usage пробрасывается.
|
||||
/// Classify: модель вернула JSON — ok=true, json = извлечённый ответ строкой
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Classify_ModelAnsweredJson_ReturnsOkAndJson()
|
||||
@@ -178,8 +170,7 @@ public sealed class AiRpcTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Classify: модель отвечала, но без разбираемого JSON после ретраев → ok=false, НЕ RPC-ошибка
|
||||
/// (README ai.proto L201–204); usage последней попытки в ответе (оценка по символам).
|
||||
/// Classify: модель отвечала, но без разбираемого JSON после ретраев → ok=false, НЕ RPC-ошибка; usage последней попытки в ответе
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Classify_AnswerWithoutJson_ReturnsOkFalseWithUsage()
|
||||
@@ -211,7 +202,7 @@ public sealed class AiRpcTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Classify: провайдер недоступен — UNAVAILABLE (ядро падает в локальный разбор, aiFail).
|
||||
/// Classify: провайдер недоступен — UNAVAILABLE
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Classify_ProviderUnavailable_ThrowsUnavailable()
|
||||
@@ -232,7 +223,7 @@ public sealed class AiRpcTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// GenerateKeywords: ключи из JSON-ответа + фиксированный промпт с описанием задачи.
|
||||
/// GenerateKeywords
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GenerateKeywords_ModelReturnedKeywords_ReturnsList()
|
||||
@@ -253,7 +244,6 @@ public sealed class AiRpcTests
|
||||
Assert.Equal(["стройка", "ремонт квартир", "подряды"], reply.Keywords);
|
||||
AssertUsage(reply.Usage, SampleUsage);
|
||||
|
||||
// Фиксированный промпт (routes L36–47) и пользовательское сообщение с описанием.
|
||||
FakeProviderCall call = Assert.Single(fake.Calls);
|
||||
Assert.Contains("эксперт по поиску Telegram-каналов", call.SystemPrompt, StringComparison.Ordinal);
|
||||
Assert.Contains("Верни строго JSON", call.SystemPrompt, StringComparison.Ordinal);
|
||||
@@ -262,7 +252,7 @@ public sealed class AiRpcTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// GenerateKeywords: не-строковые элементы списка пропускаются (чистку делает ядро).
|
||||
/// GenerateKeywords
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GenerateKeywords_NonStringItems_Skipped()
|
||||
@@ -281,7 +271,7 @@ public sealed class AiRpcTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// GenerateKeywords: модель не вернула ключи — пустой список (не ошибка).
|
||||
/// GenerateKeywords
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GenerateKeywords_NoKeywordsField_ReturnsEmpty()
|
||||
@@ -300,8 +290,7 @@ public sealed class AiRpcTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EvaluateFit: fit=1 + причина; промпт собран сервисом из описания и ключей (discovery_eval
|
||||
/// L50–54), сообщение — как «Сообщение:\n…».
|
||||
/// EvaluateFit: fit=1 + причина; промпт собран сервисом из описания и ключей, сообщение — как «Сообщение:\n…».
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task EvaluateFit_ModelFits_ReturnsFitAndReason()
|
||||
@@ -332,7 +321,7 @@ public sealed class AiRpcTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EvaluateFit: ключи задачи подставляются в промпт строкой через запятую (1:1 _ai_prompt).
|
||||
/// EvaluateFit: ключи задачи подставляются в промпт строкой через запятую.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task EvaluateFit_KeywordsJoinedIntoPrompt()
|
||||
@@ -358,8 +347,7 @@ public sealed class AiRpcTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EvaluateFit: fit=0 без причины модели — причина по умолчанию «не подходит» (1:1 _ai_reason
|
||||
/// discovery_eval L167–171); строковое «нет» трактуется как ложь (1:1 _ai_fit L158–164).
|
||||
/// EvaluateFit: fit=0 без причины модели — причина по умолчанию «не подходит»; строковое «нет» трактуется как ложь.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task EvaluateFit_ModelNoFit_ReturnsDefaultReason()
|
||||
@@ -379,7 +367,7 @@ public sealed class AiRpcTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EvaluateFit: fit строкой «нет» — false (паритет _ai_fit), причина из модели.
|
||||
/// EvaluateFit: fit строкой «нет» — false, причина из модели.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task EvaluateFit_StringFalsyFit_ReturnsFalse()
|
||||
@@ -399,7 +387,7 @@ public sealed class AiRpcTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EvaluateFit: длинная причина модели усекается до 200 символов (1:1 _AI_REASON_LIMIT).
|
||||
/// EvaluateFit: длинная причина модели усекается до 200 символов.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task EvaluateFit_LongReason_IsTruncatedTo200()
|
||||
@@ -420,7 +408,7 @@ public sealed class AiRpcTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Конфиг-валидация: запрос без конфига провайдера (пустой base_url) → INVALID_ARGUMENT.
|
||||
/// Конфиг-валидация
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Classify_WithoutProviderConfig_IsInvalidArgument()
|
||||
@@ -441,7 +429,7 @@ public sealed class AiRpcTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Конфиг-валидация: пустая model конфига → INVALID_ARGUMENT (вызов модели невозможен).
|
||||
/// Конфиг-валидация
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GenerateKeywords_EmptyModel_IsInvalidArgument()
|
||||
@@ -466,7 +454,7 @@ public sealed class AiRpcTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Серверный лимит text (ai.proto Filter.text: core обрезает до 4000): превышение → INVALID_ARGUMENT.
|
||||
/// Серверный лимит text
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Filter_TooLongText_IsInvalidArgument()
|
||||
@@ -492,7 +480,7 @@ public sealed class AiRpcTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Серверный лимит description (ai.proto GenerateKeywords.description: core обрезает до 4000).
|
||||
/// Серверный лимит description
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GenerateKeywords_TooLongDescription_IsInvalidArgument()
|
||||
@@ -517,7 +505,7 @@ public sealed class AiRpcTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Обязательный tenant-id в metadata (Ruling 1): отсутствует → UNAUTHENTICATED до вызова.
|
||||
/// Обязательный tenant-id в metadata
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Classify_WithoutTenantId_IsUnauthenticated()
|
||||
|
||||
@@ -12,7 +12,6 @@ using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Deal.Ai.Tests.Ai;
|
||||
|
||||
// Общий харнесс in-proc gRPC-тестов ai-service (план Task 8): поднимает хост (AiServiceHost.Create)
|
||||
// в процессе теста на эфемерном порту и через configureServices-хук подменяет LLM-фасад фейком
|
||||
// (FakeProviderClient, без сети) и функцию паузы ретраев (мгновенная) — сценарии не
|
||||
// ждут 0.8/2 с между попытками. Регистрация, добавленная харнессом после дефолтных, побеждает
|
||||
@@ -20,17 +19,17 @@ namespace Deal.Ai.Tests.Ai;
|
||||
internal static class AiTestHost
|
||||
{
|
||||
/// <summary>
|
||||
/// Env-ключ ожидаемого service-token (зеркало ServiceTokenInterceptor).
|
||||
/// Env-ключ ожидаемого service-token
|
||||
/// </summary>
|
||||
public const string ServiceTokenEnvKey = "DEAL_SERVICE_TOKEN";
|
||||
|
||||
/// <summary>
|
||||
/// Ключ gRPC-metadata с service-token (зеркало ServiceTokenInterceptor).
|
||||
/// Ключ gRPC-metadata с service-token
|
||||
/// </summary>
|
||||
public const string ServiceTokenMetadataKey = ServiceTokenInterceptor.ServiceTokenMetadataKey;
|
||||
|
||||
/// <summary>
|
||||
/// Ключ gRPC-metadata с tenant-id (зеркало AiServiceImpl).
|
||||
/// Ключ gRPC-metadata с tenant-id
|
||||
/// </summary>
|
||||
public const string TenantIdMetadataKey = AiServiceImpl.TenantIdMetadataKey;
|
||||
|
||||
@@ -45,7 +44,7 @@ internal static class AiTestHost
|
||||
public const string DefaultTenantId = "tenant-test";
|
||||
|
||||
/// <summary>
|
||||
/// Deadline RPC-вызовов теста (сек).
|
||||
/// Deadline RPC-вызовов теста
|
||||
/// </summary>
|
||||
public const int RpcDeadlineSeconds = 15;
|
||||
|
||||
@@ -100,7 +99,7 @@ internal static class AiTestHost
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Подменяет HTTP-фасад вызовов модели фейком сценария (последняя регистрация побеждает).
|
||||
/// Подменяет HTTP-фасад вызовов модели фейком сценария
|
||||
/// </summary>
|
||||
/// <param name="services">Коллекция сервисов хоста.</param>
|
||||
/// <param name="fake">Фейк-провайдер сценария.</param>
|
||||
@@ -108,14 +107,14 @@ internal static class AiTestHost
|
||||
=> services.AddSingleton<IProviderClient>(fake);
|
||||
|
||||
/// <summary>
|
||||
/// Делает паузы ретраев мгновенными (иначе сценарии ждали бы 0.8/2 с).
|
||||
/// Делает паузы ретраев мгновенными
|
||||
/// </summary>
|
||||
/// <param name="services">Коллекция сервисов хоста.</param>
|
||||
public static void DisableRetryDelays(IServiceCollection services)
|
||||
=> services.AddSingleton<Func<TimeSpan, CancellationToken, Task>>(static (_, _) => Task.CompletedTask);
|
||||
|
||||
/// <summary>
|
||||
/// Строит metadata вызова: service-token (+ tenant-id, если задан).
|
||||
/// Строит metadata вызова
|
||||
/// </summary>
|
||||
/// <param name="serviceToken">Значение заголовка service-token.</param>
|
||||
/// <param name="tenantId">Id тенанта (null — без заголовка tenant-id).</param>
|
||||
@@ -136,7 +135,7 @@ internal static class AiTestHost
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// CallOptions RPC: metadata + deadline (рекомендации README src/contracts L62–73).
|
||||
/// CallOptions RPC
|
||||
/// </summary>
|
||||
/// <param name="metadata">Metadata вызова.</param>
|
||||
public static CallOptions CallOptions(Metadata metadata)
|
||||
|
||||
@@ -8,7 +8,6 @@ namespace Deal.Ai.Tests.Ai;
|
||||
// Config: Конфиг провайдера вызова.
|
||||
internal sealed record FakeProviderCall(string SystemPrompt, string UserText, LlmConfig Config);
|
||||
|
||||
// Фейк-провайдер LLM-вызовов (без сети; план Task 8): поведение задаётся сценарием (ответ текстом,
|
||||
// usage либо сбой), каждый вызов записывается в Calls — тесты проверяют и собранные
|
||||
// RPC-слоем промпты, и число попыток ретраев.
|
||||
internal sealed class FakeProviderClient : IProviderClient
|
||||
@@ -25,7 +24,7 @@ internal sealed class FakeProviderClient : IProviderClient
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Все вызовы фейка в порядке поступления (для проверок RPC-веток).
|
||||
/// Все вызовы фейка в порядке поступления
|
||||
/// </summary>
|
||||
public List<FakeProviderCall> Calls { get; } = [];
|
||||
|
||||
|
||||
@@ -4,14 +4,12 @@ using Microsoft.Extensions.Logging.Abstractions;
|
||||
namespace Deal.Ai.Tests.Ai;
|
||||
|
||||
/// <summary>
|
||||
/// Unit-тесты оркестратора вызовов модели (план Task 7; ProviderCaller, 1:1 chat_json ai.py L80–117):
|
||||
/// ретраи 2 с паузами 0.8/2 с, извлечение JSON, различение «провайдер не ответил» и «ответ без
|
||||
/// JSON», usage. Фейк-провайдер без сети; паузы ретраев мгновенные (харнесс-делегат).
|
||||
/// Unit-тесты оркестратора вызовов модели
|
||||
/// </summary>
|
||||
public sealed class ProviderCallerTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Успех с первого вызова: JSON-объект извлечён, usage API-ответа в результате.
|
||||
/// Успех с первого вызова
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ChatJsonAsync_FirstAttemptSuccess_ReturnsJsonAndUsage()
|
||||
@@ -29,7 +27,7 @@ public sealed class ProviderCallerTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Марdown-обёртка ответа модели снимается фасадом (extract_json L175–183).
|
||||
/// Марdown-обёртка ответа модели снимается фасадом.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ChatJsonAsync_MarkdownWrappedJson_Extracts()
|
||||
@@ -45,7 +43,7 @@ public sealed class ProviderCallerTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Без usage API-ответа фасад оценивает токены по символам (≈chars/4).
|
||||
/// Без usage API-ответа фасад оценивает токены по символам
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ChatJsonAsync_WithoutProviderUsage_EstimatesTokens()
|
||||
@@ -63,7 +61,7 @@ public sealed class ProviderCallerTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Сбой на первых двух попытках и успех на третьей: итог успешен, попыток — 3.
|
||||
/// Сбой на первых двух попытках и успех на третьей
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ChatJsonAsync_TwoFailuresThenSuccess_RetriesAndSucceeds()
|
||||
@@ -86,8 +84,7 @@ public sealed class ProviderCallerTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Все попытки — транспортный сбой: LlmCallException ProviderUnavailable с текстом 1:1 Ruling 5
|
||||
/// (ai.py L115–117); usage отсутствует (модель не отвечала).
|
||||
/// Все попытки — транспортный сбой
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ChatJsonAsync_AllTransportFailures_ThrowsProviderUnavailable()
|
||||
@@ -108,8 +105,7 @@ public sealed class ProviderCallerTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Модель отвечала, но ни одна попытка не дала разбираемый JSON: AnswerNotJson с usage последней
|
||||
/// попытки (Classify вернёт ok=false; README ai.proto L201–204).
|
||||
/// Модель отвечала, но ни одна попытка не дала разбираемый JSON
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ChatJsonAsync_AllAnswersNotJson_ThrowsAnswerNotJsonWithUsage()
|
||||
@@ -128,8 +124,7 @@ public sealed class ProviderCallerTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Отмена (deadline RPC) прерывает вызов без «упаковки» в ошибку провайдера: исключение
|
||||
/// отмены из попытки не перехватывается как сбой (ретрятся только LlmHttpException).
|
||||
/// Отмена (deadline RPC) прерывает вызов без «упаковки» в ошибку провайдера
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ChatJsonAsync_Cancelled_PropagatesCancellation()
|
||||
|
||||
@@ -3,14 +3,12 @@ using Deal.Ai.Llm;
|
||||
namespace Deal.Ai.Tests.Ai;
|
||||
|
||||
/// <summary>
|
||||
/// Unit-тесты оценки токенов (план Task 7; TokenEstimator, Ruling 5): usage API-ответа провайдера
|
||||
/// проходит как есть (total «берём как есть»), при отсутствии — оценка по символам ≈ceil(chars/4).
|
||||
/// Без сети.
|
||||
/// Unit-тесты оценки токенов
|
||||
/// </summary>
|
||||
public sealed class TokenEstimatorTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Usage провайдера проходит без изменений, включая total ≠ сумме (как отдал API).
|
||||
/// Usage провайдера проходит без изменений, включая total ≠ сумме
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Resolve_WithProviderUsage_PassesThrough()
|
||||
@@ -25,7 +23,7 @@ public sealed class TokenEstimatorTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Нет usage провайдера — оценка по символам: prompt из system+user, completion из текста.
|
||||
/// Нет usage провайдера — оценка по символам
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Resolve_WithoutProviderUsage_EstimatesByChars()
|
||||
@@ -39,7 +37,7 @@ public sealed class TokenEstimatorTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Округление вверх: ровно 4 символа — 1 токен, 5 символов — 2 токена.
|
||||
/// Округление вверх
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Resolve_WithoutProviderUsage_RoundsUp()
|
||||
@@ -52,7 +50,7 @@ public sealed class TokenEstimatorTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Пустые тексты дают нулевую оценку (не отрицательную).
|
||||
/// Пустые тексты дают нулевую оценку
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Resolve_EmptyTexts_ReturnsZeroTokens()
|
||||
|
||||
@@ -9,18 +9,7 @@ using Microsoft.AspNetCore.Builder;
|
||||
namespace Deal.Ai.Tests.Grpc;
|
||||
|
||||
/// <summary>
|
||||
/// Интеграционные тесты хоста ai-service (каркас Task 4 + логика Task 8).
|
||||
///
|
||||
/// Хост поднимается В процессе теста (Kestrel HTTP/2, эфемерный порт) через AiServiceHost.Create —
|
||||
/// ту же сборку хоста, что использует Program.cs, поэтому тесты покрывают реальную настройку
|
||||
/// Kestrel/AddGrpc/health, а не её копию. Проверки: gRPC-health → SERVING; ServiceTokenInterceptor
|
||||
/// (Ruling 1): запрос без токена и с неверным токеном → UNAUTHENTICATED; верный токен проходит к
|
||||
/// методу (реализация Task 8: пустой запрос без конфига провайдера → INVALID_ARGUMENT); при
|
||||
/// незаданном DEAL_SERVICE_TOKEN — fail-closed.
|
||||
///
|
||||
/// Токен интерцептор читает из конфигурации (env DEAL_SERVICE_TOKEN) — тесты выставляют env на время
|
||||
/// сценария и восстанавливают исходное значение. Все тесты класса живут в одном процессе/классе,
|
||||
/// чтобы env и свободные порты не конфликтовали (xunit исполняет методы класса последовательно).
|
||||
/// Интеграционные тесты хоста ai-service.
|
||||
/// </summary>
|
||||
public sealed class AiServiceHostTests
|
||||
{
|
||||
@@ -33,15 +22,13 @@ public sealed class AiServiceHostTests
|
||||
// Токен сценариев теста.
|
||||
private const string ValidToken = "task4-test-token";
|
||||
|
||||
// Id тенанта запросов (доходит до метода при верном токене; Ruling 1).
|
||||
private const string TenantId = "tenant-test";
|
||||
|
||||
// Deadline RPC-вызовов теста (сек).
|
||||
private const int RpcDeadlineSeconds = 10;
|
||||
|
||||
/// <summary>
|
||||
/// HealthCheck (grpc.health.v1.Health/Check) отвечает SERVING — хост поднялся, gRPC-инфраструктура
|
||||
/// и health-сервис работают (Ruling 12; health освобождён от service-token).
|
||||
/// HealthCheck (grpc.health.v1.Health/Check) отвечает SERVING — хост поднялся, gRPC-инфраструктура и health-сервис работают.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task HealthCheck_ReturnsServing()
|
||||
@@ -60,7 +47,7 @@ public sealed class AiServiceHostTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Запрос без metadata «service-token» → UNAUTHENTICATED (Ruling 1).
|
||||
/// Запрос без metadata «service-token» → UNAUTHENTICATED.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Classify_WithoutToken_IsUnauthenticated()
|
||||
@@ -72,7 +59,7 @@ public sealed class AiServiceHostTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Запрос с неверным токеном → UNAUTHENTICATED (Ruling 1).
|
||||
/// Запрос с неверным токеном → UNAUTHENTICATED.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Classify_WithWrongToken_IsUnauthenticated()
|
||||
@@ -84,9 +71,7 @@ public sealed class AiServiceHostTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Верный токен проходит интерцептор к методу — кодогенерация и маппинг сервиса работают:
|
||||
/// пустой запрос (без конфига провайдера) доходит до реализации Classify (Task 8) и отклоняется
|
||||
/// валидацией INVALID_ARGUMENT, а не UNIMPLEMENTED.
|
||||
/// Верный токен проходит интерцептор к методу — кодогенерация и маппинг сервиса работают
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Classify_WithValidToken_ReachesServiceAndValidatesConfig()
|
||||
@@ -98,9 +83,7 @@ public sealed class AiServiceHostTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fail-closed (шаблон Task 3): DEAL_SERVICE_TOKEN не задан — Deal-RPC отклоняется всегда,
|
||||
/// в т.ч. запрос с «пустым» значением metadata (без гарда сравнение «» == «» пропустило бы его);
|
||||
/// health при этом продолжает отвечать SERVING (инфраструктурный liveness не ломается).
|
||||
/// Fail-closed: DEAL_SERVICE_TOKEN не задан — Deal-RPC отклоняется всегда, в т.ч.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task WithoutConfiguredToken_DealRpcFailsClosed_HealthStillServing()
|
||||
@@ -138,7 +121,6 @@ public sealed class AiServiceHostTests
|
||||
|
||||
// Вызывает Classify и проверяет, что сервер ответил ожидаемым кодом статуса.
|
||||
// Запрос несёт полный metadata (service-token + tenant-id), чтобы верный токен доходил
|
||||
// до реализации метода (заглушки больше нет — Task 8), а не падал на tenant-проверке.
|
||||
// channel: Канал к хосту ai-service.
|
||||
// tokenHeader: Значение metadata «service-token» либо null (нет заголовка).
|
||||
// expected: Ожидаемый StatusCode.
|
||||
|
||||
@@ -4,8 +4,7 @@ using Deal.Ai.Llm;
|
||||
namespace Deal.Ai.Tests.Support;
|
||||
|
||||
/// <summary>
|
||||
/// Unit-тесты извлечения JSON из ответа модели (план Task 7; JsonExtractor, 1:1 extract_json
|
||||
/// ai.py L175–183): чистая строка JSON, markdown-обёртки, проза вокруг, отказы. Без сети.
|
||||
/// Unit-тесты извлечения JSON из ответа модели
|
||||
/// </summary>
|
||||
public sealed class JsonExtractorTests
|
||||
{
|
||||
@@ -22,7 +21,7 @@ public sealed class JsonExtractorTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Markdown-обёртка ```json снимается (типичный ответ DeepSeek).
|
||||
/// Markdown-обёртка ```json снимается
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void TryExtractObject_JsonFence_Unwraps()
|
||||
@@ -59,7 +58,7 @@ public sealed class JsonExtractorTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Текст без JSON — null (попытка неудачна, фасад повторяет вызов).
|
||||
/// Текст без JSON — null
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void TryExtractObject_NoJson_ReturnsNull()
|
||||
@@ -78,7 +77,7 @@ public sealed class JsonExtractorTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// JSON верхнего уровня не объект (массив/строка) — null (схемы ответов всегда объект).
|
||||
/// JSON верхнего уровня не объект
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void TryExtractObject_NonObjectRoot_ReturnsNull()
|
||||
|
||||
@@ -5,10 +5,7 @@ using Deal.Ai.Llm;
|
||||
namespace Deal.Ai.Tests.Support;
|
||||
|
||||
/// <summary>
|
||||
/// HTTP-тесты LlmHttpClient на заглушке HttpMessageHandler (план Task 7 Acceptance; без сети):
|
||||
/// форма OpenAI-совместимого запроса ({base}/chat/completions, Bearer, temperature 0.2,
|
||||
/// max_tokens 8000), форма Anthropic ({base}/v1/messages, x-api-key + anthropic-version),
|
||||
/// разбор ответов/usage, отказ без ключа, reasoning-без-ответа, HTTP-ошибка и таймаут.
|
||||
/// HTTP-тесты LlmHttpClient на заглушке HttpMessageHandler
|
||||
/// </summary>
|
||||
public sealed class LlmHttpClientTests
|
||||
{
|
||||
@@ -50,7 +47,7 @@ public sealed class LlmHttpClientTests
|
||||
""";
|
||||
|
||||
/// <summary>
|
||||
/// OpenAI-совместимый вызов: URL, Bearer, форма тела (model/messages/temperature/max_tokens).
|
||||
/// OpenAI-совместимый вызов
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ChatAsync_OpenAiStyle_BuildsWireRequest()
|
||||
@@ -84,7 +81,7 @@ public sealed class LlmHttpClientTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Локальный OpenAI-совместимый провайдер без ключа: заголовок Authorization не шлётся.
|
||||
/// Локальный OpenAI-совместимый провайдер без ключа
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ChatAsync_OpenAiStyleWithoutApiKey_SkipsAuthorization()
|
||||
@@ -98,7 +95,7 @@ public sealed class LlmHttpClientTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Модель вернула только reasoning без ответа — сбой попытки (ai.py L149–151).
|
||||
/// Модель вернула только reasoning без ответа — сбой попытки.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ChatAsync_OpenAiReasoningOnly_Throws()
|
||||
@@ -115,7 +112,7 @@ public sealed class LlmHttpClientTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Anthropic-вызов: URL /v1/messages, x-api-key + anthropic-version, форма тела Messages API.
|
||||
/// Anthropic-вызов
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ChatAsync_AnthropicStyle_BuildsWireRequest()
|
||||
@@ -145,13 +142,12 @@ public sealed class LlmHttpClientTests
|
||||
Assert.Equal("user", (string?)messages[0]!["role"]);
|
||||
Assert.Equal("Сообщение", (string?)messages[0]!["content"]);
|
||||
|
||||
// Склейка text-блоков content[] + usage (input/output → total = сумма; ai.py L168–172).
|
||||
Assert.Equal("{\"fit\": 1} ещё текст", result.Text);
|
||||
Assert.Equal(new ProviderUsage(4, 6, 10), result.Usage);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// HTTP-ошибка провайдера — сбой попытки с кодом статуса (повод для ретрая).
|
||||
/// HTTP-ошибка провайдера — сбой попытки с кодом статуса
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ChatAsync_HttpError_Throws()
|
||||
@@ -166,7 +162,7 @@ public sealed class LlmHttpClientTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Неожиданная форма ответа (не JSON) — сбой попытки, а не падение.
|
||||
/// Неожиданная форма ответа
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ChatAsync_UnexpectedBody_Throws()
|
||||
@@ -182,7 +178,7 @@ public sealed class LlmHttpClientTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Таймаут попытки (90/60 с в проде; в тесте — 60 мс) → LlmHttpException.
|
||||
/// Таймаут попытки
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ChatAsync_Timeout_Throws()
|
||||
|
||||
@@ -13,7 +13,6 @@ internal sealed record CapturedHttpRequest(
|
||||
IReadOnlyDictionary<string, string> Headers,
|
||||
string? Body);
|
||||
|
||||
// Заглушка HttpMessageHandler для HTTP-тестов LlmHttpClient (план Task 7; без сети): записывает
|
||||
// запросы (URL/заголовки/тело) и отвечает по сценарию; опциональная задержка — для теста таймаута.
|
||||
internal sealed class StubHttpMessageHandler : HttpMessageHandler
|
||||
{
|
||||
@@ -93,14 +92,14 @@ internal sealed class StubHttpMessageHandler : HttpMessageHandler
|
||||
=> _ => new HttpResponseMessage(statusCode);
|
||||
|
||||
/// <summary>
|
||||
/// Разбирает тело запроса как JSON-объект (для проверок формы).
|
||||
/// Разбирает тело запроса как JSON-объект
|
||||
/// </summary>
|
||||
/// <param name="request">Снимок запроса.</param>
|
||||
public static JsonObject BodyOf(CapturedHttpRequest request)
|
||||
=> JsonNode.Parse(request.Body!)!.AsObject();
|
||||
|
||||
/// <summary>
|
||||
/// Снимает заголовок авторизации Bearer (null — заголовка нет).
|
||||
/// Снимает заголовок авторизации Bearer
|
||||
/// </summary>
|
||||
/// <param name="request">Снимок запроса.</param>
|
||||
public static string? BearerOf(CapturedHttpRequest request)
|
||||
|
||||
@@ -6,41 +6,17 @@ using Deal.Grpc.Hosting.Services;
|
||||
namespace Deal.Ai;
|
||||
|
||||
/// <summary>
|
||||
/// Собирает WebApplication gRPC-хоста ai-service (план Task 4/7/8; Ruling 1/2/5/12).
|
||||
///
|
||||
/// Продакшн-точка входа вызывает <see cref="Create"/> из Program.cs (порт из env GRPC_PORT/PORT);
|
||||
/// интеграционные тесты (Deal.Ai.Tests) — из своего процесса на эфемерном порту, поэтому
|
||||
/// конфигурация хоста живёт здесь один раз и не дублируется в тестах.
|
||||
/// Транспорт/AddGrpc/health — общая серверная обвязка <see cref="GrpcServer"/> (Deal.Grpc.Hosting,
|
||||
/// C31): mTLS (env DEAL_MTLS_*, Ruling 6/Task 13), Kestrel HTTP/2, интерцепторы service-token и
|
||||
/// access-лога, gRPC-health; здесь — только регистрации логики ai-service.
|
||||
/// Регистрации логики (план Task 7/8, Ruling 5): LLM-фасад провайдеров — HTTP-клиент
|
||||
/// (<see cref="Llm.LlmHttpClient"/>, OpenAI-совместимые chat/completions + Anthropic Messages API,
|
||||
/// таймауты 90/60 с) как <see cref="Llm.IProviderClient"/> и оркестратор вызовов
|
||||
/// (<see cref="Llm.ProviderCaller"/>: ретраи 2 с паузами 0.8/2 с, извлечение JSON из markdown,
|
||||
/// usage API или оценка по символам). Сервис без БД и настроек (ядро передаёт заполненные промпты
|
||||
/// и конфиг провайдера в теле запроса); configureServices-хук — seam для фейков тестов
|
||||
/// (подмена IProviderClient и функции паузы ретраев).
|
||||
/// Собирает WebApplication gRPC-хоста ai-service.
|
||||
/// </summary>
|
||||
public static class AiServiceHost
|
||||
{
|
||||
/// <summary>
|
||||
/// Создаёт (не запускает) хост: общая обвязка GrpcServer (Kestrel HTTP/2 на 0.0.0.0:grpcPort,
|
||||
/// dev — plaintext + service-token, Ruling 2; при DEAL_MTLS_ENABLED=1 — HTTPS с серверным
|
||||
/// сертификатом и требованием клиентского, Ruling 6/Task 13), затем LLM-фасад (Task 7)
|
||||
/// и маппинг <see cref="AiServiceImpl"/> (Task 8).
|
||||
/// Создаёт (не запускает) хост
|
||||
/// </summary>
|
||||
/// <param name="grpcPort">TCP-порт Kestrel.</param>
|
||||
/// <param name="args">Аргументы командной строки (Program.cs); в тестах не нужны.</param>
|
||||
/// <param name="configureServices">
|
||||
/// Опциональный хук DI для тестов (подмена LLM-фасада фейками: регистрация, добавленная здесь,
|
||||
/// побеждает — DI резолвит последнюю; см. AiServiceHostTests/AiRpcTests).
|
||||
/// </param>
|
||||
/// <param name="configureBuilder">
|
||||
/// Опциональный хук конфигурации билдера для production-точки входа (Program.cs): Serilog
|
||||
/// (DealLogging.Configure, Ruling 7/Task 14). Тесты хост поднимают БЕЗ этого хука — логирование
|
||||
/// файлов/консоли тестам не нужно.
|
||||
/// </param>
|
||||
/// <param name="configureServices">Опциональный хук DI для тестов (подмена LLM-фасада фейками: регистрация, добавленная здесь, побеждает — DI резолвит последнюю; см. AiServiceHostTests/AiRpcTests).</param>
|
||||
/// <param name="configureBuilder">Опциональный хук конфигурации билдера для production-точки входа (Program.cs): Serilog. Тесты хост поднимают БЕЗ этого хука — логирование файлов/консоли тестам не нужно.</param>
|
||||
/// <returns>Собранный хост; запуск — StartAsync/RunAsync у вызывающего.</returns>
|
||||
public static WebApplication Create(
|
||||
int grpcPort,
|
||||
@@ -52,14 +28,11 @@ public static class AiServiceHost
|
||||
|
||||
// Общая серверная обвязка (Deal.Grpc.Hosting, C31): mTLS env DEAL_MTLS_* — загрузка
|
||||
// сертификатов сразу с fail-fast (compose-prod монтирует deploy/certs, scripts/mtls-certs.sh);
|
||||
// Kestrel HTTP/2 (dev — plaintext + обязательный service-token, Ruling 2); AddGrpc
|
||||
// (access-лог первым, затем service-token, потолок сообщения) и gRPC-health (Ruling 12).
|
||||
MtlsCertificates? mtlsCertificates = GrpcServer.LoadMtlsCertificates(builder);
|
||||
GrpcServer.ConfigureKestrelHttp2Endpoint(builder, grpcPort, mtlsCertificates);
|
||||
builder.Services.AddDealGrpcServer();
|
||||
builder.Services.AddReadyHealthCheck("хост ai-service готов");
|
||||
|
||||
// LLM-фасад провайдеров (план Task 7, Ruling 5): HTTP-клиент одной попытки вызова (таймаут
|
||||
// попытки управляется внутри — 90 с OpenAI / 60 с Anthropic; клиент без общего таймаута) и
|
||||
// оркестратор ретраев/JSON/usage поверх него. Ключи API — в конфиге запроса, не в DI/логах.
|
||||
builder.Services.AddHttpClient<Llm.LlmHttpClient>(static httpClient =>
|
||||
|
||||
@@ -7,23 +7,12 @@ using Grpc.Core;
|
||||
namespace Deal.Ai;
|
||||
|
||||
/// <summary>
|
||||
/// Реализация серверной стороны Deal.Grpc.Ai.AiService — команды ядра в ai-service
|
||||
/// (ai.proto, контракты Task 1; Ruling 1/5).
|
||||
///
|
||||
/// Логика (план Task 8, поверх LLM-фасада Task 7): Filter/Classify/GenerateKeywords/EvaluateFit
|
||||
/// вызывают модель через <see cref="ProviderCaller"/> по конфигу ProviderConfig из тела запроса;
|
||||
/// сервис без БД, настроек и большинства промптов не знает (ядро передаёт заполненные промпты).
|
||||
/// Сервисные промпты только там, где прототип держит их фиксированными: генерация ключевых слов
|
||||
/// (discovery_routes L36–47) и оценка fit (discovery_eval L50–54). Каждый ответ несёт usage
|
||||
/// (Ruling 5). Недоступность провайдера после ретраев → UNAVAILABLE с detail
|
||||
/// «ИИ (имя) не ответил корректно — повторите попытку через несколько секунд» (ядро падает в
|
||||
/// локальный разбор); ответ модели без разбираемого JSON в Classify — ok=false, не RPC-ошибка
|
||||
/// (README ai.proto L201–204).
|
||||
/// Реализация серверной стороны Deal.Grpc.Ai.AiService — команды ядра в ai-service.
|
||||
/// </summary>
|
||||
public sealed class AiServiceImpl : AiService.AiServiceBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Ключ gRPC-metadata с id тенанта (обязателен на всех RPC — Ruling 1).
|
||||
/// Ключ gRPC-metadata с id тенанта.
|
||||
/// </summary>
|
||||
public const string TenantIdMetadataKey = "tenant-id";
|
||||
|
||||
@@ -57,10 +46,8 @@ public sealed class AiServiceImpl : AiService.AiServiceBase
|
||||
// Деталь отказа: ключ задачи длиннее лимита (INVALID_ARGUMENT).
|
||||
private const string KeywordTooLongDetail = "Слишком длинный ключ задачи";
|
||||
|
||||
// Потолок длины текста сообщения (1:1: core обрезает до 4000 — ai.proto Filter.text/EvaluateFit.text).
|
||||
private const int MaxTextLength = 4000;
|
||||
|
||||
// Потолок длины описания ниши/задачи (1:1: core обрезает до 4000 — ai.proto GenerateKeywords.description).
|
||||
private const int MaxDescriptionLength = 4000;
|
||||
|
||||
// Защитный потолок длины промпта (Filter.prompt/Classify.system_prompt; лимит не декларирован).
|
||||
@@ -69,20 +56,14 @@ public sealed class AiServiceImpl : AiService.AiServiceBase
|
||||
// Защитный потолок длины user-контекста Classify (лимит не декларирован).
|
||||
private const int MaxUserContextLength = 20000;
|
||||
|
||||
// Потолок числа ключей задачи EvaluateFit (после _clean_keywords ядро шлёт ≤30 — запас на рост).
|
||||
private const int MaxKeywordsCount = 200;
|
||||
|
||||
// Потолок длины одного ключа задачи EvaluateFit (_clean_keywords: ≤60 симв. — запас на рост).
|
||||
private const int MaxKeywordLength = 200;
|
||||
|
||||
// Префикс пользовательского сообщения фильтра (1:1 ai.py filter_incoming L193).
|
||||
private const string FilterUserPrefix = "Сообщение:\n";
|
||||
|
||||
// Префикс пользовательского сообщения генератора ключей (1:1 discovery_routes L206).
|
||||
private const string KeywordsUserPrefix = "Описание ниши/задачи:\n";
|
||||
|
||||
// Фиксированный системный промпт генерации ключевых слов (1:1 _KEYWORDS_PROMPT
|
||||
// discovery_routes L36–47; пользовательское сообщение — описание задачи).
|
||||
private const string GenerateKeywordsSystemPrompt =
|
||||
"Ты — эксперт по поиску Telegram-каналов и групп. По описанию ниши/задачи "
|
||||
+ "составь поисковые ключевые слова, по которым в глобальном поиске Telegram "
|
||||
@@ -95,35 +76,26 @@ public sealed class AiServiceImpl : AiService.AiServiceBase
|
||||
+ "- конкретные для ниши, включая сленг заказчиков и подрядчиков;\n"
|
||||
+ "- без дублей и близких по смыслу повторов.";
|
||||
|
||||
// Шаблон системного промпта оценки fit (1:1 _AI_PROMPT discovery_eval L50–54): подставляются
|
||||
// описание и ключи задачи (строкой через запятую, как _ai_prompt L153–155).
|
||||
private const string EvaluateFitSystemPromptTemplate =
|
||||
"Оцени, относится ли сообщение к сфере/задаче. Описание: {0}. Ключи: {1}. "
|
||||
+ "Верни JSON {{\"fit\": 0|1, \"reason\": \"краткая причина\"}}.";
|
||||
|
||||
// Потолок длины причины решения модели (1:1 _AI_REASON_LIMIT discovery_eval L43).
|
||||
private const int MaxEvalReasonLength = 200;
|
||||
|
||||
// Причина по умолчанию при fit=true, если модель причину не дала (1:1 _ai_reason L170).
|
||||
private const string FitReasonDefault = "подходит";
|
||||
|
||||
// Причина по умолчанию при fit=false, если модель причину не дала (1:1 _ai_reason L170).
|
||||
private const string NotFitReasonDefault = "не подходит";
|
||||
|
||||
// Имя поля решения фильтра в JSON-ответе модели (1:1 ai.py L195).
|
||||
private const string PassFieldName = "pass";
|
||||
|
||||
// Имя поля причины в JSON-ответе модели.
|
||||
private const string ReasonFieldName = "reason";
|
||||
|
||||
// Имя поля решения fit в JSON-ответе модели (1:1 discovery_eval _ai_fit).
|
||||
private const string FitFieldName = "fit";
|
||||
|
||||
// Имя поля списка ключевых слов в JSON-ответе модели.
|
||||
private const string KeywordsFieldName = "keywords";
|
||||
|
||||
// Значения, которые строковый ответ модели трактует как «ложь» (bool() в python: fit/filter —
|
||||
// 1:1 _ai_fit discovery_eval L158–164; для фильтра отсутствие поля = true, 1:1 ai.py L195).
|
||||
private static readonly IReadOnlySet<string> FalsyAnswerValues = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"0",
|
||||
@@ -141,7 +113,7 @@ public sealed class AiServiceImpl : AiService.AiServiceBase
|
||||
/// Создаёт gRPC-сервис команд ядра поверх LLM-фасада.
|
||||
/// </summary>
|
||||
/// <param name="caller">Оркестратор вызовов модели (ретраи + извлечение JSON + usage).</param>
|
||||
/// <param name="logger">Логгер аудита (Ruling 13; ключи API не логируются).</param>
|
||||
/// <param name="logger">Логгер аудита.</param>
|
||||
public AiServiceImpl(ProviderCaller caller, ILogger<AiServiceImpl> logger)
|
||||
{
|
||||
_caller = caller;
|
||||
@@ -149,10 +121,7 @@ public sealed class AiServiceImpl : AiService.AiServiceBase
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Filter — ИИ-фильтр входящих сообщений (ai.py filter_incoming L188–198): решение {pass, reason}
|
||||
/// по заполненному ядром aiFilterPrompt (system) и тексту. Ветку «фильтр не применялся»
|
||||
/// (aiFilterEnabled/недоступность) ядро обрабатывает до вызова; недоступность провайдера —
|
||||
/// UNAVAILABLE (ядро пропускает сообщение).
|
||||
/// Filter — ИИ-фильтр входящих сообщений
|
||||
/// </summary>
|
||||
public override async Task<FilterReply> Filter(FilterRequest request, ServerCallContext context)
|
||||
{
|
||||
@@ -190,11 +159,7 @@ public sealed class AiServiceImpl : AiService.AiServiceBase
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Classify — полный разбор лида (ai.py classify L218–258): ответ {ok, json}, где json — строка
|
||||
/// с извлечённым ответом модели (типовую схему задаёт промпт); строгий маппинг json → карточку
|
||||
/// делает ядро (1:1 normalize_stack/clean_budget/build_contacts). Модель отвечала без
|
||||
/// разбираемого JSON после ретраев → ok=false (не RPC-ошибка; ядро падает в локальный разбор);
|
||||
/// провайдер недоступен → UNAVAILABLE.
|
||||
/// Classify — полный разбор лида
|
||||
/// </summary>
|
||||
public override async Task<ClassifyReply> Classify(ClassifyRequest request, ServerCallContext context)
|
||||
{
|
||||
@@ -235,9 +200,7 @@ public sealed class AiServiceImpl : AiService.AiServiceBase
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// GenerateKeywords — ключевые слова discovery-задачи по описанию (фикс. промпт discovery_routes
|
||||
/// L36–47 + описание): ответ {keywords}. Очистку (_clean_keywords: ≤30, ≤60 симв., дедуп) и мягкую
|
||||
/// ошибку для UI делает ядро (Ruling 11); недоступность провайдера — UNAVAILABLE.
|
||||
/// GenerateKeywords — ключевые слова discovery-задачи по описанию
|
||||
/// </summary>
|
||||
public override async Task<GenerateKeywordsReply> GenerateKeywords(GenerateKeywordsRequest request, ServerCallContext context)
|
||||
{
|
||||
@@ -272,9 +235,7 @@ public sealed class AiServiceImpl : AiService.AiServiceBase
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EvaluateFit — оценка соответствия сообщения задаче поиска (промпт discovery_eval L50–54;
|
||||
/// текст + описание + ключи задачи): ответ {fit, reason}. Ядро зовёт только при aiEnabled;
|
||||
/// сбой — фолбэк на эвристику (Ruling 10).
|
||||
/// EvaluateFit — оценка соответствия сообщения задаче поиска
|
||||
/// </summary>
|
||||
public override async Task<EvaluateFitReply> EvaluateFit(EvaluateFitRequest request, ServerCallContext context)
|
||||
{
|
||||
@@ -314,7 +275,6 @@ public sealed class AiServiceImpl : AiService.AiServiceBase
|
||||
}
|
||||
}
|
||||
|
||||
// Читает tenant-id из metadata (обязателен; отсутствие — UNAUTHENTICATED, Ruling 1).
|
||||
// context: Контекст вызова.
|
||||
private static string RequireTenantId(ServerCallContext context)
|
||||
{
|
||||
@@ -389,7 +349,6 @@ public sealed class AiServiceImpl : AiService.AiServiceBase
|
||||
providerConfig.ApiStyle);
|
||||
}
|
||||
|
||||
// Пишет предупреждение аудита о недоступности ИИ (Ruling 13; без ключей и текстов).
|
||||
// method: Имя RPC для аудита.
|
||||
// tenantId: Id тенанта.
|
||||
// config: Конфиг провайдера вызова.
|
||||
@@ -406,14 +365,11 @@ public sealed class AiServiceImpl : AiService.AiServiceBase
|
||||
config.DisplayName,
|
||||
callError.Kind);
|
||||
|
||||
// Превращает ошибку фасада в RPC-ошибку UNAVAILABLE с detail 1:1 Ruling 5.
|
||||
// callError: Итоговая ошибка фасада.
|
||||
private static RpcException ToUnavailable(LlmCallException callError)
|
||||
=> new(new Status(StatusCode.Unavailable, callError.Message));
|
||||
|
||||
// Читает булево поле JSON-ответа модели: bool как есть; строка — ложь только для значений из
|
||||
// FalsyAnswerValues (1:1 _ai_fit discovery_eval L158–164); число — ненулевое = true;
|
||||
// поля нет/не разбирается — defaultValue (фильтр: true, ai.py L195; fit: false, discovery_eval).
|
||||
// json: Корневой объект ответа модели.
|
||||
// fieldName: Имя поля.
|
||||
// defaultValue: Значение при отсутствии/неразбираемости поля.
|
||||
@@ -461,7 +417,6 @@ public sealed class AiServiceImpl : AiService.AiServiceBase
|
||||
}
|
||||
|
||||
// Причина решения fit: поле reason модели, при отсутствии — «подходит»/«не подходит»
|
||||
// (1:1 _ai_reason discovery_eval L167–171), потолок длины MaxEvalReasonLength.
|
||||
// json: Корневой объект ответа модели.
|
||||
// fit: Решение модели.
|
||||
private static string ReadFitReason(JsonObject json, bool fit)
|
||||
@@ -492,7 +447,6 @@ public sealed class AiServiceImpl : AiService.AiServiceBase
|
||||
}
|
||||
}
|
||||
|
||||
// Склеивает ключи задачи для промпта оценки (1:1 _ai_prompt discovery_eval L153–155).
|
||||
// keywords: Ключи задачи.
|
||||
private static string JoinKeywords(IEnumerable<string> keywords)
|
||||
=> string.Join(
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
namespace Deal.Ai.Llm;
|
||||
|
||||
/// <summary>
|
||||
/// Абстракция одного HTTP-вызова LLM-провайдера (план Task 7; seam для фейков тестов RPC-веток).
|
||||
/// Реализация по конфигу выбирает схему вызова: OpenAI-совместимые <c>POST {base}/chat/completions</c>
|
||||
/// (Bearer) либо Anthropic <c>POST {base}/v1/messages</c> (x-api-key + anthropic-version). Сетевые
|
||||
/// сбои/неожиданные ответы — <see cref="LlmHttpException"/> (одна попытка; ретраи — <see cref="ProviderCaller"/>).
|
||||
/// Абстракция одного HTTP-вызова LLM-провайдера.
|
||||
/// </summary>
|
||||
public interface IProviderClient
|
||||
{
|
||||
@@ -14,7 +11,6 @@ public interface IProviderClient
|
||||
/// <param name="config">Конфиг активного провайдера (стиль API выбирается по <c>ApiStyle</c>).</param>
|
||||
/// <param name="systemPrompt">Системный промпт (заполненный ядром либо фиксированный сервиса).</param>
|
||||
/// <param name="userText">Пользовательское сообщение/контекст.</param>
|
||||
/// <param name="cancellationToken">Токен отмены (deadline RPC).</param>
|
||||
/// <returns>Текст ответа модели и usage API-ответа (null — провайдер usage не вернул).</returns>
|
||||
public Task<ProviderChatResult> ChatAsync(
|
||||
LlmConfig config,
|
||||
|
||||
@@ -4,9 +4,7 @@ using System.Text.Json.Nodes;
|
||||
namespace Deal.Ai.Llm;
|
||||
|
||||
/// <summary>
|
||||
/// Извлечение JSON-объекта из ответа модели (план Task 7; 1:1 extract_json ai.py L175–183):
|
||||
/// снимается markdown-обёртка ```json … ```, затем берётся срез между первой «{» и последней «}»,
|
||||
/// результат парсится как объект. Любая аномалия — null (попытка считается неудачной и повторяется).
|
||||
/// Извлечение JSON-объекта из ответа модели
|
||||
/// </summary>
|
||||
public static class JsonExtractor
|
||||
{
|
||||
@@ -48,7 +46,6 @@ public static class JsonExtractor
|
||||
}
|
||||
}
|
||||
|
||||
// Снимает markdown-обёртку ```json … ``` (как в extract_json L177–179): возвращает содержимое
|
||||
// между открывающей и закрывающей обёртками; обёртки нет/незакрыта — null.
|
||||
// raw: Текст ответа (уже обрезанный).
|
||||
private static string? UnwrapFence(string raw)
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
namespace Deal.Ai.Llm;
|
||||
|
||||
/// <summary>
|
||||
/// Итоговая ошибка вызова после исчерпания ретраев (план Task 7: «ошибки → исключение с кодом»).
|
||||
/// Текст сообщения — 1:1 Ruling 5 / ai.py L115–117: «ИИ (имя) не ответил корректно — повторите
|
||||
/// попытку через несколько секунд»; RPC-слой отдаёт его как detail статуса UNAVAILABLE.
|
||||
/// Итоговая ошибка вызова после исчерпания ретраев.
|
||||
/// </summary>
|
||||
public sealed class LlmCallException : Exception
|
||||
{
|
||||
@@ -32,8 +30,7 @@ public sealed class LlmCallException : Exception
|
||||
public LlmCallFailureKind Kind { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Usage последней попытки, вернувшей текст модели: заполнен при <see cref="LlmCallFailureKind.AnswerNotJson"/>
|
||||
/// (Classify отвечает ok=false и всё равно несёт usage; Ruling 5).
|
||||
/// Usage последней попытки, вернувшей текст модели
|
||||
/// </summary>
|
||||
public LlmUsage? Usage { get; }
|
||||
}
|
||||
|
||||
@@ -1,19 +1,17 @@
|
||||
namespace Deal.Ai.Llm;
|
||||
|
||||
/// <summary>
|
||||
/// Причина исчерпания попыток вызова (Ruling 5 / README ai.proto L201–204): провайдер не ответил
|
||||
/// корректно (UNAVAILABLE) либо модель отвечала, но ни один ответ не разобран как JSON
|
||||
/// (Classify — ok=false, не RPC-ошибка).
|
||||
/// Причина исчерпания попыток вызова
|
||||
/// </summary>
|
||||
public enum LlmCallFailureKind
|
||||
{
|
||||
/// <summary>
|
||||
/// Провайдер не ответил после ретраев (сеть/таймаут/HTTP/пустой ответ) → UNAVAILABLE.
|
||||
/// Провайдер не ответил после ретраев
|
||||
/// </summary>
|
||||
ProviderUnavailable,
|
||||
|
||||
/// <summary>
|
||||
/// Модель отвечала текстом, но JSON не извлечён после ретраев (Classify → ok=false).
|
||||
/// Модель отвечала текстом, но JSON не извлечён после ретраев
|
||||
/// </summary>
|
||||
AnswerNotJson,
|
||||
}
|
||||
|
||||
@@ -3,15 +3,14 @@ using System.Text.Json.Nodes;
|
||||
namespace Deal.Ai.Llm;
|
||||
|
||||
/// <summary>
|
||||
/// Успешный результат вызова модели: извлечённый JSON-объект (схему задаёт промпт) и итоговая
|
||||
/// оценка токенов (usage API или оценка по символам).
|
||||
/// Успешный результат вызова модели
|
||||
/// </summary>
|
||||
/// <param name="Json">Корневой объект JSON-ответа модели.</param>
|
||||
/// <param name="Usage">Итоговая оценка токенов вызова.</param>
|
||||
public sealed record LlmCallResult(JsonObject Json, LlmUsage Usage)
|
||||
{
|
||||
/// <summary>
|
||||
/// Извлечённый ответ модели компактной json-строкой (ClassifyReply.json — маппинг в ядре).
|
||||
/// Извлечённый ответ модели компактной json-строкой
|
||||
/// </summary>
|
||||
public string JsonText => Json.ToJsonString();
|
||||
}
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
namespace Deal.Ai.Llm;
|
||||
|
||||
/// <summary>
|
||||
/// Эффективный конфиг LLM-провайдера на один вызов (план Task 7, Ruling 5): зеркало
|
||||
/// <c>ProviderConfig</c> из ai.proto. Ядро передаёт заполненный конфиг в теле каждого запроса
|
||||
/// (base_url/model/api_key расшифрованы, api_style — из каталога AiProviders); сервис настроек
|
||||
/// тенанта не хранит и не знает.
|
||||
/// Эффективный конфиг LLM-провайдера на один вызов
|
||||
/// </summary>
|
||||
public sealed record LlmConfig(
|
||||
string ProviderId,
|
||||
@@ -14,12 +11,10 @@ public sealed record LlmConfig(
|
||||
string? ApiStyle)
|
||||
{
|
||||
/// <summary>
|
||||
/// Значение api_style для Anthropic Messages API (пусто/иное — OpenAI-совместимый).
|
||||
/// Значение api_style для Anthropic Messages API
|
||||
/// </summary>
|
||||
public const string AnthropicApiStyle = "anthropic";
|
||||
|
||||
// Отображаемые имена известных провайдеров (1:1 каталог AiProviders констант python) — для
|
||||
// текста ошибки «ИИ (имя) …» (Ruling 5, ai.py L115–117). Неизвестный id — как есть.
|
||||
private static readonly IReadOnlyDictionary<string, string> KnownProviderNames =
|
||||
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
@@ -33,12 +28,12 @@ public sealed record LlmConfig(
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Истинно, когда конфиг задаёт Anthropic Messages API (x-api-key + anthropic-version).
|
||||
/// Истинно, когда конфиг задаёт Anthropic Messages API
|
||||
/// </summary>
|
||||
public bool IsAnthropic => string.Equals(ApiStyle, AnthropicApiStyle, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Имя провайдера для сообщений об ошибках и логов (ключ API в него не входит).
|
||||
/// Имя провайдера для сообщений об ошибках и логов
|
||||
/// </summary>
|
||||
public string DisplayName => KnownProviderNames.TryGetValue(ProviderId, out string? name)
|
||||
? name
|
||||
|
||||
@@ -5,12 +5,7 @@ using System.Text.Json.Nodes;
|
||||
namespace Deal.Ai.Llm;
|
||||
|
||||
/// <summary>
|
||||
/// HTTP-реализация <see cref="IProviderClient"/> (план Task 7; 1:1 ai.py _call_openai/_call_anthropic
|
||||
/// 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).
|
||||
/// HTTP-реализация <see cref="IProviderClient"/>
|
||||
/// </summary>
|
||||
public sealed class LlmHttpClient : IProviderClient
|
||||
{
|
||||
@@ -23,22 +18,17 @@ public sealed class LlmHttpClient : IProviderClient
|
||||
// Заголовок версии Anthropic API.
|
||||
private const string AnthropicVersionHeader = "anthropic-version";
|
||||
|
||||
// Значение версии Anthropic API (фиксированное, как в python).
|
||||
private const string AnthropicVersionValue = "2023-06-01";
|
||||
|
||||
// Заголовок ключа Anthropic API.
|
||||
private const string AnthropicApiKeyHeader = "x-api-key";
|
||||
|
||||
// Температура вызовов OpenAI-совместимых API (Ruling 5; как в ai.py L137).
|
||||
private const double Temperature = 0.2;
|
||||
|
||||
// Потолок токенов ответа (max_tokens; как в python для обоих стилей).
|
||||
private const int MaxResponseTokens = 8000;
|
||||
|
||||
// Таймаут одной попытки OpenAI-совместимого вызова (Ruling 5; 90 с).
|
||||
private static readonly TimeSpan OpenAiCallTimeout = TimeSpan.FromSeconds(90);
|
||||
|
||||
// Таймаут одной попытки Anthropic-вызова (Ruling 5; 60 с).
|
||||
private static readonly TimeSpan AnthropicCallTimeout = TimeSpan.FromSeconds(60);
|
||||
|
||||
private readonly HttpClient _httpClient;
|
||||
@@ -46,7 +36,7 @@ public sealed class LlmHttpClient : IProviderClient
|
||||
private readonly TimeSpan _anthropicCallTimeout;
|
||||
|
||||
/// <summary>
|
||||
/// Создаёт HTTP-клиент провайдеров с типовыми таймаутами (90/60 с).
|
||||
/// Создаёт HTTP-клиент провайдеров с типовыми таймаутами
|
||||
/// </summary>
|
||||
/// <param name="httpClient">HttpClient (регистрируется в DI; таймаут управляется на попытку).</param>
|
||||
public LlmHttpClient(HttpClient httpClient)
|
||||
@@ -69,12 +59,11 @@ public sealed class LlmHttpClient : IProviderClient
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Выполняет один вызов модели по выбранной схеме API (Ruling 5).
|
||||
/// Выполняет один вызов модели по выбранной схеме API.
|
||||
/// </summary>
|
||||
/// <param name="config">Конфиг провайдера (стиль — <c>ApiStyle</c>).</param>
|
||||
/// <param name="systemPrompt">Системный промпт.</param>
|
||||
/// <param name="userText">Пользовательское сообщение/контекст.</param>
|
||||
/// <param name="cancellationToken">Токен отмены (deadline RPC).</param>
|
||||
/// <returns>Текст ответа и usage API-ответа (null при его отсутствии).</returns>
|
||||
public async Task<ProviderChatResult> ChatAsync(
|
||||
LlmConfig config,
|
||||
@@ -109,7 +98,6 @@ public sealed class LlmHttpClient : IProviderClient
|
||||
}
|
||||
|
||||
// Собирает запрос OpenAI-совместимого чата: {base}/chat/completions, Bearer при заданном ключе,
|
||||
// тело 1:1 ai.py L126–139 (messages system/user, temperature 0.2, max_tokens 8000).
|
||||
// config: Конфиг провайдера.
|
||||
// systemPrompt: Системный промпт.
|
||||
// userText: Пользовательское сообщение.
|
||||
@@ -139,7 +127,6 @@ public sealed class LlmHttpClient : IProviderClient
|
||||
}
|
||||
|
||||
// Собирает запрос Anthropic Messages API: {base}/v1/messages, x-api-key + anthropic-version,
|
||||
// тело 1:1 ai.py L155–167 (system отдельным полем, messages=[user]).
|
||||
// config: Конфиг провайдера.
|
||||
// systemPrompt: Системный промпт.
|
||||
// userText: Пользовательское сообщение.
|
||||
@@ -184,7 +171,6 @@ public sealed class LlmHttpClient : IProviderClient
|
||||
return isAnthropic ? ReadAnthropicBody(body) : ReadOpenAiBody(body);
|
||||
}
|
||||
|
||||
// Разбирает OpenAI-совместимый ответ: choices[0].message.content (+ usage; ai.py L143–152).
|
||||
// body: Тело ответа.
|
||||
private static ProviderChatResult ReadOpenAiBody(string body)
|
||||
{
|
||||
@@ -201,14 +187,12 @@ public sealed class LlmHttpClient : IProviderClient
|
||||
string? content = ReadStringField(message, "content");
|
||||
if (string.IsNullOrEmpty(content) && !string.IsNullOrEmpty(ReadStringField(message, "reasoning_content")))
|
||||
{
|
||||
// Модель «подумала», но ответа не дала (переполнение/обрыв) — сбой, пробуем ещё раз (ai.py L149–151).
|
||||
throw new LlmHttpException("Модель вернула только reasoning без ответа");
|
||||
}
|
||||
|
||||
return new ProviderChatResult(content ?? string.Empty, ReadOpenAiUsage(payload["usage"]));
|
||||
}
|
||||
|
||||
// Разбирает Anthropic-ответ: склейка text блоков content[] (+ usage input/output; ai.py L168–172).
|
||||
// body: Тело ответа.
|
||||
private static ProviderChatResult ReadAnthropicBody(string body)
|
||||
{
|
||||
@@ -281,7 +265,6 @@ public sealed class LlmHttpClient : IProviderClient
|
||||
throw UnexpectedApiResponse("тело не является JSON-объектом");
|
||||
}
|
||||
|
||||
// Собирает текст ошибки неожиданного ответа: только тип/причина, без содержимого тела (Ruling 13).
|
||||
// failureKind: Короткая причина (без тела ответа и секретов).
|
||||
private static LlmHttpException UnexpectedApiResponse(string failureKind)
|
||||
=> new($"Неожиданный ответ ИИ-провайдера: {failureKind}");
|
||||
@@ -327,7 +310,6 @@ public sealed class LlmHttpClient : IProviderClient
|
||||
return result;
|
||||
}
|
||||
|
||||
// Убирает хвостовые «/» базового URL (как ai.py L90: rstrip("/")).
|
||||
// baseUrl: Базовый URL из конфига.
|
||||
private static string NormalizeBaseUrl(string baseUrl) => baseUrl.TrimEnd('/');
|
||||
}
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
namespace Deal.Ai.Llm;
|
||||
|
||||
/// <summary>
|
||||
/// Ошибка одной HTTP-попытки вызова провайдера (план Task 7): сетевой сбой, таймаут, HTTP-ошибка
|
||||
/// или неожиданная форма ответа API. Обрабатывается в <see cref="ProviderCaller"/> как повод для
|
||||
/// ретрая; текст внутренний (ключи/секреты и тело ответа в него не попадают — Ruling 13).
|
||||
/// Ошибка одной HTTP-попытки вызова провайдера
|
||||
/// </summary>
|
||||
public sealed class LlmHttpException : Exception
|
||||
{
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
namespace Deal.Ai.Llm;
|
||||
|
||||
/// <summary>
|
||||
/// Политика ретраев вызова LLM (план Task 7; ai.py chat_json L96–117): max_retries=2 → всего 3
|
||||
/// попытки с нарастающими паузами 0.8 с и 2 с между ними. Разовый сбой (перегрузка API, пустой/
|
||||
/// не-JSON ответ) не должен превращаться в «ИИ недоступен» без повторных попыток.
|
||||
/// Политика ретраев вызова LLM
|
||||
/// </summary>
|
||||
public static class LlmRetryPolicy
|
||||
{
|
||||
/// <summary>
|
||||
/// Число дополнительных попыток после первой (всего — <see cref="AttemptCount"/>).
|
||||
/// Число дополнительных попыток после первой
|
||||
/// </summary>
|
||||
public const int RetryCount = 2;
|
||||
|
||||
@@ -18,7 +16,7 @@ public static class LlmRetryPolicy
|
||||
public const int AttemptCount = RetryCount + 1;
|
||||
|
||||
/// <summary>
|
||||
/// Паузы между попытками: 0.8 с (после 1-й) и 2 с (после 2-й).
|
||||
/// Паузы между попытками
|
||||
/// </summary>
|
||||
public static readonly IReadOnlyList<TimeSpan> RetryDelays =
|
||||
[
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
namespace Deal.Ai.Llm;
|
||||
|
||||
/// <summary>
|
||||
/// Итоговая оценка токенов вызова для gRPC-ответа (Ruling 5): берётся из usage API-ответа
|
||||
/// провайдера, при его отсутствии оценивается по символам (≈chars/4). Ядро копит значения
|
||||
/// в tenant-KV aiTokenUsage.
|
||||
/// Итоговая оценка токенов вызова для gRPC-ответа
|
||||
/// </summary>
|
||||
/// <param name="PromptTokens">Токены запроса (система + пользователь).</param>
|
||||
/// <param name="CompletionTokens">Токены ответа модели.</param>
|
||||
|
||||
@@ -3,12 +3,7 @@ using System.Text.Json.Nodes;
|
||||
namespace Deal.Ai.Llm;
|
||||
|
||||
/// <summary>
|
||||
/// Оркестратор вызова модели с ретраями и извлечением JSON (план Task 7; 1:1 chat_json ai.py
|
||||
/// 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).
|
||||
/// Оркестратор вызова модели с ретраями и извлечением JSON
|
||||
/// </summary>
|
||||
public sealed class ProviderCaller
|
||||
{
|
||||
@@ -38,7 +33,6 @@ public sealed class ProviderCaller
|
||||
/// <param name="config">Конфиг активного провайдера.</param>
|
||||
/// <param name="systemPrompt">Системный промпт (заполненный ядром или фиксированный сервиса).</param>
|
||||
/// <param name="userText">Пользовательское сообщение/контекст.</param>
|
||||
/// <param name="cancellationToken">Токен отмены (deadline RPC).</param>
|
||||
/// <returns>Извлечённый JSON-объект и итоговую оценку токенов.</returns>
|
||||
/// <exception cref="LlmCallException">Все попытки исчерпаны (см. <see cref="LlmCallFailureKind"/>).</exception>
|
||||
public async Task<LlmCallResult> ChatJsonAsync(
|
||||
@@ -82,7 +76,6 @@ public sealed class ProviderCaller
|
||||
|
||||
if (lastModelText is not null)
|
||||
{
|
||||
// Модель отвечала текстом, но ни одна попытка не дала разбираемый JSON (README ai.proto L201–204).
|
||||
LlmUsage usage = TokenEstimator.Resolve(lastUsage, promptText, lastModelText);
|
||||
throw new LlmCallException(LlmCallFailureKind.AnswerNotJson, config.DisplayName, usage);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
namespace Deal.Ai.Llm;
|
||||
|
||||
/// <summary>
|
||||
/// Результат одной успешной HTTP-попытки вызова модели (текст ответа + usage API).
|
||||
/// Результат одной успешной HTTP-попытки вызова модели
|
||||
/// </summary>
|
||||
/// <param name="Text">Текст ответа модели (может быть не-JSON — разбор в <see cref="JsonExtractor"/>).</param>
|
||||
/// <param name="Usage">Usage из API-ответа провайдера; null — провайдер его не вернул (оценка по символам).</param>
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
namespace Deal.Ai.Llm;
|
||||
|
||||
/// <summary>
|
||||
/// Usage токенов из API-ответа провайдера (Ruling 5). Поля не путать с <see cref="LlmUsage"/>:
|
||||
/// здесь «как отдал провайдер» (total берём как есть — у провайдера он может отличаться от суммы),
|
||||
/// финальную оценку/подстановку делает <see cref="TokenEstimator"/>.
|
||||
/// Usage токенов из API-ответа провайдера.
|
||||
/// </summary>
|
||||
/// <param name="PromptTokens">Токены запроса (система + пользователь).</param>
|
||||
/// <param name="CompletionTokens">Токены ответа модели.</param>
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
namespace Deal.Ai.Llm;
|
||||
|
||||
/// <summary>
|
||||
/// Оценка токенов вызова (план Task 7, Ruling 5): при отсутствии usage в API-ответе токены
|
||||
/// оцениваются по символам ≈ chars/4 (округление вверх). Запрос = system + user, ответ = текст модели.
|
||||
/// Оценка токенов вызова
|
||||
/// </summary>
|
||||
public static class TokenEstimator
|
||||
{
|
||||
// Примерное число символов на один токен (Ruling 5: «≈chars/4»).
|
||||
private const int EstimatedCharsPerToken = 4;
|
||||
|
||||
/// <summary>
|
||||
/// Сводит usage вызова: usage провайдера как есть (total «берём как есть»), при отсутствии —
|
||||
/// оценка по длинам промпта и ответа.
|
||||
/// Сводит usage вызова
|
||||
/// </summary>
|
||||
/// <param name="providerUsage">Usage из API-ответа (null — провайдер его не вернул).</param>
|
||||
/// <param name="promptText">Полный текст запроса (система + пользователь) для оценки.</param>
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
// ai-service — точка входа gRPC-хоста (план Task 4/7/8; Ruling 1/2/5/12).
|
||||
//
|
||||
// Kestrel HTTP/2 на порту 5102 (env GRPC_PORT, затем PORT) + AddGrpc с интерцепторами service-token
|
||||
// и access-лога + стандартный gRPC-health (grpc.health.v1.Health). Транспорт: dev — plaintext
|
||||
// (Ruling 2); mTLS (TLS + клиентский сертификат) — при DEAL_MTLS_ENABLED=1 (Ruling 6, план Task 13;
|
||||
// сертификаты deploy/certs — scripts/mtls-certs.sh, env передаёт compose-prod Task 14); fail-closed:
|
||||
// Production без mTLS не стартует (GrpcHostEnvironment.RequireMtlsInProduction).
|
||||
// Серверная обвязка (Kestrel/AddGrpc/health) — общий Deal.Grpc.Hosting (C31): хост-фабрика
|
||||
// AiServiceHost.Create используется и интеграционными тестами (Deal.Ai.Tests), которые поднимают
|
||||
// его в своём процессе на эфемерном порту. Методы AiService (Filter/Classify/GenerateKeywords/
|
||||
// EvaluateFit) реализованы поверх LLM-фасада (OpenAI-совместимые + Anthropic, без БД — Ruling 5;
|
||||
// задачи 7–8): фасад живёт в Deal.Ai/Llm.
|
||||
|
||||
using Deal.Ai;
|
||||
@@ -17,17 +13,13 @@ using Deal.Grpc.Hosting.Models;
|
||||
using Deal.Grpc.Hosting.Options;
|
||||
using Deal.Grpc.Hosting.Services;
|
||||
|
||||
// Порт по умолчанию — 5102 (Ruling 12, compose.dev.yml); переопределяется env GRPC_PORT (контейнер)
|
||||
// или PORT (общий конвенциональный env хостинг-платформ) — см. GrpcHostEnvironment.ResolveGrpcPort.
|
||||
const int defaultGrpcPort = 5102;
|
||||
// Имя процесса для rolling-файла логов (Ruling 7, Task 14): data/logs/deal-ai-<дата>.json.
|
||||
const string aiProcessName = "ai";
|
||||
|
||||
int grpcPort = GrpcHostEnvironment.ResolveGrpcPort(defaultGrpcPort);
|
||||
// Порт эндпоинта метрик /metrics (HTTP/1.1, отдельно от gRPC HTTP/2; этап 12, пакет A).
|
||||
int metricsPort = DealMetricsHosting.ResolveMetricsPort(DealMetricsHosting.DefaultMetricsPort);
|
||||
|
||||
// Serilog (Ruling 7, план Task 14): JSON-консоль + rolling-файл data/logs/deal-ai-*.json —
|
||||
// конфигурируется production-точкой входа через configureBuilder-хук хоста (тесты хост поднимают
|
||||
// без Serilog, DealLogging.Configure в AiServiceHost/Create вызывается только здесь). Метрики
|
||||
// (OTel → Prometheus, /metrics) — тем же хуком до builder.Build().
|
||||
@@ -39,10 +31,8 @@ WebApplication app = AiServiceHost.Create(
|
||||
DealMetricsHosting.AddDealMetrics(builder, metricsPort);
|
||||
});
|
||||
|
||||
// Эндпоинт метрик /metrics (HTTP/1.1 на отдельном порту): формат Prometheus (этап 12, пакет A).
|
||||
DealMetricsHosting.MapDealMetrics(app);
|
||||
|
||||
// Режим транспорта — из тех же env, что читал хост (Ruling 6, Task 13): mTLS при DEAL_MTLS_ENABLED=1.
|
||||
MtlsOptions mtlsOptions = MtlsOptions.FromConfiguration(app.Configuration);
|
||||
|
||||
// Fail-closed (замечание code-review): отсутствие/опечатка DEAL_MTLS_ENABLED не должны давать
|
||||
|
||||
+138
-172
@@ -1,172 +1,138 @@
|
||||
// ai.proto — контракт между ядром Deal и ai-service (этап 6).
|
||||
//
|
||||
// ai-service — фасад LLM-провайдеров без БД (Ruling 5, дизайн-док §7.3):
|
||||
// ядро передаёт в теле каждого запроса готовые (заполненные) промпты и/или
|
||||
// текст + конфиг активного провайдера (ProviderConfig); сервис вызывает
|
||||
// провайдера (OpenAI-совместимые POST {base}/chat/completions, Anthropic
|
||||
// POST {base}/v1/messages; temperature 0.2, таймауты 90/60 с, retry 2 с
|
||||
// паузами 0.8/2 с) и возвращает ответ + оценку токенов. Конфиг провайдера
|
||||
// (id/base/model/apiKey/api_style) ядро кладёт в поле provider_config каждого
|
||||
// запроса — сервис настроек тенанта не знает и не хранит.
|
||||
//
|
||||
// Каждый RPC обязан нести в gRPC-metadata два заголовка (Ruling 1):
|
||||
// tenant-id — id тенанта (строка; учёт токенов в ядре по нему);
|
||||
// service-token — общий токен сервисов (env DEAL_SERVICE_TOKEN); неверный/
|
||||
// пустой → UNAUTHENTICATED.
|
||||
//
|
||||
// Ошибки домена — gRPC-статусы (Ruling 1/5):
|
||||
// INVALID_ARGUMENT — невалидный запрос (пустой текст/промпт и т.п.);
|
||||
// UNAVAILABLE — провайдер не ответил корректно после ретраев; detail =
|
||||
// «ИИ (имя) не ответил корректно — повторите попытку через
|
||||
// несколько секунд» (ядро падает в локальный разбор).
|
||||
//
|
||||
// Учёт токенов (Ruling 5): каждый reply несёт usage{prompt/completion/total}.
|
||||
// Берётся из usage API-ответа провайдера; при отсутствии оценивается по
|
||||
// символам (≈chars/4). Ядро копит значения в tenant-KV aiTokenUsage.
|
||||
//
|
||||
// Deadlines (клиент ядра): все RPC — 120 с (90 с провайдер + ретраи 0.8/2 с;
|
||||
// при недоступности ядро не ждёт повторно — Ruling 6 кэш/фолбэк).
|
||||
syntax = "proto3";
|
||||
|
||||
package deal.ai.v1;
|
||||
|
||||
option csharp_namespace = "Deal.Grpc.Ai";
|
||||
|
||||
service AiService {
|
||||
// ИИ-фильтр входящих сообщений (ai.py filter_incoming L188–198, Ruling 5):
|
||||
// ядро шлёт заполненный aiFilterPrompt (fill_prompt делает ядро) и текст;
|
||||
// решение {pass, reason}. Ветку «фильтр не применялся» (aiFilterEnabled/
|
||||
// недоступность) обрабатывает ядро до вызова — сервис всегда отвечает.
|
||||
rpc Filter(FilterRequest) returns (FilterReply);
|
||||
|
||||
// Полный разбор лида (ai.py classify L218–258, Ruling 5): ядро собирает
|
||||
// system_prompt = заполненные aiPrompt + cardPrompt и user-контекст
|
||||
// «Доски + примеры разметки + Сообщение»; сервис возвращает извлечённый
|
||||
// ответ модели как json-строку (типовую схему задаёт промпт). Строгий
|
||||
// маппинг json → AiParsedCardDto делает ядро (1:1 normalize_stack/
|
||||
// clean_budget/build_contacts).
|
||||
rpc Classify(ClassifyRequest) returns (ClassifyReply);
|
||||
|
||||
// Генерация ключевых слов для discovery-задачи по описанию (фикс. промпт
|
||||
// discovery_routes L36–47 + описание; Ruling 5): ответ {keywords}. Очистку
|
||||
// (_clean_keywords: ≤30, ≤60 симв., дедуп) и мягкие ошибки делает ядро.
|
||||
rpc GenerateKeywords(GenerateKeywordsRequest) returns (GenerateKeywordsReply);
|
||||
|
||||
// Оценка соответствия сообщения задаче поиска (промпт discovery_eval
|
||||
// L50–54; Ruling 5/10): текст + описание + ключи задачи → {fit, reason}.
|
||||
// Ядро зовёт только при aiEnabled; сбой/не-JSON — фолбэк на эвристику.
|
||||
rpc EvaluateFit(EvaluateFitRequest) returns (EvaluateFitReply);
|
||||
}
|
||||
|
||||
// --- Запросы/ответы AiService ---
|
||||
|
||||
// Конфиг активного LLM-провайдера на запрос (Ruling 5: ядро расшифровывает
|
||||
// aiConfigs и передаёт в теле каждого запроса; сервис не хранит настроек).
|
||||
// Форма 1:1 с эффективным конфигом core: настройка aiConfigs тенанта хранит
|
||||
// {apiKey, baseUrl, model} (camelCase; apiKey шифруется AES-GCM этапа 2),
|
||||
// api_style — из каталога AiProviders (Settings); HTTP-клиенту провайдера
|
||||
// нужны baseUrl+model+apiKey для запроса и api_style для выбора схемы вызова
|
||||
// (OpenAI-совместимые chat/completions vs Anthropic Messages API).
|
||||
message ProviderConfig {
|
||||
// Id провайдера (ключ aiConfigs / каталога AiProviders: deepseek, openai,
|
||||
// anthropic, ollama, lmstudio, custom…).
|
||||
string provider_id = 1;
|
||||
// Эффективный базовый URL API (aiConfigs.baseUrl или дефолт каталога).
|
||||
string base_url = 2;
|
||||
// API-ключ открытым текстом (расшифрован ядром); пуст для локальных
|
||||
// провайдеров (ollama/lmstudio) — заголовок авторизации не шлётся.
|
||||
optional string api_key = 3;
|
||||
// Активная модель (aiConfigs.model или первая из каталога провайдера).
|
||||
string model = 4;
|
||||
// Стиль API: пуст — OpenAI-совместимый (POST {base}/chat/completions,
|
||||
// Bearer); "anthropic" — Messages API (POST {base}/v1/messages,
|
||||
// x-api-key + anthropic-version).
|
||||
optional string api_style = 5;
|
||||
}
|
||||
|
||||
message FilterRequest {
|
||||
// Заполненный промпт фильтра (настройка aiFilterPrompt с подстановкой
|
||||
// {domain}/{keywords} — делает ядро; Ruling 5).
|
||||
string prompt = 1;
|
||||
// Текст сообщения (ядро обрезает до 4000, как ai.py L193).
|
||||
string text = 2;
|
||||
// Конфиг активного провайдера (Ruling 5; все запросы ai-сервиса).
|
||||
ProviderConfig provider_config = 3;
|
||||
}
|
||||
|
||||
message FilterReply {
|
||||
// True — сообщение проходит фильтр (не спам/реклама/служебное).
|
||||
bool pass = 1;
|
||||
// Причина отказа при pass=false (текст ветки filter_ai; пуст при пропуске).
|
||||
optional string reason = 2;
|
||||
// Оценка токенов вызова (Ruling 5).
|
||||
Usage usage = 3;
|
||||
}
|
||||
|
||||
message ClassifyRequest {
|
||||
// system_prompt = заполненные aiPrompt + cardPrompt (структура карточки,
|
||||
// «О заявке»; собирает ядро — Ruling 5).
|
||||
string system_prompt = 1;
|
||||
// user-контекст: «Доски + примеры разметки + Новое сообщение» (собирает
|
||||
// ядро, 1:1 classify L243–251).
|
||||
string user_context = 2;
|
||||
// Конфиг активного провайдера (Ruling 5; все запросы ai-сервиса).
|
||||
ProviderConfig provider_config = 3;
|
||||
}
|
||||
|
||||
message ClassifyReply {
|
||||
// True — модель вернула разбираемый JSON (ok=false — ответ без JSON после
|
||||
// ретраев; ядро трактует как «не разобрано» и падает в локальный путь).
|
||||
bool ok = 1;
|
||||
// Сырой JSON-ответ модели (строкой; маппинг в карточку — в ядре).
|
||||
optional string json = 2;
|
||||
// Оценка токенов вызова (Ruling 5).
|
||||
Usage usage = 3;
|
||||
}
|
||||
|
||||
message GenerateKeywordsRequest {
|
||||
// Описание ниши/задачи (ядро обрезает до 4000, discovery_routes L29).
|
||||
string description = 1;
|
||||
// Конфиг активного провайдера (Ruling 5; все запросы ai-сервиса).
|
||||
ProviderConfig provider_config = 2;
|
||||
}
|
||||
|
||||
message GenerateKeywordsReply {
|
||||
// Сгенерированные ключи (пустой список — модель не выделила ключи;
|
||||
// чистку/дедуп и мягкую ошибку для UI делает ядро — Ruling 11).
|
||||
repeated string keywords = 1;
|
||||
// Оценка токенов вызова (Ruling 5).
|
||||
Usage usage = 2;
|
||||
}
|
||||
|
||||
message EvaluateFitRequest {
|
||||
// Текст сообщения для оценки (выборка кандидата; ядро ограничивает 4000).
|
||||
string text = 1;
|
||||
// Описание задачи поиска (discovery_eval L51).
|
||||
string description = 2;
|
||||
// Ключи задачи (discovery_eval L52; подставляются в промпт сервисом).
|
||||
repeated string keywords = 3;
|
||||
// Конфиг активного провайдера (Ruling 5; все запросы ai-сервиса).
|
||||
ProviderConfig provider_config = 4;
|
||||
}
|
||||
|
||||
message EvaluateFitReply {
|
||||
// True — сообщение относится к сфере/задаче (JSON {"fit": 0|1}).
|
||||
bool fit = 1;
|
||||
// Краткая причина решения модели (пуст, если модель её не дала).
|
||||
optional string reason = 2;
|
||||
// Оценка токенов вызова (Ruling 5).
|
||||
Usage usage = 3;
|
||||
}
|
||||
|
||||
// Оценка токенов вызова провайдера (Ruling 5: usage{prompt/completion/total};
|
||||
// из usage API-ответа, при отсутствии — по символам ≈chars/4).
|
||||
message Usage {
|
||||
// Токены запроса (system + user).
|
||||
uint32 prompt = 1;
|
||||
// Токены ответа модели.
|
||||
uint32 completion = 2;
|
||||
// Суммарно (prompt + completion; может отличаться от суммы при подсчёте
|
||||
// провайдером — берём как есть).
|
||||
uint32 total = 3;
|
||||
}
|
||||
//
|
||||
// ядро передаёт в теле каждого запроса готовые (заполненные) промпты и/или
|
||||
// текст + конфиг активного провайдера (ProviderConfig); сервис вызывает
|
||||
// провайдера (OpenAI-совместимые POST {base}/chat/completions, Anthropic
|
||||
// POST {base}/v1/messages; temperature 0.2, таймауты 90/60 с, retry 2 с
|
||||
// паузами 0.8/2 с) и возвращает ответ + оценку токенов. Конфиг провайдера
|
||||
// (id/base/model/apiKey/api_style) ядро кладёт в поле provider_config каждого
|
||||
// запроса — сервис настроек тенанта не знает и не хранит.
|
||||
//
|
||||
// tenant-id — id тенанта (строка; учёт токенов в ядре по нему);
|
||||
// service-token — общий токен сервисов (env DEAL_SERVICE_TOKEN); неверный/
|
||||
// пустой → UNAUTHENTICATED.
|
||||
//
|
||||
// INVALID_ARGUMENT — невалидный запрос (пустой текст/промпт и т.п.);
|
||||
// UNAVAILABLE — провайдер не ответил корректно после ретраев; detail =
|
||||
// «ИИ (имя) не ответил корректно — повторите попытку через
|
||||
// несколько секунд» (ядро падает в локальный разбор).
|
||||
//
|
||||
// Берётся из usage API-ответа провайдера; при отсутствии оценивается по
|
||||
// символам (≈chars/4). Ядро копит значения в tenant-KV aiTokenUsage.
|
||||
//
|
||||
// Deadlines (клиент ядра): все RPC — 120 с (90 с провайдер + ретраи 0.8/2 с;
|
||||
syntax = "proto3";
|
||||
|
||||
package deal.ai.v1;
|
||||
|
||||
option csharp_namespace = "Deal.Grpc.Ai";
|
||||
|
||||
service AiService {
|
||||
// ядро шлёт заполненный aiFilterPrompt (fill_prompt делает ядро) и текст;
|
||||
// решение {pass, reason}. Ветку «фильтр не применялся» (aiFilterEnabled/
|
||||
// недоступность) обрабатывает ядро до вызова — сервис всегда отвечает.
|
||||
rpc Filter(FilterRequest) returns (FilterReply);
|
||||
|
||||
// «Доски + примеры разметки + Сообщение»; сервис возвращает извлечённый
|
||||
// ответ модели как json-строку (типовую схему задаёт промпт). Строгий
|
||||
// clean_budget/build_contacts).
|
||||
rpc Classify(ClassifyRequest) returns (ClassifyReply);
|
||||
|
||||
// Генерация ключевых слов для discovery-задачи по описанию (фикс. промпт
|
||||
rpc GenerateKeywords(GenerateKeywordsRequest) returns (GenerateKeywordsReply);
|
||||
|
||||
// Оценка соответствия сообщения задаче поиска (промпт discovery_eval
|
||||
// Ядро зовёт только при aiEnabled; сбой/не-JSON — фолбэк на эвристику.
|
||||
rpc EvaluateFit(EvaluateFitRequest) returns (EvaluateFitReply);
|
||||
}
|
||||
|
||||
// --- Запросы/ответы AiService ---
|
||||
|
||||
// aiConfigs и передаёт в теле каждого запроса; сервис не хранит настроек).
|
||||
// api_style — из каталога AiProviders (Settings); HTTP-клиенту провайдера
|
||||
// нужны baseUrl+model+apiKey для запроса и api_style для выбора схемы вызова
|
||||
// (OpenAI-совместимые chat/completions vs Anthropic Messages API).
|
||||
message ProviderConfig {
|
||||
// Id провайдера (ключ aiConfigs / каталога AiProviders: deepseek, openai,
|
||||
// anthropic, ollama, lmstudio, custom…).
|
||||
string provider_id = 1;
|
||||
// Эффективный базовый URL API (aiConfigs.baseUrl или дефолт каталога).
|
||||
string base_url = 2;
|
||||
// API-ключ открытым текстом (расшифрован ядром); пуст для локальных
|
||||
// провайдеров (ollama/lmstudio) — заголовок авторизации не шлётся.
|
||||
optional string api_key = 3;
|
||||
// Активная модель (aiConfigs.model или первая из каталога провайдера).
|
||||
string model = 4;
|
||||
// Стиль API: пуст — OpenAI-совместимый (POST {base}/chat/completions,
|
||||
// Bearer); "anthropic" — Messages API (POST {base}/v1/messages,
|
||||
// x-api-key + anthropic-version).
|
||||
optional string api_style = 5;
|
||||
}
|
||||
|
||||
message FilterRequest {
|
||||
// Заполненный промпт фильтра (настройка aiFilterPrompt с подстановкой
|
||||
string prompt = 1;
|
||||
string text = 2;
|
||||
ProviderConfig provider_config = 3;
|
||||
}
|
||||
|
||||
message FilterReply {
|
||||
// True — сообщение проходит фильтр (не спам/реклама/служебное).
|
||||
bool pass = 1;
|
||||
// Причина отказа при pass=false (текст ветки filter_ai; пуст при пропуске).
|
||||
optional string reason = 2;
|
||||
Usage usage = 3;
|
||||
}
|
||||
|
||||
message ClassifyRequest {
|
||||
string system_prompt = 1;
|
||||
// user-контекст: «Доски + примеры разметки + Новое сообщение» (собирает
|
||||
string user_context = 2;
|
||||
ProviderConfig provider_config = 3;
|
||||
}
|
||||
|
||||
message ClassifyReply {
|
||||
// True — модель вернула разбираемый JSON (ok=false — ответ без JSON после
|
||||
// ретраев; ядро трактует как «не разобрано» и падает в локальный путь).
|
||||
bool ok = 1;
|
||||
// Сырой JSON-ответ модели (строкой; маппинг в карточку — в ядре).
|
||||
optional string json = 2;
|
||||
Usage usage = 3;
|
||||
}
|
||||
|
||||
message GenerateKeywordsRequest {
|
||||
string description = 1;
|
||||
ProviderConfig provider_config = 2;
|
||||
}
|
||||
|
||||
message GenerateKeywordsReply {
|
||||
// Сгенерированные ключи (пустой список — модель не выделила ключи;
|
||||
repeated string keywords = 1;
|
||||
Usage usage = 2;
|
||||
}
|
||||
|
||||
message EvaluateFitRequest {
|
||||
// Текст сообщения для оценки (выборка кандидата; ядро ограничивает 4000).
|
||||
string text = 1;
|
||||
string description = 2;
|
||||
repeated string keywords = 3;
|
||||
ProviderConfig provider_config = 4;
|
||||
}
|
||||
|
||||
message EvaluateFitReply {
|
||||
// True — сообщение относится к сфере/задаче (JSON {"fit": 0|1}).
|
||||
bool fit = 1;
|
||||
// Краткая причина решения модели (пуст, если модель её не дала).
|
||||
optional string reason = 2;
|
||||
Usage usage = 3;
|
||||
}
|
||||
|
||||
// из usage API-ответа, при отсутствии — по символам ≈chars/4).
|
||||
message Usage {
|
||||
// Токены запроса (system + user).
|
||||
uint32 prompt = 1;
|
||||
// Токены ответа модели.
|
||||
uint32 completion = 2;
|
||||
// Суммарно (prompt + completion; может отличаться от суммы при подсчёте
|
||||
// провайдером — берём как есть).
|
||||
uint32 total = 3;
|
||||
}
|
||||
|
||||
+127
-147
@@ -1,147 +1,127 @@
|
||||
// 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).
|
||||
// Модель per-tenant: пул в ml-service, файл SQLite data/ml/<tenantId>.sqlite
|
||||
// (Ruling 4). Обучение ядро шлёт батчами из очереди ml_outbox
|
||||
// (MlOutboxFlushScheduler, Ruling 6).
|
||||
//
|
||||
// Каждый RPC обязан нести в gRPC-metadata два заголовка (Ruling 1):
|
||||
// tenant-id — id тенанта (строка; модель тенанта — в пуле сервиса);
|
||||
// service-token — общий токен сервисов (env DEAL_SERVICE_TOKEN); неверный/
|
||||
// пустой → UNAUTHENTICATED.
|
||||
//
|
||||
// Ошибки домена — gRPC-статусы с detail = текст причины 1:1 (Ruling 1):
|
||||
// INVALID_ARGUMENT — невалидный запрос (пустой text и т.п.);
|
||||
// UNAVAILABLE — хранилище модели недоступно (ядро отвечает «не уверен»,
|
||||
// Ruling 6 — кэш reachable 15 с).
|
||||
//
|
||||
// Семантика неготовой модели: Predict НЕ ошибка — модель без опыта отвечает
|
||||
// фиксированным «не уверен»: take=false, label пуст, scores пуст, hits=0,
|
||||
// ready=false, margin пуст, terms пуст, type пуст (Ruling 5 этапа 2, 1:1).
|
||||
//
|
||||
// Deadlines (клиент ядра): Predict — 5 с; Status/Reset — 10 с; TrainBatch — 30 с
|
||||
// (батч ≤100 примеров, одна транзакция).
|
||||
syntax = "proto3";
|
||||
|
||||
package deal.ml.v1;
|
||||
|
||||
option csharp_namespace = "Deal.Grpc.Ml";
|
||||
|
||||
service MlService {
|
||||
// Предсказание по тексту сообщения (model.py predict L184–293).
|
||||
// take/label/scores/hits/margin/terms/type осмысленны только при take=true;
|
||||
// scores — до 5 лучших «класс → вес» (round 3), margin — адаптивный порог
|
||||
// (0.9/0.7/0.5/0.35 после 0/60/150/400 примеров), terms — узнанные термины
|
||||
// класса-победителя, type — решение о типе заявки (t:hire/t:order).
|
||||
rpc Predict(PredictRequest) returns (PredictReply);
|
||||
|
||||
// Статус модели тенанта (model.py status L325–345): ready/classes/learned/eval.
|
||||
// classes — «label → вес» (round 2, по убыванию); eval — самооценка по окну
|
||||
// последних подтверждённых решений (EVAL_WINDOW). Модель создаётся лениво
|
||||
// по первому обращению (Ruling 4) — отсутствие опыта это НЕ ошибка.
|
||||
rpc Status(StatusRequest) returns (StatusReply);
|
||||
|
||||
// Полный сброс модели тенанта (model.py reset L348–354): очистка классов,
|
||||
// терминов и журнала самооценки + пересоздание файла. Ok=true; мягкая
|
||||
// ошибка — Ok=false + error (ядро чистит свою ml_outbox только при успехе).
|
||||
rpc Reset(ResetRequest) returns (ResetReply);
|
||||
|
||||
// Пакетное обучение (model.py learn_batch L147–173): одна транзакция +
|
||||
// пакетные вставки терминов; самооценка по действиям пользователя (delta=1,
|
||||
// не t:*) до применения. Ответ — число применённых примеров.
|
||||
rpc TrainBatch(TrainBatchRequest) returns (TrainBatchReply);
|
||||
}
|
||||
|
||||
message PredictRequest {
|
||||
// Текст сообщения (ядро передаёт уже обрезанный/нормализованный, как
|
||||
// ml_routes.py L86–90; пустой/пробельный — не ошибка: ответ «не уверен»).
|
||||
string text = 1;
|
||||
}
|
||||
|
||||
message PredictReply {
|
||||
// True — модель уверена (take) и решение можно использовать без ИИ.
|
||||
bool take = 1;
|
||||
// Класс решения: id колонки канбана (b_…) или "spam"; пуст, если не уверена.
|
||||
optional string label = 2;
|
||||
// Веса классов: «label → вес» (до 5 лучших; пуст у неготовой модели).
|
||||
map<string, double> scores = 3;
|
||||
// Сколько терминов класса-победителя модель узнала в тексте.
|
||||
int32 hits = 4;
|
||||
// Модель обучена (набрала MIN_TOTAL/MIN_WINNER/MIN_WINNER_SPAM) и может
|
||||
// принимать решения.
|
||||
bool ready = 5;
|
||||
// Порог уверенности решения (адаптивный margin, 2 знака); пуст — нет решения.
|
||||
optional double margin = 6;
|
||||
// Узнанные термины класса-победителя (подсказка структуры карточки, ≤8).
|
||||
repeated string terms = 7;
|
||||
// Решение о типе заявки (hire/order); пуст — модель тип не определила.
|
||||
TypeDecision type = 8;
|
||||
}
|
||||
|
||||
// Решение ML о типе заявки (predict L233–238; MlTypeDecisionDto).
|
||||
message TypeDecision {
|
||||
// True — модель уверена в типе.
|
||||
bool take = 1;
|
||||
// Тип: "hire" | "order".
|
||||
string label = 2;
|
||||
// Внутренний класс ML: "t:hire" | "t:order" (не показывается UI).
|
||||
string value = 3;
|
||||
// Запас уверенности (margin, 2 знака).
|
||||
double margin = 4;
|
||||
}
|
||||
|
||||
message StatusRequest {}
|
||||
|
||||
message StatusReply {
|
||||
// Модель готова принимать решения.
|
||||
bool ready = 1;
|
||||
// Классы модели: «label → вес» (round 2; пуст, пока нет обучения).
|
||||
map<string, double> classes = 2;
|
||||
// Всего примеров, на которых модель обучалась (сумма по классам).
|
||||
int32 learned = 3;
|
||||
// Самооценка модели по последним подтверждённым решениям.
|
||||
ModelEval eval = 4;
|
||||
}
|
||||
|
||||
// Окно самооценки модели (model.py status L329–339; MlEvalDto).
|
||||
message ModelEval {
|
||||
// Решений в окне самооценки (последние EVAL_WINDOW).
|
||||
int32 count = 1;
|
||||
// Из них совпавших с действием пользователя.
|
||||
int32 correct = 2;
|
||||
// Доля верных (correct/count, 0..1; 0 при пустом окне).
|
||||
double accuracy = 3;
|
||||
}
|
||||
|
||||
message ResetRequest {}
|
||||
|
||||
message ResetReply {
|
||||
// True — модель сброшена (и ядро очищает свою очередь обучения).
|
||||
bool ok = 1;
|
||||
// Текст ошибки при сбое сброса (пуст при успехе) — мягкая ошибка.
|
||||
optional string error = 2;
|
||||
}
|
||||
|
||||
message TrainBatchRequest {
|
||||
// Примеры обучения (1 транзакция на батч; ядро шлёт ≤100 за цикл, Ruling 6).
|
||||
repeated TrainExample items = 1;
|
||||
}
|
||||
|
||||
// Один обучающий пример (строка ml_outbox ядра: text/label/delta).
|
||||
message TrainExample {
|
||||
// Текст примера (source_msg карточки или title).
|
||||
string text = 1;
|
||||
// Метка: id доски (b_…), "spam" либо тип "t:hire"/"t:order".
|
||||
string label = 2;
|
||||
// Вес сигнала: 1.0 — действие пользователя; −1.0 — снять метку;
|
||||
// 0.4/0.6 — сигналы ИИ/правил (этапы 4/6).
|
||||
double delta = 3;
|
||||
}
|
||||
|
||||
message TrainBatchReply {
|
||||
// Число применённых примеров (= len(items) при успехе).
|
||||
int32 learned = 1;
|
||||
}
|
||||
//
|
||||
// (MlPredictResultDto/MlServiceStatusDto/MlEvalDto/MlResetResultDto).
|
||||
// Модель per-tenant: пул в ml-service, файл SQLite data/ml/<tenantId>.sqlite
|
||||
//
|
||||
// tenant-id — id тенанта (строка; модель тенанта — в пуле сервиса);
|
||||
// service-token — общий токен сервисов (env DEAL_SERVICE_TOKEN); неверный/
|
||||
// пустой → UNAUTHENTICATED.
|
||||
//
|
||||
// INVALID_ARGUMENT — невалидный запрос (пустой text и т.п.);
|
||||
// UNAVAILABLE — хранилище модели недоступно (ядро отвечает «не уверен»,
|
||||
//
|
||||
// Семантика неготовой модели: Predict НЕ ошибка — модель без опыта отвечает
|
||||
// фиксированным «не уверен»: take=false, label пуст, scores пуст, hits=0,
|
||||
//
|
||||
// Deadlines (клиент ядра): Predict — 5 с; Status/Reset — 10 с; TrainBatch — 30 с
|
||||
// (батч ≤100 примеров, одна транзакция).
|
||||
syntax = "proto3";
|
||||
|
||||
package deal.ml.v1;
|
||||
|
||||
option csharp_namespace = "Deal.Grpc.Ml";
|
||||
|
||||
service MlService {
|
||||
// take/label/scores/hits/margin/terms/type осмысленны только при take=true;
|
||||
// scores — до 5 лучших «класс → вес» (round 3), margin — адаптивный порог
|
||||
// (0.9/0.7/0.5/0.35 после 0/60/150/400 примеров), terms — узнанные термины
|
||||
// класса-победителя, type — решение о типе заявки (t:hire/t:order).
|
||||
rpc Predict(PredictRequest) returns (PredictReply);
|
||||
|
||||
// classes — «label → вес» (round 2, по убыванию); eval — самооценка по окну
|
||||
// последних подтверждённых решений (EVAL_WINDOW). Модель создаётся лениво
|
||||
rpc Status(StatusRequest) returns (StatusReply);
|
||||
|
||||
// терминов и журнала самооценки + пересоздание файла. Ok=true; мягкая
|
||||
// ошибка — Ok=false + error (ядро чистит свою ml_outbox только при успехе).
|
||||
rpc Reset(ResetRequest) returns (ResetReply);
|
||||
|
||||
// пакетные вставки терминов; самооценка по действиям пользователя (delta=1,
|
||||
// не t:*) до применения. Ответ — число применённых примеров.
|
||||
rpc TrainBatch(TrainBatchRequest) returns (TrainBatchReply);
|
||||
}
|
||||
|
||||
message PredictRequest {
|
||||
// Текст сообщения (ядро передаёт уже обрезанный/нормализованный, как
|
||||
string text = 1;
|
||||
}
|
||||
|
||||
message PredictReply {
|
||||
// True — модель уверена (take) и решение можно использовать без ИИ.
|
||||
bool take = 1;
|
||||
// Класс решения: id колонки канбана (b_…) или "spam"; пуст, если не уверена.
|
||||
optional string label = 2;
|
||||
// Веса классов: «label → вес» (до 5 лучших; пуст у неготовой модели).
|
||||
map<string, double> scores = 3;
|
||||
// Сколько терминов класса-победителя модель узнала в тексте.
|
||||
int32 hits = 4;
|
||||
// Модель обучена (набрала MIN_TOTAL/MIN_WINNER/MIN_WINNER_SPAM) и может
|
||||
// принимать решения.
|
||||
bool ready = 5;
|
||||
// Порог уверенности решения (адаптивный margin, 2 знака); пуст — нет решения.
|
||||
optional double margin = 6;
|
||||
// Узнанные термины класса-победителя (подсказка структуры карточки, ≤8).
|
||||
repeated string terms = 7;
|
||||
// Решение о типе заявки (hire/order); пуст — модель тип не определила.
|
||||
TypeDecision type = 8;
|
||||
}
|
||||
|
||||
message TypeDecision {
|
||||
// True — модель уверена в типе.
|
||||
bool take = 1;
|
||||
// Тип: "hire" | "order".
|
||||
string label = 2;
|
||||
// Внутренний класс ML: "t:hire" | "t:order" (не показывается UI).
|
||||
string value = 3;
|
||||
// Запас уверенности (margin, 2 знака).
|
||||
double margin = 4;
|
||||
}
|
||||
|
||||
message StatusRequest {}
|
||||
|
||||
message StatusReply {
|
||||
// Модель готова принимать решения.
|
||||
bool ready = 1;
|
||||
// Классы модели: «label → вес» (round 2; пуст, пока нет обучения).
|
||||
map<string, double> classes = 2;
|
||||
// Всего примеров, на которых модель обучалась (сумма по классам).
|
||||
int32 learned = 3;
|
||||
// Самооценка модели по последним подтверждённым решениям.
|
||||
ModelEval eval = 4;
|
||||
}
|
||||
|
||||
message ModelEval {
|
||||
// Решений в окне самооценки (последние EVAL_WINDOW).
|
||||
int32 count = 1;
|
||||
// Из них совпавших с действием пользователя.
|
||||
int32 correct = 2;
|
||||
// Доля верных (correct/count, 0..1; 0 при пустом окне).
|
||||
double accuracy = 3;
|
||||
}
|
||||
|
||||
message ResetRequest {}
|
||||
|
||||
message ResetReply {
|
||||
// True — модель сброшена (и ядро очищает свою очередь обучения).
|
||||
bool ok = 1;
|
||||
// Текст ошибки при сбое сброса (пуст при успехе) — мягкая ошибка.
|
||||
optional string error = 2;
|
||||
}
|
||||
|
||||
message TrainBatchRequest {
|
||||
repeated TrainExample items = 1;
|
||||
}
|
||||
|
||||
// Один обучающий пример (строка ml_outbox ядра: text/label/delta).
|
||||
message TrainExample {
|
||||
// Текст примера (source_msg карточки или title).
|
||||
string text = 1;
|
||||
// Метка: id доски (b_…), "spam" либо тип "t:hire"/"t:order".
|
||||
string label = 2;
|
||||
// Вес сигнала: 1.0 — действие пользователя; −1.0 — снять метку;
|
||||
double delta = 3;
|
||||
}
|
||||
|
||||
message TrainBatchReply {
|
||||
// Число применённых примеров (= len(items) при успехе).
|
||||
int32 learned = 1;
|
||||
}
|
||||
|
||||
+401
-453
@@ -1,453 +1,401 @@
|
||||
// telegram.proto — контракт между ядром Deal и telegram-service (этап 6).
|
||||
//
|
||||
// Два сервиса в одном файле (дизайн-док §6.2, план Task 1, Ruling 1/7):
|
||||
// * TelegramService — команды ядра к telegram-service (порт-гейт ITelegramGateway):
|
||||
// подключение/отключение аккаунта, каталог диалогов, мониторинг, backfill,
|
||||
// превью, discovery-операции (поиск/инфо/чтение/вступление/выход);
|
||||
// * IngressService — исходящий поток telegram-service → ядро: сырые сообщения
|
||||
// (PushMessage), синхронизация каталога (SyncDialogs), статус аккаунта
|
||||
// (ReportStatus). Сервер ингресса живёт в Deal.Api (:5082, Ruling 7).
|
||||
//
|
||||
// Семантика методов 1:1 с python-прототипом backend/app/services/telegram.py
|
||||
// (имена L134–873) и api-map §3.3/§4.8/§4.9; хранение диалогов/статуса — только
|
||||
// в ядре (модуль Deal.Modules.Telegram, Ruling 7), сервис БД тенантов не знает.
|
||||
//
|
||||
// Каждый RPC обязан нести в gRPC-metadata два заголовка (Ruling 1):
|
||||
// tenant-id — id тенанта (строка; единственный источник принадлежности,
|
||||
// полю в теле не доверяем);
|
||||
// service-token — общий токен сервисов (env DEAL_SERVICE_TOKEN); неверный/
|
||||
// пустой → UNAUTHENTICATED.
|
||||
//
|
||||
// Ошибки домена — gRPC-статусы с detail = текст причины 1:1 с прототипом:
|
||||
// INVALID_ARGUMENT — неверный ввод/неверный код/неверный пароль и т.п.;
|
||||
// NOT_FOUND — диалог/сущность не найдены (нет сессии тенанта и т.п.);
|
||||
// FAILED_PRECONDITION— операция невозможна в текущей фазе (нет сессии и т.п.);
|
||||
// RESOURCE_EXHAUSTED — FloodWait Telegram (detail начинается с префикса "flood");
|
||||
// UNAVAILABLE — недоступность Telegram/сети (безопасный повтор).
|
||||
//
|
||||
// Значения строк (канон контракта, .NET-код обеих сторон — новый):
|
||||
// * phase: idle|phone|code|password|qr|ready (как status() прототипа L85);
|
||||
// * kind: channel (канал) | group (группа/супергруппа) | forum (форум) |
|
||||
// chat (личный чат/бот). 1:1 с _kind_of (L461–466): broadcast →
|
||||
// channel, megagroup/gigagroup/group → group, остальное → chat.
|
||||
// Forum выставляется отдельным флагом is_forum (GetInfo); в
|
||||
// каталоге (RefreshDialogs) форум приходит как group.
|
||||
//
|
||||
// Deadlines (клиент ядра; уточняются адаптерами T2+):
|
||||
// * быстрые команды статуса/мониторинга — 10 с;
|
||||
// * сетевые операции Telegram (QR/код/поиск/инфо/чтение/вступление) — 60 с;
|
||||
// * Backfill/RefreshDialogs (паузы анти-бана 1.5–3 с/сообщение) — 120 с;
|
||||
// * IngressService (локальная сеть core) — 10 с (сбой догоняет sweep).
|
||||
syntax = "proto3";
|
||||
|
||||
package deal.telegram.v1;
|
||||
|
||||
option csharp_namespace = "Deal.Grpc.Telegram";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TelegramService — команды ядра → telegram-service (клиентская сторона в core)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
service TelegramService {
|
||||
// Текущий статус аккаунта/фазы входа тенанта (status() прототипа L103–119).
|
||||
// live-поля для GET /api/tg/status (Ruling 8); monitored/keysSet ядро считает
|
||||
// само. Нет сессии — FAILED_PRECONDITION «Telegram не подключён».
|
||||
rpc GetStatus(GetStatusRequest) returns (GetStatusReply);
|
||||
|
||||
// Вход по номеру телефона: запросить код (start_phone L134–147).
|
||||
// api_id/api_hash — глобальные ключи приложения Telegram, задаёт оператор (ТЗ §4.1/§8.1),
|
||||
// передаёт ядро в теле (Ruling 3); нет ключей — ядро отвечает 400 «Ключи Telegram
|
||||
// не заданы оператором» до вызова. Ответ: новая фаза ("code").
|
||||
rpc StartPhone(StartPhoneRequest) returns (StartPhoneReply);
|
||||
|
||||
// Начать QR-вход (qr_start L286–300). Ответ: фаза + qrUrl (t.me/qr/...);
|
||||
// если аккаунт уже авторизован — фаза "ready", qrUrl пуст.
|
||||
rpc StartQr(StartQrRequest) returns (StartQrReply);
|
||||
|
||||
// Отправить SMS-код (submit_code L149–166). Ошибки: «Неверный код»,
|
||||
// «Код истёк — запросите новый» → INVALID_ARGUMENT. Нужен 2FA — фаза
|
||||
// "password", иначе "ready". Фазы вне "code" → FAILED_PRECONDITION.
|
||||
rpc SendCode(SendCodeRequest) returns (SendCodeReply);
|
||||
|
||||
// Облачный пароль 2FA (submit_password L168–176). Ошибка «Неверный облачный
|
||||
// пароль» → INVALID_ARGUMENT. Ответ: фаза ("ready").
|
||||
rpc SendPassword(SendPasswordRequest) returns (SendPasswordReply);
|
||||
|
||||
// Отключить аккаунт, удалить сессию тенанта (disconnect L189–207).
|
||||
rpc Logout(LogoutRequest) returns (LogoutReply);
|
||||
|
||||
// Синхронизировать каталог диалогов из Telegram (refresh_dialogs L505–519):
|
||||
// актуальный список sources диалогов аккаунта (entries). Удаление/обновление
|
||||
// каталога и авто-мониторинг новых делает ядро (SyncFromTelegram, Ruling 7).
|
||||
rpc RefreshDialogs(RefreshDialogsRequest) returns (RefreshDialogsReply);
|
||||
|
||||
// Включить/выключить мониторинг диалога (set_monitor L536–546): обновляет
|
||||
// зеркало monitored в сервисе. Первый backfill (force=false) запускает ядро
|
||||
// отдельным RPC Backfill. Ответ: ok/enabled.
|
||||
rpc SetMonitor(SetMonitorRequest) returns (SetMonitorReply);
|
||||
|
||||
// Мониторинг всех диалогов сразу (set_monitor_all L548–567). Ответ:
|
||||
// ok/count/enabled (count — сколько диалогов в каталоге тенанта).
|
||||
rpc SetMonitorAll(SetMonitorAllRequest) returns (SetMonitorAllReply);
|
||||
|
||||
// Перечитать последние ~10 сообщений диалога и отправить их в ядро потоком
|
||||
// PushMessage (backfill_dialog L349–390; паузы анти-бана 1.5–3 с/сообщение,
|
||||
// mark-as-read). force=true — «Перечитать» по кнопке даже для разобранных.
|
||||
// Ответ: сколько сообщений отправлено (processed).
|
||||
rpc Backfill(BackfillRequest) returns (BackfillReply);
|
||||
|
||||
// Последние сообщения диалога для превью (dialog_messages L583–620):
|
||||
// свежие из Telegram; признак lead и фолбэк на БД добавляет ядро
|
||||
// (у сервиса нет БД тенанта). limit 1..50 (api-map /dialogs/preview).
|
||||
rpc ReadRecent(ReadRecentRequest) returns (ReadRecentReply);
|
||||
|
||||
// Глобальный поиск каналов/групп по ключу (discovery_search L624–664).
|
||||
// Пауза анти-бана после поиска — внутри сервиса. Личные чаты/боты ядро
|
||||
// отсеивает само (Ruling 10). Результат — entries канала/группы.
|
||||
rpc Search(SearchRequest) returns (SearchReply);
|
||||
|
||||
// Инфо об источнике для оценки (discovery_info L666–716): имя/username/kind/
|
||||
// hue + participants и is_forum (полный чат). Сбои определения не роняют
|
||||
// RPC: participants пуст, остальные поля — из entity/каталога.
|
||||
rpc GetInfo(GetInfoRequest) returns (GetInfoReply);
|
||||
|
||||
// Выборка последних сообщений источника для оценки кандидата
|
||||
// (discovery_read L718–760): форумы читаются по активным темам. История
|
||||
// недоступна (приватный/закрытый источник) — ok=false, error="no_history",
|
||||
// это НЕ ошибка RPC. limit ≥ 1; topic_id/topic_title заполнены для форумов.
|
||||
rpc ReadForEval(ReadForEvalRequest) returns (ReadForEvalReply);
|
||||
|
||||
// Вступить в канал/группу по @username (discovery_join L818–839; ручной
|
||||
// join вне квот — паузу перед авто-join делает воркер ядра, Ruling 10).
|
||||
// FloodWait → RESOURCE_EXHAUSTED (detail с префиксом "flood").
|
||||
rpc Join(JoinRequest) returns (JoinReply);
|
||||
|
||||
// Выйти из канала/группы (discovery_leave L841–848). NOT_FOUND — нет
|
||||
// диалога/членства.
|
||||
rpc Leave(LeaveRequest) returns (LeaveReply);
|
||||
}
|
||||
|
||||
// --- Запросы/ответы TelegramService ---
|
||||
|
||||
message GetStatusRequest {}
|
||||
|
||||
// Статус аккаунта/фазы входа (shape прототипа status() L110–118; monitored и
|
||||
// keysSet ядро добавляет само из своей БД/настроек — Ruling 8).
|
||||
message GetStatusReply {
|
||||
// Фаза входа: idle|phone|code|password|qr|ready.
|
||||
string phase = 1;
|
||||
// Клиент Telegram подключён и авторизован.
|
||||
bool connected = 2;
|
||||
// Жив ли realtime-listener (поток новых сообщений → PushMessage).
|
||||
bool listener = 3;
|
||||
// Аккаунт "@username" (для справки; источник истины — KV tgAccount по
|
||||
// ReportStatus, ядро использует KV — Ruling 8).
|
||||
string account = 4;
|
||||
// Текст последней ошибки (null, если ошибки нет).
|
||||
optional string error = 5;
|
||||
// URL QR-входа (заполнен только при phase == "qr").
|
||||
optional string qr_url = 6;
|
||||
}
|
||||
|
||||
// Подключение по телефону: ключи API передаёт ядро (Ruling 3).
|
||||
message StartPhoneRequest {
|
||||
// Номер телефона в международном формате (как ввёл пользователь).
|
||||
string phone = 1;
|
||||
// api_id приложения Telegram (глобальные ключи, задаёт оператор; 5..9 цифр).
|
||||
int32 api_id = 2;
|
||||
// api_hash приложения Telegram (глобальные ключи, задаёт оператор).
|
||||
string api_hash = 3;
|
||||
}
|
||||
|
||||
message StartPhoneReply {
|
||||
// Фаза после запроса кода ("code"); при ошибке — RPC-статус.
|
||||
string phase = 1;
|
||||
}
|
||||
|
||||
message StartQrRequest {
|
||||
// api_id/api_hash приложения Telegram (см. StartPhoneRequest).
|
||||
int32 api_id = 1;
|
||||
string api_hash = 2;
|
||||
}
|
||||
|
||||
message StartQrReply {
|
||||
// Фаза после запуска: "qr" (ждём сканирования) либо "ready" (уже вошли).
|
||||
string phase = 1;
|
||||
// URL вида https://t.me/qr/... для отрисовки QR; пуст при phase != "qr".
|
||||
string qr_url = 2;
|
||||
}
|
||||
|
||||
message SendCodeRequest {
|
||||
// Код из SMS/Telegram-сообщения.
|
||||
string code = 1;
|
||||
}
|
||||
|
||||
message SendCodeReply {
|
||||
// Фаза после проверки кода: "password" (нужен 2FA) или "ready".
|
||||
string phase = 1;
|
||||
}
|
||||
|
||||
message SendPasswordRequest {
|
||||
// Облачный пароль 2FA.
|
||||
string password = 1;
|
||||
}
|
||||
|
||||
message SendPasswordReply {
|
||||
// Фаза после входа ("ready").
|
||||
string phase = 1;
|
||||
}
|
||||
|
||||
message LogoutRequest {}
|
||||
|
||||
message LogoutReply {
|
||||
// True — аккаунт отключён, сессия тенанта удалена.
|
||||
bool ok = 1;
|
||||
}
|
||||
|
||||
message RefreshDialogsRequest {}
|
||||
|
||||
message RefreshDialogsReply {
|
||||
// Актуальный каталог диалогов аккаунта (id/name/username/kind/hue).
|
||||
// Ядро применяет его через SyncFromTelegram (Ruling 7).
|
||||
repeated DialogEntry entries = 1;
|
||||
}
|
||||
|
||||
// Один диалог/канал каталога или результат поиска (shape refresh L516 и
|
||||
// discovery_search L653–660: tuple id/name/handle/kind/hue; handle == username).
|
||||
message DialogEntry {
|
||||
// Подписанный id диалога: каналы "-100…", группы "-…", личные "+…".
|
||||
string id = 1;
|
||||
// Отображаемое имя (title/first_name) или id, если имени нет.
|
||||
string name = 2;
|
||||
// Username (handle) источника; пуст, если нет публичного username.
|
||||
string username = 3;
|
||||
// Тип: channel|group|forum|chat (канон контракта, см. шапку файла).
|
||||
string kind = 4;
|
||||
// Цвет источника из палитры DIALOG_HUES (hex, "#rrggbb") — считает сервис.
|
||||
string hue = 5;
|
||||
}
|
||||
|
||||
message SetMonitorRequest {
|
||||
// Id диалога каталога.
|
||||
string dialog_id = 1;
|
||||
// True — мониторить (сообщения → PushMessage в ядро), false — выключить.
|
||||
bool enabled = 2;
|
||||
}
|
||||
|
||||
message SetMonitorReply {
|
||||
bool ok = 1;
|
||||
// Зеркальное значение enabled (для ответов эндпоинтов {ok, enabled}).
|
||||
bool enabled = 2;
|
||||
}
|
||||
|
||||
message SetMonitorAllRequest {
|
||||
// True — мониторить все диалоги каталога, false — снять мониторинг со всех.
|
||||
bool enabled = 1;
|
||||
}
|
||||
|
||||
message SetMonitorAllReply {
|
||||
bool ok = 1;
|
||||
// Сколько диалогов в каталоге тенанта (api-map /monitor-all → count).
|
||||
int32 count = 2;
|
||||
bool enabled = 3;
|
||||
}
|
||||
|
||||
message BackfillRequest {
|
||||
// Id диалога для перечитывания.
|
||||
string dialog_id = 1;
|
||||
// True — перечитать, даже если диалог уже разобран (кнопка «Перечитать»).
|
||||
bool force = 2;
|
||||
}
|
||||
|
||||
message BackfillReply {
|
||||
// Сколько сообщений отправлено в ядро потоком PushMessage.
|
||||
int32 processed = 1;
|
||||
}
|
||||
|
||||
message ReadRecentRequest {
|
||||
// Id диалога.
|
||||
string dialog_id = 1;
|
||||
// Сколько последних сообщений (1..50; api-map /dialogs/preview limit 1..50).
|
||||
int32 limit = 2;
|
||||
}
|
||||
|
||||
message ReadRecentReply {
|
||||
// Последние сообщения (от новых к старым). lead/фолбэк на БД — в ядре.
|
||||
repeated PreviewMessage messages = 1;
|
||||
}
|
||||
|
||||
// Сообщение превью диалога (api-map §4.8 L351: {id, text, time, lead}).
|
||||
message PreviewMessage {
|
||||
// Id сообщения в Telegram (int); фолбэк-сообщения из БД ядра — строки
|
||||
// "m_<dialog>_<msg>", поэтому значение передаётся строкой.
|
||||
string id = 1;
|
||||
// Текст сообщения.
|
||||
string text = 2;
|
||||
// Время сообщения, epoch-ms.
|
||||
int64 time = 3;
|
||||
}
|
||||
|
||||
message SearchRequest {
|
||||
// Поисковый запрос (ключ задачи discovery).
|
||||
string query = 1;
|
||||
// Верхняя граница результатов (прототип: default 30).
|
||||
int32 limit = 2;
|
||||
}
|
||||
|
||||
message SearchReply {
|
||||
// Найденные источники (каналы/группы; личные чаты/ботов отсеивает ядро).
|
||||
repeated DialogEntry results = 1;
|
||||
}
|
||||
|
||||
message GetInfoRequest {
|
||||
// Id источника (подписанный; из каталога или результата поиска).
|
||||
string dialog_id = 1;
|
||||
}
|
||||
|
||||
// Инфо об источнике для оценки кандидата discovery (discovery_info L674–682).
|
||||
message ChannelInfo {
|
||||
string id = 1;
|
||||
string name = 2;
|
||||
string username = 3;
|
||||
// Тип: channel|group|forum|chat.
|
||||
string kind = 4;
|
||||
string hue = 5;
|
||||
// Число участников (full_chat); пусто — определить не удалось.
|
||||
optional int32 participants = 6;
|
||||
// True — мегагруппа-форум (темы); ядро трактует kind как "forum" (Ruling 10).
|
||||
bool is_forum = 7;
|
||||
}
|
||||
|
||||
message GetInfoReply {
|
||||
ChannelInfo info = 1;
|
||||
}
|
||||
|
||||
message ReadForEvalRequest {
|
||||
// Id источника.
|
||||
string dialog_id = 1;
|
||||
// Размер выборки (прототип discovery_read: limit сообщений/тем).
|
||||
int32 limit = 2;
|
||||
}
|
||||
|
||||
message ReadForEvalReply {
|
||||
// True — выборка получена; false — история недоступна без членства.
|
||||
bool ok = 1;
|
||||
// Код причины при ok=false: "no_history" (остальные поля пусты).
|
||||
optional string error = 2;
|
||||
// Сообщения выборки (форумы — по активным темам, topic_id/topic_title
|
||||
// заполнены; для обычных источников — null).
|
||||
repeated EvalMessage messages = 3;
|
||||
}
|
||||
|
||||
// Сообщение выборки discovery_read (_discovery_message_item L803–816).
|
||||
message EvalMessage {
|
||||
// Id сообщения в Telegram.
|
||||
int64 id = 1;
|
||||
// Текст сообщения (непустой; пустые тексты отбрасывает сервис).
|
||||
string text = 2;
|
||||
// Время сообщения, epoch-ms.
|
||||
int64 date_ms = 3;
|
||||
// Id темы форума (для обычных источников пусто).
|
||||
optional int64 topic_id = 4;
|
||||
// Название темы форума (для обычных источников пусто).
|
||||
optional string topic_title = 5;
|
||||
}
|
||||
|
||||
message JoinRequest {
|
||||
// @username источника (без "@"; пусто → INVALID_ARGUMENT).
|
||||
string username = 1;
|
||||
}
|
||||
|
||||
message JoinReply {
|
||||
bool ok = 1;
|
||||
}
|
||||
|
||||
message LeaveRequest {
|
||||
// Id диалога для выхода.
|
||||
string dialog_id = 1;
|
||||
}
|
||||
|
||||
message LeaveReply {
|
||||
bool ok = 1;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// IngressService — исходящий поток telegram-service → ядро
|
||||
// (gRPC-сервер в Deal.Api :5082; Ruling 7; интерцептор service-token;
|
||||
// tenantId из metadata → собственный scope с ITenantContext.SetTenant)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
service IngressService {
|
||||
// Новое/догоняющее сообщение мониторящегося диалога → очередь пайплайна
|
||||
// ядра (PipelineIngestService.EnqueueAsync, контракт demo-ingest; + превью в
|
||||
// TgMessages). Дубль dialog+msgId уже в очереди — не растёт (duplicate=true).
|
||||
rpc PushMessage(PushMessageRequest) returns (PushMessageReply);
|
||||
|
||||
// Синхронизация каталога диалогов: ядро применяет entries (SyncFromTelegram:
|
||||
// авто-мониторинг по autoMonitorNew, обновление, удаление отсутствующих) и
|
||||
// отвечает актуальным списком monitored id — сервис держит зеркало
|
||||
// мониторинга в памяти (Ruling 7), по нему фильтрует события realtime.
|
||||
rpc SyncDialogs(SyncDialogsRequest) returns (SyncDialogsReply);
|
||||
|
||||
// Периодический/событийный статус аккаунта: ядро пишет KV tgStatus/tgAccount
|
||||
// и публикует SSE system_status + тосты на переходах фаз (Ruling 7).
|
||||
rpc ReportStatus(ReportStatusRequest) returns (ReportStatusReply);
|
||||
}
|
||||
|
||||
// Сообщение из потока в ядро. Поля 1:1 с QueuedMessage/PipelineIngestRequest
|
||||
// (Ruling 7, demo-ingest L7–59): dialog_id + канальные поля плоские; msg_id —
|
||||
// дубль-гвард; msg_at — время исходного сообщения, без него ядро подставит now.
|
||||
message PushMessageRequest {
|
||||
// Id диалога-источника (подписанный; пуст — приём no-op).
|
||||
string dialog_id = 1;
|
||||
// Имя канала/диалога (title/first_name или id).
|
||||
string channel_name = 2;
|
||||
// Username канала/диалога (пуст, если нет).
|
||||
string channel_handle = 3;
|
||||
// Цвет канала из палитры DIALOG_HUES (hex; считает сервис — Ruling 7).
|
||||
string channel_hue = 4;
|
||||
// Id исходного сообщения в Telegram (дубль-гвард dialog+msgId).
|
||||
optional int64 msg_id = 5;
|
||||
// Текст сообщения (сервис шлёт как есть; приём обрежет до 6000).
|
||||
string text = 6;
|
||||
// Время исходного сообщения, epoch-ms; пусто — ядро подставит now.
|
||||
optional int64 msg_at = 7;
|
||||
}
|
||||
|
||||
message PushMessageReply {
|
||||
// True — сообщение принято (no-op с пустым текстом/диалогом — accepted=false).
|
||||
bool accepted = 1;
|
||||
// True — дубль dialog_id+msg_id уже в очереди (очередь не выросла).
|
||||
bool duplicate = 2;
|
||||
}
|
||||
|
||||
message SyncDialogsRequest {
|
||||
// Актуальный каталог диалогов (собирает сервис, как refresh_dialogs).
|
||||
repeated DialogEntry entries = 1;
|
||||
}
|
||||
|
||||
message SyncDialogsReply {
|
||||
// Id диалогов с включённым мониторингом (зеркало сервиса после синка).
|
||||
repeated string monitored_ids = 1;
|
||||
}
|
||||
|
||||
// Статус аккаунта для ядра (shape прототипа _publish_status L315–316/status()).
|
||||
message ReportStatusRequest {
|
||||
// Фаза: idle|phone|code|password|qr|ready.
|
||||
string phase = 1;
|
||||
// Клиент подключён и авторизован.
|
||||
bool connected = 2;
|
||||
// Realtime-listener жив.
|
||||
bool listener = 3;
|
||||
// Аккаунт "@username" (пуст после выхода) → KV tgAccount.
|
||||
string account = 4;
|
||||
// Текст ошибки (пуст, если нет) → KV tgStatus.error.
|
||||
optional string error = 5;
|
||||
// URL QR-входа при phase == "qr".
|
||||
optional string qr_url = 6;
|
||||
}
|
||||
|
||||
message ReportStatusReply {
|
||||
// True — статус принят и сохранён ядром.
|
||||
bool ok = 1;
|
||||
}
|
||||
//
|
||||
// * TelegramService — команды ядра к telegram-service (порт-гейт ITelegramGateway):
|
||||
// подключение/отключение аккаунта, каталог диалогов, мониторинг, backfill,
|
||||
// превью, discovery-операции (поиск/инфо/чтение/вступление/выход);
|
||||
// * IngressService — исходящий поток telegram-service → ядро: сырые сообщения
|
||||
// (PushMessage), синхронизация каталога (SyncDialogs), статус аккаунта
|
||||
//
|
||||
//
|
||||
// tenant-id — id тенанта (строка; единственный источник принадлежности,
|
||||
// полю в теле не доверяем);
|
||||
// service-token — общий токен сервисов (env DEAL_SERVICE_TOKEN); неверный/
|
||||
// пустой → UNAUTHENTICATED.
|
||||
//
|
||||
// INVALID_ARGUMENT — неверный ввод/неверный код/неверный пароль и т.п.;
|
||||
// NOT_FOUND — диалог/сущность не найдены (нет сессии тенанта и т.п.);
|
||||
// FAILED_PRECONDITION— операция невозможна в текущей фазе (нет сессии и т.п.);
|
||||
// RESOURCE_EXHAUSTED — FloodWait Telegram (detail начинается с префикса "flood");
|
||||
// UNAVAILABLE — недоступность Telegram/сети (безопасный повтор).
|
||||
//
|
||||
// Значения строк (канон контракта, .NET-код обеих сторон — новый):
|
||||
// * kind: channel (канал) | group (группа/супергруппа) | forum (форум) |
|
||||
// channel, megagroup/gigagroup/group → group, остальное → chat.
|
||||
// Forum выставляется отдельным флагом is_forum (GetInfo); в
|
||||
// каталоге (RefreshDialogs) форум приходит как group.
|
||||
//
|
||||
// * быстрые команды статуса/мониторинга — 10 с;
|
||||
// * сетевые операции Telegram (QR/код/поиск/инфо/чтение/вступление) — 60 с;
|
||||
// * Backfill/RefreshDialogs (паузы анти-бана 1.5–3 с/сообщение) — 120 с;
|
||||
// * IngressService (локальная сеть core) — 10 с (сбой догоняет sweep).
|
||||
syntax = "proto3";
|
||||
|
||||
package deal.telegram.v1;
|
||||
|
||||
option csharp_namespace = "Deal.Grpc.Telegram";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TelegramService — команды ядра → telegram-service (клиентская сторона в core)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
service TelegramService {
|
||||
// само. Нет сессии — FAILED_PRECONDITION «Telegram не подключён».
|
||||
rpc GetStatus(GetStatusRequest) returns (GetStatusReply);
|
||||
|
||||
// api_id/api_hash — глобальные ключи приложения Telegram, задаёт оператор (ТЗ §4.1/§8.1),
|
||||
// не заданы оператором» до вызова. Ответ: новая фаза ("code").
|
||||
rpc StartPhone(StartPhoneRequest) returns (StartPhoneReply);
|
||||
|
||||
// если аккаунт уже авторизован — фаза "ready", qrUrl пуст.
|
||||
rpc StartQr(StartQrRequest) returns (StartQrReply);
|
||||
|
||||
// «Код истёк — запросите новый» → INVALID_ARGUMENT. Нужен 2FA — фаза
|
||||
// "password", иначе "ready". Фазы вне "code" → FAILED_PRECONDITION.
|
||||
rpc SendCode(SendCodeRequest) returns (SendCodeReply);
|
||||
|
||||
// пароль» → INVALID_ARGUMENT. Ответ: фаза ("ready").
|
||||
rpc SendPassword(SendPasswordRequest) returns (SendPasswordReply);
|
||||
|
||||
rpc Logout(LogoutRequest) returns (LogoutReply);
|
||||
|
||||
// актуальный список sources диалогов аккаунта (entries). Удаление/обновление
|
||||
rpc RefreshDialogs(RefreshDialogsRequest) returns (RefreshDialogsReply);
|
||||
|
||||
// зеркало monitored в сервисе. Первый backfill (force=false) запускает ядро
|
||||
// отдельным RPC Backfill. Ответ: ok/enabled.
|
||||
rpc SetMonitor(SetMonitorRequest) returns (SetMonitorReply);
|
||||
|
||||
// ok/count/enabled (count — сколько диалогов в каталоге тенанта).
|
||||
rpc SetMonitorAll(SetMonitorAllRequest) returns (SetMonitorAllReply);
|
||||
|
||||
// Перечитать последние ~10 сообщений диалога и отправить их в ядро потоком
|
||||
// mark-as-read). force=true — «Перечитать» по кнопке даже для разобранных.
|
||||
// Ответ: сколько сообщений отправлено (processed).
|
||||
rpc Backfill(BackfillRequest) returns (BackfillReply);
|
||||
|
||||
// свежие из Telegram; признак lead и фолбэк на БД добавляет ядро
|
||||
// (у сервиса нет БД тенанта). limit 1..50 (api-map /dialogs/preview).
|
||||
rpc ReadRecent(ReadRecentRequest) returns (ReadRecentReply);
|
||||
|
||||
// Пауза анти-бана после поиска — внутри сервиса. Личные чаты/боты ядро
|
||||
rpc Search(SearchRequest) returns (SearchReply);
|
||||
|
||||
// hue + participants и is_forum (полный чат). Сбои определения не роняют
|
||||
// RPC: participants пуст, остальные поля — из entity/каталога.
|
||||
rpc GetInfo(GetInfoRequest) returns (GetInfoReply);
|
||||
|
||||
// Выборка последних сообщений источника для оценки кандидата
|
||||
// недоступна (приватный/закрытый источник) — ok=false, error="no_history",
|
||||
// это НЕ ошибка RPC. limit ≥ 1; topic_id/topic_title заполнены для форумов.
|
||||
rpc ReadForEval(ReadForEvalRequest) returns (ReadForEvalReply);
|
||||
|
||||
// FloodWait → RESOURCE_EXHAUSTED (detail с префиксом "flood").
|
||||
rpc Join(JoinRequest) returns (JoinReply);
|
||||
|
||||
// диалога/членства.
|
||||
rpc Leave(LeaveRequest) returns (LeaveReply);
|
||||
}
|
||||
|
||||
// --- Запросы/ответы TelegramService ---
|
||||
|
||||
message GetStatusRequest {}
|
||||
|
||||
message GetStatusReply {
|
||||
// Фаза входа: idle|phone|code|password|qr|ready.
|
||||
string phase = 1;
|
||||
// Клиент Telegram подключён и авторизован.
|
||||
bool connected = 2;
|
||||
// Жив ли realtime-listener (поток новых сообщений → PushMessage).
|
||||
bool listener = 3;
|
||||
// Аккаунт "@username" (для справки; источник истины — KV tgAccount по
|
||||
string account = 4;
|
||||
// Текст последней ошибки (null, если ошибки нет).
|
||||
optional string error = 5;
|
||||
// URL QR-входа (заполнен только при phase == "qr").
|
||||
optional string qr_url = 6;
|
||||
}
|
||||
|
||||
message StartPhoneRequest {
|
||||
// Номер телефона в международном формате (как ввёл пользователь).
|
||||
string phone = 1;
|
||||
// api_id приложения Telegram (глобальные ключи, задаёт оператор; 5..9 цифр).
|
||||
int32 api_id = 2;
|
||||
// api_hash приложения Telegram (глобальные ключи, задаёт оператор).
|
||||
string api_hash = 3;
|
||||
}
|
||||
|
||||
message StartPhoneReply {
|
||||
// Фаза после запроса кода ("code"); при ошибке — RPC-статус.
|
||||
string phase = 1;
|
||||
}
|
||||
|
||||
message StartQrRequest {
|
||||
// api_id/api_hash приложения Telegram (см. StartPhoneRequest).
|
||||
int32 api_id = 1;
|
||||
string api_hash = 2;
|
||||
}
|
||||
|
||||
message StartQrReply {
|
||||
// Фаза после запуска: "qr" (ждём сканирования) либо "ready" (уже вошли).
|
||||
string phase = 1;
|
||||
// URL вида https://t.me/qr/... для отрисовки QR; пуст при phase != "qr".
|
||||
string qr_url = 2;
|
||||
}
|
||||
|
||||
message SendCodeRequest {
|
||||
// Код из SMS/Telegram-сообщения.
|
||||
string code = 1;
|
||||
}
|
||||
|
||||
message SendCodeReply {
|
||||
// Фаза после проверки кода: "password" (нужен 2FA) или "ready".
|
||||
string phase = 1;
|
||||
}
|
||||
|
||||
message SendPasswordRequest {
|
||||
// Облачный пароль 2FA.
|
||||
string password = 1;
|
||||
}
|
||||
|
||||
message SendPasswordReply {
|
||||
// Фаза после входа ("ready").
|
||||
string phase = 1;
|
||||
}
|
||||
|
||||
message LogoutRequest {}
|
||||
|
||||
message LogoutReply {
|
||||
// True — аккаунт отключён, сессия тенанта удалена.
|
||||
bool ok = 1;
|
||||
}
|
||||
|
||||
message RefreshDialogsRequest {}
|
||||
|
||||
message RefreshDialogsReply {
|
||||
// Актуальный каталог диалогов аккаунта (id/name/username/kind/hue).
|
||||
repeated DialogEntry entries = 1;
|
||||
}
|
||||
|
||||
message DialogEntry {
|
||||
// Подписанный id диалога: каналы "-100…", группы "-…", личные "+…".
|
||||
string id = 1;
|
||||
// Отображаемое имя (title/first_name) или id, если имени нет.
|
||||
string name = 2;
|
||||
// Username (handle) источника; пуст, если нет публичного username.
|
||||
string username = 3;
|
||||
// Тип: channel|group|forum|chat (канон контракта, см. шапку файла).
|
||||
string kind = 4;
|
||||
// Цвет источника из палитры DIALOG_HUES (hex, "#rrggbb") — считает сервис.
|
||||
string hue = 5;
|
||||
}
|
||||
|
||||
message SetMonitorRequest {
|
||||
// Id диалога каталога.
|
||||
string dialog_id = 1;
|
||||
// True — мониторить (сообщения → PushMessage в ядро), false — выключить.
|
||||
bool enabled = 2;
|
||||
}
|
||||
|
||||
message SetMonitorReply {
|
||||
bool ok = 1;
|
||||
// Зеркальное значение enabled (для ответов эндпоинтов {ok, enabled}).
|
||||
bool enabled = 2;
|
||||
}
|
||||
|
||||
message SetMonitorAllRequest {
|
||||
// True — мониторить все диалоги каталога, false — снять мониторинг со всех.
|
||||
bool enabled = 1;
|
||||
}
|
||||
|
||||
message SetMonitorAllReply {
|
||||
bool ok = 1;
|
||||
// Сколько диалогов в каталоге тенанта (api-map /monitor-all → count).
|
||||
int32 count = 2;
|
||||
bool enabled = 3;
|
||||
}
|
||||
|
||||
message BackfillRequest {
|
||||
// Id диалога для перечитывания.
|
||||
string dialog_id = 1;
|
||||
// True — перечитать, даже если диалог уже разобран (кнопка «Перечитать»).
|
||||
bool force = 2;
|
||||
}
|
||||
|
||||
message BackfillReply {
|
||||
// Сколько сообщений отправлено в ядро потоком PushMessage.
|
||||
int32 processed = 1;
|
||||
}
|
||||
|
||||
message ReadRecentRequest {
|
||||
// Id диалога.
|
||||
string dialog_id = 1;
|
||||
// Сколько последних сообщений (1..50; api-map /dialogs/preview limit 1..50).
|
||||
int32 limit = 2;
|
||||
}
|
||||
|
||||
message ReadRecentReply {
|
||||
// Последние сообщения (от новых к старым). lead/фолбэк на БД — в ядре.
|
||||
repeated PreviewMessage messages = 1;
|
||||
}
|
||||
|
||||
message PreviewMessage {
|
||||
// Id сообщения в Telegram (int); фолбэк-сообщения из БД ядра — строки
|
||||
// "m_<dialog>_<msg>", поэтому значение передаётся строкой.
|
||||
string id = 1;
|
||||
// Текст сообщения.
|
||||
string text = 2;
|
||||
// Время сообщения, epoch-ms.
|
||||
int64 time = 3;
|
||||
}
|
||||
|
||||
message SearchRequest {
|
||||
// Поисковый запрос (ключ задачи discovery).
|
||||
string query = 1;
|
||||
int32 limit = 2;
|
||||
}
|
||||
|
||||
message SearchReply {
|
||||
// Найденные источники (каналы/группы; личные чаты/ботов отсеивает ядро).
|
||||
repeated DialogEntry results = 1;
|
||||
}
|
||||
|
||||
message GetInfoRequest {
|
||||
// Id источника (подписанный; из каталога или результата поиска).
|
||||
string dialog_id = 1;
|
||||
}
|
||||
|
||||
message ChannelInfo {
|
||||
string id = 1;
|
||||
string name = 2;
|
||||
string username = 3;
|
||||
// Тип: channel|group|forum|chat.
|
||||
string kind = 4;
|
||||
string hue = 5;
|
||||
// Число участников (full_chat); пусто — определить не удалось.
|
||||
optional int32 participants = 6;
|
||||
bool is_forum = 7;
|
||||
}
|
||||
|
||||
message GetInfoReply {
|
||||
ChannelInfo info = 1;
|
||||
}
|
||||
|
||||
message ReadForEvalRequest {
|
||||
// Id источника.
|
||||
string dialog_id = 1;
|
||||
int32 limit = 2;
|
||||
}
|
||||
|
||||
message ReadForEvalReply {
|
||||
// True — выборка получена; false — история недоступна без членства.
|
||||
bool ok = 1;
|
||||
// Код причины при ok=false: "no_history" (остальные поля пусты).
|
||||
optional string error = 2;
|
||||
// Сообщения выборки (форумы — по активным темам, topic_id/topic_title
|
||||
// заполнены; для обычных источников — null).
|
||||
repeated EvalMessage messages = 3;
|
||||
}
|
||||
|
||||
message EvalMessage {
|
||||
// Id сообщения в Telegram.
|
||||
int64 id = 1;
|
||||
// Текст сообщения (непустой; пустые тексты отбрасывает сервис).
|
||||
string text = 2;
|
||||
// Время сообщения, epoch-ms.
|
||||
int64 date_ms = 3;
|
||||
// Id темы форума (для обычных источников пусто).
|
||||
optional int64 topic_id = 4;
|
||||
// Название темы форума (для обычных источников пусто).
|
||||
optional string topic_title = 5;
|
||||
}
|
||||
|
||||
message JoinRequest {
|
||||
// @username источника (без "@"; пусто → INVALID_ARGUMENT).
|
||||
string username = 1;
|
||||
}
|
||||
|
||||
message JoinReply {
|
||||
bool ok = 1;
|
||||
}
|
||||
|
||||
message LeaveRequest {
|
||||
// Id диалога для выхода.
|
||||
string dialog_id = 1;
|
||||
}
|
||||
|
||||
message LeaveReply {
|
||||
bool ok = 1;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// IngressService — исходящий поток telegram-service → ядро
|
||||
// tenantId из metadata → собственный scope с ITenantContext.SetTenant)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
service IngressService {
|
||||
// Новое/догоняющее сообщение мониторящегося диалога → очередь пайплайна
|
||||
// ядра (PipelineIngestService.EnqueueAsync, контракт demo-ingest; + превью в
|
||||
// TgMessages). Дубль dialog+msgId уже в очереди — не растёт (duplicate=true).
|
||||
rpc PushMessage(PushMessageRequest) returns (PushMessageReply);
|
||||
|
||||
// Синхронизация каталога диалогов: ядро применяет entries (SyncFromTelegram:
|
||||
// авто-мониторинг по autoMonitorNew, обновление, удаление отсутствующих) и
|
||||
// отвечает актуальным списком monitored id — сервис держит зеркало
|
||||
rpc SyncDialogs(SyncDialogsRequest) returns (SyncDialogsReply);
|
||||
|
||||
// Периодический/событийный статус аккаунта: ядро пишет KV tgStatus/tgAccount
|
||||
rpc ReportStatus(ReportStatusRequest) returns (ReportStatusReply);
|
||||
}
|
||||
|
||||
// дубль-гвард; msg_at — время исходного сообщения, без него ядро подставит now.
|
||||
message PushMessageRequest {
|
||||
// Id диалога-источника (подписанный; пуст — приём no-op).
|
||||
string dialog_id = 1;
|
||||
// Имя канала/диалога (title/first_name или id).
|
||||
string channel_name = 2;
|
||||
// Username канала/диалога (пуст, если нет).
|
||||
string channel_handle = 3;
|
||||
string channel_hue = 4;
|
||||
// Id исходного сообщения в Telegram (дубль-гвард dialog+msgId).
|
||||
optional int64 msg_id = 5;
|
||||
// Текст сообщения (сервис шлёт как есть; приём обрежет до 6000).
|
||||
string text = 6;
|
||||
// Время исходного сообщения, epoch-ms; пусто — ядро подставит now.
|
||||
optional int64 msg_at = 7;
|
||||
}
|
||||
|
||||
message PushMessageReply {
|
||||
// True — сообщение принято (no-op с пустым текстом/диалогом — accepted=false).
|
||||
bool accepted = 1;
|
||||
// True — дубль dialog_id+msg_id уже в очереди (очередь не выросла).
|
||||
bool duplicate = 2;
|
||||
}
|
||||
|
||||
message SyncDialogsRequest {
|
||||
// Актуальный каталог диалогов (собирает сервис, как refresh_dialogs).
|
||||
repeated DialogEntry entries = 1;
|
||||
}
|
||||
|
||||
message SyncDialogsReply {
|
||||
// Id диалогов с включённым мониторингом (зеркало сервиса после синка).
|
||||
repeated string monitored_ids = 1;
|
||||
}
|
||||
|
||||
message ReportStatusRequest {
|
||||
// Фаза: idle|phone|code|password|qr|ready.
|
||||
string phase = 1;
|
||||
// Клиент подключён и авторизован.
|
||||
bool connected = 2;
|
||||
// Realtime-listener жив.
|
||||
bool listener = 3;
|
||||
// Аккаунт "@username" (пуст после выхода) → KV tgAccount.
|
||||
string account = 4;
|
||||
// Текст ошибки (пуст, если нет) → KV tgStatus.error.
|
||||
optional string error = 5;
|
||||
// URL QR-входа при phase == "qr".
|
||||
optional string qr_url = 6;
|
||||
}
|
||||
|
||||
message ReportStatusReply {
|
||||
// True — статус принят и сохранён ядром.
|
||||
bool ok = 1;
|
||||
}
|
||||
|
||||
@@ -3,31 +3,22 @@ using Deal.Modules.Tenants.Application.Services;
|
||||
namespace Deal.Api.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Настройки httpOnly-куки сессии. Привязываются из секции "Cookies" конфигурации (IOptions).
|
||||
/// Настройки httpOnly-куки сессии.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Источник значений — конфигурация: секция <c>Cookies</c> в appsettings.json /
|
||||
/// appsettings.Development.json и переменные окружения <c>Cookies__*</c> (имя, срок, Secure).
|
||||
/// <para>
|
||||
/// Срок жизни по умолчанию — единый источник числа «30 дней»: константа модуля
|
||||
/// <see cref="AuthService.SessionLifetimeDays"/>, на которую ссылается код-дефолт свойства
|
||||
/// <see cref="Days"/>. Значение из конфигурации (<c>Cookies__Days</c>) при необходимости перекрывает его.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class CookieOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Имя куки (Ruling 6: <c>deal_session</c>).
|
||||
/// Имя куки.
|
||||
/// </summary>
|
||||
public string Name { get; set; } = "deal_session";
|
||||
|
||||
/// <summary>
|
||||
/// Срок жизни куки в днях; совпадает со сроком жизни сессии (Ruling 6).
|
||||
/// Срок жизни куки в днях; совпадает со сроком жизни сессии.
|
||||
/// </summary>
|
||||
public int Days { get; set; } = AuthService.SessionLifetimeDays;
|
||||
|
||||
/// <summary>
|
||||
/// Флаг Secure куки (dev=false; включается при HTTPS-проксировании, Ruling 6).
|
||||
/// Флаг Secure куки.
|
||||
/// </summary>
|
||||
public bool Secure { get; set; }
|
||||
}
|
||||
|
||||
@@ -1,24 +1,17 @@
|
||||
namespace Deal.Api.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Настройки авто-очистки данных (этап 12, пакет B): секция <c>DataRetention</c> конфигурации
|
||||
/// (appsettings.json + переменные окружения <c>DataRetention__*</c>).
|
||||
/// Настройки авто-очистки данных
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Управляет фоновым циклом <c>DataRetentionScheduler</c>: удаление записей аудита старше
|
||||
/// <see cref="AuditRetentionDays"/> (по умолчанию 180 дней — разумный операционный срок) и очистка
|
||||
/// накопительных полей лимитов/счётчиков прошедших окон. <see cref="Enabled"/>=false полностью
|
||||
/// выключает фоновую очистку (например, при внешнем управлении retention).
|
||||
/// </remarks>
|
||||
public sealed class DataRetentionOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Включён ли фоновый цикл авто-очистки (по умолчанию — да).
|
||||
/// Включён ли фоновый цикл авто-очистки
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Срок хранения записей аудита в днях (по умолчанию 180); неположительное значение — дефолт.
|
||||
/// Срок хранения записей аудита в днях
|
||||
/// </summary>
|
||||
public int AuditRetentionDays { get; set; } = 180;
|
||||
}
|
||||
|
||||
@@ -1,39 +1,22 @@
|
||||
namespace Deal.Api.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Настройки доверия прокси-заголовкам (план Task 12; замечание ревью T4/T11 к Ruling 5/10): секция
|
||||
/// <c>ForwardedHeaders</c> конфигурации (appsettings.json + переменные окружения <c>ForwardedHeaders__*</c>).
|
||||
/// Настройки доверия прокси-заголовкам
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// В PROD наружу смотрит только Caddy (compose-prod, Task 14), core принимает соединения от него:
|
||||
/// без обработки X-Forwarded-For/X-Forwarded-Proto RemoteIpAddress (HttpContext.Connection)
|
||||
/// всех запросов — адрес Caddy, и audit-IP (ClientIp эндпоинтов, Ruling 4) и rate-limit-по-IP
|
||||
/// (политики Ruling 5, LoginAttemptGuard) схлопываются в один бакет прокси. UseForwardedHeaders
|
||||
/// доверяет заголовкам только клиентов из <see cref="KnownProxies"/> (IP-адреса) и
|
||||
/// <see cref="KnownNetworks"/> (подсети CIDR).
|
||||
/// <para>
|
||||
/// <c>Enabled=false</c> — код-дефолт и значение dev/тестов: прокси в dev-стеке нет (compose.dev —
|
||||
/// core наружу напрямую :5080), curl-приёмки от заголовков не зависят. PROD включает env
|
||||
/// (<c>ForwardedHeaders__Enabled=true</c>) и перечисляет Caddy: KnownProxies (его IP) либо KnownNetworks
|
||||
/// (узкий CIDR compose-сети). Пустые KnownProxies/KnownNetworks у ForwardedHeadersMiddleware означают
|
||||
/// «доверять любому клиенту» — Program.BuildForwardedHeadersOptions не допускает пустоту и добавляет
|
||||
/// loopback-фолбэк (dev-прокси на хосте: vite/локальный Caddy); явное перечисление в конфиге замещает его.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class ForwardedHeadersConfig
|
||||
{
|
||||
/// <summary>
|
||||
/// Включена ли обработка прокси-заголовков (dev/тесты — false; PROD за Caddy — true).
|
||||
/// Включена ли обработка прокси-заголовков
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Доверенные прокси-адреса: IP клиентов, которым можно верить в X-Forwarded-For/Proto.
|
||||
/// Доверенные прокси-адреса
|
||||
/// </summary>
|
||||
public string[] KnownProxies { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Доверенные подсети прокси в CIDR (например "172.16.0.0/12" — compose-сеть PROD).
|
||||
/// Доверенные подсети прокси в CIDR
|
||||
/// </summary>
|
||||
public string[] KnownNetworks { get; set; } = [];
|
||||
}
|
||||
|
||||
@@ -3,33 +3,22 @@ using Deal.Modules.Tenants.Application.Services;
|
||||
namespace Deal.Api.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Настройки httpOnly-куки сессии оператора. Привязываются из секции "OperatorCookies" конфигурации (IOptions).
|
||||
/// Настройки httpOnly-куки сессии оператора.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Источник значений — конфигурация: секция <c>OperatorCookies</c> в appsettings.json и переменные
|
||||
/// окружения <c>OperatorCookies__*</c> (имя, срок, Secure). Имя по умолчанию — <c>deal_operator_session</c>:
|
||||
/// отдельная от тенантной <c>deal_session</c> кука (Ruling 1 этапа 7) — операторская сессия не может быть
|
||||
/// подменена тенантной и наоборот (сессии разрешаются разными middleware).
|
||||
/// <para>
|
||||
/// Срок жизни по умолчанию — единый источник числа «12 часов»: константа модуля
|
||||
/// <see cref="OperatorAuthService.SessionLifetimeHours"/>, на которую ссылается код-дефолт свойства
|
||||
/// <see cref="Hours"/>. Значение из конфигурации (<c>OperatorCookies__Hours</c>) при необходимости перекрывает его.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class OperatorCookieOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Имя куки (Ruling 1: <c>deal_operator_session</c>).
|
||||
/// Имя куки.
|
||||
/// </summary>
|
||||
public string Name { get; set; } = "deal_operator_session";
|
||||
|
||||
/// <summary>
|
||||
/// Срок жизни куки в часах; совпадает со сроком жизни сессии оператора (Ruling 1: 12).
|
||||
/// Срок жизни куки в часах; совпадает со сроком жизни сессии оператора.
|
||||
/// </summary>
|
||||
public int Hours { get; set; } = OperatorAuthService.SessionLifetimeHours;
|
||||
|
||||
/// <summary>
|
||||
/// Флаг Secure куки (dev=false; включается при HTTPS-проксировании, Ruling 1).
|
||||
/// Флаг Secure куки.
|
||||
/// </summary>
|
||||
public bool Secure { get; set; }
|
||||
}
|
||||
|
||||
@@ -1,43 +1,37 @@
|
||||
namespace Deal.Api.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Настройки rate limiting и защиты входа (план Task 11, Ruling 5): секция <c>RateLimit</c> конфигурации
|
||||
/// (appsettings.json + переменные окружения <c>RateLimit__*</c>).
|
||||
/// Настройки rate limiting и защиты входа
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <c>Enabled=false</c> — код-дефолт и значение dev/тестов: политики и middleware не регистрируются вовсе,
|
||||
/// curl-приёмки и unit-хосты не режутся. PROD включает env-переопределением (<c>RateLimit__Enabled=true</c>
|
||||
/// в compose-prod, Task 14). Все окна политик — фиксированные, 1 минута (имена свойств — «PerMinute»).
|
||||
/// </remarks>
|
||||
public sealed class RateLimitOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Включены ли rate limiting и LoginAttemptGuard (dev/тесты — false, Ruling 5).
|
||||
/// Включены ли rate limiting и LoginAttemptGuard.
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Лимит политики "auth" (фиксированное окно в минуту на IP) для /api/auth/login и /api/operator/auth/login.
|
||||
/// Лимит политики "auth"
|
||||
/// </summary>
|
||||
public int AuthPerMinute { get; set; } = 10;
|
||||
|
||||
/// <summary>
|
||||
/// Лимит политики "api" (в минуту на тенанта либо IP анонима) для остальных /api-эндпоинтов.
|
||||
/// Лимит политики "api"
|
||||
/// </summary>
|
||||
public int ApiPerMinute { get; set; } = 600;
|
||||
|
||||
/// <summary>
|
||||
/// Лимит gRPC-ингресса (в минуту на tenant-id из metadata; интерцептор IngressRateLimitInterceptor).
|
||||
/// Лимит gRPC-ингресса
|
||||
/// </summary>
|
||||
public int GrpcIngressPerMinute { get; set; } = 600;
|
||||
|
||||
/// <summary>
|
||||
/// Порог неудачных попыток входа ключа ip|login до блокировки (LoginAttemptGuard).
|
||||
/// Порог неудачных попыток входа ключа ip|login до блокировки
|
||||
/// </summary>
|
||||
public int LoginAttemptsMax { get; set; } = 5;
|
||||
|
||||
/// <summary>
|
||||
/// Окно учёта неудачных попыток входа в минутах (LoginAttemptGuard; текст 429 — фиксированный «15 минут»).
|
||||
/// Окно учёта неудачных попыток входа в минутах
|
||||
/// </summary>
|
||||
public int LoginAttemptWindowMin { get; set; } = 15;
|
||||
}
|
||||
|
||||
@@ -1,25 +1,12 @@
|
||||
namespace Deal.Api.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Настройки безопасности HTTP (план Task 12, Ruling 10(2)/9): секция <c>Security</c> конфигурации
|
||||
/// (appsettings.json + переменные окружения <c>Security__*</c>).
|
||||
/// Настройки безопасности HTTP
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <see cref="AllowedOrigins"/> — единый явный allowlist для Origin-проверки мутаций
|
||||
/// (<see cref="Deal.Api.Middleware.OriginGuardMiddleware"/>) и CORS-политики. Пустой список — dev-режим:
|
||||
/// OriginGuard принимает только «свой» origin запроса (схема + Host, для не-GET запросов /api),
|
||||
/// CORS разрешает любой origin (текущее поведение «как в прототипе»). Непустой список (PROD,
|
||||
/// compose-prod, Ruling 9) — CORS становится строгим allowlist + credentials; OriginGuard дополнительно
|
||||
/// принимает перечисленные origin'ы (в т.ч. когда запрос идёт не от «своего» Host — фронт за прокси).
|
||||
/// <para>
|
||||
/// Записи — полные origin'ы в том виде, в каком их шлёт браузер: схема://хост[:порт], без завершающего
|
||||
/// слэша (например <c>https://deal.example</c>, <c>http://localhost:5173</c>). Сравнение регистронезависимо.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class SecurityOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Явный allowlist Origin/CORS (схема://хост[:порт]); пусто — dev-режим «любой origin + свой Host».
|
||||
/// Явный allowlist Origin/CORS
|
||||
/// </summary>
|
||||
public string[] AllowedOrigins { get; set; } = [];
|
||||
}
|
||||
|
||||
@@ -3,21 +3,11 @@ using Deal.Modules.Kanban.Application.Models;
|
||||
namespace Deal.Api.Dtos;
|
||||
|
||||
/// <summary>
|
||||
/// Ответ POST /api/admin/tick — форма {storage, reminders, pipeline, queue} (dashboard_routes.py admin_tick L327–337, план Task 10).
|
||||
/// Ответ POST /api/admin/tick — форма {storage, reminders, pipeline, queue}.
|
||||
/// </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="Reminders">«Выстрелившие» напоминания {id,title,containerId} — список SSE reminder_due тика.</param>
|
||||
/// <param name="Pipeline">Счётчики pump: словарь ключей прототипа; пуст, если pump не дал результата.</param>
|
||||
/// <param name="Pipeline">Счётчики pump: словарь ключей; пуст, если pump не дал результата.</param>
|
||||
/// <param name="Queue">Строк очереди входящих после прохода pump (queue_len).</param>
|
||||
public sealed record AdminTickResultDto(
|
||||
StorageTickStatsDto Storage,
|
||||
|
||||
@@ -7,26 +7,15 @@ using Deal.Modules.Settings.Application.Models;
|
||||
namespace Deal.Api.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Эндпоинт проверки подключения AI-провайдера: POST /api/ai/check (Ruling 7/8, api-map §4.10).
|
||||
/// Эндпоинт проверки подключения AI-провайдера
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// «Только для Settings-экрана» (Ruling 8): фронт жмёт «Проверить подключение» (store.js
|
||||
/// checkAiConnection) БЕЗ тела — сервер читает АКТИВНУЮ конфигурацию провайдера тенанта
|
||||
/// (настройки <c>aiProvider</c> + <c>aiConfigs</c> с расшифровкой ключа через <see cref="ISecretCipher"/>;
|
||||
/// 1:1 с ai_svc._cfg(), ai.py L25–33), вызывает порт <see cref="IAiConnectionChecker"/> и отдаёт
|
||||
/// {ok, message} + статус провайдера. Требует сессию: 401 {detail} (формат прототипа).
|
||||
/// Резолв scoped-зависимостей — через RequestServices ПОСЛЕ проверки сессии (как SettingsEndpoints:
|
||||
/// DI-биндинг параметров выполняется до тела, а ISettingsStore требует tenant-контекст запроса).
|
||||
/// </remarks>
|
||||
public static class AiCheckEndpoint
|
||||
{
|
||||
// Префикс группы API (общий для эндпоинтов этапа, Ruling 8).
|
||||
private const string ApiGroupPrefix = "/api";
|
||||
|
||||
// Путь проверки подключения AI-провайдера.
|
||||
private const string AiCheckPath = "/ai/check";
|
||||
|
||||
// OpenAPI-тег группы (в прототипе роутер settings — settings_routes.py).
|
||||
private const string OpenApiTag = "settings";
|
||||
|
||||
/// <summary>
|
||||
@@ -59,14 +48,11 @@ public static class AiCheckEndpoint
|
||||
return Results.Ok(result);
|
||||
}
|
||||
|
||||
// Собирает запрос проверки из активной конфигурации провайдера (1:1 с ai_svc._cfg()).
|
||||
// store: KV-хранилище настроек тенанта.
|
||||
// secretCipher: Шифр секретов (расшифровка apiKey).
|
||||
// ct: Токен отмены.
|
||||
// Возвращает: Запрос проверки: id провайдера + эффективные base/model + расшифрованный ключ.
|
||||
// Эффективные значения = дефолты SettingsDefaults, перекрытые сохранёнными
|
||||
// переопределениями (Ruling 1); пустое переопределение base/model → дефолт каталога
|
||||
// (семантика «cfg.get(...) or meta[...]» прототипа).
|
||||
private static async Task<AiCheckRequest> BuildActiveCheckRequestAsync(
|
||||
ISettingsStore store,
|
||||
ISecretCipher secretCipher,
|
||||
|
||||
@@ -7,40 +7,22 @@ using Deal.Contracts.Integrations.Models;
|
||||
namespace Deal.Api.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Эндпоинты ИИ-предложений: POST /api/ai/suggest-columns и POST /api/ai/suggest-keywords
|
||||
/// (план Task 14 L476–479; прототип dashboard_routes.py L395–409).
|
||||
/// Эндпоинты ИИ-предложений
|
||||
/// </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
|
||||
{
|
||||
// Префикс группы AI-эндпоинтов этапа (роутер dashboard, prefix="/api"; пути L395/L406).
|
||||
private const string AiGroupPrefix = "/api/ai";
|
||||
|
||||
// Путь предложения колонок (dashboard_routes.py L395).
|
||||
private const string SuggestColumnsPath = "/suggest-columns";
|
||||
|
||||
// Путь предложения ключевых слов (dashboard_routes.py L404).
|
||||
private const string SuggestKeywordsPath = "/suggest-keywords";
|
||||
|
||||
// OpenAPI-тег группы (в прототипе роутер dashboard — dashboard_routes.py).
|
||||
private const string OpenApiTag = "dashboard";
|
||||
|
||||
// SSE-тип события тоста (Ruling 5; api.js слушает 'toast').
|
||||
private const string ToastEventType = "toast";
|
||||
|
||||
// Текст тоста после успешных предложений колонок (suggest.py L162, 1:1).
|
||||
private const string SuggestToastTextFormat = "ИИ предложил колонок: {0} — откройте и решите";
|
||||
|
||||
// Иконка тоста предложений колонок (sparkles, 1:1 с прототипом).
|
||||
private const string SparklesIcon = "sparkles";
|
||||
|
||||
/// <summary>
|
||||
@@ -57,9 +39,7 @@ public static class AiSuggestEndpoints
|
||||
}
|
||||
|
||||
// POST /api/ai/suggest-columns: анализ «Неразобранного» и создание колонок-предложений.
|
||||
// Ответ — результат порта 1:1: {ok:true, created:N} — доски suggested=true созданы (эндпоинт шлёт
|
||||
// SSE-toast), {ok:false, reason} (+ cooldown) — мягкая причина (HTTP 200). Кулдаун/«мало карточек»/
|
||||
// «похожие колонки уже есть» — за адаптером LocalColumnSuggester (Ruling 3).
|
||||
private static async Task<IResult> SuggestColumnsAsync(HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!context.HasUser())
|
||||
@@ -82,7 +62,6 @@ public static class AiSuggestEndpoints
|
||||
}
|
||||
|
||||
// POST /api/ai/suggest-keywords: слова-маркеры сферы по карточкам (настройки «Сфера и ключи»).
|
||||
// Ответ — результат порта 1:1: {ok:true, keywords:[…]} (≤60) либо {ok:false, reason} (HTTP 200).
|
||||
private static async Task<IResult> SuggestKeywordsAsync(HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!context.HasUser())
|
||||
|
||||
@@ -11,13 +11,8 @@ using CookieOptions = Deal.Api.Configuration.CookieOptions;
|
||||
namespace Deal.Api.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// HTTP-эндпоинты аутентификации (группа /api/auth). Контракт 1:1 с прототипом auth_routes.py.
|
||||
/// HTTP-эндпоинты аутентификации
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Успех-ответы — <c>{ok:true,...}</c>, ошибки — HTTP-код + <c>{"detail":"..."}</c> (Ruling 10).
|
||||
/// Кука сессии выставляется на login и change-password (свежий токен). Сообщения об ошибках —
|
||||
/// фиксированные строки прототипа.
|
||||
/// </remarks>
|
||||
public static class AuthEndpoints
|
||||
{
|
||||
private const string InvalidCredentialsDetail = "Неверный логин или пароль";
|
||||
@@ -28,7 +23,7 @@ public static class AuthEndpoints
|
||||
private const string AuthOpenApiTag = "auth";
|
||||
|
||||
/// <summary>
|
||||
/// Регистрирует группу /api/auth: login, logout, me, change-password.
|
||||
/// Регистрирует группу /api/auth
|
||||
/// </summary>
|
||||
/// <param name="app">Построитель маршрутов приложения.</param>
|
||||
/// <returns>Построитель маршрутов для цепочки вызовов.</returns>
|
||||
@@ -36,7 +31,6 @@ public static class AuthEndpoints
|
||||
{
|
||||
var group = app.MapGroup(AuthGroupPrefix).WithTags(AuthOpenApiTag);
|
||||
|
||||
// Политика "auth" rate limiter (план Task 11, Ruling 5): фиксированное окно 10/мин на IP
|
||||
// ручки входа; остальные ручки группы — под глобальной API-политикой (по тенанту/IP).
|
||||
group.MapPost("/login", LoginAsync).RequireRateLimiting(RateLimitPolicies.AuthPolicy);
|
||||
group.MapPost("/logout", LogoutAsync);
|
||||
@@ -46,8 +40,6 @@ public static class AuthEndpoints
|
||||
return app;
|
||||
}
|
||||
|
||||
// POST /api/auth/login: проверка учётных данных, выдача куки сессии; результат пишется в аудит (Task 4/7).
|
||||
// До AuthService отрабатывает LoginAttemptGuard (5 неудач ip|login за 15 мин → 429, Ruling 5).
|
||||
private static async Task<IResult> LoginAsync(
|
||||
LoginRequest body,
|
||||
AuthService authService,
|
||||
@@ -59,7 +51,6 @@ public static class AuthEndpoints
|
||||
{
|
||||
string? attemptedLogin = NormalizeLogin(body.Login);
|
||||
|
||||
// Защита входа (план Task 11, Ruling 5): блокировка ключа ip|login до проверки учётных данных —
|
||||
// 429 «Слишком много попыток входа…» (в dev при RateLimit:Enabled=false гвард выключен).
|
||||
if (await loginAttemptGuard.IsBlockedAsync(ClientIp(context), attemptedLogin, ct))
|
||||
{
|
||||
@@ -68,10 +59,6 @@ public static class AuthEndpoints
|
||||
|
||||
var result = await authService.LoginAsync(body.Login, body.Password, ct);
|
||||
|
||||
// Приостановленный тенант: вход заблокирован (Ruling 10(5)). Отдельный текст от «неверных учётных
|
||||
// данных»; tenant_login_failed пишется с tenantId и ActorId (ревью Task 4: failed-логины suspended-тенанта).
|
||||
// Решение Task 7: HTTP-код 403 (а не 401) — учётка существует, доступ запрещён; приёмочный текст плана
|
||||
// Task 16 формулирует «login 401» — отклонение зафиксировано для api-map/техдок в task-7-report.md.
|
||||
if (result.Error == LoginResultDto.ErrorTenantSuspended)
|
||||
{
|
||||
await auditService.AppendAsync(new AuditRecordDto(
|
||||
@@ -87,8 +74,6 @@ public static class AuthEndpoints
|
||||
|
||||
if (result.Login is null || result.Token is null)
|
||||
{
|
||||
// Пустой/пробельный login и неверные учётные данные — одно сообщение (семантика прототипа).
|
||||
// Аудит tenant_login_failed пишем только для реальной попытки (непустой логин), без пароля (Ruling 4);
|
||||
// счётчик неудач гварда растёт там же — пустые логины ключа не имеют (блокирует только auth-политика).
|
||||
if (attemptedLogin is not null)
|
||||
{
|
||||
@@ -105,7 +90,6 @@ public static class AuthEndpoints
|
||||
return EndpointResults.Unauthorized(InvalidCredentialsDetail);
|
||||
}
|
||||
|
||||
// Успешный вход сбрасывает счётчик неудач ключа ip|login (Ruling 5).
|
||||
await loginAttemptGuard.ResetAsync(ClientIp(context), result.Login, ct);
|
||||
|
||||
await auditService.AppendAsync(new AuditRecordDto(
|
||||
@@ -121,7 +105,6 @@ public static class AuthEndpoints
|
||||
}
|
||||
|
||||
// POST /api/auth/logout: удаление сессии по токену из куки и очистка куки (всегда ok).
|
||||
// Если удалённая сессия была impersonation — пишется аудит impersonation_stopped (Task 7, ревью: полный аудит).
|
||||
private static async Task<IResult> LogoutAsync(
|
||||
AuthService authService,
|
||||
AuditService auditService,
|
||||
@@ -146,7 +129,6 @@ public static class AuthEndpoints
|
||||
DetailJson: AuditService.ToDetailJson(new { login = logout.Login })), ct);
|
||||
}
|
||||
|
||||
// Выход пользователя тенанта (этап 10, T1): событие пишется при живой разрешённой сессии.
|
||||
if (user is not null)
|
||||
{
|
||||
await AuditAppender.AppendTenantAsync(context, AuditEvents.TenantLogout, new { login = user.Login }, ct);
|
||||
@@ -185,7 +167,6 @@ public static class AuthEndpoints
|
||||
var result = await authService.ChangePasswordAsync(user.Login, body.OldPassword, body.NewPassword, ct);
|
||||
if (!result.Ok || result.NewToken is null)
|
||||
{
|
||||
// Семантика прототипа: код ошибки различает «старый пароль неверен» и «слишком короткий».
|
||||
var detail = result.Error == ChangePasswordResultDto.ErrorTooShort
|
||||
? PasswordTooShortDetail
|
||||
: WrongOldPasswordDetail;
|
||||
|
||||
@@ -11,15 +11,8 @@ using Deal.Modules.Tenants.Application.Models;
|
||||
namespace Deal.Api.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Детальные операции карточки: создание локальной, «взять в работу», патч, ссылки, файлы,
|
||||
/// напоминания, очистка «Отклонено» — продолжение группы /api/cards (этап 9, T6).
|
||||
/// Детальные операции карточки
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Единый контракт /api/cards (R5): операции проектной карточки (патч полей, ссылки, файлы, напоминания)
|
||||
/// теперь живут на том же ресурсе карточки. Список/чтение/перенос/комментарии/корзина — в
|
||||
/// <see cref="CardsEndpoints"/>; здесь — уникальные подпути. Все мутации возвращают обновлённую
|
||||
/// единую карточку (чтение после записи через <see cref="CardsService"/>). Все эндпоинты требуют сессию.
|
||||
/// </remarks>
|
||||
public static class CardDetailsEndpoints
|
||||
{
|
||||
// Префикс группы (общий с CardsEndpoints).
|
||||
@@ -83,7 +76,7 @@ public static class CardDetailsEndpoints
|
||||
private const string FileNameQuoteCharacter = "\"";
|
||||
|
||||
/// <summary>
|
||||
/// Регистрирует уникальные подпути /api/cards (создание, take, патч, ссылки, файлы, напоминания).
|
||||
/// Регистрирует уникальные подпути /api/cards
|
||||
/// </summary>
|
||||
/// <param name="app">Построитель маршрутов приложения.</param>
|
||||
/// <returns>Построитель маршрутов для цепочки вызовов.</returns>
|
||||
@@ -122,7 +115,6 @@ public static class CardDetailsEndpoints
|
||||
CardsService service = context.RequestServices.GetRequiredService<CardsService>();
|
||||
CardDto created = await service.CreateLocalCardAsync(ToCreateLocalDto(body), ct);
|
||||
|
||||
// Аудит создания карточки (этап 10, T1).
|
||||
await AuditAppender.AppendTenantAsync(context, AuditEvents.CardCreated, new { cardId = created.Id }, ct);
|
||||
return await ReadCardAsync(context, created.Id, ct);
|
||||
}
|
||||
@@ -433,7 +425,6 @@ public static class CardDetailsEndpoints
|
||||
// Возвращает: Имя, безопасное для заголовка.
|
||||
private static string ToDownloadFileName(string name) => name.Replace(FileNameQuoteCharacter, string.Empty);
|
||||
|
||||
// Переводит тело POST /api/cards в начальные поля сервиса (поля 1:1 с CardLocalCreateDto).
|
||||
// body: Тело запроса.
|
||||
// Возвращает: DTO модуля для CardsService.CreateLocalCardAsync.
|
||||
private static CardLocalCreateDto ToCreateLocalDto(CreateCardRequest body)
|
||||
|
||||
@@ -14,21 +14,8 @@ using Deal.Modules.Tenants.Application.Models;
|
||||
namespace Deal.Api.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Эндпоинты карточек и поиска: GET /api/cards[?containerId=], /cards/counts, /cards/{id},
|
||||
/// mark-all-seen/mark-col-seen, move/trash/restore/DELETE, clear-col, comments, reclassify (batch + {id}),
|
||||
/// GET /api/search (этап 9, T6).
|
||||
/// Эндпоинты карточек и поиска
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Единый контракт /api/cards (R5): старые ручки /api/leads, /api/projects, /api/boards и /api/columns
|
||||
/// упразднены. GET /cards →
|
||||
/// {items}; counts — плоская wire-форма {new, <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
|
||||
{
|
||||
// Префикс группы карточек.
|
||||
@@ -40,26 +27,22 @@ public static class CardsEndpoints
|
||||
// Путь поиска (GET).
|
||||
private const string SearchPath = "/search";
|
||||
|
||||
// OpenAPI-тег группы (в прототипе роутер dashboard — dashboard_routes.py).
|
||||
private const string OpenApiTag = "dashboard";
|
||||
|
||||
// 404: карточка не найдена (dashboard_routes.py _lead_or_404 L92–96).
|
||||
private const string CardNotFoundDetail = "Карточка не найдена";
|
||||
|
||||
// 400 GET /cards: containerId не существует.
|
||||
private const string UnknownColumnDetail = "Неизвестный контейнер";
|
||||
|
||||
// Инициатор перехода при ручном переносе — действие пользователя (R4 этапа 9).
|
||||
private const string UserActor = "user";
|
||||
|
||||
// SSE-тип события завершения переклассификации (этап 12, остаток 2; api.js слушает 'cards_reclassified').
|
||||
private const string ReclassifiedEventType = "cards_reclassified";
|
||||
|
||||
// Контекст ручного перехода карточки: пользователь, обучение ML по цели переноса.
|
||||
private static readonly TransitionContext UserMoveContext = new() { Actor = UserActor, Learn = true };
|
||||
|
||||
/// <summary>
|
||||
/// Регистрирует группы /api/cards и /api (карточки + поиск). Статические сегменты — до /cards/{cardId}.
|
||||
/// Регистрирует группы /api/cards и /api
|
||||
/// </summary>
|
||||
/// <param name="app">Построитель маршрутов приложения.</param>
|
||||
/// <returns>Построитель маршрутов для цепочки вызовов.</returns>
|
||||
@@ -112,7 +95,6 @@ public static class CardsEndpoints
|
||||
return Results.Ok(new { items = cards });
|
||||
}
|
||||
|
||||
// GET /api/cards/counts: плоская wire-форма счётчиков {new, <col>:{count,new}, learning, ml, ai} (L161–163, §4.1 L257).
|
||||
private static async Task<IResult> CountsAsync(HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!context.HasUser())
|
||||
@@ -123,7 +105,6 @@ public static class CardsEndpoints
|
||||
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
|
||||
CardCountsDto counts = await cardsService.CountsAsync(ct);
|
||||
|
||||
// Разворачивание CardCountsDto: колонки — корневые ключи (counts L268–279), служебные — фиксированные.
|
||||
var wire = new Dictionary<string, object> { ["new"] = counts.New };
|
||||
foreach ((string col, CardColumnCountDto column) in counts.Columns)
|
||||
{
|
||||
@@ -136,7 +117,6 @@ public static class CardsEndpoints
|
||||
return Results.Ok(wire);
|
||||
}
|
||||
|
||||
// GET /api/cards/{cardId}: одна карточка; 404 «Карточка не найдена» (L166–168).
|
||||
private static async Task<IResult> GetCardAsync(
|
||||
string cardId,
|
||||
HttpContext context,
|
||||
@@ -154,7 +134,6 @@ public static class CardsEndpoints
|
||||
: Results.Ok(card);
|
||||
}
|
||||
|
||||
// POST /api/cards/mark-all-seen: снять «новое» со всех карточек (L177–180); ответ {ok:true}.
|
||||
private static async Task<IResult> MarkAllSeenAsync(HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!context.HasUser())
|
||||
@@ -167,7 +146,6 @@ public static class CardsEndpoints
|
||||
return Results.Ok(new { ok = true });
|
||||
}
|
||||
|
||||
// POST /api/cards/mark-col-seen: снять «новое» с колонки (L187–191); ответ {ok:true}.
|
||||
private static async Task<IResult> MarkColSeenAsync(
|
||||
MarkColBody body,
|
||||
HttpContext context,
|
||||
@@ -181,7 +159,6 @@ public static class CardsEndpoints
|
||||
if (body.Col is null)
|
||||
{
|
||||
// Пустая/отсутствующая col попала бы в mark_seen как «не задана» и сняла бы «новое» со ВСЕХ
|
||||
// карточек (truthiness python, L250–256) — эндпоинт защищает от вызова с null (прототип: 422).
|
||||
return EndpointResults.BadRequest(UnknownColumnDetail);
|
||||
}
|
||||
|
||||
@@ -190,7 +167,6 @@ public static class CardsEndpoints
|
||||
return Results.Ok(new { ok = true });
|
||||
}
|
||||
|
||||
// POST /api/cards/{cardId}/move {to}: перенос карточки между контейнерами (этап 9, R4).
|
||||
// Маршрутизацию цели (стадия «Выбранных» vs дашборд-контейнер) и побочные эффекты выполняет единый
|
||||
// доменный механизм перехода ICardMover: стадия — запись истории и сброс напоминания
|
||||
// (move_stage), дашборд-контейнер — журнал/обучение ML. Ответ — обновлённая карточка; 400 при
|
||||
@@ -218,7 +194,6 @@ public static class CardsEndpoints
|
||||
return EndpointResults.NotFound(CardNotFoundDetail);
|
||||
}
|
||||
|
||||
// Аудит переноса карточки (этап 10, T1): цель — минимальный безопасный идентификатор.
|
||||
await AuditAppender.AppendTenantAsync(context, AuditEvents.CardMoved, new { cardId, to = body.To }, ct);
|
||||
|
||||
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
|
||||
@@ -228,7 +203,6 @@ public static class CardsEndpoints
|
||||
: Results.Ok(unified);
|
||||
}
|
||||
|
||||
// POST /api/cards/{cardId}/trash: в корзину + обучение ML spam (L203–207); ответ {ok:true}; 404.
|
||||
private static async Task<IResult> TrashAsync(
|
||||
string cardId,
|
||||
HttpContext context,
|
||||
@@ -246,12 +220,10 @@ public static class CardsEndpoints
|
||||
return EndpointResults.NotFound(CardNotFoundDetail);
|
||||
}
|
||||
|
||||
// Аудит отправки карточки в корзину (этап 10, T1).
|
||||
await AuditAppender.AppendTenantAsync(context, AuditEvents.CardTrashed, new { cardId }, ct);
|
||||
return Results.Ok(new { ok = true });
|
||||
}
|
||||
|
||||
// POST /api/cards/{cardId}/restore: возврат из архив/корзины на канбан (L210–214); ответ {ok, col}; 404.
|
||||
private static async Task<IResult> RestoreAsync(
|
||||
string cardId,
|
||||
HttpContext context,
|
||||
@@ -269,12 +241,10 @@ public static class CardsEndpoints
|
||||
return EndpointResults.NotFound(CardNotFoundDetail);
|
||||
}
|
||||
|
||||
// Аудит возврата карточки из корзины/архива (этап 10, T1).
|
||||
await AuditAppender.AppendTenantAsync(context, AuditEvents.CardRestored, new { cardId, col }, ct);
|
||||
return Results.Ok(new { ok = true, col });
|
||||
}
|
||||
|
||||
// DELETE /api/cards/{cardId}: удалить навсегда (Cards + комментарии; L217–221); ответ {ok:true}; 404.
|
||||
private static async Task<IResult> DeleteAsync(
|
||||
string cardId,
|
||||
HttpContext context,
|
||||
@@ -292,12 +262,10 @@ public static class CardsEndpoints
|
||||
return EndpointResults.NotFound(CardNotFoundDetail);
|
||||
}
|
||||
|
||||
// Аудит удаления карточки навсегда (этап 10, T1).
|
||||
await AuditAppender.AppendTenantAsync(context, AuditEvents.CardDeleted, new { cardId }, ct);
|
||||
return Results.Ok(new { ok = true });
|
||||
}
|
||||
|
||||
// POST /api/cards/clear-col {col}: очистить корзину/архив (L228–235); ответ {ok, cleared}; 400.
|
||||
private static async Task<IResult> ClearColAsync(
|
||||
ClearColBody body,
|
||||
HttpContext context,
|
||||
@@ -315,7 +283,6 @@ public static class CardsEndpoints
|
||||
: Results.Ok(new { ok = true, cleared = result.Cleared });
|
||||
}
|
||||
|
||||
// POST /api/cards/{cardId}/comments {text}: добавить комментарий (L238–242); ответ {comments}; 400 «Пустой комментарий»; 404.
|
||||
private static async Task<IResult> AddCommentAsync(
|
||||
string cardId,
|
||||
CommentBody body,
|
||||
@@ -339,7 +306,6 @@ public static class CardsEndpoints
|
||||
return EndpointResults.NotFound(CardNotFoundDetail);
|
||||
}
|
||||
|
||||
// Аудит добавления комментария (этап 10, T1): текст комментария в детали не пишется.
|
||||
await AuditAppender.AppendTenantAsync(context, AuditEvents.CardCommentAdded, new { cardId }, ct);
|
||||
return Results.Ok(new { comments = result.Comments });
|
||||
}
|
||||
@@ -414,7 +380,6 @@ public static class CardsEndpoints
|
||||
reason = result.Reason,
|
||||
};
|
||||
|
||||
// Публикует SSE cards_reclassified после успешного прохода (Ruling 5: публикации — из Api).
|
||||
// Публикуется только когда проход реально выполнен и что-то изменил (started и
|
||||
// reclassified > 0): пустой inbox/всё пропущено не меняют доску — событие не шлём. Нагрузка
|
||||
// минимальная: сколько обработано и перемещено (фронт перечитывает доску). Без подписчиков — no-op.
|
||||
@@ -455,7 +420,6 @@ public static class CardsEndpoints
|
||||
ct);
|
||||
}
|
||||
|
||||
// GET /api/search?q=: поиск карточек (FTS + LIKE, Ruling 6/Task 12; dashboard_routes L254–256). Ответ {leads, messages: []}.
|
||||
private static async Task<IResult> SearchAsync(
|
||||
string? q,
|
||||
HttpContext context,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
namespace Deal.Api.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Тело POST /api/auth/change-password. Входящий JSON — camelCase (oldPassword, newPassword).
|
||||
/// Тело POST /api/auth/change-password.
|
||||
/// </summary>
|
||||
/// <param name="OldPassword">Текущий пароль.</param>
|
||||
/// <param name="NewPassword">Новый пароль (минимум 8 символов).</param>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
namespace Deal.Api.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Тело POST /api/admin/check-message (1:1 с CheckMessageBody, dashboard_routes.py L72–73).
|
||||
/// Тело POST /api/admin/check-message.
|
||||
/// </summary>
|
||||
/// <param name="Text">Текст сообщения для проверки фильтром (этап 1 + этап 2 тестера).</param>
|
||||
/// <param name="Text">Текст сообщения для проверки фильтром.</param>
|
||||
public sealed record CheckMessageRequest(string Text);
|
||||
|
||||
@@ -9,15 +9,8 @@ using Deal.Modules.Tenants.Application.Models;
|
||||
namespace Deal.Api.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Эндпоинты контейнеров (колонок/стадий/зон) и состояния колонок: GET/POST /api/containers,
|
||||
/// PATCH /{id}/accept, PATCH/DELETE /{id}, POST /reorder, GET/PATCH state (этап 9, T4/T6).
|
||||
/// Эндпоинты контейнеров
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Единый реестр контейнеров приходит на смену /api/boards + /api/columns (R5): список, создание,
|
||||
/// частичное обновление, принятие ИИ-предложения, удаление с переносом карточек в inbox, reorder и
|
||||
/// состояние колонок (colState). Все эндпоинты требуют сессию: 401 {detail}. ContainersService
|
||||
/// резолвится из RequestServices ПОСЛЕ проверки сессии.
|
||||
/// </remarks>
|
||||
public static class ContainersEndpoints
|
||||
{
|
||||
// Префикс группы контейнеров.
|
||||
@@ -42,7 +35,7 @@ public static class ContainersEndpoints
|
||||
private static readonly JsonSerializerOptions RequestJsonOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
/// <summary>
|
||||
/// Регистрирует группы /api/containers (контейнеры + состояние колонок).
|
||||
/// Регистрирует группы /api/containers
|
||||
/// </summary>
|
||||
/// <param name="app">Построитель маршрутов приложения.</param>
|
||||
/// <returns>Построитель маршрутов для цепочки вызовов.</returns>
|
||||
@@ -104,7 +97,6 @@ public static class ContainersEndpoints
|
||||
Note: body.Note ?? string.Empty),
|
||||
ct);
|
||||
|
||||
// Аудит создания контейнера (этап 10, T1).
|
||||
await AuditAppender.AppendTenantAsync(context, AuditEvents.ContainerCreated, new { id = created.Id, name = created.Name }, ct);
|
||||
return Results.Ok(new { id = created.Id });
|
||||
}
|
||||
@@ -168,7 +160,6 @@ public static class ContainersEndpoints
|
||||
return EndpointResults.NotFound(ContainerNotFoundDetail);
|
||||
}
|
||||
|
||||
// Аудит изменения контейнера (этап 10, T1).
|
||||
await AuditAppender.AppendTenantAsync(context, AuditEvents.ContainerUpdated, new { id = updated.Id }, ct);
|
||||
return Results.Ok(new { id = updated.Id });
|
||||
}
|
||||
@@ -191,7 +182,6 @@ public static class ContainersEndpoints
|
||||
return EndpointResults.NotFound(ContainerNotFoundDetail);
|
||||
}
|
||||
|
||||
// Аудит изменения контейнера (принятие ИИ-предложения) — этап 10, T1.
|
||||
await AuditAppender.AppendTenantAsync(context, AuditEvents.ContainerUpdated, new { id = accepted.Id }, ct);
|
||||
return Results.Ok(accepted);
|
||||
}
|
||||
@@ -210,7 +200,6 @@ public static class ContainersEndpoints
|
||||
ContainersService containers = context.RequestServices.GetRequiredService<ContainersService>();
|
||||
int moved = await containers.DeleteAsync(containerId, ct);
|
||||
|
||||
// Аудит удаления контейнера (этап 10, T1).
|
||||
await AuditAppender.AppendTenantAsync(context, AuditEvents.ContainerDeleted, new { id = containerId }, ct);
|
||||
return Results.Ok(new { ok = true, movedToInbox = moved });
|
||||
}
|
||||
|
||||
@@ -14,27 +14,12 @@ using Deal.Modules.Telegram.Application;
|
||||
namespace Deal.Api.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Эндпоинты /api/discovery: задачи поиска, кандидаты, чёрный список, лог, генерация ключей (Ruling 11, api-map §3.8).
|
||||
/// Эндпоинты /api/discovery
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 13 эндпоинтов 1:1 с <c>backend/app/routers/discovery_routes.py</c> (prefix /api/discovery): tasks
|
||||
/// (list/create/patch/delete/start/pause), generate-keywords (мягкая ошибка HTTP 200 {keywords: [], error},
|
||||
/// Ruling 11), candidates (статус-фильтр), join/reject (ручные, вне квот воркера), blacklist, log. Тела ответов —
|
||||
/// DTO модуля Discovery (camelCase, §4.8 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
|
||||
{
|
||||
// Префикс группы /api/discovery (python: router prefix, discovery_routes.py L26).
|
||||
private const string DiscoveryGroupPrefix = "/api/discovery";
|
||||
|
||||
// OpenAPI-тег группы (в прототипе роутер discovery — discovery_routes.py).
|
||||
private const string DiscoveryOpenApiTag = "discovery";
|
||||
|
||||
// Путь списка задач (GET).
|
||||
@@ -70,38 +55,28 @@ public static class DiscoveryEndpoints
|
||||
// Путь лога задачи (GET).
|
||||
private const string TaskLogPath = "/tasks/{task_id}/log";
|
||||
|
||||
// 404 create/start/patch/candidates/log: задачи нет (python _task_or_404 L83–87).
|
||||
private const string TaskNotFoundDetail = "Задача не найдена";
|
||||
|
||||
// 404 join/reject: кандидата нет (python _candidate_or_404 L90–94).
|
||||
private const string CandidateNotFoundDetail = "Кандидат не найден";
|
||||
|
||||
// 400 join: уже вступили (python L235–237).
|
||||
private const string AlreadyJoinedDetail = "Уже вступили в этот источник";
|
||||
|
||||
// 400 reject: источник уже вступили (python L256–258).
|
||||
private const string JoinedRejectDetail = "Уже вступили — удалите источник из каналов";
|
||||
|
||||
// 400 join: ошибка Telegram при вступлении (python L240–242, текст с @username).
|
||||
private const string JoinFailedFormat = "Не удалось вступить в @{0}: {1}";
|
||||
|
||||
// Причина отклонения вручную для чёрного списка/лога (python L260: reason="отклонено вручную").
|
||||
private const string ManualRejectReason = "отклонено вручную";
|
||||
|
||||
// Мягкая ошибка generate-keywords: ИИ выключен (python _ai_unavailable_reason L99–100).
|
||||
private const string AiDisabledDetail = "ИИ выключен в настройках (aiEnabled)";
|
||||
|
||||
// Мягкая ошибка generate-keywords: описания нет (python L201–202).
|
||||
private const string NoDescriptionDetail = "У задачи нет описания — по нему генерируются ключи";
|
||||
|
||||
// Страховочный потолок числа сгенерированных ключей (python _KEYWORDS_LIMIT L31: промпт просит 10–16).
|
||||
private const int KeywordsLimit = 30;
|
||||
|
||||
// Потолок длины одного ключа (python _KEYWORD_LENGTH_LIMIT L33: короткие фразы для поиска Telegram).
|
||||
private const int KeywordLengthLimit = 60;
|
||||
|
||||
/// <summary>
|
||||
/// Регистрирует группу /api/discovery: 13 эндпоинтов (tasks + generate-keywords + candidates + join/reject + blacklist + log).
|
||||
/// Регистрирует группу /api/discovery
|
||||
/// </summary>
|
||||
/// <param name="app">Построитель маршрутов приложения.</param>
|
||||
/// <returns>Построитель маршрутов для цепочки вызовов.</returns>
|
||||
@@ -126,7 +101,6 @@ public static class DiscoveryEndpoints
|
||||
return app;
|
||||
}
|
||||
|
||||
// GET /api/discovery/tasks: список задач, старые первыми (list_tasks L141–143).
|
||||
private static async Task<IResult> ListTasksAsync(HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!context.HasUser())
|
||||
@@ -139,7 +113,6 @@ public static class DiscoveryEndpoints
|
||||
return Results.Ok(new { items });
|
||||
}
|
||||
|
||||
// POST /api/discovery/tasks: создать задачу поиска (create_task L146–151; дефолты — в сервисе).
|
||||
private static async Task<IResult> CreateTaskAsync(
|
||||
DiscoveryTaskCreateBody body,
|
||||
HttpContext context,
|
||||
@@ -162,7 +135,6 @@ public static class DiscoveryEndpoints
|
||||
}
|
||||
}
|
||||
|
||||
// PATCH /api/discovery/tasks/{task_id}: обновить задачу (patch_task L154–161; 404/400).
|
||||
private static async Task<IResult> PatchTaskAsync(
|
||||
string task_id,
|
||||
DiscoveryTaskPatchBody body,
|
||||
@@ -186,7 +158,6 @@ public static class DiscoveryEndpoints
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /api/discovery/tasks/{task_id}: удалить задачу с кандидатами и логом (delete_task L164–168).
|
||||
private static async Task<IResult> DeleteTaskAsync(
|
||||
string task_id,
|
||||
HttpContext context,
|
||||
@@ -202,7 +173,6 @@ public static class DiscoveryEndpoints
|
||||
return deleted ? Results.Ok(new { ok = true }) : EndpointResults.NotFound(TaskNotFoundDetail);
|
||||
}
|
||||
|
||||
// POST /api/discovery/tasks/{task_id}/start: запуск поиска (start_task L171–179; пустые ключи → 400).
|
||||
private static async Task<IResult> StartTaskAsync(
|
||||
string task_id,
|
||||
HttpContext context,
|
||||
@@ -225,7 +195,6 @@ public static class DiscoveryEndpoints
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/discovery/tasks/{task_id}/pause: пауза поиска (pause_task L181–187).
|
||||
private static async Task<IResult> PauseTaskAsync(
|
||||
string task_id,
|
||||
HttpContext context,
|
||||
@@ -242,8 +211,6 @@ public static class DiscoveryEndpoints
|
||||
}
|
||||
|
||||
// POST /api/discovery/tasks/{task_id}/generate-keywords: ИИ-ключи по описанию задачи
|
||||
// (generate_keywords L189–211). ИИ выключен/недоступен/нет описания → HTTP 200 {keywords: [], error}.
|
||||
// Очистка ключей ответа — CleanKeywords (python _clean_keywords L111–128: ≤30, ≤60
|
||||
// символов, дедуп casefold); описание режет до 4000 сам адаптер (GrpcAiTools.MaxDescriptionCodePoints).
|
||||
// Локальный режим (LocalAiTools, UseLocal=true) — NotSupportedException → та же мягкая ветка с текстом причины.
|
||||
private static async Task<IResult> GenerateKeywordsAsync(
|
||||
@@ -284,18 +251,15 @@ public static class DiscoveryEndpoints
|
||||
return Results.Ok(new { keywords = CleanKeywords(result.Keywords) });
|
||||
}
|
||||
|
||||
// Недоступность провайдера/сервиса — мягкая ошибка для UI (Ruling 11), HTTP 200.
|
||||
return Results.Ok(new { keywords = Array.Empty<string>(), error = result.Error ?? ServiceUnavailableText });
|
||||
}
|
||||
catch (NotSupportedException exception)
|
||||
{
|
||||
// Локальный режим: ai-service не подключён — инструменты недоступны (LocalAiTools, Task 15).
|
||||
return Results.Ok(new { keywords = Array.Empty<string>(), error = exception.Message });
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/discovery/tasks/{task_id}/candidates?status=: кандидаты задачи с фильтром
|
||||
// new|review|joined|rejected (list_candidates L216–224; невалидный статус — пустой список).
|
||||
private static async Task<IResult> ListCandidatesAsync(
|
||||
string task_id,
|
||||
string? status,
|
||||
@@ -319,7 +283,6 @@ public static class DiscoveryEndpoints
|
||||
return Results.Ok(new { items });
|
||||
}
|
||||
|
||||
// POST /api/discovery/candidates/{dialog_id}/join: ручное вступление вне квот (join_candidate L226–251).
|
||||
// RPC Join → строка каталога Dialogs (монитор on) + зеркало → фоновый первый разбор → снятие чёрного списка →
|
||||
// mark_joined(auto:false). Ошибка Telegram → 400 с текстом причины.
|
||||
private static async Task<IResult> JoinCandidateAsync(
|
||||
@@ -357,12 +320,10 @@ public static class DiscoveryEndpoints
|
||||
}
|
||||
|
||||
// Источник в каталоге (монитор on, backfilled=false) + монитор-зеркало telegram-service
|
||||
// (python add_dialog_monitored L242; решение T18: локальную строку пишет Api-слой).
|
||||
DialogsService dialogs = context.RequestServices.GetRequiredService<DialogsService>();
|
||||
await dialogs.AddDiscoveredMonitoredAsync(dialog_id, row.Name, username, row.Kind, row.Hue, ct);
|
||||
|
||||
// Догон последних сообщений — в фоне: join из UI не должен висеть на паузах backfill
|
||||
// (python _spawn(_backfill_quiet) L244–245; источник уже в каталоге и мониторится).
|
||||
context.RequestServices.GetRequiredService<TelegramBackfillScheduler>().ScheduleFirstBackfill(dialog_id);
|
||||
|
||||
DiscoveryBlacklistService blacklist = context.RequestServices.GetRequiredService<DiscoveryBlacklistService>();
|
||||
@@ -380,7 +341,6 @@ public static class DiscoveryEndpoints
|
||||
}
|
||||
|
||||
// POST /api/discovery/candidates/{dialog_id}/reject: отклонить кандидата — в чёрный список
|
||||
// (reject_candidate L253–264; уже вступившего — нельзя, 400).
|
||||
private static async Task<IResult> RejectCandidateAsync(
|
||||
string dialog_id,
|
||||
HttpContext context,
|
||||
@@ -414,7 +374,6 @@ public static class DiscoveryEndpoints
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/discovery/blacklist: чёрный список источников (list_blacklist L269–271).
|
||||
private static async Task<IResult> ListBlacklistAsync(HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!context.HasUser())
|
||||
@@ -427,7 +386,6 @@ public static class DiscoveryEndpoints
|
||||
return Results.Ok(new { items });
|
||||
}
|
||||
|
||||
// DELETE /api/discovery/blacklist/{dialog_id}: снять источник с чёрного списка (remove_blacklist L274–277).
|
||||
private static async Task<IResult> RemoveBlacklistAsync(
|
||||
string dialog_id,
|
||||
HttpContext context,
|
||||
@@ -443,7 +401,6 @@ public static class DiscoveryEndpoints
|
||||
return Results.Ok(new { ok = true });
|
||||
}
|
||||
|
||||
// GET /api/discovery/tasks/{task_id}/log: лог задачи (task_log L282–285), события от новых к старым.
|
||||
private static async Task<IResult> TaskLogAsync(
|
||||
string task_id,
|
||||
HttpContext context,
|
||||
@@ -467,10 +424,8 @@ public static class DiscoveryEndpoints
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ключи из ответа ИИ: строки без пустых/длинных и повторов (python _clean_keywords L111–128).
|
||||
/// Ключи из ответа ИИ
|
||||
/// </summary>
|
||||
/// <remarks>Повтор считается по <c>casefold</c> python: здесь — регистронезависимое сравнение
|
||||
/// (RU/EN-ключи; StringComparer.OrdinalIgnoreCase). Потолок списка — <see cref="KeywordsLimit"/>.</remarks>
|
||||
/// <param name="raw">Сырые ключи ответа модели (null — пусто).</param>
|
||||
/// <returns>Очищенные ключи (не более 30, каждый ≤60 символов).</returns>
|
||||
public static IReadOnlyList<string> CleanKeywords(IEnumerable<string>? raw)
|
||||
@@ -505,13 +460,11 @@ public static class DiscoveryEndpoints
|
||||
return outList;
|
||||
}
|
||||
|
||||
// Фолбэк-текст недоступного ИИ, если адаптер причину не вернул (мягкая ошибка, Ruling 11).
|
||||
private const string ServiceUnavailableText = "ИИ недоступен — повторите попытку через несколько секунд";
|
||||
|
||||
// Читает настройку aiEnabled (KV; отсутствие строки — дефолт SettingsDefaults).
|
||||
// settings: KV-хранилище настроек тенанта.
|
||||
// ct: Токен отмены.
|
||||
// Возвращает: True — ИИ включён (ветки выключателя отрабатывает вызывающий, Ruling 10/11).
|
||||
private static async Task<bool> ReadAiEnabledAsync(ISettingsStore settings, CancellationToken ct)
|
||||
{
|
||||
SettingValue? row = await settings.GetAsync(SettingsKeys.AiEnabled, ct);
|
||||
@@ -552,7 +505,6 @@ public static class DiscoveryEndpoints
|
||||
};
|
||||
}
|
||||
|
||||
// Маппит тело патча в сервисный патч (не-null значения; как python model_dump(exclude_none=True)).
|
||||
// body: Тело запроса (wire-поля camelCase).
|
||||
// Возвращает: Патч задачи (DiscoveryTaskPatch).
|
||||
private static DiscoveryTaskPatch ToPatch(DiscoveryTaskPatchBody body)
|
||||
|
||||
@@ -6,37 +6,23 @@ using Deal.Api.Services;
|
||||
namespace Deal.Api.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// SSE-поток событий канбана: GET /api/events (Ruling 5; прототип events_routes.py L15–38).
|
||||
/// SSE-поток событий канбана
|
||||
/// </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
|
||||
{
|
||||
// Путь потока (роутер events, events_routes.py L12: prefix="/api").
|
||||
private const string EventsPath = "/api/events";
|
||||
|
||||
// OpenAPI-тег группы (в прототипе — роутер events_routes.py).
|
||||
private const string OpenApiTag = "events";
|
||||
|
||||
// Тип контента потока (events_routes.py L32).
|
||||
private const string EventStreamContentType = "text/event-stream";
|
||||
|
||||
// Директива кеширования: поток не кешируется (events_routes.py L34).
|
||||
private const string NoCacheHeaderValue = "no-cache";
|
||||
|
||||
// Отключение буферизации ответа nginx-прокси (events_routes.py L35).
|
||||
private const string NoBufferingHeaderValue = "no";
|
||||
|
||||
// Ping-комментарий: строки протокола SSE, начинающиеся с ':', клиент игнорирует.
|
||||
private const string PingComment = ": ping\n\n";
|
||||
|
||||
// Интервал ping при тишине: держим соединение (events_routes.py L24: timeout=15).
|
||||
private static readonly TimeSpan PingInterval = TimeSpan.FromSeconds(15);
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -5,29 +5,14 @@ using Deal.Modules.Settings.Application.Models;
|
||||
namespace Deal.Api.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Тестер фильтра входящих: POST /api/admin/check-message (Ruling 8, api-map §3.2 L109, §4.10 L364).
|
||||
/// Тестер фильтра входящих
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Имитация этапов пайплайна для тестера в настройках — 1:1 с <c>dashboard_routes.py</c> 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
|
||||
{
|
||||
// Префикс группы API (общий для эндпоинтов этапа, Ruling 8).
|
||||
private const string ApiGroupPrefix = "/api";
|
||||
|
||||
// Путь тестера фильтра входящих (dashboard_routes.py L267).
|
||||
private const string CheckMessagePath = "/admin/check-message";
|
||||
|
||||
// OpenAPI-тег группы (эндпоинт Settings-экрана, Ruling 8).
|
||||
private const string OpenApiTag = "settings";
|
||||
|
||||
/// <summary>
|
||||
@@ -42,7 +27,6 @@ public static class FilterTesterEndpoints
|
||||
return app;
|
||||
}
|
||||
|
||||
// POST /api/admin/check-message: этап-1 правила + этап-2 (skipped) для тестера (dashboard_routes.py L267–284).
|
||||
private static async Task<IResult> CheckAsync(
|
||||
CheckMessageRequest body,
|
||||
HttpContext context,
|
||||
@@ -57,7 +41,6 @@ public static class FilterTesterEndpoints
|
||||
IncomingRules incomingRules = context.RequestServices.GetRequiredService<IncomingRules>();
|
||||
IncomingRulesResult stage1 = await incomingRules.CheckAsync(body.Text, ct);
|
||||
|
||||
// Ответ 1:1 с прототипом: stage2 на этапе 2 всегда skipped (Ruling 4/8, план L377–380).
|
||||
if (!stage1.Pass)
|
||||
{
|
||||
return Results.Ok(new
|
||||
|
||||
@@ -5,17 +5,8 @@ using Deal.Modules.Tenants.Application.Services;
|
||||
namespace Deal.Api.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Публичный эндпоинт активации инвайта: POST /api/join (Ruling 2/11 этапа 7).
|
||||
/// Публичный эндпоинт активации инвайта
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Ручка не требует сессии (публичная; фронт её не вызывает — API-only, curl/будущий UI). Тело
|
||||
/// {code, email, name?, password} → JoinService: валидация кода/email/пароля, CAS-резервирование инвайта,
|
||||
/// создание тенанта (при пустом TenantId — с провижинингом схемы) и пользователя. Кука НЕ ставится: после
|
||||
/// активации клиент входит обычным /api/auth/login (план Task 6). Успех — {ok:true, login}; ошибки — 400
|
||||
/// {detail} с фиксированным текстом причины (все отказы активации — 400, включая истёкший инвайт: слой
|
||||
/// эндпоинта, см. Task 6; Ruling 2 называет это «410-семантикой» — ресурс больше недоступен). Результат
|
||||
/// пишется в аудит — invite_joined (актор — новый пользователь тенанта, детали email+codeHash).
|
||||
/// </remarks>
|
||||
public static class JoinEndpoint
|
||||
{
|
||||
// Путь ручки (вне группы /api/operator — публичная).
|
||||
@@ -27,7 +18,6 @@ public static class JoinEndpoint
|
||||
// Текст 400: приглашение с таким кодом не найдено.
|
||||
private const string InviteNotFoundDetail = "Приглашение не найдено";
|
||||
|
||||
// Текст 400: срок действия приглашения истёк (план Task 6, Ruling 2).
|
||||
private const string InviteExpiredDetail = "Срок действия приглашения истёк";
|
||||
|
||||
// Текст 400: приглашение уже активировано (повторная активация тем же кодом).
|
||||
@@ -36,13 +26,10 @@ public static class JoinEndpoint
|
||||
// Текст 400: приглашение отозвано оператором.
|
||||
private const string InviteRevokedDetail = "Приглашение отозвано";
|
||||
|
||||
// Текст 400: email запроса не совпадает с email приглашения (Ruling 2).
|
||||
private const string EmailMismatchDetail = "Email не совпадает с приглашением";
|
||||
|
||||
// Текст 400: пользователь с таким email уже зарегистрирован (users.login unique, Ruling 2).
|
||||
private const string EmailTakenDetail = "Этот email уже зарегистрирован";
|
||||
|
||||
// Текст 400: пароль короче минимума (текст как в AuthEndpoints, план Task 6).
|
||||
private const string PasswordTooShortDetail = "Пароль слишком короткий (минимум 8 символов)";
|
||||
|
||||
// Текст 400: целевой тенант инвайта не существует (Security review).
|
||||
@@ -62,7 +49,6 @@ public static class JoinEndpoint
|
||||
return app;
|
||||
}
|
||||
|
||||
// POST /api/join: активация инвайта; успех пишется в аудит (invite_activated, Task 6/Ruling 4).
|
||||
private static async Task<IResult> JoinAsync(
|
||||
JoinRequest body,
|
||||
JoinService joinService,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
namespace Deal.Api.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Тело POST /api/join — активация инвайта (Ruling 2, Task 6 этапа 7). Входящий JSON — camelCase (code, email, name?, password).
|
||||
/// Тело POST /api/join — активация инвайта.
|
||||
/// </summary>
|
||||
/// <param name="Code">Код приглашения (16 url-safe символов).</param>
|
||||
/// <param name="Email">Email активирующего; обязан совпасть с email приглашения (нормализует JoinService).</param>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
namespace Deal.Api.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Тело POST /api/auth/login. Входящий JSON — camelCase (login, password).
|
||||
/// Тело POST /api/auth/login.
|
||||
/// </summary>
|
||||
/// <param name="Login">Логин пользователя.</param>
|
||||
/// <param name="Password">Пароль в открытом виде.</param>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
namespace Deal.Api.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Тело POST /api/ml/apply. Входящий JSON — camelCase (dialogId, msgId, action).
|
||||
/// Тело POST /api/ml/apply.
|
||||
/// </summary>
|
||||
/// <param name="DialogId">Id диалога/канала Telegram, где лежит исходное сообщение.</param>
|
||||
/// <param name="MsgId">Id сообщения внутри диалога.</param>
|
||||
/// <param name="Action">Ручное решение: spam | board:<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);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
namespace Deal.Api.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Тело POST /api/ml/candidates. Входящий JSON — camelCase (dialogId, limit).
|
||||
/// Тело POST /api/ml/candidates.
|
||||
/// </summary>
|
||||
/// <param name="DialogId">Id диалога/канала Telegram; пусто — выборка по всем источникам тенанта (§8).</param>
|
||||
/// <param name="Limit">Сколько последних сообщений вернуть (кламп 1..60, дефолт 10).</param>
|
||||
|
||||
@@ -8,27 +8,12 @@ using Deal.Modules.Pipeline.Application.Services;
|
||||
namespace Deal.Api.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Эндпоинты ML-панели: GET /api/ml/status, POST /api/ml/reset, /predict, /candidates, /apply (Ruling 8, api-map §3.7).
|
||||
/// Эндпоинты ML-панели
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Тела ответов 1:1 с прототипом <c>backend/app/routers/ml_routes.py</c> (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
|
||||
{
|
||||
// Префикс группы /api/ml (Ruling 8: MapMlEndpoints).
|
||||
private const string MlGroupPrefix = "/api/ml";
|
||||
|
||||
// OpenAPI-тег группы (в прототипе роутер ml — ml_routes.py).
|
||||
private const string MlOpenApiTag = "ml";
|
||||
|
||||
// Путь статуса ML (GET).
|
||||
@@ -46,20 +31,16 @@ public static class MlEndpoints
|
||||
// Путь ручного решения по сообщению (POST).
|
||||
private const string ApplyPath = "/apply";
|
||||
|
||||
// Минимальная длина текста для проверки (ml_routes.py L87: len(text) < 2 → 400).
|
||||
private const int MinPredictTextLength = 2;
|
||||
|
||||
// Длина текста в ответе predict: первые 200 символов (ml_routes.py L90 text[:200]).
|
||||
private const int PredictTextPreviewLength = 200;
|
||||
|
||||
// Сообщение 400 для слишком короткого текста (ml_routes.py L88, план Task 9 L348).
|
||||
private const string EnterTextDetail = "Введите текст";
|
||||
|
||||
// Сообщение 404 apply: исходное сообщение не найдено (ml_routes.py L142, план Task 9 L352).
|
||||
private const string MessageNotFoundDetail = "Исходное сообщение не найдено";
|
||||
|
||||
/// <summary>
|
||||
/// Регистрирует группу /api/ml: status/reset/predict/candidates/apply.
|
||||
/// Регистрирует группу /api/ml
|
||||
/// </summary>
|
||||
/// <param name="app">Построитель маршрутов приложения.</param>
|
||||
/// <returns>Построитель маршрутов для цепочки вызовов.</returns>
|
||||
@@ -76,7 +57,6 @@ public static class MlEndpoints
|
||||
return app;
|
||||
}
|
||||
|
||||
// GET /api/ml/status: статус ML-сервиса + локальная статистика (ml_routes.py L66–75).
|
||||
private static async Task<IResult> StatusAsync(HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!context.HasUser())
|
||||
@@ -88,7 +68,6 @@ public static class MlEndpoints
|
||||
return Results.Ok(await mlClient.StatusAsync(ct));
|
||||
}
|
||||
|
||||
// POST /api/ml/reset: полный сброс модели + очистка очереди обучения (ml_routes.py L78–81).
|
||||
private static async Task<IResult> ResetAsync(HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!context.HasUser())
|
||||
@@ -100,7 +79,6 @@ public static class MlEndpoints
|
||||
return Results.Ok(await mlClient.ResetAsync(ct));
|
||||
}
|
||||
|
||||
// POST /api/ml/predict: проверка ML на тексте (ml_routes.py L84–90).
|
||||
private static async Task<IResult> PredictAsync(
|
||||
MlPredictRequest body,
|
||||
HttpContext context,
|
||||
@@ -120,7 +98,6 @@ public static class MlEndpoints
|
||||
IMlClient mlClient = context.RequestServices.GetRequiredService<IMlClient>();
|
||||
MlPredictResultDto result = await mlClient.PredictAsync(text, ct);
|
||||
|
||||
// Ответ 1:1 с ml_routes.py L90: {"text": <первые 200>, **результат предсказания}.
|
||||
string preview = text.Length <= PredictTextPreviewLength
|
||||
? text
|
||||
: text[..PredictTextPreviewLength];
|
||||
@@ -138,7 +115,6 @@ public static class MlEndpoints
|
||||
});
|
||||
}
|
||||
|
||||
// POST /api/ml/candidates: последние сообщения канала + мнение ML (ml_routes.py L112–134).
|
||||
private static async Task<IResult> CandidatesAsync(
|
||||
MlCandidatesRequest body,
|
||||
HttpContext context,
|
||||
@@ -154,7 +130,6 @@ public static class MlEndpoints
|
||||
return Results.Ok(new { items });
|
||||
}
|
||||
|
||||
// POST /api/ml/apply: ручное решение по сообщению (ml_routes.py L137–171).
|
||||
private static async Task<IResult> ApplyAsync(
|
||||
MlApplyRequest body,
|
||||
HttpContext context,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
namespace Deal.Api.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Тело POST /api/ml/predict. Входящий JSON — camelCase (text).
|
||||
/// Тело POST /api/ml/predict.
|
||||
/// </summary>
|
||||
/// <param name="Text">Текст сообщения для проверки ML (обрезается/тримится обработчиком, как ml_routes.py L86).</param>
|
||||
/// <param name="Text">Текст сообщения для проверки ML.</param>
|
||||
public sealed record MlPredictRequest(string Text);
|
||||
|
||||
@@ -6,17 +6,10 @@ using Deal.Modules.Tenants.Application.Services;
|
||||
namespace Deal.Api.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Операторские read-only эндпоинты аналитики: /api/operator/analytics/{overview,tokens,activity} (этап 10, T3).
|
||||
/// Операторские read-only эндпоинты аналитики
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Только под операторской сессией: без неё 401 «Требуется вход оператора» (как прочие /api/operator/*).
|
||||
/// Ничего не меняет (read-only). groupBy — day|tenant|provider|model (неизвестное — 400 {detail}); from/to —
|
||||
/// ISO-8601 (включительно), как у аудита; activity поддерживает фильтры eventType/actorType/actorId/tenantId,
|
||||
/// limit (1..500) и offset. Все ответы — camelCase (контракт: docs/architecture/2026-09-10-operator-analytics-contract.md).
|
||||
/// </remarks>
|
||||
public static class OperatorAnalyticsEndpoints
|
||||
{
|
||||
// Префикс группы аналитики (Ruling 4 этапа 10).
|
||||
private const string AnalyticsGroupPrefix = "/api/operator/analytics";
|
||||
|
||||
// OpenAPI-тег группы.
|
||||
@@ -29,7 +22,7 @@ public static class OperatorAnalyticsEndpoints
|
||||
private const string InvalidGroupByDetail = "Неизвестная группировка (day|tenant|provider|model)";
|
||||
|
||||
/// <summary>
|
||||
/// Регистрирует группу /api/operator/analytics: overview/tokens/activity.
|
||||
/// Регистрирует группу /api/operator/analytics
|
||||
/// </summary>
|
||||
/// <param name="app">Построитель маршрутов приложения.</param>
|
||||
/// <returns>Построитель маршрутов для цепочки вызовов.</returns>
|
||||
@@ -159,7 +152,7 @@ public static class OperatorAnalyticsEndpoints
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Известна ли группировка расхода токенов (day|tenant|provider|model).
|
||||
/// Известна ли группировка расхода токенов
|
||||
/// </summary>
|
||||
/// <param name="groupBy">Значение группировки.</param>
|
||||
/// <returns>True — поддерживаемая группировка.</returns>
|
||||
|
||||
@@ -6,28 +6,19 @@ using Deal.Modules.Tenants.Application.Services;
|
||||
namespace Deal.Api.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Операторский эндпоинт чтения аудита: GET /api/operator/audit (Ruling 4 этапа 7).
|
||||
/// Операторский эндпоинт чтения аудита
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Чтение — только оператору: без операторской сессии 401 «Требуется вход оператора» (как /api/operator/auth/me).
|
||||
/// Фильтры-query: eventType, actorType, tenantId, from, to, limit (At DESC, limit клампится в
|
||||
/// 1..<see cref="AuditService.MaxQueryLimit"/>, дефолт — <see cref="AuditService.DefaultQueryLimit"/>).
|
||||
/// Ответ — {items: [...], total}: total — полное число записей по фильтру (без учёта limit). Запись событий —
|
||||
/// только через <see cref="AuditService"/> (append-only); этот эндпоинт лишь читает.
|
||||
/// </remarks>
|
||||
public static class OperatorAuditEndpoints
|
||||
{
|
||||
// Префикс группы операторских ручек /api/operator (Ruling 11).
|
||||
private const string OperatorGroupPrefix = "/api/operator";
|
||||
|
||||
// Путь ленты аудита относительно группы.
|
||||
private const string AuditPath = "/audit";
|
||||
|
||||
// OpenAPI-тег группы (Ruling 11: операторская админка — API-only).
|
||||
private const string OperatorOpenApiTag = "operator";
|
||||
|
||||
/// <summary>
|
||||
/// Регистрирует группу /api/operator: GET /audit (лента аудита; другие ручки — задачи 5/7/10).
|
||||
/// Регистрирует группу /api/operator
|
||||
/// </summary>
|
||||
/// <param name="app">Построитель маршрутов приложения.</param>
|
||||
/// <returns>Построитель маршрутов для цепочки вызовов.</returns>
|
||||
@@ -45,7 +36,6 @@ public static class OperatorAuditEndpoints
|
||||
// from: Нижняя граница At (включительно; ISO-8601).
|
||||
// to: Верхняя граница At (включительно; ISO-8601).
|
||||
// limit: Размер выборки (дефолт 100, клампится 1..500).
|
||||
// offset: Смещение страницы (≥0; этап 10, T3).
|
||||
// context: Контекст запроса.
|
||||
// auditService: Сервис аудита (scoped).
|
||||
// ct: Токен отмены.
|
||||
@@ -77,7 +67,7 @@ public static class OperatorAuditEndpoints
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Нормализует limit запроса: дефолт <see cref="AuditService.DefaultQueryLimit"/>, кламп 1..500 (Ruling 4).
|
||||
/// Нормализует limit запроса
|
||||
/// </summary>
|
||||
/// <param name="limit">Запрошенный размер выборки (null — не задан).</param>
|
||||
/// <returns>Значение для фильтра.</returns>
|
||||
@@ -87,7 +77,7 @@ public static class OperatorAuditEndpoints
|
||||
: Math.Max(1, Math.Min(AuditService.MaxQueryLimit, limit.Value));
|
||||
|
||||
/// <summary>
|
||||
/// Нормализует offset запроса: отрицательное/отсутствующее — 0 (этап 10, T3).
|
||||
/// Нормализует offset запроса
|
||||
/// </summary>
|
||||
/// <param name="offset">Запрошенное смещение (null — не задано).</param>
|
||||
/// <returns>Неотрицательное смещение.</returns>
|
||||
|
||||
@@ -12,15 +12,8 @@ using OperatorCookieOptions = Deal.Api.Configuration.OperatorCookieOptions;
|
||||
namespace Deal.Api.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// HTTP-эндпоинты аутентификации оператора (группа /api/operator/auth). Зеркало AuthEndpoints для операторов (Ruling 1).
|
||||
/// HTTP-эндпоинты аутентификации оператора
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Оператор ≠ пользователь тенанта: вход по отдельным public-таблицам (OperatorAuthService/IOperatorAuthStore),
|
||||
/// сессия — в куке deal_operator_session (отдельная от deal_session; 12 ч, httpOnly, SameSite=Lax).
|
||||
/// Успех-ответы — <c>{ok:true,...}</c>, ошибки — HTTP-код + <c>{"detail":"..."}</c> (Ruling 10). Защищённые
|
||||
/// ручки (me) требуют операторскую сессию (401 «Требуется вход оператора») — тенантная кука не проходит.
|
||||
/// Результаты входа пишутся в аудит (operator_login_ok/failed, Task 4/Ruling 4).
|
||||
/// </remarks>
|
||||
public static class OperatorAuthEndpoints
|
||||
{
|
||||
private const string InvalidCredentialsDetail = "Неверный логин или пароль оператора";
|
||||
@@ -28,7 +21,7 @@ public static class OperatorAuthEndpoints
|
||||
private const string OperatorAuthOpenApiTag = "operator-auth";
|
||||
|
||||
/// <summary>
|
||||
/// Регистрирует группу /api/operator/auth: login, logout, me.
|
||||
/// Регистрирует группу /api/operator/auth
|
||||
/// </summary>
|
||||
/// <param name="app">Построитель маршрутов приложения.</param>
|
||||
/// <returns>Построитель маршрутов для цепочки вызовов.</returns>
|
||||
@@ -36,7 +29,6 @@ public static class OperatorAuthEndpoints
|
||||
{
|
||||
var group = app.MapGroup(OperatorAuthGroupPrefix).WithTags(OperatorAuthOpenApiTag);
|
||||
|
||||
// Политика "auth" rate limiter (план Task 11, Ruling 5): фиксированное окно 10/мин на IP ручки
|
||||
// входа оператора; остальные ручки группы — под глобальной API-политикой (по тенанту/IP).
|
||||
group.MapPost("/login", LoginAsync).RequireRateLimiting(RateLimitPolicies.AuthPolicy);
|
||||
group.MapPost("/logout", LogoutAsync);
|
||||
@@ -45,8 +37,6 @@ public static class OperatorAuthEndpoints
|
||||
return app;
|
||||
}
|
||||
|
||||
// POST /api/operator/auth/login: проверка учётных данных оператора, выдача куки сессии; результат пишется в аудит (Task 4).
|
||||
// До OperatorAuthService отрабатывает LoginAttemptGuard (5 неудач ip|login за 15 мин → 429, Ruling 5).
|
||||
private static async Task<IResult> LoginAsync(
|
||||
LoginRequest body,
|
||||
OperatorAuthService operatorAuthService,
|
||||
@@ -58,7 +48,6 @@ public static class OperatorAuthEndpoints
|
||||
{
|
||||
string? attemptedLogin = NormalizeLogin(body.Login);
|
||||
|
||||
// Защита входа оператора (план Task 11, Ruling 5): зеркало AuthEndpoints — блокировка ключа
|
||||
// ip|login до проверки учётных данных (в dev при RateLimit:Enabled=false гвард выключен).
|
||||
if (await loginAttemptGuard.IsBlockedAsync(ClientIp(context), attemptedLogin, ct))
|
||||
{
|
||||
@@ -69,7 +58,6 @@ public static class OperatorAuthEndpoints
|
||||
if (result.Login is null || result.Token is null)
|
||||
{
|
||||
// Неверные учётные данные оператора — одно сообщение (зеркало AuthEndpoints).
|
||||
// Аудит operator_login_failed — только для реальной попытки (непустой логин), без пароля (Ruling 4);
|
||||
// счётчик неудач гварда растёт там же (пустые логины ключа не имеют).
|
||||
if (attemptedLogin is not null)
|
||||
{
|
||||
@@ -86,7 +74,6 @@ public static class OperatorAuthEndpoints
|
||||
return EndpointResults.Unauthorized(InvalidCredentialsDetail);
|
||||
}
|
||||
|
||||
// Успешный вход оператора сбрасывает счётчик неудач ключа ip|login (Ruling 5).
|
||||
await loginAttemptGuard.ResetAsync(ClientIp(context), result.Login, ct);
|
||||
|
||||
await auditService.AppendAsync(new AuditRecordDto(
|
||||
@@ -115,7 +102,6 @@ public static class OperatorAuthEndpoints
|
||||
await operatorAuthService.LogoutAsync(rawToken, ct);
|
||||
context.Response.Cookies.Delete(cookieName);
|
||||
|
||||
// Выход оператора (этап 10, T1): событие пишется при живой разрешённой сессии.
|
||||
if (operatorIdentity is not null)
|
||||
{
|
||||
await AuditAppender.AppendOperatorAsync(context, AuditEvents.OperatorLogout, new { login = operatorIdentity.Login }, ct);
|
||||
@@ -124,7 +110,6 @@ public static class OperatorAuthEndpoints
|
||||
return Results.Ok(new { ok = true });
|
||||
}
|
||||
|
||||
// GET /api/operator/auth/me: проверка живой операторской сессии (401 без неё, Ruling 1).
|
||||
private static IResult MeAsync(HttpContext context)
|
||||
{
|
||||
var operatorIdentity = context.GetCurrentOperator();
|
||||
|
||||
@@ -10,25 +10,10 @@ using Microsoft.EntityFrameworkCore;
|
||||
namespace Deal.Api.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Операторский health: GET /api/operator/health (план Task 10, Ruling 3/6/9/11) — ядро/БД и
|
||||
/// автономные сервисы ml/ai/telegram.
|
||||
/// Операторский health
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Ручка — только оператору (401 «Требуется вход оператора» без операторской сессии). Ответ всегда 200
|
||||
/// (информационный операторский обзор, как /api/health) с полями состояния:
|
||||
/// <c>{ok, core:{db:"ok"|"down"}, services:[{name, mode:"grpc"|"local", status, reachable}],
|
||||
/// queues:{pipeline, mlOutbox}, sessions:{active}}</c>. Глубины очередей обработки/ML-outbox и число
|
||||
/// активных сессий (§10.2) собирает общий <see cref="RuntimeDepthsCollector"/> (тот же путь, что метрики).
|
||||
/// Проверка БД — <c>SELECT 1</c> через DealDbContext (public-схема) с таймаутом 5 с; сбой (контейнер не поднят/
|
||||
/// сеть) → core.db=down без падения ручки. Сервисы: при <c>Services:*:UseLocal=true</c> — <c>{mode:"local",
|
||||
/// reachable:false, status:"local"}</c> (Local-адаптеры, реальный сервис не поднят — Ruling 6; dev-приёмка);
|
||||
/// в gRPC-режиме — <see cref="ServiceHealthProbe"/> к <c>Services:*:Endpoint</c> (grpc.health.v1, таймаут 3 с):
|
||||
/// SERVING → status=ok, иной статус → unhealthy, недоступен → down. <c>ok</c> сводки — БД доступна и все
|
||||
/// сервисы в порядке (Local-режим не считается сбоем).
|
||||
/// </remarks>
|
||||
public static class OperatorHealthEndpoints
|
||||
{
|
||||
// Префикс группы операторских ручек health (Ruling 11).
|
||||
private const string OperatorGroupPrefix = "/api/operator";
|
||||
|
||||
// Путь health-ручки.
|
||||
@@ -46,7 +31,6 @@ public static class OperatorHealthEndpoints
|
||||
// Статус сервиса: ответил, но не SERVING (grpc NOT_SERVING/SERVICE_UNKNOWN).
|
||||
private const string StatusUnhealthy = "unhealthy";
|
||||
|
||||
// Статус сервиса в Local-режиме: реальный сервис не подключён (UseLocal=true, Ruling 6).
|
||||
private const string StatusLocal = "local";
|
||||
|
||||
// Режим сервиса: Local-адаптеры (UseLocal=true).
|
||||
@@ -68,7 +52,7 @@ public static class OperatorHealthEndpoints
|
||||
private const int DatabaseProbeTimeoutMilliseconds = 5000;
|
||||
|
||||
/// <summary>
|
||||
/// Регистрирует GET /api/operator/health (health ядра/БД и автономных сервисов).
|
||||
/// Регистрирует GET /api/operator/health
|
||||
/// </summary>
|
||||
/// <param name="app">Построитель маршрутов приложения.</param>
|
||||
/// <returns>Построитель маршрутов для цепочки вызовов.</returns>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
namespace Deal.Api.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Тело POST /api/operator/invites: email приглашённого и опциональный целевой тенант (Ruling 2 этапа 7).
|
||||
/// Тело POST /api/operator/invites
|
||||
/// </summary>
|
||||
/// <param name="Email">Email приглашённого (регистр/пробелы не важны — нормализует InvitesService).</param>
|
||||
/// <param name="TenantId">Целевой тенант; null — при активации будет создан новый тенант (Task 6).</param>
|
||||
/// <param name="TenantId">Целевой тенант; null — при активации будет создан новый тенант.</param>
|
||||
public sealed record OperatorInviteCreateRequest(string? Email, Guid? TenantId);
|
||||
|
||||
@@ -6,21 +6,13 @@ using Deal.Modules.Tenants.Application.Services;
|
||||
namespace Deal.Api.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Операторские эндпоинты приглашений: GET /api/operator/invites, POST (создание), POST {code}/revoke (Ruling 2/11 этапа 7).
|
||||
/// Операторские эндпоинты приглашений
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Создание/отзыв/чтение — только оператор: без операторской сессии 401 «Требуется вход оператора»
|
||||
/// (как /api/operator/auth/me). Создание возвращает {code, email, tenantId, expiresAt, status} (план Task 5),
|
||||
/// список — {items:[...]} (полные строки; форма как у GET /api/operator/audit), отзыв — {ok:true}. Результаты
|
||||
/// пишутся в аудит — invite_created/invite_revoked с email и codeHash в DetailJson (Ruling 4; операторские события,
|
||||
/// TenantId null; хэш кода — Security review). Тексты ошибок — фиксированные строки HTTP-слоя (паттерн AuthEndpoints).
|
||||
/// </remarks>
|
||||
public static class OperatorInvitesEndpoints
|
||||
{
|
||||
// Текст 400: email пустой/некорректного формата.
|
||||
private const string InvalidEmailDetail = "Некорректный email";
|
||||
|
||||
// Текст 400: на email уже есть активное приглашение (план Task 5, Ruling 2).
|
||||
private const string DuplicateActiveDetail = "Для этого email уже есть активное приглашение";
|
||||
|
||||
// Текст 404: приглашение с таким кодом не найдено.
|
||||
@@ -29,7 +21,6 @@ public static class OperatorInvitesEndpoints
|
||||
// Текст 400: отзыв приглашения не в статусе pending (уже отозвано/использовано/истекло).
|
||||
private const string InviteNotPendingDetail = "Отозвать можно только ожидающее активации приглашение";
|
||||
|
||||
// Префикс группы операторских ручек приглашений (Ruling 11).
|
||||
private const string InvitesGroupPrefix = "/api/operator/invites";
|
||||
|
||||
// Относительный путь отзыва приглашения.
|
||||
@@ -39,7 +30,7 @@ public static class OperatorInvitesEndpoints
|
||||
private const string InvitesOpenApiTag = "operator-invites";
|
||||
|
||||
/// <summary>
|
||||
/// Регистрирует группу /api/operator/invites: GET (список), POST (создание), POST {code}/revoke (отзыв).
|
||||
/// Регистрирует группу /api/operator/invites
|
||||
/// </summary>
|
||||
/// <param name="app">Построитель маршрутов приложения.</param>
|
||||
/// <returns>Построитель маршрутов для цепочки вызовов.</returns>
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
namespace Deal.Api.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Тело PATCH /api/operator/tenants/{id}/limit: смена лимитов ИИ-бюджета тенанта (план Task 10, Ruling 3).
|
||||
/// Оба поля опциональны — меняется только заданное; смена бюджета/периода сбрасывает флаги Warned80/
|
||||
/// NotifiedExhausted (новый период открывает пороги тостов, один тост на период на порог, Ruling 3).
|
||||
/// Тело PATCH /api/operator/tenants/{id}/limit
|
||||
/// </summary>
|
||||
/// <param name="Budget">Новый бюджет периода в токенах (≥0; 0 — ИИ запрещён); null — оставить текущий.</param>
|
||||
/// <param name="Period">Новый тип периода (константа <c>TenantLimitPeriods</c>: month|day); null — оставить текущий.</param>
|
||||
|
||||
@@ -7,21 +7,8 @@ using Deal.Modules.Tenants.Application.Services;
|
||||
namespace Deal.Api.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Операторские эндпоинты лимитов ИИ-бюджета: сводка по всем тенантам и просмотр/смена лимита тенанта
|
||||
/// (план Task 10, Ruling 3/11).
|
||||
/// Операторские эндпоинты лимитов ИИ-бюджета
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Все ручки — только оператору: без операторской сессии 401 «Требуется вход оператора» (как остальные
|
||||
/// /api/operator/*). GET /api/operator/limits — сводка {items:[{tenantId, name, budget, period, used, percent,
|
||||
/// status}]} по реестру тенантов (Ruling 3: строка лимита на путь чтения заводится лениво с дефолт-бюджетом —
|
||||
/// тенант без расхода виден как «дефолт, 0»). GET/PATCH /api/operator/tenants/{id}/limit — детали/смена лимита:
|
||||
/// PATCH принимает {budget?, period?} (оба опциональны — меняется только заданное; null-тело/без полей → 400),
|
||||
/// сбрасывает Warned80/NotifiedExhausted через UpdateBudgetAsync (Ruling 3: смена бюджета открывает пороги
|
||||
/// тостов заново) и пишет аудит tenant_limit_changed (только при реальном изменении — повторный PATCH с теми же
|
||||
/// значениями идемпотентен, аудит не дублируется). Отрицательный бюджет/чужой период отсекаются 400 до вызова
|
||||
/// хранилища; тенант проверяется по реестру (404 «Тенант не найден»). Ответы деталей — единая форма
|
||||
/// (см. <see cref="BuildDetailDto"/>) — статус тенанта, флаги порогов и процент расхода.
|
||||
/// </remarks>
|
||||
public static class OperatorLimitsEndpoints
|
||||
{
|
||||
// Текст 400: PATCH без полей (null-тело/пустой объект).
|
||||
@@ -39,10 +26,8 @@ public static class OperatorLimitsEndpoints
|
||||
// Верхняя граница процента расхода (диапазон 0..100) — константа расчёта CalculatePercent.
|
||||
private const int PercentMax = 100;
|
||||
|
||||
// Префикс сводки лимитов (Ruling 11: /api/operator/*).
|
||||
private const string OperatorGroupPrefix = "/api/operator";
|
||||
|
||||
// Префикс группы операторских ручек тенантов (общий с Task 7).
|
||||
private const string TenantsGroupPrefix = "/api/operator/tenants";
|
||||
|
||||
// Путь сводки лимитов по всем тенантам.
|
||||
@@ -54,12 +39,10 @@ public static class OperatorLimitsEndpoints
|
||||
// OpenAPI-тег группы сводки лимитов.
|
||||
private const string LimitsOpenApiTag = "operator-limits";
|
||||
|
||||
// Без состояния, поэтому безопасен как статический экземпляр (период-математика Task 8).
|
||||
private static readonly TokenBudgetService BudgetService = new();
|
||||
|
||||
/// <summary>
|
||||
/// Регистрирует ручки лимитов: GET /api/operator/limits (сводка) и GET/PATCH
|
||||
/// /api/operator/tenants/{id}/limit (детали/смена).
|
||||
/// Регистрирует ручки лимитов
|
||||
/// </summary>
|
||||
/// <param name="app">Построитель маршрутов приложения.</param>
|
||||
/// <returns>Построитель маршрутов для цепочки вызовов.</returns>
|
||||
@@ -72,7 +55,6 @@ public static class OperatorLimitsEndpoints
|
||||
return app;
|
||||
}
|
||||
|
||||
// GET /api/operator/limits: сводка бюджета/расхода по всем тенантам (план Task 10).
|
||||
private static async Task<IResult> ListSummaryAsync(
|
||||
HttpContext context,
|
||||
ITenantRepository tenantRepository,
|
||||
@@ -89,7 +71,6 @@ public static class OperatorLimitsEndpoints
|
||||
var items = new List<object>(tenants.Count);
|
||||
foreach (TenantRecordDto tenant in tenants)
|
||||
{
|
||||
// Ленивый reset периода внутри GetStateAsync (Ruling 3): сводка всегда про текущий период.
|
||||
BudgetStateDto state = await limitStore.GetStateAsync(tenant.Id, ct);
|
||||
items.Add(new
|
||||
{
|
||||
@@ -199,14 +180,8 @@ public static class OperatorLimitsEndpoints
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Процент расхода бюджета для операторской сводки/деталей (0..100, floor).
|
||||
/// Процент расхода бюджета для операторской сводки/деталей
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Бюджет ≤0 трактуется как исчерпанный (лимит 0 запрещает ИИ, Ruling 3) → 100%; расход ≥ бюджета также
|
||||
/// показывается как 100 (потолок индикатора). Расчёт — в double: диапазон long (до ~9.2·10¹⁸ токенов)
|
||||
/// не переполняет double, floor-ошибка возможна только на границе целого при масштабах, нереальных для
|
||||
/// бюджета токенов (целочисленный used·100/budget переполнялся бы при used > ~9.2·10¹⁶).
|
||||
/// </remarks>
|
||||
/// <param name="usedTokens">Использовано токенов с начала периода.</param>
|
||||
/// <param name="budgetTokens">Бюджет периода.</param>
|
||||
/// <returns>Процент в диапазоне 0..100 (расход сверх бюджета показывается как 100).</returns>
|
||||
|
||||
@@ -5,16 +5,8 @@ using Deal.Infrastructure.Tenancy;
|
||||
namespace Deal.Api.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Операторские maintenance-ручки (этап 12, пакет C): пакетная миграция схем всех тенантов.
|
||||
/// Операторские maintenance-ручки
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Ручка — только оператору (401 «Требуется вход оператора» без операторской сессии).
|
||||
/// POST /api/operator/maintenance/tenants/migrate — идемпотентно проводит провижининг/миграции схем ВСЕХ
|
||||
/// тенантов реестра (CREATE SCHEMA IF NOT EXISTS + EF Migrate, применяющий только неприменённые миграции)
|
||||
/// с ограниченным параллелизмом и логированием прогресса (<see cref="TenantSchemaMigrationService"/>).
|
||||
/// Ответ <c>{ok, total, migrated, failed, failedSchemas, durationMs}</c>; ok=false, если хотя бы одна схема
|
||||
/// не мигрирована (сбой одной не прерывает остальные — оператор видит список проблемных схем).
|
||||
/// </remarks>
|
||||
public static class OperatorMaintenanceEndpoints
|
||||
{
|
||||
// Префикс группы операторских maintenance-ручек.
|
||||
@@ -27,7 +19,7 @@ public static class OperatorMaintenanceEndpoints
|
||||
private const string MaintenanceOpenApiTag = "operator-maintenance";
|
||||
|
||||
/// <summary>
|
||||
/// Регистрирует группу /api/operator/maintenance: пакетная миграция схем тенантов.
|
||||
/// Регистрирует группу /api/operator/maintenance
|
||||
/// </summary>
|
||||
/// <param name="app">Построитель маршрутов приложения.</param>
|
||||
/// <returns>Построитель маршрутов для цепочки вызовов.</returns>
|
||||
|
||||
@@ -8,21 +8,8 @@ using Deal.Modules.Tenants.Application.Services;
|
||||
namespace Deal.Api.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Операторские ручки глобальных (системных) настроек: ключи приложения Telegram
|
||||
/// (ТЗ §4.1/§8.1).
|
||||
/// Операторские ручки глобальных
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Все ручки — только под операторской сессией: без неё 401 «Требуется вход оператора». Ключи Telegram
|
||||
/// задаёт оператор глобально (едины для всех тенантов), тенант их не видит и не задаёт.
|
||||
/// <list type="bullet">
|
||||
/// <item>GET /api/operator/settings/telegram-keys — маскированный снимок: apiId (не секрет, открыт),
|
||||
/// apiHash (маска) и keysSet;</item>
|
||||
/// <item>PUT /api/operator/settings/telegram-keys {apiId?, apiHash?} — частичное сохранение (можно
|
||||
/// передать только одно поле, второе сохраняется); валидация (api_id 5..9 цифр, api_hash непустой),
|
||||
/// шифрование секрета и аудит telegram_keys_changed (без секретов в деталях).</item>
|
||||
/// </list>
|
||||
/// Ошибки — 400/401 <c>{detail}</c> (формат прототипа, Ruling 10).
|
||||
/// </remarks>
|
||||
public static class OperatorSettingsEndpoints
|
||||
{
|
||||
// Префикс группы операторских настроек.
|
||||
@@ -47,7 +34,7 @@ public static class OperatorSettingsEndpoints
|
||||
private const string InvalidApiHashDetail = "Укажите непустой api_hash";
|
||||
|
||||
/// <summary>
|
||||
/// Регистрирует группу /api/operator/settings: telegram-keys (GET/PUT).
|
||||
/// Регистрирует группу /api/operator/settings
|
||||
/// </summary>
|
||||
/// <param name="app">Построитель маршрутов приложения.</param>
|
||||
/// <returns>Построитель маршрутов для цепочки вызовов.</returns>
|
||||
@@ -135,7 +122,6 @@ public static class OperatorSettingsEndpoints
|
||||
|
||||
await keys.SaveAsync(effectiveApiId, effectiveApiHash, ct);
|
||||
|
||||
// Аудит смены глобальных ключей: apiId — не секрет, apiHash в детали не пишется (Ruling 4).
|
||||
await auditService.AppendAsync(new AuditRecordDto(
|
||||
AuditEvents.TelegramKeysChanged,
|
||||
AuditActorTypes.Operator,
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
namespace Deal.Api.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Тело POST /api/operator/tenants: создание тенанта оператором (план Task 7, Ruling 11).
|
||||
/// Тело POST /api/operator/tenants
|
||||
/// </summary>
|
||||
/// <param name="Name">Имя тенанта (обязательно; пробелы по краям обрезаются).</param>
|
||||
/// <param name="Email">Email владельца (опционально): создаёт сразу пользователя-владельца с одноразовым
|
||||
/// паролем (иначе владелец заводится инвайтом, Ruling 2).</param>
|
||||
/// <param name="Email">Email владельца (опционально): создаёт сразу пользователя-владельца с одноразовым паролем.</param>
|
||||
public sealed record OperatorTenantCreateRequest(string? Name, string? Email);
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
namespace Deal.Api.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Тело POST /api/operator/tenants/{id}/impersonate: опциональный логин пользователя тенанта (план Task 7).
|
||||
/// Тело POST /api/operator/tenants/{id}/impersonate
|
||||
/// </summary>
|
||||
/// <param name="Login">Логин пользователя, под которым оператор входит (impersonation); null/пустой —
|
||||
/// берётся первый пользователь тенанта (по времени создания).</param>
|
||||
/// <param name="Login">Логин пользователя, под которым оператор входит (impersonation); null/пустой — берётся первый пользователь тенанта (по времени создания).</param>
|
||||
public sealed record OperatorTenantImpersonateRequest(string? Login);
|
||||
|
||||
@@ -8,24 +8,8 @@ using CookieOptions = Deal.Api.Configuration.CookieOptions;
|
||||
namespace Deal.Api.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Операторские эндпоинты тенантов: create/список/детали, suspend/unsuspend, impersonation (план Task 7, Ruling 1/4/10/11).
|
||||
/// Операторские эндпоинты тенантов
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Все ручки — только оператору: без операторской сессии 401 «Требуется вход оператора» (как остальные
|
||||
/// /api/operator/*). POST "" (create {name, email?}) — тенант (Status active) + провижининг схемы + аудит
|
||||
/// tenant_created; список — {items:[...]} (реестр + счётчик пользователей; поля лимитов добавит Task 8),
|
||||
/// детали — {id,name,status,createdAt,users:[...]}. Suspend/unsuspend меняют Status тенанта
|
||||
/// (TenantAdminService) и пишут аудит tenant_status_changed (только при реальном изменении — повторный
|
||||
/// suspend идемпотентен). Impersonation выпускает tenant-сессию целевого пользователя
|
||||
/// (AuthService, механизм обычного входа; пароль не меняется) и возвращает {sessionToken, expiresAt,
|
||||
/// tenantId, login} — токен используется как значение куки deal_session; аудит impersonation_started
|
||||
/// (DetailJson: targetLogin, tenantId), завершение — logout'ом пользователя (impersonation_stopped в
|
||||
/// AuthEndpoints). Зафиксированные решения Task 7: suspend-гейт отвечает 403 (не 401; см. AuthEndpoints),
|
||||
/// impersonation suspended-тенанта разрешён (аудируется; ИИ заморожен гейтом Task 9), PATCH {status} плана
|
||||
/// заменён на явные POST /suspend|/unsuspend, budget? при create не принимается до Task 8/10 — отклонения
|
||||
/// для api-map/техдок Task 16 зафиксированы в task-7-report.md. Тексты ошибок — фиксированные строки
|
||||
/// HTTP-слоя (паттерн OperatorInvitesEndpoints).
|
||||
/// </remarks>
|
||||
public static class OperatorTenantsEndpoints
|
||||
{
|
||||
// Текст 400: имя тенанта пустое/пробельное (create).
|
||||
@@ -46,7 +30,6 @@ public static class OperatorTenantsEndpoints
|
||||
// Текст 400: в тенанте нет пользователей, а login не указан (impersonation без выбора).
|
||||
private const string TenantHasNoUsersDetail = "В тенанте нет пользователей для входа";
|
||||
|
||||
// Префикс группы операторских ручек тенантов (Ruling 11).
|
||||
private const string TenantsGroupPrefix = "/api/operator/tenants";
|
||||
|
||||
// Относительный путь деталей тенанта.
|
||||
@@ -65,7 +48,7 @@ public static class OperatorTenantsEndpoints
|
||||
private const string TenantsOpenApiTag = "operator-tenants";
|
||||
|
||||
/// <summary>
|
||||
/// Регистрирует группу /api/operator/tenants: список, create, детали, suspend/unsuspend, impersonate.
|
||||
/// Регистрирует группу /api/operator/tenants
|
||||
/// </summary>
|
||||
/// <param name="app">Построитель маршрутов приложения.</param>
|
||||
/// <returns>Построитель маршрутов для цепочки вызовов.</returns>
|
||||
@@ -83,7 +66,6 @@ public static class OperatorTenantsEndpoints
|
||||
return app;
|
||||
}
|
||||
|
||||
// GET /api/operator/tenants: список тенантов со счётчиками пользователей (план Task 7).
|
||||
private static async Task<IResult> ListAsync(
|
||||
HttpContext context,
|
||||
TenantAdminService tenantAdminService,
|
||||
@@ -100,8 +82,6 @@ public static class OperatorTenantsEndpoints
|
||||
}
|
||||
|
||||
// POST /api/operator/tenants: создание тенанта (Status active + провижининг схемы); аудит tenant_created.
|
||||
// Решение Task 7: PATCH {status} заменён на явные POST /suspend и /unsuspend — create принимает только
|
||||
// {name, email?}; budget?/лимиты — зона Task 8/10 (прецедент: join-строка лимитов отложена в Task 6).
|
||||
private static async Task<IResult> CreateAsync(
|
||||
OperatorTenantCreateRequest body,
|
||||
HttpContext context,
|
||||
|
||||
@@ -7,28 +7,12 @@ using Deal.Modules.Pipeline.Application.Services;
|
||||
namespace Deal.Api.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Эндпоинты вкладки «Обработка»: GET /api/pipeline/stats|queue|rejected, POST /api/pipeline/rejected/clear,
|
||||
/// DELETE /api/pipeline/rejected/{rejId}, POST /api/pipeline/rejected/{rejId}/return (план Task 9 L437–460,
|
||||
/// Rulings 6/10; прототип processing_routes.py L17–74).
|
||||
/// Эндпоинты вкладки «Обработка»
|
||||
/// </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
|
||||
{
|
||||
// Префикс группы (роутер processing, prefix="/api/pipeline" — processing_routes.py L10).
|
||||
private const string PipelineGroupPrefix = "/api/pipeline";
|
||||
|
||||
// OpenAPI-тег группы (в прототипе роутер processing — processing_routes.py L10).
|
||||
private const string OpenApiTag = "processing";
|
||||
|
||||
// Путь сводки вкладки «Обработка» (GET).
|
||||
@@ -49,14 +33,12 @@ public static class PipelineEndpoints
|
||||
// Путь возврата записи отсева в обработку (POST).
|
||||
private const string RejectedReturnPath = "/rejected/{rejId}/return";
|
||||
|
||||
// 404 return: записи отсева нет (processing_routes.py L71: KeyError → 404, Ruling 10).
|
||||
private const string RejectedNotFoundDetail = "Запись не найдена";
|
||||
|
||||
// Размер страницы по умолчанию списков очереди/отсева (processing.DEFAULT_LIMIT L48; фронт шлёт 120/80).
|
||||
private const int DefaultPageSize = 100;
|
||||
|
||||
/// <summary>
|
||||
/// Регистрирует группу /api/pipeline: stats/queue/rejected/clear/{rejId}/return.
|
||||
/// Регистрирует группу /api/pipeline
|
||||
/// </summary>
|
||||
/// <param name="app">Построитель маршрутов приложения.</param>
|
||||
/// <returns>Построитель маршрутов для цепочки вызовов.</returns>
|
||||
@@ -64,7 +46,6 @@ public static class PipelineEndpoints
|
||||
{
|
||||
var pipeline = app.MapGroup(PipelineGroupPrefix).WithTags(OpenApiTag);
|
||||
|
||||
// Статические сегменты до /rejected/{rejId} (Ruling 10, api-map L19; порядок 1:1 с прототипом).
|
||||
pipeline.MapGet(StatsPath, StatsAsync);
|
||||
pipeline.MapGet(QueuePath, QueueAsync);
|
||||
pipeline.MapGet(RejectedPath, RejectedAsync);
|
||||
@@ -75,7 +56,6 @@ public static class PipelineEndpoints
|
||||
return app;
|
||||
}
|
||||
|
||||
// GET /api/pipeline/stats: сводка вкладки {queue:{new,ai,total}, rejected} (processing_routes.py L17–20, stats L315–320).
|
||||
private static async Task<IResult> StatsAsync(HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!context.HasUser())
|
||||
@@ -87,9 +67,6 @@ public static class PipelineEndpoints
|
||||
return Results.Ok(await processing.StatsAsync(ct));
|
||||
}
|
||||
|
||||
// GET /api/pipeline/queue?limit=: сырые сообщения очереди + счётчики + число отсева (processing_routes.py 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(
|
||||
int? limit,
|
||||
HttpContext context,
|
||||
@@ -107,8 +84,6 @@ public static class PipelineEndpoints
|
||||
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}.
|
||||
private static async Task<IResult> RejectedAsync(
|
||||
string? q,
|
||||
@@ -127,7 +102,6 @@ public static class PipelineEndpoints
|
||||
return Results.Ok(page);
|
||||
}
|
||||
|
||||
// POST /api/pipeline/rejected/clear: полная безвозвратная очистка отсева (processing_routes.py L45–49, clear_all L120–125).
|
||||
private static async Task<IResult> ClearAsync(HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!context.HasUser())
|
||||
@@ -140,8 +114,6 @@ public static class PipelineEndpoints
|
||||
return Results.Ok(new { ok = true, cleared });
|
||||
}
|
||||
|
||||
// DELETE /api/pipeline/rejected/{rejId}: удалить запись отсева; ответ {ok:true} всегда (delete_one L196–198, Ruling 10).
|
||||
// Прототип не проверяет наличие записи — 404 не шлём (план Task 9 L444; Ruling 10 «always ok»).
|
||||
private static async Task<IResult> DeleteAsync(
|
||||
string rejId,
|
||||
HttpContext context,
|
||||
@@ -157,10 +129,6 @@ public static class PipelineEndpoints
|
||||
return Results.Ok(new { ok = true });
|
||||
}
|
||||
|
||||
// POST /api/pipeline/rejected/{rejId}/return {reason=""}: вернуть отсеянное в обработку (return_to_queue L128–193).
|
||||
// Успех — {id, returned:true, returnedAt} (запись помечается returned, НЕ удаляется — аудит Ruling 10);
|
||||
// причины 400 (уже возвращено/повтор-dup/нет текста) — константы PipelineProcessingService (строки 1:1 с
|
||||
// прототипом); записи нет — 404 «Запись не найдена» (текст 404 — слой эндпоинтов).
|
||||
private static async Task<IResult> ReturnAsync(
|
||||
string rejId,
|
||||
ReturnReasonRequest body,
|
||||
|
||||
@@ -6,21 +6,10 @@ using Deal.Modules.Settings.Application.Services;
|
||||
namespace Deal.Api.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// HTTP-эндпоинты курсов валют: GET /api/rates, POST /api/rates/refresh (Ruling 8, api-map §3.4 L149–150).
|
||||
/// HTTP-эндпоинты курсов валют
|
||||
/// </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
|
||||
{
|
||||
// Префикс группы API (общий для эндпоинтов этапа, Ruling 8).
|
||||
private const string ApiGroupPrefix = "/api";
|
||||
|
||||
// Путь текущих курсов (GET).
|
||||
@@ -29,7 +18,6 @@ public static class RatesEndpoints
|
||||
// Путь принудительного обновления (POST).
|
||||
private const string RatesRefreshPath = "/rates/refresh";
|
||||
|
||||
// OpenAPI-тег группы (в прототипе роутер settings — settings_routes.py).
|
||||
private const string OpenApiTag = "settings";
|
||||
|
||||
/// <summary>
|
||||
@@ -58,7 +46,6 @@ public static class RatesEndpoints
|
||||
|
||||
RatesDto current = await ratesService.GetAsync(ct);
|
||||
|
||||
// Ленивое обновление (Ruling 6, план Task 8): протухший кэш / смена источника / нет кэша —
|
||||
// фоновый RefreshAsync в отдельном scope; ответ — текущий кэш.
|
||||
if (await ratesService.ShouldFetchAsync(ct))
|
||||
{
|
||||
@@ -68,7 +55,6 @@ public static class RatesEndpoints
|
||||
return Results.Ok(current);
|
||||
}
|
||||
|
||||
// POST /api/rates/refresh: принудительное обновление; ответ {ok, rates} (1:1 settings_routes.py L229–232).
|
||||
private static async Task<IResult> RefreshRatesAsync(HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!context.HasUser())
|
||||
|
||||
@@ -1,13 +1,8 @@
|
||||
namespace Deal.Api.Endpoints.RequestModels;
|
||||
|
||||
/// <summary>
|
||||
/// Тело POST /api/cards/{cardId}/links — добавление ссылки карточке (add_link L133–143).
|
||||
/// Тело POST /api/cards/{cardId}/links — добавление ссылки карточке.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Wire-имена — camelCase: name (пустой по умолчанию) и url. url Trim'ится; пустой url → 400 «Пустая
|
||||
/// ссылка» (валидация CardsService.AddLinkAsync); url без схемы http://https:// получает префикс https://.
|
||||
/// Пустое name → ссылка называется url. Ответ — карточка после мутации.
|
||||
/// </remarks>
|
||||
/// <param name="Name">Название ссылки; пустое → name = url.</param>
|
||||
/// <param name="Url">URL ссылки (без схемы — добавится https://).</param>
|
||||
public sealed record CardLinkRequest(string? Name = null, string? Url = null);
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
namespace Deal.Api.Endpoints.RequestModels;
|
||||
|
||||
/// <summary>
|
||||
/// Тело POST /api/cards/clear-col — полная очистка служебной колонки (dashboard_routes.py ClearColBody L224–225, api-map §3.2 L93).
|
||||
/// Тело POST /api/cards/clear-col — полная очистка служебной колонки.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Wire-имя — camelCase: col — "trash" | "archive" (другие колонки/отсутствие значения → 400
|
||||
/// «Очищать можно только корзину или архив», валидация CardsService.ClearColAsync L237–247).
|
||||
/// </remarks>
|
||||
public sealed record ClearColBody(string? Col);
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
namespace Deal.Api.Endpoints.RequestModels;
|
||||
|
||||
/// <summary>
|
||||
/// Тело PATCH /api/containers/{containerId}/state — смена состояния колонки (этап 9, T4).
|
||||
/// Тело PATCH /api/containers/{containerId}/state — смена состояния колонки.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Wire-имена — camelCase: collapsed (bool) / width ("sm"|"md"|"lg"). Поле со значением null (либо
|
||||
/// отсутствующее) текущее значение не меняет (прототип model_dump(exclude_none=True) + merge в текущее
|
||||
/// состояние колонки, L146–149). Пустой патч {} сохраняет текущее состояние (для новой колонки — {}).
|
||||
/// </remarks>
|
||||
public sealed record ColStateBody(bool? Collapsed, string? Width);
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
namespace Deal.Api.Endpoints.RequestModels;
|
||||
|
||||
/// <summary>
|
||||
/// Тело POST /api/cards/{cardId}/comments — добавление комментария (dashboard_routes.py CommentBody L60–61).
|
||||
/// Тело POST /api/cards/{cardId}/comments — добавление комментария.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Wire-имя — camelCase: text. Пустой/пробельный текст либо явный null → 400 «Пустой комментарий»
|
||||
/// (валидация CardsService.AddCommentAsync, 1:1 с dashboard_routes L240–241).
|
||||
/// </remarks>
|
||||
public sealed record CommentBody(string? Text);
|
||||
|
||||
@@ -3,14 +3,8 @@ using Deal.Modules.Kanban.Application.Models;
|
||||
namespace Deal.Api.Endpoints.RequestModels;
|
||||
|
||||
/// <summary>
|
||||
/// Тело POST /api/containers — создание контейнера (этап 9, T4).
|
||||
/// Тело POST /api/containers — создание контейнера.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Wire-имена — camelCase: name/description/color/space/kind/suggested/note/rules. Name — обязательное:
|
||||
/// отсутствие либо явный null → 400 «Укажите название колонки»; пустая строка допустима — сервис
|
||||
/// подставит «Новая колонка». Description/Note имеют дефолт "". Отсутствующие группы правил
|
||||
/// трактуются как пустые (null-устойчивость).
|
||||
/// </remarks>
|
||||
/// <param name="Name">Имя контейнера (обязательно).</param>
|
||||
/// <param name="Description">Описание (опционально).</param>
|
||||
/// <param name="Color">Цвет (опционально; null — палитра).</param>
|
||||
|
||||
@@ -3,13 +3,8 @@ using Deal.Modules.Kanban.Application.Models;
|
||||
namespace Deal.Api.Endpoints.RequestModels;
|
||||
|
||||
/// <summary>
|
||||
/// Тело PATCH /api/containers/{id} — частичное обновление контейнера (этап 9, T4).
|
||||
/// Тело PATCH /api/containers/{id} — частичное обновление контейнера.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Wire-имена — camelCase: name/description/color/collapsed/suggested/note/rules/policy. Поле со
|
||||
/// значением null означает «не менять»; исключение — ЯВНЫЙ null у name → 400 «Укажите название колонки».
|
||||
/// JSON-объекты rules/policy заменяются целиком.
|
||||
/// </remarks>
|
||||
/// <param name="Name">Новое имя (null — не менять).</param>
|
||||
/// <param name="Description">Новое описание (null — не менять).</param>
|
||||
/// <param name="Color">Новый цвет (null — не менять).</param>
|
||||
|
||||
@@ -3,14 +3,9 @@ using Deal.Modules.Kanban.Application.Models;
|
||||
namespace Deal.Api.Endpoints.RequestModels;
|
||||
|
||||
/// <summary>
|
||||
/// Тело POST /api/cards — ручное («локальное») создание карточки (этап 9, T6).
|
||||
/// Тело POST /api/cards — ручное
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Wire-имена — camelCase: title/summary/stack/budget/contact/tzText/containerId (алиас stage).
|
||||
/// containerId — id контейнера (стадии/доски); неизвестный/отсутствующий не отвергается: карточка
|
||||
/// создаётся в planned. budget — объект {from,to,cur} либо null.
|
||||
/// </remarks>
|
||||
/// <param name="Title">Заголовок карточки (Trim() в сервисе; пустой допустим).</param>
|
||||
/// <param name="Title">Заголовок карточки (Trim в сервисе; пустой допустим).</param>
|
||||
/// <param name="Summary">Краткое содержание карточки.</param>
|
||||
/// <param name="Stack">Стек/направления (null — пустой стек).</param>
|
||||
/// <param name="Budget">Бюджет (from/to/cur); null — бюджета нет.</param>
|
||||
|
||||
@@ -1,28 +1,22 @@
|
||||
namespace Deal.Api.Endpoints.RequestModels;
|
||||
|
||||
/// <summary>
|
||||
/// Тело POST /api/discovery/tasks (TaskCreate discovery_routes.py L50–60; api-map §3.8 L204).
|
||||
/// Тело POST /api/discovery/tasks.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Wire-имена — camelCase: name/description/keywords/minSubscribers/lang/threshold/sampleSize/planJoins/autoJoin.
|
||||
/// Значения-дефолты pydantic повторяет сервис DiscoveryTasksService: description/keywords — пустые, lang — «ru»,
|
||||
/// minSubscribers — 0, planJoins — 1, autoJoin — false; threshold/sampleSize — из настроек (null → дефолт).
|
||||
/// name — единственное поле без дефолта: пустое/пробельное значение → 400 «Укажите название задачи».
|
||||
/// </remarks>
|
||||
public sealed record DiscoveryTaskCreateBody
|
||||
{
|
||||
/// <summary>
|
||||
/// Название задачи (обязательное; Trim, пустое → 400).
|
||||
/// Название задачи
|
||||
/// </summary>
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Описание ниши/цели (источник для generate-keywords); null → пустая строка.
|
||||
/// Описание ниши/цели
|
||||
/// </summary>
|
||||
public string? Description { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Ключевые слова поиска; null/пусто — список пуст (start до добавления ключей → 400).
|
||||
/// Ключевые слова поиска; null/пусто — список пуст
|
||||
/// </summary>
|
||||
public IReadOnlyList<string>? Keywords { get; init; }
|
||||
|
||||
@@ -32,22 +26,22 @@ public sealed record DiscoveryTaskCreateBody
|
||||
public int? MinSubscribers { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Язык источников: «ru»|«any»; null/иное → «ru» (нормализует сервис).
|
||||
/// Язык источников
|
||||
/// </summary>
|
||||
public string? Lang { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Порог подходящих сообщений оценки, % (кламп 1..100); null → discEvalThreshold.
|
||||
/// Порог подходящих сообщений оценки, %
|
||||
/// </summary>
|
||||
public int? Threshold { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Размер выборки сообщений оценки (кламп ≥1); null → discEvalSample.
|
||||
/// Размер выборки сообщений оценки
|
||||
/// </summary>
|
||||
public int? SampleSize { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// План авто-вступлений (1..discJoinLimit + бюджет); null → 1.
|
||||
/// План авто-вступлений
|
||||
/// </summary>
|
||||
public int? PlanJoins { get; init; }
|
||||
|
||||
|
||||
@@ -1,57 +1,52 @@
|
||||
namespace Deal.Api.Endpoints.RequestModels;
|
||||
|
||||
/// <summary>
|
||||
/// Тело PATCH /api/discovery/tasks/{task_id} (TaskPatch discovery_routes.py L62–71; api-map §3.8 L205).
|
||||
/// Тело PATCH /api/discovery/tasks/{task_id}.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Wire-имена — camelCase; все поля optional: null/отсутствующее поле не меняется (в DiscoveryTaskPatch
|
||||
/// пробрасываются только не-null значения, как python model_dump(exclude_none=True)). keywords — полная замена
|
||||
/// списка (пустой список очищает ключи); увеличение planJoins проверяется план-бюджетом.
|
||||
/// </remarks>
|
||||
public sealed record DiscoveryTaskPatchBody
|
||||
{
|
||||
/// <summary>
|
||||
/// Новое название (после Trim; пустое допустимо на patch — 1:1 прототип).
|
||||
/// Новое название.
|
||||
/// </summary>
|
||||
public string? Name { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Новое описание (пустая строка очищает).
|
||||
/// Новое описание
|
||||
/// </summary>
|
||||
public string? Description { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Новые ключевые слова (полная замена; null — не менять).
|
||||
/// Новые ключевые слова
|
||||
/// </summary>
|
||||
public IReadOnlyList<string>? Keywords { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Новый минимум участников (кламп ≥0).
|
||||
/// Новый минимум участников
|
||||
/// </summary>
|
||||
public int? MinSubscribers { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Новый язык: «ru»|«any» (иное → «ru»).
|
||||
/// Новый язык: «ru»|«any»
|
||||
/// </summary>
|
||||
public string? Lang { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Новый порог оценки, % (кламп 1..100).
|
||||
/// Новый порог оценки, %
|
||||
/// </summary>
|
||||
public int? Threshold { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Новый размер выборки (кламп ≥1).
|
||||
/// Новый размер выборки
|
||||
/// </summary>
|
||||
public int? SampleSize { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Новый план авто-вступлений (рост — с проверкой бюджета).
|
||||
/// Новый план авто-вступлений
|
||||
/// </summary>
|
||||
public int? PlanJoins { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Новый флаг авто-вступлений (false — выключить).
|
||||
/// Новый флаг авто-вступлений
|
||||
/// </summary>
|
||||
public bool? AutoJoin { get; init; }
|
||||
}
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
namespace Deal.Api.Endpoints.RequestModels;
|
||||
|
||||
/// <summary>
|
||||
/// Тело POST /api/cards/mark-col-seen — снять «новое» с колонки (dashboard_routes.py MarkColBody L183–184, api-map §3.2 L88).
|
||||
/// Тело POST /api/cards/mark-col-seen — снять «новое» с колонки.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Wire-имя — camelCase: col — колонка (inbox/archive/trash/доска). Отсутствие/null col → 400
|
||||
/// «Неизвестная колонка»: иначе пустой col попал бы в CardsService.MarkSeenAsync и снял «новое» со ВСЕХ
|
||||
/// карточек (truthiness-семантика прототипа: пустая строка = параметр не задан, mark_seen L250–256) —
|
||||
/// эндпоинт защищает от такого вызова (прототип: pydantic required 422).
|
||||
/// </remarks>
|
||||
public sealed record MarkColBody(string? Col);
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
namespace Deal.Api.Endpoints.RequestModels;
|
||||
|
||||
/// <summary>
|
||||
/// Тело POST /api/cards/{lead_id}/move — перенос карточки (dashboard_routes.py MoveBody L56–57, api-map §3.2 L89).
|
||||
/// Тело POST /api/cards/{lead_id}/move — перенос карточки.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Wire-имя — camelCase: to — колонка назначения: "inbox" либо id доски (<c>b_...</c>). Цель валидирует
|
||||
/// CardsService (400 «Переносить можно только на доски или в «Неразобранное»»); отсутствующий/null to
|
||||
/// трактуются той же валидацией (прототип — pydantic required 422).
|
||||
/// </remarks>
|
||||
public sealed record MoveBody(string? To);
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
namespace Deal.Api.Endpoints.RequestModels;
|
||||
|
||||
/// <summary>
|
||||
/// Тело PUT /api/operator/settings/telegram-keys: глобальные ключи приложения Telegram,
|
||||
/// задаваемые оператором (ТЗ §4.1/§8.1). Поддерживается частичное обновление: непереданное поле
|
||||
/// (<c>null</c>) сохраняет текущее значение, явное значение валидируется. Если ключей ещё нет,
|
||||
/// оба поля обязательны.
|
||||
/// Тело PUT /api/operator/settings/telegram-keys
|
||||
/// </summary>
|
||||
/// <param name="ApiId">api_id приложения Telegram (5..9 цифр); null — не менялось.</param>
|
||||
/// <param name="ApiHash">api_hash приложения Telegram (непустой секрет; хранится зашифрованным); null — не менялось.</param>
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
namespace Deal.Api.Endpoints.RequestModels;
|
||||
|
||||
/// <summary>
|
||||
/// Тело POST /api/containers/reorder — новый порядок контейнеров пространства (этап 9, T4).
|
||||
/// Тело POST /api/containers/reorder — новый порядок контейнеров пространства.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Wire-имена — camelCase: space (пространство dashboard/selected; отсутствие — dashboard) и order
|
||||
/// (список id контейнеров в новом порядке). Отсутствие/явный null у order — 400 «Не указан порядок колонок».
|
||||
/// </remarks>
|
||||
/// <param name="Space">Пространство (dashboard/selected); null — dashboard.</param>
|
||||
/// <param name="Order">Id контейнеров в новом порядке.</param>
|
||||
public sealed record OrderBody(string? Space, IReadOnlyList<string>? Order);
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
namespace Deal.Api.Endpoints.RequestModels;
|
||||
|
||||
/// <summary>
|
||||
/// Тело POST /api/cards/reclassify — ИИ-переклассификация «Неразобранного» (dashboard_routes.py ReclassifyBody L64–65, api-map §3.2 L95).
|
||||
/// Тело POST /api/cards/reclassify — ИИ-переклассификация «Неразобранного».
|
||||
/// </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);
|
||||
|
||||
@@ -1,17 +1,7 @@
|
||||
namespace Deal.Api.Endpoints.RequestModels;
|
||||
|
||||
/// <summary>
|
||||
/// Тело POST /api/cards/{cardId}/reminder — установка напоминания hold-карточке
|
||||
/// (projects_routes.py ReminderBody L52–53, api-map §3.5 L172; Ruling 3).
|
||||
/// Тело POST /api/cards/{cardId}/reminder — установка напоминания hold-карточке.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Wire-имя — camelCase: at — время напоминания в epoch-мс (рассчитывает фронт HoldReminderDialog:
|
||||
/// «через N дней (1–30)» или «дата+время» локального времени; store.js setHoldReminder 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>
|
||||
public sealed record ReminderSetRequest(long? At);
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
namespace Deal.Api.Endpoints.RequestModels;
|
||||
|
||||
/// <summary>
|
||||
/// Тело POST /api/pipeline/rejected/{rejId}/return — причина возврата в обработку (processing_routes.py ReturnBody L13–15, api-map §3.6 L185).
|
||||
/// Тело POST /api/pipeline/rejected/{rejId}/return — причина возврата в обработку.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Wire-имя — camelCase: reason (опциональна, дефолт "" — как pydantic reason: str = ""; фронт шлёт
|
||||
/// {reason} всегда). Пустая причина допустима: сервис тримит и кладёт на запись для аудита
|
||||
/// (return_to_queue L158, Ruling 10).
|
||||
/// </remarks>
|
||||
public sealed record ReturnReasonRequest(string? Reason);
|
||||
|
||||
@@ -1,13 +1,8 @@
|
||||
namespace Deal.Api.Endpoints.RequestModels;
|
||||
|
||||
/// <summary>
|
||||
/// Тело POST /api/cards/take — «взять в работу» карточки с дашборда (этап 9, T6).
|
||||
/// Тело POST /api/cards/take — «взять в работу» карточки с дашборда.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Wire-имена — camelCase: cardId — id карточки; leadId — алиас (совместимость со старым фронтом).
|
||||
/// Карточка не клонируется: она переносится в контейнер planned. Отсутствующий/несуществующий id →
|
||||
/// 404 «Карточка не найдена».
|
||||
/// </remarks>
|
||||
/// <param name="CardId">Id карточки, берущейся в работу.</param>
|
||||
/// <param name="LeadId">Алиас cardId.</param>
|
||||
public sealed record TakeCardRequest(string? CardId = null, string? LeadId = null);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
namespace Deal.Api.Endpoints.RequestModels;
|
||||
|
||||
/// <summary>
|
||||
/// Тело POST /api/tg/dialogs/{dialog_id}/monitor и /monitor-all (tg_routes.py MonitorBody L54–56).
|
||||
/// Тело POST /api/tg/dialogs/{dialog_id}/monitor и /monitor-all.
|
||||
/// </summary>
|
||||
/// <param name="Enabled">True — мониторить (сообщения → PushMessage в ядро), false — выключить.</param>
|
||||
public sealed record TgMonitorBody(bool Enabled);
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
namespace Deal.Api.Endpoints.RequestModels;
|
||||
|
||||
/// <summary>
|
||||
/// Тело POST /api/tg/dialogs/preview — последние сообщения диалога (tg_routes.py PreviewBody L58–60).
|
||||
/// Тело POST /api/tg/dialogs/preview — последние сообщения диалога.
|
||||
/// </summary>
|
||||
/// <param name="DialogId">Id диалога (подписанный).</param>
|
||||
/// <param name="Limit">Сколько последних сообщений; дефолт 24, кламп 1..50 (python L153).</param>
|
||||
/// <param name="Limit">Сколько последних сообщений; дефолт 24, кламп 1..50.</param>
|
||||
public sealed record TgPreviewBody(string DialogId, int? Limit);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
namespace Deal.Api.Endpoints.RequestModels;
|
||||
|
||||
/// <summary>
|
||||
/// Тело POST /api/tg/send-code — SMS-код входа (tg_routes.py CodeBody L46–48).
|
||||
/// Тело POST /api/tg/send-code — SMS-код входа.
|
||||
/// </summary>
|
||||
/// <param name="Code">Код из SMS/Telegram-сообщения (trim перед отправкой, python L89).</param>
|
||||
/// <param name="Code">Код из SMS/Telegram-сообщения.</param>
|
||||
public sealed record TgSendCodeRequest(string Code);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
namespace Deal.Api.Endpoints.RequestModels;
|
||||
|
||||
/// <summary>
|
||||
/// Тело POST /api/tg/send-password — облачный пароль 2FA (tg_routes.py PasswordBody L50–52).
|
||||
/// Тело POST /api/tg/send-password — облачный пароль 2FA.
|
||||
/// </summary>
|
||||
/// <param name="Password">Пароль облачной защиты (как ввёл пользователь, без trim — python L100).</param>
|
||||
/// <param name="Password">Пароль облачной защиты.</param>
|
||||
public sealed record TgSendPasswordRequest(string Password);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
namespace Deal.Api.Endpoints.RequestModels;
|
||||
|
||||
/// <summary>
|
||||
/// Тело POST /api/tg/start-phone — вход по номеру телефона (tg_routes.py PhoneBody L42–44).
|
||||
/// Тело POST /api/tg/start-phone — вход по номеру телефона.
|
||||
/// </summary>
|
||||
/// <param name="Phone">Номер в международном формате (как ввёл пользователь; обрезается обработчиком, python L71).</param>
|
||||
/// <param name="Phone">Номер в международном формате.</param>
|
||||
public sealed record TgStartPhoneRequest(string Phone);
|
||||
|
||||
@@ -9,26 +9,8 @@ using Deal.Modules.Tenants.Application.Models;
|
||||
namespace Deal.Api.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// HTTP-эндпоинты настроек тенанта: GET/PATCH /api/settings (api-map §3.4 L146–147, §4.6).
|
||||
/// HTTP-эндпоинты настроек тенанта
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// GET — публичный снимок настроек (дефолты + переопределения, маски секретов, providers — Ruling 3);
|
||||
/// PATCH — произвольный JSON-объект публичных полей §4.6, ответ — полный снимок после применения
|
||||
/// (фронт затирает локальный state ответом — store.js). Оба эндпоинта требуют сессию:
|
||||
/// 401 {"detail":"Требуется авторизация"} (Ruling 10). Мягкая семантика: невалидное поле PATCH
|
||||
/// просто не применяется; жёсткая ошибка — только тело не JSON-объект (400).
|
||||
/// Побочные эффекты прототипа L186–192: PATCH с полем rateSource запускает фоновое
|
||||
/// обновление кэша курсов (<see cref="RatesRefreshScheduler"/>, Ruling 6); пересчёт карточек при смене
|
||||
/// targetCurrency/conversionOn выполняет сам SettingsService через порт <see cref="IRatesChangedListener"/>
|
||||
/// (реализация — ConversionRecomputer модуля Kanban, Ruling 7, Task 12).
|
||||
/// <para>
|
||||
/// SettingsService резолвится из RequestServices ВНУТРИ обработчика после проверки сессии, а не
|
||||
/// параметром эндпоинта: DI-биндинг параметров выполняется до тела обработчика, а зависимость
|
||||
/// сервиса — scoped TenantDbContext, опции которого строятся по tenant-контексту запроса
|
||||
/// (без сессии контекст не разрешим — ошибка конфигурации). Так запрос без сессии получает 401,
|
||||
/// а не 500 при резолве.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class SettingsEndpoints
|
||||
{
|
||||
private const string ApiGroupPrefix = "/api";
|
||||
@@ -83,7 +65,6 @@ public static class SettingsEndpoints
|
||||
catch (JsonException)
|
||||
{
|
||||
// Не-JSON или не-объект целиком — ошибка запроса: 400 + detail
|
||||
// (в прототипе FastAPI на такое тело — 422).
|
||||
return EndpointResults.BadRequest(InvalidBodyDetail);
|
||||
}
|
||||
|
||||
@@ -95,11 +76,8 @@ public static class SettingsEndpoints
|
||||
SettingsService settingsService = context.RequestServices.GetRequiredService<SettingsService>();
|
||||
PublicSettingsDto result = await settingsService.ApplyPatchAsync(body, ct);
|
||||
|
||||
// Аудит сохранения настроек (этап 10, T1): только имена полей — значения (в т.ч. секреты) не пишутся.
|
||||
await AuditAppender.AppendTenantAsync(context, AuditEvents.SettingsUpdated, new { fields = body.Keys }, ct);
|
||||
|
||||
// Смена источника курсов в PATCH (settings_routes.py L188–189) — фоновое обновление кэша
|
||||
// курсов (Ruling 6, Task 8). RefreshAsync читает уже сохранённую настройку rateSource.
|
||||
if (ShouldScheduleRatesRefresh(body))
|
||||
{
|
||||
context.RequestServices.GetRequiredService<RatesRefreshScheduler>().Schedule();
|
||||
@@ -108,7 +86,6 @@ public static class SettingsEndpoints
|
||||
return Results.Ok(result);
|
||||
}
|
||||
|
||||
// Запускать ли фоновый refresh курсов после PATCH (семантика if body.get("rateSource") L188).
|
||||
// body: Тело PATCH — публичные ключи §4.6.
|
||||
// Возвращает: True — поле rateSource передано «правдивым» значением (не null/пустая строка).
|
||||
private static bool ShouldScheduleRatesRefresh(Dictionary<string, JsonElement> body)
|
||||
@@ -118,7 +95,6 @@ public static class SettingsEndpoints
|
||||
return false;
|
||||
}
|
||||
|
||||
// JSON-булево/число в python «правдивы» и запускают refresh; пустая строка/null — нет.
|
||||
return element.ValueKind switch
|
||||
{
|
||||
JsonValueKind.String => !string.IsNullOrEmpty(element.GetString()),
|
||||
|
||||
@@ -5,37 +5,16 @@ using Deal.Infrastructure.Services;
|
||||
namespace Deal.Api.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Служебные storage-эндпоинты: POST /api/admin/tick и POST /api/admin/fts/rebuild (план Tasks 10–11,
|
||||
/// Rulings 6/8/11; прототип dashboard_routes.py L261–264, L327–337).
|
||||
/// Служебные storage-эндпоинты
|
||||
/// </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
|
||||
{
|
||||
// Префикс группы (роутер dashboard, prefix="/api"; admin-пути прототипа L261/L327).
|
||||
private const string AdminGroupPrefix = "/api";
|
||||
|
||||
// Путь ручного тика правил хранения (dashboard_routes.py L327).
|
||||
private const string TickPath = "/admin/tick";
|
||||
|
||||
// Путь пересборки поискового индекса (dashboard_routes.py L261; Ruling 6).
|
||||
private const string FtsRebuildPath = "/admin/fts/rebuild";
|
||||
|
||||
// OpenAPI-тег группы (в прототипе роутер dashboard — dashboard_routes.py).
|
||||
private const string OpenApiTag = "dashboard";
|
||||
|
||||
/// <summary>
|
||||
@@ -51,12 +30,9 @@ public static class StorageEndpoints
|
||||
return app;
|
||||
}
|
||||
|
||||
// POST /api/admin/tick: правила хранения + очистка отсева + напоминания + pump + SSE-тосты/new_card/reminder_due (admin_tick L327–337).
|
||||
// Весь состав тика — AdminTickOrchestrator (вынесен из эндпоинта для unit-тестов логики и
|
||||
// переиспользования): тик StorageTickService (Kanban) → PurgeExpiredAsync (отсев 3 суток, merge в
|
||||
// storage.purgedRejected) → тосты статистики (включая «Отсев очищен: N записей (3 дн.)») → CheckDueAsync
|
||||
// (напоминания «Отложено»: reminders ответа + SSE reminder_due, план Task 11; сбой не роняет тик) →
|
||||
// PumpOnceAsync (сбой не роняет тик) → SSE new_card по созданным карточкам → queue. Формы — 1:1 с прототипом.
|
||||
private static async Task<IResult> AdminTickAsync(HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!context.HasUser())
|
||||
@@ -69,11 +45,8 @@ public static class StorageEndpoints
|
||||
return Results.Ok(await orchestrator.TickAsync(tenantId, ct));
|
||||
}
|
||||
|
||||
// POST /api/admin/fts/rebuild: пересборка FTS-индексов тенанта; ответ {ok:true, ready:true} (Ruling 6).
|
||||
// SearchTsv — генерируемые STORED-колонки Cards/RejectedItems: авто-актуальны, «пересборка» = создание
|
||||
// отсутствующих GIN-индексов (CREATE INDEX IF NOT EXISTS) + ANALYZE таблиц (FtsMaintenance.RebuildAsync).
|
||||
// Сбой обслуживания возвращает {ok:false, ready:false} (прототип fts_rebuild L261–264: rebuild() → ok,
|
||||
// is_ready() → ready) — кнопка Settings фронта показывает ошибку по ready (store.js L1883–1889).
|
||||
private static async Task<IResult> FtsRebuildAsync(HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!context.HasUser())
|
||||
|
||||
@@ -11,25 +11,12 @@ using Deal.Modules.Tenants.Application.Models;
|
||||
namespace Deal.Api.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Эндпоинты /api/tg: статус, веб-авторизация (phone/QR/код/2FA/logout), диалоги и мониторинг (Ruling 8, api-map §3.3).
|
||||
/// Эндпоинты /api/tg
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Тела ответов 1:1 с прототипом <c>backend/app/routers/tg_routes.py</c>:
|
||||
/// <c>status</c> — §4.9 (собирает <see cref="TgStatusService"/>); start-phone/send-code/send-password — <c>{phase}</c>;
|
||||
/// start-qr — <c>{phase, qrUrl}</c>; logout — <c>{ok:true}</c>; dialogs — <c>{items:[диалог §4.8]}</c> (type — русская
|
||||
/// форма на границе: канал/группа/чат, заметка Task 1); refresh — <c>{ok, count}</c> либо <c>{ok:false,
|
||||
/// reason:"not-connected", count:0}</c> (мягкая ветка 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
|
||||
{
|
||||
// Префикс группы /api/tg (python: router prefix, tg_routes.py L12).
|
||||
private const string TgGroupPrefix = "/api/tg";
|
||||
|
||||
// OpenAPI-тег группы (в прототипе роутер tg — tg_routes.py).
|
||||
private const string TgOpenApiTag = "telegram";
|
||||
|
||||
// Путь статуса аккаунта/фазы входа (GET).
|
||||
@@ -80,24 +67,18 @@ public static class TelegramEndpoints
|
||||
// Деталь недоступного telegram-service/неподключённого аккаунта (глобальная строка контракта).
|
||||
private const string NotConnectedDetail = "Telegram не подключён";
|
||||
|
||||
// Reason мягкой ветки refresh: аккаунт не подключён (tg_routes.py L120).
|
||||
private const string NotConnectedReason = "not-connected";
|
||||
|
||||
// Фаза успешной привязки аккаунта Telegram (telegram_linked — этап 10, T1).
|
||||
private const string ReadyPhase = "ready";
|
||||
|
||||
// Дефолт limit превью (PreviewBody L60: limit = 24).
|
||||
private const int PreviewDefaultLimit = 24;
|
||||
|
||||
// Нижняя граница limit превью (python L153: min(…, 1)).
|
||||
private const int PreviewLimitMin = 1;
|
||||
|
||||
// Верхняя граница limit превью (python L153: max(…, 50); api-map /dialogs/preview).
|
||||
private const int PreviewLimitMax = 50;
|
||||
|
||||
/// <summary>
|
||||
/// Регистрирует группу /api/tg: status/start-phone/start-qr/send-code/send-password/logout/dialogs/refresh/
|
||||
/// monitor-all/backfill-all/preview/{dialog_id}/monitor/{dialog_id}/backfill (qr-image — TelegramQrImageEndpoint).
|
||||
/// Регистрирует группу /api/tg
|
||||
/// </summary>
|
||||
/// <param name="app">Построитель маршрутов приложения.</param>
|
||||
/// <returns>Построитель маршрутов для цепочки вызовов.</returns>
|
||||
@@ -122,7 +103,6 @@ public static class TelegramEndpoints
|
||||
return app;
|
||||
}
|
||||
|
||||
// GET /api/tg/status: статус аккаунта/фазы входа (tg_routes.py L63–65; форма §4.9).
|
||||
private static async Task<IResult> StatusAsync(HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!context.HasUser())
|
||||
@@ -134,7 +114,6 @@ public static class TelegramEndpoints
|
||||
return Results.Ok(await statusService.GetAsync(ct));
|
||||
}
|
||||
|
||||
// POST /api/tg/start-phone: запросить код по номеру (tg_routes.py L68–74; python L134–147).
|
||||
private static async Task<IResult> StartPhoneAsync(
|
||||
TgStartPhoneRequest body,
|
||||
HttpContext context,
|
||||
@@ -169,7 +148,6 @@ public static class TelegramEndpoints
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/tg/start-qr: начать QR-вход (tg_routes.py L77–83; python qr_start L286–300).
|
||||
private static async Task<IResult> StartQrAsync(HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!context.HasUser())
|
||||
@@ -194,7 +172,6 @@ public static class TelegramEndpoints
|
||||
TelegramAuthResultDto result = await gateway.StartQrAsync(apiId, keys.ApiHash, ct);
|
||||
if (result.Phase == ReadyPhase)
|
||||
{
|
||||
// Аудит привязки Telegram (этап 10, T1): аккаунт уже авторизован — фаза ready.
|
||||
await AuditAppender.AppendTenantAsync(context, AuditEvents.TelegramLinked, new { phase = result.Phase }, ct);
|
||||
}
|
||||
|
||||
@@ -206,7 +183,6 @@ public static class TelegramEndpoints
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/tg/send-code: отправить SMS-код (tg_routes.py L86–94; python submit_code L149–166).
|
||||
private static async Task<IResult> SendCodeAsync(
|
||||
TgSendCodeRequest body,
|
||||
HttpContext context,
|
||||
@@ -223,7 +199,6 @@ public static class TelegramEndpoints
|
||||
string phase = await gateway.SendCodeAsync((body.Code ?? string.Empty).Trim(), ct);
|
||||
if (phase == ReadyPhase)
|
||||
{
|
||||
// Аудит привязки Telegram (этап 10, T1): вход завершён без 2FA — фаза ready.
|
||||
await AuditAppender.AppendTenantAsync(context, AuditEvents.TelegramLinked, new { phase }, ct);
|
||||
}
|
||||
|
||||
@@ -235,7 +210,6 @@ public static class TelegramEndpoints
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/tg/send-password: облачный пароль 2FA (tg_routes.py L97–103; python submit_password L168–176).
|
||||
private static async Task<IResult> SendPasswordAsync(
|
||||
TgSendPasswordRequest body,
|
||||
HttpContext context,
|
||||
@@ -252,7 +226,6 @@ public static class TelegramEndpoints
|
||||
string phase = await gateway.SendPasswordAsync(body.Password ?? string.Empty, ct);
|
||||
if (phase == ReadyPhase)
|
||||
{
|
||||
// Аудит привязки Telegram (этап 10, T1): 2FA пройдена — фаза ready.
|
||||
await AuditAppender.AppendTenantAsync(context, AuditEvents.TelegramLinked, new { phase }, ct);
|
||||
}
|
||||
|
||||
@@ -264,7 +237,6 @@ public static class TelegramEndpoints
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/tg/logout: отключить аккаунт, удалить сессию (tg_routes.py L106–109; python disconnect L189–207).
|
||||
private static async Task<IResult> LogoutAsync(HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!context.HasUser())
|
||||
@@ -284,7 +256,6 @@ public static class TelegramEndpoints
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/tg/dialogs: список диалогов из БД (tg_routes.py L112–114; list_dialogs L521–534).
|
||||
private static async Task<IResult> DialogsAsync(HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!context.HasUser())
|
||||
@@ -295,8 +266,6 @@ public static class TelegramEndpoints
|
||||
DialogsService dialogs = context.RequestServices.GetRequiredService<DialogsService>();
|
||||
IReadOnlyList<TelegramDialogDto> rows = await dialogs.ListAsync(ct);
|
||||
|
||||
// Форма §4.8 L349: {id, name, handle, type, hue, on, last:{text,time}}; type — русская форма на границе
|
||||
// (EN-канон каталога channel/group/forum/chat → «канал»/«группа»/«чат», заметка Task 1/«кривое место» п.4).
|
||||
var items = new List<object?>(rows.Count);
|
||||
foreach (TelegramDialogDto dialog in rows)
|
||||
{
|
||||
@@ -317,7 +286,6 @@ public static class TelegramEndpoints
|
||||
return Results.Ok(new { items });
|
||||
}
|
||||
|
||||
// POST /api/tg/dialogs/refresh: синхронизировать каталог диалогов из Telegram (tg_routes.py L117–122).
|
||||
private static async Task<IResult> DialogsRefreshAsync(HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!context.HasUser())
|
||||
@@ -328,7 +296,6 @@ public static class TelegramEndpoints
|
||||
DialogsService dialogs = context.RequestServices.GetRequiredService<DialogsService>();
|
||||
ITelegramGateway gateway = context.RequestServices.GetRequiredService<ITelegramGateway>();
|
||||
|
||||
// 1:1 tg_routes.py L119–120: аккаунт не подключён (или сервис недоступен — Ruling 7) → мягкая ветка
|
||||
// {ok:false, reason:"not-connected", count:0} HTTP 200 — refresh не ошибка запроса.
|
||||
bool connected;
|
||||
try
|
||||
@@ -358,7 +325,6 @@ public static class TelegramEndpoints
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/tg/dialogs/monitor-all: мониторинг всех каналов (tg_routes.py L125–129; L548–567).
|
||||
private static async Task<IResult> MonitorAllAsync(
|
||||
TgMonitorBody body,
|
||||
HttpContext context,
|
||||
@@ -375,13 +341,11 @@ public static class TelegramEndpoints
|
||||
TelegramMonitorAllDto result = await dialogs.SetMonitorAllAsync(body.Enabled, ct);
|
||||
if (body.Enabled && result.BackfillNeededIds.Count > 0)
|
||||
{
|
||||
// Первое включение неразобранных: фоновый разбор списком (python L566: _spawn(_backfill_dialogs)).
|
||||
context.RequestServices.GetRequiredService<TelegramBackfillScheduler>().ScheduleFirstBackfills(result.BackfillNeededIds);
|
||||
}
|
||||
|
||||
if (body.Enabled)
|
||||
{
|
||||
// Аудит включения каналов (этап 10, T1): без имён/содержимого.
|
||||
await AuditAppender.AppendTenantAsync(context, AuditEvents.ChannelEnabled, new { all = true, count = result.Count }, ct);
|
||||
}
|
||||
|
||||
@@ -393,7 +357,6 @@ public static class TelegramEndpoints
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/tg/dialogs/backfill-all: «Перечитать» включённые каналы в фоне (tg_routes.py L132–136).
|
||||
private static async Task<IResult> BackfillAllAsync(HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!context.HasUser())
|
||||
@@ -405,14 +368,12 @@ public static class TelegramEndpoints
|
||||
int count = (await dialogs.ListMonitoredIdsAsync(ct)).Count;
|
||||
if (count > 0)
|
||||
{
|
||||
// Ответ — сразу {ok, count}, разбор идёт в фоне (python L580: _spawn(backfill_monitored)).
|
||||
context.RequestServices.GetRequiredService<TelegramBackfillScheduler>().ScheduleReadRecent();
|
||||
}
|
||||
|
||||
return Results.Ok(new { ok = true, count });
|
||||
}
|
||||
|
||||
// POST /api/tg/dialogs/{dialog_id}/monitor: вкл/выкл мониторинг канала (tg_routes.py L139–142; L536–546).
|
||||
private static async Task<IResult> SetMonitorAsync(
|
||||
string dialog_id,
|
||||
TgMonitorBody body,
|
||||
@@ -430,13 +391,11 @@ public static class TelegramEndpoints
|
||||
TelegramMonitorToggleDto result = await dialogs.SetMonitorAsync(dialog_id, body.Enabled, ct);
|
||||
if (result.BackfillNeeded)
|
||||
{
|
||||
// Первое включение неразобранного канала: фоновый разбор (python L546: _spawn(backfill_dialog)).
|
||||
context.RequestServices.GetRequiredService<TelegramBackfillScheduler>().ScheduleFirstBackfill(dialog_id);
|
||||
}
|
||||
|
||||
if (result.Enabled)
|
||||
{
|
||||
// Аудит включения канала (этап 10, T1).
|
||||
await AuditAppender.AppendTenantAsync(context, AuditEvents.ChannelEnabled, new { dialogId = dialog_id }, ct);
|
||||
}
|
||||
|
||||
@@ -448,8 +407,6 @@ public static class TelegramEndpoints
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/tg/dialogs/{dialog_id}/backfill: разбор одного диалога (tg_routes.py L145–148; L349–390).
|
||||
// Сервер-only эндпоинт (фронт не вызывает, api-map §3.3 L139/п.9): первый разбор/догон одного канала.
|
||||
private static async Task<IResult> BackfillDialogAsync(
|
||||
string dialog_id,
|
||||
HttpContext context,
|
||||
@@ -472,7 +429,6 @@ public static class TelegramEndpoints
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/tg/dialogs/preview: последние сообщения диалога (tg_routes.py L151–153; dialog_messages L583–620).
|
||||
private static async Task<IResult> PreviewAsync(
|
||||
TgPreviewBody body,
|
||||
HttpContext context,
|
||||
@@ -500,7 +456,6 @@ public static class TelegramEndpoints
|
||||
return keys.KeysSet ? keys : null;
|
||||
}
|
||||
|
||||
// 400 {detail} по ошибке гейта: канонический detail RPC либо «Telegram не подключён» (Ruling 7/8).
|
||||
// exception: Исключение вызова гейта (RpcException домена/транспорта, прочее).
|
||||
// Возвращает: 400-ответ с текстом причины.
|
||||
private static IResult GatewayError(Exception exception)
|
||||
@@ -509,7 +464,7 @@ public static class TelegramEndpoints
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Текст причины ошибки гейта для {detail} (канонические тексты telegram-service 1:1, Ruling 7).
|
||||
/// Текст причины ошибки гейта для {detail}.
|
||||
/// </summary>
|
||||
/// <param name="exception">Исключение вызова гейта.</param>
|
||||
/// <returns>Текст причины.</returns>
|
||||
@@ -519,16 +474,13 @@ public static class TelegramEndpoints
|
||||
{
|
||||
// Доменная RPC-ошибка: detail от telegram-service («Неверный код», «Telegram не подключён», …).
|
||||
global::Grpc.Core.RpcException rpc when !string.IsNullOrEmpty(rpc.Status.Detail) => rpc.Status.Detail,
|
||||
// Недоступность/прочий транспорт — «не подключён» (GrpcTelegramClient нормализует, Ruling 7).
|
||||
_ => NotConnectedDetail,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Русская форма типа источника на границе эндпоинта (заметка Task 1, api-map §4.8 L349).
|
||||
/// Русская форма типа источника на границе эндпоинта.
|
||||
/// </summary>
|
||||
/// <remarks>Каталог ядра хранит EN-канон (channel/group/forum/chat); наружу (вкладка «Каналы», фильтр по типу)
|
||||
/// — русские подписи python (_kind_of L461–466: «канал»/«группа»/«чат»; форум отображается как группа).</remarks>
|
||||
/// <param name="kind">Тип источника (EN-канон каталога либо уже русская подпись).</param>
|
||||
/// <returns>Русская подпись: channel→«канал», group/forum→«группа», chat→«чат»; иное — как есть.</returns>
|
||||
public static string ToRussianDialogType(string kind)
|
||||
|
||||
@@ -7,16 +7,8 @@ using Net.Codecrete.QrCodeGenerator;
|
||||
namespace Deal.Api.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// GET /api/tg/qr-image: SVG QR-кода входа (tg_routes.py L30–39; Ruling 8).
|
||||
/// GET /api/tg/qr-image
|
||||
/// </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
|
||||
{
|
||||
// Префикс группы /api/tg (общий с TelegramEndpoints).
|
||||
@@ -25,16 +17,12 @@ public static class TelegramQrImageEndpoint
|
||||
// Путь SVG QR-кода (GET; фронт добавляет ?t=N от кэша).
|
||||
private const string QrImagePath = "/qr-image";
|
||||
|
||||
// OpenAPI-тег группы (в прототипе роутер tg — tg_routes.py).
|
||||
private const string QrImageOpenApiTag = "telegram";
|
||||
|
||||
// Деталь 404: QR не активен (python L34, глобальная строка контракта).
|
||||
private const string QrNotActiveDetail = "QR не активен — начните вход по QR";
|
||||
|
||||
// Media-type SVG-ответа (python L37: image/svg+xml).
|
||||
private const string SvgMediaType = "image/svg+xml";
|
||||
|
||||
// Ширина рамки (quiet zone) QR в модулях (python L22: qrcode border=1).
|
||||
private const int QrBorderModules = 1;
|
||||
|
||||
/// <summary>
|
||||
@@ -49,7 +37,6 @@ public static class TelegramQrImageEndpoint
|
||||
return app;
|
||||
}
|
||||
|
||||
// GET /api/tg/qr-image: SVG QR-кода фазы входа «qr» (tg_routes.py L30–39).
|
||||
private static async Task<IResult> QrImageAsync(HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!context.HasUser())
|
||||
@@ -57,7 +44,6 @@ public static class TelegramQrImageEndpoint
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
// Активен только в фазе «qr» живого статуса telegram-service; сервис недоступен/фаза иная — 404 (python L33–34).
|
||||
TelegramAccountStatusDto live;
|
||||
try
|
||||
{
|
||||
@@ -77,7 +63,6 @@ public static class TelegramQrImageEndpoint
|
||||
QrCode qr = QrCode.EncodeText(live.QrUrl, QrCode.Ecc.Medium);
|
||||
string svg = qr.ToSvgString(QrBorderModules);
|
||||
|
||||
// Свежий QR на каждый запрос (не кэшировать); inline — как python L37–38.
|
||||
context.Response.Headers.CacheControl = "no-store";
|
||||
context.Response.Headers.ContentDisposition = "inline";
|
||||
return Results.Text(svg, SvgMediaType);
|
||||
|
||||
@@ -5,21 +5,12 @@ using System.Threading.Channels;
|
||||
namespace Deal.Api.Events;
|
||||
|
||||
/// <summary>
|
||||
/// Singleton SSE-брокер этапа: per-tenant каналы событий (Ruling 5, план Task 9).
|
||||
/// Singleton SSE-брокер
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Канал заводится на тенанта при подписке (тенант сессии — CurrentUser.TenantId), публикация идёт
|
||||
/// в канал тенанта по явному идентификатору — её делают ТОЛЬКО эндпоинты Api после вызова сервисов
|
||||
/// модулей (Task 10/13/14); фоновые задачи вне tenant-запроса публикуют со своим scope + ITenantContext
|
||||
/// (Task 11). Публикация без подписчиков канала — no-op, не падает (Ruling 5). Очередь подписчика —
|
||||
/// bounded ≤200 с вытеснением старых, как прототип sse.py (maxsize=200, при переполнении get_nowait →
|
||||
/// put_nowait текущего события). Потокобезопасен: словарь защищён гейтом; запись в каналы —
|
||||
/// неблокирующий TryWrite (DropOldest) вне гейта, подписки/отписки конкурентны публикациям.
|
||||
/// </remarks>
|
||||
public sealed class SseBroker
|
||||
{
|
||||
/// <summary>
|
||||
/// Ёмкость очереди подписчика (sse.py L20: <c>asyncio.Queue(maxsize=200)</c>).
|
||||
/// Ёмкость очереди подписчика
|
||||
/// </summary>
|
||||
public const int SubscriberQueueCapacity = 200;
|
||||
|
||||
@@ -36,15 +27,14 @@ public sealed class SseBroker
|
||||
private readonly Dictionary<Guid, Dictionary<Guid, Channel<SseEvent>>> _subscribersByTenant = new();
|
||||
|
||||
/// <summary>
|
||||
/// Подписывает клиента на канал тенанта: новая bounded-очередь (≤200, DropOldest).
|
||||
/// Подписывает клиента на канал тенанта
|
||||
/// </summary>
|
||||
/// <param name="tenantId">Тенант сессии запроса (Ruling 5: канал по TenantId при подписке).</param>
|
||||
/// <param name="tenantId">Тенант сессии запроса.</param>
|
||||
/// <returns>Подписка: идентификатор для отписки и читатель канала событий.</returns>
|
||||
public SseSubscription Subscribe(Guid tenantId)
|
||||
{
|
||||
var channel = Channel.CreateBounded<SseEvent>(new BoundedChannelOptions(SubscriberQueueCapacity)
|
||||
{
|
||||
// Вытеснение старых при переполнении (sse.py L36–44): TryWrite не блокирует и не падает.
|
||||
FullMode = BoundedChannelFullMode.DropOldest,
|
||||
SingleReader = true,
|
||||
SingleWriter = false,
|
||||
@@ -66,7 +56,7 @@ public sealed class SseBroker
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Отписывает клиента по завершении SSE-соединения (events_routes.py L27–28).
|
||||
/// Отписывает клиента по завершении SSE-соединения.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">Тенант канала подписки.</param>
|
||||
/// <param name="subscriptionId">Идентификатор подписки из <see cref="Subscribe"/>.</param>
|
||||
@@ -89,10 +79,10 @@ public sealed class SseBroker
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Публикует событие в канал тенанта (Ruling 5: публикации — из эндпоинтов Api).
|
||||
/// Публикует событие в канал тенанта.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">Тенант-получатель; без подписчиков — no-op, не падает.</param>
|
||||
/// <param name="eventType">Тип события (new_card/toast этапа 3; api.js L78–79).</param>
|
||||
/// <param name="eventType">Тип события.</param>
|
||||
/// <param name="payload">Полезная нагрузка — сериализуется в JSON (camelCase, без \u).</param>
|
||||
public void Publish(
|
||||
Guid tenantId,
|
||||
@@ -101,7 +91,7 @@ public sealed class SseBroker
|
||||
Publish(tenantId, new SseEvent(eventType, JsonSerializer.Serialize(payload, PublishJsonOptions)));
|
||||
|
||||
/// <summary>
|
||||
/// Публикует готовое событие (тип + JSON) в канал тенанта.
|
||||
/// Публикует готовое событие
|
||||
/// </summary>
|
||||
/// <param name="tenantId">Тенант-получатель; без подписчиков — no-op, не падает.</param>
|
||||
/// <param name="sseEvent">Событие с уже сериализованной нагрузкой.</param>
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
namespace Deal.Api.Events;
|
||||
|
||||
/// <summary>
|
||||
/// Событие SSE-потока: тип + JSON-полезная нагрузка (Ruling 5; прототип sse.py L29–30).
|
||||
/// Событие SSE-потока
|
||||
/// </summary>
|
||||
/// <param name="Type">Тип события — фронт слушает <c>addEventListener</c> по имени
|
||||
/// (api.js L78–81): на этапе 3 — <c>new_card</c> (полный объект карточки) и <c>toast</c> {text, icon}.</param>
|
||||
/// <param name="Type">Тип события — фронт слушает <c>addEventListener</c> по имени: на — <c>new_card</c> (полный объект карточки) и <c>toast</c> {text, icon}.</param>
|
||||
/// <param name="Json">Полезная нагрузка, сериализованная в JSON (camelCase, без \u-экранирования).</param>
|
||||
public sealed record SseEvent(string Type, string Json)
|
||||
{
|
||||
/// <summary>
|
||||
/// Отрисовывает frame протокола SSE: <c>event: <type>\ndata: <json>\n\n</c> (sse.py L30).
|
||||
/// Отрисовывает frame протокола SSE
|
||||
/// </summary>
|
||||
/// <returns>Готовый frame для отправки в поток ответа.</returns>
|
||||
public string RenderFrame() => $"event: {Type}\ndata: {Json}\n\n";
|
||||
|
||||
@@ -3,10 +3,9 @@ using System.Threading.Channels;
|
||||
namespace Deal.Api.Events;
|
||||
|
||||
/// <summary>
|
||||
/// Активная подписка на канал SSE тенанта (прототип sse.py — очередь подписчика L19–20).
|
||||
/// Активная подписка на канал SSE тенанта.
|
||||
/// </summary>
|
||||
/// <param name="Id">Идентификатор подписки — передаётся в <see cref="SseBroker.Unsubscribe"/>.</param>
|
||||
/// <param name="TenantId">Тенант канала: тенант сессии при подписке (Ruling 5).</param>
|
||||
/// <param name="Events">Канал событий подписчика: bounded-очередь ≤200 с вытеснением старых
|
||||
/// (DropOldest, как get_nowait+put_nowait прототипа L36–44).</param>
|
||||
/// <param name="TenantId">Тенант канала: тенант сессии при подписке.</param>
|
||||
/// <param name="Events">Канал событий подписчика: bounded-очередь ≤200 с вытеснением старых.</param>
|
||||
public sealed record SseSubscription(Guid Id, Guid TenantId, ChannelReader<SseEvent> Events);
|
||||
|
||||
@@ -3,36 +3,22 @@ using Deal.Modules.Kanban.Application.Models;
|
||||
namespace Deal.Api.Events;
|
||||
|
||||
/// <summary>
|
||||
/// Публикация SSE-тостов статистики тика правил хранения в канал тенанта (Ruling 8; notify_tick_stats L496–504).
|
||||
/// Публикация SSE-тостов статистики тика правил хранения в канал тенанта.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Единый хелпер Api-слоя для POST /api/admin/tick (StorageEndpoints/AdminTickOrchestrator, Task 10) и
|
||||
/// фонового StorageTickScheduler (Task 11): публикует тосты только по ненулевым счётчикам, тексты и иконки
|
||||
/// 1:1 с прототипом; без подписчиков канала публикация — no-op (Ruling 5). Вынесен из StorageEndpoints,
|
||||
/// чтобы ручной и фоновый тики не дублировали логику. Модуль Kanban тосты не публикует (Ruling 5:
|
||||
/// публикации SSE — обязанность Api-слоя).
|
||||
/// </remarks>
|
||||
public sealed class StorageToastPublisher
|
||||
{
|
||||
// Тип SSE-события тоста (Ruling 5; api.js L79 слушает 'toast').
|
||||
private const string ToastEventType = "toast";
|
||||
|
||||
// Текст тоста автоархива: N карточек ушло в архив (notify_tick_stats L498).
|
||||
private const string AutoArchiveToastText = "Автоархив: {0} карточек";
|
||||
|
||||
// Текст тоста очистки архива: N карточек удалено из архива (notify_tick_stats L500).
|
||||
private const string ArchiveClearedToastText = "Архив очищен: {0} (90 дн.)";
|
||||
|
||||
// Текст тоста очистки корзины: N карточек удалено из корзины (notify_tick_stats L502).
|
||||
private const string TrashClearedToastText = "Корзина очищена: {0} (7 дн.)";
|
||||
|
||||
// Текст тоста автоочистки отсева пайплайна: N записей старше 3 суток (notify_tick_stats L503–504).
|
||||
private const string RejectedPurgedToastText = "Отсев очищен: {0} записей (3 дн.)";
|
||||
|
||||
// Иконка тоста автоархива (Ruling 8, 1:1 с прототипом).
|
||||
private const string ClockIcon = "clock";
|
||||
|
||||
// Иконка тостов очисток архива/корзины (Ruling 8, 1:1 с прототипом).
|
||||
private const string TrashIcon = "trash";
|
||||
|
||||
private readonly SseBroker _broker;
|
||||
@@ -48,13 +34,8 @@ public sealed class StorageToastPublisher
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Публикует тосты статистики тика по ненулевым счётчикам (notify_tick_stats L496–504).
|
||||
/// Публикует тосты статистики тика по ненулевым счётчикам.
|
||||
/// </summary>
|
||||
/// <remarks>Тексты и иконки 1:1 с прототипом; дни в скобках («90 дн.»/«7 дн.»/«3 дн.») — фиксированные
|
||||
/// строки прототипа (не пересчитываются от настроек). Ветка purgedRejected («Отсев очищен: N записей
|
||||
/// (3 дн.)») — план Task 10: счётчик наполняет оркестратор тика (AdminTickOrchestrator) очисткой отсева
|
||||
/// PipelineProcessingService.PurgeExpiredAsync; фоновый цикл Task 11 публикует ту же ветку по своему тику.
|
||||
/// Публикация в канал тенанта; без подписчиков — no-op (Ruling 5).</remarks>
|
||||
/// <param name="tenantId">Тенант-получатель тостов (сессия запроса / канал тенанта цикла).</param>
|
||||
/// <param name="stats">Статистика только что выполненного тика.</param>
|
||||
public void PublishTickToasts(Guid tenantId, StorageTickStatsDto stats)
|
||||
|
||||
@@ -3,7 +3,7 @@ using Deal.Api.Models;
|
||||
namespace Deal.Api.Extensions;
|
||||
|
||||
/// <summary>
|
||||
/// Хелперы доступа к текущему пользователю запроса (минимальные API).
|
||||
/// Хелперы доступа к текущему пользователю запроса
|
||||
/// </summary>
|
||||
public static class AuthHelpers
|
||||
{
|
||||
@@ -18,12 +18,12 @@ public static class AuthHelpers
|
||||
public const string CurrentOperatorItemKey = "CurrentOperator";
|
||||
|
||||
/// <summary>
|
||||
/// Сообщение 401 для эндпоинтов, требующих авторизации (семантика прототипа, Ruling 10).
|
||||
/// Сообщение 401 для эндпоинтов, требующих авторизации.
|
||||
/// </summary>
|
||||
public const string UnauthorizedDetail = "Требуется авторизация";
|
||||
|
||||
/// <summary>
|
||||
/// Сообщение 401 для ручек /api/operator/* без разрешённой операторской сессии (Ruling 1).
|
||||
/// Сообщение 401 для ручек /api/operator/* без разрешённой операторской сессии.
|
||||
/// </summary>
|
||||
public const string OperatorUnauthorizedDetail = "Требуется вход оператора";
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user