Инициализировать репозиторий «Дейл»
Первый коммит: модульный монолит ядра (.NET 10) и gRPC-сервисы ai/ml/telegram, фронтенд Vue 3/Vite/Tailwind, документация (ТЗ, инструкция пользователя, техдокументация, код-стайл), бэклог, скрипты развёртывания и архив прототипа LeadRadar.
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
# Дейл (Deal) — исходники
|
||||
|
||||
- `core/` — модульный монолит .NET (бизнес-логика, API)
|
||||
- `ml-service/` — ML (.NET + ONNX), отдельный процесс
|
||||
- `ai-service/` — LLM-фасад, отдельный процесс
|
||||
- `telegram-service/` — ферма сессий Telegram, отдельный процесс
|
||||
- `contracts/` — общие .proto (gRPC)
|
||||
- `frontend/` — Vue (переехал из LeadRadar как есть)
|
||||
|
||||
Подробности: `docs/architecture/2026-09-05-deal-architecture-design.md`
|
||||
@@ -0,0 +1,589 @@
|
||||
using Deal.Ai.Llm;
|
||||
using Deal.Grpc.Ai;
|
||||
using Grpc.Core;
|
||||
using Grpc.Net.Client;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace Deal.Ai.Tests;
|
||||
|
||||
/// <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 конфига.
|
||||
/// </summary>
|
||||
public sealed class AiRpcTests
|
||||
{
|
||||
// Имя провайдера сценариев (для текста ошибки UNAVAILABLE).
|
||||
private const string ProviderId = "deepseek";
|
||||
|
||||
// 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) не ответил корректно — повторите попытку через несколько секунд";
|
||||
|
||||
/// <summary>
|
||||
/// Filter: модель пропускает сообщение — pass=true с причиной и usage API-ответа.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Filter_ModelPasses_ReturnsPassReasonAndUsage()
|
||||
{
|
||||
FakeProviderClient fake = Success("""{"pass": true, "reason": "похоже на заявку"}""");
|
||||
await AiTestHost.RunAsync(Host(fake), async channel =>
|
||||
{
|
||||
var client = new AiService.AiServiceClient(channel);
|
||||
|
||||
FilterReply reply = await client.FilterAsync(
|
||||
new FilterRequest
|
||||
{
|
||||
Prompt = "Фильтр: {domain}",
|
||||
Text = "Ищем разработчика на проект",
|
||||
ProviderConfig = RequestConfig(),
|
||||
},
|
||||
Options());
|
||||
|
||||
Assert.True(reply.Pass);
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Filter: модель отклоняет сообщение — pass=false с причиной отказа.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Filter_ModelRejects_ReturnsPassFalse()
|
||||
{
|
||||
FakeProviderClient fake = Success("""{"pass": false, "reason": "реклама канала"}""");
|
||||
await AiTestHost.RunAsync(Host(fake), async channel =>
|
||||
{
|
||||
var client = new AiService.AiServiceClient(channel);
|
||||
|
||||
FilterReply reply = await client.FilterAsync(
|
||||
new FilterRequest { Prompt = "Фильтр", Text = "Подпишись на канал", ProviderConfig = RequestConfig() },
|
||||
Options());
|
||||
|
||||
Assert.False(reply.Pass);
|
||||
Assert.Equal("реклама канала", reply.Reason);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Filter: модель не вернула pass — по умолчанию пропуск (1:1 ai.py L195: bool(get(pass, true))).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Filter_MissingPassField_DefaultsToPass()
|
||||
{
|
||||
FakeProviderClient fake = Success("""{"note": "ничего не понял"}""");
|
||||
await AiTestHost.RunAsync(Host(fake), async channel =>
|
||||
{
|
||||
var client = new AiService.AiServiceClient(channel);
|
||||
|
||||
FilterReply reply = await client.FilterAsync(
|
||||
new FilterRequest { Prompt = "Фильтр", Text = "Текст", ProviderConfig = RequestConfig() },
|
||||
Options());
|
||||
|
||||
Assert.True(reply.Pass);
|
||||
Assert.False(reply.HasReason);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Filter: провайдер недоступен после ретраев (3 попытки) → UNAVAILABLE с текстом 1:1 Ruling 5
|
||||
/// (ядро трактует как «ИИ недоступен» и пропускает сообщение локальным путём).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Filter_ProviderUnavailable_ThrowsUnavailableWithDetail()
|
||||
{
|
||||
FakeProviderClient fake = Failing();
|
||||
await AiTestHost.RunAsync(Host(fake), async channel =>
|
||||
{
|
||||
var client = new AiService.AiServiceClient(channel);
|
||||
|
||||
RpcException exception = await Assert.ThrowsAsync<RpcException>(() =>
|
||||
client.FilterAsync(
|
||||
new FilterRequest { Prompt = "Фильтр", Text = "Текст", ProviderConfig = RequestConfig() },
|
||||
Options()).ResponseAsync);
|
||||
|
||||
Assert.Equal(StatusCode.Unavailable, exception.StatusCode);
|
||||
Assert.Equal(UnavailableDetail, exception.Status.Detail);
|
||||
Assert.Equal(LlmRetryPolicy.AttemptCount, fake.Calls.Count);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Filter: ответ модели без JSON после ретраев — тоже UNAVAILABLE (у метода нет ok-поля).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Filter_AnswerWithoutJson_ThrowsUnavailable()
|
||||
{
|
||||
FakeProviderClient fake = new((_, _, _) =>
|
||||
Task.FromResult(new ProviderChatResult("какой-то текст без json", Usage: null)));
|
||||
await AiTestHost.RunAsync(Host(fake), async channel =>
|
||||
{
|
||||
var client = new AiService.AiServiceClient(channel);
|
||||
|
||||
RpcException exception = await Assert.ThrowsAsync<RpcException>(() =>
|
||||
client.FilterAsync(
|
||||
new FilterRequest { Prompt = "Фильтр", Text = "Текст", ProviderConfig = RequestConfig() },
|
||||
Options()).ResponseAsync);
|
||||
|
||||
Assert.Equal(StatusCode.Unavailable, exception.StatusCode);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Classify: модель вернула JSON — ok=true, json = извлечённый ответ строкой (маппинг в ядре),
|
||||
/// usage пробрасывается.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Classify_ModelAnsweredJson_ReturnsOkAndJson()
|
||||
{
|
||||
const string modelJson =
|
||||
"""{"board": "dev", "title": "Нужен python-разработчик", "budget": null, "contacts": []}""";
|
||||
FakeProviderClient fake = Success(modelJson);
|
||||
await AiTestHost.RunAsync(Host(fake), async channel =>
|
||||
{
|
||||
var client = new AiService.AiServiceClient(channel);
|
||||
|
||||
ClassifyReply reply = await client.ClassifyAsync(
|
||||
new ClassifyRequest
|
||||
{
|
||||
SystemPrompt = "Система карточки",
|
||||
UserContext = "Доски:\n- dev: разработка\nНовое сообщение: ищем middle",
|
||||
ProviderConfig = RequestConfig(),
|
||||
},
|
||||
Options());
|
||||
|
||||
Assert.True(reply.Ok);
|
||||
Assert.True(reply.HasJson);
|
||||
AssertUsage(reply.Usage, SampleUsage);
|
||||
|
||||
JsonObject json = JsonNode.Parse(reply.Json)!.AsObject();
|
||||
Assert.Equal("dev", (string?)json["board"]);
|
||||
Assert.Equal("Нужен python-разработчик", (string?)json["title"]);
|
||||
|
||||
FakeProviderCall call = Assert.Single(fake.Calls);
|
||||
Assert.Equal("Система карточки", call.SystemPrompt);
|
||||
Assert.Equal("Доски:\n- dev: разработка\nНовое сообщение: ищем middle", call.UserText);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Classify: модель отвечала, но без разбираемого JSON после ретраев → ok=false, НЕ RPC-ошибка
|
||||
/// (README ai.proto L201–204); usage последней попытки в ответе (оценка по символам).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Classify_AnswerWithoutJson_ReturnsOkFalseWithUsage()
|
||||
{
|
||||
const string systemPrompt = "Система карточки";
|
||||
const string userContext = "Доски и сообщение для разбора";
|
||||
const string garbage = "простите, я не умею в json";
|
||||
FakeProviderClient fake = new((_, _, _) =>
|
||||
Task.FromResult(new ProviderChatResult(garbage, Usage: null)));
|
||||
await AiTestHost.RunAsync(Host(fake), async channel =>
|
||||
{
|
||||
var client = new AiService.AiServiceClient(channel);
|
||||
|
||||
ClassifyReply reply = await client.ClassifyAsync(
|
||||
new ClassifyRequest
|
||||
{
|
||||
SystemPrompt = systemPrompt,
|
||||
UserContext = userContext,
|
||||
ProviderConfig = RequestConfig(),
|
||||
},
|
||||
Options());
|
||||
|
||||
Assert.False(reply.Ok);
|
||||
Assert.False(reply.HasJson);
|
||||
Assert.Equal(EstimateTokens(systemPrompt.Length + userContext.Length), reply.Usage.Prompt);
|
||||
Assert.Equal(EstimateTokens(garbage.Length), reply.Usage.Completion);
|
||||
Assert.Equal(LlmRetryPolicy.AttemptCount, fake.Calls.Count);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Classify: провайдер недоступен — UNAVAILABLE (ядро падает в локальный разбор, aiFail).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Classify_ProviderUnavailable_ThrowsUnavailable()
|
||||
{
|
||||
FakeProviderClient fake = Failing();
|
||||
await AiTestHost.RunAsync(Host(fake), async channel =>
|
||||
{
|
||||
var client = new AiService.AiServiceClient(channel);
|
||||
|
||||
RpcException exception = await Assert.ThrowsAsync<RpcException>(() =>
|
||||
client.ClassifyAsync(
|
||||
new ClassifyRequest { SystemPrompt = "S", UserContext = "U", ProviderConfig = RequestConfig() },
|
||||
Options()).ResponseAsync);
|
||||
|
||||
Assert.Equal(StatusCode.Unavailable, exception.StatusCode);
|
||||
Assert.Equal(UnavailableDetail, exception.Status.Detail);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// GenerateKeywords: ключи из JSON-ответа + фиксированный промпт с описанием задачи.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GenerateKeywords_ModelReturnedKeywords_ReturnsList()
|
||||
{
|
||||
FakeProviderClient fake = Success("""{"keywords": ["стройка", "ремонт квартир", "подряды"]}""");
|
||||
await AiTestHost.RunAsync(Host(fake), async channel =>
|
||||
{
|
||||
var client = new AiService.AiServiceClient(channel);
|
||||
|
||||
GenerateKeywordsReply reply = await client.GenerateKeywordsAsync(
|
||||
new GenerateKeywordsRequest
|
||||
{
|
||||
Description = "Поиск каналов со стройкой и ремонтом",
|
||||
ProviderConfig = RequestConfig(),
|
||||
},
|
||||
Options());
|
||||
|
||||
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);
|
||||
Assert.Equal("Описание ниши/задачи:\nПоиск каналов со стройкой и ремонтом", call.UserText);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// GenerateKeywords: не-строковые элементы списка пропускаются (чистку делает ядро).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GenerateKeywords_NonStringItems_Skipped()
|
||||
{
|
||||
FakeProviderClient fake = Success("""{"keywords": ["php", 42, "c#"]}""");
|
||||
await AiTestHost.RunAsync(Host(fake), async channel =>
|
||||
{
|
||||
var client = new AiService.AiServiceClient(channel);
|
||||
|
||||
GenerateKeywordsReply reply = await client.GenerateKeywordsAsync(
|
||||
new GenerateKeywordsRequest { Description = "Разработка", ProviderConfig = RequestConfig() },
|
||||
Options());
|
||||
|
||||
Assert.Equal(["php", "c#"], reply.Keywords);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// GenerateKeywords: модель не вернула ключи — пустой список (не ошибка).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GenerateKeywords_NoKeywordsField_ReturnsEmpty()
|
||||
{
|
||||
FakeProviderClient fake = Success("""{"note": "не нашёл ключей"}""");
|
||||
await AiTestHost.RunAsync(Host(fake), async channel =>
|
||||
{
|
||||
var client = new AiService.AiServiceClient(channel);
|
||||
|
||||
GenerateKeywordsReply reply = await client.GenerateKeywordsAsync(
|
||||
new GenerateKeywordsRequest { Description = "Разработка", ProviderConfig = RequestConfig() },
|
||||
Options());
|
||||
|
||||
Assert.Empty(reply.Keywords);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EvaluateFit: fit=1 + причина; промпт собран сервисом из описания и ключей (discovery_eval
|
||||
/// L50–54), сообщение — как «Сообщение:\n…».
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task EvaluateFit_ModelFits_ReturnsFitAndReason()
|
||||
{
|
||||
FakeProviderClient fake = Success("""{"fit": 1, "reason": "строительная тематика"}""");
|
||||
await AiTestHost.RunAsync(Host(fake), async channel =>
|
||||
{
|
||||
var client = new AiService.AiServiceClient(channel);
|
||||
|
||||
EvaluateFitReply reply = await client.EvaluateFitAsync(
|
||||
new EvaluateFitRequest
|
||||
{
|
||||
Text = "Нужна бригада отделочников на объект",
|
||||
Description = "Поиск подрядчиков на отделку",
|
||||
ProviderConfig = RequestConfig(),
|
||||
},
|
||||
Options());
|
||||
|
||||
Assert.True(reply.Fit);
|
||||
Assert.Equal("строительная тематика", reply.Reason);
|
||||
AssertUsage(reply.Usage, SampleUsage);
|
||||
|
||||
FakeProviderCall call = Assert.Single(fake.Calls);
|
||||
Assert.Equal("Сообщение:\nНужна бригада отделочников на объект", call.UserText);
|
||||
Assert.Contains("Оцени, относится ли сообщение к сфере/задаче", call.SystemPrompt, StringComparison.Ordinal);
|
||||
Assert.Contains("Описание: Поиск подрядчиков на отделку.", call.SystemPrompt, StringComparison.Ordinal);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EvaluateFit: ключи задачи подставляются в промпт строкой через запятую (1:1 _ai_prompt).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task EvaluateFit_KeywordsJoinedIntoPrompt()
|
||||
{
|
||||
FakeProviderClient fake = Success("""{"fit": 0, "reason": "не совпадает"}""");
|
||||
await AiTestHost.RunAsync(Host(fake), async channel =>
|
||||
{
|
||||
var client = new AiService.AiServiceClient(channel);
|
||||
|
||||
await client.EvaluateFitAsync(
|
||||
new EvaluateFitRequest
|
||||
{
|
||||
Text = "Текст",
|
||||
Description = "Описание",
|
||||
Keywords = { "стройка", " ремонт ", " ", "подряд" },
|
||||
ProviderConfig = RequestConfig(),
|
||||
},
|
||||
Options());
|
||||
|
||||
string systemPrompt = Assert.Single(fake.Calls).SystemPrompt;
|
||||
Assert.Contains("Ключи: стройка, ремонт, подряд.", systemPrompt, StringComparison.Ordinal);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EvaluateFit: fit=0 без причины модели — причина по умолчанию «не подходит» (1:1 _ai_reason
|
||||
/// discovery_eval L167–171); строковое «нет» трактуется как ложь (1:1 _ai_fit L158–164).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task EvaluateFit_ModelNoFit_ReturnsDefaultReason()
|
||||
{
|
||||
FakeProviderClient fake = Success("""{"fit": 0}""");
|
||||
await AiTestHost.RunAsync(Host(fake), async channel =>
|
||||
{
|
||||
var client = new AiService.AiServiceClient(channel);
|
||||
|
||||
EvaluateFitReply reply = await client.EvaluateFitAsync(
|
||||
new EvaluateFitRequest { Text = "Текст", Description = "Описание", ProviderConfig = RequestConfig() },
|
||||
Options());
|
||||
|
||||
Assert.False(reply.Fit);
|
||||
Assert.Equal("не подходит", reply.Reason);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EvaluateFit: fit строкой «нет» — false (паритет _ai_fit), причина из модели.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task EvaluateFit_StringFalsyFit_ReturnsFalse()
|
||||
{
|
||||
FakeProviderClient fake = Success("""{"fit": "нет", "reason": "другая сфера"}""");
|
||||
await AiTestHost.RunAsync(Host(fake), async channel =>
|
||||
{
|
||||
var client = new AiService.AiServiceClient(channel);
|
||||
|
||||
EvaluateFitReply reply = await client.EvaluateFitAsync(
|
||||
new EvaluateFitRequest { Text = "Текст", Description = "Описание", ProviderConfig = RequestConfig() },
|
||||
Options());
|
||||
|
||||
Assert.False(reply.Fit);
|
||||
Assert.Equal("другая сфера", reply.Reason);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EvaluateFit: длинная причина модели усекается до 200 символов (1:1 _AI_REASON_LIMIT).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task EvaluateFit_LongReason_IsTruncatedTo200()
|
||||
{
|
||||
string longReason = new('а', 250);
|
||||
FakeProviderClient fake = Success($$"""{"fit": 1, "reason": "{{longReason}}"}""");
|
||||
await AiTestHost.RunAsync(Host(fake), async channel =>
|
||||
{
|
||||
var client = new AiService.AiServiceClient(channel);
|
||||
|
||||
EvaluateFitReply reply = await client.EvaluateFitAsync(
|
||||
new EvaluateFitRequest { Text = "Текст", Description = "Описание", ProviderConfig = RequestConfig() },
|
||||
Options());
|
||||
|
||||
Assert.Equal(200, reply.Reason.Length);
|
||||
Assert.StartsWith("ааааа", reply.Reason, StringComparison.Ordinal);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Конфиг-валидация: запрос без конфига провайдера (пустой base_url) → INVALID_ARGUMENT.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Classify_WithoutProviderConfig_IsInvalidArgument()
|
||||
{
|
||||
FakeProviderClient fake = Success("{}");
|
||||
await AiTestHost.RunAsync(Host(fake), async channel =>
|
||||
{
|
||||
var client = new AiService.AiServiceClient(channel);
|
||||
|
||||
RpcException exception = await Assert.ThrowsAsync<RpcException>(() =>
|
||||
client.ClassifyAsync(
|
||||
new ClassifyRequest { SystemPrompt = "S", UserContext = "U" },
|
||||
Options()).ResponseAsync);
|
||||
|
||||
Assert.Equal(StatusCode.InvalidArgument, exception.StatusCode);
|
||||
Assert.Empty(fake.Calls);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Конфиг-валидация: пустая model конфига → INVALID_ARGUMENT (вызов модели невозможен).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GenerateKeywords_EmptyModel_IsInvalidArgument()
|
||||
{
|
||||
FakeProviderClient fake = Success("{}");
|
||||
await AiTestHost.RunAsync(Host(fake), async channel =>
|
||||
{
|
||||
var client = new AiService.AiServiceClient(channel);
|
||||
|
||||
RpcException exception = await Assert.ThrowsAsync<RpcException>(() =>
|
||||
client.GenerateKeywordsAsync(
|
||||
new GenerateKeywordsRequest
|
||||
{
|
||||
Description = "Описание",
|
||||
ProviderConfig = new ProviderConfig { BaseUrl = "https://api.example.com/v1" },
|
||||
},
|
||||
Options()).ResponseAsync);
|
||||
|
||||
Assert.Equal(StatusCode.InvalidArgument, exception.StatusCode);
|
||||
Assert.Empty(fake.Calls);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Серверный лимит text (ai.proto Filter.text: core обрезает до 4000): превышение → INVALID_ARGUMENT.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Filter_TooLongText_IsInvalidArgument()
|
||||
{
|
||||
FakeProviderClient fake = Success("""{"pass": true}""");
|
||||
await AiTestHost.RunAsync(Host(fake), async channel =>
|
||||
{
|
||||
var client = new AiService.AiServiceClient(channel);
|
||||
|
||||
RpcException exception = await Assert.ThrowsAsync<RpcException>(() =>
|
||||
client.FilterAsync(
|
||||
new FilterRequest
|
||||
{
|
||||
Prompt = "Фильтр",
|
||||
Text = new string('а', 4001),
|
||||
ProviderConfig = RequestConfig(),
|
||||
},
|
||||
Options()).ResponseAsync);
|
||||
|
||||
Assert.Equal(StatusCode.InvalidArgument, exception.StatusCode);
|
||||
Assert.Empty(fake.Calls);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Серверный лимит description (ai.proto GenerateKeywords.description: core обрезает до 4000).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GenerateKeywords_TooLongDescription_IsInvalidArgument()
|
||||
{
|
||||
FakeProviderClient fake = Success("{}");
|
||||
await AiTestHost.RunAsync(Host(fake), async channel =>
|
||||
{
|
||||
var client = new AiService.AiServiceClient(channel);
|
||||
|
||||
RpcException exception = await Assert.ThrowsAsync<RpcException>(() =>
|
||||
client.GenerateKeywordsAsync(
|
||||
new GenerateKeywordsRequest
|
||||
{
|
||||
Description = new string('а', 4001),
|
||||
ProviderConfig = RequestConfig(),
|
||||
},
|
||||
Options()).ResponseAsync);
|
||||
|
||||
Assert.Equal(StatusCode.InvalidArgument, exception.StatusCode);
|
||||
Assert.Empty(fake.Calls);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Обязательный tenant-id в metadata (Ruling 1): отсутствует → UNAUTHENTICATED до вызова.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Classify_WithoutTenantId_IsUnauthenticated()
|
||||
{
|
||||
FakeProviderClient fake = Success("{}");
|
||||
await AiTestHost.RunAsync(Host(fake), async channel =>
|
||||
{
|
||||
var client = new AiService.AiServiceClient(channel);
|
||||
Metadata metadata = AiTestHost.CallMetadata(AiTestHost.DefaultToken, tenantId: null);
|
||||
|
||||
RpcException exception = await Assert.ThrowsAsync<RpcException>(() =>
|
||||
client.ClassifyAsync(
|
||||
new ClassifyRequest { SystemPrompt = "S", UserContext = "U", ProviderConfig = RequestConfig() },
|
||||
AiTestHost.CallOptions(metadata)).ResponseAsync);
|
||||
|
||||
Assert.Equal(StatusCode.Unauthenticated, exception.StatusCode);
|
||||
Assert.Empty(fake.Calls);
|
||||
});
|
||||
}
|
||||
|
||||
// Конфиг DI сценария: фейк-провайдер + мгновенные паузы ретраев.
|
||||
// fake: Фейк-провайдер вызовов.
|
||||
private static Action<IServiceCollection> Host(FakeProviderClient fake)
|
||||
=> services =>
|
||||
{
|
||||
AiTestHost.UseFakeProvider(services, fake);
|
||||
AiTestHost.DisableRetryDelays(services);
|
||||
};
|
||||
|
||||
// CallOptions с service-token и tenant-id сценария.
|
||||
private static CallOptions Options()
|
||||
=> AiTestHost.CallOptions(
|
||||
AiTestHost.CallMetadata(AiTestHost.DefaultToken, AiTestHost.DefaultTenantId));
|
||||
|
||||
// Конфиг провайдера запроса (зеркало того, что ядро кладёт в тело запроса).
|
||||
private static ProviderConfig RequestConfig()
|
||||
=> new()
|
||||
{
|
||||
ProviderId = ProviderId,
|
||||
BaseUrl = "https://api.example.com/v1",
|
||||
Model = "deepseek-v4-flash",
|
||||
ApiKey = "test-key",
|
||||
};
|
||||
|
||||
// Фейк, всегда отвечающий заданным JSON-текстом с usage API-ответа.
|
||||
// jsonText: JSON-текст ответа модели.
|
||||
private static FakeProviderClient Success(string jsonText)
|
||||
=> new((_, _, _) => Task.FromResult(new ProviderChatResult(jsonText, SampleUsage)));
|
||||
|
||||
// Фейк, всегда падающий транспортной ошибкой (повод для ретрая).
|
||||
private static FakeProviderClient Failing()
|
||||
=> new((_, _, _) => Task.FromException<ProviderChatResult>(new LlmHttpException("сеть недоступна")));
|
||||
|
||||
// Оценка токенов по символам (зеркало TokenEstimator; для проверки usage в ответе).
|
||||
// charCount: Число символов.
|
||||
private static uint EstimateTokens(int charCount)
|
||||
=> (uint)((charCount + 3) / 4);
|
||||
|
||||
// Проверяет проброс usage провайдера в gRPC-ответ.
|
||||
// usage: Usage ответа RPC.
|
||||
// expected: Ожидаемое значение.
|
||||
private static void AssertUsage(Deal.Grpc.Ai.Usage usage, ProviderUsage expected)
|
||||
{
|
||||
Assert.Equal((uint)expected.PromptTokens, usage.Prompt);
|
||||
Assert.Equal((uint)expected.CompletionTokens, usage.Completion);
|
||||
Assert.Equal((uint)expected.TotalTokens, usage.Total);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
using Deal.Grpc.Ai;
|
||||
using Deal.Ai;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Grpc.Core;
|
||||
using Grpc.Health.V1;
|
||||
using Grpc.Net.Client;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using Xunit;
|
||||
|
||||
namespace Deal.Ai.Tests;
|
||||
|
||||
/// <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 исполняет методы класса последовательно).
|
||||
/// </summary>
|
||||
public sealed class AiServiceHostTests
|
||||
{
|
||||
// Env-ключ ожидаемого токена (зеркало ServiceTokenInterceptor).
|
||||
private const string ServiceTokenEnvKey = "DEAL_SERVICE_TOKEN";
|
||||
|
||||
// Ключ gRPC-metadata (зеркало ServiceTokenInterceptor.ServiceTokenMetadataKey).
|
||||
private const string ServiceTokenMetadataKey = "service-token";
|
||||
|
||||
// Токен сценариев теста.
|
||||
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).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task HealthCheck_ReturnsServing()
|
||||
{
|
||||
await RunHostScenarioAsync(
|
||||
ValidToken,
|
||||
async channel =>
|
||||
{
|
||||
var health = new Health.HealthClient(channel);
|
||||
HealthCheckResponse response = await health.CheckAsync(
|
||||
new HealthCheckRequest(),
|
||||
deadline: DateTime.UtcNow.AddSeconds(RpcDeadlineSeconds));
|
||||
|
||||
Assert.Equal(HealthCheckResponse.Types.ServingStatus.Serving, response.Status);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Запрос без metadata «service-token» → UNAUTHENTICATED (Ruling 1).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Classify_WithoutToken_IsUnauthenticated()
|
||||
{
|
||||
await AssertDealRpcRejectedAsync(
|
||||
ValidToken,
|
||||
tokenHeader: null,
|
||||
expected: StatusCode.Unauthenticated);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Запрос с неверным токеном → UNAUTHENTICATED (Ruling 1).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Classify_WithWrongToken_IsUnauthenticated()
|
||||
{
|
||||
await AssertDealRpcRejectedAsync(
|
||||
ValidToken,
|
||||
tokenHeader: "wrong-token",
|
||||
expected: StatusCode.Unauthenticated);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Верный токен проходит интерцептор к методу — кодогенерация и маппинг сервиса работают:
|
||||
/// пустой запрос (без конфига провайдера) доходит до реализации Classify (Task 8) и отклоняется
|
||||
/// валидацией INVALID_ARGUMENT, а не UNIMPLEMENTED.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Classify_WithValidToken_ReachesServiceAndValidatesConfig()
|
||||
{
|
||||
await AssertDealRpcRejectedAsync(
|
||||
ValidToken,
|
||||
tokenHeader: ValidToken,
|
||||
expected: StatusCode.InvalidArgument);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fail-closed (шаблон Task 3): DEAL_SERVICE_TOKEN не задан — Deal-RPC отклоняется всегда,
|
||||
/// в т.ч. запрос с «пустым» значением metadata (без гарда сравнение «» == «» пропустило бы его);
|
||||
/// health при этом продолжает отвечать SERVING (инфраструктурный liveness не ломается).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task WithoutConfiguredToken_DealRpcFailsClosed_HealthStillServing()
|
||||
{
|
||||
await RunHostScenarioAsync(
|
||||
serviceToken: null,
|
||||
async channel =>
|
||||
{
|
||||
var health = new Health.HealthClient(channel);
|
||||
HealthCheckResponse healthResponse = await health.CheckAsync(
|
||||
new HealthCheckRequest(),
|
||||
deadline: DateTime.UtcNow.AddSeconds(RpcDeadlineSeconds));
|
||||
Assert.Equal(HealthCheckResponse.Types.ServingStatus.Serving, healthResponse.Status);
|
||||
|
||||
// Пустое значение metadata — попытка обойти fail-closed (равно незаданному env-токену).
|
||||
await AssertRejectedAsync(channel, string.Empty, StatusCode.Unauthenticated);
|
||||
await AssertRejectedAsync(channel, ValidToken, StatusCode.Unauthenticated);
|
||||
});
|
||||
}
|
||||
|
||||
// Прогоняет Classify с заданным заголовком «service-token» (null — без заголовка) и проверяет
|
||||
// ожидаемый код статуса RPC-исключения.
|
||||
// serviceToken: Ожидаемый токен хоста (env DEAL_SERVICE_TOKEN).
|
||||
// tokenHeader: Значение metadata «service-token» запроса либо null (нет заголовка).
|
||||
// expected: Ожидаемый StatusCode ответа.
|
||||
private static async Task AssertDealRpcRejectedAsync(string? serviceToken, string? tokenHeader, StatusCode expected)
|
||||
{
|
||||
await RunHostScenarioAsync(
|
||||
serviceToken,
|
||||
channel => AssertRejectedAsync(channel, tokenHeader, expected));
|
||||
}
|
||||
|
||||
// Вызывает Classify и проверяет, что сервер ответил ожидаемым кодом статуса.
|
||||
// Запрос несёт полный metadata (service-token + tenant-id), чтобы верный токен доходил
|
||||
// до реализации метода (заглушки больше нет — Task 8), а не падал на tenant-проверке.
|
||||
// channel: Канал к хосту ai-service.
|
||||
// tokenHeader: Значение metadata «service-token» либо null (нет заголовка).
|
||||
// expected: Ожидаемый StatusCode.
|
||||
private static async Task AssertRejectedAsync(GrpcChannel channel, string? tokenHeader, StatusCode expected)
|
||||
{
|
||||
var client = new AiService.AiServiceClient(channel);
|
||||
Metadata metadata = new() { { "tenant-id", TenantId } };
|
||||
if (tokenHeader is not null)
|
||||
{
|
||||
metadata.Add(ServiceTokenMetadataKey, tokenHeader);
|
||||
}
|
||||
|
||||
var callOptions = new CallOptions(
|
||||
metadata,
|
||||
deadline: DateTime.UtcNow.AddSeconds(RpcDeadlineSeconds));
|
||||
|
||||
AsyncUnaryCall<ClassifyReply> call = client.ClassifyAsync(new ClassifyRequest(), callOptions);
|
||||
RpcException exception = await Assert.ThrowsAsync<RpcException>(() => call.ResponseAsync);
|
||||
|
||||
Assert.Equal(expected, exception.StatusCode);
|
||||
}
|
||||
|
||||
// Поднимает хост на эфемерном порту с заданным env-токеном, выполняет сценарий и гарантированно
|
||||
// гасит хост/канал и восстанавливает исходный env.
|
||||
// serviceToken: Значение env DEAL_SERVICE_TOKEN для сценария (null — убрать).
|
||||
// scenario: Сценарий с каналом к поднятому хосту.
|
||||
private static async Task RunHostScenarioAsync(string? serviceToken, Func<GrpcChannel, Task> scenario)
|
||||
{
|
||||
string? originalToken = Environment.GetEnvironmentVariable(ServiceTokenEnvKey);
|
||||
Environment.SetEnvironmentVariable(ServiceTokenEnvKey, serviceToken);
|
||||
|
||||
WebApplication? app = null;
|
||||
GrpcChannel? channel = null;
|
||||
try
|
||||
{
|
||||
int port = FreeTcpPort();
|
||||
app = AiServiceHost.Create(port);
|
||||
await app.StartAsync();
|
||||
|
||||
channel = GrpcChannel.ForAddress($"http://127.0.0.1:{port}");
|
||||
await scenario(channel);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (channel is not null)
|
||||
{
|
||||
channel.Dispose();
|
||||
}
|
||||
|
||||
if (app is not null)
|
||||
{
|
||||
await app.StopAsync();
|
||||
await app.DisposeAsync();
|
||||
}
|
||||
|
||||
Environment.SetEnvironmentVariable(ServiceTokenEnvKey, originalToken);
|
||||
}
|
||||
}
|
||||
|
||||
// Возвращает свободный TCP-порт (127.0.0.1:0 → освобождение перед биндом хоста).
|
||||
private static int FreeTcpPort()
|
||||
{
|
||||
using var listener = new TcpListener(IPAddress.Loopback, 0);
|
||||
listener.Start();
|
||||
return ((IPEndPoint)listener.LocalEndpoint).Port;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
using Deal.Ai;
|
||||
using Deal.Ai.Llm;
|
||||
using Deal.Grpc.Hosting;
|
||||
using Grpc.Core;
|
||||
using Grpc.Net.Client;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
|
||||
namespace Deal.Ai.Tests;
|
||||
|
||||
// Общий харнесс in-proc gRPC-тестов ai-service (план Task 8): поднимает хост (AiServiceHost.Create)
|
||||
// в процессе теста на эфемерном порту и через configureServices-хук подменяет LLM-фасад фейком
|
||||
// (FakeProviderClient, без сети) и функцию паузы ретраев (мгновенная) — сценарии не
|
||||
// ждут 0.8/2 с между попытками. Регистрация, добавленная харнессом после дефолтных, побеждает
|
||||
// (DI резолвит последнюю). Исходное значение env DEAL_SERVICE_TOKEN восстанавливается.
|
||||
internal static class AiTestHost
|
||||
{
|
||||
/// <summary>
|
||||
/// Env-ключ ожидаемого service-token (зеркало ServiceTokenInterceptor).
|
||||
/// </summary>
|
||||
public const string ServiceTokenEnvKey = "DEAL_SERVICE_TOKEN";
|
||||
|
||||
/// <summary>
|
||||
/// Ключ gRPC-metadata с service-token (зеркало ServiceTokenInterceptor).
|
||||
/// </summary>
|
||||
public const string ServiceTokenMetadataKey = ServiceTokenInterceptor.ServiceTokenMetadataKey;
|
||||
|
||||
/// <summary>
|
||||
/// Ключ gRPC-metadata с tenant-id (зеркало AiServiceImpl).
|
||||
/// </summary>
|
||||
public const string TenantIdMetadataKey = AiServiceImpl.TenantIdMetadataKey;
|
||||
|
||||
/// <summary>
|
||||
/// Токен сценариев теста.
|
||||
/// </summary>
|
||||
public const string DefaultToken = "deal-ai-test-token";
|
||||
|
||||
/// <summary>
|
||||
/// Id тенанта сценариев по умолчанию.
|
||||
/// </summary>
|
||||
public const string DefaultTenantId = "tenant-test";
|
||||
|
||||
/// <summary>
|
||||
/// Deadline RPC-вызовов теста (сек).
|
||||
/// </summary>
|
||||
public const int RpcDeadlineSeconds = 15;
|
||||
|
||||
/// <summary>
|
||||
/// Прогоняет сценарий против хоста со стандартным токеном и фейк-подменой фасада.
|
||||
/// </summary>
|
||||
/// <param name="configureServices">Настройка DI сценария (фейк-провайдер, мгновенные ретраи).</param>
|
||||
/// <param name="scenario">Сценарий с gRPC-каналом к хосту.</param>
|
||||
public static Task RunAsync(
|
||||
Action<IServiceCollection> configureServices,
|
||||
Func<GrpcChannel, Task> scenario)
|
||||
=> RunAsync(DefaultToken, configureServices, scenario);
|
||||
|
||||
/// <summary>
|
||||
/// Прогоняет сценарий против поднятого хоста с заданным env-токеном и подменами DI.
|
||||
/// </summary>
|
||||
/// <param name="serviceToken">Значение env DEAL_SERVICE_TOKEN (null — убрать переменную).</param>
|
||||
/// <param name="configureServices">Настройка DI сценария (может быть пустой).</param>
|
||||
/// <param name="scenario">Сценарий с gRPC-каналом к хосту.</param>
|
||||
public static async Task RunAsync(
|
||||
string? serviceToken,
|
||||
Action<IServiceCollection> configureServices,
|
||||
Func<GrpcChannel, Task> scenario)
|
||||
{
|
||||
string? originalToken = Environment.GetEnvironmentVariable(ServiceTokenEnvKey);
|
||||
Environment.SetEnvironmentVariable(ServiceTokenEnvKey, serviceToken);
|
||||
|
||||
WebApplication? app = null;
|
||||
GrpcChannel? channel = null;
|
||||
try
|
||||
{
|
||||
int port = FreeTcpPort();
|
||||
app = AiServiceHost.Create(port, configureServices: configureServices);
|
||||
await app.StartAsync();
|
||||
|
||||
channel = GrpcChannel.ForAddress($"http://127.0.0.1:{port}");
|
||||
await scenario(channel);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (channel is not null)
|
||||
{
|
||||
channel.Dispose();
|
||||
}
|
||||
|
||||
if (app is not null)
|
||||
{
|
||||
await app.StopAsync();
|
||||
await app.DisposeAsync();
|
||||
}
|
||||
|
||||
Environment.SetEnvironmentVariable(ServiceTokenEnvKey, originalToken);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Подменяет HTTP-фасад вызовов модели фейком сценария (последняя регистрация побеждает).
|
||||
/// </summary>
|
||||
/// <param name="services">Коллекция сервисов хоста.</param>
|
||||
/// <param name="fake">Фейк-провайдер сценария.</param>
|
||||
public static void UseFakeProvider(IServiceCollection services, FakeProviderClient fake)
|
||||
=> 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, если задан).
|
||||
/// </summary>
|
||||
/// <param name="serviceToken">Значение заголовка service-token.</param>
|
||||
/// <param name="tenantId">Id тенанта (null — без заголовка tenant-id).</param>
|
||||
public static Metadata CallMetadata(string? serviceToken, string? tenantId = null)
|
||||
{
|
||||
var metadata = new Metadata();
|
||||
if (serviceToken is not null)
|
||||
{
|
||||
metadata.Add(ServiceTokenMetadataKey, serviceToken);
|
||||
}
|
||||
|
||||
if (tenantId is not null)
|
||||
{
|
||||
metadata.Add(TenantIdMetadataKey, tenantId);
|
||||
}
|
||||
|
||||
return metadata;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// CallOptions RPC: metadata + deadline (рекомендации README src/contracts L62–73).
|
||||
/// </summary>
|
||||
/// <param name="metadata">Metadata вызова.</param>
|
||||
public static CallOptions CallOptions(Metadata metadata)
|
||||
=> new(metadata, deadline: DateTime.UtcNow.AddSeconds(RpcDeadlineSeconds));
|
||||
|
||||
// Возвращает свободный TCP-порт (127.0.0.1:0 → освобождение перед биндом хоста).
|
||||
private static int FreeTcpPort()
|
||||
{
|
||||
using var listener = new TcpListener(IPAddress.Loopback, 0);
|
||||
listener.Start();
|
||||
return ((IPEndPoint)listener.LocalEndpoint).Port;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
using Xunit;
|
||||
|
||||
// In-proc gRPC-тесты ai-service поднимают реальные Kestrel-хосты и меняют процесс-глобальную
|
||||
// env-переменную DEAL_SERVICE_TOKEN на время сценария (AiTestHost и AiServiceHostTests).
|
||||
// Параллельный прогон классов дал бы гонки на env — тесты сериализованы (тот же шаблон, что
|
||||
// AssemblyInfo тестов ml/telegram-сервисов).
|
||||
[assembly: CollectionBehavior(DisableTestParallelization = true)]
|
||||
@@ -0,0 +1,40 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<!--
|
||||
Deal.Ai.Tests — интеграционные тесты каркаса ai-service (план Task 4, Acceptance):
|
||||
хост (Kestrel HTTP/2) поднимается в процессе теста на эфемерном порту через AiServiceHost.Create
|
||||
(та же сборка, что и Program.cs), проверяются gRPC-health → SERVING и ServiceTokenInterceptor
|
||||
(нет/неверный токен → UNAUTHENTICATED; пустой env DEAL_SERVICE_TOKEN → fail-closed). Стек тестов —
|
||||
как в Deal.Telegram.Tests (Task 2) и Deal.Ml.Tests (Task 3).
|
||||
-->
|
||||
<PropertyGroup>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Клиент gRPC в тестах (транспорт HTTP/2 + типы grpc.health.v1.Health). -->
|
||||
<PackageReference Include="Grpc.Net.Client" Version="2.83.0" />
|
||||
<PackageReference Include="Grpc.HealthCheck" Version="2.83.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- WebApplication/Kestrel-типы приходят из общего фреймворка ASP.NET Core (хосты тестов). -->
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Deal.Ai\Deal.Ai.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,42 @@
|
||||
using Deal.Ai.Llm;
|
||||
|
||||
namespace Deal.Ai.Tests;
|
||||
|
||||
// Зафиксированный вызов фейк-провайдера (для проверки собранных RPC промптов).
|
||||
// SystemPrompt: Системный промпт вызова.
|
||||
// UserText: Пользовательское сообщение вызова.
|
||||
// Config: Конфиг провайдера вызова.
|
||||
internal sealed record FakeProviderCall(string SystemPrompt, string UserText, LlmConfig Config);
|
||||
|
||||
// Фейк-провайдер LLM-вызовов (без сети; план Task 8): поведение задаётся сценарием (ответ текстом,
|
||||
// usage либо сбой), каждый вызов записывается в Calls — тесты проверяют и собранные
|
||||
// RPC-слоем промпты, и число попыток ретраев.
|
||||
internal sealed class FakeProviderClient : IProviderClient
|
||||
{
|
||||
private readonly Func<string, string, LlmConfig, Task<ProviderChatResult>> _handler;
|
||||
|
||||
/// <summary>
|
||||
/// Создаёт фейк с поведением сценария.
|
||||
/// </summary>
|
||||
/// <param name="handler">Поведение вызова: (systemPrompt, userText, config) → результат/сбой.</param>
|
||||
public FakeProviderClient(Func<string, string, LlmConfig, Task<ProviderChatResult>> handler)
|
||||
{
|
||||
_handler = handler;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Все вызовы фейка в порядке поступления (для проверок RPC-веток).
|
||||
/// </summary>
|
||||
public List<FakeProviderCall> Calls { get; } = [];
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<ProviderChatResult> ChatAsync(
|
||||
LlmConfig config,
|
||||
string systemPrompt,
|
||||
string userText,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Calls.Add(new FakeProviderCall(systemPrompt, userText, config));
|
||||
return _handler(systemPrompt, userText, config);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
using System.Text.Json.Nodes;
|
||||
using Deal.Ai.Llm;
|
||||
|
||||
namespace Deal.Ai.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit-тесты извлечения JSON из ответа модели (план Task 7; JsonExtractor, 1:1 extract_json
|
||||
/// ai.py L175–183): чистая строка JSON, markdown-обёртки, проза вокруг, отказы. Без сети.
|
||||
/// </summary>
|
||||
public sealed class JsonExtractorTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Чистая JSON-строка объекта разбирается как есть.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void TryExtractObject_PlainObject_Parses()
|
||||
{
|
||||
JsonObject? json = JsonExtractor.TryExtractObject("""{"pass": true, "reason": "ок"}""");
|
||||
|
||||
Assert.NotNull(json);
|
||||
Assert.True((bool)json!["pass"]!);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Markdown-обёртка ```json снимается (типичный ответ DeepSeek).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void TryExtractObject_JsonFence_Unwraps()
|
||||
{
|
||||
JsonObject? json = JsonExtractor.TryExtractObject("```json\n{\"fit\": 1}\n```");
|
||||
|
||||
Assert.NotNull(json);
|
||||
Assert.Equal(1, (int)json!["fit"]!);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Обёртка без метки языка и с прозой вокруг — тоже снимается.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void TryExtractObject_PlainFenceWithProse_Unwraps()
|
||||
{
|
||||
const string answer = "Вот результат:\n```\n{\"keywords\": [\"php\"]}\n```\nНадеюсь, поможет.";
|
||||
JsonObject? json = JsonExtractor.TryExtractObject(answer);
|
||||
|
||||
Assert.NotNull(json);
|
||||
Assert.Single((JsonArray)json!["keywords"]!);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// JSON внутри произвольного текста берётся срезом между первой «{» и последней «}».
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void TryExtractObject_JsonInsideProse_Slices()
|
||||
{
|
||||
JsonObject? json = JsonExtractor.TryExtractObject("модель решила: {\"pass\": false} конец");
|
||||
|
||||
Assert.NotNull(json);
|
||||
Assert.False((bool)json!["pass"]!);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Текст без JSON — null (попытка неудачна, фасад повторяет вызов).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void TryExtractObject_NoJson_ReturnsNull()
|
||||
{
|
||||
Assert.Null(JsonExtractor.TryExtractObject("извините, не могу ответить"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Незакрытая обёртка и битый JSON — null, а не исключение.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void TryExtractObject_BrokenJson_ReturnsNull()
|
||||
{
|
||||
Assert.Null(JsonExtractor.TryExtractObject("```json\n{\"pass\": tru"));
|
||||
Assert.Null(JsonExtractor.TryExtractObject("{\"pass\": "));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// JSON верхнего уровня не объект (массив/строка) — null (схемы ответов всегда объект).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void TryExtractObject_NonObjectRoot_ReturnsNull()
|
||||
{
|
||||
Assert.Null(JsonExtractor.TryExtractObject("[\"php\", \"c#\"]"));
|
||||
Assert.Null(JsonExtractor.TryExtractObject("\"просто строка\""));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Пустой/пробельный текст — null.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void TryExtractObject_Empty_ReturnsNull()
|
||||
{
|
||||
Assert.Null(JsonExtractor.TryExtractObject(string.Empty));
|
||||
Assert.Null(JsonExtractor.TryExtractObject(" "));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
using System.Net;
|
||||
using System.Text.Json.Nodes;
|
||||
using Deal.Ai.Llm;
|
||||
|
||||
namespace Deal.Ai.Tests;
|
||||
|
||||
/// <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-ошибка и таймаут.
|
||||
/// </summary>
|
||||
public sealed class LlmHttpClientTests
|
||||
{
|
||||
// Таймауты тестового клиента (короткие — чтобы таймаут-сценарий не ждал 90/60 с).
|
||||
private static readonly TimeSpan TestCallTimeout = TimeSpan.FromMilliseconds(60);
|
||||
|
||||
// Задержка заглушки для таймаут-сценария (должна превышать TestCallTimeout).
|
||||
private static readonly TimeSpan TestHandlerDelay = TimeSpan.FromMilliseconds(300);
|
||||
|
||||
// База OpenAI-совместимого провайдера сценария (как у openai/ollama/lmstudio — с /v1).
|
||||
private const string OpenAiBaseUrl = "https://api.example.com/v1";
|
||||
|
||||
// База Anthropic-провайдера сценария (без /v1 — путь добавляет клиент).
|
||||
private const string AnthropicBaseUrl = "https://api.anthropic.com";
|
||||
|
||||
// Ответ OpenAI-совместимого API с текстом и usage.
|
||||
private const string OpenAiJsonReply =
|
||||
"""
|
||||
{
|
||||
"choices": [
|
||||
{
|
||||
"message": { "role": "assistant", "content": "{\"fit\": 1}" }
|
||||
}
|
||||
],
|
||||
"usage": { "prompt_tokens": 10, "completion_tokens": 20, "total_tokens": 30 }
|
||||
}
|
||||
""";
|
||||
|
||||
// Ответ Anthropic Messages API: два text-блока и usage input/output.
|
||||
private const string AnthropicJsonReply =
|
||||
"""
|
||||
{
|
||||
"content": [
|
||||
{ "type": "text", "text": "{\"fit\": 1}" },
|
||||
{ "type": "text", "text": " ещё текст" }
|
||||
],
|
||||
"usage": { "input_tokens": 4, "output_tokens": 6 }
|
||||
}
|
||||
""";
|
||||
|
||||
/// <summary>
|
||||
/// OpenAI-совместимый вызов: URL, Bearer, форма тела (model/messages/temperature/max_tokens).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ChatAsync_OpenAiStyle_BuildsWireRequest()
|
||||
{
|
||||
var handler = new StubHttpMessageHandler(StubHttpMessageHandler.JsonOk(OpenAiJsonReply));
|
||||
LlmHttpClient client = CreateClient(handler);
|
||||
|
||||
ProviderChatResult result = await client.ChatAsync(
|
||||
OpenAiConfig(apiKey: "secret-key"),
|
||||
"Система",
|
||||
"Сообщение",
|
||||
CancellationToken.None);
|
||||
|
||||
CapturedHttpRequest request = handler.LastRequest;
|
||||
Assert.StartsWith("POST https://api.example.com/v1/chat/completions", request.Url);
|
||||
Assert.Equal("Bearer secret-key", StubHttpMessageHandler.BearerOf(request));
|
||||
|
||||
JsonObject body = StubHttpMessageHandler.BodyOf(request);
|
||||
Assert.Equal("deepseek-v4-flash", (string?)body["model"]);
|
||||
Assert.Equal(0.2, (double?)body["temperature"]);
|
||||
Assert.Equal(8000, (int?)body["max_tokens"]);
|
||||
|
||||
JsonArray messages = (JsonArray)body["messages"]!;
|
||||
Assert.Equal("system", (string?)messages[0]!["role"]);
|
||||
Assert.Equal("Система", (string?)messages[0]!["content"]);
|
||||
Assert.Equal("user", (string?)messages[1]!["role"]);
|
||||
Assert.Equal("Сообщение", (string?)messages[1]!["content"]);
|
||||
|
||||
Assert.Equal("{\"fit\": 1}", result.Text);
|
||||
Assert.Equal(new ProviderUsage(10, 20, 30), result.Usage);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Локальный OpenAI-совместимый провайдер без ключа: заголовок Authorization не шлётся.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ChatAsync_OpenAiStyleWithoutApiKey_SkipsAuthorization()
|
||||
{
|
||||
var handler = new StubHttpMessageHandler(StubHttpMessageHandler.JsonOk(OpenAiJsonReply));
|
||||
LlmHttpClient client = CreateClient(handler);
|
||||
|
||||
await client.ChatAsync(OpenAiConfig(apiKey: null), "Система", "Сообщение", CancellationToken.None);
|
||||
|
||||
Assert.Null(StubHttpMessageHandler.BearerOf(handler.LastRequest));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Модель вернула только reasoning без ответа — сбой попытки (ai.py L149–151).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ChatAsync_OpenAiReasoningOnly_Throws()
|
||||
{
|
||||
const string reasoningOnlyReply =
|
||||
"""{ "choices": [ { "message": { "role": "assistant", "reasoning_content": "хм, подумаю" } } ] }""";
|
||||
var handler = new StubHttpMessageHandler(StubHttpMessageHandler.JsonOk(reasoningOnlyReply));
|
||||
LlmHttpClient client = CreateClient(handler);
|
||||
|
||||
LlmHttpException exception = await Assert.ThrowsAsync<LlmHttpException>(() =>
|
||||
client.ChatAsync(OpenAiConfig(), "Система", "Сообщение", CancellationToken.None));
|
||||
|
||||
Assert.Contains("reasoning", exception.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Anthropic-вызов: URL /v1/messages, x-api-key + anthropic-version, форма тела Messages API.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ChatAsync_AnthropicStyle_BuildsWireRequest()
|
||||
{
|
||||
var handler = new StubHttpMessageHandler(StubHttpMessageHandler.JsonOk(AnthropicJsonReply));
|
||||
LlmHttpClient client = CreateClient(handler);
|
||||
|
||||
ProviderChatResult result = await client.ChatAsync(
|
||||
AnthropicConfig(),
|
||||
"Система",
|
||||
"Сообщение",
|
||||
CancellationToken.None);
|
||||
|
||||
CapturedHttpRequest request = handler.LastRequest;
|
||||
Assert.StartsWith("POST https://api.anthropic.com/v1/messages", request.Url);
|
||||
Assert.Equal("anthropic-key", request.Headers["x-api-key"]);
|
||||
Assert.Equal("2023-06-01", request.Headers["anthropic-version"]);
|
||||
|
||||
JsonObject body = StubHttpMessageHandler.BodyOf(request);
|
||||
Assert.Equal("claude-sonnet-5", (string?)body["model"]);
|
||||
Assert.Equal(8000, (int?)body["max_tokens"]);
|
||||
Assert.Equal("Система", (string?)body["system"]);
|
||||
Assert.DoesNotContain("temperature", body);
|
||||
|
||||
JsonArray messages = (JsonArray)body["messages"]!;
|
||||
Assert.Single(messages);
|
||||
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-ошибка провайдера — сбой попытки с кодом статуса (повод для ретрая).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ChatAsync_HttpError_Throws()
|
||||
{
|
||||
var handler = new StubHttpMessageHandler(StubHttpMessageHandler.Status(HttpStatusCode.InternalServerError));
|
||||
LlmHttpClient client = CreateClient(handler);
|
||||
|
||||
LlmHttpException exception = await Assert.ThrowsAsync<LlmHttpException>(() =>
|
||||
client.ChatAsync(OpenAiConfig(), "Система", "Сообщение", CancellationToken.None));
|
||||
|
||||
Assert.Contains("500", exception.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Неожиданная форма ответа (не JSON) — сбой попытки, а не падение.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ChatAsync_UnexpectedBody_Throws()
|
||||
{
|
||||
var handler = new StubHttpMessageHandler(_ => new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent("<html>upstream error</html>", System.Text.Encoding.UTF8, "text/html"),
|
||||
});
|
||||
LlmHttpClient client = CreateClient(handler);
|
||||
|
||||
await Assert.ThrowsAsync<LlmHttpException>(() =>
|
||||
client.ChatAsync(OpenAiConfig(), "Система", "Сообщение", CancellationToken.None));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Таймаут попытки (90/60 с в проде; в тесте — 60 мс) → LlmHttpException.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ChatAsync_Timeout_Throws()
|
||||
{
|
||||
var handler = new StubHttpMessageHandler(
|
||||
StubHttpMessageHandler.JsonOk(OpenAiJsonReply),
|
||||
delay: TestHandlerDelay);
|
||||
LlmHttpClient client = new(
|
||||
TestHttpClient(handler),
|
||||
TestCallTimeout,
|
||||
TestCallTimeout);
|
||||
|
||||
LlmHttpException exception = await Assert.ThrowsAsync<LlmHttpException>(() =>
|
||||
client.ChatAsync(OpenAiConfig(), "Система", "Сообщение", CancellationToken.None));
|
||||
|
||||
Assert.Contains("Таймаут", exception.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
// Конфиг OpenAI-совместимого вызова сценария.
|
||||
// apiKey: Ключ API (null — локальный провайдер без ключа).
|
||||
private static LlmConfig OpenAiConfig(string? apiKey = "test-key")
|
||||
=> new("deepseek", OpenAiBaseUrl, "deepseek-v4-flash", apiKey, ApiStyle: null);
|
||||
|
||||
// Конфиг Anthropic-вызова сценария.
|
||||
private static LlmConfig AnthropicConfig()
|
||||
=> new("anthropic", AnthropicBaseUrl, "claude-sonnet-5", "anthropic-key", LlmConfig.AnthropicApiStyle);
|
||||
|
||||
// Клиент сценария над заглушкой (без общего таймаута — управление на попытку).
|
||||
// handler: Заглушка обработчика.
|
||||
private static LlmHttpClient CreateClient(StubHttpMessageHandler handler)
|
||||
=> new(TestHttpClient(handler));
|
||||
|
||||
// HttpClient над заглушкой без общего таймаута (таймаут попытки задаёт клиент).
|
||||
// handler: Заглушка обработчика.
|
||||
private static HttpClient TestHttpClient(StubHttpMessageHandler handler)
|
||||
=> new(handler) { Timeout = Timeout.InfiniteTimeSpan };
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
using Deal.Ai.Llm;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace Deal.Ai.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit-тесты оркестратора вызовов модели (план Task 7; ProviderCaller, 1:1 chat_json ai.py L80–117):
|
||||
/// ретраи 2 с паузами 0.8/2 с, извлечение JSON, различение «провайдер не ответил» и «ответ без
|
||||
/// JSON», usage. Фейк-провайдер без сети; паузы ретраев мгновенные (харнесс-делегат).
|
||||
/// </summary>
|
||||
public sealed class ProviderCallerTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Успех с первого вызова: JSON-объект извлечён, usage API-ответа в результате.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ChatJsonAsync_FirstAttemptSuccess_ReturnsJsonAndUsage()
|
||||
{
|
||||
var fake = new FakeProviderClient((_, _, _) => Task.FromResult(
|
||||
new ProviderChatResult("""{"pass": false}""", new ProviderUsage(11, 5, 16))));
|
||||
ProviderCaller caller = CreateCaller(fake);
|
||||
|
||||
LlmCallResult result = await caller.ChatJsonAsync(Config(), "system", "user", CancellationToken.None);
|
||||
|
||||
Assert.False((bool)result.Json["pass"]!);
|
||||
Assert.Equal("""{"pass":false}""", result.JsonText);
|
||||
Assert.Equal(16, result.Usage.TotalTokens);
|
||||
Assert.Single(fake.Calls);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Марdown-обёртка ответа модели снимается фасадом (extract_json L175–183).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ChatJsonAsync_MarkdownWrappedJson_Extracts()
|
||||
{
|
||||
var fake = new FakeProviderClient((_, _, _) => Task.FromResult(
|
||||
new ProviderChatResult("```json\n{\"fit\": 1}\n```", Usage: null)));
|
||||
ProviderCaller caller = CreateCaller(fake);
|
||||
|
||||
LlmCallResult result = await caller.ChatJsonAsync(Config(), "system", "user", CancellationToken.None);
|
||||
|
||||
Assert.Equal(1, (int)result.Json["fit"]!);
|
||||
Assert.Single(fake.Calls);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Без usage API-ответа фасад оценивает токены по символам (≈chars/4).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ChatJsonAsync_WithoutProviderUsage_EstimatesTokens()
|
||||
{
|
||||
var fake = new FakeProviderClient((_, _, _) => Task.FromResult(
|
||||
new ProviderChatResult("{}", Usage: null)));
|
||||
ProviderCaller caller = CreateCaller(fake);
|
||||
|
||||
LlmCallResult result = await caller.ChatJsonAsync(Config(), "abcdefgh", "user", CancellationToken.None);
|
||||
|
||||
// prompt-текст = 8 + 4 = 12 символов → 3 токена; completion = "{}" = 2 символа → 1 токен.
|
||||
Assert.Equal(3, result.Usage.PromptTokens);
|
||||
Assert.Equal(1, result.Usage.CompletionTokens);
|
||||
Assert.Equal(4, result.Usage.TotalTokens);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Сбой на первых двух попытках и успех на третьей: итог успешен, попыток — 3.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ChatJsonAsync_TwoFailuresThenSuccess_RetriesAndSucceeds()
|
||||
{
|
||||
int attempts = 0;
|
||||
var fake = new FakeProviderClient((_, _, _) =>
|
||||
{
|
||||
attempts++;
|
||||
return attempts < 3
|
||||
? Task.FromException<ProviderChatResult>(new LlmHttpException("таймаут"))
|
||||
: Task.FromResult(new ProviderChatResult("""{"ok": 1}""", Usage: null));
|
||||
});
|
||||
ProviderCaller caller = CreateCaller(fake);
|
||||
|
||||
LlmCallResult result = await caller.ChatJsonAsync(Config(), "system", "user", CancellationToken.None);
|
||||
|
||||
Assert.Equal(1, (int)result.Json["ok"]!);
|
||||
Assert.Equal(3, attempts);
|
||||
Assert.Equal(3, fake.Calls.Count);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Все попытки — транспортный сбой: LlmCallException ProviderUnavailable с текстом 1:1 Ruling 5
|
||||
/// (ai.py L115–117); usage отсутствует (модель не отвечала).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ChatJsonAsync_AllTransportFailures_ThrowsProviderUnavailable()
|
||||
{
|
||||
var fake = new FakeProviderClient((_, _, _) =>
|
||||
Task.FromException<ProviderChatResult>(new LlmHttpException("сеть недоступна")));
|
||||
ProviderCaller caller = CreateCaller(fake);
|
||||
|
||||
LlmCallException exception = await Assert.ThrowsAsync<LlmCallException>(() =>
|
||||
caller.ChatJsonAsync(Config(), "system", "user", CancellationToken.None));
|
||||
|
||||
Assert.Equal(LlmCallFailureKind.ProviderUnavailable, exception.Kind);
|
||||
Assert.Null(exception.Usage);
|
||||
Assert.Equal(
|
||||
"ИИ (DeepSeek) не ответил корректно — повторите попытку через несколько секунд",
|
||||
exception.Message);
|
||||
Assert.Equal(LlmRetryPolicy.AttemptCount, fake.Calls.Count);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Модель отвечала, но ни одна попытка не дала разбираемый JSON: AnswerNotJson с usage последней
|
||||
/// попытки (Classify вернёт ok=false; README ai.proto L201–204).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ChatJsonAsync_AllAnswersNotJson_ThrowsAnswerNotJsonWithUsage()
|
||||
{
|
||||
var fake = new FakeProviderClient((_, _, _) =>
|
||||
Task.FromResult(new ProviderChatResult("сожалею, не понимаю", Usage: null)));
|
||||
ProviderCaller caller = CreateCaller(fake);
|
||||
|
||||
LlmCallException exception = await Assert.ThrowsAsync<LlmCallException>(() =>
|
||||
caller.ChatJsonAsync(Config(), "system", "user", CancellationToken.None));
|
||||
|
||||
Assert.Equal(LlmCallFailureKind.AnswerNotJson, exception.Kind);
|
||||
Assert.NotNull(exception.Usage);
|
||||
Assert.True(exception.Usage!.CompletionTokens > 0);
|
||||
Assert.Equal(LlmRetryPolicy.AttemptCount, fake.Calls.Count);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Отмена (deadline RPC) прерывает вызов без «упаковки» в ошибку провайдера: исключение
|
||||
/// отмены из попытки не перехватывается как сбой (ретрятся только LlmHttpException).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ChatJsonAsync_Cancelled_PropagatesCancellation()
|
||||
{
|
||||
using var cancellation = new CancellationTokenSource();
|
||||
cancellation.Cancel();
|
||||
var fake = new FakeProviderClient(
|
||||
(_, _, _) => Task.FromException<ProviderChatResult>(new OperationCanceledException()));
|
||||
ProviderCaller caller = CreateCaller(fake);
|
||||
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(() =>
|
||||
caller.ChatJsonAsync(Config(), "system", "user", cancellation.Token));
|
||||
}
|
||||
|
||||
// Создаёт оркестратор с фейком и мгновенными паузами ретраев.
|
||||
// fake: Фейк-провайдер.
|
||||
private static ProviderCaller CreateCaller(FakeProviderClient fake)
|
||||
=> new(fake, static (_, _) => Task.CompletedTask, NullLogger<ProviderCaller>.Instance);
|
||||
|
||||
// Типовой конфиг провайдера сценариев.
|
||||
private static LlmConfig Config()
|
||||
=> new("deepseek", "https://api.example.com/v1", "deepseek-v4-flash", "test-key", null);
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using System.Text.Json.Nodes;
|
||||
using Deal.Ai.Llm;
|
||||
|
||||
namespace Deal.Ai.Tests;
|
||||
|
||||
// Снимок HTTP-запроса, записанный заглушкой обработчика (для проверок wire-контракта).
|
||||
// Url: Полный URL запроса.
|
||||
// Headers: Заголовки запроса (включая заголовки содержимого).
|
||||
// Body: Тело запроса (JSON-строка) либо null.
|
||||
internal sealed record CapturedHttpRequest(
|
||||
string Url,
|
||||
IReadOnlyDictionary<string, string> Headers,
|
||||
string? Body);
|
||||
|
||||
// Заглушка HttpMessageHandler для HTTP-тестов LlmHttpClient (план Task 7; без сети): записывает
|
||||
// запросы (URL/заголовки/тело) и отвечает по сценарию; опциональная задержка — для теста таймаута.
|
||||
internal sealed class StubHttpMessageHandler : HttpMessageHandler
|
||||
{
|
||||
private readonly Func<HttpRequestMessage, HttpResponseMessage> _responder;
|
||||
private readonly TimeSpan? _delay;
|
||||
|
||||
/// <summary>
|
||||
/// Создаёт заглушку с ответчиком сценария.
|
||||
/// </summary>
|
||||
/// <param name="responder">Ответчик: request → response.</param>
|
||||
/// <param name="delay">Необязательная задержка ответа (тест таймаута).</param>
|
||||
public StubHttpMessageHandler(
|
||||
Func<HttpRequestMessage, HttpResponseMessage> responder,
|
||||
TimeSpan? delay = null)
|
||||
{
|
||||
_responder = responder;
|
||||
_delay = delay;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Запросы заглушки в порядке поступления.
|
||||
/// </summary>
|
||||
public List<CapturedHttpRequest> Requests { get; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Последний запрос заглушки.
|
||||
/// </summary>
|
||||
public CapturedHttpRequest LastRequest => Requests[^1];
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
string? body = request.Content is null
|
||||
? null
|
||||
: await request.Content.ReadAsStringAsync(cancellationToken);
|
||||
|
||||
var headers = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (KeyValuePair<string, IEnumerable<string>> header in request.Headers)
|
||||
{
|
||||
headers[header.Key] = string.Join(", ", header.Value);
|
||||
}
|
||||
|
||||
if (request.Content?.Headers is { } contentHeaders)
|
||||
{
|
||||
foreach (KeyValuePair<string, IEnumerable<string>> header in contentHeaders)
|
||||
{
|
||||
headers[header.Key] = string.Join(", ", header.Value);
|
||||
}
|
||||
}
|
||||
|
||||
Requests.Add(new CapturedHttpRequest(
|
||||
request.Method + " " + (request.RequestUri?.ToString() ?? string.Empty),
|
||||
headers,
|
||||
body));
|
||||
|
||||
if (_delay is { } delay)
|
||||
{
|
||||
await Task.Delay(delay, cancellationToken);
|
||||
}
|
||||
|
||||
return _responder(request);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ответчик: JSON-тело с HTTP 200.
|
||||
/// </summary>
|
||||
/// <param name="json">Тело ответа.</param>
|
||||
public static Func<HttpRequestMessage, HttpResponseMessage> JsonOk(string json)
|
||||
=> _ => new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(json, Encoding.UTF8, "application/json"),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Ответчик: заданный HTTP-статус без тела.
|
||||
/// </summary>
|
||||
/// <param name="statusCode">Код ответа.</param>
|
||||
public static Func<HttpRequestMessage, HttpResponseMessage> Status(HttpStatusCode statusCode)
|
||||
=> _ => new HttpResponseMessage(statusCode);
|
||||
|
||||
/// <summary>
|
||||
/// Разбирает тело запроса как JSON-объект (для проверок формы).
|
||||
/// </summary>
|
||||
/// <param name="request">Снимок запроса.</param>
|
||||
public static JsonObject BodyOf(CapturedHttpRequest request)
|
||||
=> JsonNode.Parse(request.Body!)!.AsObject();
|
||||
|
||||
/// <summary>
|
||||
/// Снимает заголовок авторизации Bearer (null — заголовка нет).
|
||||
/// </summary>
|
||||
/// <param name="request">Снимок запроса.</param>
|
||||
public static string? BearerOf(CapturedHttpRequest request)
|
||||
=> request.Headers.TryGetValue("Authorization", out string? value) ? value : null;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
using Deal.Ai.Llm;
|
||||
|
||||
namespace Deal.Ai.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit-тесты оценки токенов (план Task 7; TokenEstimator, Ruling 5): usage API-ответа провайдера
|
||||
/// проходит как есть (total «берём как есть»), при отсутствии — оценка по символам ≈ceil(chars/4).
|
||||
/// Без сети.
|
||||
/// </summary>
|
||||
public sealed class TokenEstimatorTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Usage провайдера проходит без изменений, включая total ≠ сумме (как отдал API).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Resolve_WithProviderUsage_PassesThrough()
|
||||
{
|
||||
var provider = new ProviderUsage(PromptTokens: 11, CompletionTokens: 5, TotalTokens: 16);
|
||||
|
||||
LlmUsage usage = TokenEstimator.Resolve(provider, "system", "text");
|
||||
|
||||
Assert.Equal(11, usage.PromptTokens);
|
||||
Assert.Equal(5, usage.CompletionTokens);
|
||||
Assert.Equal(16, usage.TotalTokens);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Нет usage провайдера — оценка по символам: prompt из system+user, completion из текста.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Resolve_WithoutProviderUsage_EstimatesByChars()
|
||||
{
|
||||
// prompt-текст = 8 символов → 2 токена; completion-текст = 13 символов → ceil(13/4) = 4.
|
||||
LlmUsage usage = TokenEstimator.Resolve(null, "12345678", """{"pass": true}""");
|
||||
|
||||
Assert.Equal(2, usage.PromptTokens);
|
||||
Assert.Equal(4, usage.CompletionTokens);
|
||||
Assert.Equal(6, usage.TotalTokens);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Округление вверх: ровно 4 символа — 1 токен, 5 символов — 2 токена.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Resolve_WithoutProviderUsage_RoundsUp()
|
||||
{
|
||||
LlmUsage fourChars = TokenEstimator.Resolve(null, "abcd", string.Empty);
|
||||
LlmUsage fiveChars = TokenEstimator.Resolve(null, "abcde", string.Empty);
|
||||
|
||||
Assert.Equal(1, fourChars.PromptTokens);
|
||||
Assert.Equal(2, fiveChars.PromptTokens);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Пустые тексты дают нулевую оценку (не отрицательную).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Resolve_EmptyTexts_ReturnsZeroTokens()
|
||||
{
|
||||
LlmUsage usage = TokenEstimator.Resolve(null, string.Empty, string.Empty);
|
||||
|
||||
Assert.Equal(0, usage.PromptTokens);
|
||||
Assert.Equal(0, usage.CompletionTokens);
|
||||
Assert.Equal(0, usage.TotalTokens);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
# Visual Studio Version 17
|
||||
VisualStudioVersion = 17.0.31903.59
|
||||
MinimumVisualStudioVersion = 10.0.40219.1
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Deal.Ai", "Deal.Ai\Deal.Ai.csproj", "{5D42B2A8-6E3F-4A71-9B2C-3D8E1F4A6C01}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Deal.Proto", "..\contracts\Deal.Proto.csproj", "{5D42B2A8-6E3F-4A71-9B2C-3D8E1F4A6C02}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Deal.Ai.Tests", "Deal.Ai.Tests\Deal.Ai.Tests.csproj", "{5D42B2A8-6E3F-4A71-9B2C-3D8E1F4A6C03}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Deal.Grpc.Hosting", "..\grpc-hosting\Deal.Grpc.Hosting\Deal.Grpc.Hosting.csproj", "{9A1CB276-18E5-4A6A-B7EC-BC345AFE547C}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
Debug|x64 = Debug|x64
|
||||
Debug|x86 = Debug|x86
|
||||
Release|Any CPU = Release|Any CPU
|
||||
Release|x64 = Release|x64
|
||||
Release|x86 = Release|x86
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{5D42B2A8-6E3F-4A71-9B2C-3D8E1F4A6C01}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{5D42B2A8-6E3F-4A71-9B2C-3D8E1F4A6C01}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{5D42B2A8-6E3F-4A71-9B2C-3D8E1F4A6C01}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{5D42B2A8-6E3F-4A71-9B2C-3D8E1F4A6C01}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{5D42B2A8-6E3F-4A71-9B2C-3D8E1F4A6C01}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{5D42B2A8-6E3F-4A71-9B2C-3D8E1F4A6C01}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{5D42B2A8-6E3F-4A71-9B2C-3D8E1F4A6C01}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{5D42B2A8-6E3F-4A71-9B2C-3D8E1F4A6C01}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{5D42B2A8-6E3F-4A71-9B2C-3D8E1F4A6C01}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{5D42B2A8-6E3F-4A71-9B2C-3D8E1F4A6C01}.Release|x64.Build.0 = Release|Any CPU
|
||||
{5D42B2A8-6E3F-4A71-9B2C-3D8E1F4A6C01}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{5D42B2A8-6E3F-4A71-9B2C-3D8E1F4A6C01}.Release|x86.Build.0 = Release|Any CPU
|
||||
{5D42B2A8-6E3F-4A71-9B2C-3D8E1F4A6C02}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{5D42B2A8-6E3F-4A71-9B2C-3D8E1F4A6C02}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{5D42B2A8-6E3F-4A71-9B2C-3D8E1F4A6C02}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{5D42B2A8-6E3F-4A71-9B2C-3D8E1F4A6C02}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{5D42B2A8-6E3F-4A71-9B2C-3D8E1F4A6C02}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{5D42B2A8-6E3F-4A71-9B2C-3D8E1F4A6C02}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{5D42B2A8-6E3F-4A71-9B2C-3D8E1F4A6C02}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{5D42B2A8-6E3F-4A71-9B2C-3D8E1F4A6C02}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{5D42B2A8-6E3F-4A71-9B2C-3D8E1F4A6C02}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{5D42B2A8-6E3F-4A71-9B2C-3D8E1F4A6C02}.Release|x64.Build.0 = Release|Any CPU
|
||||
{5D42B2A8-6E3F-4A71-9B2C-3D8E1F4A6C02}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{5D42B2A8-6E3F-4A71-9B2C-3D8E1F4A6C02}.Release|x86.Build.0 = Release|Any CPU
|
||||
{5D42B2A8-6E3F-4A71-9B2C-3D8E1F4A6C03}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{5D42B2A8-6E3F-4A71-9B2C-3D8E1F4A6C03}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{5D42B2A8-6E3F-4A71-9B2C-3D8E1F4A6C03}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{5D42B2A8-6E3F-4A71-9B2C-3D8E1F4A6C03}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{5D42B2A8-6E3F-4A71-9B2C-3D8E1F4A6C03}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{5D42B2A8-6E3F-4A71-9B2C-3D8E1F4A6C03}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{5D42B2A8-6E3F-4A71-9B2C-3D8E1F4A6C03}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{5D42B2A8-6E3F-4A71-9B2C-3D8E1F4A6C03}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{5D42B2A8-6E3F-4A71-9B2C-3D8E1F4A6C03}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{5D42B2A8-6E3F-4A71-9B2C-3D8E1F4A6C03}.Release|x64.Build.0 = Release|Any CPU
|
||||
{5D42B2A8-6E3F-4A71-9B2C-3D8E1F4A6C03}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{5D42B2A8-6E3F-4A71-9B2C-3D8E1F4A6C03}.Release|x86.Build.0 = Release|Any CPU
|
||||
{9A1CB276-18E5-4A6A-B7EC-BC345AFE547C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{9A1CB276-18E5-4A6A-B7EC-BC345AFE547C}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{9A1CB276-18E5-4A6A-B7EC-BC345AFE547C}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{9A1CB276-18E5-4A6A-B7EC-BC345AFE547C}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{9A1CB276-18E5-4A6A-B7EC-BC345AFE547C}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{9A1CB276-18E5-4A6A-B7EC-BC345AFE547C}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{9A1CB276-18E5-4A6A-B7EC-BC345AFE547C}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{9A1CB276-18E5-4A6A-B7EC-BC345AFE547C}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{9A1CB276-18E5-4A6A-B7EC-BC345AFE547C}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{9A1CB276-18E5-4A6A-B7EC-BC345AFE547C}.Release|x64.Build.0 = Release|Any CPU
|
||||
{9A1CB276-18E5-4A6A-B7EC-BC345AFE547C}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{9A1CB276-18E5-4A6A-B7EC-BC345AFE547C}.Release|x86.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
@@ -0,0 +1,85 @@
|
||||
using Deal.Grpc.Hosting;
|
||||
|
||||
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 и функции паузы ретраев).
|
||||
/// </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>
|
||||
/// <returns>Собранный хост; запуск — StartAsync/RunAsync у вызывающего.</returns>
|
||||
public static WebApplication Create(
|
||||
int grpcPort,
|
||||
string[]? args = null,
|
||||
Action<IServiceCollection>? configureServices = null,
|
||||
Action<WebApplicationBuilder>? configureBuilder = null)
|
||||
{
|
||||
WebApplicationBuilder builder = WebApplication.CreateBuilder(args ?? []);
|
||||
|
||||
// Общая серверная обвязка (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 =>
|
||||
httpClient.Timeout = Timeout.InfiniteTimeSpan);
|
||||
builder.Services.AddSingleton<Llm.IProviderClient>(
|
||||
static sp => sp.GetRequiredService<Llm.LlmHttpClient>());
|
||||
builder.Services.AddSingleton<Func<TimeSpan, CancellationToken, Task>>(DefaultRetryDelayAsync);
|
||||
builder.Services.AddSingleton<Llm.ProviderCaller>();
|
||||
|
||||
configureServices?.Invoke(builder.Services);
|
||||
configureBuilder?.Invoke(builder);
|
||||
|
||||
WebApplication app = builder.Build();
|
||||
|
||||
app.MapGrpcService<AiServiceImpl>();
|
||||
app.MapGrpcHealthChecksService();
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
// Пауза между ретраями по умолчанию (Task.Delay; тесты подменяют через DI).
|
||||
// delay: Длительность паузы.
|
||||
// cancellationToken: Токен отмены.
|
||||
private static Task DefaultRetryDelayAsync(TimeSpan delay, CancellationToken cancellationToken)
|
||||
=> Task.Delay(delay, cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,503 @@
|
||||
using System.Globalization;
|
||||
using System.Text.Json.Nodes;
|
||||
using Deal.Ai.Llm;
|
||||
using Deal.Grpc.Ai;
|
||||
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).
|
||||
/// </summary>
|
||||
public sealed class AiServiceImpl : AiService.AiServiceBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Ключ gRPC-metadata с id тенанта (обязателен на всех RPC — Ruling 1).
|
||||
/// </summary>
|
||||
public const string TenantIdMetadataKey = "tenant-id";
|
||||
|
||||
// Деталь отказа: tenant-id отсутствует в metadata (UNAUTHENTICATED, шаблон MlServiceImpl).
|
||||
private const string TenantIdMissingDetail = "tenant-id отсутствует в metadata";
|
||||
|
||||
// Деталь отказа: не задан конфиг ИИ-провайдера (INVALID_ARGUMENT).
|
||||
private const string ProviderConfigMissingDetail = "Не задан конфиг ИИ-провайдера (provider_config)";
|
||||
|
||||
// Деталь отказа: пустой base_url конфига (INVALID_ARGUMENT).
|
||||
private const string ProviderBaseUrlEmptyDetail = "Конфиг ИИ-провайдера: пустой base_url";
|
||||
|
||||
// Деталь отказа: пустая model конфига (INVALID_ARGUMENT).
|
||||
private const string ProviderModelEmptyDetail = "Конфиг ИИ-провайдера: пустая model";
|
||||
|
||||
// Деталь отказа: текст сообщения длиннее контрактного лимита (INVALID_ARGUMENT).
|
||||
private const string MessageTextTooLongDetail = "Слишком длинный текст сообщения";
|
||||
|
||||
// Деталь отказа: промпт длиннее защитного лимита (INVALID_ARGUMENT).
|
||||
private const string PromptTooLongDetail = "Слишком длинный промпт";
|
||||
|
||||
// Деталь отказа: описание ниши/задачи длиннее контрактного лимита (INVALID_ARGUMENT).
|
||||
private const string DescriptionTooLongDetail = "Слишком длинное описание ниши/задачи";
|
||||
|
||||
// Деталь отказа: контекст Classify длиннее защитного лимита (INVALID_ARGUMENT).
|
||||
private const string UserContextTooLongDetail = "Слишком длинный контекст разбора";
|
||||
|
||||
// Деталь отказа: слишком много ключей задачи в EvaluateFit (INVALID_ARGUMENT).
|
||||
private const string TooManyKeywordsDetail = "Слишком много ключей задачи";
|
||||
|
||||
// Деталь отказа: ключ задачи длиннее лимита (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; лимит не декларирован).
|
||||
private const int MaxPromptLength = 20000;
|
||||
|
||||
// Защитный потолок длины 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 "
|
||||
+ "находят подходящие источники. Верни строго JSON вида "
|
||||
+ "{\"keywords\": [\"...\", \"...\"]}. Требования к списку:\n"
|
||||
+ "- 10–16 ключей;\n"
|
||||
+ "- примерно поровну русских и английских (английские — популярные в нише термины);\n"
|
||||
+ "- короткие фразы 1–4 слова;\n"
|
||||
+ "- без #, @, кавычек и лишней пунктуации;\n"
|
||||
+ "- конкретные для ниши, включая сленг заказчиков и подрядчиков;\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",
|
||||
"false",
|
||||
"no",
|
||||
"нет",
|
||||
"null",
|
||||
"none",
|
||||
};
|
||||
|
||||
private readonly ProviderCaller _caller;
|
||||
private readonly ILogger<AiServiceImpl> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Создаёт gRPC-сервис команд ядра поверх LLM-фасада.
|
||||
/// </summary>
|
||||
/// <param name="caller">Оркестратор вызовов модели (ретраи + извлечение JSON + usage).</param>
|
||||
/// <param name="logger">Логгер аудита (Ruling 13; ключи API не логируются).</param>
|
||||
public AiServiceImpl(ProviderCaller caller, ILogger<AiServiceImpl> logger)
|
||||
{
|
||||
_caller = caller;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Filter — ИИ-фильтр входящих сообщений (ai.py filter_incoming L188–198): решение {pass, reason}
|
||||
/// по заполненному ядром aiFilterPrompt (system) и тексту. Ветку «фильтр не применялся»
|
||||
/// (aiFilterEnabled/недоступность) ядро обрабатывает до вызова; недоступность провайдера —
|
||||
/// UNAVAILABLE (ядро пропускает сообщение).
|
||||
/// </summary>
|
||||
public override async Task<FilterReply> Filter(FilterRequest request, ServerCallContext context)
|
||||
{
|
||||
string tenantId = RequireTenantId(context);
|
||||
EnsureLengthAtMost(request.Text, MaxTextLength, MessageTextTooLongDetail);
|
||||
EnsureLengthAtMost(request.Prompt, MaxPromptLength, PromptTooLongDetail);
|
||||
LlmConfig config = ResolveConfig(request.ProviderConfig);
|
||||
try
|
||||
{
|
||||
LlmCallResult result = await _caller
|
||||
.ChatJsonAsync(config, request.Prompt, FilterUserPrefix + request.Text, context.CancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
bool pass = ReadBoolField(result.Json, PassFieldName, defaultValue: true);
|
||||
string? reason = ReadStringField(result.Json, ReasonFieldName);
|
||||
|
||||
var reply = new FilterReply { Pass = pass, Usage = ToUsage(result.Usage) };
|
||||
if (reason is not null)
|
||||
{
|
||||
reply.Reason = reason;
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Аудит: tenant {TenantId} filter → pass={Pass}, usage={TotalTokens}",
|
||||
tenantId,
|
||||
pass,
|
||||
result.Usage.TotalTokens);
|
||||
return reply;
|
||||
}
|
||||
catch (LlmCallException callError)
|
||||
{
|
||||
LogAiUnavailable("filter", tenantId, config, callError);
|
||||
throw ToUnavailable(callError);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Classify — полный разбор лида (ai.py classify L218–258): ответ {ok, json}, где json — строка
|
||||
/// с извлечённым ответом модели (типовую схему задаёт промпт); строгий маппинг json → карточку
|
||||
/// делает ядро (1:1 normalize_stack/clean_budget/build_contacts). Модель отвечала без
|
||||
/// разбираемого JSON после ретраев → ok=false (не RPC-ошибка; ядро падает в локальный разбор);
|
||||
/// провайдер недоступен → UNAVAILABLE.
|
||||
/// </summary>
|
||||
public override async Task<ClassifyReply> Classify(ClassifyRequest request, ServerCallContext context)
|
||||
{
|
||||
string tenantId = RequireTenantId(context);
|
||||
EnsureLengthAtMost(request.SystemPrompt, MaxPromptLength, PromptTooLongDetail);
|
||||
EnsureLengthAtMost(request.UserContext, MaxUserContextLength, UserContextTooLongDetail);
|
||||
LlmConfig config = ResolveConfig(request.ProviderConfig);
|
||||
try
|
||||
{
|
||||
LlmCallResult result = await _caller
|
||||
.ChatJsonAsync(config, request.SystemPrompt, request.UserContext, context.CancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var reply = new ClassifyReply { Ok = true, Json = result.JsonText, Usage = ToUsage(result.Usage) };
|
||||
|
||||
_logger.LogInformation(
|
||||
"Аудит: tenant {TenantId} classify → ok=true, usage={TotalTokens}",
|
||||
tenantId,
|
||||
result.Usage.TotalTokens);
|
||||
return reply;
|
||||
}
|
||||
catch (LlmCallException callError) when (callError.Kind == LlmCallFailureKind.AnswerNotJson)
|
||||
{
|
||||
// «Ответ без разбираемого JSON» — контрактная форма ClassifyReply.ok=false (README ai.proto).
|
||||
_logger.LogWarning(
|
||||
"Аудит: tenant {TenantId} classify → ok=false ({Provider}): ответ модели без JSON",
|
||||
tenantId,
|
||||
config.DisplayName);
|
||||
|
||||
var reply = new ClassifyReply { Ok = false, Usage = ToUsage(callError.Usage) };
|
||||
return reply;
|
||||
}
|
||||
catch (LlmCallException callError)
|
||||
{
|
||||
LogAiUnavailable("classify", tenantId, config, callError);
|
||||
throw ToUnavailable(callError);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// GenerateKeywords — ключевые слова discovery-задачи по описанию (фикс. промпт discovery_routes
|
||||
/// L36–47 + описание): ответ {keywords}. Очистку (_clean_keywords: ≤30, ≤60 симв., дедуп) и мягкую
|
||||
/// ошибку для UI делает ядро (Ruling 11); недоступность провайдера — UNAVAILABLE.
|
||||
/// </summary>
|
||||
public override async Task<GenerateKeywordsReply> GenerateKeywords(
|
||||
GenerateKeywordsRequest request, ServerCallContext context)
|
||||
{
|
||||
string tenantId = RequireTenantId(context);
|
||||
EnsureLengthAtMost(request.Description, MaxDescriptionLength, DescriptionTooLongDetail);
|
||||
LlmConfig config = ResolveConfig(request.ProviderConfig);
|
||||
try
|
||||
{
|
||||
LlmCallResult result = await _caller
|
||||
.ChatJsonAsync(
|
||||
config,
|
||||
GenerateKeywordsSystemPrompt,
|
||||
KeywordsUserPrefix + request.Description,
|
||||
context.CancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var reply = new GenerateKeywordsReply { Usage = ToUsage(result.Usage) };
|
||||
reply.Keywords.AddRange(ReadKeywords(result.Json));
|
||||
|
||||
_logger.LogInformation(
|
||||
"Аудит: tenant {TenantId} generate_keywords → keywords={Count}, usage={TotalTokens}",
|
||||
tenantId,
|
||||
reply.Keywords.Count,
|
||||
result.Usage.TotalTokens);
|
||||
return reply;
|
||||
}
|
||||
catch (LlmCallException callError)
|
||||
{
|
||||
LogAiUnavailable("generate_keywords", tenantId, config, callError);
|
||||
throw ToUnavailable(callError);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EvaluateFit — оценка соответствия сообщения задаче поиска (промпт discovery_eval L50–54;
|
||||
/// текст + описание + ключи задачи): ответ {fit, reason}. Ядро зовёт только при aiEnabled;
|
||||
/// сбой — фолбэк на эвристику (Ruling 10).
|
||||
/// </summary>
|
||||
public override async Task<EvaluateFitReply> EvaluateFit(
|
||||
EvaluateFitRequest request, ServerCallContext context)
|
||||
{
|
||||
string tenantId = RequireTenantId(context);
|
||||
EnsureLengthAtMost(request.Text, MaxTextLength, MessageTextTooLongDetail);
|
||||
EnsureLengthAtMost(request.Description, MaxDescriptionLength, DescriptionTooLongDetail);
|
||||
EnsureKeywordsWithinBounds(request.Keywords);
|
||||
LlmConfig config = ResolveConfig(request.ProviderConfig);
|
||||
try
|
||||
{
|
||||
string systemPrompt = string.Format(
|
||||
CultureInfo.InvariantCulture,
|
||||
EvaluateFitSystemPromptTemplate,
|
||||
request.Description,
|
||||
JoinKeywords(request.Keywords));
|
||||
|
||||
LlmCallResult result = await _caller
|
||||
.ChatJsonAsync(config, systemPrompt, FilterUserPrefix + request.Text, context.CancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
bool fit = ReadBoolField(result.Json, FitFieldName, defaultValue: false);
|
||||
string reason = ReadFitReason(result.Json, fit);
|
||||
|
||||
var reply = new EvaluateFitReply { Fit = fit, Reason = reason, Usage = ToUsage(result.Usage) };
|
||||
|
||||
_logger.LogInformation(
|
||||
"Аудит: tenant {TenantId} evaluate_fit → fit={Fit}, usage={TotalTokens}",
|
||||
tenantId,
|
||||
fit,
|
||||
result.Usage.TotalTokens);
|
||||
return reply;
|
||||
}
|
||||
catch (LlmCallException callError)
|
||||
{
|
||||
LogAiUnavailable("evaluate_fit", tenantId, config, callError);
|
||||
throw ToUnavailable(callError);
|
||||
}
|
||||
}
|
||||
|
||||
// Читает tenant-id из metadata (обязателен; отсутствие — UNAUTHENTICATED, Ruling 1).
|
||||
// context: Контекст вызова.
|
||||
private static string RequireTenantId(ServerCallContext context)
|
||||
{
|
||||
string? tenantId = context.RequestHeaders.GetValue(TenantIdMetadataKey);
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
throw new RpcException(new Status(StatusCode.Unauthenticated, TenantIdMissingDetail));
|
||||
}
|
||||
|
||||
return tenantId;
|
||||
}
|
||||
|
||||
// INVALID_ARGUMENT при превышении лимита длины текстового поля (серверный enforcement ai.proto).
|
||||
// value: Значение поля запроса (в proto строка не бывает null).
|
||||
// maxLength: Допустимый максимум символов.
|
||||
// detail: Текст отказа (detail RPC).
|
||||
private static void EnsureLengthAtMost(string value, int maxLength, string detail)
|
||||
{
|
||||
if (value.Length > maxLength)
|
||||
{
|
||||
throw new RpcException(new Status(StatusCode.InvalidArgument, detail));
|
||||
}
|
||||
}
|
||||
|
||||
// Проверяет число и длины ключей задачи EvaluateFit (защита промпта от раздувания).
|
||||
// keywords: Ключи задачи из запроса.
|
||||
private static void EnsureKeywordsWithinBounds(IEnumerable<string> keywords)
|
||||
{
|
||||
int count = 0;
|
||||
foreach (string keyword in keywords)
|
||||
{
|
||||
if (++count > MaxKeywordsCount)
|
||||
{
|
||||
throw new RpcException(new Status(StatusCode.InvalidArgument, TooManyKeywordsDetail));
|
||||
}
|
||||
|
||||
if (keyword.Length > MaxKeywordLength)
|
||||
{
|
||||
throw new RpcException(new Status(StatusCode.InvalidArgument, KeywordTooLongDetail));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Проверяет конфиг провайдера и строит рабочий конфиг вызова. Конфиг невалиден (пустые
|
||||
// base_url/model) — INVALID_ARGUMENT: без конфига вызов модели невозможен.
|
||||
// providerConfig: Конфиг из тела запроса (ядро всегда заполняет).
|
||||
private static LlmConfig ResolveConfig(ProviderConfig? providerConfig)
|
||||
{
|
||||
if (providerConfig is null)
|
||||
{
|
||||
throw new RpcException(new Status(StatusCode.InvalidArgument, ProviderConfigMissingDetail));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(providerConfig.BaseUrl))
|
||||
{
|
||||
throw new RpcException(new Status(StatusCode.InvalidArgument, ProviderBaseUrlEmptyDetail));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(providerConfig.Model))
|
||||
{
|
||||
throw new RpcException(new Status(StatusCode.InvalidArgument, ProviderModelEmptyDetail));
|
||||
}
|
||||
|
||||
return new LlmConfig(
|
||||
providerConfig.ProviderId,
|
||||
providerConfig.BaseUrl,
|
||||
providerConfig.Model,
|
||||
providerConfig.ApiKey,
|
||||
providerConfig.ApiStyle);
|
||||
}
|
||||
|
||||
// Пишет предупреждение аудита о недоступности ИИ (Ruling 13; без ключей и текстов).
|
||||
// method: Имя RPC для аудита.
|
||||
// tenantId: Id тенанта.
|
||||
// config: Конфиг провайдера вызова.
|
||||
// callError: Итоговая ошибка фасада.
|
||||
private void LogAiUnavailable(string method, string tenantId, LlmConfig config, LlmCallException callError)
|
||||
=> _logger.LogWarning(
|
||||
"Аудит: tenant {TenantId} {Method} → ИИ ({Provider}) недоступен ({Kind})",
|
||||
tenantId,
|
||||
method,
|
||||
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: Значение при отсутствии/неразбираемости поля.
|
||||
private static bool ReadBoolField(JsonObject json, string fieldName, bool defaultValue)
|
||||
{
|
||||
if (json[fieldName] is not JsonValue value)
|
||||
{
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
if (value.TryGetValue<bool>(out bool flag))
|
||||
{
|
||||
return flag;
|
||||
}
|
||||
|
||||
if (value.TryGetValue<string>(out string? raw) && raw is not null)
|
||||
{
|
||||
string text = raw.Trim();
|
||||
return text.Length == 0 ? defaultValue : !FalsyAnswerValues.Contains(text);
|
||||
}
|
||||
|
||||
if (value.TryGetValue<int>(out int number))
|
||||
{
|
||||
return number != 0;
|
||||
}
|
||||
|
||||
return value.TryGetValue<double>(out double fractional) && fractional != 0;
|
||||
}
|
||||
|
||||
// Читает строковое поле ответа модели (null — поля нет/не строка/пусто).
|
||||
// json: Корневой объект ответа модели.
|
||||
// fieldName: Имя поля.
|
||||
private static string? ReadStringField(JsonObject json, string fieldName)
|
||||
{
|
||||
if (json[fieldName] is not JsonValue value || !value.TryGetValue<string>(out string? text))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
string trimmed = text.Trim();
|
||||
return trimmed.Length == 0 ? null : trimmed;
|
||||
}
|
||||
|
||||
// Причина решения fit: поле reason модели, при отсутствии — «подходит»/«не подходит»
|
||||
// (1:1 _ai_reason discovery_eval L167–171), потолок длины MaxEvalReasonLength.
|
||||
// json: Корневой объект ответа модели.
|
||||
// fit: Решение модели.
|
||||
private static string ReadFitReason(JsonObject json, bool fit)
|
||||
{
|
||||
string reason = ReadStringField(json, ReasonFieldName) ?? (fit ? FitReasonDefault : NotFitReasonDefault);
|
||||
return reason.Length <= MaxEvalReasonLength ? reason : reason[..MaxEvalReasonLength];
|
||||
}
|
||||
|
||||
// Читает список ключевых слов из ответа модели (не-строки пропускаются; чистку — ядро).
|
||||
// json: Корневой объект ответа модели.
|
||||
private static IEnumerable<string> ReadKeywords(JsonObject json)
|
||||
{
|
||||
if (json[KeywordsFieldName] is not JsonArray keywords)
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
foreach (JsonNode? keywordNode in keywords)
|
||||
{
|
||||
if (keywordNode is JsonValue value && value.TryGetValue<string>(out string? keyword))
|
||||
{
|
||||
string trimmed = keyword.Trim();
|
||||
if (trimmed.Length > 0)
|
||||
{
|
||||
yield return trimmed;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Склеивает ключи задачи для промпта оценки (1:1 _ai_prompt discovery_eval L153–155).
|
||||
// keywords: Ключи задачи.
|
||||
private static string JoinKeywords(IEnumerable<string> keywords)
|
||||
=> string.Join(
|
||||
", ",
|
||||
keywords.Where(keyword => !string.IsNullOrWhiteSpace(keyword)).Select(keyword => keyword.Trim()));
|
||||
|
||||
// Маппит итоговую оценку токенов в gRPC-usage (null → нули — контрактный ответ без usage).
|
||||
// usage: Оценка токенов вызова.
|
||||
private static Usage ToUsage(LlmUsage? usage)
|
||||
=> new()
|
||||
{
|
||||
Prompt = usage is null ? 0U : (uint)usage.PromptTokens,
|
||||
Completion = usage is null ? 0U : (uint)usage.CompletionTokens,
|
||||
Total = usage is null ? 0U : (uint)usage.TotalTokens,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
// Тесты (Deal.Ai.Tests) проверяют таймауты HTTP-попыток фасада через конструктор LlmHttpClient с
|
||||
// короткими таймаутами (внутренний) — публичный контракт ради тестов не расширяется.
|
||||
[assembly: InternalsVisibleTo("Deal.Ai.Tests")]
|
||||
@@ -0,0 +1,43 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<!--
|
||||
Deal.Ai — gRPC-хост ai-service (план Task 4/7/8; Ruling 1/2/5/12).
|
||||
|
||||
Кодогенерация .proto — в общем проекте src/contracts/Deal.Proto.csproj (Task 1, Ruling 1):
|
||||
сервис подключает его ProjectReference и использует сгенерированную серверную базу
|
||||
Deal.Grpc.Ai.AiService.AiServiceBase (решение по способу подключения — T2, см. task-2-report).
|
||||
Клиентская сторона ai.proto сгенерирована в Deal.Proto (GrpcServices="Both") — понадобится
|
||||
core-адаптерам GrpcAiClassifier/GrpcAiTools (Ruling 6, задачи 15/17).
|
||||
|
||||
Логика LLM-фасада (OpenAI-совместимые + Anthropic; НЕ БД — Ruling 5) реализована в задачах 7–8:
|
||||
фасад в Deal.Ai/Llm (HTTP-клиент провайдеров, ретраи, JSON-извлечение, usage), RPC
|
||||
Filter/Classify/GenerateKeywords/EvaluateFit — в AiServiceImpl поверх фасада.
|
||||
|
||||
Сборка: 0 warnings / 0 errors (TreatWarningsAsErrors, Directory.Build.props каталога сервиса).
|
||||
-->
|
||||
<PropertyGroup>
|
||||
<AssemblyName>Deal.Ai</AssemblyName>
|
||||
<RootNamespace>Deal.Ai</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Структурированные логи Serilog (Ruling 7, план Task 14): JSON-консоль + rolling-файл
|
||||
data/logs/deal-ai-*.json; конфигурация — Deal.Ai/DealLogging.cs (Program.cs).
|
||||
Пакет тянет консоль/файл/compact-формат транзитивно. -->
|
||||
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- gRPC-сервер ASP.NET Core (Kestrel HTTP/2) + стандартный gRPC-health (Ruling 12). -->
|
||||
<PackageReference Include="Grpc.AspNetCore" Version="2.83.0" />
|
||||
<PackageReference Include="Grpc.AspNetCore.HealthChecks" Version="2.83.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Общая серверная обвязка gRPC-хостов (C31): интерцепторы/mTLS/DealLogging/GrpcServer —
|
||||
единый источник вместо копий в трёх сервисах. -->
|
||||
<ProjectReference Include="..\..\grpc-hosting\Deal.Grpc.Hosting\Deal.Grpc.Hosting.csproj" />
|
||||
<ProjectReference Include="..\..\contracts\Deal.Proto.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,38 @@
|
||||
# ai-service: gRPC-хост ИИ-фасада (план Task 4; Ruling 12 — запись в deploy/compose.dev.yml).
|
||||
#
|
||||
# КОНТЕКСТ СБОРКИ — корень репозитория: Deal.Ai.csproj ссылается на src/contracts/Deal.Proto.csproj
|
||||
# (общий проект кодогенерации, Task 1) вне каталога сервиса, поэтому нельзя собирать из
|
||||
# src/ai-service. Запуск из корня: docker build -f src/ai-service/Deal.Ai/Dockerfile .
|
||||
# Порт — env GRPC_PORT (Program.cs), в compose.dev.yml задан 5102.
|
||||
|
||||
# --- Этап сборки: restore + publish ---
|
||||
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||
WORKDIR /repo
|
||||
|
||||
# Restore-слой: только csproj/props (кэш слоёв Docker — restore не повторяется при правке исходников).
|
||||
COPY src/contracts/Deal.Proto.csproj src/contracts/
|
||||
COPY src/grpc-hosting/Deal.Grpc.Hosting/Deal.Grpc.Hosting.csproj src/grpc-hosting/Deal.Grpc.Hosting/
|
||||
COPY src/ai-service/Directory.Build.props src/ai-service/
|
||||
COPY src/ai-service/Deal.Ai/Deal.Ai.csproj src/ai-service/Deal.Ai/
|
||||
RUN dotnet restore src/ai-service/Deal.Ai/Deal.Ai.csproj
|
||||
|
||||
# Исходники: контракты (.proto) + общая gRPC-обвязка + проект сервиса.
|
||||
COPY src/contracts/ src/contracts/
|
||||
COPY src/grpc-hosting/ src/grpc-hosting/
|
||||
COPY src/ai-service/Deal.Ai/ src/ai-service/Deal.Ai/
|
||||
RUN dotnet publish src/ai-service/Deal.Ai/Deal.Ai.csproj -c Release -o /app/publish
|
||||
|
||||
# --- Runtime-этап ---
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final
|
||||
WORKDIR /app
|
||||
EXPOSE 5102
|
||||
COPY --from=build /app/publish .
|
||||
|
||||
# grpc_health_probe — healthcheck контейнера (Ruling 12): gRPC-health освобождён от service-token
|
||||
# (см. ServiceTokenInterceptor), поэтому проба идёт без metadata.
|
||||
COPY --from=ghcr.io/grpc-ecosystem/grpc-health-probe:v0.4.35 /ko-app/grpc-health-probe /bin/grpc_health_probe
|
||||
|
||||
# Volume-ов нет: ai-service без БД и без персистентных файлов (Ruling 5) — конфиг провайдера
|
||||
# (id/base/model/apiKey/api_style) приходит в теле каждого запроса от ядра, ответы не хранятся.
|
||||
|
||||
ENTRYPOINT ["dotnet", "Deal.Ai.dll"]
|
||||
@@ -0,0 +1,24 @@
|
||||
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"/>).
|
||||
/// </summary>
|
||||
public interface IProviderClient
|
||||
{
|
||||
/// <summary>
|
||||
/// Выполняет один вызов модели по системному и пользовательскому сообщениям.
|
||||
/// </summary>
|
||||
/// <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,
|
||||
string systemPrompt,
|
||||
string userText,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace Deal.Ai.Llm;
|
||||
|
||||
/// <summary>
|
||||
/// Извлечение JSON-объекта из ответа модели (план Task 7; 1:1 extract_json ai.py L175–183):
|
||||
/// снимается markdown-обёртка ```json … ```, затем берётся срез между первой «{» и последней «}»,
|
||||
/// результат парсится как объект. Любая аномалия — null (попытка считается неудачной и повторяется).
|
||||
/// </summary>
|
||||
public static class JsonExtractor
|
||||
{
|
||||
// Маркер markdown-обёртки кода в ответах моделей.
|
||||
private const string CodeFence = "```";
|
||||
|
||||
// Метка языка после открывающей обёртки (```json).
|
||||
private const string JsonFenceLabel = "json";
|
||||
|
||||
/// <summary>
|
||||
/// Пытается извлечь JSON-объект из текста ответа модели.
|
||||
/// </summary>
|
||||
/// <param name="text">Сырой ответ модели.</param>
|
||||
/// <returns>Объект JSON либо null (нет JSON/не объект/не разбирается).</returns>
|
||||
public static JsonObject? TryExtractObject(string text)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
string candidate = UnwrapFence(text.Trim()) ?? text.Trim();
|
||||
|
||||
int openBrace = candidate.IndexOf('{');
|
||||
int closeBrace = candidate.LastIndexOf('}');
|
||||
if (openBrace < 0 || closeBrace <= openBrace)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
string slice = candidate[openBrace..(closeBrace + 1)];
|
||||
try
|
||||
{
|
||||
return JsonNode.Parse(slice) as JsonObject;
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Снимает markdown-обёртку ```json … ``` (как в extract_json L177–179): возвращает содержимое
|
||||
// между открывающей и закрывающей обёртками; обёртки нет/незакрыта — null.
|
||||
// raw: Текст ответа (уже обрезанный).
|
||||
private static string? UnwrapFence(string raw)
|
||||
{
|
||||
int open = raw.IndexOf(CodeFence, StringComparison.Ordinal);
|
||||
if (open < 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
int contentStart = open + CodeFence.Length;
|
||||
int close = raw.IndexOf(CodeFence, contentStart, StringComparison.Ordinal);
|
||||
if (close < 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
string inner = raw[contentStart..close].Trim();
|
||||
if (inner.StartsWith(JsonFenceLabel, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
inner = inner[JsonFenceLabel.Length..].TrimStart();
|
||||
}
|
||||
|
||||
return inner;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
namespace Deal.Ai.Llm;
|
||||
|
||||
/// <summary>
|
||||
/// Итоговая ошибка вызова после исчерпания ретраев (план Task 7: «ошибки → исключение с кодом»).
|
||||
/// Текст сообщения — 1:1 Ruling 5 / ai.py L115–117: «ИИ (имя) не ответил корректно — повторите
|
||||
/// попытку через несколько секунд»; RPC-слой отдаёт его как detail статуса UNAVAILABLE.
|
||||
/// </summary>
|
||||
public sealed class LlmCallException : Exception
|
||||
{
|
||||
// Шаблон текста ошибки (имя провайдера из конфига).
|
||||
private const string DetailTemplate = "ИИ ({0}) не ответил корректно — повторите попытку через несколько секунд";
|
||||
|
||||
/// <summary>
|
||||
/// Создаёт итоговую ошибку вызова после исчерпания попыток.
|
||||
/// </summary>
|
||||
/// <param name="kind">Причина исчерпания (см. <see cref="LlmCallFailureKind"/>).</param>
|
||||
/// <param name="providerDisplayName">Отображаемое имя провайдера (без ключей).</param>
|
||||
/// <param name="usage">Usage последней ответившей попытки (для Kind=AnswerNotJson; иначе null).</param>
|
||||
public LlmCallException(LlmCallFailureKind kind, string providerDisplayName, LlmUsage? usage = null)
|
||||
: base(string.Format(DetailTemplate, providerDisplayName))
|
||||
{
|
||||
Kind = kind;
|
||||
Usage = usage;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Причина исчерпания попыток вызова.
|
||||
/// </summary>
|
||||
public LlmCallFailureKind Kind { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Usage последней попытки, вернувшей текст модели: заполнен при <see cref="LlmCallFailureKind.AnswerNotJson"/>
|
||||
/// (Classify отвечает ok=false и всё равно несёт usage; Ruling 5).
|
||||
/// </summary>
|
||||
public LlmUsage? Usage { get; }
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
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).
|
||||
/// </summary>
|
||||
AnswerNotJson,
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
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 — маппинг в ядре).
|
||||
/// </summary>
|
||||
public string JsonText => Json.ToJsonString();
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
namespace Deal.Ai.Llm;
|
||||
|
||||
/// <summary>
|
||||
/// Эффективный конфиг LLM-провайдера на один вызов (план Task 7, Ruling 5): зеркало
|
||||
/// <c>ProviderConfig</c> из ai.proto. Ядро передаёт заполненный конфиг в теле каждого запроса
|
||||
/// (base_url/model/api_key расшифрованы, api_style — из каталога AiProviders); сервис настроек
|
||||
/// тенанта не хранит и не знает.
|
||||
/// </summary>
|
||||
public sealed record LlmConfig(
|
||||
string ProviderId,
|
||||
string BaseUrl,
|
||||
string Model,
|
||||
string? ApiKey,
|
||||
string? ApiStyle)
|
||||
{
|
||||
/// <summary>
|
||||
/// Значение api_style для Anthropic Messages API (пусто/иное — OpenAI-совместимый).
|
||||
/// </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)
|
||||
{
|
||||
["deepseek"] = "DeepSeek",
|
||||
["openai"] = "OpenAI",
|
||||
["openrouter"] = "OpenRouter",
|
||||
["anthropic"] = "Anthropic Claude",
|
||||
["ollama"] = "Ollama (локально)",
|
||||
["lmstudio"] = "LM Studio (локально)",
|
||||
["custom"] = "Другой (OpenAI-совместимый)",
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Истинно, когда конфиг задаёт Anthropic Messages API (x-api-key + anthropic-version).
|
||||
/// </summary>
|
||||
public bool IsAnthropic => string.Equals(ApiStyle, AnthropicApiStyle, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Имя провайдера для сообщений об ошибках и логов (ключ API в него не входит).
|
||||
/// </summary>
|
||||
public string DisplayName => KnownProviderNames.TryGetValue(ProviderId, out string? name)
|
||||
? name
|
||||
: ProviderId;
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
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).
|
||||
/// </summary>
|
||||
public sealed class LlmHttpClient : IProviderClient
|
||||
{
|
||||
// Относительный путь OpenAI-совместимого эндпоинта (база уже без хвостового «/»).
|
||||
private const string OpenAiCompletionsPath = "/chat/completions";
|
||||
|
||||
// Относительный путь Anthropic Messages API.
|
||||
private const string AnthropicMessagesPath = "/v1/messages";
|
||||
|
||||
// Заголовок версии 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;
|
||||
private readonly TimeSpan _openAiCallTimeout;
|
||||
private readonly TimeSpan _anthropicCallTimeout;
|
||||
|
||||
/// <summary>
|
||||
/// Создаёт HTTP-клиент провайдеров с типовыми таймаутами (90/60 с).
|
||||
/// </summary>
|
||||
/// <param name="httpClient">HttpClient (регистрируется в DI; таймаут управляется на попытку).</param>
|
||||
public LlmHttpClient(HttpClient httpClient)
|
||||
: this(httpClient, OpenAiCallTimeout, AnthropicCallTimeout)
|
||||
{
|
||||
}
|
||||
|
||||
// Создаёт HTTP-клиент с таймаутами для тестов (без реальной сети).
|
||||
// httpClient: HttpClient над фейковым обработчиком.
|
||||
// openAiCallTimeout: Таймаут OpenAI-совместимой попытки.
|
||||
// anthropicCallTimeout: Таймаут Anthropic-попытки.
|
||||
internal LlmHttpClient(HttpClient httpClient, TimeSpan openAiCallTimeout, TimeSpan anthropicCallTimeout)
|
||||
{
|
||||
_httpClient = httpClient;
|
||||
_openAiCallTimeout = openAiCallTimeout;
|
||||
_anthropicCallTimeout = anthropicCallTimeout;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Выполняет один вызов модели по выбранной схеме API (Ruling 5).
|
||||
/// </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,
|
||||
string systemPrompt,
|
||||
string userText,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
TimeSpan timeout = config.IsAnthropic ? _anthropicCallTimeout : _openAiCallTimeout;
|
||||
using var timeoutSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
timeoutSource.CancelAfter(timeout);
|
||||
|
||||
try
|
||||
{
|
||||
using HttpRequestMessage request = config.IsAnthropic
|
||||
? BuildAnthropicRequest(config, systemPrompt, userText)
|
||||
: BuildOpenAiRequest(config, systemPrompt, userText);
|
||||
|
||||
using HttpResponseMessage response = await _httpClient
|
||||
.SendAsync(request, timeoutSource.Token)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return await ReadResponseAsync(response, config.IsAnthropic, timeoutSource.Token).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw new LlmHttpException($"Таймаут вызова ИИ-провайдера: {timeout.TotalSeconds:0} с");
|
||||
}
|
||||
catch (HttpRequestException httpError)
|
||||
{
|
||||
throw new LlmHttpException($"Сетевая ошибка вызова ИИ-провайдера: {httpError.Message}", httpError);
|
||||
}
|
||||
}
|
||||
|
||||
// Собирает запрос OpenAI-совместимого чата: {base}/chat/completions, Bearer при заданном ключе,
|
||||
// тело 1:1 ai.py L126–139 (messages system/user, temperature 0.2, max_tokens 8000).
|
||||
// config: Конфиг провайдера.
|
||||
// systemPrompt: Системный промпт.
|
||||
// userText: Пользовательское сообщение.
|
||||
private static HttpRequestMessage BuildOpenAiRequest(LlmConfig config, string systemPrompt, string userText)
|
||||
{
|
||||
var request = new HttpRequestMessage(HttpMethod.Post, NormalizeBaseUrl(config.BaseUrl) + OpenAiCompletionsPath);
|
||||
if (!string.IsNullOrEmpty(config.ApiKey))
|
||||
{
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", config.ApiKey);
|
||||
}
|
||||
|
||||
var body = new JsonObject
|
||||
{
|
||||
["model"] = config.Model,
|
||||
["messages"] = new JsonArray(
|
||||
ChatMessage("system", systemPrompt),
|
||||
ChatMessage("user", userText)),
|
||||
["temperature"] = Temperature,
|
||||
["max_tokens"] = MaxResponseTokens,
|
||||
};
|
||||
|
||||
request.Content = JsonBody(body);
|
||||
return request;
|
||||
}
|
||||
|
||||
// Собирает запрос Anthropic Messages API: {base}/v1/messages, x-api-key + anthropic-version,
|
||||
// тело 1:1 ai.py L155–167 (system отдельным полем, messages=[user]).
|
||||
// config: Конфиг провайдера.
|
||||
// systemPrompt: Системный промпт.
|
||||
// userText: Пользовательское сообщение.
|
||||
private static HttpRequestMessage BuildAnthropicRequest(LlmConfig config, string systemPrompt, string userText)
|
||||
{
|
||||
var request = new HttpRequestMessage(HttpMethod.Post, NormalizeBaseUrl(config.BaseUrl) + AnthropicMessagesPath);
|
||||
request.Headers.TryAddWithoutValidation(AnthropicApiKeyHeader, config.ApiKey ?? string.Empty);
|
||||
request.Headers.TryAddWithoutValidation(AnthropicVersionHeader, AnthropicVersionValue);
|
||||
|
||||
var body = new JsonObject
|
||||
{
|
||||
["model"] = config.Model,
|
||||
["max_tokens"] = MaxResponseTokens,
|
||||
["system"] = systemPrompt,
|
||||
["messages"] = new JsonArray(ChatMessage("user", userText)),
|
||||
};
|
||||
|
||||
request.Content = JsonBody(body);
|
||||
return request;
|
||||
}
|
||||
|
||||
// Читает ответ HTTP: статус ≠ 2xx или неожиданная форма — LlmHttpException (повод
|
||||
// для ретрая); иначе текст ответа + usage из тела (см. парсеры стилей).
|
||||
// response: Ответ провайдера.
|
||||
// isAnthropic: Стиль ответа (Anthropic vs OpenAI-совместимый).
|
||||
// cancellationToken: Токен отмены попытки.
|
||||
private static async Task<ProviderChatResult> ReadResponseAsync(
|
||||
HttpResponseMessage response,
|
||||
bool isAnthropic,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
throw new LlmHttpException(
|
||||
$"ИИ-провайдер вернул HTTP {(int)response.StatusCode} {response.ReasonPhrase}");
|
||||
}
|
||||
|
||||
string body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
return isAnthropic ? ReadAnthropicBody(body) : ReadOpenAiBody(body);
|
||||
}
|
||||
|
||||
// Разбирает OpenAI-совместимый ответ: choices[0].message.content (+ usage; ai.py L143–152).
|
||||
// body: Тело ответа.
|
||||
private static ProviderChatResult ReadOpenAiBody(string body)
|
||||
{
|
||||
JsonObject? payload = ParseObjectOrThrow(body);
|
||||
JsonArray? choices = payload["choices"] as JsonArray;
|
||||
if (choices is null || choices.Count == 0)
|
||||
{
|
||||
// Тип сбоя без содержимого тела (замечание code-review: тело ответа наружу/в лог не уходит).
|
||||
throw UnexpectedApiResponse("пустой или отсутствующий список choices");
|
||||
}
|
||||
|
||||
// choices[i] — объект выбора {message, finish_reason, …}; текст — в message.content.
|
||||
JsonObject? message = (choices[0] as JsonObject)?["message"] as JsonObject;
|
||||
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)
|
||||
{
|
||||
JsonObject? payload = ParseObjectOrThrow(body);
|
||||
|
||||
var text = new StringBuilder();
|
||||
if (payload["content"] is JsonArray contentBlocks)
|
||||
{
|
||||
foreach (JsonNode? blockNode in contentBlocks)
|
||||
{
|
||||
if (blockNode is JsonObject block)
|
||||
{
|
||||
text.Append(ReadStringField(block, "text"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new ProviderChatResult(text.ToString(), ReadAnthropicUsage(payload["usage"]));
|
||||
}
|
||||
|
||||
// Возвращает usage OpenAI-совместимого ответа (prompt/completion/total_tokens) либо null.
|
||||
// usageNode: Узел usage ответа.
|
||||
private static ProviderUsage? ReadOpenAiUsage(JsonNode? usageNode)
|
||||
{
|
||||
if (usageNode is not JsonObject usage)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
int? promptTokens = ReadIntField(usage, "prompt_tokens");
|
||||
int? completionTokens = ReadIntField(usage, "completion_tokens");
|
||||
int? totalTokens = ReadIntField(usage, "total_tokens");
|
||||
return promptTokens is null || completionTokens is null || totalTokens is null
|
||||
? null
|
||||
: new ProviderUsage(promptTokens.Value, completionTokens.Value, totalTokens.Value);
|
||||
}
|
||||
|
||||
// Возвращает usage Anthropic-ответа (input/output → total = сумма) либо null.
|
||||
// usageNode: Узел usage ответа.
|
||||
private static ProviderUsage? ReadAnthropicUsage(JsonNode? usageNode)
|
||||
{
|
||||
if (usageNode is not JsonObject usage)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
int? inputTokens = ReadIntField(usage, "input_tokens");
|
||||
int? outputTokens = ReadIntField(usage, "output_tokens");
|
||||
return inputTokens is null || outputTokens is null
|
||||
? null
|
||||
: new ProviderUsage(inputTokens.Value, outputTokens.Value, inputTokens.Value + outputTokens.Value);
|
||||
}
|
||||
|
||||
// Читает тело как JSON-объект; при не-JSON/не-объекте — исключение (неожиданный ответ API).
|
||||
// body: Тело ответа.
|
||||
private static JsonObject ParseObjectOrThrow(string body)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (JsonNode.Parse(body) is JsonObject payload)
|
||||
{
|
||||
return payload;
|
||||
}
|
||||
}
|
||||
catch (System.Text.Json.JsonException)
|
||||
{
|
||||
// Ниже — общий текст «неожиданный ответ API».
|
||||
}
|
||||
|
||||
throw UnexpectedApiResponse("тело не является JSON-объектом");
|
||||
}
|
||||
|
||||
// Собирает текст ошибки неожиданного ответа: только тип/причина, без содержимого тела (Ruling 13).
|
||||
// failureKind: Короткая причина (без тела ответа и секретов).
|
||||
private static LlmHttpException UnexpectedApiResponse(string failureKind)
|
||||
=> new($"Неожиданный ответ ИИ-провайдера: {failureKind}");
|
||||
|
||||
// Создаёт сообщение чата {role, content} (формат обоих API).
|
||||
// role: Роль сообщения.
|
||||
// content: Текст сообщения.
|
||||
private static JsonObject ChatMessage(string role, string content)
|
||||
=> new()
|
||||
{
|
||||
["role"] = role,
|
||||
["content"] = content,
|
||||
};
|
||||
|
||||
// Создаёт JSON-содержимое запроса (application/json).
|
||||
// body: Объект тела запроса.
|
||||
private static StringContent JsonBody(JsonObject body)
|
||||
=> new(body.ToJsonString(), Encoding.UTF8, "application/json");
|
||||
|
||||
// Читает строковое поле объекта (null — поля нет/не строка).
|
||||
// parent: Объект ответа.
|
||||
// fieldName: Имя поля.
|
||||
private static string? ReadStringField(JsonObject? parent, string fieldName)
|
||||
{
|
||||
if (parent is null || parent[fieldName] is not JsonValue value)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return value.TryGetValue<string>(out string? result) ? result : null;
|
||||
}
|
||||
|
||||
// Читает целочисленное поле объекта (null — поля нет/не число).
|
||||
// parent: Объект ответа.
|
||||
// fieldName: Имя поля.
|
||||
private static int? ReadIntField(JsonObject parent, string fieldName)
|
||||
{
|
||||
if (parent[fieldName] is not JsonValue value || !value.TryGetValue<int>(out int result))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Убирает хвостовые «/» базового URL (как ai.py L90: rstrip("/")).
|
||||
// baseUrl: Базовый URL из конфига.
|
||||
private static string NormalizeBaseUrl(string baseUrl) => baseUrl.TrimEnd('/');
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
namespace Deal.Ai.Llm;
|
||||
|
||||
/// <summary>
|
||||
/// Ошибка одной HTTP-попытки вызова провайдера (план Task 7): сетевой сбой, таймаут, HTTP-ошибка
|
||||
/// или неожиданная форма ответа API. Обрабатывается в <see cref="ProviderCaller"/> как повод для
|
||||
/// ретрая; текст внутренний (ключи/секреты и тело ответа в него не попадают — Ruling 13).
|
||||
/// </summary>
|
||||
public sealed class LlmHttpException : Exception
|
||||
{
|
||||
/// <summary>
|
||||
/// Создаёт ошибку HTTP-попытки с текстом причины.
|
||||
/// </summary>
|
||||
/// <param name="message">Краткое описание (без ключей и секретов).</param>
|
||||
public LlmHttpException(string message)
|
||||
: base(message)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Создаёт ошибку HTTP-попытки с текстом причины и внутренним исключением.
|
||||
/// </summary>
|
||||
/// <param name="message">Краткое описание (без ключей и секретов).</param>
|
||||
/// <param name="innerException">Исключение-источник (транспортная ошибка).</param>
|
||||
public LlmHttpException(string message, Exception innerException)
|
||||
: base(message, innerException)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
namespace Deal.Ai.Llm;
|
||||
|
||||
/// <summary>
|
||||
/// Политика ретраев вызова LLM (план Task 7; ai.py chat_json L96–117): max_retries=2 → всего 3
|
||||
/// попытки с нарастающими паузами 0.8 с и 2 с между ними. Разовый сбой (перегрузка API, пустой/
|
||||
/// не-JSON ответ) не должен превращаться в «ИИ недоступен» без повторных попыток.
|
||||
/// </summary>
|
||||
public static class LlmRetryPolicy
|
||||
{
|
||||
/// <summary>
|
||||
/// Число дополнительных попыток после первой (всего — <see cref="AttemptCount"/>).
|
||||
/// </summary>
|
||||
public const int RetryCount = 2;
|
||||
|
||||
/// <summary>
|
||||
/// Общее число попыток вызова.
|
||||
/// </summary>
|
||||
public const int AttemptCount = RetryCount + 1;
|
||||
|
||||
/// <summary>
|
||||
/// Паузы между попытками: 0.8 с (после 1-й) и 2 с (после 2-й).
|
||||
/// </summary>
|
||||
public static readonly IReadOnlyList<TimeSpan> RetryDelays =
|
||||
[
|
||||
TimeSpan.FromMilliseconds(800),
|
||||
TimeSpan.FromSeconds(2),
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace Deal.Ai.Llm;
|
||||
|
||||
/// <summary>
|
||||
/// Итоговая оценка токенов вызова для gRPC-ответа (Ruling 5): берётся из usage API-ответа
|
||||
/// провайдера, при его отсутствии оценивается по символам (≈chars/4). Ядро копит значения
|
||||
/// в tenant-KV aiTokenUsage.
|
||||
/// </summary>
|
||||
/// <param name="PromptTokens">Токены запроса (система + пользователь).</param>
|
||||
/// <param name="CompletionTokens">Токены ответа модели.</param>
|
||||
/// <param name="TotalTokens">Суммарно (по данным провайдера — как есть).</param>
|
||||
public sealed record LlmUsage(int PromptTokens, int CompletionTokens, int TotalTokens);
|
||||
@@ -0,0 +1,97 @@
|
||||
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).
|
||||
/// </summary>
|
||||
public sealed class ProviderCaller
|
||||
{
|
||||
private readonly IProviderClient _client;
|
||||
private readonly ILogger<ProviderCaller> _logger;
|
||||
private readonly Func<TimeSpan, CancellationToken, Task> _retryDelayAsync;
|
||||
|
||||
/// <summary>
|
||||
/// Создаёт оркестратор вызовов с заданной функцией паузы между попытками.
|
||||
/// </summary>
|
||||
/// <param name="client">HTTP-клиент одной попытки (в тестах — фейк).</param>
|
||||
/// <param name="retryDelayAsync">Функция паузы между попытками (в тестах — мгновенная).</param>
|
||||
/// <param name="logger">Логгер (ключи и текст промптов в логи не попадают).</param>
|
||||
public ProviderCaller(
|
||||
IProviderClient client,
|
||||
Func<TimeSpan, CancellationToken, Task> retryDelayAsync,
|
||||
ILogger<ProviderCaller> logger)
|
||||
{
|
||||
_client = client;
|
||||
_retryDelayAsync = retryDelayAsync;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Вызывает модель по системному и пользовательскому сообщениям и извлекает JSON-объект ответа.
|
||||
/// </summary>
|
||||
/// <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(
|
||||
LlmConfig config,
|
||||
string systemPrompt,
|
||||
string userText,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
string promptText = systemPrompt + userText;
|
||||
string? lastModelText = null;
|
||||
ProviderUsage? lastUsage = null;
|
||||
LlmHttpException? lastHttpError = null;
|
||||
|
||||
for (int attempt = 0; attempt < LlmRetryPolicy.AttemptCount; attempt++)
|
||||
{
|
||||
try
|
||||
{
|
||||
ProviderChatResult result = await _client
|
||||
.ChatAsync(config, systemPrompt, userText, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
lastModelText = result.Text;
|
||||
lastUsage = result.Usage;
|
||||
|
||||
JsonObject? json = JsonExtractor.TryExtractObject(result.Text);
|
||||
if (json is not null)
|
||||
{
|
||||
return new LlmCallResult(json, TokenEstimator.Resolve(result.Usage, promptText, result.Text));
|
||||
}
|
||||
}
|
||||
catch (LlmHttpException httpError)
|
||||
{
|
||||
lastHttpError = httpError;
|
||||
}
|
||||
|
||||
if (attempt < LlmRetryPolicy.RetryCount)
|
||||
{
|
||||
await _retryDelayAsync(LlmRetryPolicy.RetryDelays[attempt], cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
_logger.LogWarning(
|
||||
"ИИ ({Provider}) не ответил корректно после {Attempts} попыток: {Error}",
|
||||
config.DisplayName,
|
||||
LlmRetryPolicy.AttemptCount,
|
||||
lastHttpError?.Message ?? "пустой ответ");
|
||||
throw new LlmCallException(LlmCallFailureKind.ProviderUnavailable, config.DisplayName);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace Deal.Ai.Llm;
|
||||
|
||||
/// <summary>
|
||||
/// Результат одной успешной HTTP-попытки вызова модели (текст ответа + usage API).
|
||||
/// </summary>
|
||||
/// <param name="Text">Текст ответа модели (может быть не-JSON — разбор в <see cref="JsonExtractor"/>).</param>
|
||||
/// <param name="Usage">Usage из API-ответа провайдера; null — провайдер его не вернул (оценка по символам).</param>
|
||||
public sealed record ProviderChatResult(string Text, ProviderUsage? Usage);
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace Deal.Ai.Llm;
|
||||
|
||||
/// <summary>
|
||||
/// Usage токенов из API-ответа провайдера (Ruling 5). Поля не путать с <see cref="LlmUsage"/>:
|
||||
/// здесь «как отдал провайдер» (total берём как есть — у провайдера он может отличаться от суммы),
|
||||
/// финальную оценку/подстановку делает <see cref="TokenEstimator"/>.
|
||||
/// </summary>
|
||||
/// <param name="PromptTokens">Токены запроса (система + пользователь).</param>
|
||||
/// <param name="CompletionTokens">Токены ответа модели.</param>
|
||||
/// <param name="TotalTokens">Суммарно по данным провайдера (Anthropic: input + output).</param>
|
||||
public sealed record ProviderUsage(int PromptTokens, int CompletionTokens, int TotalTokens);
|
||||
@@ -0,0 +1,35 @@
|
||||
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 «берём как есть»), при отсутствии —
|
||||
/// оценка по длинам промпта и ответа.
|
||||
/// </summary>
|
||||
/// <param name="providerUsage">Usage из API-ответа (null — провайдер его не вернул).</param>
|
||||
/// <param name="promptText">Полный текст запроса (система + пользователь) для оценки.</param>
|
||||
/// <param name="completionText">Текст ответа модели для оценки.</param>
|
||||
public static LlmUsage Resolve(ProviderUsage? providerUsage, string promptText, string completionText)
|
||||
{
|
||||
if (providerUsage is not null)
|
||||
{
|
||||
return new LlmUsage(providerUsage.PromptTokens, providerUsage.CompletionTokens, providerUsage.TotalTokens);
|
||||
}
|
||||
|
||||
int promptTokens = EstimateTokens(promptText.Length);
|
||||
int completionTokens = EstimateTokens(completionText.Length);
|
||||
return new LlmUsage(promptTokens, completionTokens, promptTokens + completionTokens);
|
||||
}
|
||||
|
||||
// Оценка токенов по числу символов: ≈ceil(chars/4).
|
||||
// charCount: Число символов текста.
|
||||
private static int EstimateTokens(int charCount)
|
||||
=> (charCount + EstimatedCharsPerToken - 1) / EstimatedCharsPerToken;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
// 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;
|
||||
using Deal.Grpc.Hosting;
|
||||
|
||||
// Порт по умолчанию — 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().
|
||||
WebApplication app = AiServiceHost.Create(
|
||||
grpcPort,
|
||||
configureBuilder: builder =>
|
||||
{
|
||||
DealLogging.Configure(builder, aiProcessName);
|
||||
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 не должны давать
|
||||
// «тихого» plaintext в Production; Development (и прочие не-prod окружения) — как раньше.
|
||||
GrpcHostEnvironment.RequireMtlsInProduction(mtlsOptions);
|
||||
|
||||
app.Logger.LogInformation(
|
||||
"ai-service стартует: gRPC {Transport} 0.0.0.0:{Port} (health /grpc.health.v1.Health/Check)",
|
||||
mtlsOptions.Enabled ? "mTLS (TLS + клиентский сертификат)" : "plaintext + service-token",
|
||||
grpcPort);
|
||||
|
||||
await app.RunAsync();
|
||||
@@ -0,0 +1,11 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<AnalysisLevel>latest</AnalysisLevel>
|
||||
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,46 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<!--
|
||||
Deal.Proto — общий проект кодогенерации .proto-контрактов этапа 6 (Ruling 1).
|
||||
|
||||
Компилирует все три контракта (telegram/ml/ai.proto) через Grpc.Tools с
|
||||
генерацией и клиентской, и серверной стороны (GrpcServices="Both"): сборка
|
||||
этого проекта — первый прогон кодогенерации и валидации .proto до создания
|
||||
сервисных проектов (Task 2–4). Сгенерированные типы живут в пространствах
|
||||
имён из option csharp_namespace (Deal.Grpc.Telegram/Deal.Grpc.Ml/Deal.Grpc.Ai)
|
||||
и переиспользуются core и сервисами через ProjectReference.
|
||||
|
||||
Сборка: 0 warnings / 0 errors (TreatWarningsAsErrors), как остальные sln.
|
||||
-->
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<AnalysisLevel>latest</AnalysisLevel>
|
||||
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
|
||||
<AssemblyName>Deal.Proto</AssemblyName>
|
||||
<RootNamespace>Deal.Proto</RootNamespace>
|
||||
<GenerateDocumentationFile>false</GenerateDocumentationFile>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Кодогенерация: protoc + плагин gRPC C# (только для сборки). -->
|
||||
<PackageReference Include="Grpc.Tools" Version="2.83.0" PrivateAssets="All" />
|
||||
<!-- Рантайм сгенерированных сообщений (MessageParser/ByteString и т.п.). -->
|
||||
<PackageReference Include="Google.Protobuf" Version="3.35.1" />
|
||||
<!-- Типы gRPC C#-стабов (Grpc.Core.*): нужны сгенерированному коду при
|
||||
компиляции; транспорты (Grpc.Net.Client/Grpc.AspNetCore) подключают
|
||||
проекты-потребители по месту (Ruling 1, список NuGet этапа). -->
|
||||
<PackageReference Include="Grpc.Core.Api" Version="2.83.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Контракты этапа 6; файлы лежат рядом с проектом (src/contracts). -->
|
||||
<Protobuf Include="telegram.proto" GrpcServices="Both" />
|
||||
<Protobuf Include="ml.proto" GrpcServices="Both" />
|
||||
<Protobuf Include="ai.proto" GrpcServices="Both" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,204 @@
|
||||
# Контракты gRPC этапа 6 (`src/contracts`)
|
||||
|
||||
Контракты между ядром Deal и сервисами telegram/ml/ai (отдельные процессы).
|
||||
Источники: дизайн-док §6.2 (L145–152), план «Дейл — Этап 6: Сервисы …»
|
||||
(Task 1, Rulings 1/3/5/7), api-map §3.3/§3.7/§3.8/§4.8/§4.9/§4.10, прототип
|
||||
`backend/app/services/*.py` и `mlservice/model.py`.
|
||||
|
||||
Контракты — **единственный «язык» между процессами** (Ruling 1): сервисы не
|
||||
делят с ядром ничего, кроме этих `.proto` и NuGet.
|
||||
|
||||
| Файл | Пакет | `csharp_namespace` | Сервисы |
|
||||
|---|---|---|---|
|
||||
| `telegram.proto` | `deal.telegram.v1` | `Deal.Grpc.Telegram` | `TelegramService`, `IngressService` |
|
||||
| `ml.proto` | `deal.ml.v1` | `Deal.Grpc.Ml` | `MlService` |
|
||||
| `ai.proto` | `deal.ai.v1` | `Deal.Grpc.Ai` | `AiService` |
|
||||
|
||||
## Кодогенерация
|
||||
|
||||
- `Deal.Proto.csproj` — общий classlib: компилирует все три `.proto` через
|
||||
`Grpc.Tools` (`<Protobuf Include=... GrpcServices="Both"/>`), т.е. генерирует
|
||||
и клиент, и сервер каждого сервиса в одном проходе (неиспользуемая сторона
|
||||
игнорируется потребителем). Сборка проекта = валидация `.proto` (protoc) и
|
||||
первый прогон кодогенерации (Task 1); Task 2–4 подключают файлы к сервисам
|
||||
`ProjectReference`-ом либо `<Protobuf Include="..\..\contracts\X.proto">`.
|
||||
- Пакеты: `Grpc.Tools` (PrivateAssets), `Google.Protobuf`, `Grpc.Core.Api`.
|
||||
Транспорты — по месту: `Grpc.AspNetCore` (серверы), `Grpc.Net.Client`/клиенты.
|
||||
- Сгенерированные типы: `obj/…/{Telegram,TelegramGrpc,Ml,MlGrpc,Ai,AiGrpc}.cs`,
|
||||
пространства имён из `option csharp_namespace`.
|
||||
- Проекты-потребители: core (клиенты ML/AI; telegram-клиент-гейт + сервер
|
||||
ингресса), telegram-service/ml-service/ai-service — добавляются в Task 2–4+.
|
||||
|
||||
## Metadata (все RPC, обязательны)
|
||||
|
||||
| Заголовок | Значение | Отказ |
|
||||
|---|---|---|
|
||||
| `tenant-id` | id тенанта (строка). **Единственный** источник принадлежности; полю в теле не доверяем | отсутствует → `UNAUTHENTICATED` (для ингресса core: `SetTenant` из metadata) |
|
||||
| `service-token` | общий токен сервисов (env `DEAL_SERVICE_TOKEN`, общий в compose) | пустой/неверный → `UNAUTHENTICATED` |
|
||||
|
||||
Интерцептор `service-token` — общий шаблон в каждом процессе (Ruling 1/2);
|
||||
каждый сервис дополнительно проверяет принадлежность по своей модели (сессия/
|
||||
модель тенанта есть, иначе `NOT_FOUND`/`FAILED_PRECONDITION`).
|
||||
|
||||
## Коды ошибок (общие)
|
||||
|
||||
Ошибки домена — gRPC-статусы, `detail` = текст причины 1:1 с прототипом
|
||||
(строки «Telegram не подключён», «Неверный код», «Код истёк — запросите новый»,
|
||||
«Неверный облачный пароль», «ИИ (имя) не ответил корректно — …» и т.д.):
|
||||
|
||||
| Статус | Когда |
|
||||
|---|---|
|
||||
| `INVALID_ARGUMENT` | невалидный ввод/код/пароль/username, пустой текст |
|
||||
| `NOT_FOUND` | диалог/сущность/сессия тенанта не найдены |
|
||||
| `FAILED_PRECONDITION` | операция невозможна в текущей фазе (нет сессии и т.п.) |
|
||||
| `RESOURCE_EXHAUSTED` | FloodWait Telegram (detail начинается с префикса `flood`) |
|
||||
| `UNAVAILABLE` | недоступен Telegram/LLM-провайдер/хранилище модели (безопасный повтор/фолбэк) |
|
||||
| `UNAUTHENTICATED` | неверный/отсутствующий `service-token` |
|
||||
|
||||
«Мягкие» сценарии НЕ являются ошибками RPC: Predict неготовой модели («не
|
||||
уверен»), ReadForEval без истории (`ok=false, error="no_history"`), сброс с
|
||||
`ok=false,error`, `filter` с решением pass/reason.
|
||||
|
||||
## Deadlines (клиент)
|
||||
|
||||
| Сфера | Рекомендация | Обоснование |
|
||||
|---|---|---|
|
||||
| telegram: GetStatus/SetMonitor/SetMonitorAll/Logout | 10 с | локальная сеть/статус |
|
||||
| telegram: StartPhone/StartQr/SendCode/SendPassword/Search/GetInfo/ReadRecent/ReadForEval/Join/Leave | 60 с | сетевые операции Telegram (паузы анти-бана 2–4 с поиск) |
|
||||
| telegram: RefreshDialogs/Backfill | 120 с | iter_dialogs 500; backfill 10 сообщ. × 1.5–3 с + 3–6 с/диалог |
|
||||
| ingress: PushMessage/SyncDialogs/ReportStatus | 10 с | локальная сеть; упущенное догоняет realtime-sweep |
|
||||
| ml: Predict | 5 с | локальная модель |
|
||||
| ml: Status/Reset | 10 с | локально |
|
||||
| ml: TrainBatch | 30 с | батч ≤100, 1 транзакция |
|
||||
| ai: все RPC | 120 с | провайдер 90/60 с + ретраи 0.8/2 с |
|
||||
|
||||
---
|
||||
|
||||
## `telegram.proto`
|
||||
|
||||
Два сервиса: команды ядра → сервис (`TelegramService`) и поток сервис → ядро
|
||||
(`IngressService`, gRPC-сервер в Deal.Api :5082, Ruling 7).
|
||||
|
||||
### Словари значений
|
||||
|
||||
- `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, в каталоге форум приходит
|
||||
как group). Discovery-коды `channel/group/forum` ядро получает из kind+is_forum.
|
||||
- `error`/`qr_url`/`account` — `optional` (presence): пустое = нет ошибки/URL.
|
||||
|
||||
### Маппинги на HTTP-контракт (заметка для T13/14, код не меняется)
|
||||
|
||||
Внутренний канон контракта `kind` — EN (`channel/group/forum/chat`). Граница
|
||||
HTTP-эндпоинтов (api-map §4.8 L349, замороженный контракт фронта) НЕ 1:1:
|
||||
- `GET /api/tg/dialogs` → `item.type` остаётся **русским** («канал»/«группа»/
|
||||
«чат»), как в прототипе (`refresh_dialogs`/`list_dialogs`): на границе
|
||||
эндпоинта каналов (T13/14) нужен обратный маппинг EN → RU
|
||||
(channel→«канал», group/forum→«группа», chat→«чат»; forum в списке диалогов
|
||||
не встречается — каталог приносит его как group).
|
||||
- Discovery: `candidate.type` (`kind`) — **EN** (`channel/group/forum`), как в
|
||||
прототипе (db.py L162–166, `_kind_code`); маппинг на границе НЕ нужен.
|
||||
|
||||
### TelegramService (ядро — клиент, сервис — сервер)
|
||||
|
||||
| RPC | Запрос | Ответ | Ошибки / примечания |
|
||||
|---|---|---|---|
|
||||
| `GetStatus` | `GetStatusRequest` (пуст) | `GetStatusReply{phase,connected,listener,account,error?,qr_url?}` | нет сессии → FAILED_PRECONDITION «Telegram не подключён». live-поля для `GET /api/tg/status`; monitored/keysSet ядро считает само (Ruling 8) |
|
||||
| `StartPhone` | `StartPhoneRequest{phone, api_id, api_hash}` | `StartPhoneReply{phase}` | ключи tgKeys передаёт ядро (Ruling 3); нет ключей → INVALID_ARGUMENT «Сначала сохраните…»; ответ фаза `code` |
|
||||
| `StartQr` | `StartQrRequest{api_id, api_hash}` | `StartQrReply{phase, qr_url}` | фаза `qr` + url; уже авторизован → `ready`, url пуст |
|
||||
| `SendCode` | `SendCodeRequest{code}` | `SendCodeReply{phase}` | «Неверный код»/«Код истёк…» → INVALID_ARGUMENT; 2FA → фаза `password` |
|
||||
| `SendPassword` | `SendPasswordRequest{password}` | `SendPasswordReply{phase}` | «Неверный облачный пароль» → INVALID_ARGUMENT; ответ `ready` |
|
||||
| `Logout` | `LogoutRequest` (пуст) | `LogoutReply{ok}` | отключение + удаление сессии тенанта |
|
||||
| `RefreshDialogs` | `RefreshDialogsRequest` (пуст) | `RefreshDialogsReply{entries: DialogEntry[]}` | каталог диалогов; применять ядру через Ingress.SyncDialogs-семантику (`SyncFromTelegram`) |
|
||||
| `SetMonitor` | `SetMonitorRequest{dialog_id, enabled}` | `SetMonitorReply{ok, enabled}` | зеркало monitored в сервисе; первый backfill запускает ядро |
|
||||
| `SetMonitorAll` | `SetMonitorAllRequest{enabled}` | `SetMonitorAllReply{ok, count, enabled}` | count = диалогов в каталоге |
|
||||
| `Backfill` | `BackfillRequest{dialog_id, force}` | `BackfillReply{processed}` | последние ~10 сообщений → поток PushMessage; паузы 1.5–3 с/сообщ. + mark-as-read; force = «Перечитать» |
|
||||
| `ReadRecent` | `ReadRecentRequest{dialog_id, limit 1..50}` | `ReadRecentReply{messages: PreviewMessage[]}` | свежие из TG; lead и фолбэк на БД — в ядре |
|
||||
| `Search` | `SearchRequest{query, limit}` | `SearchReply{results: DialogEntry[]}` | пауза анти-бана внутри сервиса; личные/ботов отсеивает ядро (Ruling 10) |
|
||||
| `GetInfo` | `GetInfoRequest{dialog_id}` | `GetInfoReply{info: ChannelInfo}` | ChannelInfo{id,name,username,kind,hue,participants?,is_forum}; сбои full_chat не роняют RPC |
|
||||
| `ReadForEval` | `ReadForEvalRequest{dialog_id, limit}` | `ReadForEvalReply{ok, error?, messages: EvalMessage[]}` | форумы — по темам; история скрыта → ok=false,error="no_history" (НЕ ошибка RPC) |
|
||||
| `Join` | `JoinRequest{username}` | `JoinReply{ok}` | FloodWait → RESOURCE_EXHAUSTED (`flood`); ручной join вне квот |
|
||||
| `Leave` | `LeaveRequest{dialog_id}` | `LeaveReply{ok}` | нет членства → NOT_FOUND |
|
||||
|
||||
Общие сообщения:
|
||||
- `DialogEntry{id,name,username,kind,hue}` — каталог/поиск (id подписанный:
|
||||
каналы `-100…`, группы `-…`, личные `+…`; hue — палитра DIALOG_HUES).
|
||||
- `ChannelInfo` = DialogEntry + `participants?` + `is_forum`.
|
||||
- `PreviewMessage{id(text), text, time(ms)}` — превью (api-map §4.8 L351; `id`
|
||||
строкой: int-сообщения TG и фолбэк `m_<dialog>_<msg>`).
|
||||
- `EvalMessage{id(int64), text, date_ms, topic_id?, topic_title?}` — выборка
|
||||
оценки кандидата (`_discovery_message_item`).
|
||||
|
||||
### IngressService (сервис — клиент, ядро — сервер в Deal.Api, порт `GRPC_INGRESS_PORT` :5082)
|
||||
|
||||
| RPC | Запрос | Ответ | Примечания |
|
||||
|---|---|---|---|
|
||||
| `PushMessage` | `PushMessageRequest{dialog_id, channel_name, channel_handle, channel_hue, msg_id?, text, msg_at?}` | `PushMessageReply{accepted, duplicate}` | 1:1 QueuedMessage/demo-ingest: EnqueueAsync + превью в TgMessages; дубль dialog+msgId → duplicate=true, очередь не растёт; нет msg_at → ядро подставит now; hue считает сервис |
|
||||
| `SyncDialogs` | `SyncDialogsRequest{entries: DialogEntry[]}` | `SyncDialogsReply{monitored_ids[]}` | ядро: SyncFromTelegram (autoMonitorNew/обновление/удаление); ответ — актуальный зеркальный список monitored сервиса |
|
||||
| `ReportStatus` | `ReportStatusRequest{phase,connected,listener,account,error?,qr_url?}` | `ReportStatusReply{ok}` | ядро: KV tgStatus/tgAccount + SSE system_status/тосты на переходах фаз |
|
||||
|
||||
---
|
||||
|
||||
## `ml.proto`
|
||||
|
||||
`MlService` — пул инкрементальных наивно-байесовских моделей per-tenant
|
||||
(файл `data/ml/<tenantId>.sqlite`). Поля 1:1 с `MlPredictResultDto`/
|
||||
`MlServiceStatusDto` и model.py.
|
||||
|
||||
| RPC | Запрос | Ответ | Примечания |
|
||||
|---|---|---|---|
|
||||
| `Predict` | `PredictRequest{text}` | `PredictReply{take,label?,scores: map<string,double>,hits,ready,margin?,terms[],type?}` | «не уверен» при неготовой модели/пустом тексте — не ошибка; scores ≤5 лучших (round 3); margin адаптивный 0.9/0.7/0.5/0.35; terms ≤8; type — t:hire/t:order |
|
||||
| `Status` | `StatusRequest` (пуст) | `StatusReply{ready,classes: map<string,double>,learned,eval: ModelEval}` | classes «label → вес» round 2; модель создаётся лениво |
|
||||
| `Reset` | `ResetRequest` (пуст) | `ResetReply{ok, error?}` | очистка classes/terms/eval_log + пересоздание файла; ok=false — мягкая ошибка (ядро чистит ml_outbox только при ok) |
|
||||
| `TrainBatch` | `TrainBatchRequest{items: TrainExample[]}` | `TrainBatchReply{learned}` | 1 транзакция + пакетные вставки терминов (= learn_batch); learned = применено примеров |
|
||||
|
||||
Сообщения:
|
||||
- `TypeDecision{take,label("hire"|"order"),value("t:hire"|"t:order"),margin}` —
|
||||
решение о типе заявки.
|
||||
- `ModelEval{count,correct,accuracy}` — окно самооценки (EVAL_WINDOW последних
|
||||
подтверждённых решений).
|
||||
- `TrainExample{text,label,delta}` — строка обучения (ml_outbox): delta 1.0
|
||||
пользователь / −1.0 снять / 0.4–0.6 ИИ-правила.
|
||||
|
||||
Пороги и константы (Ruling 4): MIN_TOTAL 20, MIN_WINNER 6, MIN_WINNER_SPAM 4,
|
||||
MIN_HITS 2, MARGIN 0.9, типы `t:*` с MIN_TYPE_WINNER 4 — живут в ml-service
|
||||
(реализация Task 5/6), в контракт не входят.
|
||||
|
||||
---
|
||||
|
||||
## `ai.proto`
|
||||
|
||||
`AiService` — фасад LLM-провайдеров без БД (Ruling 5). Ядро передаёт в теле
|
||||
каждого запроса заполненные промпты/контекст + конфиг активного провайдера
|
||||
(`provider_config`); сервис возвращает ответ модели + оценку токенов.
|
||||
|
||||
| RPC | Запрос | Ответ | Примечания |
|
||||
|---|---|---|---|
|
||||
| `Filter` | `FilterRequest{prompt, text, provider_config}` | `FilterReply{pass, reason?, usage}` | prompt = заполненный aiFilterPrompt (ядро); «фильтр не применялся» обрабатывает ядро до вызова |
|
||||
| `Classify` | `ClassifyRequest{system_prompt, user_context, provider_config}` | `ClassifyReply{ok, json?, usage}` | system_prompt = aiPrompt+cardPrompt, user_context = «Доски + примеры + Сообщение» (собирает ядро); json — сырой ответ модели строкой; строгий маппинг в карточку — ядро |
|
||||
| `GenerateKeywords` | `GenerateKeywordsRequest{description, provider_config}` | `GenerateKeywordsReply{keywords[], usage}` | фикс. промпт (routes L36–47); очистку `_clean_keywords` и мягкие ошибки делает ядро (Ruling 11) |
|
||||
| `EvaluateFit` | `EvaluateFitRequest{text, description, keywords[], provider_config}` | `EvaluateFitReply{fit, reason?, usage}` | промпт discovery_eval L50–54; ядро зовёт при aiEnabled, сбой → эвристика |
|
||||
|
||||
`provider_config` — конфиг активного провайдера на запрос (Ruling 5 «в теле
|
||||
каждого запроса»): ядро собирает эффективный конфиг (настройка `aiConfigs`
|
||||
тенанта хранит `{apiKey, baseUrl, model}` в camelCase, ключ шифруется AES-GCM;
|
||||
`apiStyle` — из каталога `AiProviders`) и передаёт в теле; сервис настроек не
|
||||
хранит. Форма — сообщение `ProviderConfig`:
|
||||
|
||||
| Поле | Обязательность | Описание |
|
||||
|---|---|---|
|
||||
| `provider_id` | да | Id провайдера (ключ `aiConfigs`/каталога: deepseek/openai/anthropic/ollama/lmstudio/custom…) |
|
||||
| `base_url` | да | Эффективный базовый URL API (`aiConfigs.baseUrl` или дефолт каталога) |
|
||||
| `api_key` | опц. | Ключ открытым текстом (расшифрован ядром); пуст у локальных провайдеров — заголовок не шлётся |
|
||||
| `model` | да | Активная модель (`aiConfigs.model` или первая из каталога) |
|
||||
| `api_style` | опц. | Стиль API: пуст — OpenAI-совместимый (`{base}/chat/completions`, Bearer); `"anthropic"` — Messages API (`{base}/v1/messages`, x-api-key + anthropic-version) |
|
||||
|
||||
Сообщения:
|
||||
- `Usage{prompt, completion, total}` — оценка токенов в каждом reply (из usage
|
||||
API-ответа; при отсутствии ≈chars/4; ядро копит в KV `aiTokenUsage`).
|
||||
|
||||
Ошибки: провайдер не ответил корректно после ретраев → `UNAVAILABLE` с detail
|
||||
«ИИ (имя) не ответил корректно — повторите попытку через несколько секунд».
|
||||
`ClassifyReply.ok=false` — ответ без разбираемого JSON (не RPC-ошибка); ядро
|
||||
трактует как «не разобрано» и использует локальный путь.
|
||||
@@ -0,0 +1,172 @@
|
||||
// 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;
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
// 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;
|
||||
}
|
||||
@@ -0,0 +1,453 @@
|
||||
// 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;
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
using Deal.Api.Events;
|
||||
using Deal.Modules.Kanban.Application;
|
||||
using Deal.Modules.Kanban.Application.Models;
|
||||
using Deal.Modules.Pipeline.Application;
|
||||
using Deal.Modules.Pipeline.Application.Models;
|
||||
|
||||
namespace Deal.Api;
|
||||
|
||||
/// <summary>
|
||||
/// Оркестратор ручного тика POST /api/admin/tick (план Tasks 10–11, Ruling 8/9; dashboard_routes.py admin_tick L327–337).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Api-слой объединяет сервисы модулей (Kanban тик правил хранения + Pipeline очистка отсева и pump +
|
||||
/// Projects проверка напоминаний «Отложено») и публикует SSE (Ruling 5/8/9 — публикации только из Api;
|
||||
/// модули остаются чистыми). Порядок 1:1 с прототипом:
|
||||
/// (1) <see cref="StorageTickService.TickAsync"/> — автоархив и очистки архива/корзины;
|
||||
/// (2) <see cref="PipelineProcessingService.PurgeExpiredAsync"/> — отсев старше 3 суток (tick_storage L485–493),
|
||||
/// результат вливается в storage.purgedRejected (Ruling 9);
|
||||
/// (3) SSE-тосты статистики (<see cref="StorageToastPublisher"/>, notify_tick_stats L496–504) — до pump, как в
|
||||
/// прототипе (L333);
|
||||
/// (4) проверка наступивших напоминаний <see cref="CardsService.CheckDueRemindersAsync"/> (admin_tick L334,
|
||||
/// check_reminders L264–282; план Task 11, Ruling 3): «выстрелившие» {id,title,containerId} помечены fired и
|
||||
/// публикуются SSE <c>reminder_due</c> (Ruling 8 — toast НЕ шлём, у фронта модалка ReminderNotice); сбой
|
||||
/// проверки НЕ роняет тик: лог + reminders ответа пуст;
|
||||
/// (5) <see cref="PipelineWorkerService.PumpOnceAsync"/> под общим воркер-гейтом тенанта (Task 10/11): pump
|
||||
/// одного тенанта выполняет либо ручной тик, либо фоновый цикл — при занятом гейте проход пропускается;
|
||||
/// сбой pump НЕ роняет тик: исключение логируется, pipeline ответа пуст ({} как при занятом локе прототипа
|
||||
/// L901–902), очередь остаётся до следующего тика/фонового цикла. Операция отмены (OCE) пробрасывается — запрос прерван;
|
||||
/// (6) SSE new_card по каждой созданной карточке (Ruling 8/9; полный CardDto, как publish из Api);
|
||||
/// (7) queue = строк очереди после pump (queue_len L337). Ответ — <see cref="AdminTickResultDto"/>.
|
||||
/// </remarks>
|
||||
/// <param name="storageTick">Тик правил хранения канбана (StorageTickService модуля Kanban).</param>
|
||||
/// <param name="processing">Очистка отсева и счётчики очереди (модуль Pipeline).</param>
|
||||
/// <param name="worker">Один проход pump по очереди входящих (модуль Pipeline).</param>
|
||||
/// <param name="reminders">Проверка наступивших напоминаний «Отложено» (CardsService, Ruling 3).</param>
|
||||
/// <param name="toastPublisher">Публикатор SSE-тостов статистики тика (общий с фоновым циклом Task 11).</param>
|
||||
/// <param name="broker">SSE-брокер канала тенанта (публикация reminder_due/new_card).</param>
|
||||
/// <param name="pumpGate">Общий воркер-гейт pump тенанта (singleton; общий с фоновым циклом Task 11).</param>
|
||||
/// <param name="logger">Логгер сбоя проверки напоминаний/pump (тик продолжается без этих веток).</param>
|
||||
public sealed class AdminTickOrchestrator(
|
||||
StorageTickService storageTick,
|
||||
PipelineProcessingService processing,
|
||||
PipelineWorkerService worker,
|
||||
CardsService reminders,
|
||||
StorageToastPublisher toastPublisher,
|
||||
SseBroker broker,
|
||||
PipelinePumpGate pumpGate,
|
||||
ILogger<AdminTickOrchestrator> logger)
|
||||
{
|
||||
// SSE-тип события новой карточки (Ruling 5; api.js слушает 'new_card').
|
||||
private const string NewCardEventType = "new_card";
|
||||
|
||||
// SSE-тип события «выстрелившего» напоминания «Отложено» (Ruling 8, api-map §2: {id,title,containerId}).
|
||||
private const string ReminderDueEventType = "reminder_due";
|
||||
|
||||
// Ключи счётчиков pump в pipeline-словаре ответа (1:1 со словарём _pump_unlocked python L921).
|
||||
private static readonly string[] PipelineCounterKeys =
|
||||
[
|
||||
"staged", "rulesStored", "mlStored", "mlDrop", "typeDrop", "aiStored", "aiDrop", "aiFail", "noBudget",
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// Выполняет один ручной тик тенанта: правила хранения + очистка отсева + напоминания + pump + SSE-публикации.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">Тенант-получатель (сессия запроса; канал SSE-публикаций).</param>
|
||||
/// <param name="ct">Токен отмены запроса.</param>
|
||||
/// <returns>Ответ {storage, reminders, pipeline, queue}; сбой проверки напоминаний/pump не выбрасывается наружу.</returns>
|
||||
public async Task<AdminTickResultDto> TickAsync(Guid tenantId, CancellationToken ct)
|
||||
{
|
||||
// (1) Тик правил хранения канбана (как этап 3; leads.py tick_storage L454–484).
|
||||
StorageTickStatsDto storage = await storageTick.TickAsync(ct);
|
||||
|
||||
// (2) Очистка отсева пайплайна: записи старше 3 суток — безвозвратно (tick_storage L485–486); счётчик
|
||||
// вливается в storage.purgedRejected (Ruling 9: ответ тика объединяет статистику, L488–493).
|
||||
int purgedRejected = await processing.PurgeExpiredAsync(ct);
|
||||
StorageTickStatsDto mergedStorage = storage with { PurgedRejected = purgedRejected };
|
||||
|
||||
// (3) Тосты статистики — до pump, как в прототипе (L333): очистка отсева видна, даже если pump упадёт.
|
||||
toastPublisher.PublishTickToasts(tenantId, mergedStorage);
|
||||
|
||||
// (4) Проверка наступивших напоминаний «Отложено» (admin_tick L334 → check_reminders L264–282; план
|
||||
// Task 11, Ruling 3): CheckDueAsync помечает due-строки fired и возвращает их {id,title,stage}. Сбой
|
||||
// проверки НЕ роняет тик: лог + reminders ответа пуст (очередь/хранение продолжают работать).
|
||||
IReadOnlyList<CardReminderDueDto> dueReminders;
|
||||
try
|
||||
{
|
||||
dueReminders = await reminders.CheckDueRemindersAsync(ct);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Запрос отменён — прерываем тик штатно (не «сбой проверки напоминаний»).
|
||||
throw;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogWarning(exception, "POST /api/admin/tick: проверка напоминаний не удалась — reminders ответа пуст");
|
||||
dueReminders = Array.Empty<CardReminderDueDto>();
|
||||
}
|
||||
|
||||
// SSE reminder_due по каждому «выстрелившему» напоминанию (Ruling 8: событие {id,title,stage}, toast НЕ
|
||||
// шлём — у фронта модалка ReminderNotice; без подписчиков канала публикация — no-op). После MarkFired
|
||||
// (внутри CheckDueAsync), как прототип L277–281 — публикуются уже «сработавшие» записи.
|
||||
foreach (CardReminderDueDto due in dueReminders)
|
||||
{
|
||||
broker.Publish(tenantId, ReminderDueEventType, due);
|
||||
}
|
||||
|
||||
// (5) Один проход pump под гейтом тенанта; сбой не роняет тик: pipeline={}, очередь дождётся
|
||||
// следующего тика/фонового цикла (Ruling 10; Task 11 — гейт общий с фоновым циклом).
|
||||
PipelinePumpResult? pump = await PumpOnceSafelyAsync(tenantId, ct);
|
||||
|
||||
// (6) SSE new_card по карточкам, созданным проходом (Ruling 8/9; без подписчиков — no-op).
|
||||
if (pump is not null)
|
||||
{
|
||||
foreach (CardDto card in pump.CreatedCards)
|
||||
{
|
||||
broker.Publish(tenantId, NewCardEventType, card);
|
||||
}
|
||||
}
|
||||
|
||||
// (7) Строк очереди после pump (queue_len L337: total = new + ai).
|
||||
QueueCountsDto queueCounts = await processing.QueueCountsAsync(ct);
|
||||
|
||||
return new AdminTickResultDto(
|
||||
mergedStorage,
|
||||
dueReminders,
|
||||
pump is null ? new Dictionary<string, int>() : ToPipelineWireDict(pump),
|
||||
queueCounts.Total);
|
||||
}
|
||||
|
||||
// Один проход воркера под гейтом тенанта с изоляцией сбоя: исключения pump не роняют тик (Task 10).
|
||||
// tenantId: Тенант тика (ключ гейта, общего с фоновым циклом Task 11).
|
||||
// ct: Токен отмены запроса.
|
||||
// Возвращает: Результат прохода либо null — гейт занят другим воркером/pump упал (pipeline ответа пуст).
|
||||
private async Task<PipelinePumpResult?> PumpOnceSafelyAsync(Guid tenantId, CancellationToken ct)
|
||||
{
|
||||
// Общий воркер-гейт (Ruling 8, аналог asyncio.Lock pipeline.py L40): admin/tick и фоновый цикл не
|
||||
// разбирают очередь тенанта одновременно. Гейт занят (фоновый цикл уже pump'ит) — проход пропускаем,
|
||||
// как прототип при занятом локе (L901–902): pipeline={}, очередь дождётся следующего срабатывания.
|
||||
if (!pumpGate.TryEnter(tenantId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return await worker.PumpOnceAsync(ct);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Запрос отменён — прерываем тик штатно (не «сбой pump»).
|
||||
throw;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogWarning(exception, "POST /api/admin/tick: проход pump не удался — тик возвращает storage без pipeline");
|
||||
return null;
|
||||
}
|
||||
finally
|
||||
{
|
||||
pumpGate.Exit(tenantId);
|
||||
}
|
||||
}
|
||||
|
||||
// Счётчики результата pump → pipeline-словарь ответа (9 ключей словаря python L921; карточки в
|
||||
// wire не выходят — они ушли отдельными SSE new_card).
|
||||
// pump: Результат успешного прохода pump.
|
||||
// Возвращает: Словарь счётчиков в wire-порядке прототипа.
|
||||
private static IReadOnlyDictionary<string, int> ToPipelineWireDict(PipelinePumpResult pump)
|
||||
{
|
||||
int[] counters =
|
||||
[
|
||||
pump.Staged, pump.RulesStored, pump.MlStored, pump.MlDrop, pump.TypeDrop,
|
||||
pump.AiStored, pump.AiDrop, pump.AiFail, pump.NoBudget,
|
||||
];
|
||||
|
||||
var wire = new Dictionary<string, int>(PipelineCounterKeys.Length, StringComparer.Ordinal);
|
||||
for (int index = 0; index < PipelineCounterKeys.Length; index++)
|
||||
{
|
||||
wire[PipelineCounterKeys[index]] = counters[index];
|
||||
}
|
||||
|
||||
return wire;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using Deal.Modules.Kanban.Application.Models;
|
||||
|
||||
namespace Deal.Api;
|
||||
|
||||
/// <summary>
|
||||
/// Ответ POST /api/admin/tick — форма {storage, reminders, pipeline, queue} (dashboard_routes.py admin_tick L327–337, план Task 10).
|
||||
/// </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="Queue">Строк очереди входящих после прохода pump (queue_len).</param>
|
||||
public sealed record AdminTickResultDto(
|
||||
StorageTickStatsDto Storage,
|
||||
IReadOnlyList<CardReminderDueDto> Reminders,
|
||||
IReadOnlyDictionary<string, int> Pipeline,
|
||||
int Queue);
|
||||
@@ -0,0 +1,33 @@
|
||||
using Deal.Modules.Tenants.Application;
|
||||
|
||||
namespace Deal.Api.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Настройки httpOnly-куки сессии. Привязываются из секции "Cookies" конфигурации (IOptions).
|
||||
/// </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).
|
||||
/// </summary>
|
||||
public bool Secure { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
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).
|
||||
/// </summary>
|
||||
public string[] KnownNetworks { get; set; } = [];
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using Deal.Modules.Tenants.Application;
|
||||
|
||||
namespace Deal.Api.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Настройки httpOnly-куки сессии оператора. Привязываются из секции "OperatorCookies" конфигурации (IOptions).
|
||||
/// </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).
|
||||
/// </summary>
|
||||
public bool Secure { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
namespace Deal.Api.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Настройки rate limiting и защиты входа (план Task 11, Ruling 5): секция <c>RateLimit</c> конфигурации
|
||||
/// (appsettings.json + переменные окружения <c>RateLimit__*</c>).
|
||||
/// </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).
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Лимит политики "auth" (фиксированное окно в минуту на IP) для /api/auth/login и /api/operator/auth/login.
|
||||
/// </summary>
|
||||
public int AuthPerMinute { get; set; } = 10;
|
||||
|
||||
/// <summary>
|
||||
/// Лимит политики "api" (в минуту на тенанта либо IP анонима) для остальных /api-эндпоинтов.
|
||||
/// </summary>
|
||||
public int ApiPerMinute { get; set; } = 600;
|
||||
|
||||
/// <summary>
|
||||
/// Лимит gRPC-ингресса (в минуту на tenant-id из metadata; интерцептор IngressRateLimitInterceptor).
|
||||
/// </summary>
|
||||
public int GrpcIngressPerMinute { get; set; } = 600;
|
||||
|
||||
/// <summary>
|
||||
/// Порог неудачных попыток входа ключа ip|login до блокировки (LoginAttemptGuard).
|
||||
/// </summary>
|
||||
public int LoginAttemptsMax { get; set; } = 5;
|
||||
|
||||
/// <summary>
|
||||
/// Окно учёта неудачных попыток входа в минутах (LoginAttemptGuard; текст 429 — фиксированный «15 минут»).
|
||||
/// </summary>
|
||||
public int LoginAttemptWindowMin { get; set; } = 15;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
namespace Deal.Api.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Настройки безопасности HTTP (план Task 12, Ruling 10(2)/9): секция <c>Security</c> конфигурации
|
||||
/// (appsettings.json + переменные окружения <c>Security__*</c>).
|
||||
/// </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».
|
||||
/// </summary>
|
||||
public string[] AllowedOrigins { get; set; } = [];
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Контракты этапа 6: Deal.Proto компилирует telegram/ml/ai.proto (GrpcServices="Both") — здесь нужна
|
||||
серверная база Deal.Grpc.Telegram.IngressServiceBase (gRPC-ингресс, Ruling 1/7). -->
|
||||
<ProjectReference Include="..\..\contracts\Deal.Proto.csproj" />
|
||||
<ProjectReference Include="..\Deal.SharedKernel\Deal.SharedKernel.csproj" />
|
||||
<ProjectReference Include="..\Deal.Contracts\Deal.Contracts.csproj" />
|
||||
<ProjectReference Include="..\Deal.Infrastructure\Deal.Infrastructure.csproj" />
|
||||
<ProjectReference Include="..\Deal.Modules.Settings\Deal.Modules.Settings.csproj" />
|
||||
<ProjectReference Include="..\Deal.Modules.Tenants\Deal.Modules.Tenants.csproj" />
|
||||
<ProjectReference Include="..\Deal.Modules.Kanban\Deal.Modules.Kanban.csproj" />
|
||||
<ProjectReference Include="..\Deal.Modules.Pipeline\Deal.Modules.Pipeline.csproj" />
|
||||
<ProjectReference Include="..\Deal.Modules.Telegram\Deal.Modules.Telegram.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Структурированные логи Serilog (Ruling 7, план Task 14): JSON-консоль + rolling-файл
|
||||
data/logs/deal-core-*.json; конфигурация — Deal.Api/Logging/DealLogging.cs (вызов из Program.cs).
|
||||
Пакет тянет консоль/файл/compact-формат транзитивно. -->
|
||||
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- gRPC-сервер ASP.NET Core (второй Kestrel-endpoint HTTP/2, план Task 12, Ruling 1/7). -->
|
||||
<PackageReference Include="Grpc.AspNetCore" Version="2.83.0" />
|
||||
<!-- Стандартный gRPC-health ингресса (план Task 20, Ruling 12): healthcheck контейнера core
|
||||
в docker compose; grpc.health.v1.Health токеном не проверяется (см. IngressServiceTokenInterceptor). -->
|
||||
<PackageReference Include="Grpc.AspNetCore.HealthChecks" Version="2.83.0" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.11">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<!-- SVG QR-кода /api/tg/qr-image (план Task 14/Ruling 8): генерация без внешних растровых зависимостей. -->
|
||||
<PackageReference Include="Net.Codecrete.QrCodeGenerator" Version="2.0.6" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Метрики OpenTelemetry → Prometheus (этап 12, пакет A): хостинг OTel, инструментация входящих
|
||||
ASP.NET Core-запросов, исходящих HTTP-клиентов и экспортёр /metrics; GrpcNetClient — трейс-
|
||||
инструментация исходящих gRPC-вызовов (метрик в ней нет; держим для будущего трейсинга).
|
||||
Версии — 1.17.x (Prometheus-экспортёр и gRPC-клиент только pre-release-линией; остальные —
|
||||
1.17.0 stable). Настройка — Deal.Api/Observability/DealMetricsHosting.cs. -->
|
||||
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.17.0" />
|
||||
<PackageReference Include="OpenTelemetry.Exporter.Prometheus.AspNetCore" Version="1.17.0-beta.1" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.17.0" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.17.0" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.GrpcNetClient" Version="1.17.0-beta.1" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,35 @@
|
||||
# deal-api (core): HTTP-портал :5080 + gRPC-ингресс telegram-service :5082 (план Task 20; Ruling 12).
|
||||
#
|
||||
# КОНТЕКСТ СБОРКИ — корень репозитория: Deal.Api ссылается на проекты всего src/core (модули,
|
||||
# Infrastructure, SharedKernel/Contracts) и на src/contracts/Deal.Proto.csproj (кодогенерация .proto,
|
||||
# Task 1). Запуск из корня: docker build -f src/core/Deal.Api/Dockerfile .
|
||||
# Порт HTTP — env ASPNETCORE_URLS; порт ингресса — env GRPC_INGRESS_PORT (Program.cs), в compose.dev.yml 5082.
|
||||
#
|
||||
# Примечание о кэше слоёв: restore-слой копирует весь src/core (csproj-файлы отдельных проектов не
|
||||
# вычленяются — у Deal.Api ~10 ProjectReference внутри каталога; для dev-compose это приемлемо).
|
||||
# .dockerignore исключает **/bin и **/obj из контекста сборки.
|
||||
|
||||
# --- Этап сборки: restore + publish ---
|
||||
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||
WORKDIR /repo
|
||||
|
||||
COPY src/contracts/ src/contracts/
|
||||
COPY src/core/ src/core/
|
||||
RUN dotnet restore src/core/Deal.Api/Deal.Api.csproj
|
||||
RUN dotnet publish src/core/Deal.Api/Deal.Api.csproj -c Release -o /app/publish
|
||||
|
||||
# --- Runtime-этап ---
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final
|
||||
WORKDIR /app
|
||||
EXPOSE 5080 5082
|
||||
COPY --from=build /app/publish .
|
||||
|
||||
# grpc_health_probe — healthcheck контейнера (Ruling 12): gRPC-health ингресса (:5082) освобождён
|
||||
# от service-token (см. IngressServiceTokenInterceptor), поэтому проба идёт без metadata.
|
||||
COPY --from=ghcr.io/grpc-ecosystem/grpc-health-probe:v0.4.35 /ko-app/grpc-health-probe /bin/grpc_health_probe
|
||||
|
||||
# data/encryption.key (шифрование секретов) и LocalFileStorage data/attachments живут под ContentRoot
|
||||
# (/app/data); каталог монтируется volume-ом deal_api_data из compose.dev.yml. В полном dev-стеке файлы
|
||||
# идут в MinIO (Storage__Minio__* env, см. compose.dev.yml) — volume остаётся для ключа шифрования.
|
||||
|
||||
ENTRYPOINT ["dotnet", "Deal.Api.dll"]
|
||||
@@ -0,0 +1,168 @@
|
||||
using System.Text.Json;
|
||||
using Deal.Api.Http;
|
||||
using Deal.Modules.Settings.Application;
|
||||
using Deal.Modules.Settings.Application.Models;
|
||||
|
||||
namespace Deal.Api.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Эндпоинт проверки подключения AI-провайдера: POST /api/ai/check (Ruling 7/8, api-map §4.10).
|
||||
/// </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>
|
||||
/// Регистрирует POST /api/ai/check.
|
||||
/// </summary>
|
||||
/// <param name="app">Построитель маршрутов приложения.</param>
|
||||
/// <returns>Построитель маршрутов для цепочки вызовов.</returns>
|
||||
public static IEndpointRouteBuilder MapAiCheckEndpoint(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup(ApiGroupPrefix).WithTags(OpenApiTag);
|
||||
group.MapPost(AiCheckPath, CheckAsync);
|
||||
return app;
|
||||
}
|
||||
|
||||
// POST /api/ai/check: проверка соединения с активным AI-провайдером тенанта.
|
||||
private static async Task<IResult> CheckAsync(HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (context.GetCurrentUser() is null)
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
// Резолв после 401-гейта: ISettingsStore — scoped на TenantDbContext (tenant-контекст запроса).
|
||||
ISettingsStore store = context.RequestServices.GetRequiredService<ISettingsStore>();
|
||||
ISecretCipher secretCipher = context.RequestServices.GetRequiredService<ISecretCipher>();
|
||||
IAiConnectionChecker checker = context.RequestServices.GetRequiredService<IAiConnectionChecker>();
|
||||
|
||||
AiCheckRequest checkRequest = await BuildActiveCheckRequestAsync(store, secretCipher, ct);
|
||||
AiCheckResultDto result = await checker.CheckAsync(checkRequest, ct);
|
||||
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,
|
||||
CancellationToken ct)
|
||||
{
|
||||
string providerId = await ReadActiveProviderIdAsync(store, ct);
|
||||
AiProviderDefinition? meta = AiProviders.All.FirstOrDefault(provider => provider.Id == providerId);
|
||||
|
||||
// Неизвестный id (ручное вмешательство в БД — PATCH-гейт SettingsService не даёт сохранить):
|
||||
// HTTP не выполняется — ответит SSRF-гейт checker (allowlist).
|
||||
if (meta is null)
|
||||
{
|
||||
return new AiCheckRequest(providerId, string.Empty, string.Empty, string.Empty, IsLocal: false, ApiStyle: null);
|
||||
}
|
||||
|
||||
AiConfigSetting config = await ReadEffectiveConfigAsync(store, providerId, ct);
|
||||
string apiKey = secretCipher.Decrypt(config.ApiKey);
|
||||
string baseUrl = string.IsNullOrEmpty(config.BaseUrl) ? meta.Base : config.BaseUrl;
|
||||
string model = string.IsNullOrEmpty(config.Model)
|
||||
? meta.Models.FirstOrDefault() ?? string.Empty
|
||||
: config.Model;
|
||||
|
||||
return new AiCheckRequest(providerId, baseUrl, model, apiKey, meta.Local, meta.ApiStyle);
|
||||
}
|
||||
|
||||
// Читает активный провайдер: сохранённый aiProvider или дефолт (повреждённое значение — дефолт).
|
||||
// store: KV-хранилище настроек тенанта.
|
||||
// ct: Токен отмены.
|
||||
// Возвращает: id провайдера.
|
||||
private static async Task<string> ReadActiveProviderIdAsync(ISettingsStore store, CancellationToken ct)
|
||||
{
|
||||
SettingValue? row = await store.GetAsync(SettingsKeys.AiProvider, ct);
|
||||
if (row is null)
|
||||
{
|
||||
return SettingsDefaults.AiProvider;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using JsonDocument document = JsonDocument.Parse(row.ValueJson);
|
||||
if (document.RootElement.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
return document.RootElement.GetString() ?? SettingsDefaults.AiProvider;
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// Повреждённая строка — дефолт (мягкая семантика, как в SettingsService).
|
||||
}
|
||||
|
||||
return SettingsDefaults.AiProvider;
|
||||
}
|
||||
|
||||
// Эффективный конфиг провайдера: дефолт SettingsDefaults, перекрытый сохранённым aiConfigs.
|
||||
// store: KV-хранилище настроек тенанта.
|
||||
// providerId: Активный провайдер (id из каталога).
|
||||
// ct: Токен отмены.
|
||||
// Возвращает: Конфиг {apiKey, baseUrl, model}; повреждённая строка aiConfigs — дефолт.
|
||||
private static async Task<AiConfigSetting> ReadEffectiveConfigAsync(ISettingsStore store, string providerId, CancellationToken ct)
|
||||
{
|
||||
AiConfigSetting defaults = SettingsDefaults.AiConfigs[providerId];
|
||||
SettingValue? row = await store.GetAsync(SettingsKeys.AiConfigs, ct);
|
||||
if (row is null)
|
||||
{
|
||||
return defaults;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using JsonDocument document = JsonDocument.Parse(row.ValueJson);
|
||||
JsonElement root = document.RootElement;
|
||||
if (root.ValueKind == JsonValueKind.Object
|
||||
&& root.TryGetProperty(providerId, out JsonElement entry)
|
||||
&& entry.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
return new AiConfigSetting(
|
||||
ApiKey: ReadField(entry, "apiKey") ?? defaults.ApiKey,
|
||||
BaseUrl: ReadField(entry, "baseUrl") ?? defaults.BaseUrl,
|
||||
Model: ReadField(entry, "model") ?? defaults.Model);
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// Повреждённая строка — дефолт (не роняем проверку).
|
||||
}
|
||||
|
||||
return defaults;
|
||||
}
|
||||
|
||||
// Читает строковое поле объекта конфигурации (имена полей camelCase, как пишет SettingsService).
|
||||
// entry: JSON-объект конфигурации провайдера.
|
||||
// field: Имя поля (apiKey/baseUrl/model).
|
||||
// Возвращает: Значение или null, если поле отсутствует/не строка.
|
||||
private static string? ReadField(JsonElement entry, string field)
|
||||
{
|
||||
return entry.TryGetProperty(field, out JsonElement value) && value.ValueKind == JsonValueKind.String
|
||||
? value.GetString()
|
||||
: null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
using Deal.Api.Events;
|
||||
using Deal.Api.Http;
|
||||
using Deal.Contracts.Integrations;
|
||||
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>
|
||||
/// Регистрирует POST /api/ai/suggest-columns и POST /api/ai/suggest-keywords.
|
||||
/// </summary>
|
||||
/// <param name="app">Построитель маршрутов приложения.</param>
|
||||
/// <returns>Построитель маршрутов для цепочки вызовов.</returns>
|
||||
public static IEndpointRouteBuilder MapAiSuggestEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup(AiGroupPrefix).WithTags(OpenApiTag);
|
||||
group.MapPost(SuggestColumnsPath, SuggestColumnsAsync);
|
||||
group.MapPost(SuggestKeywordsPath, SuggestKeywordsAsync);
|
||||
return app;
|
||||
}
|
||||
|
||||
// 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 (!HasUser(context))
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
IColumnSuggester suggester = context.RequestServices.GetRequiredService<IColumnSuggester>();
|
||||
SuggestColumnsResultDto result = await suggester.SuggestColumnsAsync(ct);
|
||||
if (result.Ok)
|
||||
{
|
||||
SseBroker broker = context.RequestServices.GetRequiredService<SseBroker>();
|
||||
broker.Publish(
|
||||
context.GetCurrentUser()!.TenantId,
|
||||
ToastEventType,
|
||||
new { text = string.Format(SuggestToastTextFormat, result.Created), icon = SparklesIcon });
|
||||
}
|
||||
|
||||
return Results.Ok(result);
|
||||
}
|
||||
|
||||
// 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 (!HasUser(context))
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
IColumnSuggester suggester = context.RequestServices.GetRequiredService<IColumnSuggester>();
|
||||
SuggestKeywordsResultDto result = await suggester.SuggestKeywordsAsync(ct);
|
||||
return Results.Ok(result);
|
||||
}
|
||||
|
||||
// Разрешена ли сессия запроса (SessionMiddleware наполняет CurrentUser и tenant-контекст).
|
||||
// context: Контекст запроса.
|
||||
private static bool HasUser(HttpContext context) => context.GetCurrentUser() is not null;
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
using Deal.Api.Http;
|
||||
using Deal.Api.Middleware;
|
||||
using Deal.Modules.Tenants.Application;
|
||||
using Deal.Modules.Tenants.Application.Models;
|
||||
using Microsoft.Extensions.Options;
|
||||
// Имя конфигурационного типа совпадает с Microsoft.AspNetCore.Http.CookieOptions — фиксируем алиасом.
|
||||
using CookieOptions = Deal.Api.Configuration.CookieOptions;
|
||||
|
||||
namespace Deal.Api.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// HTTP-эндпоинты аутентификации (группа /api/auth). Контракт 1:1 с прототипом auth_routes.py.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Успех-ответы — <c>{ok:true,...}</c>, ошибки — HTTP-код + <c>{"detail":"..."}</c> (Ruling 10).
|
||||
/// Кука сессии выставляется на login и change-password (свежий токен). Сообщения об ошибках —
|
||||
/// фиксированные строки прототипа.
|
||||
/// </remarks>
|
||||
public static class AuthEndpoints
|
||||
{
|
||||
private const string InvalidCredentialsDetail = "Неверный логин или пароль";
|
||||
private const string WrongOldPasswordDetail = "Текущий пароль неверен";
|
||||
private const string PasswordTooShortDetail = "Пароль слишком короткий (минимум 8 символов)";
|
||||
private const string TenantSuspendedDetail = "Учётная запись приостановлена. Обратитесь к оператору";
|
||||
private const string AuthGroupPrefix = "/api/auth";
|
||||
private const string AuthOpenApiTag = "auth";
|
||||
|
||||
/// <summary>
|
||||
/// Регистрирует группу /api/auth: login, logout, me, change-password.
|
||||
/// </summary>
|
||||
/// <param name="app">Построитель маршрутов приложения.</param>
|
||||
/// <returns>Построитель маршрутов для цепочки вызовов.</returns>
|
||||
public static IEndpointRouteBuilder MapAuthEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
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);
|
||||
group.MapGet("/me", MeAsync);
|
||||
group.MapPost("/change-password", ChangePasswordAsync);
|
||||
|
||||
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,
|
||||
AuditService auditService,
|
||||
IOptions<CookieOptions> cookieOptions,
|
||||
HttpContext context,
|
||||
CancellationToken ct,
|
||||
LoginAttemptGuard loginAttemptGuard)
|
||||
{
|
||||
string? attemptedLogin = NormalizeLogin(body.Login);
|
||||
|
||||
// Защита входа (план Task 11, Ruling 5): блокировка ключа ip|login до проверки учётных данных —
|
||||
// 429 «Слишком много попыток входа…» (в dev при RateLimit:Enabled=false гвард выключен).
|
||||
if (await loginAttemptGuard.IsBlockedAsync(ClientIp(context), attemptedLogin, ct))
|
||||
{
|
||||
return EndpointResults.TooManyRequests(LoginAttemptGuard.BlockedDetail);
|
||||
}
|
||||
|
||||
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(
|
||||
AuditEvents.TenantLoginFailed,
|
||||
AuditActorTypes.Tenant,
|
||||
ActorId: result.UserId,
|
||||
TenantId: result.TenantId,
|
||||
Ip: ClientIp(context),
|
||||
DetailJson: AuditService.ToDetailJson(new { login = NormalizeLogin(body.Login) })), ct);
|
||||
|
||||
return EndpointResults.Forbidden(TenantSuspendedDetail);
|
||||
}
|
||||
|
||||
if (result.Login is null || result.Token is null)
|
||||
{
|
||||
// Пустой/пробельный login и неверные учётные данные — одно сообщение (семантика прототипа).
|
||||
// Аудит tenant_login_failed пишем только для реальной попытки (непустой логин), без пароля (Ruling 4);
|
||||
// счётчик неудач гварда растёт там же — пустые логины ключа не имеют (блокирует только auth-политика).
|
||||
if (attemptedLogin is not null)
|
||||
{
|
||||
await loginAttemptGuard.RecordFailureAsync(ClientIp(context), attemptedLogin, ct);
|
||||
await auditService.AppendAsync(new AuditRecordDto(
|
||||
AuditEvents.TenantLoginFailed,
|
||||
AuditActorTypes.Tenant,
|
||||
ActorId: null,
|
||||
TenantId: null,
|
||||
Ip: ClientIp(context),
|
||||
DetailJson: AuditService.ToDetailJson(new { login = attemptedLogin })), ct);
|
||||
}
|
||||
|
||||
return EndpointResults.Unauthorized(InvalidCredentialsDetail);
|
||||
}
|
||||
|
||||
// Успешный вход сбрасывает счётчик неудач ключа ip|login (Ruling 5).
|
||||
await loginAttemptGuard.ResetAsync(ClientIp(context), result.Login, ct);
|
||||
|
||||
await auditService.AppendAsync(new AuditRecordDto(
|
||||
AuditEvents.TenantLoginOk,
|
||||
AuditActorTypes.Tenant,
|
||||
ActorId: result.UserId,
|
||||
TenantId: result.TenantId,
|
||||
Ip: ClientIp(context),
|
||||
DetailJson: AuditService.ToDetailJson(new { login = result.Login })), ct);
|
||||
|
||||
SessionCookieWriter.Append(context, cookieOptions.Value, result.Token);
|
||||
return Results.Ok(new { ok = true, login = result.Login });
|
||||
}
|
||||
|
||||
// POST /api/auth/logout: удаление сессии по токену из куки и очистка куки (всегда ok).
|
||||
// Если удалённая сессия была impersonation — пишется аудит impersonation_stopped (Task 7, ревью: полный аудит).
|
||||
private static async Task<IResult> LogoutAsync(
|
||||
AuthService authService,
|
||||
AuditService auditService,
|
||||
IOptions<CookieOptions> cookieOptions,
|
||||
HttpContext context,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var cookieName = cookieOptions.Value.Name;
|
||||
var rawToken = context.Request.Cookies[cookieName];
|
||||
// Пользователь разрешённой сессии — до её удаления (SessionMiddleware наполнил Items на старте запроса).
|
||||
CurrentUser? user = context.GetCurrentUser();
|
||||
var logout = await authService.LogoutAsync(rawToken, ct);
|
||||
if (logout is not null)
|
||||
{
|
||||
// Актор — оператор, начавший impersonation (маркер сессии); тенант — для фильтра TenantId.
|
||||
await auditService.AppendAsync(new AuditRecordDto(
|
||||
AuditEvents.ImpersonationStopped,
|
||||
AuditActorTypes.Operator,
|
||||
ActorId: logout.OperatorId,
|
||||
TenantId: logout.TenantId,
|
||||
Ip: ClientIp(context),
|
||||
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);
|
||||
}
|
||||
|
||||
context.Response.Cookies.Delete(cookieName);
|
||||
return Results.Ok(new { ok = true });
|
||||
}
|
||||
|
||||
// GET /api/auth/me: проверка живой сессии.
|
||||
private static IResult MeAsync(HttpContext context)
|
||||
{
|
||||
var user = context.GetCurrentUser();
|
||||
if (user is null)
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
return Results.Ok(new { login = user.Login, ok = true });
|
||||
}
|
||||
|
||||
// POST /api/auth/change-password: смена пароля и перевыпуск куки (свежая сессия).
|
||||
private static async Task<IResult> ChangePasswordAsync(
|
||||
ChangePasswordRequest body,
|
||||
AuthService authService,
|
||||
IOptions<CookieOptions> cookieOptions,
|
||||
HttpContext context,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var user = context.GetCurrentUser();
|
||||
if (user is null)
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
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;
|
||||
return EndpointResults.BadRequest(detail);
|
||||
}
|
||||
|
||||
// Старые сессии удалены внутри сервиса; выдаём клиенту свежую куку.
|
||||
SessionCookieWriter.Append(context, cookieOptions.Value, result.NewToken);
|
||||
return Results.Ok(new { ok = true });
|
||||
}
|
||||
|
||||
// Нормализованная попытка логина для аудита (нижний регистр/обрезка, как AuthService); null — писать нечего.
|
||||
// login: Логин из тела запроса.
|
||||
// Возвращает: Нормализованный логин или null при пустом/пробельном входе.
|
||||
private static string? NormalizeLogin(string? login)
|
||||
{
|
||||
string? normalized = login?.Trim().ToLowerInvariant();
|
||||
return string.IsNullOrEmpty(normalized) ? null : normalized;
|
||||
}
|
||||
|
||||
// IP-адрес клиента для аудита (без порта; null, если недоступен).
|
||||
// context: Контекст запроса.
|
||||
// Возвращает: Строковое представление IP или null.
|
||||
private static string? ClientIp(HttpContext context) => context.Connection.RemoteIpAddress?.ToString();
|
||||
}
|
||||
@@ -0,0 +1,432 @@
|
||||
using System.Text.Json;
|
||||
using Deal.Api.Endpoints.RequestModels;
|
||||
using Deal.Api.Http;
|
||||
using Deal.Contracts.Integrations;
|
||||
using Deal.Contracts.Integrations.Models;
|
||||
using Deal.Modules.Kanban.Application;
|
||||
using Deal.Modules.Kanban.Application.Models;
|
||||
using Deal.Modules.Tenants.Application;
|
||||
|
||||
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).
|
||||
private const string CardsGroupPrefix = "/api/cards";
|
||||
|
||||
// Статический сегмент «взять в работу» (регистрируется до /{cardId}).
|
||||
private const string TakePath = "/take";
|
||||
|
||||
// Статический сегмент очистки «Отклонено» (регистрируется до /{cardId}).
|
||||
private const string ClearRejectedPath = "/clear-rejected";
|
||||
|
||||
// Параметрический сегмент карточки (PATCH /{cardId}).
|
||||
private const string CardIdPath = "/{cardId}";
|
||||
|
||||
// Вложенный путь добавления ссылки.
|
||||
private const string LinksPath = "/{cardId}/links";
|
||||
|
||||
// Вложенный путь удаления ссылки.
|
||||
private const string LinkItemPath = "/{cardId}/links/{linkId}";
|
||||
|
||||
// Вложенный путь загрузки вложений (multipart, поле files).
|
||||
private const string FilesPath = "/{cardId}/files";
|
||||
|
||||
// Вложенный путь скачивания вложения (поток + attachment).
|
||||
private const string FileDownloadPath = "/{cardId}/files/{fileId}/download";
|
||||
|
||||
// Вложенный путь удаления вложения.
|
||||
private const string FileItemPath = "/{cardId}/files/{fileId}";
|
||||
|
||||
// Вложенный путь установки/снятия напоминания.
|
||||
private const string ReminderPath = "/{cardId}/reminder";
|
||||
|
||||
// Вложенный путь «напомнить позже».
|
||||
private const string ReminderSnoozePath = "/{cardId}/reminder/snooze";
|
||||
|
||||
// OpenAPI-тег группы.
|
||||
private const string OpenApiTag = "cards";
|
||||
|
||||
// 404: карточка не найдена.
|
||||
private const string CardNotFoundDetail = "Карточка не найдена";
|
||||
|
||||
// 400: тело PATCH не JSON-объект.
|
||||
private const string InvalidBodyDetail = "Тело запроса должно быть JSON-объектом";
|
||||
|
||||
// 400: POST файлов без multipart/form-data.
|
||||
private const string FormExpectedDetail = "Ожидается multipart/form-data";
|
||||
|
||||
// 400: POST напоминания без поля at.
|
||||
private const string ReminderAtMissingDetail = "Поле at (epoch-ms) обязательно";
|
||||
|
||||
// 404 download: объекта нет в хранилище.
|
||||
private const string FileNotFoundInStorageDetail = "Файл не найден в MinIO";
|
||||
|
||||
// 410 download: у записи файла нет objectKey.
|
||||
private const string FileNotSavedDetail = "Файл не сохранён в объектном хранилище";
|
||||
|
||||
// Content-Type скачивания по умолчанию.
|
||||
private const string DownloadContentTypeFallback = "application/octet-stream";
|
||||
|
||||
// Символ, убираемый из имени файла для Content-Disposition.
|
||||
private const string FileNameQuoteCharacter = "\"";
|
||||
|
||||
/// <summary>
|
||||
/// Регистрирует уникальные подпути /api/cards (создание, take, патч, ссылки, файлы, напоминания).
|
||||
/// </summary>
|
||||
/// <param name="app">Построитель маршрутов приложения.</param>
|
||||
/// <returns>Построитель маршрутов для цепочки вызовов.</returns>
|
||||
public static IEndpointRouteBuilder MapCardDetailsEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var cards = app.MapGroup(CardsGroupPrefix).WithTags(OpenApiTag);
|
||||
|
||||
// Статические сегменты (/take, /clear-rejected) ДО /{cardId}; вложенные — за /{cardId}.
|
||||
cards.MapPost("", CreateCardAsync);
|
||||
cards.MapPost(TakePath, TakeCardAsync);
|
||||
cards.MapPost(ClearRejectedPath, ClearRejectedAsync);
|
||||
cards.MapPatch(CardIdPath, PatchCardAsync);
|
||||
cards.MapPost(LinksPath, AddLinkAsync);
|
||||
cards.MapDelete(LinkItemPath, RemoveLinkAsync);
|
||||
cards.MapPost(FilesPath, UploadFilesAsync);
|
||||
cards.MapGet(FileDownloadPath, DownloadFileAsync);
|
||||
cards.MapDelete(FileItemPath, RemoveFileAsync);
|
||||
cards.MapPost(ReminderPath, SetReminderAsync);
|
||||
cards.MapDelete(ReminderPath, ClearReminderAsync);
|
||||
cards.MapPost(ReminderSnoozePath, SnoozeReminderAsync);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
// POST /api/cards: ручное создание «локальной» карточки. Ответ — созданная карточка.
|
||||
private static async Task<IResult> CreateCardAsync(CreateCardRequest body, HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!HasUser(context))
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
// POST /api/cards/take {cardId}: «взять в работу» — перенос карточки в planned. Ответ — карточка.
|
||||
private static async Task<IResult> TakeCardAsync(TakeCardRequest body, HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!HasUser(context))
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
CardsService service = context.RequestServices.GetRequiredService<CardsService>();
|
||||
CardDto? card = await service.TakeCardAsync(body.CardId ?? body.LeadId ?? string.Empty, ct);
|
||||
return card is null
|
||||
? EndpointResults.NotFound(CardNotFoundDetail)
|
||||
: await ReadCardAsync(context, card.Id, ct);
|
||||
}
|
||||
|
||||
// POST /api/cards/clear-rejected: полная очистка терминальной стадии «Отклонено».
|
||||
private static async Task<IResult> ClearRejectedAsync(HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!HasUser(context))
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
CardsService service = context.RequestServices.GetRequiredService<CardsService>();
|
||||
int cleared = await service.ClearRejectedAsync(ct);
|
||||
return Results.Ok(new { ok = true, cleared });
|
||||
}
|
||||
|
||||
// PATCH /api/cards/{cardId}: точечная правка полей (title/summary/contact/tzText/stack/budget).
|
||||
// Тело читается как произвольный JSON-объект (presence-aware): явный null чистящих полей
|
||||
// (budget:null, stack:null) не теряется типизированным биндингом. Ответ — обновлённая карточка.
|
||||
private static async Task<IResult> PatchCardAsync(string cardId, HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!HasUser(context))
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
IReadOnlyDictionary<string, JsonElement>? body = await ReadPatchBodyAsync(context, ct);
|
||||
if (body is null)
|
||||
{
|
||||
return EndpointResults.BadRequest(InvalidBodyDetail);
|
||||
}
|
||||
|
||||
CardsService service = context.RequestServices.GetRequiredService<CardsService>();
|
||||
CardDto? card = await service.PatchCardAsync(cardId, body, ct);
|
||||
return card is null
|
||||
? EndpointResults.NotFound(CardNotFoundDetail)
|
||||
: await ReadCardAsync(context, cardId, ct);
|
||||
}
|
||||
|
||||
// POST /api/cards/{cardId}/links {name?,url}: добавить ссылку. Ответ — карточка.
|
||||
private static async Task<IResult> AddLinkAsync(string cardId, CardLinkRequest body, HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!HasUser(context))
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
CardsService service = context.RequestServices.GetRequiredService<CardsService>();
|
||||
CardResultDto result = await service.AddLinkAsync(
|
||||
cardId,
|
||||
body.Name ?? string.Empty,
|
||||
body.Url ?? string.Empty,
|
||||
ct);
|
||||
if (result.Error is not null)
|
||||
{
|
||||
return EndpointResults.BadRequest(result.Error);
|
||||
}
|
||||
|
||||
return result.Card is null
|
||||
? EndpointResults.NotFound(CardNotFoundDetail)
|
||||
: await ReadCardAsync(context, cardId, ct);
|
||||
}
|
||||
|
||||
// DELETE /api/cards/{cardId}/links/{linkId}: удалить ссылку. Ответ — карточка.
|
||||
private static async Task<IResult> RemoveLinkAsync(string cardId, string linkId, HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!HasUser(context))
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
CardsService service = context.RequestServices.GetRequiredService<CardsService>();
|
||||
CardResultDto result = await service.RemoveLinkAsync(cardId, linkId, ct);
|
||||
return result.Card is null
|
||||
? EndpointResults.NotFound(CardNotFoundDetail)
|
||||
: await ReadCardAsync(context, cardId, ct);
|
||||
}
|
||||
|
||||
// POST /api/cards/{cardId}/files: загрузка вложений (multipart/form-data, поле files).
|
||||
// Ответ — обновлённая карточка (с новыми files). Карточки нет → 404 до записи объектов. Каждый файл:
|
||||
// имя/ContentType/поток/длина → CardsService.AddFileAsync. Ранний null — гонка (404).
|
||||
private static async Task<IResult> UploadFilesAsync(string cardId, HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!HasUser(context))
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
|
||||
if (await cardsService.GetCardAsync(cardId, ct) is null)
|
||||
{
|
||||
return EndpointResults.NotFound(CardNotFoundDetail);
|
||||
}
|
||||
|
||||
IFormCollection form;
|
||||
try
|
||||
{
|
||||
form = await context.Request.ReadFormAsync(ct);
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
// Тело не multipart/form-data — ReadFormAsync бросает; фронт так не шлёт.
|
||||
return EndpointResults.BadRequest(FormExpectedDetail);
|
||||
}
|
||||
|
||||
foreach (IFormFile file in form.Files)
|
||||
{
|
||||
await using Stream content = file.OpenReadStream();
|
||||
CardFileDto? entry = await cardsService.AddFileAsync(cardId, file.FileName, file.ContentType, content, file.Length, ct);
|
||||
if (entry is null)
|
||||
{
|
||||
return EndpointResults.NotFound(CardNotFoundDetail);
|
||||
}
|
||||
}
|
||||
|
||||
return await ReadCardAsync(context, cardId, ct);
|
||||
}
|
||||
|
||||
// GET /api/cards/{cardId}/files/{fileId}/download: поток содержимого вложения.
|
||||
// Карточки/записи нет → 404; пустой objectKey → 410; объекта нет в хранилище/сбой → 404. Ответ — поток
|
||||
// с Content-Length/Content-Type из дескриптора; Content-Disposition attachment, имя без кавычек.
|
||||
private static async Task<IResult> DownloadFileAsync(string cardId, string fileId, HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!HasUser(context))
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
|
||||
CardFileDto? entry = await cardsService.GetFileEntryAsync(cardId, fileId, ct);
|
||||
if (entry is null)
|
||||
{
|
||||
return EndpointResults.NotFound(CardNotFoundDetail);
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(entry.ObjectKey))
|
||||
{
|
||||
return EndpointResults.Gone(FileNotSavedDetail);
|
||||
}
|
||||
|
||||
IFileStorage storage = context.RequestServices.GetRequiredService<IFileStorage>();
|
||||
FileMeta? meta;
|
||||
Stream? stream;
|
||||
try
|
||||
{
|
||||
meta = await storage.StatAsync(entry.ObjectKey, ct);
|
||||
stream = meta is null ? null : await storage.GetAsync(entry.ObjectKey, ct);
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return EndpointResults.NotFound(FileNotFoundInStorageDetail);
|
||||
}
|
||||
|
||||
if (meta is null || stream is null)
|
||||
{
|
||||
return EndpointResults.NotFound(FileNotFoundInStorageDetail);
|
||||
}
|
||||
|
||||
context.Response.ContentLength = meta.Size;
|
||||
string contentType = string.IsNullOrWhiteSpace(meta.ContentType)
|
||||
? DownloadContentTypeFallback
|
||||
: meta.ContentType;
|
||||
return Results.Stream(stream, contentType, fileDownloadName: ToDownloadFileName(entry.Name));
|
||||
}
|
||||
|
||||
// DELETE /api/cards/{cardId}/files/{fileId}: открепить файл. Ответ — карточка.
|
||||
private static async Task<IResult> RemoveFileAsync(string cardId, string fileId, HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!HasUser(context))
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
|
||||
CardDto? card = await cardsService.RemoveFileAsync(cardId, fileId, ct);
|
||||
return card is null
|
||||
? EndpointResults.NotFound(CardNotFoundDetail)
|
||||
: await ReadCardAsync(context, cardId, ct);
|
||||
}
|
||||
|
||||
// POST /api/cards/{cardId}/reminder {at: epoch-ms}: установить напоминание. Ответ — карточка.
|
||||
private static async Task<IResult> SetReminderAsync(string cardId, ReminderSetRequest body, HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!HasUser(context))
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
if (body.At is null)
|
||||
{
|
||||
return EndpointResults.BadRequest(ReminderAtMissingDetail);
|
||||
}
|
||||
|
||||
CardsService service = context.RequestServices.GetRequiredService<CardsService>();
|
||||
CardResultDto result = await service.SetReminderAsync(cardId, body.At.Value, ct);
|
||||
if (result.Error is not null)
|
||||
{
|
||||
return EndpointResults.BadRequest(result.Error);
|
||||
}
|
||||
|
||||
return result.Card is null
|
||||
? EndpointResults.NotFound(CardNotFoundDetail)
|
||||
: await ReadCardAsync(context, cardId, ct);
|
||||
}
|
||||
|
||||
// DELETE /api/cards/{cardId}/reminder: снять напоминание. Ответ — карточка.
|
||||
private static async Task<IResult> ClearReminderAsync(string cardId, HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!HasUser(context))
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
CardsService service = context.RequestServices.GetRequiredService<CardsService>();
|
||||
return await service.ClearReminderAsync(cardId, ct)
|
||||
? await ReadCardAsync(context, cardId, ct)
|
||||
: EndpointResults.NotFound(CardNotFoundDetail);
|
||||
}
|
||||
|
||||
// POST /api/cards/{cardId}/reminder/snooze: «напомнить позже» (now + 24 ч). Ответ — карточка.
|
||||
private static async Task<IResult> SnoozeReminderAsync(string cardId, HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!HasUser(context))
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
CardsService service = context.RequestServices.GetRequiredService<CardsService>();
|
||||
return await service.SnoozeReminderAsync(cardId, ct)
|
||||
? await ReadCardAsync(context, cardId, ct)
|
||||
: EndpointResults.NotFound(CardNotFoundDetail);
|
||||
}
|
||||
|
||||
// Читает карточку через единый сервис и возвращает её как ответ (404 — карточки нет).
|
||||
// context: Контекст запроса (для резолва CardsService).
|
||||
// cardId: Id карточки.
|
||||
// ct: Токен отмены.
|
||||
// Возвращает: 200 с единой карточкой либо 404.
|
||||
private static async Task<IResult> ReadCardAsync(HttpContext context, string cardId, CancellationToken ct)
|
||||
{
|
||||
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
|
||||
CardDto? card = await cardsService.GetCardAsync(cardId, ct);
|
||||
return card is null
|
||||
? EndpointResults.NotFound(CardNotFoundDetail)
|
||||
: Results.Ok(card);
|
||||
}
|
||||
|
||||
// Имя файла для Content-Disposition без кавычек «"».
|
||||
// name: Имя файла как в метаданных записи.
|
||||
// Возвращает: Имя, безопасное для заголовка.
|
||||
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)
|
||||
{
|
||||
return new CardLocalCreateDto(
|
||||
Title: body.Title,
|
||||
Summary: body.Summary,
|
||||
Stack: body.Stack,
|
||||
Budget: body.Budget,
|
||||
Contact: body.Contact,
|
||||
TzText: body.TzText,
|
||||
ContainerId: body.ContainerId ?? body.Stage);
|
||||
}
|
||||
|
||||
// Читает тело PATCH как произвольный JSON-объект: ключ → JsonElement (presence-aware).
|
||||
// context: Контекст запроса.
|
||||
// ct: Токен отмены.
|
||||
// Возвращает: Словарь ключей тела либо null — тело не JSON-объект.
|
||||
private static async Task<IReadOnlyDictionary<string, JsonElement>?> ReadPatchBodyAsync(HttpContext context, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await JsonSerializer.DeserializeAsync<Dictionary<string, JsonElement>>(
|
||||
context.Request.Body,
|
||||
options: null,
|
||||
cancellationToken: ct);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Разрешена ли сессия запроса.
|
||||
// context: Контекст запроса.
|
||||
// Возвращает: True — сессия есть.
|
||||
private static bool HasUser(HttpContext context) => context.GetCurrentUser() is not null;
|
||||
}
|
||||
@@ -0,0 +1,447 @@
|
||||
using Deal.Api.Endpoints.RequestModels;
|
||||
using Deal.Api.Events;
|
||||
using Deal.Api.Http;
|
||||
using Deal.Modules.Cards.Application;
|
||||
using Deal.Modules.Kanban.Application;
|
||||
using Deal.Modules.Kanban.Application.Models;
|
||||
using Deal.Modules.Pipeline.Application;
|
||||
using Deal.Modules.Pipeline.Application.Models;
|
||||
using Deal.Modules.Tenants.Application;
|
||||
|
||||
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
|
||||
{
|
||||
// Префикс группы карточек.
|
||||
private const string CardsGroupPrefix = "/api/cards";
|
||||
|
||||
// Префикс группы поиска (единственный эндпоинт группы — /api/search).
|
||||
private const string ApiGroupPrefix = "/api";
|
||||
|
||||
// Путь поиска (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}.
|
||||
/// </summary>
|
||||
/// <param name="app">Построитель маршрутов приложения.</param>
|
||||
/// <returns>Построитель маршрутов для цепочки вызовов.</returns>
|
||||
public static IEndpointRouteBuilder MapCardsEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var leads = app.MapGroup(CardsGroupPrefix).WithTags(OpenApiTag);
|
||||
|
||||
// Статические сегменты ДО /cards/{cardId}: ASP.NET Core отдаёт приоритет литералам, порядок регистрации
|
||||
// сохранён для читаемости.
|
||||
leads.MapGet("", ListCardsAsync);
|
||||
leads.MapGet("/counts", CountsAsync);
|
||||
leads.MapPost("/mark-all-seen", MarkAllSeenAsync);
|
||||
leads.MapPost("/mark-col-seen", MarkColSeenAsync);
|
||||
leads.MapPost("/clear-col", ClearColAsync);
|
||||
leads.MapPost("/reclassify", ReclassifyAsync);
|
||||
leads.MapGet("/{cardId}", GetCardAsync);
|
||||
leads.MapPost("/{cardId}/reclassify", ReclassifyOneAsync);
|
||||
leads.MapPost("/{cardId}/move", MoveAsync);
|
||||
leads.MapPost("/{cardId}/trash", TrashAsync);
|
||||
leads.MapPost("/{cardId}/restore", RestoreAsync);
|
||||
leads.MapDelete("/{cardId}", DeleteAsync);
|
||||
leads.MapPost("/{cardId}/comments", AddCommentAsync);
|
||||
|
||||
app.MapGroup(ApiGroupPrefix).WithTags(OpenApiTag).MapGet(SearchPath, SearchAsync);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
// GET /api/cards?containerId=: карточки контейнера (или все карточки дашборда); 400 «Неизвестный контейнер».
|
||||
// Параметр col принят как алиас containerId (совместимость со старым фронтом).
|
||||
private static async Task<IResult> ListCardsAsync(string? containerId, string? col, HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!HasUser(context))
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
string? target = string.IsNullOrEmpty(containerId) ? col : containerId;
|
||||
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
|
||||
if (!string.IsNullOrEmpty(target) && !await IsKnownContainerAsync(target, context, ct))
|
||||
{
|
||||
return EndpointResults.BadRequest(UnknownColumnDetail);
|
||||
}
|
||||
|
||||
IReadOnlyList<CardDto> cards = await cardsService.ListCardsAsync(target, ct);
|
||||
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 (!HasUser(context))
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
wire[col] = column;
|
||||
}
|
||||
|
||||
wire["learning"] = counts.Learning;
|
||||
wire["ml"] = counts.Ml;
|
||||
wire["ai"] = counts.Ai;
|
||||
return Results.Ok(wire);
|
||||
}
|
||||
|
||||
// GET /api/cards/{cardId}: одна карточка; 404 «Карточка не найдена» (L166–168).
|
||||
private static async Task<IResult> GetCardAsync(string cardId, HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!HasUser(context))
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
|
||||
CardDto? card = await cardsService.GetCardAsync(cardId, ct);
|
||||
return card is null
|
||||
? EndpointResults.NotFound(CardNotFoundDetail)
|
||||
: Results.Ok(card);
|
||||
}
|
||||
|
||||
// POST /api/cards/mark-all-seen: снять «новое» со всех карточек (L177–180); ответ {ok:true}.
|
||||
private static async Task<IResult> MarkAllSeenAsync(HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!HasUser(context))
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
|
||||
await cardsService.MarkSeenAsync(cardId: null, col: null, ct);
|
||||
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, CancellationToken ct)
|
||||
{
|
||||
if (!HasUser(context))
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
if (body.Col is null)
|
||||
{
|
||||
// Пустая/отсутствующая col попала бы в mark_seen как «не задана» и сняла бы «новое» со ВСЕХ
|
||||
// карточек (truthiness python, L250–256) — эндпоинт защищает от вызова с null (прототип: 422).
|
||||
return EndpointResults.BadRequest(UnknownColumnDetail);
|
||||
}
|
||||
|
||||
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
|
||||
await cardsService.MarkSeenAsync(cardId: null, col: body.Col, ct);
|
||||
return Results.Ok(new { ok = true });
|
||||
}
|
||||
|
||||
// POST /api/cards/{cardId}/move {to}: перенос карточки между контейнерами (этап 9, R4).
|
||||
// Маршрутизацию цели (стадия «Выбранных» vs дашборд-контейнер) и побочные эффекты выполняет единый
|
||||
// доменный механизм перехода ICardMover: стадия — запись истории и сброс напоминания
|
||||
// (move_stage), дашборд-контейнер — журнал/обучение ML. Ответ — обновлённая карточка; 400 при
|
||||
// несуществующем контейнере, 404 — карточки нет.
|
||||
private static async Task<IResult> MoveAsync(string cardId, MoveBody body, HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!HasUser(context))
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
ICardMover mover = context.RequestServices.GetRequiredService<ICardMover>();
|
||||
CardMoveResultDto outcome = await mover.MoveAsync(cardId, body.To ?? string.Empty, UserMoveContext, ct);
|
||||
if (outcome.Error is not null)
|
||||
{
|
||||
return EndpointResults.BadRequest(outcome.Error);
|
||||
}
|
||||
|
||||
if (!outcome.Exists)
|
||||
{
|
||||
return EndpointResults.NotFound(CardNotFoundDetail);
|
||||
}
|
||||
|
||||
// Аудит переноса карточки (этап 10, T1): цель — минимальный безопасный идентификатор.
|
||||
await AuditAppender.AppendTenantAsync(context, AuditEvents.CardMoved, new { cardId, to = body.To }, ct);
|
||||
|
||||
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
|
||||
CardDto? unified = await cardsService.GetCardAsync(cardId, ct);
|
||||
return unified is null
|
||||
? EndpointResults.NotFound(CardNotFoundDetail)
|
||||
: 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, CancellationToken ct)
|
||||
{
|
||||
if (!HasUser(context))
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
|
||||
CardDto? card = await cardsService.TrashCardAsync(cardId, ct);
|
||||
if (card is null)
|
||||
{
|
||||
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, CancellationToken ct)
|
||||
{
|
||||
if (!HasUser(context))
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
|
||||
string? col = await cardsService.RestoreCardAsync(cardId, ct);
|
||||
if (col is null)
|
||||
{
|
||||
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, CancellationToken ct)
|
||||
{
|
||||
if (!HasUser(context))
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
|
||||
bool deleted = await cardsService.DeleteForeverAsync(cardId, ct);
|
||||
if (!deleted)
|
||||
{
|
||||
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, CancellationToken ct)
|
||||
{
|
||||
if (!HasUser(context))
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
|
||||
ClearColResultDto result = await cardsService.ClearColAsync(body.Col ?? string.Empty, ct);
|
||||
return result.Error is not null
|
||||
? EndpointResults.BadRequest(result.Error)
|
||||
: 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, HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!HasUser(context))
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
|
||||
AddCommentResultDto result = await cardsService.AddCommentAsync(cardId, body.Text ?? string.Empty, ct);
|
||||
if (result.Error is not null)
|
||||
{
|
||||
return EndpointResults.BadRequest(result.Error);
|
||||
}
|
||||
|
||||
if (result.Comments is null)
|
||||
{
|
||||
return EndpointResults.NotFound(CardNotFoundDetail);
|
||||
}
|
||||
|
||||
// Аудит добавления комментария (этап 10, T1): текст комментария в детали не пишется.
|
||||
await AuditAppender.AppendTenantAsync(context, AuditEvents.CardCommentAdded, new { cardId }, ct);
|
||||
return Results.Ok(new { comments = result.Comments });
|
||||
}
|
||||
|
||||
// POST /api/cards/reclassify: переклассификация «Неразобранного» (все карточки либо ids).
|
||||
// Тело ids опционально (фронт шлёт запрос без тела — все карточки inbox). Проход синхронный; при занятом
|
||||
// проходе ответ {started:false, busy:true}. Поля started/busy/attempted сохранены ради совместимости,
|
||||
// добавлены reclassified/moved/kept/trashed/skipped/usedAi/reason. Аудит — card_reclassified; после
|
||||
// успешного прохода (reclassified > 0) публикуется SSE cards_reclassified {reclassified,moved}.
|
||||
// body: Тело запроса (ids — опционально).
|
||||
// context: Контекст запроса.
|
||||
// ct: Токен отмены.
|
||||
private static async Task<IResult> ReclassifyAsync(ReclassifyBody? body, HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!HasUser(context))
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
CardReclassifier reclassifier = context.RequestServices.GetRequiredService<CardReclassifier>();
|
||||
ReclassifyResultDto result = await reclassifier.ReclassifyInboxAsync(body?.Ids, ct);
|
||||
await AppendReclassifyAuditAsync(context, result, ct);
|
||||
PublishReclassified(context, result);
|
||||
return Results.Ok(ToReclassifyWire(result));
|
||||
}
|
||||
|
||||
// POST /api/cards/{cardId}/reclassify: переклассификация одной карточки; 404 «Карточка не найдена».
|
||||
// cardId: Id карточки (c_...).
|
||||
// context: Контекст запроса.
|
||||
// ct: Токен отмены.
|
||||
private static async Task<IResult> ReclassifyOneAsync(string cardId, HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!HasUser(context))
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
|
||||
CardDto? card = await cardsService.GetCardAsync(cardId, ct);
|
||||
if (card is null)
|
||||
{
|
||||
return EndpointResults.NotFound(CardNotFoundDetail);
|
||||
}
|
||||
|
||||
CardReclassifier reclassifier = context.RequestServices.GetRequiredService<CardReclassifier>();
|
||||
ReclassifyResultDto result = await reclassifier.ReclassifyCardAsync(card, ct);
|
||||
await AppendReclassifyAuditAsync(context, result, ct);
|
||||
PublishReclassified(context, result);
|
||||
return Results.Ok(ToReclassifyWire(result));
|
||||
}
|
||||
|
||||
// Wire-форма итога переклассификации (camelCase; сохранены started/busy/attempted).
|
||||
// result: Итог прохода.
|
||||
// Возвращает: Объект ответа эндпоинта.
|
||||
private static object ToReclassifyWire(ReclassifyResultDto result) => new
|
||||
{
|
||||
started = result.Started,
|
||||
busy = result.Busy,
|
||||
attempted = result.Attempted,
|
||||
reclassified = result.Reclassified,
|
||||
moved = result.Moved,
|
||||
kept = result.Kept,
|
||||
trashed = result.Trashed,
|
||||
skipped = result.Skipped,
|
||||
usedAi = result.UsedAi,
|
||||
reason = result.Reason,
|
||||
};
|
||||
|
||||
// Публикует SSE cards_reclassified после успешного прохода (Ruling 5: публикации — из Api).
|
||||
// Публикуется только когда проход реально выполнен и что-то изменил (started и
|
||||
// reclassified > 0): пустой inbox/всё пропущено не меняют доску — событие не шлём. Нагрузка
|
||||
// минимальная: сколько обработано и перемещено (фронт перечитывает доску). Без подписчиков — no-op.
|
||||
// context: Контекст запроса (тенант-канал сессии).
|
||||
// result: Итог прохода.
|
||||
private static void PublishReclassified(HttpContext context, ReclassifyResultDto result)
|
||||
{
|
||||
if (!result.Started || result.Reclassified == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
SseBroker broker = context.RequestServices.GetRequiredService<SseBroker>();
|
||||
broker.Publish(
|
||||
context.GetCurrentUser()!.TenantId,
|
||||
ReclassifiedEventType,
|
||||
new { reclassified = result.Reclassified, moved = result.Moved });
|
||||
}
|
||||
|
||||
// Аудит переклассификации: пишется только когда проход реально что-то изменил.
|
||||
// context: Контекст запроса.
|
||||
// result: Итог прохода.
|
||||
// ct: Токен отмены.
|
||||
private static Task AppendReclassifyAuditAsync(HttpContext context, ReclassifyResultDto result, CancellationToken ct)
|
||||
{
|
||||
if (result.Reclassified == 0)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
return AuditAppender.AppendTenantAsync(
|
||||
context,
|
||||
AuditEvents.CardReclassified,
|
||||
new { attempted = result.Attempted, reclassified = result.Reclassified, moved = result.Moved, trashed = result.Trashed },
|
||||
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, CancellationToken ct)
|
||||
{
|
||||
if (!HasUser(context))
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
|
||||
IReadOnlyList<CardDto> leads = await cardsService.SearchCardsAsync(q, ct);
|
||||
|
||||
// messages всегда []: telegram-сообщений здесь нет, фронт их не читает.
|
||||
return Results.Ok(new { cards = leads, messages = Array.Empty<object>() });
|
||||
}
|
||||
|
||||
// ── Внутреннее ─────────────────────────────────────────────────────────
|
||||
|
||||
// Существует ли контейнер с таким id (служебная зона/стадия/доска).
|
||||
// col: Значение query-параметра containerId (непустое).
|
||||
// context: Контекст запроса (для резолва ContainersService).
|
||||
// ct: Токен отмены.
|
||||
// Возвращает: True — контейнер допустим для фильтра.
|
||||
private static async Task<bool> IsKnownContainerAsync(string col, HttpContext context, CancellationToken ct)
|
||||
{
|
||||
ContainersService containers = context.RequestServices.GetRequiredService<ContainersService>();
|
||||
return await containers.GetAsync(col, ct) is not null;
|
||||
}
|
||||
|
||||
// Разрешена ли сессия запроса (SessionMiddleware наполняет CurrentUser и tenant-контекст).
|
||||
// context: Контекст запроса.
|
||||
private static bool HasUser(HttpContext context) => context.GetCurrentUser() is not null;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace Deal.Api.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Тело POST /api/auth/change-password. Входящий JSON — camelCase (oldPassword, newPassword).
|
||||
/// </summary>
|
||||
/// <param name="OldPassword">Текущий пароль.</param>
|
||||
/// <param name="NewPassword">Новый пароль (минимум 8 символов).</param>
|
||||
public sealed record ChangePasswordRequest(string OldPassword, string NewPassword);
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace Deal.Api.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Тело POST /api/admin/check-message (1:1 с CheckMessageBody, dashboard_routes.py L72–73).
|
||||
/// </summary>
|
||||
/// <param name="Text">Текст сообщения для проверки фильтром (этап 1 + этап 2 тестера).</param>
|
||||
public sealed record CheckMessageRequest(string Text);
|
||||
@@ -0,0 +1,304 @@
|
||||
using System.Text.Json;
|
||||
using Deal.Api.Endpoints.RequestModels;
|
||||
using Deal.Api.Http;
|
||||
using Deal.Modules.Kanban.Application;
|
||||
using Deal.Modules.Kanban.Application.Models;
|
||||
using Deal.Modules.Tenants.Application;
|
||||
|
||||
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
|
||||
{
|
||||
// Префикс группы контейнеров.
|
||||
private const string ContainersGroupPrefix = "/api/containers";
|
||||
|
||||
// OpenAPI-тег группы.
|
||||
private const string OpenApiTag = "containers";
|
||||
|
||||
// 404 PATCH/accept: контейнер не найден.
|
||||
private const string ContainerNotFoundDetail = "Контейнер не найден";
|
||||
|
||||
// 400: отсутствующий/явный null name контейнера.
|
||||
private const string ContainerNameRequiredDetail = "Укажите название колонки";
|
||||
|
||||
// 400 reorder: отсутствующий/явный null order.
|
||||
private const string ContainerOrderRequiredDetail = "Не указан порядок колонок";
|
||||
|
||||
// 400 PATCH: тело не JSON-объект.
|
||||
private const string InvalidBodyDetail = "Тело запроса должно быть JSON-объектом";
|
||||
|
||||
// Опции разбора PATCH-тела: web-дефолты (camelCase + регистронезависимость).
|
||||
private static readonly JsonSerializerOptions RequestJsonOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
/// <summary>
|
||||
/// Регистрирует группы /api/containers (контейнеры + состояние колонок).
|
||||
/// </summary>
|
||||
/// <param name="app">Построитель маршрутов приложения.</param>
|
||||
/// <returns>Построитель маршрутов для цепочки вызовов.</returns>
|
||||
public static IEndpointRouteBuilder MapContainersEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var containers = app.MapGroup(ContainersGroupPrefix).WithTags(OpenApiTag);
|
||||
containers.MapGet("", ListContainersAsync);
|
||||
containers.MapPost("", CreateContainerAsync);
|
||||
containers.MapPost("/reorder", ReorderContainersAsync);
|
||||
containers.MapGet("/state", GetColumnsStateAsync);
|
||||
containers.MapPatch("/{containerId}/state", PatchColumnStateAsync);
|
||||
containers.MapPost("/{containerId}/accept", AcceptSuggestedAsync);
|
||||
containers.MapPatch("/{containerId}", PatchContainerAsync);
|
||||
containers.MapDelete("/{containerId}", DeleteContainerAsync);
|
||||
return app;
|
||||
}
|
||||
|
||||
// GET /api/containers?space=: список контейнеров пространства (или всех) со счётчиками.
|
||||
private static async Task<IResult> ListContainersAsync(string? space, HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!HasUser(context))
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
ContainersService containers = context.RequestServices.GetRequiredService<ContainersService>();
|
||||
return Results.Ok(new { items = await containers.ListAsync(space, ct) });
|
||||
}
|
||||
|
||||
// POST /api/containers: создать контейнер; ответ {id}.
|
||||
private static async Task<IResult> CreateContainerAsync(ContainerCreateRequest body, HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!HasUser(context))
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
if (body.Name is null)
|
||||
{
|
||||
return EndpointResults.BadRequest(ContainerNameRequiredDetail);
|
||||
}
|
||||
|
||||
ContainersService containers = context.RequestServices.GetRequiredService<ContainersService>();
|
||||
ContainerDto created = await containers.CreateAsync(
|
||||
new ContainerCreateDto(
|
||||
Name: body.Name,
|
||||
Description: body.Description ?? string.Empty,
|
||||
Color: body.Color,
|
||||
Space: body.Space ?? ContainerSpaces.Dashboard,
|
||||
Kind: body.Kind ?? ContainerKinds.Board,
|
||||
Suggested: body.Suggested ?? false,
|
||||
Rules: NormalizeWireRules(body.Rules),
|
||||
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 });
|
||||
}
|
||||
|
||||
// PATCH /api/containers/{id}: частичное обновление; ответ {id}; 404 «Контейнер не найден».
|
||||
private static async Task<IResult> PatchContainerAsync(string containerId, JsonElement body, HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!HasUser(context))
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
if (body.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
return EndpointResults.BadRequest(InvalidBodyDetail);
|
||||
}
|
||||
|
||||
foreach (JsonProperty property in body.EnumerateObject())
|
||||
{
|
||||
if (string.Equals(property.Name, "name", StringComparison.OrdinalIgnoreCase)
|
||||
&& property.Value.ValueKind == JsonValueKind.Null)
|
||||
{
|
||||
return EndpointResults.BadRequest(ContainerNameRequiredDetail);
|
||||
}
|
||||
}
|
||||
|
||||
ContainerPatchRequest? patchBody;
|
||||
try
|
||||
{
|
||||
patchBody = body.Deserialize<ContainerPatchRequest>(RequestJsonOptions);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return EndpointResults.BadRequest(InvalidBodyDetail);
|
||||
}
|
||||
|
||||
if (patchBody is null)
|
||||
{
|
||||
return EndpointResults.BadRequest(InvalidBodyDetail);
|
||||
}
|
||||
|
||||
ContainersService containers = context.RequestServices.GetRequiredService<ContainersService>();
|
||||
ContainerDto? updated = await containers.PatchAsync(
|
||||
containerId,
|
||||
new ContainerPatchDto(
|
||||
patchBody.Name,
|
||||
patchBody.Description,
|
||||
patchBody.Color,
|
||||
patchBody.Collapsed,
|
||||
patchBody.Suggested,
|
||||
patchBody.Note,
|
||||
NormalizeWireRules(patchBody.Rules),
|
||||
patchBody.Policy),
|
||||
ct);
|
||||
if (updated is null)
|
||||
{
|
||||
return EndpointResults.NotFound(ContainerNotFoundDetail);
|
||||
}
|
||||
|
||||
// Аудит изменения контейнера (этап 10, T1).
|
||||
await AuditAppender.AppendTenantAsync(context, AuditEvents.ContainerUpdated, new { id = updated.Id }, ct);
|
||||
return Results.Ok(new { id = updated.Id });
|
||||
}
|
||||
|
||||
// POST /api/containers/{id}/accept: принять ИИ-предложение (suggested=false).
|
||||
private static async Task<IResult> AcceptSuggestedAsync(string containerId, HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!HasUser(context))
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
ContainersService containers = context.RequestServices.GetRequiredService<ContainersService>();
|
||||
ContainerDto? accepted = await containers.AcceptSuggestedAsync(containerId, ct);
|
||||
if (accepted is null)
|
||||
{
|
||||
return EndpointResults.NotFound(ContainerNotFoundDetail);
|
||||
}
|
||||
|
||||
// Аудит изменения контейнера (принятие ИИ-предложения) — этап 10, T1.
|
||||
await AuditAppender.AppendTenantAsync(context, AuditEvents.ContainerUpdated, new { id = accepted.Id }, ct);
|
||||
return Results.Ok(accepted);
|
||||
}
|
||||
|
||||
// DELETE /api/containers/{id}: удалить контейнер; карточки → «Неразобранное» новыми.
|
||||
private static async Task<IResult> DeleteContainerAsync(string containerId, HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!HasUser(context))
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
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 });
|
||||
}
|
||||
|
||||
// POST /api/containers/reorder: порядок контейнеров пространства; ответ {ok:true}.
|
||||
private static async Task<IResult> ReorderContainersAsync(OrderBody body, HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!HasUser(context))
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
if (body.Order is null)
|
||||
{
|
||||
return EndpointResults.BadRequest(ContainerOrderRequiredDetail);
|
||||
}
|
||||
|
||||
ContainersService containers = context.RequestServices.GetRequiredService<ContainersService>();
|
||||
await containers.ReorderAsync(body.Space ?? ContainerSpaces.Dashboard, body.Order, ct);
|
||||
return Results.Ok(new { ok = true });
|
||||
}
|
||||
|
||||
// GET /api/containers/state: свёрнутость/ширина всех колонок (colState).
|
||||
private static async Task<IResult> GetColumnsStateAsync(HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!HasUser(context))
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
ContainersService containers = context.RequestServices.GetRequiredService<ContainersService>();
|
||||
IReadOnlyDictionary<string, ColumnStateDto> state = await containers.GetColStateAsync(ct);
|
||||
|
||||
var wire = new Dictionary<string, object>(StringComparer.Ordinal);
|
||||
foreach ((string colId, ColumnStateDto colState) in state)
|
||||
{
|
||||
wire[colId] = ToWireState(colState);
|
||||
}
|
||||
|
||||
return Results.Ok(wire);
|
||||
}
|
||||
|
||||
// PATCH /api/containers/{id}/state: merge патча в состояние колонки; ответ — состояние этой колонки.
|
||||
private static async Task<IResult> PatchColumnStateAsync(string containerId, ColStateBody body, HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!HasUser(context))
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
ContainersService containers = context.RequestServices.GetRequiredService<ContainersService>();
|
||||
ColumnStateDto merged = await containers.PatchColStateAsync(
|
||||
containerId,
|
||||
new ColumnStateDto(body.Collapsed, body.Width),
|
||||
ct);
|
||||
return Results.Ok(ToWireState(merged));
|
||||
}
|
||||
|
||||
// Правила из wire → каноничный ContainerRulesDto: отсутствующие группы становятся пустыми списками.
|
||||
// rules: Правила из тела запроса (null — «не меняются/нет правил»).
|
||||
// Возвращает: Каноничные правила с не-null группами; null — правил в теле нет.
|
||||
private static ContainerRulesDto? NormalizeWireRules(ContainerRulesDto? rules)
|
||||
{
|
||||
if (rules is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return rules with
|
||||
{
|
||||
Mode = rules.Mode ?? string.Empty,
|
||||
Direction = rules.Direction ?? Array.Empty<string>(),
|
||||
Keywords = rules.Keywords ?? Array.Empty<string>(),
|
||||
Stack = rules.Stack ?? Array.Empty<string>(),
|
||||
Grade = rules.Grade ?? Array.Empty<string>(),
|
||||
Exclude = rules.Exclude ?? Array.Empty<string>(),
|
||||
Budget = rules.Budget is null ? null : rules.Budget with { Cur = rules.Budget.Cur ?? string.Empty },
|
||||
Levels = rules.Levels ?? Array.Empty<string>(),
|
||||
Locations = rules.Locations ?? Array.Empty<string>(),
|
||||
Types = rules.Types ?? Array.Empty<string>(),
|
||||
Prices = rules.Prices is null ? null : rules.Prices with { Cur = rules.Prices.Cur ?? string.Empty },
|
||||
};
|
||||
}
|
||||
|
||||
// Состояние колонки → wire-объект только с заданными полями (collapsed/width), без null.
|
||||
// state: Состояние колонки (могут быть null-поля).
|
||||
// Возвращает: Словарь из не-null полей состояния.
|
||||
private static Dictionary<string, object> ToWireState(ColumnStateDto state)
|
||||
{
|
||||
var wire = new Dictionary<string, object>();
|
||||
if (state.Collapsed is { } collapsed)
|
||||
{
|
||||
wire["collapsed"] = collapsed;
|
||||
}
|
||||
|
||||
if (state.Width is not null)
|
||||
{
|
||||
wire["width"] = state.Width;
|
||||
}
|
||||
|
||||
return wire;
|
||||
}
|
||||
|
||||
// Разрешена ли сессия запроса.
|
||||
// context: Контекст запроса.
|
||||
// Возвращает: True — сессия есть.
|
||||
private static bool HasUser(HttpContext context) => context.GetCurrentUser() is not null;
|
||||
}
|
||||
@@ -0,0 +1,540 @@
|
||||
using System.Text.Json;
|
||||
using Deal.Api.Endpoints.RequestModels;
|
||||
using Deal.Api.Http;
|
||||
using Deal.Contracts.Integrations;
|
||||
using Deal.Contracts.Integrations.Models;
|
||||
using Deal.Modules.Discovery.Application;
|
||||
using Deal.Modules.Discovery.Application.Models;
|
||||
using Deal.Modules.Settings.Application;
|
||||
using Deal.Modules.Settings.Application.Models;
|
||||
using Deal.Modules.Telegram.Application;
|
||||
|
||||
namespace Deal.Api.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Эндпоинты /api/discovery: задачи поиска, кандидаты, чёрный список, лог, генерация ключей (Ruling 11, api-map §3.8).
|
||||
/// </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).
|
||||
private const string TasksPath = "/tasks";
|
||||
|
||||
// Путь одной задачи: обновление (PATCH) / удаление (DELETE).
|
||||
private const string TaskPath = "/tasks/{task_id}";
|
||||
|
||||
// Путь запуска поиска (POST).
|
||||
private const string TaskStartPath = "/tasks/{task_id}/start";
|
||||
|
||||
// Путь паузы поиска (POST).
|
||||
private const string TaskPausePath = "/tasks/{task_id}/pause";
|
||||
|
||||
// Путь ИИ-генерации ключевых слов по описанию задачи (POST).
|
||||
private const string TaskGenerateKeywordsPath = "/tasks/{task_id}/generate-keywords";
|
||||
|
||||
// Путь списка кандидатов задачи со статус-фильтром (GET).
|
||||
private const string TaskCandidatesPath = "/tasks/{task_id}/candidates";
|
||||
|
||||
// Путь ручного вступления в кандидата (POST).
|
||||
private const string CandidateJoinPath = "/candidates/{dialog_id}/join";
|
||||
|
||||
// Путь отклонения кандидата в чёрный список (POST).
|
||||
private const string CandidateRejectPath = "/candidates/{dialog_id}/reject";
|
||||
|
||||
// Путь чёрного списка (GET) и снятия записи (DELETE).
|
||||
private const string BlacklistPath = "/blacklist";
|
||||
|
||||
// Путь записи чёрного списка (DELETE).
|
||||
private const string BlacklistItemPath = "/blacklist/{dialog_id}";
|
||||
|
||||
// Путь лога задачи (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).
|
||||
/// </summary>
|
||||
/// <param name="app">Построитель маршрутов приложения.</param>
|
||||
/// <returns>Построитель маршрутов для цепочки вызовов.</returns>
|
||||
public static IEndpointRouteBuilder MapDiscoveryEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup(DiscoveryGroupPrefix).WithTags(DiscoveryOpenApiTag);
|
||||
|
||||
group.MapGet(TasksPath, ListTasksAsync);
|
||||
group.MapPost(TasksPath, CreateTaskAsync);
|
||||
group.MapPatch(TaskPath, PatchTaskAsync);
|
||||
group.MapDelete(TaskPath, DeleteTaskAsync);
|
||||
group.MapPost(TaskStartPath, StartTaskAsync);
|
||||
group.MapPost(TaskPausePath, PauseTaskAsync);
|
||||
group.MapPost(TaskGenerateKeywordsPath, GenerateKeywordsAsync);
|
||||
group.MapGet(TaskCandidatesPath, ListCandidatesAsync);
|
||||
group.MapPost(CandidateJoinPath, JoinCandidateAsync);
|
||||
group.MapPost(CandidateRejectPath, RejectCandidateAsync);
|
||||
group.MapGet(BlacklistPath, ListBlacklistAsync);
|
||||
group.MapDelete(BlacklistItemPath, RemoveBlacklistAsync);
|
||||
group.MapGet(TaskLogPath, TaskLogAsync);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
// GET /api/discovery/tasks: список задач, старые первыми (list_tasks L141–143).
|
||||
private static async Task<IResult> ListTasksAsync(HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!HasUser(context))
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
DiscoveryTasksService tasks = context.RequestServices.GetRequiredService<DiscoveryTasksService>();
|
||||
IReadOnlyList<DiscoveryTaskDto> items = await tasks.ListAsync(ct);
|
||||
return Results.Ok(new { items });
|
||||
}
|
||||
|
||||
// POST /api/discovery/tasks: создать задачу поиска (create_task L146–151; дефолты — в сервисе).
|
||||
private static async Task<IResult> CreateTaskAsync(DiscoveryTaskCreateBody body, HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!HasUser(context))
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
DiscoveryTasksService tasks = context.RequestServices.GetRequiredService<DiscoveryTasksService>();
|
||||
DiscoveryTaskDto task = await tasks.CreateAsync(ToDraft(body), ct);
|
||||
return Results.Ok(task);
|
||||
}
|
||||
catch (DiscoveryValidationException exception)
|
||||
{
|
||||
return EndpointResults.BadRequest(exception.Message);
|
||||
}
|
||||
}
|
||||
|
||||
// PATCH /api/discovery/tasks/{task_id}: обновить задачу (patch_task L154–161; 404/400).
|
||||
private static async Task<IResult> PatchTaskAsync(string task_id, DiscoveryTaskPatchBody body, HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!HasUser(context))
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
DiscoveryTasksService tasks = context.RequestServices.GetRequiredService<DiscoveryTasksService>();
|
||||
DiscoveryTaskDto? task = await tasks.PatchAsync(task_id, ToPatch(body), ct);
|
||||
return task is null ? EndpointResults.NotFound(TaskNotFoundDetail) : Results.Ok(task);
|
||||
}
|
||||
catch (DiscoveryValidationException exception)
|
||||
{
|
||||
return EndpointResults.BadRequest(exception.Message);
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /api/discovery/tasks/{task_id}: удалить задачу с кандидатами и логом (delete_task L164–168).
|
||||
private static async Task<IResult> DeleteTaskAsync(string task_id, HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!HasUser(context))
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
DiscoveryTasksService tasks = context.RequestServices.GetRequiredService<DiscoveryTasksService>();
|
||||
bool deleted = await tasks.DeleteAsync(task_id, ct);
|
||||
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, CancellationToken ct)
|
||||
{
|
||||
if (!HasUser(context))
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
DiscoveryTasksService tasks = context.RequestServices.GetRequiredService<DiscoveryTasksService>();
|
||||
DiscoveryTaskDto? task = await tasks.StartAsync(task_id, ct);
|
||||
return task is null ? EndpointResults.NotFound(TaskNotFoundDetail) : Results.Ok(task);
|
||||
}
|
||||
catch (DiscoveryValidationException exception)
|
||||
{
|
||||
return EndpointResults.BadRequest(exception.Message);
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/discovery/tasks/{task_id}/pause: пауза поиска (pause_task L181–187).
|
||||
private static async Task<IResult> PauseTaskAsync(string task_id, HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!HasUser(context))
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
DiscoveryTasksService tasks = context.RequestServices.GetRequiredService<DiscoveryTasksService>();
|
||||
DiscoveryTaskDto? task = await tasks.PauseAsync(task_id, ct);
|
||||
return task is null ? EndpointResults.NotFound(TaskNotFoundDetail) : Results.Ok(task);
|
||||
}
|
||||
|
||||
// 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(string task_id, HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!HasUser(context))
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
DiscoveryTasksService tasks = context.RequestServices.GetRequiredService<DiscoveryTasksService>();
|
||||
DiscoveryTaskDto? task = await tasks.GetAsync(task_id, ct);
|
||||
if (task is null)
|
||||
{
|
||||
return EndpointResults.NotFound(TaskNotFoundDetail);
|
||||
}
|
||||
|
||||
ISettingsStore settings = context.RequestServices.GetRequiredService<ISettingsStore>();
|
||||
if (!await ReadAiEnabledAsync(settings, ct))
|
||||
{
|
||||
return Results.Ok(new { keywords = Array.Empty<string>(), error = AiDisabledDetail });
|
||||
}
|
||||
|
||||
string description = (task.Description ?? string.Empty).Trim();
|
||||
if (description.Length == 0)
|
||||
{
|
||||
return Results.Ok(new { keywords = Array.Empty<string>(), error = NoDescriptionDetail });
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
IAiTools aiTools = context.RequestServices.GetRequiredService<IAiTools>();
|
||||
AiGenerateKeywordsResultDto result = await aiTools.GenerateKeywordsAsync(description, ct);
|
||||
if (result.Ok)
|
||||
{
|
||||
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, HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!HasUser(context))
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
DiscoveryTasksService tasks = context.RequestServices.GetRequiredService<DiscoveryTasksService>();
|
||||
DiscoveryTaskDto? task = await tasks.GetAsync(task_id, ct);
|
||||
if (task is null)
|
||||
{
|
||||
return EndpointResults.NotFound(TaskNotFoundDetail);
|
||||
}
|
||||
|
||||
DiscoveryCandidatesService candidates = context.RequestServices.GetRequiredService<DiscoveryCandidatesService>();
|
||||
IReadOnlyList<DiscoveryCandidateDto> items = await candidates.ListAsync(task_id, status, ct);
|
||||
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(string dialog_id, HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!HasUser(context))
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
DiscoveryCandidatesService candidates = context.RequestServices.GetRequiredService<DiscoveryCandidatesService>();
|
||||
DiscoveryCandidateDto? row = await candidates.GetAsync(dialog_id, ct);
|
||||
if (row is null)
|
||||
{
|
||||
return EndpointResults.NotFound(CandidateNotFoundDetail);
|
||||
}
|
||||
|
||||
if (row.Status == DiscoveryCandidateStatuses.Joined)
|
||||
{
|
||||
return EndpointResults.BadRequest(AlreadyJoinedDetail);
|
||||
}
|
||||
|
||||
string username = (row.Username ?? string.Empty).Trim().TrimStart('@');
|
||||
try
|
||||
{
|
||||
ITelegramGateway gateway = context.RequestServices.GetRequiredService<ITelegramGateway>();
|
||||
await gateway.JoinAsync(username, ct);
|
||||
}
|
||||
catch (Exception exception) when (exception is not OperationCanceledException)
|
||||
{
|
||||
string reason = TelegramEndpoints.GatewayErrorText(exception);
|
||||
return EndpointResults.BadRequest(string.Format(JoinFailedFormat, username, reason));
|
||||
}
|
||||
|
||||
// Источник в каталоге (монитор 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>();
|
||||
await blacklist.RemoveAsync(dialog_id, ct);
|
||||
|
||||
try
|
||||
{
|
||||
DiscoveryCandidateDto? joined = await candidates.MarkJoinedAsync(dialog_id, auto: false, ct);
|
||||
return joined is null ? EndpointResults.NotFound(CandidateNotFoundDetail) : Results.Ok(joined);
|
||||
}
|
||||
catch (DiscoveryValidationException exception)
|
||||
{
|
||||
return EndpointResults.BadRequest(exception.Message);
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/discovery/candidates/{dialog_id}/reject: отклонить кандидата — в чёрный список
|
||||
// (reject_candidate L253–264; уже вступившего — нельзя, 400).
|
||||
private static async Task<IResult> RejectCandidateAsync(string dialog_id, HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!HasUser(context))
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
DiscoveryCandidatesService candidates = context.RequestServices.GetRequiredService<DiscoveryCandidatesService>();
|
||||
DiscoveryCandidateDto? row = await candidates.GetAsync(dialog_id, ct);
|
||||
if (row is null)
|
||||
{
|
||||
return EndpointResults.NotFound(CandidateNotFoundDetail);
|
||||
}
|
||||
|
||||
if (row.Status == DiscoveryCandidateStatuses.Joined)
|
||||
{
|
||||
return EndpointResults.BadRequest(JoinedRejectDetail);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
DiscoveryCandidateDto? rejected = await candidates.MarkRejectedAsync(dialog_id, ManualRejectReason, ct);
|
||||
return rejected is null ? EndpointResults.NotFound(CandidateNotFoundDetail) : Results.Ok(rejected);
|
||||
}
|
||||
catch (DiscoveryValidationException exception)
|
||||
{
|
||||
return EndpointResults.BadRequest(exception.Message);
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/discovery/blacklist: чёрный список источников (list_blacklist L269–271).
|
||||
private static async Task<IResult> ListBlacklistAsync(HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!HasUser(context))
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
DiscoveryBlacklistService blacklist = context.RequestServices.GetRequiredService<DiscoveryBlacklistService>();
|
||||
IReadOnlyList<DiscoveryBlacklistDto> items = await blacklist.ListAsync(ct);
|
||||
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, CancellationToken ct)
|
||||
{
|
||||
if (!HasUser(context))
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
DiscoveryBlacklistService blacklist = context.RequestServices.GetRequiredService<DiscoveryBlacklistService>();
|
||||
await blacklist.RemoveAsync(dialog_id, ct);
|
||||
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, CancellationToken ct)
|
||||
{
|
||||
if (!HasUser(context))
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
DiscoveryTasksService tasks = context.RequestServices.GetRequiredService<DiscoveryTasksService>();
|
||||
DiscoveryTaskDto? task = await tasks.GetAsync(task_id, ct);
|
||||
if (task is null)
|
||||
{
|
||||
return EndpointResults.NotFound(TaskNotFoundDetail);
|
||||
}
|
||||
|
||||
DiscoveryLogService log = context.RequestServices.GetRequiredService<DiscoveryLogService>();
|
||||
IReadOnlyList<DiscoveryLogDto> items = await log.TaskLogAsync(task_id, ct);
|
||||
return Results.Ok(new { items });
|
||||
}
|
||||
|
||||
/// <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)
|
||||
{
|
||||
if (raw is null)
|
||||
{
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
|
||||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var outList = new List<string>();
|
||||
foreach (string? item in raw)
|
||||
{
|
||||
if (item is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
string keyword = item.Trim();
|
||||
if (keyword.Length == 0 || keyword.Length > KeywordLengthLimit || !seen.Add(keyword))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
outList.Add(keyword);
|
||||
if (outList.Count >= KeywordsLimit)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
if (row is null)
|
||||
{
|
||||
return SettingsDefaults.AiEnabled;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using JsonDocument document = JsonDocument.Parse(row.ValueJson);
|
||||
return document.RootElement.ValueKind is JsonValueKind.True or JsonValueKind.False
|
||||
? document.RootElement.GetBoolean()
|
||||
: SettingsDefaults.AiEnabled;
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return SettingsDefaults.AiEnabled;
|
||||
}
|
||||
}
|
||||
|
||||
// Маппит тело создания в черновик сервиса (значения пограничные передаются как есть — дефолты в сервисе).
|
||||
// body: Тело запроса (wire-поля camelCase).
|
||||
// Возвращает: Черновик создания задачи (DiscoveryTaskDraft).
|
||||
private static DiscoveryTaskDraft ToDraft(DiscoveryTaskCreateBody body)
|
||||
{
|
||||
return new DiscoveryTaskDraft
|
||||
{
|
||||
Name = body.Name,
|
||||
Description = body.Description ?? string.Empty,
|
||||
Keywords = body.Keywords,
|
||||
MinSubscribers = body.MinSubscribers,
|
||||
Lang = body.Lang,
|
||||
Threshold = body.Threshold,
|
||||
SampleSize = body.SampleSize,
|
||||
PlanJoins = body.PlanJoins,
|
||||
AutoJoin = body.AutoJoin,
|
||||
};
|
||||
}
|
||||
|
||||
// Маппит тело патча в сервисный патч (не-null значения; как python model_dump(exclude_none=True)).
|
||||
// body: Тело запроса (wire-поля camelCase).
|
||||
// Возвращает: Патч задачи (DiscoveryTaskPatch).
|
||||
private static DiscoveryTaskPatch ToPatch(DiscoveryTaskPatchBody body)
|
||||
{
|
||||
return new DiscoveryTaskPatch
|
||||
{
|
||||
Name = body.Name,
|
||||
Description = body.Description,
|
||||
Keywords = body.Keywords,
|
||||
MinSubscribers = body.MinSubscribers,
|
||||
Lang = body.Lang,
|
||||
Threshold = body.Threshold,
|
||||
SampleSize = body.SampleSize,
|
||||
PlanJoins = body.PlanJoins,
|
||||
AutoJoin = body.AutoJoin,
|
||||
};
|
||||
}
|
||||
|
||||
// Разрешена ли сессия запроса (SessionMiddleware наполняет CurrentUser и tenant-контекст).
|
||||
// context: Контекст запроса.
|
||||
private static bool HasUser(HttpContext context) => context.GetCurrentUser() is not null;
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
using Deal.Api.Events;
|
||||
using Deal.Api.Http;
|
||||
|
||||
namespace Deal.Api.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// SSE-поток событий канбана: GET /api/events (Ruling 5; прототип events_routes.py L15–38).
|
||||
/// </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>
|
||||
/// Регистрирует GET /api/events.
|
||||
/// </summary>
|
||||
/// <param name="app">Построитель маршрутов приложения.</param>
|
||||
/// <returns>Построитель маршрутов для цепочки вызовов.</returns>
|
||||
public static IEndpointRouteBuilder MapEventsEndpoint(this IEndpointRouteBuilder app)
|
||||
{
|
||||
app.MapGet(EventsPath, StreamEventsAsync).WithTags(OpenApiTag);
|
||||
return app;
|
||||
}
|
||||
|
||||
// GET /api/events: поток text/event-stream канала тенанта сессии.
|
||||
// context: Контекст запроса (сессия — HttpContext.Items).
|
||||
// ct: Отмена запроса: клиент отвалился — завершаем поток и отписываемся.
|
||||
private static async Task StreamEventsAsync(HttpContext context, CancellationToken ct)
|
||||
{
|
||||
CurrentUser? user = context.GetCurrentUser();
|
||||
if (user is null)
|
||||
{
|
||||
await EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail).ExecuteAsync(context);
|
||||
return;
|
||||
}
|
||||
|
||||
SseBroker broker = context.RequestServices.GetRequiredService<SseBroker>();
|
||||
SseSubscription subscription = broker.Subscribe(user.TenantId);
|
||||
try
|
||||
{
|
||||
HttpResponse response = context.Response;
|
||||
response.ContentType = EventStreamContentType;
|
||||
response.Headers["Cache-Control"] = NoCacheHeaderValue;
|
||||
response.Headers["X-Accel-Buffering"] = NoBufferingHeaderValue;
|
||||
|
||||
while (true)
|
||||
{
|
||||
using var pingTimeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
pingTimeout.CancelAfter(PingInterval);
|
||||
try
|
||||
{
|
||||
await subscription.Events.WaitToReadAsync(pingTimeout.Token);
|
||||
}
|
||||
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
|
||||
{
|
||||
// Тишина 15 с — ping держит соединение; отмену клиента ловит внешний catch.
|
||||
await WriteFrameAsync(response, PingComment, ct);
|
||||
continue;
|
||||
}
|
||||
|
||||
while (subscription.Events.TryRead(out SseEvent? sseEvent))
|
||||
{
|
||||
await WriteFrameAsync(response, sseEvent.RenderFrame(), ct);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||
{
|
||||
// Клиент закрыл соединение — штатное завершение потока.
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// Сброс соединения клиентом (закрытая вкладка/обрыв сети): ответ уже не доставить.
|
||||
}
|
||||
finally
|
||||
{
|
||||
broker.Unsubscribe(user.TenantId, subscription.Id);
|
||||
}
|
||||
}
|
||||
|
||||
// Пишет frame в поток ответа и сбрасывает буфер — события уходят сразу (не пачкой).
|
||||
// response: Ответ (stream уже начат).
|
||||
// frame: Frame протокола SSE.
|
||||
// ct: Токен отмены запроса.
|
||||
private static async Task WriteFrameAsync(HttpResponse response, string frame, CancellationToken ct)
|
||||
{
|
||||
await response.WriteAsync(frame, ct);
|
||||
await response.Body.FlushAsync(ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
using Deal.Api.Http;
|
||||
using Deal.Modules.Settings.Application;
|
||||
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>
|
||||
/// Регистрирует POST /api/admin/check-message.
|
||||
/// </summary>
|
||||
/// <param name="app">Построитель маршрутов приложения.</param>
|
||||
/// <returns>Построитель маршрутов для цепочки вызовов.</returns>
|
||||
public static IEndpointRouteBuilder MapFilterTesterEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup(ApiGroupPrefix).WithTags(OpenApiTag);
|
||||
group.MapPost(CheckMessagePath, CheckAsync);
|
||||
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, CancellationToken ct)
|
||||
{
|
||||
if (!HasUser(context))
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
// Резолв после 401-гейта: IncomingRules — scoped на TenantDbContext (tenant-контекст запроса).
|
||||
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
|
||||
{
|
||||
stage1 = new { pass = stage1.Pass, reason = stage1.Reason },
|
||||
stage2 = new { pass = false, reason = (string?)null, skipped = true },
|
||||
passed = false,
|
||||
});
|
||||
}
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
stage1 = new { pass = stage1.Pass, reason = stage1.Reason },
|
||||
stage2 = new { pass = true, reason = (string?)null, skipped = true },
|
||||
passed = true,
|
||||
});
|
||||
}
|
||||
|
||||
// Разрешена ли сессия запроса (SessionMiddleware наполняет CurrentUser и tenant-контекст).
|
||||
// context: Контекст запроса.
|
||||
private static bool HasUser(HttpContext context) => context.GetCurrentUser() is not null;
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
using Deal.Api.Http;
|
||||
using Deal.Modules.Tenants.Application;
|
||||
using Deal.Modules.Tenants.Application.Models;
|
||||
|
||||
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 — публичная).
|
||||
private const string JoinPath = "/api/join";
|
||||
|
||||
// OpenAPI-тег.
|
||||
private const string JoinOpenApiTag = "join";
|
||||
|
||||
// Текст 400: приглашение с таким кодом не найдено.
|
||||
private const string InviteNotFoundDetail = "Приглашение не найдено";
|
||||
|
||||
// Текст 400: срок действия приглашения истёк (план Task 6, Ruling 2).
|
||||
private const string InviteExpiredDetail = "Срок действия приглашения истёк";
|
||||
|
||||
// Текст 400: приглашение уже активировано (повторная активация тем же кодом).
|
||||
private const string InviteUsedDetail = "Приглашение уже использовано";
|
||||
|
||||
// Текст 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).
|
||||
private const string TenantNotFoundDetail = "Тенант приглашения не найден";
|
||||
|
||||
// Текст 400: целевой тенант инвайта приостановлен (Security review).
|
||||
private const string TenantSuspendedDetail = "Тенант приглашения приостановлен";
|
||||
|
||||
/// <summary>
|
||||
/// Регистрирует POST /api/join.
|
||||
/// </summary>
|
||||
/// <param name="app">Построитель маршрутов приложения.</param>
|
||||
/// <returns>Построитель маршрутов для цепочки вызовов.</returns>
|
||||
public static IEndpointRouteBuilder MapJoinEndpoint(this IEndpointRouteBuilder app)
|
||||
{
|
||||
app.MapPost(JoinPath, JoinAsync).WithTags(JoinOpenApiTag);
|
||||
return app;
|
||||
}
|
||||
|
||||
// POST /api/join: активация инвайта; успех пишется в аудит (invite_activated, Task 6/Ruling 4).
|
||||
private static async Task<IResult> JoinAsync(
|
||||
JoinRequest body,
|
||||
JoinService joinService,
|
||||
AuditService auditService,
|
||||
HttpContext context,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var result = await joinService.ActivateAsync(body.Code, body.Email, body.Name, body.Password, ct);
|
||||
if (!result.Ok || result.Login is null || result.UserId is null || result.TenantId is null)
|
||||
{
|
||||
return EndpointResults.BadRequest(DetailFor(result.Error));
|
||||
}
|
||||
|
||||
await auditService.AppendAsync(new AuditRecordDto(
|
||||
AuditEvents.InviteJoined,
|
||||
AuditActorTypes.Tenant,
|
||||
ActorId: result.UserId,
|
||||
TenantId: result.TenantId,
|
||||
Ip: ClientIp(context),
|
||||
// Код инвайта — capability-токен: в аудит пишется только SHA-256-хэш (Security review).
|
||||
DetailJson: AuditService.ToDetailJson(new { email = result.Login, codeHash = SessionTokens.HashToken(body.Code?.Trim() ?? string.Empty) })), ct);
|
||||
|
||||
return Results.Ok(new { ok = true, login = result.Login });
|
||||
}
|
||||
|
||||
// Фиксированный текст 400 по коду ошибки JoinService (все отказы активации — 400).
|
||||
// error: Код ошибки JoinResultDto.
|
||||
// Возвращает: Текст детали ошибки.
|
||||
private static string DetailFor(string? error) =>
|
||||
error switch
|
||||
{
|
||||
JoinResultDto.ErrorExpired => InviteExpiredDetail,
|
||||
JoinResultDto.ErrorUsed => InviteUsedDetail,
|
||||
JoinResultDto.ErrorRevoked => InviteRevokedDetail,
|
||||
JoinResultDto.ErrorEmailMismatch => EmailMismatchDetail,
|
||||
JoinResultDto.ErrorEmailTaken => EmailTakenDetail,
|
||||
JoinResultDto.ErrorPasswordTooShort => PasswordTooShortDetail,
|
||||
JoinResultDto.ErrorTenantNotFound => TenantNotFoundDetail,
|
||||
JoinResultDto.ErrorTenantSuspended => TenantSuspendedDetail,
|
||||
_ => InviteNotFoundDetail,
|
||||
};
|
||||
|
||||
// IP-адрес клиента для аудита (без порта; null, если недоступен).
|
||||
// context: Контекст запроса.
|
||||
// Возвращает: Строковое представление IP или null.
|
||||
private static string? ClientIp(HttpContext context) => context.Connection.RemoteIpAddress?.ToString();
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace Deal.Api.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Тело POST /api/join — активация инвайта (Ruling 2, Task 6 этапа 7). Входящий JSON — camelCase (code, email, name?, password).
|
||||
/// </summary>
|
||||
/// <param name="Code">Код приглашения (16 url-safe символов).</param>
|
||||
/// <param name="Email">Email активирующего; обязан совпасть с email приглашения (нормализует JoinService).</param>
|
||||
/// <param name="Name">Имя нового тенанта (только когда у инвайта нет целевого тенанта); null — имя = email.</param>
|
||||
/// <param name="Password">Пароль пользователя (минимум 8 символов).</param>
|
||||
public sealed record JoinRequest(string? Code, string? Email, string? Name, string? Password);
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace Deal.Api.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Тело POST /api/auth/login. Входящий JSON — camelCase (login, password).
|
||||
/// </summary>
|
||||
/// <param name="Login">Логин пользователя.</param>
|
||||
/// <param name="Password">Пароль в открытом виде.</param>
|
||||
public sealed record LoginRequest(string Login, string Password);
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace Deal.Api.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Тело POST /api/ml/apply. Входящий JSON — camelCase (dialogId, msgId, action).
|
||||
/// </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>
|
||||
public sealed record MlApplyRequest(string DialogId, int MsgId, string Action);
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace Deal.Api.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Тело POST /api/ml/candidates. Входящий JSON — camelCase (dialogId, limit).
|
||||
/// </summary>
|
||||
/// <param name="DialogId">Id диалога/канала Telegram; пусто — выборка по всем источникам тенанта (§8).</param>
|
||||
/// <param name="Limit">Сколько последних сообщений вернуть (кламп 1..60, дефолт 10).</param>
|
||||
public sealed record MlCandidatesRequest(string DialogId, int Limit);
|
||||
@@ -0,0 +1,182 @@
|
||||
using Deal.Api.Http;
|
||||
using Deal.Contracts.Integrations;
|
||||
using Deal.Contracts.Integrations.Models;
|
||||
using Deal.Modules.Pipeline.Application;
|
||||
using Deal.Modules.Pipeline.Application.Models;
|
||||
|
||||
namespace Deal.Api.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Эндпоинты ML-панели: GET /api/ml/status, POST /api/ml/reset, /predict, /candidates, /apply (Ruling 8, api-map §3.7).
|
||||
/// </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).
|
||||
private const string StatusPath = "/status";
|
||||
|
||||
// Путь сброса модели (POST).
|
||||
private const string ResetPath = "/reset";
|
||||
|
||||
// Путь проверки ML на тексте (POST).
|
||||
private const string PredictPath = "/predict";
|
||||
|
||||
// Путь разбора сообщений канала (POST).
|
||||
private const string CandidatesPath = "/candidates";
|
||||
|
||||
// Путь ручного решения по сообщению (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.
|
||||
/// </summary>
|
||||
/// <param name="app">Построитель маршрутов приложения.</param>
|
||||
/// <returns>Построитель маршрутов для цепочки вызовов.</returns>
|
||||
public static IEndpointRouteBuilder MapMlEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup(MlGroupPrefix).WithTags(MlOpenApiTag);
|
||||
|
||||
group.MapGet(StatusPath, StatusAsync);
|
||||
group.MapPost(ResetPath, ResetAsync);
|
||||
group.MapPost(PredictPath, PredictAsync);
|
||||
group.MapPost(CandidatesPath, CandidatesAsync);
|
||||
group.MapPost(ApplyPath, ApplyAsync);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
// GET /api/ml/status: статус ML-сервиса + локальная статистика (ml_routes.py L66–75).
|
||||
private static async Task<IResult> StatusAsync(HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!HasUser(context))
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
IMlClient mlClient = context.RequestServices.GetRequiredService<IMlClient>();
|
||||
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 (!HasUser(context))
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
IMlClient mlClient = context.RequestServices.GetRequiredService<IMlClient>();
|
||||
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, CancellationToken ct)
|
||||
{
|
||||
if (!HasUser(context))
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
string text = (body.Text ?? string.Empty).Trim();
|
||||
if (text.Length < MinPredictTextLength)
|
||||
{
|
||||
return EndpointResults.BadRequest(EnterTextDetail);
|
||||
}
|
||||
|
||||
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];
|
||||
return Results.Ok(new
|
||||
{
|
||||
text = preview,
|
||||
take = result.Take,
|
||||
label = result.Label,
|
||||
scores = result.Scores,
|
||||
hits = result.Hits,
|
||||
ready = result.Ready,
|
||||
margin = result.Margin,
|
||||
terms = result.Terms,
|
||||
type = result.Type,
|
||||
});
|
||||
}
|
||||
|
||||
// POST /api/ml/candidates: последние сообщения канала + мнение ML (ml_routes.py L112–134).
|
||||
private static async Task<IResult> CandidatesAsync(MlCandidatesRequest body, HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!HasUser(context))
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
MlReviewService review = context.RequestServices.GetRequiredService<MlReviewService>();
|
||||
IReadOnlyList<MlCandidateDto> items = await review.CandidatesAsync(body.DialogId, body.Limit, ct);
|
||||
return Results.Ok(new { items });
|
||||
}
|
||||
|
||||
// POST /api/ml/apply: ручное решение по сообщению (ml_routes.py L137–171).
|
||||
private static async Task<IResult> ApplyAsync(MlApplyRequest body, HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!HasUser(context))
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
MlReviewService review = context.RequestServices.GetRequiredService<MlReviewService>();
|
||||
MlApplyResult? result = await review.ApplyAsync(body.DialogId, body.MsgId, body.Action, ct);
|
||||
if (result is null)
|
||||
{
|
||||
return EndpointResults.NotFound(MessageNotFoundDetail);
|
||||
}
|
||||
|
||||
if (result.Error is not null)
|
||||
{
|
||||
return EndpointResults.BadRequest(result.Error);
|
||||
}
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
ok = result.Ok,
|
||||
learned = result.Learned,
|
||||
moved = result.Moved,
|
||||
leadId = result.LeadId,
|
||||
});
|
||||
}
|
||||
|
||||
// Разрешена ли сессия запроса (SessionMiddleware наполняет CurrentUser и tenant-контекст).
|
||||
// context: Контекст запроса.
|
||||
private static bool HasUser(HttpContext context) => context.GetCurrentUser() is not null;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace Deal.Api.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Тело POST /api/ml/predict. Входящий JSON — camelCase (text).
|
||||
/// </summary>
|
||||
/// <param name="Text">Текст сообщения для проверки ML (обрезается/тримится обработчиком, как ml_routes.py L86).</param>
|
||||
public sealed record MlPredictRequest(string Text);
|
||||
@@ -0,0 +1,168 @@
|
||||
using Deal.Api.Http;
|
||||
using Deal.Modules.Tenants.Application;
|
||||
using Deal.Modules.Tenants.Application.Models;
|
||||
|
||||
namespace Deal.Api.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Операторские read-only эндпоинты аналитики: /api/operator/analytics/{overview,tokens,activity} (этап 10, T3).
|
||||
/// </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-тег группы.
|
||||
private const string OperatorOpenApiTag = "operator";
|
||||
|
||||
// Группировка расхода токенов по умолчанию (сутки).
|
||||
private const string DefaultGroupBy = TokenUsageGroupBys.Day;
|
||||
|
||||
// 400 tokens: неизвестная группировка.
|
||||
private const string InvalidGroupByDetail = "Неизвестная группировка (day|tenant|provider|model)";
|
||||
|
||||
/// <summary>
|
||||
/// Регистрирует группу /api/operator/analytics: overview/tokens/activity.
|
||||
/// </summary>
|
||||
/// <param name="app">Построитель маршрутов приложения.</param>
|
||||
/// <returns>Построитель маршрутов для цепочки вызовов.</returns>
|
||||
public static IEndpointRouteBuilder MapOperatorAnalyticsEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup(AnalyticsGroupPrefix).WithTags(OperatorOpenApiTag);
|
||||
group.MapGet("/overview", OverviewAsync);
|
||||
group.MapGet("/tokens", TokensAsync);
|
||||
group.MapGet("/activity", ActivityAsync);
|
||||
group.MapGet("/suspicious", SuspiciousAsync);
|
||||
return app;
|
||||
}
|
||||
|
||||
// GET /api/operator/analytics/suspicious?from=&to=: находки детектора подозрительной активности (§10.5).
|
||||
// from: Начало окна анализа (включительно; ISO-8601); null — последние 24 часа.
|
||||
// to: Конец окна анализа (включительно; ISO-8601); null — «сейчас».
|
||||
// context: Контекст запроса.
|
||||
// suspiciousService: Детектор подозрительной активности (scoped).
|
||||
// ct: Токен отмены.
|
||||
// Возвращает: 200 сводка находок или 401 без операторской сессии.
|
||||
private static async Task<IResult> SuspiciousAsync(
|
||||
DateTimeOffset? from,
|
||||
DateTimeOffset? to,
|
||||
HttpContext context,
|
||||
SuspiciousActivityService suspiciousService,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (context.GetCurrentOperator() is null)
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail);
|
||||
}
|
||||
|
||||
SuspiciousActivityDto report = await suspiciousService.AnalyzeAsync(from, to, ct);
|
||||
return Results.Ok(report);
|
||||
}
|
||||
|
||||
// GET /api/operator/analytics/overview?from=&to=: сводка (тенанты, токены, события, входы/выходы).
|
||||
// from: Начало периода (включительно; ISO-8601).
|
||||
// to: Конец периода (включительно; ISO-8601).
|
||||
// context: Контекст запроса.
|
||||
// analyticsService: Сервис аналитики (scoped).
|
||||
// ct: Токен отмены.
|
||||
// Возвращает: 200 сводка или 401 без операторской сессии.
|
||||
private static async Task<IResult> OverviewAsync(
|
||||
DateTimeOffset? from,
|
||||
DateTimeOffset? to,
|
||||
HttpContext context,
|
||||
AnalyticsService analyticsService,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (context.GetCurrentOperator() is null)
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail);
|
||||
}
|
||||
|
||||
AnalyticsOverviewDto overview = await analyticsService.OverviewAsync(from, to, ct);
|
||||
return Results.Ok(overview);
|
||||
}
|
||||
|
||||
// GET /api/operator/analytics/tokens?groupBy=&tenantId=&from=&to=: агрегаты расхода токенов.
|
||||
// groupBy: Группировка day|tenant|provider|model (дефолт day).
|
||||
// tenantId: Тенант (равенство; пусто — все тенанты).
|
||||
// from: Начало периода (включительно; ISO-8601).
|
||||
// to: Конец периода (включительно; ISO-8601).
|
||||
// context: Контекст запроса.
|
||||
// analyticsService: Сервис аналитики (scoped).
|
||||
// ct: Токен отмены.
|
||||
// Возвращает: 200 агрегаты, 400 неизвестная группировка или 401 без операторской сессии.
|
||||
private static async Task<IResult> TokensAsync(
|
||||
string? groupBy,
|
||||
Guid? tenantId,
|
||||
DateTimeOffset? from,
|
||||
DateTimeOffset? to,
|
||||
HttpContext context,
|
||||
AnalyticsService analyticsService,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (context.GetCurrentOperator() is null)
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail);
|
||||
}
|
||||
|
||||
string normalizedGroupBy = string.IsNullOrWhiteSpace(groupBy) ? DefaultGroupBy : groupBy;
|
||||
if (!IsKnownGroupBy(normalizedGroupBy))
|
||||
{
|
||||
return EndpointResults.BadRequest(InvalidGroupByDetail);
|
||||
}
|
||||
|
||||
AnalyticsTokensDto tokens = await analyticsService.TokensAsync(normalizedGroupBy, tenantId, from, to, ct);
|
||||
return Results.Ok(tokens);
|
||||
}
|
||||
|
||||
// GET /api/operator/analytics/activity?eventType=&actorType=&actorId=&tenantId=&from=&to=&limit=&offset=: лента действий.
|
||||
// eventType: Тип события (равенство; пусто — без фильтра).
|
||||
// actorType: Тип актора operator|tenant|system (равенство).
|
||||
// actorId: Идентификатор актора (равенство).
|
||||
// tenantId: Тенант (равенство).
|
||||
// from: Нижняя граница At (включительно; ISO-8601).
|
||||
// to: Верхняя граница At (включительно; ISO-8601).
|
||||
// limit: Размер страницы (дефолт 100, кламп 1..500).
|
||||
// offset: Смещение страницы (≥0).
|
||||
// context: Контекст запроса.
|
||||
// analyticsService: Сервис аналитики (scoped).
|
||||
// ct: Токен отмены.
|
||||
// Возвращает: 200 {items, total, limit, offset} или 401 без операторской сессии.
|
||||
private static async Task<IResult> ActivityAsync(
|
||||
string? eventType,
|
||||
string? actorType,
|
||||
Guid? actorId,
|
||||
Guid? tenantId,
|
||||
DateTimeOffset? from,
|
||||
DateTimeOffset? to,
|
||||
int? limit,
|
||||
int? offset,
|
||||
HttpContext context,
|
||||
AnalyticsService analyticsService,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (context.GetCurrentOperator() is null)
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail);
|
||||
}
|
||||
|
||||
AnalyticsActivityDto activity = await analyticsService.ActivityAsync(
|
||||
eventType, actorType, actorId, tenantId, from, to, limit, offset, ct);
|
||||
return Results.Ok(activity);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Известна ли группировка расхода токенов (day|tenant|provider|model).
|
||||
/// </summary>
|
||||
/// <param name="groupBy">Значение группировки.</param>
|
||||
/// <returns>True — поддерживаемая группировка.</returns>
|
||||
public static bool IsKnownGroupBy(string groupBy) =>
|
||||
groupBy is TokenUsageGroupBys.Day or TokenUsageGroupBys.Tenant
|
||||
or TokenUsageGroupBys.Provider or TokenUsageGroupBys.Model;
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
using Deal.Api.Http;
|
||||
using Deal.Modules.Tenants.Application;
|
||||
using Deal.Modules.Tenants.Application.Models;
|
||||
|
||||
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).
|
||||
/// </summary>
|
||||
/// <param name="app">Построитель маршрутов приложения.</param>
|
||||
/// <returns>Построитель маршрутов для цепочки вызовов.</returns>
|
||||
public static IEndpointRouteBuilder MapOperatorAuditEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
app.MapGroup(OperatorGroupPrefix).WithTags(OperatorOpenApiTag).MapGet(AuditPath, ListAsync);
|
||||
return app;
|
||||
}
|
||||
|
||||
// GET /api/operator/audit?eventType=&actorType=&actorId=&tenantId=&from=&to=&limit=&offset=: лента аудита.
|
||||
// eventType: Фильтр по типу события (равенство; пусто — без фильтра).
|
||||
// actorType: Фильтр по типу актора operator|tenant|system (равенство).
|
||||
// actorId: Фильтр по идентификатору актора (равенство).
|
||||
// tenantId: Фильтр по тенанту (равенство).
|
||||
// from: Нижняя граница At (включительно; ISO-8601).
|
||||
// to: Верхняя граница At (включительно; ISO-8601).
|
||||
// limit: Размер выборки (дефолт 100, клампится 1..500).
|
||||
// offset: Смещение страницы (≥0; этап 10, T3).
|
||||
// context: Контекст запроса.
|
||||
// auditService: Сервис аудита (scoped).
|
||||
// ct: Токен отмены.
|
||||
// Возвращает: 200 {items:[...], total} или 401 без операторской сессии.
|
||||
private static async Task<IResult> ListAsync(
|
||||
string? eventType,
|
||||
string? actorType,
|
||||
Guid? actorId,
|
||||
Guid? tenantId,
|
||||
DateTimeOffset? from,
|
||||
DateTimeOffset? to,
|
||||
int? limit,
|
||||
int? offset,
|
||||
HttpContext context,
|
||||
AuditService auditService,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var operatorIdentity = context.GetCurrentOperator();
|
||||
if (operatorIdentity is null)
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail);
|
||||
}
|
||||
|
||||
var filter = new AuditQueryDto(
|
||||
eventType, actorType, tenantId, from, to, NormalizeLimit(limit), actorId, NormalizeOffset(offset));
|
||||
IReadOnlyList<AuditRecordDto> items = await auditService.QueryAsync(filter, ct);
|
||||
int total = await auditService.CountAsync(filter, ct);
|
||||
return Results.Ok(new { items, total });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Нормализует limit запроса: дефолт <see cref="AuditService.DefaultQueryLimit"/>, кламп 1..500 (Ruling 4).
|
||||
/// </summary>
|
||||
/// <param name="limit">Запрошенный размер выборки (null — не задан).</param>
|
||||
/// <returns>Значение для фильтра.</returns>
|
||||
public static int NormalizeLimit(int? limit) =>
|
||||
limit is null
|
||||
? AuditService.DefaultQueryLimit
|
||||
: Math.Max(1, Math.Min(AuditService.MaxQueryLimit, limit.Value));
|
||||
|
||||
/// <summary>
|
||||
/// Нормализует offset запроса: отрицательное/отсутствующее — 0 (этап 10, T3).
|
||||
/// </summary>
|
||||
/// <param name="offset">Запрошенное смещение (null — не задано).</param>
|
||||
/// <returns>Неотрицательное смещение.</returns>
|
||||
public static int NormalizeOffset(int? offset) => Math.Max(0, offset ?? 0);
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
using Deal.Api.Http;
|
||||
using Deal.Api.Middleware;
|
||||
using Deal.Modules.Tenants.Application;
|
||||
using Deal.Modules.Tenants.Application.Models;
|
||||
using Microsoft.Extensions.Options;
|
||||
using AspNetCoreCookieOptions = Microsoft.AspNetCore.Http.CookieOptions;
|
||||
// Имя конфигурационного типа совпадает с Microsoft.AspNetCore.Http.CookieOptions — фиксируем алиасами.
|
||||
using OperatorCookieOptions = Deal.Api.Configuration.OperatorCookieOptions;
|
||||
|
||||
namespace Deal.Api.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// HTTP-эндпоинты аутентификации оператора (группа /api/operator/auth). Зеркало AuthEndpoints для операторов (Ruling 1).
|
||||
/// </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 = "Неверный логин или пароль оператора";
|
||||
private const string OperatorAuthGroupPrefix = "/api/operator/auth";
|
||||
private const string OperatorAuthOpenApiTag = "operator-auth";
|
||||
|
||||
/// <summary>
|
||||
/// Регистрирует группу /api/operator/auth: login, logout, me.
|
||||
/// </summary>
|
||||
/// <param name="app">Построитель маршрутов приложения.</param>
|
||||
/// <returns>Построитель маршрутов для цепочки вызовов.</returns>
|
||||
public static IEndpointRouteBuilder MapOperatorAuthEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
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);
|
||||
group.MapGet("/me", MeAsync);
|
||||
|
||||
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,
|
||||
AuditService auditService,
|
||||
IOptions<OperatorCookieOptions> cookieOptions,
|
||||
HttpContext context,
|
||||
CancellationToken ct,
|
||||
LoginAttemptGuard loginAttemptGuard)
|
||||
{
|
||||
string? attemptedLogin = NormalizeLogin(body.Login);
|
||||
|
||||
// Защита входа оператора (план Task 11, Ruling 5): зеркало AuthEndpoints — блокировка ключа
|
||||
// ip|login до проверки учётных данных (в dev при RateLimit:Enabled=false гвард выключен).
|
||||
if (await loginAttemptGuard.IsBlockedAsync(ClientIp(context), attemptedLogin, ct))
|
||||
{
|
||||
return EndpointResults.TooManyRequests(LoginAttemptGuard.BlockedDetail);
|
||||
}
|
||||
|
||||
var result = await operatorAuthService.LoginAsync(body.Login, body.Password, ct);
|
||||
if (result.Login is null || result.Token is null)
|
||||
{
|
||||
// Неверные учётные данные оператора — одно сообщение (зеркало AuthEndpoints).
|
||||
// Аудит operator_login_failed — только для реальной попытки (непустой логин), без пароля (Ruling 4);
|
||||
// счётчик неудач гварда растёт там же (пустые логины ключа не имеют).
|
||||
if (attemptedLogin is not null)
|
||||
{
|
||||
await loginAttemptGuard.RecordFailureAsync(ClientIp(context), attemptedLogin, ct);
|
||||
await auditService.AppendAsync(new AuditRecordDto(
|
||||
AuditEvents.OperatorLoginFailed,
|
||||
AuditActorTypes.Operator,
|
||||
ActorId: null,
|
||||
TenantId: null,
|
||||
Ip: ClientIp(context),
|
||||
DetailJson: AuditService.ToDetailJson(new { login = attemptedLogin })), ct);
|
||||
}
|
||||
|
||||
return EndpointResults.Unauthorized(InvalidCredentialsDetail);
|
||||
}
|
||||
|
||||
// Успешный вход оператора сбрасывает счётчик неудач ключа ip|login (Ruling 5).
|
||||
await loginAttemptGuard.ResetAsync(ClientIp(context), result.Login, ct);
|
||||
|
||||
await auditService.AppendAsync(new AuditRecordDto(
|
||||
AuditEvents.OperatorLoginOk,
|
||||
AuditActorTypes.Operator,
|
||||
ActorId: result.OperatorId,
|
||||
TenantId: null,
|
||||
Ip: ClientIp(context),
|
||||
DetailJson: AuditService.ToDetailJson(new { login = result.Login })), ct);
|
||||
|
||||
SetOperatorSessionCookie(context, cookieOptions.Value, result.Token);
|
||||
return Results.Ok(new { ok = true, login = result.Login });
|
||||
}
|
||||
|
||||
// POST /api/operator/auth/logout: удаление операторской сессии по токену из куки и очистка куки (всегда ok).
|
||||
private static async Task<IResult> LogoutAsync(
|
||||
OperatorAuthService operatorAuthService,
|
||||
IOptions<OperatorCookieOptions> cookieOptions,
|
||||
HttpContext context,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var cookieName = cookieOptions.Value.Name;
|
||||
var rawToken = context.Request.Cookies[cookieName];
|
||||
// Оператор разрешённой сессии — до её удаления (OperatorSessionMiddleware наполнил Items).
|
||||
CurrentOperator? operatorIdentity = context.GetCurrentOperator();
|
||||
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);
|
||||
}
|
||||
|
||||
return Results.Ok(new { ok = true });
|
||||
}
|
||||
|
||||
// GET /api/operator/auth/me: проверка живой операторской сессии (401 без неё, Ruling 1).
|
||||
private static IResult MeAsync(HttpContext context)
|
||||
{
|
||||
var operatorIdentity = context.GetCurrentOperator();
|
||||
if (operatorIdentity is null)
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail);
|
||||
}
|
||||
|
||||
return Results.Ok(new { login = operatorIdentity.Login, ok = true });
|
||||
}
|
||||
|
||||
// Выставляет httpOnly-куку сессии оператора: SameSite=Lax, Path=/, MaxAge=Hours, Secure — из конфига.
|
||||
// context: Контекст запроса.
|
||||
// options: Настройки куки из конфигурации (секция OperatorCookies).
|
||||
// rawToken: Raw-токен операторской сессии.
|
||||
private static void SetOperatorSessionCookie(HttpContext context, OperatorCookieOptions options, string rawToken)
|
||||
{
|
||||
// MaxAge — OperatorCookies:Hours; код-дефолт значения ссылается на
|
||||
// OperatorAuthService.SessionLifetimeHours (единый источник «12 часов», см. OperatorCookieOptions).
|
||||
context.Response.Cookies.Append(
|
||||
options.Name,
|
||||
rawToken,
|
||||
new AspNetCoreCookieOptions
|
||||
{
|
||||
HttpOnly = true,
|
||||
SameSite = SameSiteMode.Lax,
|
||||
Path = "/",
|
||||
MaxAge = TimeSpan.FromHours(options.Hours),
|
||||
Secure = options.Secure,
|
||||
});
|
||||
}
|
||||
|
||||
// Нормализованная попытка логина для аудита (нижний регистр/обрезка); null — писать нечего.
|
||||
// login: Логин из тела запроса.
|
||||
// Возвращает: Нормализованный логин или null при пустом/пробельном входе.
|
||||
private static string? NormalizeLogin(string? login)
|
||||
{
|
||||
string? normalized = login?.Trim().ToLowerInvariant();
|
||||
return string.IsNullOrEmpty(normalized) ? null : normalized;
|
||||
}
|
||||
|
||||
// IP-адрес клиента для аудита (без порта; null, если недоступен).
|
||||
// context: Контекст запроса.
|
||||
// Возвращает: Строковое представление IP или null.
|
||||
private static string? ClientIp(HttpContext context) => context.Connection.RemoteIpAddress?.ToString();
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
using Deal.Api.Http;
|
||||
using Deal.Api.Observability;
|
||||
using Deal.Infrastructure.Integrations;
|
||||
using Deal.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Deal.Api.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Операторский health: GET /api/operator/health (план Task 10, Ruling 3/6/9/11) — ядро/БД и
|
||||
/// автономные сервисы ml/ai/telegram.
|
||||
/// </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-ручки.
|
||||
private const string HealthPath = "/health";
|
||||
|
||||
// OpenAPI-тег группы.
|
||||
private const string OperatorOpenApiTag = "operator-health";
|
||||
|
||||
// Статус БД/сервиса: доступна/здоров (ok).
|
||||
private const string StatusOk = "ok";
|
||||
|
||||
// Статус сервиса: БД/сервис недоступен (down).
|
||||
private const string StatusDown = "down";
|
||||
|
||||
// Статус сервиса: ответил, но не SERVING (grpc NOT_SERVING/SERVICE_UNKNOWN).
|
||||
private const string StatusUnhealthy = "unhealthy";
|
||||
|
||||
// Статус сервиса в Local-режиме: реальный сервис не подключён (UseLocal=true, Ruling 6).
|
||||
private const string StatusLocal = "local";
|
||||
|
||||
// Режим сервиса: Local-адаптеры (UseLocal=true).
|
||||
private const string ModeLocal = "local";
|
||||
|
||||
// Режим сервиса: gRPC-клиент (UseLocal=false).
|
||||
private const string ModeGrpc = "grpc";
|
||||
|
||||
// Имя ml-service в ответе (порядок секций — как в стартовых логах Program.cs).
|
||||
private const string MlServiceName = "ml";
|
||||
|
||||
// Имя ai-service в ответе.
|
||||
private const string AiServiceName = "ai";
|
||||
|
||||
// Имя telegram-service в ответе.
|
||||
private const string TelegramServiceName = "telegram";
|
||||
|
||||
// Таймаут проверки БД, миллисекунд (health не должен висеть на мёртвом хосте Postgres).
|
||||
private const int DatabaseProbeTimeoutMilliseconds = 5000;
|
||||
|
||||
/// <summary>
|
||||
/// Регистрирует GET /api/operator/health (health ядра/БД и автономных сервисов).
|
||||
/// </summary>
|
||||
/// <param name="app">Построитель маршрутов приложения.</param>
|
||||
/// <returns>Построитель маршрутов для цепочки вызовов.</returns>
|
||||
public static IEndpointRouteBuilder MapOperatorHealthEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
app.MapGroup(OperatorGroupPrefix).WithTags(OperatorOpenApiTag).MapGet(HealthPath, GetAsync);
|
||||
return app;
|
||||
}
|
||||
|
||||
// GET /api/operator/health: {ok, core:{db}, services:[{name,mode,status,reachable}]} (всегда 200).
|
||||
private static async Task<IResult> GetAsync(
|
||||
HttpContext context,
|
||||
DealDbContext dbContext,
|
||||
ServiceHealthProbe healthProbe,
|
||||
RuntimeDepthsCollector depthsCollector,
|
||||
MlServiceOptions mlOptions,
|
||||
AiServiceOptions aiOptions,
|
||||
TelegramServiceOptions telegramOptions,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var operatorIdentity = context.GetCurrentOperator();
|
||||
if (operatorIdentity is null)
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail);
|
||||
}
|
||||
|
||||
// Параллельно: БД (SELECT 1), health-пробы сервисов (до 3 с на сервис) и снимок глубин очередей/сессий.
|
||||
Task<string> databaseTask = ProbeDatabaseAsync(dbContext, ct);
|
||||
Task<List<ServiceEntryDto>> servicesTask = ProbeServicesAsync(healthProbe, mlOptions, aiOptions, telegramOptions, ct);
|
||||
Task<RuntimeDepthsDto> depthsTask = depthsCollector.CollectAsync(ct);
|
||||
await Task.WhenAll(databaseTask, servicesTask, depthsTask);
|
||||
|
||||
string databaseStatus = await databaseTask;
|
||||
List<ServiceEntryDto> services = await servicesTask;
|
||||
RuntimeDepthsDto depths = await depthsTask;
|
||||
bool ok = databaseStatus == StatusOk
|
||||
&& services.All(service => service.Mode == ModeLocal || service.Status == StatusOk);
|
||||
return Results.Ok(new
|
||||
{
|
||||
ok,
|
||||
core = new { db = databaseStatus },
|
||||
services,
|
||||
queues = new { pipeline = depths.PipelineQueue, mlOutbox = depths.MlOutbox },
|
||||
sessions = new { active = depths.ActiveSessions },
|
||||
});
|
||||
}
|
||||
|
||||
// Проверяет доступность БД core: SELECT 1 через DealDbContext (public-схема) с таймаутом 5 с.
|
||||
// dbContext: Системный контекст.
|
||||
// ct: Токен отмены.
|
||||
// Возвращает: Статус БД: ok/down (сбой не роняет ручку).
|
||||
private static async Task<string> ProbeDatabaseAsync(DealDbContext dbContext, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
timeout.CancelAfter(DatabaseProbeTimeoutMilliseconds);
|
||||
await dbContext.Database.ExecuteSqlRawAsync("SELECT 1", timeout.Token);
|
||||
return StatusOk;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Postgres недоступен (контейнер не поднят/сеть) — оператор видит core.db=down, ручка жива.
|
||||
return StatusDown;
|
||||
}
|
||||
}
|
||||
|
||||
// Пробы сервисов ml/ai/telegram по их конфигурации: Local-режим — пометка local без вызова;
|
||||
// gRPC-режим — health-проба к Services:*:Endpoint (ServiceHealthProbe).
|
||||
// healthProbe: Проба grpc.health.v1.
|
||||
// mlOptions: Конфигурация ml-service.
|
||||
// aiOptions: Конфигурация ai-service.
|
||||
// telegramOptions: Конфигурация telegram-service.
|
||||
// ct: Токен отмены.
|
||||
// Возвращает: Записи состояния сервисов в порядке ml → ai → telegram.
|
||||
private static async Task<List<ServiceEntryDto>> ProbeServicesAsync(
|
||||
ServiceHealthProbe healthProbe,
|
||||
MlServiceOptions mlOptions,
|
||||
AiServiceOptions aiOptions,
|
||||
TelegramServiceOptions telegramOptions,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var services = new List<ServiceEntryDto>(capacity: 3);
|
||||
await ProbeServiceAsync(services, healthProbe, MlServiceName, mlOptions.UseLocal, mlOptions.Endpoint, ct);
|
||||
await ProbeServiceAsync(services, healthProbe, AiServiceName, aiOptions.UseLocal, aiOptions.Endpoint, ct);
|
||||
await ProbeServiceAsync(services, healthProbe, TelegramServiceName, telegramOptions.UseLocal, telegramOptions.Endpoint, ct);
|
||||
return services;
|
||||
}
|
||||
|
||||
// Одна запись состояния сервиса в списке services (см. ProbeServicesAsync).
|
||||
// services: Куда добавить запись.
|
||||
// healthProbe: Проба grpc.health.v1.
|
||||
// name: Имя сервиса в ответе.
|
||||
// useLocal: True — Local-режим (UseLocal=true): реальный сервис не подключён.
|
||||
// endpoint: Базовый адрес сервиса (для gRPC-режима).
|
||||
// ct: Токен отмены.
|
||||
private static async Task ProbeServiceAsync(
|
||||
List<ServiceEntryDto> services,
|
||||
ServiceHealthProbe healthProbe,
|
||||
string name,
|
||||
bool useLocal,
|
||||
string endpoint,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (useLocal)
|
||||
{
|
||||
services.Add(new ServiceEntryDto(name, ModeLocal, StatusLocal, Reachable: false));
|
||||
return;
|
||||
}
|
||||
|
||||
ServiceHealthResult result = await healthProbe.ProbeAsync(endpoint, ct);
|
||||
string status = (result.Reachable, result.Serving) switch
|
||||
{
|
||||
(true, true) => StatusOk,
|
||||
(true, false) => StatusUnhealthy,
|
||||
_ => StatusDown,
|
||||
};
|
||||
services.Add(new ServiceEntryDto(name, ModeGrpc, status, result.Reachable));
|
||||
}
|
||||
|
||||
// Запись состояния сервиса в ответе /api/operator/health (приватная форма сериализации).
|
||||
// Name: Имя сервиса (ml/ai/telegram).
|
||||
// Mode: Режим: local (UseLocal=true) | grpc (UseLocal=false).
|
||||
// Status: Состояние: ok | unhealthy | down (в Local-режиме — local).
|
||||
// Reachable: True — сервис ответил на health-пробу (в Local-режиме всегда false).
|
||||
private sealed record ServiceEntryDto(string Name, string Mode, string Status, bool Reachable);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace Deal.Api.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Тело POST /api/operator/invites: email приглашённого и опциональный целевой тенант (Ruling 2 этапа 7).
|
||||
/// </summary>
|
||||
/// <param name="Email">Email приглашённого (регистр/пробелы не важны — нормализует InvitesService).</param>
|
||||
/// <param name="TenantId">Целевой тенант; null — при активации будет создан новый тенант (Task 6).</param>
|
||||
public sealed record OperatorInviteCreateRequest(string? Email, Guid? TenantId);
|
||||
@@ -0,0 +1,146 @@
|
||||
using Deal.Api.Http;
|
||||
using Deal.Modules.Tenants.Application;
|
||||
using Deal.Modules.Tenants.Application.Models;
|
||||
|
||||
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: приглашение с таким кодом не найдено.
|
||||
private const string InviteNotFoundDetail = "Приглашение не найдено";
|
||||
|
||||
// Текст 400: отзыв приглашения не в статусе pending (уже отозвано/использовано/истекло).
|
||||
private const string InviteNotPendingDetail = "Отозвать можно только ожидающее активации приглашение";
|
||||
|
||||
// Префикс группы операторских ручек приглашений (Ruling 11).
|
||||
private const string InvitesGroupPrefix = "/api/operator/invites";
|
||||
|
||||
// Относительный путь отзыва приглашения.
|
||||
private const string RevokePath = "/{code}/revoke";
|
||||
|
||||
// OpenAPI-тег группы.
|
||||
private const string InvitesOpenApiTag = "operator-invites";
|
||||
|
||||
/// <summary>
|
||||
/// Регистрирует группу /api/operator/invites: GET (список), POST (создание), POST {code}/revoke (отзыв).
|
||||
/// </summary>
|
||||
/// <param name="app">Построитель маршрутов приложения.</param>
|
||||
/// <returns>Построитель маршрутов для цепочки вызовов.</returns>
|
||||
public static IEndpointRouteBuilder MapOperatorInvitesEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup(InvitesGroupPrefix).WithTags(InvitesOpenApiTag);
|
||||
|
||||
group.MapGet("", ListAsync);
|
||||
group.MapPost("", CreateAsync);
|
||||
group.MapPost(RevokePath, RevokeAsync);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
// GET /api/operator/invites: список приглашений (новые сверху, со статусами; expired проставляется лениво).
|
||||
private static async Task<IResult> ListAsync(
|
||||
HttpContext context,
|
||||
InvitesService invitesService,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (context.GetCurrentOperator() is null)
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail);
|
||||
}
|
||||
|
||||
IReadOnlyList<InviteDto> items = await invitesService.ListAsync(ct);
|
||||
return Results.Ok(new { items });
|
||||
}
|
||||
|
||||
// POST /api/operator/invites: создание приглашения; результат пишется в аудит (invite_created).
|
||||
private static async Task<IResult> CreateAsync(
|
||||
OperatorInviteCreateRequest body,
|
||||
HttpContext context,
|
||||
InvitesService invitesService,
|
||||
AuditService auditService,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var operatorIdentity = context.GetCurrentOperator();
|
||||
if (operatorIdentity is null)
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail);
|
||||
}
|
||||
|
||||
InviteCreateResultDto result = await invitesService.CreateInviteAsync(
|
||||
operatorIdentity.OperatorId, body.Email, body.TenantId, ct);
|
||||
if (!result.Ok || result.Invite is null)
|
||||
{
|
||||
string detail = result.Error == InviteCreateResultDto.ErrorDuplicateActive
|
||||
? DuplicateActiveDetail
|
||||
: InvalidEmailDetail;
|
||||
return EndpointResults.BadRequest(detail);
|
||||
}
|
||||
|
||||
await auditService.AppendAsync(new AuditRecordDto(
|
||||
AuditEvents.InviteCreated,
|
||||
AuditActorTypes.Operator,
|
||||
ActorId: operatorIdentity.OperatorId,
|
||||
TenantId: null,
|
||||
Ip: ClientIp(context),
|
||||
// Код инвайта — capability-токен (по нему активируется приглашение): в аудит пишется
|
||||
// только его SHA-256-хэш, чтобы утечка ленты не давала рабочие коды (Security review).
|
||||
DetailJson: AuditService.ToDetailJson(new { email = result.Invite.Email, codeHash = SessionTokens.HashToken(result.Invite.Code) })), ct);
|
||||
|
||||
InviteDto invite = result.Invite;
|
||||
return Results.Ok(new { invite.Code, invite.Email, invite.TenantId, invite.ExpiresAt, invite.Status });
|
||||
}
|
||||
|
||||
// POST /api/operator/invites/{code}/revoke: отзыв ожидающего приглашения; результат пишется в аудит (invite_revoked).
|
||||
private static async Task<IResult> RevokeAsync(
|
||||
string code,
|
||||
HttpContext context,
|
||||
InvitesService invitesService,
|
||||
AuditService auditService,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var operatorIdentity = context.GetCurrentOperator();
|
||||
if (operatorIdentity is null)
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail);
|
||||
}
|
||||
|
||||
InviteRevokeResultDto result = await invitesService.RevokeAsync(code, ct);
|
||||
if (!result.Ok)
|
||||
{
|
||||
return result.Error == InviteRevokeResultDto.ErrorNotFound
|
||||
? EndpointResults.NotFound(InviteNotFoundDetail)
|
||||
: EndpointResults.BadRequest(InviteNotPendingDetail);
|
||||
}
|
||||
|
||||
await auditService.AppendAsync(new AuditRecordDto(
|
||||
AuditEvents.InviteRevoked,
|
||||
AuditActorTypes.Operator,
|
||||
ActorId: operatorIdentity.OperatorId,
|
||||
TenantId: null,
|
||||
Ip: ClientIp(context),
|
||||
DetailJson: AuditService.ToDetailJson(new { email = result.Invite!.Email, codeHash = SessionTokens.HashToken(result.Invite.Code) })), ct);
|
||||
|
||||
return Results.Ok(new { ok = true });
|
||||
}
|
||||
|
||||
// IP-адрес клиента для аудита (без порта; null, если недоступен).
|
||||
// context: Контекст запроса.
|
||||
// Возвращает: Строковое представление IP или null.
|
||||
private static string? ClientIp(HttpContext context) => context.Connection.RemoteIpAddress?.ToString();
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace Deal.Api.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Тело PATCH /api/operator/tenants/{id}/limit: смена лимитов ИИ-бюджета тенанта (план Task 10, Ruling 3).
|
||||
/// Оба поля опциональны — меняется только заданное; смена бюджета/периода сбрасывает флаги Warned80/
|
||||
/// NotifiedExhausted (новый период открывает пороги тостов, один тост на период на порог, Ruling 3).
|
||||
/// </summary>
|
||||
/// <param name="Budget">Новый бюджет периода в токенах (≥0; 0 — ИИ запрещён); null — оставить текущий.</param>
|
||||
/// <param name="Period">Новый тип периода (константа <c>TenantLimitPeriods</c>: month|day); null — оставить текущий.</param>
|
||||
public sealed record OperatorLimitUpdateRequest(long? Budget, string? Period);
|
||||
@@ -0,0 +1,245 @@
|
||||
using Deal.Api.Http;
|
||||
using Deal.Modules.Tenants.Application;
|
||||
using Deal.Modules.Tenants.Application.Models;
|
||||
|
||||
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-тело/пустой объект).
|
||||
private const string EmptyUpdateDetail = "Укажите новый бюджет или период";
|
||||
|
||||
// Текст 400: бюджет отрицательный (порог лимита не позволяет).
|
||||
private const string NegativeBudgetDetail = "Бюджет должен быть неотрицательным";
|
||||
|
||||
// Текст 400: период не month и не day (константы TenantLimitPeriods).
|
||||
private const string InvalidPeriodDetail = "Период должен быть month или day";
|
||||
|
||||
// Текст 404: тенант с таким id не найден в реестре.
|
||||
private const string TenantNotFoundDetail = "Тенант не найден";
|
||||
|
||||
// Верхняя граница процента расхода (диапазон 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";
|
||||
|
||||
// Путь сводки лимитов по всем тенантам.
|
||||
private const string SummaryPath = "/limits";
|
||||
|
||||
// Относительный путь лимита тенанта (просмотр/смена).
|
||||
private const string TenantLimitPath = "/{id:guid}/limit";
|
||||
|
||||
// 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>
|
||||
public static IEndpointRouteBuilder MapOperatorLimitsEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
app.MapGroup(OperatorGroupPrefix).WithTags(LimitsOpenApiTag).MapGet(SummaryPath, ListSummaryAsync);
|
||||
var tenantsGroup = app.MapGroup(TenantsGroupPrefix).WithTags(LimitsOpenApiTag);
|
||||
tenantsGroup.MapGet(TenantLimitPath, GetLimitAsync);
|
||||
tenantsGroup.MapPatch(TenantLimitPath, PatchLimitAsync);
|
||||
return app;
|
||||
}
|
||||
|
||||
// GET /api/operator/limits: сводка бюджета/расхода по всем тенантам (план Task 10).
|
||||
private static async Task<IResult> ListSummaryAsync(
|
||||
HttpContext context,
|
||||
ITenantRepository tenantRepository,
|
||||
ITenantLimitStore limitStore,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var operatorIdentity = context.GetCurrentOperator();
|
||||
if (operatorIdentity is null)
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail);
|
||||
}
|
||||
|
||||
IReadOnlyList<TenantRecordDto> tenants = await tenantRepository.ListAsync(ct);
|
||||
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
|
||||
{
|
||||
tenantId = tenant.Id,
|
||||
name = tenant.Name,
|
||||
budget = state.BudgetTokens,
|
||||
period = state.Period,
|
||||
used = state.UsedTokens,
|
||||
percent = CalculatePercent(state.UsedTokens, state.BudgetTokens),
|
||||
status = state.Status,
|
||||
});
|
||||
}
|
||||
|
||||
return Results.Ok(new { items });
|
||||
}
|
||||
|
||||
// GET /api/operator/tenants/{id}/limit: детали лимита тенанта (форма BuildDetailDto).
|
||||
private static async Task<IResult> GetLimitAsync(
|
||||
Guid id,
|
||||
HttpContext context,
|
||||
ITenantRepository tenantRepository,
|
||||
ITenantLimitStore limitStore,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var operatorIdentity = context.GetCurrentOperator();
|
||||
if (operatorIdentity is null)
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail);
|
||||
}
|
||||
|
||||
TenantRecordDto? tenant = await tenantRepository.FindByIdAsync(id, ct);
|
||||
if (tenant is null)
|
||||
{
|
||||
return EndpointResults.NotFound(TenantNotFoundDetail);
|
||||
}
|
||||
|
||||
BudgetStateDto state = await limitStore.GetStateAsync(id, ct);
|
||||
return Results.Ok(BuildDetailDto(tenant.Name, state));
|
||||
}
|
||||
|
||||
// PATCH /api/operator/tenants/{id}/limit: смена бюджета/периода (сброс флагов + аудит tenant_limit_changed).
|
||||
private static async Task<IResult> PatchLimitAsync(
|
||||
Guid id,
|
||||
OperatorLimitUpdateRequest? body,
|
||||
HttpContext context,
|
||||
ITenantRepository tenantRepository,
|
||||
ITenantLimitStore limitStore,
|
||||
AuditService auditService,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var operatorIdentity = context.GetCurrentOperator();
|
||||
if (operatorIdentity is null)
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail);
|
||||
}
|
||||
|
||||
if (body is null || (body.Budget is null && string.IsNullOrWhiteSpace(body.Period)))
|
||||
{
|
||||
return EndpointResults.BadRequest(EmptyUpdateDetail);
|
||||
}
|
||||
|
||||
if (body.Budget is < 0)
|
||||
{
|
||||
return EndpointResults.BadRequest(NegativeBudgetDetail);
|
||||
}
|
||||
|
||||
if (body.Period is not null
|
||||
&& body.Period != TenantLimitPeriods.Month
|
||||
&& body.Period != TenantLimitPeriods.Day)
|
||||
{
|
||||
return EndpointResults.BadRequest(InvalidPeriodDetail);
|
||||
}
|
||||
|
||||
TenantRecordDto? tenant = await tenantRepository.FindByIdAsync(id, ct);
|
||||
if (tenant is null)
|
||||
{
|
||||
return EndpointResults.NotFound(TenantNotFoundDetail);
|
||||
}
|
||||
|
||||
// Текущее состояние — источник значений не заданных в PATCH полей (период/бюджет меняются по отдельности).
|
||||
BudgetStateDto current = await limitStore.GetStateAsync(id, ct);
|
||||
long newBudget = body.Budget ?? current.BudgetTokens;
|
||||
string newPeriod = body.Period ?? current.Period;
|
||||
if (newBudget == current.BudgetTokens && newPeriod == current.Period)
|
||||
{
|
||||
// Идемпотентный повторный PATCH: без изменения хранилища и без дубля аудита.
|
||||
return Results.Ok(BuildDetailDto(tenant.Name, current));
|
||||
}
|
||||
|
||||
BudgetStateDto updated = await limitStore.UpdateBudgetAsync(id, newBudget, newPeriod, ct);
|
||||
await auditService.AppendAsync(new AuditRecordDto(
|
||||
AuditEvents.TenantLimitChanged,
|
||||
AuditActorTypes.Operator,
|
||||
ActorId: operatorIdentity.OperatorId,
|
||||
TenantId: id,
|
||||
Ip: ClientIp(context),
|
||||
DetailJson: AuditService.ToDetailJson(new
|
||||
{
|
||||
tenantId = id,
|
||||
oldBudget = current.BudgetTokens,
|
||||
oldPeriod = current.Period,
|
||||
budgetTokens = newBudget,
|
||||
period = newPeriod,
|
||||
})), ct);
|
||||
|
||||
return Results.Ok(BuildDetailDto(tenant.Name, updated));
|
||||
}
|
||||
|
||||
/// <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>
|
||||
public static int CalculatePercent(long usedTokens, long budgetTokens)
|
||||
{
|
||||
if (budgetTokens <= 0 || usedTokens >= budgetTokens)
|
||||
{
|
||||
return PercentMax;
|
||||
}
|
||||
|
||||
return (int)(usedTokens * (double)PercentMax / budgetTokens);
|
||||
}
|
||||
|
||||
// Форма деталей лимита тенанта (GET и ответ PATCH — единая).
|
||||
// name: Имя тенанта (реестр).
|
||||
// state: Состояние бюджета (после ленивого reset).
|
||||
// Возвращает: Объект ответа: лимит + расход + флаги порогов + статус тенанта.
|
||||
private static object BuildDetailDto(string name, BudgetStateDto state) => new
|
||||
{
|
||||
tenantId = state.TenantId,
|
||||
name,
|
||||
status = state.Status,
|
||||
allowed = state.Allowed,
|
||||
budget = state.BudgetTokens,
|
||||
period = state.Period,
|
||||
periodStart = state.PeriodStart,
|
||||
used = state.UsedTokens,
|
||||
remaining = BudgetService.RemainingTokens(state.UsedTokens, state.BudgetTokens),
|
||||
percent = CalculatePercent(state.UsedTokens, state.BudgetTokens),
|
||||
warned80 = state.Warned80,
|
||||
notifiedExhausted = state.NotifiedExhausted,
|
||||
};
|
||||
|
||||
// IP-адрес клиента для аудита (без порта; null, если недоступен).
|
||||
// context: Контекст запроса.
|
||||
// Возвращает: Строковое представление IP или null.
|
||||
private static string? ClientIp(HttpContext context) => context.Connection.RemoteIpAddress?.ToString();
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
using Deal.Api.Http;
|
||||
using Deal.Infrastructure.Tenancy;
|
||||
|
||||
namespace Deal.Api.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Операторские maintenance-ручки (этап 12, пакет C): пакетная миграция схем всех тенантов.
|
||||
/// </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-ручек.
|
||||
private const string MaintenanceGroupPrefix = "/api/operator/maintenance";
|
||||
|
||||
// Относительный путь пакетной миграции схем тенантов.
|
||||
private const string MigrateTenantsPath = "/tenants/migrate";
|
||||
|
||||
// OpenAPI-тег группы.
|
||||
private const string MaintenanceOpenApiTag = "operator-maintenance";
|
||||
|
||||
/// <summary>
|
||||
/// Регистрирует группу /api/operator/maintenance: пакетная миграция схем тенантов.
|
||||
/// </summary>
|
||||
/// <param name="app">Построитель маршрутов приложения.</param>
|
||||
/// <returns>Построитель маршрутов для цепочки вызовов.</returns>
|
||||
public static IEndpointRouteBuilder MapOperatorMaintenanceEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
app.MapGroup(MaintenanceGroupPrefix)
|
||||
.WithTags(MaintenanceOpenApiTag)
|
||||
.MapPost(MigrateTenantsPath, MigrateTenantsAsync);
|
||||
return app;
|
||||
}
|
||||
|
||||
// POST /api/operator/maintenance/tenants/migrate: миграция схем всех тенантов (сводка прогресса).
|
||||
private static async Task<IResult> MigrateTenantsAsync(
|
||||
HttpContext context,
|
||||
TenantSchemaMigrationService migrationService,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var operatorIdentity = context.GetCurrentOperator();
|
||||
if (operatorIdentity is null)
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail);
|
||||
}
|
||||
|
||||
TenantMigrationSummary summary = await migrationService.MigrateAllAsync(ct);
|
||||
return Results.Ok(new
|
||||
{
|
||||
ok = summary.Ok,
|
||||
total = summary.Total,
|
||||
migrated = summary.Migrated,
|
||||
failed = summary.Failed,
|
||||
failedSchemas = summary.FailedSchemas,
|
||||
durationMs = summary.DurationMs,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
using Deal.Api.Endpoints.RequestModels;
|
||||
using Deal.Api.Http;
|
||||
using Deal.Api.Telegram;
|
||||
using Deal.Modules.Tenants.Application;
|
||||
using Deal.Modules.Tenants.Application.Models;
|
||||
|
||||
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
|
||||
{
|
||||
// Префикс группы операторских настроек.
|
||||
private const string SettingsGroupPrefix = "/api/operator/settings";
|
||||
|
||||
// OpenAPI-тег группы.
|
||||
private const string SettingsOpenApiTag = "operator-settings";
|
||||
|
||||
// Относительный путь глобальных ключей Telegram (GET/PUT).
|
||||
private const string TelegramKeysPath = "/telegram-keys";
|
||||
|
||||
// Текст 400: пустое тело PUT (ни одного поля).
|
||||
private const string EmptyBodyDetail = "Укажите api_id и api_hash";
|
||||
|
||||
// Текст 400: частичное обновление, но ключей ещё нет — нужны оба поля.
|
||||
private const string MissingKeysDetail = "Ключи ещё не заданы — укажите и api_id, и api_hash";
|
||||
|
||||
// Текст 400: api_id не 5..9 цифр.
|
||||
private const string InvalidApiIdDetail = "api_id должен состоять из 5–9 цифр";
|
||||
|
||||
// Текст 400: api_hash пустой/маска/с префиксом enc:.
|
||||
private const string InvalidApiHashDetail = "Укажите непустой api_hash";
|
||||
|
||||
/// <summary>
|
||||
/// Регистрирует группу /api/operator/settings: telegram-keys (GET/PUT).
|
||||
/// </summary>
|
||||
/// <param name="app">Построитель маршрутов приложения.</param>
|
||||
/// <returns>Построитель маршрутов для цепочки вызовов.</returns>
|
||||
public static IEndpointRouteBuilder MapOperatorSettingsEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup(SettingsGroupPrefix).WithTags(SettingsOpenApiTag);
|
||||
group.MapGet(TelegramKeysPath, GetTelegramKeysAsync);
|
||||
group.MapPut(TelegramKeysPath, PutTelegramKeysAsync);
|
||||
return app;
|
||||
}
|
||||
|
||||
// GET /api/operator/settings/telegram-keys: маскированные глобальные ключи Telegram.
|
||||
// context: Контекст запроса.
|
||||
// keys: Сервис глобальных ключей Telegram (scoped).
|
||||
// ct: Токен отмены.
|
||||
// Возвращает: 200 маскированный снимок или 401 без операторской сессии.
|
||||
private static async Task<IResult> GetTelegramKeysAsync(
|
||||
HttpContext context,
|
||||
TelegramKeysService keys,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (context.GetCurrentOperator() is null)
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail);
|
||||
}
|
||||
|
||||
TelegramKeysMaskedDto snapshot = await keys.GetMaskedAsync(ct);
|
||||
return Results.Ok(snapshot);
|
||||
}
|
||||
|
||||
// PUT /api/operator/settings/telegram-keys: частичное сохранение глобальных ключей Telegram
|
||||
// оператором.
|
||||
// Поля можно передавать по отдельности: непереданное поле (null) сохраняет текущее значение,
|
||||
// явное значение (в т.ч. пустая строка) валидируется. Если ключей ещё нет, оба поля обязательны.
|
||||
// body: Тело {apiId?, apiHash?} (хотя бы одно поле).
|
||||
// context: Контекст запроса.
|
||||
// keys: Сервис глобальных ключей Telegram (scoped).
|
||||
// auditService: Сервис аудита (событие telegram_keys_changed).
|
||||
// ct: Токен отмены.
|
||||
// Возвращает: 200 маскированный снимок, 400 при невалидных/недостающих полях или 401 без операторской сессии.
|
||||
private static async Task<IResult> PutTelegramKeysAsync(
|
||||
OperatorTelegramKeysRequest? body,
|
||||
HttpContext context,
|
||||
TelegramKeysService keys,
|
||||
AuditService auditService,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var operatorIdentity = context.GetCurrentOperator();
|
||||
if (operatorIdentity is null)
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail);
|
||||
}
|
||||
|
||||
if (body is null)
|
||||
{
|
||||
return EndpointResults.BadRequest(EmptyBodyDetail);
|
||||
}
|
||||
|
||||
// null — поле не передано (сохраняем текущее); непустая строка/плейсхолдер — валидируем явно.
|
||||
string? apiId = body.ApiId?.Trim();
|
||||
string? apiHash = body.ApiHash?.Trim();
|
||||
if (apiId is null && apiHash is null)
|
||||
{
|
||||
return EndpointResults.BadRequest(EmptyBodyDetail);
|
||||
}
|
||||
|
||||
if (apiId is not null && !TelegramKeysService.IsValidApiId(apiId))
|
||||
{
|
||||
return EndpointResults.BadRequest(InvalidApiIdDetail);
|
||||
}
|
||||
|
||||
if (apiHash is not null && !TelegramKeysService.IsValidApiHash(apiHash))
|
||||
{
|
||||
return EndpointResults.BadRequest(InvalidApiHashDetail);
|
||||
}
|
||||
|
||||
// Частичное обновление: недостающее поле берём из текущих ключей; если ключей ещё нет — нужны оба.
|
||||
TgKeysSnapshot current = await keys.GetAsync(ct);
|
||||
string effectiveApiId = apiId ?? current.ApiId;
|
||||
string effectiveApiHash = apiHash ?? current.ApiHash;
|
||||
if (effectiveApiId.Length == 0 || effectiveApiHash.Length == 0)
|
||||
{
|
||||
return EndpointResults.BadRequest(MissingKeysDetail);
|
||||
}
|
||||
|
||||
await keys.SaveAsync(effectiveApiId, effectiveApiHash, ct);
|
||||
|
||||
// Аудит смены глобальных ключей: apiId — не секрет, apiHash в детали не пишется (Ruling 4).
|
||||
await auditService.AppendAsync(new AuditRecordDto(
|
||||
AuditEvents.TelegramKeysChanged,
|
||||
AuditActorTypes.Operator,
|
||||
ActorId: operatorIdentity.OperatorId,
|
||||
TenantId: null,
|
||||
Ip: ClientIp(context),
|
||||
DetailJson: AuditService.ToDetailJson(new { apiId = effectiveApiId, apiHashSet = true })), ct);
|
||||
|
||||
TelegramKeysMaskedDto snapshot = await keys.GetMaskedAsync(ct);
|
||||
return Results.Ok(snapshot);
|
||||
}
|
||||
|
||||
// IP-адрес клиента для аудита (без порта; null, если недоступен).
|
||||
// context: Контекст запроса.
|
||||
// Возвращает: Строковое представление IP или null.
|
||||
private static string? ClientIp(HttpContext context) => context.Connection.RemoteIpAddress?.ToString();
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace Deal.Api.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Тело POST /api/operator/tenants: создание тенанта оператором (план Task 7, Ruling 11).
|
||||
/// </summary>
|
||||
/// <param name="Name">Имя тенанта (обязательно; пробелы по краям обрезаются).</param>
|
||||
/// <param name="Email">Email владельца (опционально): создаёт сразу пользователя-владельца с одноразовым
|
||||
/// паролем (иначе владелец заводится инвайтом, Ruling 2).</param>
|
||||
public sealed record OperatorTenantCreateRequest(string? Name, string? Email);
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace Deal.Api.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Тело POST /api/operator/tenants/{id}/impersonate: опциональный логин пользователя тенанта (план Task 7).
|
||||
/// </summary>
|
||||
/// <param name="Login">Логин пользователя, под которым оператор входит (impersonation); null/пустой —
|
||||
/// берётся первый пользователь тенанта (по времени создания).</param>
|
||||
public sealed record OperatorTenantImpersonateRequest(string? Login);
|
||||
@@ -0,0 +1,290 @@
|
||||
using Deal.Api.Http;
|
||||
using Deal.Modules.Tenants.Application;
|
||||
using Deal.Modules.Tenants.Application.Models;
|
||||
using Microsoft.Extensions.Options;
|
||||
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).
|
||||
private const string TenantNameRequiredDetail = "Имя тенанта обязательно";
|
||||
|
||||
// Текст 400: email пустой/некорректного формата (create с владельцем).
|
||||
private const string InvalidEmailDetail = "Некорректный email";
|
||||
|
||||
// Текст 400: пользователь с таким email уже зарегистрирован (users.login unique, create с владельцем).
|
||||
private const string EmailTakenDetail = "Этот email уже зарегистрирован";
|
||||
|
||||
// Текст 404: тенант с таким id не найден.
|
||||
private const string TenantNotFoundDetail = "Тенант не найден";
|
||||
|
||||
// Текст 404: пользователь с таким login не найден в тенанте.
|
||||
private const string UserNotFoundInTenantDetail = "Пользователь не найден в тенанте";
|
||||
|
||||
// Текст 400: в тенанте нет пользователей, а login не указан (impersonation без выбора).
|
||||
private const string TenantHasNoUsersDetail = "В тенанте нет пользователей для входа";
|
||||
|
||||
// Префикс группы операторских ручек тенантов (Ruling 11).
|
||||
private const string TenantsGroupPrefix = "/api/operator/tenants";
|
||||
|
||||
// Относительный путь деталей тенанта.
|
||||
private const string TenantByIdPath = "/{id:guid}";
|
||||
|
||||
// Относительный путь приостановки тенанта.
|
||||
private const string SuspendPath = "/{id:guid}/suspend";
|
||||
|
||||
// Относительный путь возобновления тенанта.
|
||||
private const string UnsuspendPath = "/{id:guid}/unsuspend";
|
||||
|
||||
// Относительный путь impersonation пользователя тенанта.
|
||||
private const string ImpersonatePath = "/{id:guid}/impersonate";
|
||||
|
||||
// OpenAPI-тег группы.
|
||||
private const string TenantsOpenApiTag = "operator-tenants";
|
||||
|
||||
/// <summary>
|
||||
/// Регистрирует группу /api/operator/tenants: список, create, детали, suspend/unsuspend, impersonate.
|
||||
/// </summary>
|
||||
/// <param name="app">Построитель маршрутов приложения.</param>
|
||||
/// <returns>Построитель маршрутов для цепочки вызовов.</returns>
|
||||
public static IEndpointRouteBuilder MapOperatorTenantsEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup(TenantsGroupPrefix).WithTags(TenantsOpenApiTag);
|
||||
|
||||
group.MapGet("", ListAsync);
|
||||
group.MapPost("", CreateAsync);
|
||||
group.MapGet(TenantByIdPath, GetByIdAsync);
|
||||
group.MapPost(SuspendPath, SuspendAsync);
|
||||
group.MapPost(UnsuspendPath, UnsuspendAsync);
|
||||
group.MapPost(ImpersonatePath, ImpersonateAsync);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
// GET /api/operator/tenants: список тенантов со счётчиками пользователей (план Task 7).
|
||||
private static async Task<IResult> ListAsync(
|
||||
HttpContext context,
|
||||
TenantAdminService tenantAdminService,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var operatorIdentity = context.GetCurrentOperator();
|
||||
if (operatorIdentity is null)
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail);
|
||||
}
|
||||
|
||||
IReadOnlyList<TenantListItemDto> items = await tenantAdminService.ListAsync(ct);
|
||||
return Results.Ok(new { items });
|
||||
}
|
||||
|
||||
// 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,
|
||||
TenantAdminService tenantAdminService,
|
||||
AuditService auditService,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var operatorIdentity = context.GetCurrentOperator();
|
||||
if (operatorIdentity is null)
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail);
|
||||
}
|
||||
|
||||
TenantCreateResultDto result = await tenantAdminService.CreateAsync(body.Name, body.Email, ct);
|
||||
if (!result.Ok || result.Tenant is null)
|
||||
{
|
||||
return result.Error switch
|
||||
{
|
||||
TenantCreateResultDto.ErrorInvalidEmail => EndpointResults.BadRequest(InvalidEmailDetail),
|
||||
TenantCreateResultDto.ErrorEmailTaken => EndpointResults.BadRequest(EmailTakenDetail),
|
||||
_ => EndpointResults.BadRequest(TenantNameRequiredDetail),
|
||||
};
|
||||
}
|
||||
|
||||
// Одноразовый пароль владельца в аудит/логи не пишется (правило секретов); raw — только в ответе ниже.
|
||||
TenantRecordDto createdTenant = result.Tenant;
|
||||
await auditService.AppendAsync(new AuditRecordDto(
|
||||
AuditEvents.TenantCreated,
|
||||
AuditActorTypes.Operator,
|
||||
ActorId: operatorIdentity.OperatorId,
|
||||
TenantId: createdTenant.Id,
|
||||
Ip: ClientIp(context),
|
||||
DetailJson: AuditService.ToDetailJson(new { tenantId = createdTenant.Id, name = createdTenant.Name, email = result.OwnerLogin })), ct);
|
||||
|
||||
if (result.OwnerLogin is not null)
|
||||
{
|
||||
return Results.Ok(new
|
||||
{
|
||||
createdTenant.Id,
|
||||
createdTenant.Name,
|
||||
createdTenant.Status,
|
||||
createdTenant.CreatedAt,
|
||||
ownerEmail = result.OwnerLogin,
|
||||
initialPassword = result.InitialPassword,
|
||||
});
|
||||
}
|
||||
|
||||
return Results.Ok(new { createdTenant.Id, createdTenant.Name, createdTenant.Status, createdTenant.CreatedAt });
|
||||
}
|
||||
|
||||
// GET /api/operator/tenants/{id}: детали тенанта и его пользователи.
|
||||
private static async Task<IResult> GetByIdAsync(
|
||||
Guid id,
|
||||
HttpContext context,
|
||||
TenantAdminService tenantAdminService,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var operatorIdentity = context.GetCurrentOperator();
|
||||
if (operatorIdentity is null)
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail);
|
||||
}
|
||||
|
||||
TenantDetailDto? tenant = await tenantAdminService.GetAsync(id, ct);
|
||||
if (tenant is null)
|
||||
{
|
||||
return EndpointResults.NotFound(TenantNotFoundDetail);
|
||||
}
|
||||
|
||||
return Results.Ok(tenant);
|
||||
}
|
||||
|
||||
// POST /api/operator/tenants/{id}/suspend: приостановка тенанта; аудит tenant_status_changed.
|
||||
private static Task<IResult> SuspendAsync(
|
||||
Guid id,
|
||||
HttpContext context,
|
||||
TenantAdminService tenantAdminService,
|
||||
AuditService auditService,
|
||||
CancellationToken ct) =>
|
||||
ApplyStatusAsync(id, TenantStatuses.Suspended, context, tenantAdminService, auditService, ct);
|
||||
|
||||
// POST /api/operator/tenants/{id}/unsuspend: возобновление тенанта; аудит tenant_status_changed.
|
||||
private static Task<IResult> UnsuspendAsync(
|
||||
Guid id,
|
||||
HttpContext context,
|
||||
TenantAdminService tenantAdminService,
|
||||
AuditService auditService,
|
||||
CancellationToken ct) =>
|
||||
ApplyStatusAsync(id, TenantStatuses.Active, context, tenantAdminService, auditService, ct);
|
||||
|
||||
// Общая логика suspend/unsuspend: проверка оператора, смена статуса, аудит при реальном изменении.
|
||||
// id: Идентификатор тенанта.
|
||||
// status: Новый статус — константа TenantStatuses.
|
||||
// context: Контекст запроса (операторская сессия, IP).
|
||||
// tenantAdminService: Сервис реестра тенантов.
|
||||
// auditService: Сервис аудита (запись tenant_status_changed при изменении).
|
||||
// ct: Токен отмены.
|
||||
// Возвращает: 200 {ok,status} или 401/404 {detail}.
|
||||
private static async Task<IResult> ApplyStatusAsync(
|
||||
Guid id,
|
||||
string status,
|
||||
HttpContext context,
|
||||
TenantAdminService tenantAdminService,
|
||||
AuditService auditService,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var operatorIdentity = context.GetCurrentOperator();
|
||||
if (operatorIdentity is null)
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail);
|
||||
}
|
||||
|
||||
TenantStatusChangeResultDto result = await tenantAdminService.ChangeStatusAsync(id, status, ct);
|
||||
if (!result.Ok || result.Tenant is null)
|
||||
{
|
||||
return EndpointResults.NotFound(TenantNotFoundDetail);
|
||||
}
|
||||
|
||||
if (result.Changed)
|
||||
{
|
||||
await auditService.AppendAsync(new AuditRecordDto(
|
||||
AuditEvents.TenantStatusChanged,
|
||||
AuditActorTypes.Operator,
|
||||
ActorId: operatorIdentity.OperatorId,
|
||||
TenantId: result.Tenant.Id,
|
||||
Ip: ClientIp(context),
|
||||
DetailJson: AuditService.ToDetailJson(new { tenantId = result.Tenant.Id, status = result.Tenant.Status })), ct);
|
||||
}
|
||||
|
||||
return Results.Ok(new { ok = true, status = result.Tenant.Status });
|
||||
}
|
||||
|
||||
// POST /api/operator/tenants/{id}/impersonate: tenant-сессия пользователя тенанта; аудит impersonation_started.
|
||||
private static async Task<IResult> ImpersonateAsync(
|
||||
Guid id,
|
||||
OperatorTenantImpersonateRequest? body,
|
||||
HttpContext context,
|
||||
AuthService authService,
|
||||
AuditService auditService,
|
||||
IOptions<CookieOptions> cookieOptions,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var operatorIdentity = context.GetCurrentOperator();
|
||||
if (operatorIdentity is null)
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail);
|
||||
}
|
||||
|
||||
ImpersonationResultDto result = await authService.ImpersonateAsync(
|
||||
id, body?.Login, operatorIdentity.OperatorId, ct);
|
||||
if (!result.Ok || result.SessionToken is null || result.Login is null || result.TenantId is null)
|
||||
{
|
||||
return result.Error switch
|
||||
{
|
||||
ImpersonationResultDto.ErrorTenantNotFound => EndpointResults.NotFound(TenantNotFoundDetail),
|
||||
ImpersonationResultDto.ErrorUserNotFound => EndpointResults.NotFound(UserNotFoundInTenantDetail),
|
||||
_ => EndpointResults.BadRequest(TenantHasNoUsersDetail),
|
||||
};
|
||||
}
|
||||
|
||||
await auditService.AppendAsync(new AuditRecordDto(
|
||||
AuditEvents.ImpersonationStarted,
|
||||
AuditActorTypes.Operator,
|
||||
ActorId: operatorIdentity.OperatorId,
|
||||
TenantId: result.TenantId,
|
||||
Ip: ClientIp(context),
|
||||
DetailJson: AuditService.ToDetailJson(new { targetLogin = result.Login, tenantId = result.TenantId })), ct);
|
||||
|
||||
// Токен — это tenant-сессия (как после /api/auth/login): СТАВИМ ту же httpOnly-куку deal_session
|
||||
// на ответ, чтобы браузер оператора сразу получил tenant-сессию (JS не может записать httpOnly-куку).
|
||||
// Завершение — POST /api/auth/logout (пишет impersonation_stopped).
|
||||
SessionCookieWriter.Append(context, cookieOptions.Value, result.SessionToken);
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
sessionToken = result.SessionToken,
|
||||
expiresAt = result.ExpiresAt,
|
||||
tenantId = result.TenantId,
|
||||
login = result.Login,
|
||||
});
|
||||
}
|
||||
|
||||
// IP-адрес клиента для аудита (без порта; null, если недоступен).
|
||||
// context: Контекст запроса.
|
||||
// Возвращает: Строковое представление IP или null.
|
||||
private static string? ClientIp(HttpContext context) => context.Connection.RemoteIpAddress?.ToString();
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
using Deal.Api.Endpoints.RequestModels;
|
||||
using Deal.Api.Http;
|
||||
using Deal.Modules.Pipeline.Application;
|
||||
using Deal.Modules.Pipeline.Application.Models;
|
||||
|
||||
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).
|
||||
private const string StatsPath = "/stats";
|
||||
|
||||
// Путь сырых сообщений очереди (GET).
|
||||
private const string QueuePath = "/queue";
|
||||
|
||||
// Путь страницы отсева (GET).
|
||||
private const string RejectedPath = "/rejected";
|
||||
|
||||
// Путь полной очистки отсева (POST).
|
||||
private const string RejectedClearPath = "/rejected/clear";
|
||||
|
||||
// Путь удаления одной записи отсева (DELETE).
|
||||
private const string RejectedIdPath = "/rejected/{rejId}";
|
||||
|
||||
// Путь возврата записи отсева в обработку (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.
|
||||
/// </summary>
|
||||
/// <param name="app">Построитель маршрутов приложения.</param>
|
||||
/// <returns>Построитель маршрутов для цепочки вызовов.</returns>
|
||||
public static IEndpointRouteBuilder MapPipelineEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
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);
|
||||
pipeline.MapPost(RejectedClearPath, ClearAsync);
|
||||
pipeline.MapDelete(RejectedIdPath, DeleteAsync);
|
||||
pipeline.MapPost(RejectedReturnPath, ReturnAsync);
|
||||
|
||||
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 (!HasUser(context))
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
PipelineProcessingService processing = context.RequestServices.GetRequiredService<PipelineProcessingService>();
|
||||
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, CancellationToken ct)
|
||||
{
|
||||
if (!HasUser(context))
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
PipelineProcessingService processing = context.RequestServices.GetRequiredService<PipelineProcessingService>();
|
||||
IReadOnlyList<QueueItemDto> items = await processing.ListQueueAsync(limit ?? DefaultPageSize, ct);
|
||||
QueueCountsDto counts = await processing.QueueCountsAsync(ct);
|
||||
int rejected = await processing.RejectedCountAsync(ct);
|
||||
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, int? offset, int? limit, HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!HasUser(context))
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
PipelineProcessingService processing = context.RequestServices.GetRequiredService<PipelineProcessingService>();
|
||||
RejectedPageDto page = await processing.ListRejectedAsync(q ?? string.Empty, offset ?? 0, limit ?? DefaultPageSize, ct);
|
||||
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 (!HasUser(context))
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
PipelineProcessingService processing = context.RequestServices.GetRequiredService<PipelineProcessingService>();
|
||||
int cleared = await processing.ClearAsync(ct);
|
||||
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, CancellationToken ct)
|
||||
{
|
||||
if (!HasUser(context))
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
PipelineProcessingService processing = context.RequestServices.GetRequiredService<PipelineProcessingService>();
|
||||
await processing.DeleteAsync(rejId, ct);
|
||||
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, HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!HasUser(context))
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
PipelineProcessingService processing = context.RequestServices.GetRequiredService<PipelineProcessingService>();
|
||||
RejectReturnResultDto? result = await processing.ReturnAsync(rejId, body.Reason ?? string.Empty, ct);
|
||||
if (result is null)
|
||||
{
|
||||
return EndpointResults.NotFound(RejectedNotFoundDetail);
|
||||
}
|
||||
|
||||
return result.Error is not null
|
||||
? EndpointResults.BadRequest(result.Error)
|
||||
: Results.Ok(new { id = result.Id, returned = result.Returned, returnedAt = result.ReturnedAtMs });
|
||||
}
|
||||
|
||||
// Разрешена ли сессия запроса (SessionMiddleware наполняет CurrentUser и tenant-контекст).
|
||||
// context: Контекст запроса.
|
||||
private static bool HasUser(HttpContext context) => context.GetCurrentUser() is not null;
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
using Deal.Api.Http;
|
||||
using Deal.Modules.Settings.Application;
|
||||
using Deal.Modules.Settings.Application.Models;
|
||||
|
||||
namespace Deal.Api.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// HTTP-эндпоинты курсов валют: GET /api/rates, POST /api/rates/refresh (Ruling 8, api-map §3.4 L149–150).
|
||||
/// </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).
|
||||
private const string RatesPath = "/rates";
|
||||
|
||||
// Путь принудительного обновления (POST).
|
||||
private const string RatesRefreshPath = "/rates/refresh";
|
||||
|
||||
// OpenAPI-тег группы (в прототипе роутер settings — settings_routes.py).
|
||||
private const string OpenApiTag = "settings";
|
||||
|
||||
/// <summary>
|
||||
/// Регистрирует GET /api/rates и POST /api/rates/refresh.
|
||||
/// </summary>
|
||||
/// <param name="app">Построитель маршрутов приложения.</param>
|
||||
/// <returns>Построитель маршрутов для цепочки вызовов.</returns>
|
||||
public static IEndpointRouteBuilder MapRatesEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup(ApiGroupPrefix).WithTags(OpenApiTag);
|
||||
group.MapGet(RatesPath, GetRatesAsync);
|
||||
group.MapPost(RatesRefreshPath, RefreshRatesAsync);
|
||||
return app;
|
||||
}
|
||||
|
||||
// GET /api/rates: текущий кэш курсов тенанта (+ ленивый фоновый refresh при необходимости).
|
||||
private static async Task<IResult> GetRatesAsync(HttpContext context, CancellationToken ct)
|
||||
{
|
||||
if (!HasUser(context))
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
// Резолв после 401-гейта: RatesService — scoped на TenantDbContext (tenant-контекст запроса).
|
||||
RatesService ratesService = context.RequestServices.GetRequiredService<RatesService>();
|
||||
|
||||
RatesDto current = await ratesService.GetAsync(ct);
|
||||
|
||||
// Ленивое обновление (Ruling 6, план Task 8): протухший кэш / смена источника / нет кэша —
|
||||
// фоновый RefreshAsync в отдельном scope; ответ — текущий кэш.
|
||||
if (await ratesService.ShouldFetchAsync(ct))
|
||||
{
|
||||
context.RequestServices.GetRequiredService<RatesRefreshScheduler>().Schedule();
|
||||
}
|
||||
|
||||
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 (!HasUser(context))
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
RatesService ratesService = context.RequestServices.GetRequiredService<RatesService>();
|
||||
|
||||
// Синхронно: refresh по текущей настройке rateSource; при сбое ЦБ ok=false и кэш не тронут.
|
||||
bool ok = await ratesService.RefreshAsync(ct);
|
||||
RatesDto current = await ratesService.GetAsync(ct);
|
||||
return Results.Ok(new { ok, rates = current });
|
||||
}
|
||||
|
||||
// Разрешена ли сессия запроса (SessionMiddleware наполняет CurrentUser и tenant-контекст).
|
||||
// context: Контекст запроса.
|
||||
private static bool HasUser(HttpContext context) => context.GetCurrentUser() is not null;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace Deal.Api.Endpoints.RequestModels;
|
||||
|
||||
/// <summary>
|
||||
/// Тело POST /api/cards/{cardId}/links — добавление ссылки карточке (add_link L133–143).
|
||||
/// </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);
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace Deal.Api.Endpoints.RequestModels;
|
||||
|
||||
/// <summary>
|
||||
/// Тело POST /api/cards/clear-col — полная очистка служебной колонки (dashboard_routes.py ClearColBody L224–225, api-map §3.2 L93).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Wire-имя — camelCase: col — "trash" | "archive" (другие колонки/отсутствие значения → 400
|
||||
/// «Очищать можно только корзину или архив», валидация CardsService.ClearColAsync L237–247).
|
||||
/// </remarks>
|
||||
public sealed record ClearColBody(string? Col);
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace Deal.Api.Endpoints.RequestModels;
|
||||
|
||||
/// <summary>
|
||||
/// Тело PATCH /api/containers/{containerId}/state — смена состояния колонки (этап 9, T4).
|
||||
/// </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);
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace Deal.Api.Endpoints.RequestModels;
|
||||
|
||||
/// <summary>
|
||||
/// Тело POST /api/cards/{cardId}/comments — добавление комментария (dashboard_routes.py CommentBody L60–61).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Wire-имя — camelCase: text. Пустой/пробельный текст либо явный null → 400 «Пустой комментарий»
|
||||
/// (валидация CardsService.AddCommentAsync, 1:1 с dashboard_routes L240–241).
|
||||
/// </remarks>
|
||||
public sealed record CommentBody(string? Text);
|
||||
@@ -0,0 +1,30 @@
|
||||
using Deal.Modules.Kanban.Application.Models;
|
||||
|
||||
namespace Deal.Api.Endpoints.RequestModels;
|
||||
|
||||
/// <summary>
|
||||
/// Тело POST /api/containers — создание контейнера (этап 9, T4).
|
||||
/// </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>
|
||||
/// <param name="Space">Пространство (dashboard/selected; по умолчанию dashboard).</param>
|
||||
/// <param name="Kind">Вид (board/stage/service/terminal; по умолчанию board).</param>
|
||||
/// <param name="Suggested">Признак ИИ-предложения.</param>
|
||||
/// <param name="Rules">Правила попадания.</param>
|
||||
/// <param name="Note">Заметка/обоснование.</param>
|
||||
public sealed record ContainerCreateRequest(
|
||||
string? Name,
|
||||
string? Description,
|
||||
string? Color,
|
||||
string? Space,
|
||||
string? Kind,
|
||||
bool? Suggested,
|
||||
ContainerRulesDto? Rules,
|
||||
string? Note);
|
||||
@@ -0,0 +1,29 @@
|
||||
using Deal.Modules.Kanban.Application.Models;
|
||||
|
||||
namespace Deal.Api.Endpoints.RequestModels;
|
||||
|
||||
/// <summary>
|
||||
/// Тело PATCH /api/containers/{id} — частичное обновление контейнера (этап 9, T4).
|
||||
/// </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>
|
||||
/// <param name="Collapsed">Новая свёрнутость (null — не менять).</param>
|
||||
/// <param name="Suggested">Признак ИИ-предложения (null — не менять).</param>
|
||||
/// <param name="Note">Новая заметка (null — не менять).</param>
|
||||
/// <param name="Rules">Новые правила (null — не менять).</param>
|
||||
/// <param name="Policy">Новая политика (null — не менять).</param>
|
||||
public sealed record ContainerPatchRequest(
|
||||
string? Name,
|
||||
string? Description,
|
||||
string? Color,
|
||||
bool? Collapsed,
|
||||
bool? Suggested,
|
||||
string? Note,
|
||||
ContainerRulesDto? Rules,
|
||||
ContainerPolicyDto? Policy);
|
||||
@@ -0,0 +1,29 @@
|
||||
using Deal.Modules.Kanban.Application.Models;
|
||||
|
||||
namespace Deal.Api.Endpoints.RequestModels;
|
||||
|
||||
/// <summary>
|
||||
/// Тело POST /api/cards — ручное («локальное») создание карточки (этап 9, T6).
|
||||
/// </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="Summary">Краткое содержание карточки.</param>
|
||||
/// <param name="Stack">Стек/направления (null — пустой стек).</param>
|
||||
/// <param name="Budget">Бюджет (from/to/cur); null — бюджета нет.</param>
|
||||
/// <param name="Contact">Контактная строка карточки.</param>
|
||||
/// <param name="TzText">Текст технического задания.</param>
|
||||
/// <param name="ContainerId">Желаемый контейнер; null/неизвестный → planned.</param>
|
||||
/// <param name="Stage">Алиас containerId (совместимость со старым фронтом).</param>
|
||||
public sealed record CreateCardRequest(
|
||||
string Title = "",
|
||||
string Summary = "",
|
||||
IReadOnlyList<string>? Stack = null,
|
||||
CardBudgetDto? Budget = null,
|
||||
string Contact = "",
|
||||
string TzText = "",
|
||||
string? ContainerId = null,
|
||||
string? Stage = null);
|
||||
@@ -0,0 +1,58 @@
|
||||
namespace Deal.Api.Endpoints.RequestModels;
|
||||
|
||||
/// <summary>
|
||||
/// Тело POST /api/discovery/tasks (TaskCreate discovery_routes.py L50–60; api-map §3.8 L204).
|
||||
/// </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).
|
||||
/// </summary>
|
||||
public IReadOnlyList<string>? Keywords { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Минимальное число участников источника; null → 0.
|
||||
/// </summary>
|
||||
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; }
|
||||
|
||||
/// <summary>
|
||||
/// Авто-вступления воркером; null → false.
|
||||
/// </summary>
|
||||
public bool? AutoJoin { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
namespace Deal.Api.Endpoints.RequestModels;
|
||||
|
||||
/// <summary>
|
||||
/// Тело PATCH /api/discovery/tasks/{task_id} (TaskPatch discovery_routes.py L62–71; api-map §3.8 L205).
|
||||
/// </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»).
|
||||
/// </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; }
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace Deal.Api.Endpoints.RequestModels;
|
||||
|
||||
/// <summary>
|
||||
/// Тело POST /api/cards/mark-col-seen — снять «новое» с колонки (dashboard_routes.py MarkColBody L183–184, api-map §3.2 L88).
|
||||
/// </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);
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace Deal.Api.Endpoints.RequestModels;
|
||||
|
||||
/// <summary>
|
||||
/// Тело POST /api/cards/{lead_id}/move — перенос карточки (dashboard_routes.py MoveBody L56–57, api-map §3.2 L89).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Wire-имя — camelCase: to — колонка назначения: "inbox" либо id доски (<c>b_...</c>). Цель валидирует
|
||||
/// CardsService (400 «Переносить можно только на доски или в «Неразобранное»»); отсутствующий/null to
|
||||
/// трактуются той же валидацией (прототип — pydantic required 422).
|
||||
/// </remarks>
|
||||
public sealed record MoveBody(string? To);
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace Deal.Api.Endpoints.RequestModels;
|
||||
|
||||
/// <summary>
|
||||
/// Тело PUT /api/operator/settings/telegram-keys: глобальные ключи приложения Telegram,
|
||||
/// задаваемые оператором (ТЗ §4.1/§8.1). Поддерживается частичное обновление: непереданное поле
|
||||
/// (<c>null</c>) сохраняет текущее значение, явное значение валидируется. Если ключей ещё нет,
|
||||
/// оба поля обязательны.
|
||||
/// </summary>
|
||||
/// <param name="ApiId">api_id приложения Telegram (5..9 цифр); null — не менялось.</param>
|
||||
/// <param name="ApiHash">api_hash приложения Telegram (непустой секрет; хранится зашифрованным); null — не менялось.</param>
|
||||
public sealed record OperatorTelegramKeysRequest(string? ApiId, string? ApiHash);
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace Deal.Api.Endpoints.RequestModels;
|
||||
|
||||
/// <summary>
|
||||
/// Тело POST /api/containers/reorder — новый порядок контейнеров пространства (этап 9, T4).
|
||||
/// </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);
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace Deal.Api.Endpoints.RequestModels;
|
||||
|
||||
/// <summary>
|
||||
/// Тело POST /api/cards/reclassify — ИИ-переклассификация «Неразобранного» (dashboard_routes.py ReclassifyBody L64–65, api-map §3.2 L95).
|
||||
/// </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);
|
||||
@@ -0,0 +1,17 @@
|
||||
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).
|
||||
/// </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);
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace Deal.Api.Endpoints.RequestModels;
|
||||
|
||||
/// <summary>
|
||||
/// Тело POST /api/pipeline/rejected/{rejId}/return — причина возврата в обработку (processing_routes.py ReturnBody L13–15, api-map §3.6 L185).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Wire-имя — camelCase: reason (опциональна, дефолт "" — как pydantic reason: str = ""; фронт шлёт
|
||||
/// {reason} всегда). Пустая причина допустима: сервис тримит и кладёт на запись для аудита
|
||||
/// (return_to_queue L158, Ruling 10).
|
||||
/// </remarks>
|
||||
public sealed record ReturnReasonRequest(string? Reason);
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace Deal.Api.Endpoints.RequestModels;
|
||||
|
||||
/// <summary>
|
||||
/// Тело POST /api/cards/take — «взять в работу» карточки с дашборда (этап 9, T6).
|
||||
/// </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);
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace Deal.Api.Endpoints.RequestModels;
|
||||
|
||||
/// <summary>
|
||||
/// Тело POST /api/tg/dialogs/{dialog_id}/monitor и /monitor-all (tg_routes.py MonitorBody L54–56).
|
||||
/// </summary>
|
||||
/// <param name="Enabled">True — мониторить (сообщения → PushMessage в ядро), false — выключить.</param>
|
||||
public sealed record TgMonitorBody(bool Enabled);
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user