Compare commits
26
Commits
585397b9a3
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dda314cddc | ||
|
|
16f4270995 | ||
|
|
ac9d92d196 | ||
|
|
fb964f9b4f | ||
|
|
f7459069d1 | ||
|
|
37b7524665 | ||
|
|
d0e202fbc6 | ||
|
|
905129effa | ||
|
|
f31989a133 | ||
|
|
44d2ee61f7 | ||
|
|
28cf8f23ca | ||
|
|
b190ac75b1 | ||
|
|
b7c5e13fc3 | ||
|
|
04d7a0f6fb | ||
|
|
1fbe29a838 | ||
|
|
8dde35de49 | ||
|
|
71bbdd4dd5 | ||
|
|
482b345438 | ||
|
|
6a88032414 | ||
|
|
525759b232 | ||
|
|
a99bb4a713 | ||
|
|
c46bbe9332 | ||
|
|
ac2bf4b302 | ||
|
|
4a6fa374c5 | ||
|
|
ad5fa7c083 | ||
|
|
02a4f9c749 |
@@ -149,8 +149,14 @@
|
|||||||
"actorType": "tenant",
|
"actorType": "tenant",
|
||||||
"actorId": "1f2e3d4c-5b6a-7980-1234-56789abcdef0",
|
"actorId": "1f2e3d4c-5b6a-7980-1234-56789abcdef0",
|
||||||
"tenantId": "aabbccdd-eeff-0011-2233-445566778899",
|
"tenantId": "aabbccdd-eeff-0011-2233-445566778899",
|
||||||
|
"userName": "owner@example.com",
|
||||||
|
"tenantName": "ООО «Ромашка»",
|
||||||
"ip": "203.0.113.7",
|
"ip": "203.0.113.7",
|
||||||
"detailJson": "{\"cardId\":\"c_1a2b3c4d5e6f\",\"to\":\"planned\"}",
|
"changes": [
|
||||||
|
{ "field": "cardId", "to": "c_1a2b3c4d5e6f" },
|
||||||
|
{ "field": "to", "to": "planned" }
|
||||||
|
],
|
||||||
|
"detailJson": "{\"changes\":[{\"field\":\"cardId\",\"to\":\"c_1a2b3c4d5e6f\"},{\"field\":\"to\",\"to\":\"planned\"}]}",
|
||||||
"at": "2026-09-10T15:22:46.123Z",
|
"at": "2026-09-10T15:22:46.123Z",
|
||||||
"id": 1042
|
"id": 1042
|
||||||
}
|
}
|
||||||
@@ -162,7 +168,19 @@
|
|||||||
```
|
```
|
||||||
|
|
||||||
- `items` — новые сверху (`at` DESC). `total` — полное число по фильтру (без `limit`/`offset`).
|
- `items` — новые сверху (`at` DESC). `total` — полное число по фильтру (без `limit`/`offset`).
|
||||||
- `detailJson` — **строка** JSON деталей события (без секретов), может быть `null`.
|
- `userName` — логин реального пользователя (владелец пространства либо логин попытки); `null`, если не разрешён.
|
||||||
|
- `tenantName` — имя пространства события (join с реестром); `null`, если не разрешено.
|
||||||
|
- `changes` — человекочитаемые изменения параметров события (см. «Детали события»); `[]`, если деталей нет.
|
||||||
|
- `detailJson` — **строка** сырого JSON деталей события (без секретов) для спойлера; может быть `null`.
|
||||||
|
|
||||||
|
### Детали события (`changes`)
|
||||||
|
|
||||||
|
Детали события хранятся как JSON вида `{ "changes": [ { "field": "<код>", "from": "<было>", "to": "<стало>" } ] }`,
|
||||||
|
где `field` — стабильный код параметра, `from` — предыдущее значение (`null` — параметр задан впервые),
|
||||||
|
`to` — новое. Сериализация деталей — `AuditService.ToDetailJson`, форма изменения — `AuditDetails.Set`/
|
||||||
|
`AuditDetails.Change`. Ядро отдаёт `changes` как есть; человекочитаемые названия событий, акторов, параметров
|
||||||
|
и значений — в ресурсах интерфейса (`ru.js`: `operator.event`/`operator.actor`/`operator.field`/`operator.value`).
|
||||||
|
Прежние записи плоского формата (`{ "login": "..." }`, пары `old*`/`new*`) читаются обратно совместимо.
|
||||||
|
|
||||||
**Коды**: `200`, `401`.
|
**Коды**: `200`, `401`.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
namespace Deal.Ai;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Имена операций AiService для аудита
|
||||||
|
/// </summary>
|
||||||
|
public static class AiOperations
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Операция ИИ-фильтра входящих сообщений.
|
||||||
|
/// </summary>
|
||||||
|
public const string Filter = "filter";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Операция полного разбора лида.
|
||||||
|
/// </summary>
|
||||||
|
public const string Classify = "classify";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Операция генерации ключевых слов discovery-задачи.
|
||||||
|
/// </summary>
|
||||||
|
public const string GenerateKeywords = "generate_keywords";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Операция оценки соответствия сообщения задаче поиска.
|
||||||
|
/// </summary>
|
||||||
|
public const string EvaluateFit = "evaluate_fit";
|
||||||
|
}
|
||||||
@@ -150,7 +150,7 @@ public sealed class AiServiceImpl : AiService.AiServiceBase
|
|||||||
}
|
}
|
||||||
catch (LlmCallException callError)
|
catch (LlmCallException callError)
|
||||||
{
|
{
|
||||||
LogAiUnavailable("filter", tenantId, config, callError);
|
LogAiUnavailable(AiOperations.Filter, tenantId, config, callError);
|
||||||
throw ToUnavailable(callError);
|
throw ToUnavailable(callError);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -191,7 +191,7 @@ public sealed class AiServiceImpl : AiService.AiServiceBase
|
|||||||
}
|
}
|
||||||
catch (LlmCallException callError)
|
catch (LlmCallException callError)
|
||||||
{
|
{
|
||||||
LogAiUnavailable("classify", tenantId, config, callError);
|
LogAiUnavailable(AiOperations.Classify, tenantId, config, callError);
|
||||||
throw ToUnavailable(callError);
|
throw ToUnavailable(callError);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -226,7 +226,7 @@ public sealed class AiServiceImpl : AiService.AiServiceBase
|
|||||||
}
|
}
|
||||||
catch (LlmCallException callError)
|
catch (LlmCallException callError)
|
||||||
{
|
{
|
||||||
LogAiUnavailable("generate_keywords", tenantId, config, callError);
|
LogAiUnavailable(AiOperations.GenerateKeywords, tenantId, config, callError);
|
||||||
throw ToUnavailable(callError);
|
throw ToUnavailable(callError);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -267,7 +267,7 @@ public sealed class AiServiceImpl : AiService.AiServiceBase
|
|||||||
}
|
}
|
||||||
catch (LlmCallException callError)
|
catch (LlmCallException callError)
|
||||||
{
|
{
|
||||||
LogAiUnavailable("evaluate_fit", tenantId, config, callError);
|
LogAiUnavailable(AiOperations.EvaluateFit, tenantId, config, callError);
|
||||||
throw ToUnavailable(callError);
|
throw ToUnavailable(callError);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ WORKDIR /repo
|
|||||||
# Restore-слой: только csproj/props (кэш слоёв Docker — restore не повторяется при правке исходников).
|
# Restore-слой: только csproj/props (кэш слоёв Docker — restore не повторяется при правке исходников).
|
||||||
COPY src/contracts/Deal.Proto.csproj src/contracts/
|
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/grpc-hosting/Deal.Grpc.Hosting/Deal.Grpc.Hosting.csproj src/grpc-hosting/Deal.Grpc.Hosting/
|
||||||
|
COPY src/core/Deal.SharedKernel/Deal.SharedKernel.csproj src/core/Deal.SharedKernel/
|
||||||
COPY src/ai-service/Directory.Build.props src/ai-service/
|
COPY src/ai-service/Directory.Build.props src/ai-service/
|
||||||
COPY src/ai-service/Deal.Ai/Deal.Ai.csproj src/ai-service/Deal.Ai/
|
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
|
RUN dotnet restore src/ai-service/Deal.Ai/Deal.Ai.csproj
|
||||||
@@ -19,6 +20,7 @@ RUN dotnet restore src/ai-service/Deal.Ai/Deal.Ai.csproj
|
|||||||
# Исходники: контракты (.proto) + общая gRPC-обвязка + проект сервиса.
|
# Исходники: контракты (.proto) + общая gRPC-обвязка + проект сервиса.
|
||||||
COPY src/contracts/ src/contracts/
|
COPY src/contracts/ src/contracts/
|
||||||
COPY src/grpc-hosting/ src/grpc-hosting/
|
COPY src/grpc-hosting/ src/grpc-hosting/
|
||||||
|
COPY src/core/Deal.SharedKernel/ src/core/Deal.SharedKernel/
|
||||||
COPY src/ai-service/Deal.Ai/ src/ai-service/Deal.Ai/
|
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
|
RUN dotnet publish src/ai-service/Deal.Ai/Deal.Ai.csproj -c Release -o /app/publish
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
namespace Deal.Ai.Llm;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Роли сообщений чата в wire-формате ИИ-провайдеров
|
||||||
|
/// </summary>
|
||||||
|
public static class LlmChatRoles
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Роль системного сообщения.
|
||||||
|
/// </summary>
|
||||||
|
public const string System = "system";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Роль пользовательского сообщения.
|
||||||
|
/// </summary>
|
||||||
|
public const string User = "user";
|
||||||
|
}
|
||||||
@@ -100,12 +100,12 @@ public sealed class LlmHttpClient : IProviderClient
|
|||||||
|
|
||||||
var body = new JsonObject
|
var body = new JsonObject
|
||||||
{
|
{
|
||||||
["model"] = config.Model,
|
[LlmWireKeys.Model] = config.Model,
|
||||||
["messages"] = new JsonArray(
|
[LlmWireKeys.Messages] = new JsonArray(
|
||||||
ChatMessage("system", systemPrompt),
|
ChatMessage(LlmChatRoles.System, systemPrompt),
|
||||||
ChatMessage("user", userText)),
|
ChatMessage(LlmChatRoles.User, userText)),
|
||||||
["temperature"] = Temperature,
|
[LlmWireKeys.Temperature] = Temperature,
|
||||||
["max_tokens"] = MaxResponseTokens,
|
[LlmWireKeys.MaxTokens] = MaxResponseTokens,
|
||||||
};
|
};
|
||||||
|
|
||||||
request.Content = JsonBody(body);
|
request.Content = JsonBody(body);
|
||||||
@@ -123,10 +123,10 @@ public sealed class LlmHttpClient : IProviderClient
|
|||||||
|
|
||||||
var body = new JsonObject
|
var body = new JsonObject
|
||||||
{
|
{
|
||||||
["model"] = config.Model,
|
[LlmWireKeys.Model] = config.Model,
|
||||||
["max_tokens"] = MaxResponseTokens,
|
[LlmWireKeys.MaxTokens] = MaxResponseTokens,
|
||||||
["system"] = systemPrompt,
|
[LlmWireKeys.System] = systemPrompt,
|
||||||
["messages"] = new JsonArray(ChatMessage("user", userText)),
|
[LlmWireKeys.Messages] = new JsonArray(ChatMessage(LlmChatRoles.User, userText)),
|
||||||
};
|
};
|
||||||
|
|
||||||
request.Content = JsonBody(body);
|
request.Content = JsonBody(body);
|
||||||
@@ -156,7 +156,7 @@ public sealed class LlmHttpClient : IProviderClient
|
|||||||
private static ProviderChatResult ReadOpenAiBody(string body)
|
private static ProviderChatResult ReadOpenAiBody(string body)
|
||||||
{
|
{
|
||||||
JsonObject? payload = ParseObjectOrThrow(body);
|
JsonObject? payload = ParseObjectOrThrow(body);
|
||||||
JsonArray? choices = payload["choices"] as JsonArray;
|
JsonArray? choices = payload[LlmWireKeys.Choices] as JsonArray;
|
||||||
if (choices is null || choices.Count == 0)
|
if (choices is null || choices.Count == 0)
|
||||||
{
|
{
|
||||||
// Тип сбоя без содержимого тела (замечание code-review: тело ответа наружу/в лог не уходит).
|
// Тип сбоя без содержимого тела (замечание code-review: тело ответа наружу/в лог не уходит).
|
||||||
@@ -164,14 +164,14 @@ public sealed class LlmHttpClient : IProviderClient
|
|||||||
}
|
}
|
||||||
|
|
||||||
// choices[i] — объект выбора {message, finish_reason, …}; текст — в message.content.
|
// choices[i] — объект выбора {message, finish_reason, …}; текст — в message.content.
|
||||||
JsonObject? message = (choices[0] as JsonObject)?["message"] as JsonObject;
|
JsonObject? message = (choices[0] as JsonObject)?[LlmWireKeys.Message] as JsonObject;
|
||||||
string? content = ReadStringField(message, "content");
|
string? content = ReadStringField(message, LlmWireKeys.Content);
|
||||||
if (string.IsNullOrEmpty(content) && !string.IsNullOrEmpty(ReadStringField(message, "reasoning_content")))
|
if (string.IsNullOrEmpty(content) && !string.IsNullOrEmpty(ReadStringField(message, LlmWireKeys.ReasoningContent)))
|
||||||
{
|
{
|
||||||
throw new LlmHttpException("Модель вернула только reasoning без ответа");
|
throw new LlmHttpException("Модель вернула только reasoning без ответа");
|
||||||
}
|
}
|
||||||
|
|
||||||
return new ProviderChatResult(content ?? string.Empty, ReadOpenAiUsage(payload["usage"]));
|
return new ProviderChatResult(content ?? string.Empty, ReadOpenAiUsage(payload[LlmWireKeys.Usage]));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static ProviderChatResult ReadAnthropicBody(string body)
|
private static ProviderChatResult ReadAnthropicBody(string body)
|
||||||
@@ -179,18 +179,18 @@ public sealed class LlmHttpClient : IProviderClient
|
|||||||
JsonObject? payload = ParseObjectOrThrow(body);
|
JsonObject? payload = ParseObjectOrThrow(body);
|
||||||
|
|
||||||
var text = new StringBuilder();
|
var text = new StringBuilder();
|
||||||
if (payload["content"] is JsonArray contentBlocks)
|
if (payload[LlmWireKeys.Content] is JsonArray contentBlocks)
|
||||||
{
|
{
|
||||||
foreach (JsonNode? blockNode in contentBlocks)
|
foreach (JsonNode? blockNode in contentBlocks)
|
||||||
{
|
{
|
||||||
if (blockNode is JsonObject block)
|
if (blockNode is JsonObject block)
|
||||||
{
|
{
|
||||||
text.Append(ReadStringField(block, "text"));
|
text.Append(ReadStringField(block, LlmWireKeys.Text));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return new ProviderChatResult(text.ToString(), ReadAnthropicUsage(payload["usage"]));
|
return new ProviderChatResult(text.ToString(), ReadAnthropicUsage(payload[LlmWireKeys.Usage]));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Возвращает usage OpenAI-совместимого ответа (prompt/completion/total_tokens) либо null.
|
// Возвращает usage OpenAI-совместимого ответа (prompt/completion/total_tokens) либо null.
|
||||||
@@ -202,9 +202,9 @@ public sealed class LlmHttpClient : IProviderClient
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
int? promptTokens = ReadIntField(usage, "prompt_tokens");
|
int? promptTokens = ReadIntField(usage, LlmWireKeys.PromptTokens);
|
||||||
int? completionTokens = ReadIntField(usage, "completion_tokens");
|
int? completionTokens = ReadIntField(usage, LlmWireKeys.CompletionTokens);
|
||||||
int? totalTokens = ReadIntField(usage, "total_tokens");
|
int? totalTokens = ReadIntField(usage, LlmWireKeys.TotalTokens);
|
||||||
return promptTokens is null || completionTokens is null || totalTokens is null
|
return promptTokens is null || completionTokens is null || totalTokens is null
|
||||||
? null
|
? null
|
||||||
: new ProviderUsage(promptTokens.Value, completionTokens.Value, totalTokens.Value);
|
: new ProviderUsage(promptTokens.Value, completionTokens.Value, totalTokens.Value);
|
||||||
@@ -219,8 +219,8 @@ public sealed class LlmHttpClient : IProviderClient
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
int? inputTokens = ReadIntField(usage, "input_tokens");
|
int? inputTokens = ReadIntField(usage, LlmWireKeys.InputTokens);
|
||||||
int? outputTokens = ReadIntField(usage, "output_tokens");
|
int? outputTokens = ReadIntField(usage, LlmWireKeys.OutputTokens);
|
||||||
return inputTokens is null || outputTokens is null
|
return inputTokens is null || outputTokens is null
|
||||||
? null
|
? null
|
||||||
: new ProviderUsage(inputTokens.Value, outputTokens.Value, inputTokens.Value + outputTokens.Value);
|
: new ProviderUsage(inputTokens.Value, outputTokens.Value, inputTokens.Value + outputTokens.Value);
|
||||||
@@ -254,8 +254,8 @@ public sealed class LlmHttpClient : IProviderClient
|
|||||||
private static JsonObject ChatMessage(string role, string content)
|
private static JsonObject ChatMessage(string role, string content)
|
||||||
=> new()
|
=> new()
|
||||||
{
|
{
|
||||||
["role"] = role,
|
[LlmWireKeys.Role] = role,
|
||||||
["content"] = content,
|
[LlmWireKeys.Content] = content,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Создаёт JSON-содержимое запроса (application/json).
|
// Создаёт JSON-содержимое запроса (application/json).
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
namespace Deal.Ai.Llm;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// JSON-ключи wire-формата запросов и ответов ИИ-провайдеров
|
||||||
|
/// </summary>
|
||||||
|
public static class LlmWireKeys
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Ключ имени модели.
|
||||||
|
/// </summary>
|
||||||
|
public const string Model = "model";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ключ списка сообщений чата.
|
||||||
|
/// </summary>
|
||||||
|
public const string Messages = "messages";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ключ системного промпта (Anthropic — отдельным полем).
|
||||||
|
/// </summary>
|
||||||
|
public const string System = "system";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ключ температуры генерации (OpenAI-совместимый формат).
|
||||||
|
/// </summary>
|
||||||
|
public const string Temperature = "temperature";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ключ лимита токенов ответа.
|
||||||
|
/// </summary>
|
||||||
|
public const string MaxTokens = "max_tokens";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ключ списка вариантов ответа (OpenAI-совместимый формат).
|
||||||
|
/// </summary>
|
||||||
|
public const string Choices = "choices";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ключ выбранного варианта ответа (OpenAI-совместимый формат).
|
||||||
|
/// </summary>
|
||||||
|
public const string Message = "message";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ключ текстового содержимого.
|
||||||
|
/// </summary>
|
||||||
|
public const string Content = "content";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ключ текста рассуждений модели (reasoning).
|
||||||
|
/// </summary>
|
||||||
|
public const string ReasoningContent = "reasoning_content";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ключ оценки токенов вызова.
|
||||||
|
/// </summary>
|
||||||
|
public const string Usage = "usage";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ключ текста блока контента (Anthropic).
|
||||||
|
/// </summary>
|
||||||
|
public const string Text = "text";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ключ числа токенов запроса (OpenAI-совместимый формат).
|
||||||
|
/// </summary>
|
||||||
|
public const string PromptTokens = "prompt_tokens";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ключ числа токенов ответа (OpenAI-совместимый формат).
|
||||||
|
/// </summary>
|
||||||
|
public const string CompletionTokens = "completion_tokens";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ключ суммарного числа токенов (OpenAI-совместимый формат).
|
||||||
|
/// </summary>
|
||||||
|
public const string TotalTokens = "total_tokens";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ключ числа входных токенов (Anthropic).
|
||||||
|
/// </summary>
|
||||||
|
public const string InputTokens = "input_tokens";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ключ числа выходных токенов (Anthropic).
|
||||||
|
/// </summary>
|
||||||
|
public const string OutputTokens = "output_tokens";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ключ роли сообщения чата.
|
||||||
|
/// </summary>
|
||||||
|
public const string Role = "role";
|
||||||
|
}
|
||||||
@@ -127,9 +127,9 @@ public static class AiCheckEndpoint
|
|||||||
&& entry.ValueKind == JsonValueKind.Object)
|
&& entry.ValueKind == JsonValueKind.Object)
|
||||||
{
|
{
|
||||||
return new AiConfigSetting(
|
return new AiConfigSetting(
|
||||||
ApiKey: ReadField(entry, "apiKey") ?? defaults.ApiKey,
|
ApiKey: ReadField(entry, SettingsFieldKeys.ApiKey) ?? defaults.ApiKey,
|
||||||
BaseUrl: ReadField(entry, "baseUrl") ?? defaults.BaseUrl,
|
BaseUrl: ReadField(entry, SettingsFieldKeys.BaseUrl) ?? defaults.BaseUrl,
|
||||||
Model: ReadField(entry, "model") ?? defaults.Model);
|
Model: ReadField(entry, SettingsFieldKeys.Model) ?? defaults.Model);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (JsonException)
|
catch (JsonException)
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ public static class AuthEndpoints
|
|||||||
ActorId: result.UserId,
|
ActorId: result.UserId,
|
||||||
TenantId: result.TenantId,
|
TenantId: result.TenantId,
|
||||||
Ip: ClientIp(context),
|
Ip: ClientIp(context),
|
||||||
DetailJson: AuditService.ToDetailJson(new { login = NormalizeLogin(body.Login) })), ct);
|
DetailJson: AuditService.ToDetailJson([AuditDetails.Set(AuditFields.Login, NormalizeLogin(body.Login))])), ct);
|
||||||
|
|
||||||
return EndpointResults.Forbidden(TenantSuspendedDetail);
|
return EndpointResults.Forbidden(TenantSuspendedDetail);
|
||||||
}
|
}
|
||||||
@@ -84,7 +84,7 @@ public static class AuthEndpoints
|
|||||||
ActorId: null,
|
ActorId: null,
|
||||||
TenantId: null,
|
TenantId: null,
|
||||||
Ip: ClientIp(context),
|
Ip: ClientIp(context),
|
||||||
DetailJson: AuditService.ToDetailJson(new { login = attemptedLogin })), ct);
|
DetailJson: AuditService.ToDetailJson([AuditDetails.Set(AuditFields.Login, attemptedLogin)])), ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
return EndpointResults.Unauthorized(InvalidCredentialsDetail);
|
return EndpointResults.Unauthorized(InvalidCredentialsDetail);
|
||||||
@@ -98,7 +98,7 @@ public static class AuthEndpoints
|
|||||||
ActorId: result.UserId,
|
ActorId: result.UserId,
|
||||||
TenantId: result.TenantId,
|
TenantId: result.TenantId,
|
||||||
Ip: ClientIp(context),
|
Ip: ClientIp(context),
|
||||||
DetailJson: AuditService.ToDetailJson(new { login = result.Login })), ct);
|
DetailJson: AuditService.ToDetailJson([AuditDetails.Set(AuditFields.Login, result.Login)])), ct);
|
||||||
|
|
||||||
SessionCookieWriter.Append(context, cookieOptions.Value, result.Token);
|
SessionCookieWriter.Append(context, cookieOptions.Value, result.Token);
|
||||||
return Results.Ok(new { ok = true, login = result.Login });
|
return Results.Ok(new { ok = true, login = result.Login });
|
||||||
@@ -125,12 +125,12 @@ public static class AuthEndpoints
|
|||||||
ActorId: logout.OperatorId,
|
ActorId: logout.OperatorId,
|
||||||
TenantId: logout.TenantId,
|
TenantId: logout.TenantId,
|
||||||
Ip: ClientIp(context),
|
Ip: ClientIp(context),
|
||||||
DetailJson: AuditService.ToDetailJson(new { login = logout.Login })), ct);
|
DetailJson: AuditService.ToDetailJson([AuditDetails.Set(AuditFields.Login, logout.Login)])), ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (user is not null)
|
if (user is not null)
|
||||||
{
|
{
|
||||||
await AuditAppender.AppendTenantAsync(context, AuditEvents.TenantLogout, new { login = user.Login }, ct);
|
await AuditAppender.AppendTenantAsync(context, AuditEvents.TenantLogout, [AuditDetails.Set(AuditFields.Login, user.Login)], ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
context.Response.Cookies.Delete(cookieName);
|
context.Response.Cookies.Delete(cookieName);
|
||||||
|
|||||||
@@ -120,7 +120,7 @@ public static class CardDetailsEndpoints
|
|||||||
CardsService service = context.RequestServices.GetRequiredService<CardsService>();
|
CardsService service = context.RequestServices.GetRequiredService<CardsService>();
|
||||||
CardDto created = await service.CreateLocalCardAsync(ToCreateLocalDto(body), ct);
|
CardDto created = await service.CreateLocalCardAsync(ToCreateLocalDto(body), ct);
|
||||||
|
|
||||||
await AuditAppender.AppendTenantAsync(context, AuditEvents.CardCreated, new { cardId = created.Id }, ct);
|
await AuditAppender.AppendTenantAsync(context, AuditEvents.CardCreated, [AuditDetails.Set(AuditFields.CardId, created.Id)], ct);
|
||||||
return await ReadCardAsync(context, created.Id, ct);
|
return await ReadCardAsync(context, created.Id, ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -105,15 +105,15 @@ public static class CardsEndpoints
|
|||||||
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
|
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
|
||||||
CardCountsDto counts = await cardsService.CountsAsync(ct);
|
CardCountsDto counts = await cardsService.CountsAsync(ct);
|
||||||
|
|
||||||
var wire = new Dictionary<string, object> { ["new"] = counts.New };
|
var wire = new Dictionary<string, object> { [KanbanWireKeys.New] = counts.New };
|
||||||
foreach ((string col, CardColumnCountDto column) in counts.Columns)
|
foreach ((string col, CardColumnCountDto column) in counts.Columns)
|
||||||
{
|
{
|
||||||
wire[col] = column;
|
wire[col] = column;
|
||||||
}
|
}
|
||||||
|
|
||||||
wire["learning"] = counts.Learning;
|
wire[KanbanWireKeys.Learning] = counts.Learning;
|
||||||
wire["ml"] = counts.Ml;
|
wire[KanbanWireKeys.Ml] = counts.Ml;
|
||||||
wire["ai"] = counts.Ai;
|
wire[KanbanWireKeys.Ai] = counts.Ai;
|
||||||
return Results.Ok(wire);
|
return Results.Ok(wire);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -182,7 +182,14 @@ public static class CardsEndpoints
|
|||||||
return EndpointResults.BadRequest(outcome.Error);
|
return EndpointResults.BadRequest(outcome.Error);
|
||||||
}
|
}
|
||||||
|
|
||||||
await AuditAppender.AppendTenantAsync(context, AuditEvents.CardMoved, new { cardId, to = body.To }, ct);
|
await AuditAppender.AppendTenantAsync(
|
||||||
|
context,
|
||||||
|
AuditEvents.CardMoved,
|
||||||
|
[
|
||||||
|
AuditDetails.Set(AuditFields.CardId, cardId),
|
||||||
|
AuditDetails.Change(AuditFields.ContainerId, outcome.From, outcome.To),
|
||||||
|
],
|
||||||
|
ct);
|
||||||
|
|
||||||
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
|
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
|
||||||
CardDto unified = await cardsService.GetCardAsync(cardId, ct);
|
CardDto unified = await cardsService.GetCardAsync(cardId, ct);
|
||||||
@@ -202,7 +209,7 @@ public static class CardsEndpoints
|
|||||||
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
|
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
|
||||||
await cardsService.TrashCardAsync(cardId, ct);
|
await cardsService.TrashCardAsync(cardId, ct);
|
||||||
|
|
||||||
await AuditAppender.AppendTenantAsync(context, AuditEvents.CardTrashed, new { cardId }, ct);
|
await AuditAppender.AppendTenantAsync(context, AuditEvents.CardTrashed, [AuditDetails.Set(AuditFields.CardId, cardId)], ct);
|
||||||
return Results.Ok(new { ok = true });
|
return Results.Ok(new { ok = true });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -219,7 +226,7 @@ public static class CardsEndpoints
|
|||||||
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
|
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
|
||||||
string col = await cardsService.RestoreCardAsync(cardId, ct);
|
string col = await cardsService.RestoreCardAsync(cardId, ct);
|
||||||
|
|
||||||
await AuditAppender.AppendTenantAsync(context, AuditEvents.CardRestored, new { cardId, col }, ct);
|
await AuditAppender.AppendTenantAsync(context, AuditEvents.CardRestored, [AuditDetails.Set(AuditFields.CardId, cardId), AuditDetails.Set(AuditFields.Column, col)], ct);
|
||||||
return Results.Ok(new { ok = true, col });
|
return Results.Ok(new { ok = true, col });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -240,7 +247,7 @@ public static class CardsEndpoints
|
|||||||
return EndpointResults.NotFound(CardNotFoundDetail);
|
return EndpointResults.NotFound(CardNotFoundDetail);
|
||||||
}
|
}
|
||||||
|
|
||||||
await AuditAppender.AppendTenantAsync(context, AuditEvents.CardDeleted, new { cardId }, ct);
|
await AuditAppender.AppendTenantAsync(context, AuditEvents.CardDeleted, [AuditDetails.Set(AuditFields.CardId, cardId)], ct);
|
||||||
return Results.Ok(new { ok = true });
|
return Results.Ok(new { ok = true });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -284,7 +291,7 @@ public static class CardsEndpoints
|
|||||||
return EndpointResults.NotFound(CardNotFoundDetail);
|
return EndpointResults.NotFound(CardNotFoundDetail);
|
||||||
}
|
}
|
||||||
|
|
||||||
await AuditAppender.AppendTenantAsync(context, AuditEvents.CardCommentAdded, new { cardId }, ct);
|
await AuditAppender.AppendTenantAsync(context, AuditEvents.CardCommentAdded, [AuditDetails.Set(AuditFields.CardId, cardId)], ct);
|
||||||
return Results.Ok(new { comments = result.Comments });
|
return Results.Ok(new { comments = result.Comments });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -401,7 +408,12 @@ public static class CardsEndpoints
|
|||||||
return AuditAppender.AppendTenantAsync(
|
return AuditAppender.AppendTenantAsync(
|
||||||
context,
|
context,
|
||||||
AuditEvents.CardReclassified,
|
AuditEvents.CardReclassified,
|
||||||
new { attempted = result.Attempted, reclassified = result.Reclassified, moved = result.Moved, trashed = result.Trashed },
|
[
|
||||||
|
AuditDetails.Set(AuditFields.Attempted, result.Attempted),
|
||||||
|
AuditDetails.Set(AuditFields.Reclassified, result.Reclassified),
|
||||||
|
AuditDetails.Set(AuditFields.Moved, result.Moved),
|
||||||
|
AuditDetails.Set(AuditFields.Trashed, result.Trashed),
|
||||||
|
],
|
||||||
ct);
|
ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ public static class ContainersEndpoints
|
|||||||
Note: body.Note ?? string.Empty),
|
Note: body.Note ?? string.Empty),
|
||||||
ct);
|
ct);
|
||||||
|
|
||||||
await AuditAppender.AppendTenantAsync(context, AuditEvents.ContainerCreated, new { id = created.Id, name = created.Name }, ct);
|
await AuditAppender.AppendTenantAsync(context, AuditEvents.ContainerCreated, [AuditDetails.Set(AuditFields.ContainerId, created.Id), AuditDetails.Set(AuditFields.Name, created.Name)], ct);
|
||||||
return Results.Ok(new { id = created.Id });
|
return Results.Ok(new { id = created.Id });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,6 +140,7 @@ public static class ContainersEndpoints
|
|||||||
}
|
}
|
||||||
|
|
||||||
ContainersService containers = context.RequestServices.GetRequiredService<ContainersService>();
|
ContainersService containers = context.RequestServices.GetRequiredService<ContainersService>();
|
||||||
|
ContainerDto before = await containers.GetAsync(containerId, ct);
|
||||||
ContainerDto updated = await containers.PatchAsync(
|
ContainerDto updated = await containers.PatchAsync(
|
||||||
containerId,
|
containerId,
|
||||||
new ContainerPatchDto(
|
new ContainerPatchDto(
|
||||||
@@ -153,7 +154,13 @@ public static class ContainersEndpoints
|
|||||||
patchBody.Policy),
|
patchBody.Policy),
|
||||||
ct);
|
ct);
|
||||||
|
|
||||||
await AuditAppender.AppendTenantAsync(context, AuditEvents.ContainerUpdated, new { id = updated.Id }, ct);
|
await AuditAppender.AppendTenantAsync(
|
||||||
|
context,
|
||||||
|
AuditEvents.ContainerUpdated,
|
||||||
|
string.Equals(before.Name, updated.Name, StringComparison.Ordinal)
|
||||||
|
? [AuditDetails.Set(AuditFields.ContainerId, updated.Id)]
|
||||||
|
: [AuditDetails.Change(AuditFields.Name, before.Name, updated.Name)],
|
||||||
|
ct);
|
||||||
return Results.Ok(new { id = updated.Id });
|
return Results.Ok(new { id = updated.Id });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,7 +178,7 @@ public static class ContainersEndpoints
|
|||||||
ContainersService containers = context.RequestServices.GetRequiredService<ContainersService>();
|
ContainersService containers = context.RequestServices.GetRequiredService<ContainersService>();
|
||||||
ContainerDto accepted = await containers.AcceptSuggestedAsync(containerId, ct);
|
ContainerDto accepted = await containers.AcceptSuggestedAsync(containerId, ct);
|
||||||
|
|
||||||
await AuditAppender.AppendTenantAsync(context, AuditEvents.ContainerUpdated, new { id = accepted.Id }, ct);
|
await AuditAppender.AppendTenantAsync(context, AuditEvents.ContainerUpdated, [AuditDetails.Set(AuditFields.ContainerId, accepted.Id)], ct);
|
||||||
return Results.Ok(accepted);
|
return Results.Ok(accepted);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -189,7 +196,7 @@ public static class ContainersEndpoints
|
|||||||
ContainersService containers = context.RequestServices.GetRequiredService<ContainersService>();
|
ContainersService containers = context.RequestServices.GetRequiredService<ContainersService>();
|
||||||
int moved = await containers.DeleteAsync(containerId, ct);
|
int moved = await containers.DeleteAsync(containerId, ct);
|
||||||
|
|
||||||
await AuditAppender.AppendTenantAsync(context, AuditEvents.ContainerDeleted, new { id = containerId }, ct);
|
await AuditAppender.AppendTenantAsync(context, AuditEvents.ContainerDeleted, [AuditDetails.Set(AuditFields.ContainerId, containerId)], ct);
|
||||||
return Results.Ok(new { ok = true, movedToInbox = moved });
|
return Results.Ok(new { ok = true, movedToInbox = moved });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -288,12 +295,12 @@ public static class ContainersEndpoints
|
|||||||
var wire = new Dictionary<string, object>();
|
var wire = new Dictionary<string, object>();
|
||||||
if (state.Collapsed is { } collapsed)
|
if (state.Collapsed is { } collapsed)
|
||||||
{
|
{
|
||||||
wire["collapsed"] = collapsed;
|
wire[KanbanWireKeys.Collapsed] = collapsed;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state.Width is not null)
|
if (state.Width is not null)
|
||||||
{
|
{
|
||||||
wire["width"] = state.Width;
|
wire[KanbanWireKeys.Width] = state.Width;
|
||||||
}
|
}
|
||||||
|
|
||||||
return wire;
|
return wire;
|
||||||
|
|||||||
@@ -69,7 +69,8 @@ public static class JoinEndpoint
|
|||||||
TenantId: result.TenantId,
|
TenantId: result.TenantId,
|
||||||
Ip: ClientIp(context),
|
Ip: ClientIp(context),
|
||||||
// Код инвайта — capability-токен: в аудит пишется только SHA-256-хэш (Security review).
|
// Код инвайта — capability-токен: в аудит пишется только SHA-256-хэш (Security review).
|
||||||
DetailJson: AuditService.ToDetailJson(new { email = result.Login, codeHash = SessionTokens.HashToken(body.Code?.Trim() ?? string.Empty) })), ct);
|
DetailJson: AuditService.ToDetailJson(
|
||||||
|
[AuditDetails.Set(AuditFields.Email, result.Login), AuditDetails.Set(AuditFields.CodeHash, SessionTokens.HashToken(body.Code?.Trim() ?? string.Empty))])), ct);
|
||||||
|
|
||||||
return Results.Ok(new { ok = true, login = result.Login });
|
return Results.Ok(new { ok = true, login = result.Login });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using Deal.Api.Extensions;
|
using Deal.Api.Extensions;
|
||||||
using Deal.Api.Services;
|
using Deal.Api.Services;
|
||||||
|
using Deal.Modules.Tenants.Application.Abstractions;
|
||||||
using Deal.Modules.Tenants.Application.Models;
|
using Deal.Modules.Tenants.Application.Models;
|
||||||
using Deal.Modules.Tenants.Application.Services;
|
using Deal.Modules.Tenants.Application.Services;
|
||||||
|
|
||||||
@@ -132,6 +133,9 @@ public static class OperatorAnalyticsEndpoints
|
|||||||
int? offset,
|
int? offset,
|
||||||
HttpContext context,
|
HttpContext context,
|
||||||
AnalyticsService analyticsService,
|
AnalyticsService analyticsService,
|
||||||
|
ITenantRepository tenantRepository,
|
||||||
|
IAuthStore authStore,
|
||||||
|
IAuditReferenceResolver referenceResolver,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (context.GetCurrentOperator() is null)
|
if (context.GetCurrentOperator() is null)
|
||||||
@@ -141,7 +145,8 @@ public static class OperatorAnalyticsEndpoints
|
|||||||
|
|
||||||
AnalyticsActivityDto activity = await analyticsService.ActivityAsync(
|
AnalyticsActivityDto activity = await analyticsService.ActivityAsync(
|
||||||
eventType, actorType, actorId, tenantId, from, to, limit, offset, ct);
|
eventType, actorType, actorId, tenantId, from, to, limit, offset, ct);
|
||||||
return Results.Ok(activity);
|
IReadOnlyList<AuditRecordViewDto> view = await AuditViewFactory.ProjectAsync(activity.Items, tenantRepository, authStore, referenceResolver, ct);
|
||||||
|
return Results.Ok(new { items = view, total = activity.Total, limit = activity.Limit, offset = activity.Offset });
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using Deal.Api.Extensions;
|
using Deal.Api.Extensions;
|
||||||
using Deal.Api.Services;
|
using Deal.Api.Services;
|
||||||
|
using Deal.Modules.Tenants.Application.Abstractions;
|
||||||
using Deal.Modules.Tenants.Application.Models;
|
using Deal.Modules.Tenants.Application.Models;
|
||||||
using Deal.Modules.Tenants.Application.Services;
|
using Deal.Modules.Tenants.Application.Services;
|
||||||
|
|
||||||
@@ -39,6 +40,9 @@ public static class OperatorAuditEndpoints
|
|||||||
int? offset,
|
int? offset,
|
||||||
HttpContext context,
|
HttpContext context,
|
||||||
AuditService auditService,
|
AuditService auditService,
|
||||||
|
ITenantRepository tenantRepository,
|
||||||
|
IAuthStore authStore,
|
||||||
|
IAuditReferenceResolver referenceResolver,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
var operatorIdentity = context.GetCurrentOperator();
|
var operatorIdentity = context.GetCurrentOperator();
|
||||||
@@ -51,7 +55,8 @@ public static class OperatorAuditEndpoints
|
|||||||
eventType, actorType, tenantId, from, to, NormalizeLimit(limit), actorId, NormalizeOffset(offset));
|
eventType, actorType, tenantId, from, to, NormalizeLimit(limit), actorId, NormalizeOffset(offset));
|
||||||
IReadOnlyList<AuditRecordDto> items = await auditService.QueryAsync(filter, ct);
|
IReadOnlyList<AuditRecordDto> items = await auditService.QueryAsync(filter, ct);
|
||||||
int total = await auditService.CountAsync(filter, ct);
|
int total = await auditService.CountAsync(filter, ct);
|
||||||
return Results.Ok(new { items, total });
|
IReadOnlyList<AuditRecordViewDto> view = await AuditViewFactory.ProjectAsync(items, tenantRepository, authStore, referenceResolver, ct);
|
||||||
|
return Results.Ok(new { items = view, total });
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ public static class OperatorAuthEndpoints
|
|||||||
ActorId: null,
|
ActorId: null,
|
||||||
TenantId: null,
|
TenantId: null,
|
||||||
Ip: ClientIp(context),
|
Ip: ClientIp(context),
|
||||||
DetailJson: AuditService.ToDetailJson(new { login = attemptedLogin })), ct);
|
DetailJson: AuditService.ToDetailJson([AuditDetails.Set(AuditFields.Login, attemptedLogin)])), ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
return EndpointResults.Unauthorized(InvalidCredentialsDetail);
|
return EndpointResults.Unauthorized(InvalidCredentialsDetail);
|
||||||
@@ -78,7 +78,7 @@ public static class OperatorAuthEndpoints
|
|||||||
ActorId: result.OperatorId,
|
ActorId: result.OperatorId,
|
||||||
TenantId: null,
|
TenantId: null,
|
||||||
Ip: ClientIp(context),
|
Ip: ClientIp(context),
|
||||||
DetailJson: AuditService.ToDetailJson(new { login = result.Login })), ct);
|
DetailJson: AuditService.ToDetailJson([AuditDetails.Set(AuditFields.Login, result.Login)])), ct);
|
||||||
|
|
||||||
SetOperatorSessionCookie(context, cookieOptions.Value, result.Token);
|
SetOperatorSessionCookie(context, cookieOptions.Value, result.Token);
|
||||||
return Results.Ok(new { ok = true, login = result.Login });
|
return Results.Ok(new { ok = true, login = result.Login });
|
||||||
@@ -100,7 +100,7 @@ public static class OperatorAuthEndpoints
|
|||||||
|
|
||||||
if (operatorIdentity is not null)
|
if (operatorIdentity is not null)
|
||||||
{
|
{
|
||||||
await AuditAppender.AppendOperatorAsync(context, AuditEvents.OperatorLogout, new { login = operatorIdentity.Login }, ct);
|
await AuditAppender.AppendOperatorAsync(context, AuditEvents.OperatorLogout, [AuditDetails.Set(AuditFields.Login, operatorIdentity.Login)], ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Results.Ok(new { ok = true });
|
return Results.Ok(new { ok = true });
|
||||||
|
|||||||
@@ -92,7 +92,8 @@ public static class OperatorInvitesEndpoints
|
|||||||
Ip: ClientIp(context),
|
Ip: ClientIp(context),
|
||||||
// Код инвайта — capability-токен (по нему активируется приглашение): в аудит пишется
|
// Код инвайта — capability-токен (по нему активируется приглашение): в аудит пишется
|
||||||
// только его SHA-256-хэш, чтобы утечка ленты не давала рабочие коды (Security review).
|
// только его SHA-256-хэш, чтобы утечка ленты не давала рабочие коды (Security review).
|
||||||
DetailJson: AuditService.ToDetailJson(new { email = result.Invite.Email, codeHash = SessionTokens.HashToken(result.Invite.Code) })), ct);
|
DetailJson: AuditService.ToDetailJson(
|
||||||
|
[AuditDetails.Set(AuditFields.Email, result.Invite.Email), AuditDetails.Set(AuditFields.CodeHash, SessionTokens.HashToken(result.Invite.Code))])), ct);
|
||||||
|
|
||||||
InviteDto invite = result.Invite;
|
InviteDto invite = result.Invite;
|
||||||
return Results.Ok(new { invite.Code, invite.Email, invite.TenantId, invite.ExpiresAt, invite.Status });
|
return Results.Ok(new { invite.Code, invite.Email, invite.TenantId, invite.ExpiresAt, invite.Status });
|
||||||
@@ -126,7 +127,8 @@ public static class OperatorInvitesEndpoints
|
|||||||
ActorId: operatorIdentity.OperatorId,
|
ActorId: operatorIdentity.OperatorId,
|
||||||
TenantId: null,
|
TenantId: null,
|
||||||
Ip: ClientIp(context),
|
Ip: ClientIp(context),
|
||||||
DetailJson: AuditService.ToDetailJson(new { email = result.Invite!.Email, codeHash = SessionTokens.HashToken(result.Invite.Code) })), ct);
|
DetailJson: AuditService.ToDetailJson(
|
||||||
|
[AuditDetails.Set(AuditFields.Email, result.Invite!.Email), AuditDetails.Set(AuditFields.CodeHash, SessionTokens.HashToken(result.Invite.Code))])), ct);
|
||||||
|
|
||||||
return Results.Ok(new { ok = true });
|
return Results.Ok(new { ok = true });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ public static class OperatorLimitsEndpoints
|
|||||||
private static async Task<IResult> ListSummaryAsync(
|
private static async Task<IResult> ListSummaryAsync(
|
||||||
HttpContext context,
|
HttpContext context,
|
||||||
ITenantRepository tenantRepository,
|
ITenantRepository tenantRepository,
|
||||||
|
IAuthStore authStore,
|
||||||
ITenantLimitStore limitStore,
|
ITenantLimitStore limitStore,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
@@ -68,6 +69,8 @@ public static class OperatorLimitsEndpoints
|
|||||||
}
|
}
|
||||||
|
|
||||||
IReadOnlyList<TenantRecordDto> tenants = await tenantRepository.ListAsync(ct);
|
IReadOnlyList<TenantRecordDto> tenants = await tenantRepository.ListAsync(ct);
|
||||||
|
IReadOnlyDictionary<Guid, string> owners = await authStore.FindOwnerLoginsByTenantIdsAsync(
|
||||||
|
tenants.Select(tenant => tenant.Id).ToArray(), ct);
|
||||||
var items = new List<object>(tenants.Count);
|
var items = new List<object>(tenants.Count);
|
||||||
foreach (TenantRecordDto tenant in tenants)
|
foreach (TenantRecordDto tenant in tenants)
|
||||||
{
|
{
|
||||||
@@ -75,6 +78,7 @@ public static class OperatorLimitsEndpoints
|
|||||||
items.Add(new
|
items.Add(new
|
||||||
{
|
{
|
||||||
tenantId = tenant.Id,
|
tenantId = tenant.Id,
|
||||||
|
ownerLogin = owners.GetValueOrDefault(tenant.Id),
|
||||||
name = tenant.Name,
|
name = tenant.Name,
|
||||||
budget = state.BudgetTokens,
|
budget = state.BudgetTokens,
|
||||||
period = state.Period,
|
period = state.Period,
|
||||||
@@ -92,6 +96,7 @@ public static class OperatorLimitsEndpoints
|
|||||||
Guid id,
|
Guid id,
|
||||||
HttpContext context,
|
HttpContext context,
|
||||||
ITenantRepository tenantRepository,
|
ITenantRepository tenantRepository,
|
||||||
|
IAuthStore authStore,
|
||||||
ITenantLimitStore limitStore,
|
ITenantLimitStore limitStore,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
@@ -108,7 +113,7 @@ public static class OperatorLimitsEndpoints
|
|||||||
}
|
}
|
||||||
|
|
||||||
BudgetStateDto state = await limitStore.GetStateAsync(id, ct);
|
BudgetStateDto state = await limitStore.GetStateAsync(id, ct);
|
||||||
return Results.Ok(BuildDetailDto(tenant.Name, state));
|
return Results.Ok(BuildDetailDto(tenant.Name, await ResolveOwnerLoginAsync(authStore, id, ct), state));
|
||||||
}
|
}
|
||||||
|
|
||||||
// PATCH /api/operator/tenants/{id}/limit: смена бюджета/периода (сброс флагов + аудит tenant_limit_changed).
|
// PATCH /api/operator/tenants/{id}/limit: смена бюджета/периода (сброс флагов + аудит tenant_limit_changed).
|
||||||
@@ -117,6 +122,7 @@ public static class OperatorLimitsEndpoints
|
|||||||
OperatorLimitUpdateRequest? body,
|
OperatorLimitUpdateRequest? body,
|
||||||
HttpContext context,
|
HttpContext context,
|
||||||
ITenantRepository tenantRepository,
|
ITenantRepository tenantRepository,
|
||||||
|
IAuthStore authStore,
|
||||||
ITenantLimitStore limitStore,
|
ITenantLimitStore limitStore,
|
||||||
AuditService auditService,
|
AuditService auditService,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
@@ -154,10 +160,11 @@ public static class OperatorLimitsEndpoints
|
|||||||
BudgetStateDto current = await limitStore.GetStateAsync(id, ct);
|
BudgetStateDto current = await limitStore.GetStateAsync(id, ct);
|
||||||
long newBudget = body.Budget ?? current.BudgetTokens;
|
long newBudget = body.Budget ?? current.BudgetTokens;
|
||||||
string newPeriod = body.Period ?? current.Period;
|
string newPeriod = body.Period ?? current.Period;
|
||||||
|
string? ownerLogin = await ResolveOwnerLoginAsync(authStore, id, ct);
|
||||||
if (newBudget == current.BudgetTokens && newPeriod == current.Period)
|
if (newBudget == current.BudgetTokens && newPeriod == current.Period)
|
||||||
{
|
{
|
||||||
// Идемпотентный повторный PATCH: без изменения хранилища и без дубля аудита.
|
// Идемпотентный повторный PATCH: без изменения хранилища и без дубля аудита.
|
||||||
return Results.Ok(BuildDetailDto(tenant.Name, current));
|
return Results.Ok(BuildDetailDto(tenant.Name, ownerLogin, current));
|
||||||
}
|
}
|
||||||
|
|
||||||
BudgetStateDto updated = await limitStore.UpdateBudgetAsync(id, newBudget, newPeriod, ct);
|
BudgetStateDto updated = await limitStore.UpdateBudgetAsync(id, newBudget, newPeriod, ct);
|
||||||
@@ -167,16 +174,10 @@ public static class OperatorLimitsEndpoints
|
|||||||
ActorId: operatorIdentity.OperatorId,
|
ActorId: operatorIdentity.OperatorId,
|
||||||
TenantId: id,
|
TenantId: id,
|
||||||
Ip: ClientIp(context),
|
Ip: ClientIp(context),
|
||||||
DetailJson: AuditService.ToDetailJson(new
|
DetailJson: AuditService.ToDetailJson(
|
||||||
{
|
[AuditDetails.Change(AuditFields.Budget, current.BudgetTokens, newBudget), AuditDetails.Change(AuditFields.Period, current.Period, newPeriod)])), ct);
|
||||||
tenantId = id,
|
|
||||||
oldBudget = current.BudgetTokens,
|
|
||||||
oldPeriod = current.Period,
|
|
||||||
budgetTokens = newBudget,
|
|
||||||
period = newPeriod,
|
|
||||||
})), ct);
|
|
||||||
|
|
||||||
return Results.Ok(BuildDetailDto(tenant.Name, updated));
|
return Results.Ok(BuildDetailDto(tenant.Name, ownerLogin, updated));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -197,11 +198,13 @@ public static class OperatorLimitsEndpoints
|
|||||||
|
|
||||||
// Форма деталей лимита тенанта (GET и ответ PATCH — единая).
|
// Форма деталей лимита тенанта (GET и ответ PATCH — единая).
|
||||||
// name: Имя тенанта (реестр).
|
// name: Имя тенанта (реестр).
|
||||||
|
// ownerLogin: Логин владельца пространства; null, если пользователей нет.
|
||||||
// state: Состояние бюджета (после ленивого reset).
|
// state: Состояние бюджета (после ленивого reset).
|
||||||
// Возвращает: Объект ответа: лимит + расход + флаги порогов + статус тенанта.
|
// Возвращает: Объект ответа: лимит + расход + флаги порогов + статус тенанта.
|
||||||
private static object BuildDetailDto(string name, BudgetStateDto state) => new
|
private static object BuildDetailDto(string name, string? ownerLogin, BudgetStateDto state) => new
|
||||||
{
|
{
|
||||||
tenantId = state.TenantId,
|
tenantId = state.TenantId,
|
||||||
|
ownerLogin,
|
||||||
name,
|
name,
|
||||||
status = state.Status,
|
status = state.Status,
|
||||||
allowed = state.Allowed,
|
allowed = state.Allowed,
|
||||||
@@ -215,6 +218,17 @@ public static class OperatorLimitsEndpoints
|
|||||||
notifiedExhausted = state.NotifiedExhausted,
|
notifiedExhausted = state.NotifiedExhausted,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Логин владельца пространства (первый пользователь по времени создания).
|
||||||
|
// authStore: Хранилище пользователей.
|
||||||
|
// tenantId: Идентификатор пространства.
|
||||||
|
// ct: Токен отмены.
|
||||||
|
// Возвращает: Логин либо null.
|
||||||
|
private static async Task<string?> ResolveOwnerLoginAsync(IAuthStore authStore, Guid tenantId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
IReadOnlyDictionary<Guid, string> owners = await authStore.FindOwnerLoginsByTenantIdsAsync([tenantId], ct);
|
||||||
|
return owners.GetValueOrDefault(tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
// IP-адрес клиента для аудита (без порта; null, если недоступен).
|
// IP-адрес клиента для аудита (без порта; null, если недоступен).
|
||||||
// context: Контекст запроса.
|
// context: Контекст запроса.
|
||||||
// Возвращает: Строковое представление IP или null.
|
// Возвращает: Строковое представление IP или null.
|
||||||
|
|||||||
@@ -128,7 +128,8 @@ public static class OperatorSettingsEndpoints
|
|||||||
ActorId: operatorIdentity.OperatorId,
|
ActorId: operatorIdentity.OperatorId,
|
||||||
TenantId: null,
|
TenantId: null,
|
||||||
Ip: ClientIp(context),
|
Ip: ClientIp(context),
|
||||||
DetailJson: AuditService.ToDetailJson(new { apiId = effectiveApiId, apiHashSet = true })), ct);
|
DetailJson: AuditService.ToDetailJson(
|
||||||
|
[AuditDetails.Set(AuditFields.ApiId, effectiveApiId), AuditDetails.Set(AuditFields.ApiHashSet, true)])), ct);
|
||||||
|
|
||||||
TelegramKeysMaskedDto snapshot = await keys.GetMaskedAsync(ct);
|
TelegramKeysMaskedDto snapshot = await keys.GetMaskedAsync(ct);
|
||||||
return Results.Ok(snapshot);
|
return Results.Ok(snapshot);
|
||||||
|
|||||||
@@ -113,7 +113,8 @@ public static class OperatorTenantsEndpoints
|
|||||||
ActorId: operatorIdentity.OperatorId,
|
ActorId: operatorIdentity.OperatorId,
|
||||||
TenantId: createdTenant.Id,
|
TenantId: createdTenant.Id,
|
||||||
Ip: ClientIp(context),
|
Ip: ClientIp(context),
|
||||||
DetailJson: AuditService.ToDetailJson(new { tenantId = createdTenant.Id, name = createdTenant.Name, email = result.OwnerLogin })), ct);
|
DetailJson: AuditService.ToDetailJson(
|
||||||
|
[AuditDetails.Set(AuditFields.Name, createdTenant.Name), AuditDetails.Set(AuditFields.Email, result.OwnerLogin)])), ct);
|
||||||
|
|
||||||
if (result.OwnerLogin is not null)
|
if (result.OwnerLogin is not null)
|
||||||
{
|
{
|
||||||
@@ -202,7 +203,8 @@ public static class OperatorTenantsEndpoints
|
|||||||
ActorId: operatorIdentity.OperatorId,
|
ActorId: operatorIdentity.OperatorId,
|
||||||
TenantId: result.Tenant.Id,
|
TenantId: result.Tenant.Id,
|
||||||
Ip: ClientIp(context),
|
Ip: ClientIp(context),
|
||||||
DetailJson: AuditService.ToDetailJson(new { tenantId = result.Tenant.Id, status = result.Tenant.Status })), ct);
|
DetailJson: AuditService.ToDetailJson(
|
||||||
|
[AuditDetails.Change(AuditFields.Status, result.PreviousStatus, result.Tenant.Status)])), ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Results.Ok(new { ok = true, status = result.Tenant.Status });
|
return Results.Ok(new { ok = true, status = result.Tenant.Status });
|
||||||
@@ -242,7 +244,7 @@ public static class OperatorTenantsEndpoints
|
|||||||
ActorId: operatorIdentity.OperatorId,
|
ActorId: operatorIdentity.OperatorId,
|
||||||
TenantId: result.TenantId,
|
TenantId: result.TenantId,
|
||||||
Ip: ClientIp(context),
|
Ip: ClientIp(context),
|
||||||
DetailJson: AuditService.ToDetailJson(new { targetLogin = result.Login, tenantId = result.TenantId })), ct);
|
DetailJson: AuditService.ToDetailJson([AuditDetails.Set(AuditFields.TargetLogin, result.Login)])), ct);
|
||||||
|
|
||||||
// Токен — это tenant-сессия (как после /api/auth/login): СТАВИМ ту же httpOnly-куку deal_session
|
// Токен — это tenant-сессия (как после /api/auth/login): СТАВИМ ту же httpOnly-куку deal_session
|
||||||
// на ответ, чтобы браузер оператора сразу получил tenant-сессию (JS не может записать httpOnly-куку).
|
// на ответ, чтобы браузер оператора сразу получил tenant-сессию (JS не может записать httpOnly-куку).
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ using Deal.Modules.Settings.Application.Abstractions;
|
|||||||
using Deal.Modules.Settings.Application.Models;
|
using Deal.Modules.Settings.Application.Models;
|
||||||
using Deal.Modules.Settings.Application.Services;
|
using Deal.Modules.Settings.Application.Services;
|
||||||
using Deal.Modules.Tenants.Application.Models;
|
using Deal.Modules.Tenants.Application.Models;
|
||||||
|
using Deal.SharedKernel.Utilities;
|
||||||
|
|
||||||
namespace Deal.Api.Endpoints;
|
namespace Deal.Api.Endpoints;
|
||||||
|
|
||||||
@@ -18,6 +19,15 @@ public static class SettingsEndpoints
|
|||||||
private const string SettingsOpenApiTag = "settings";
|
private const string SettingsOpenApiTag = "settings";
|
||||||
private const string InvalidBodyDetail = "Тело запроса должно быть JSON-объектом";
|
private const string InvalidBodyDetail = "Тело запроса должно быть JSON-объектом";
|
||||||
|
|
||||||
|
// Максимальная длина значения в деталях аудита (промпты/списки бывают длинными).
|
||||||
|
private const int MaxAuditValueLength = 120;
|
||||||
|
|
||||||
|
// Пометка для составных настроек, чьи значения в аудит не пишутся (секреты, снимки состояния).
|
||||||
|
private const string ChangedMarker = "изменено";
|
||||||
|
|
||||||
|
// Опции сериализации снимка настроек в деталях аудита (camelCase, как на wire).
|
||||||
|
private static readonly JsonSerializerOptions AuditJsonOptions = new(JsonSerializerDefaults.Web);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Регистрирует GET/PATCH /api/settings.
|
/// Регистрирует GET/PATCH /api/settings.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -72,9 +82,10 @@ public static class SettingsEndpoints
|
|||||||
}
|
}
|
||||||
|
|
||||||
SettingsService settingsService = context.RequestServices.GetRequiredService<SettingsService>();
|
SettingsService settingsService = context.RequestServices.GetRequiredService<SettingsService>();
|
||||||
|
PublicSettingsDto before = await settingsService.GetPublicAsync(ct);
|
||||||
PublicSettingsDto result = await settingsService.ApplyPatchAsync(body, ct);
|
PublicSettingsDto result = await settingsService.ApplyPatchAsync(body, ct);
|
||||||
|
|
||||||
await AuditAppender.AppendTenantAsync(context, AuditEvents.SettingsUpdated, new { fields = body.Keys }, ct);
|
await AuditAppender.AppendTenantAsync(context, AuditEvents.SettingsUpdated, BuildSettingsChanges(before, result, body.Keys), ct);
|
||||||
|
|
||||||
if (ShouldScheduleRatesRefresh(body))
|
if (ShouldScheduleRatesRefresh(body))
|
||||||
{
|
{
|
||||||
@@ -84,6 +95,60 @@ public static class SettingsEndpoints
|
|||||||
return Results.Ok(result);
|
return Results.Ok(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Строит изменения настроек «поле: было → стало» по применённому PATCH.
|
||||||
|
// before: Снимок настроек до применения.
|
||||||
|
// after: Снимок настроек после применения.
|
||||||
|
// keys: Ключи PATCH-тела (изменённые).
|
||||||
|
// Возвращает: Изменения по каждому ключу (составные — пометкой «изменено»).
|
||||||
|
private static IReadOnlyList<AuditChangeDto> BuildSettingsChanges(
|
||||||
|
PublicSettingsDto before,
|
||||||
|
PublicSettingsDto after,
|
||||||
|
IEnumerable<string> keys)
|
||||||
|
{
|
||||||
|
using JsonDocument beforeDoc = JsonDocument.Parse(JsonSerializer.Serialize(before, AuditJsonOptions));
|
||||||
|
using JsonDocument afterDoc = JsonDocument.Parse(JsonSerializer.Serialize(after, AuditJsonOptions));
|
||||||
|
var changes = new List<AuditChangeDto>();
|
||||||
|
var keyList = new List<string>();
|
||||||
|
foreach (string key in keys)
|
||||||
|
{
|
||||||
|
keyList.Add(key);
|
||||||
|
SettingKind? kind = SettingsKeys.FindPublicKind(key);
|
||||||
|
if (kind is SettingKind.Dict or SettingKind.MyPrompts or SettingKind.AiConfigs)
|
||||||
|
{
|
||||||
|
changes.Add(AuditDetails.Set(key, ChangedMarker));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
changes.Add(AuditDetails.Change(
|
||||||
|
key,
|
||||||
|
ReadSettingText(beforeDoc.RootElement, key),
|
||||||
|
ReadSettingText(afterDoc.RootElement, key)));
|
||||||
|
}
|
||||||
|
|
||||||
|
return changes.Count > 0
|
||||||
|
? changes
|
||||||
|
: [AuditDetails.Set(AuditFields.Fields, string.Join(", ", keyList))];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Читает значение ключа снимка настроек как текст для деталей аудита.
|
||||||
|
private static string? ReadSettingText(JsonElement root, string key) =>
|
||||||
|
root.TryGetProperty(key, out JsonElement value) ? ToText(value) : null;
|
||||||
|
|
||||||
|
// Приводит значение JSON к строке отображения (длинные значения обрезаются).
|
||||||
|
private static string? ToText(JsonElement value) => value.ValueKind switch
|
||||||
|
{
|
||||||
|
JsonValueKind.Null or JsonValueKind.Undefined => null,
|
||||||
|
JsonValueKind.String => Truncate(value.GetString()),
|
||||||
|
JsonValueKind.True => BoolText.True,
|
||||||
|
JsonValueKind.False => BoolText.False,
|
||||||
|
JsonValueKind.Array => Truncate(string.Join(", ", value.EnumerateArray().Select(ToText))),
|
||||||
|
_ => Truncate(value.GetRawText()),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Обрезает длинное значение деталей аудита.
|
||||||
|
private static string? Truncate(string? value) =>
|
||||||
|
value is not null && value.Length > MaxAuditValueLength ? value[..MaxAuditValueLength] + "…" : value;
|
||||||
|
|
||||||
private static bool ShouldScheduleRatesRefresh(Dictionary<string, JsonElement> body)
|
private static bool ShouldScheduleRatesRefresh(Dictionary<string, JsonElement> body)
|
||||||
{
|
{
|
||||||
if (!body.TryGetValue(SettingsKeys.RateSource, out JsonElement element))
|
if (!body.TryGetValue(SettingsKeys.RateSource, out JsonElement element))
|
||||||
|
|||||||
@@ -67,7 +67,12 @@ public static class TelegramEndpoints
|
|||||||
|
|
||||||
private const string NotConnectedReason = "not-connected";
|
private const string NotConnectedReason = "not-connected";
|
||||||
|
|
||||||
private const string ReadyPhase = "ready";
|
private const string ReadyPhase = TelegramAuthPhases.Ready;
|
||||||
|
|
||||||
|
// Русские подписи видов диалогов для операторского UI.
|
||||||
|
private const string RussianChannelLabel = "канал";
|
||||||
|
private const string RussianGroupLabel = "группа";
|
||||||
|
private const string RussianChatLabel = "чат";
|
||||||
|
|
||||||
private const int PreviewDefaultLimit = 24;
|
private const int PreviewDefaultLimit = 24;
|
||||||
|
|
||||||
@@ -170,7 +175,7 @@ public static class TelegramEndpoints
|
|||||||
TelegramAuthResultDto result = await gateway.StartQrAsync(apiId, keys.ApiHash, ct);
|
TelegramAuthResultDto result = await gateway.StartQrAsync(apiId, keys.ApiHash, ct);
|
||||||
if (result.Phase == ReadyPhase)
|
if (result.Phase == ReadyPhase)
|
||||||
{
|
{
|
||||||
await AuditAppender.AppendTenantAsync(context, AuditEvents.TelegramLinked, new { phase = result.Phase }, ct);
|
await AuditAppender.AppendTenantAsync(context, AuditEvents.TelegramLinked, [AuditDetails.Set(AuditFields.Phase, result.Phase)], ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Results.Ok(new { phase = result.Phase, qrUrl = result.QrUrl ?? string.Empty });
|
return Results.Ok(new { phase = result.Phase, qrUrl = result.QrUrl ?? string.Empty });
|
||||||
@@ -197,7 +202,7 @@ public static class TelegramEndpoints
|
|||||||
string phase = await gateway.SendCodeAsync((body.Code ?? string.Empty).Trim(), ct);
|
string phase = await gateway.SendCodeAsync((body.Code ?? string.Empty).Trim(), ct);
|
||||||
if (phase == ReadyPhase)
|
if (phase == ReadyPhase)
|
||||||
{
|
{
|
||||||
await AuditAppender.AppendTenantAsync(context, AuditEvents.TelegramLinked, new { phase }, ct);
|
await AuditAppender.AppendTenantAsync(context, AuditEvents.TelegramLinked, [AuditDetails.Set(AuditFields.Phase, phase)], ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Results.Ok(new { phase });
|
return Results.Ok(new { phase });
|
||||||
@@ -224,7 +229,7 @@ public static class TelegramEndpoints
|
|||||||
string phase = await gateway.SendPasswordAsync(body.Password ?? string.Empty, ct);
|
string phase = await gateway.SendPasswordAsync(body.Password ?? string.Empty, ct);
|
||||||
if (phase == ReadyPhase)
|
if (phase == ReadyPhase)
|
||||||
{
|
{
|
||||||
await AuditAppender.AppendTenantAsync(context, AuditEvents.TelegramLinked, new { phase }, ct);
|
await AuditAppender.AppendTenantAsync(context, AuditEvents.TelegramLinked, [AuditDetails.Set(AuditFields.Phase, phase)], ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Results.Ok(new { phase });
|
return Results.Ok(new { phase });
|
||||||
@@ -343,7 +348,7 @@ public static class TelegramEndpoints
|
|||||||
|
|
||||||
if (body.Enabled)
|
if (body.Enabled)
|
||||||
{
|
{
|
||||||
await AuditAppender.AppendTenantAsync(context, AuditEvents.ChannelEnabled, new { all = true, count = result.Count }, ct);
|
await AuditAppender.AppendTenantAsync(context, AuditEvents.ChannelEnabled, [AuditDetails.Set(AuditFields.All, true), AuditDetails.Set(AuditFields.Count, result.Count)], ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Results.Ok(new { ok = true, count = result.Count, enabled = body.Enabled });
|
return Results.Ok(new { ok = true, count = result.Count, enabled = body.Enabled });
|
||||||
@@ -393,7 +398,7 @@ public static class TelegramEndpoints
|
|||||||
|
|
||||||
if (result.Enabled)
|
if (result.Enabled)
|
||||||
{
|
{
|
||||||
await AuditAppender.AppendTenantAsync(context, AuditEvents.ChannelEnabled, new { dialogId = dialog_id }, ct);
|
await AuditAppender.AppendTenantAsync(context, AuditEvents.ChannelEnabled, [AuditDetails.Set(AuditFields.DialogId, dialog_id)], ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Results.Ok(new { ok = true, enabled = result.Enabled });
|
return Results.Ok(new { ok = true, enabled = result.Enabled });
|
||||||
@@ -482,9 +487,9 @@ public static class TelegramEndpoints
|
|||||||
{
|
{
|
||||||
return kind switch
|
return kind switch
|
||||||
{
|
{
|
||||||
"channel" => "канал",
|
TelegramDialogKinds.Channel => RussianChannelLabel,
|
||||||
"group" or "forum" => "группа",
|
TelegramDialogKinds.Group or TelegramDialogKinds.Forum => RussianGroupLabel,
|
||||||
"chat" => "чат",
|
TelegramDialogKinds.Chat => RussianChatLabel,
|
||||||
_ => kind,
|
_ => kind,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -165,6 +165,8 @@ builder.Services.AddSingleton<TelegramBackfillScheduler>();
|
|||||||
|
|
||||||
builder.Services.AddScoped<AdminTickOrchestrator>();
|
builder.Services.AddScoped<AdminTickOrchestrator>();
|
||||||
|
|
||||||
|
builder.Services.AddScoped<IAuditReferenceResolver, AuditReferenceResolver>();
|
||||||
|
|
||||||
builder.Services.AddScoped<FtsMaintenance>();
|
builder.Services.AddScoped<FtsMaintenance>();
|
||||||
|
|
||||||
builder.Services.AddSingleton<SseBroker>();
|
builder.Services.AddSingleton<SseBroker>();
|
||||||
|
|||||||
@@ -13,12 +13,14 @@ public static class AuditAppender
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Пишет событие действия пользователя тенанта
|
/// Пишет событие действия пользователя тенанта
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="context">Контекст запроса (сессия пользователя, IP).</param>
|
||||||
/// <param name="eventType">Тип события — константа <see cref="AuditEvents"/>.</param>
|
/// <param name="eventType">Тип события — константа <see cref="AuditEvents"/>.</param>
|
||||||
/// <param name="details">Минимальные детали события (обычно анонимный объект) или null.</param>
|
/// <param name="changes">Изменения параметров события.</param>
|
||||||
|
/// <param name="ct">Токен отмены.</param>
|
||||||
public static async Task AppendTenantAsync(
|
public static async Task AppendTenantAsync(
|
||||||
HttpContext context,
|
HttpContext context,
|
||||||
string eventType,
|
string eventType,
|
||||||
object? details,
|
IReadOnlyList<AuditChangeDto> changes,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
CurrentUser? user = context.GetCurrentUser();
|
CurrentUser? user = context.GetCurrentUser();
|
||||||
@@ -35,19 +37,21 @@ public static class AuditAppender
|
|||||||
ActorId: user.UserId,
|
ActorId: user.UserId,
|
||||||
TenantId: user.TenantId,
|
TenantId: user.TenantId,
|
||||||
Ip: ClientIp(context),
|
Ip: ClientIp(context),
|
||||||
DetailJson: DetailJson(details)),
|
DetailJson: DetailJson(changes)),
|
||||||
ct);
|
ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Пишет событие действия оператора
|
/// Пишет событие действия оператора
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="context">Контекст запроса (операторская сессия, IP).</param>
|
||||||
/// <param name="eventType">Тип события — константа <see cref="AuditEvents"/>.</param>
|
/// <param name="eventType">Тип события — константа <see cref="AuditEvents"/>.</param>
|
||||||
/// <param name="details">Минимальные детали события (обычно анонимный объект) или null.</param>
|
/// <param name="changes">Изменения параметров события.</param>
|
||||||
|
/// <param name="ct">Токен отмены.</param>
|
||||||
public static async Task AppendOperatorAsync(
|
public static async Task AppendOperatorAsync(
|
||||||
HttpContext context,
|
HttpContext context,
|
||||||
string eventType,
|
string eventType,
|
||||||
object? details,
|
IReadOnlyList<AuditChangeDto> changes,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
CurrentOperator? operatorIdentity = context.GetCurrentOperator();
|
CurrentOperator? operatorIdentity = context.GetCurrentOperator();
|
||||||
@@ -64,18 +68,16 @@ public static class AuditAppender
|
|||||||
ActorId: operatorIdentity.OperatorId,
|
ActorId: operatorIdentity.OperatorId,
|
||||||
TenantId: null,
|
TenantId: null,
|
||||||
Ip: ClientIp(context),
|
Ip: ClientIp(context),
|
||||||
DetailJson: DetailJson(details)),
|
DetailJson: DetailJson(changes)),
|
||||||
ct);
|
ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Сериализует детали события (null — деталей нет).
|
// Сериализует детали события (пустой список — деталей нет).
|
||||||
// details: Объект деталей или null.
|
// changes: Изменения параметров события.
|
||||||
// Возвращает: JSON деталей (camelCase) или null.
|
// Возвращает: JSON деталей или null.
|
||||||
private static string? DetailJson(object? details) =>
|
private static string? DetailJson(IReadOnlyList<AuditChangeDto> changes) =>
|
||||||
details is null ? null : AuditService.ToDetailJson(details);
|
changes.Count == 0 ? null : AuditService.ToDetailJson(changes);
|
||||||
|
|
||||||
// IP-адрес клиента для аудита (без порта; null, если недоступен).
|
// IP-адрес клиента для аудита (без порта; null, если недоступен).
|
||||||
// context: Контекст запроса.
|
|
||||||
// Возвращает: Строковое представление IP или null.
|
|
||||||
private static string? ClientIp(HttpContext context) => context.Connection.RemoteIpAddress?.ToString();
|
private static string? ClientIp(HttpContext context) => context.Connection.RemoteIpAddress?.ToString();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
using Deal.Modules.Cards.Application.Models;
|
||||||
|
using Deal.Modules.Kanban.Application.Abstractions;
|
||||||
|
using Deal.Modules.Kanban.Application.Models;
|
||||||
|
using Deal.Modules.Tenants.Application.Models;
|
||||||
|
using Deal.SharedKernel.Tenants.Abstractions;
|
||||||
|
using Deal.SharedKernel.Tenants.Models;
|
||||||
|
|
||||||
|
namespace Deal.Api.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Резолвер ссылок аудита на данные тенанта
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="scopeFactory">Фабрика scope для чтения схемы тенанта.</param>
|
||||||
|
/// <param name="logger">Логгер сбоев разрешения ссылок.</param>
|
||||||
|
public sealed class AuditReferenceResolver(
|
||||||
|
IServiceScopeFactory scopeFactory,
|
||||||
|
ILogger<AuditReferenceResolver> logger) : IAuditReferenceResolver
|
||||||
|
{
|
||||||
|
async Task<IReadOnlyDictionary<string, string>> IAuditReferenceResolver.ResolveAsync(
|
||||||
|
IReadOnlyList<AuditRecordDto> records,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
var names = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||||
|
foreach (IGrouping<Guid, AuditRecordDto> group in GroupByTenant(records))
|
||||||
|
{
|
||||||
|
await ResolveTenantAsync(group.Key, group, names, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
return names;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IEnumerable<IGrouping<Guid, AuditRecordDto>> GroupByTenant(IReadOnlyList<AuditRecordDto> records) =>
|
||||||
|
records
|
||||||
|
.Where(record => record.TenantId is not null)
|
||||||
|
.GroupBy(record => record.TenantId!.Value);
|
||||||
|
|
||||||
|
private async Task ResolveTenantAsync(
|
||||||
|
Guid tenantId,
|
||||||
|
IEnumerable<AuditRecordDto> records,
|
||||||
|
Dictionary<string, string> names,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
var cardIds = new HashSet<string>(StringComparer.Ordinal);
|
||||||
|
var boardIds = new HashSet<string>(StringComparer.Ordinal);
|
||||||
|
foreach (AuditRecordDto record in records)
|
||||||
|
{
|
||||||
|
foreach (AuditChangeDto change in record.AuditChanges())
|
||||||
|
{
|
||||||
|
Collect(change.From, cardIds, boardIds);
|
||||||
|
Collect(change.To, cardIds, boardIds);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cardIds.Count == 0 && boardIds.Count == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await using AsyncServiceScope scope = scopeFactory.CreateAsyncScope();
|
||||||
|
ITenantContext tenantContext = scope.ServiceProvider.GetRequiredService<ITenantContext>();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
tenantContext.SetTenant(new TenantId(tenantId.ToString("N")));
|
||||||
|
ICardStore store = scope.ServiceProvider.GetRequiredService<ICardStore>();
|
||||||
|
await ResolveCardsAsync(store, cardIds, names, ct);
|
||||||
|
await ResolveContainersAsync(store, boardIds, names, ct);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
logger.LogWarning(exception, "Резолвер аудита: ссылки тенанта {TenantId} не разрешены", tenantId);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
tenantContext.Reset();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Collect(
|
||||||
|
string? value,
|
||||||
|
HashSet<string> cardIds,
|
||||||
|
HashSet<string> boardIds)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(value))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value.StartsWith(CardIds.CardPrefix, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
cardIds.Add(value);
|
||||||
|
}
|
||||||
|
else if (value.StartsWith(KanbanIdPrefixes.Board, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
boardIds.Add(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task ResolveCardsAsync(
|
||||||
|
ICardStore store,
|
||||||
|
HashSet<string> cardIds,
|
||||||
|
Dictionary<string, string> names,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
foreach (string cardId in cardIds)
|
||||||
|
{
|
||||||
|
CardDto? card = await store.GetCardAsync(cardId, ct);
|
||||||
|
if (card is not null && !string.IsNullOrWhiteSpace(card.Title))
|
||||||
|
{
|
||||||
|
names[cardId] = card.Title;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task ResolveContainersAsync(
|
||||||
|
ICardStore store,
|
||||||
|
HashSet<string> boardIds,
|
||||||
|
Dictionary<string, string> names,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
foreach (string boardId in boardIds)
|
||||||
|
{
|
||||||
|
ContainerDto? container = await store.GetContainerAsync(boardId, ct);
|
||||||
|
if (container is not null && !string.IsNullOrWhiteSpace(container.Name))
|
||||||
|
{
|
||||||
|
names[boardId] = container.Name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
using Deal.Modules.Tenants.Application.Abstractions;
|
||||||
|
using Deal.Modules.Tenants.Application.Models;
|
||||||
|
|
||||||
|
namespace Deal.Api.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Проекция записей аудита для операторской консоли
|
||||||
|
/// </summary>
|
||||||
|
public static class AuditViewFactory
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Дополняет записи аудита реальными пользователями и разбирает изменения деталей
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="records">Записи аудита.</param>
|
||||||
|
/// <param name="tenantRepository">Реестр пространств для разрешения имён.</param>
|
||||||
|
/// <param name="authStore">Хранилище пользователей для разрешения логинов владельцев.</param>
|
||||||
|
/// <param name="referenceResolver">Резолвер ссылок на карточки и колонки.</param>
|
||||||
|
/// <param name="ct">Токен отмены.</param>
|
||||||
|
/// <returns>Записи для чтения оператором.</returns>
|
||||||
|
public static async Task<IReadOnlyList<AuditRecordViewDto>> ProjectAsync(
|
||||||
|
IReadOnlyList<AuditRecordDto> records,
|
||||||
|
ITenantRepository tenantRepository,
|
||||||
|
IAuthStore authStore,
|
||||||
|
IAuditReferenceResolver referenceResolver,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
Guid[] tenantIds = DistinctTenantIds(records);
|
||||||
|
IReadOnlyDictionary<Guid, string> names = await ResolveNamesAsync(tenantIds, tenantRepository, ct);
|
||||||
|
IReadOnlyDictionary<Guid, string> owners = await ResolveOwnerLoginsAsync(tenantIds, authStore, ct);
|
||||||
|
IReadOnlyDictionary<string, string> references = await referenceResolver.ResolveAsync(records, ct);
|
||||||
|
|
||||||
|
var view = new List<AuditRecordViewDto>(records.Count);
|
||||||
|
foreach (AuditRecordDto record in records)
|
||||||
|
{
|
||||||
|
string? tenantName = record.TenantId is { } tenantId && names.TryGetValue(tenantId, out string? name)
|
||||||
|
? name
|
||||||
|
: null;
|
||||||
|
view.Add(new AuditRecordViewDto(
|
||||||
|
record.EventType,
|
||||||
|
record.ActorType,
|
||||||
|
record.ActorId,
|
||||||
|
record.TenantId,
|
||||||
|
ResolveUserName(record, owners),
|
||||||
|
tenantName,
|
||||||
|
record.Ip,
|
||||||
|
ResolveChanges(record, references),
|
||||||
|
record.DetailJson,
|
||||||
|
record.At,
|
||||||
|
record.Id));
|
||||||
|
}
|
||||||
|
|
||||||
|
return view;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Заменяет идентификаторы ссылок изменения значениями из карты имён и убирает пустые изменения.
|
||||||
|
// record: Запись аудита.
|
||||||
|
// references: Имена по идентификаторам ссылок.
|
||||||
|
// Возвращает: Изменения с читаемыми значениями ссылок без пар «без изменения».
|
||||||
|
private static IReadOnlyList<AuditChangeDto> ResolveChanges(
|
||||||
|
AuditRecordDto record,
|
||||||
|
IReadOnlyDictionary<string, string> references)
|
||||||
|
{
|
||||||
|
IReadOnlyList<AuditChangeDto> changes = record.AuditChanges();
|
||||||
|
var resolved = new List<AuditChangeDto>(changes.Count);
|
||||||
|
foreach (AuditChangeDto change in changes)
|
||||||
|
{
|
||||||
|
string? from = ResolveReference(references, change.From);
|
||||||
|
string? to = ResolveReference(references, change.To);
|
||||||
|
if (IsUnchanged(from, to))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
resolved.Add(change with { From = from, To = to });
|
||||||
|
}
|
||||||
|
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сравнивает значения «было» и «стало».
|
||||||
|
// from: Значение до изменения.
|
||||||
|
// to: Значение после изменения.
|
||||||
|
// Возвращает: True — значения заданы и совпадают.
|
||||||
|
private static bool IsUnchanged(string? from, string? to) =>
|
||||||
|
from is not null && to is not null && string.Equals(from, to, StringComparison.Ordinal);
|
||||||
|
|
||||||
|
// Подставляет имя вместо идентификатора ссылки, если оно известно.
|
||||||
|
// references: Имена по идентификаторам ссылок.
|
||||||
|
// value: Значение изменения.
|
||||||
|
// Возвращает: Имя ссылки либо исходное значение.
|
||||||
|
private static string? ResolveReference(
|
||||||
|
IReadOnlyDictionary<string, string> references,
|
||||||
|
string? value) =>
|
||||||
|
value is not null && references.TryGetValue(value, out string? name) ? name : value;
|
||||||
|
|
||||||
|
// Логин реального пользователя: владелец пространства, иначе логин/email из деталей события.
|
||||||
|
// record: Запись аудита.
|
||||||
|
// owners: Логины владельцев по идентификаторам пространств.
|
||||||
|
// Возвращает: Логин либо null.
|
||||||
|
private static string? ResolveUserName(
|
||||||
|
AuditRecordDto record,
|
||||||
|
IReadOnlyDictionary<Guid, string> owners)
|
||||||
|
{
|
||||||
|
if (record.TenantId is { } tenantId && owners.TryGetValue(tenantId, out string? owner))
|
||||||
|
{
|
||||||
|
return owner;
|
||||||
|
}
|
||||||
|
|
||||||
|
return record.DetailValue(AuditFields.Login) ?? record.DetailValue(AuditFields.Email);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Уникальные идентификаторы пространств выборки.
|
||||||
|
private static Guid[] DistinctTenantIds(IReadOnlyList<AuditRecordDto> records) =>
|
||||||
|
records
|
||||||
|
.Select(record => record.TenantId)
|
||||||
|
.Where(id => id is not null)
|
||||||
|
.Select(id => id!.Value)
|
||||||
|
.Distinct()
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
// Разрешает имена пространств выборкой по уникальным идентификаторам.
|
||||||
|
private static async Task<IReadOnlyDictionary<Guid, string>> ResolveNamesAsync(
|
||||||
|
Guid[] tenantIds,
|
||||||
|
ITenantRepository tenantRepository,
|
||||||
|
CancellationToken ct) =>
|
||||||
|
tenantIds.Length == 0
|
||||||
|
? new Dictionary<Guid, string>()
|
||||||
|
: await tenantRepository.FindNamesByIdsAsync(tenantIds, ct);
|
||||||
|
|
||||||
|
// Разрешает логины владельцев пространств выборкой по уникальным идентификаторам.
|
||||||
|
private static async Task<IReadOnlyDictionary<Guid, string>> ResolveOwnerLoginsAsync(
|
||||||
|
Guid[] tenantIds,
|
||||||
|
IAuthStore authStore,
|
||||||
|
CancellationToken ct) =>
|
||||||
|
tenantIds.Length == 0
|
||||||
|
? new Dictionary<Guid, string>()
|
||||||
|
: await authStore.FindOwnerLoginsByTenantIdsAsync(tenantIds, ct);
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
using Deal.Modules.Tenants.Application.Models;
|
||||||
|
|
||||||
|
namespace Deal.Api.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Разрешает идентификаторы ссылок аудита в читаемые имена
|
||||||
|
/// </summary>
|
||||||
|
public interface IAuditReferenceResolver
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Разрешает ссылки на карточки и колонки в их заголовки и имена
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="records">Записи аудита выборки.</param>
|
||||||
|
/// <param name="ct">Токен отмены.</param>
|
||||||
|
/// <returns>Имена по идентификаторам ссылок; неразрешённые идентификаторы отсутствуют.</returns>
|
||||||
|
public Task<IReadOnlyDictionary<string, string>> ResolveAsync(
|
||||||
|
IReadOnlyList<AuditRecordDto> records,
|
||||||
|
CancellationToken ct);
|
||||||
|
}
|
||||||
@@ -12,6 +12,10 @@ public sealed class TelegramBackfillScheduler(
|
|||||||
IHostApplicationLifetime applicationLifetime,
|
IHostApplicationLifetime applicationLifetime,
|
||||||
ILogger<TelegramBackfillScheduler> logger)
|
ILogger<TelegramBackfillScheduler> logger)
|
||||||
{
|
{
|
||||||
|
// Имена фоновых задач (метки запуска).
|
||||||
|
private const string BackfillMonitoredJob = "backfill_monitored";
|
||||||
|
private const string FirstBackfillJob = "first_backfill";
|
||||||
|
|
||||||
// Флаг in-flight «Перечитать» всех каналов (Interlocked): повторный вызов не плодит
|
// Флаг in-flight «Перечитать» всех каналов (Interlocked): повторный вызов не плодит
|
||||||
// параллельные полные перечитывания (Security review, как RatesRefreshScheduler).
|
// параллельные полные перечитывания (Security review, как RatesRefreshScheduler).
|
||||||
private int _readRecentInProgress;
|
private int _readRecentInProgress;
|
||||||
@@ -27,7 +31,7 @@ public sealed class TelegramBackfillScheduler(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
RunBackground("backfill_monitored", RunReadRecentAsync);
|
RunBackground(BackfillMonitoredJob, RunReadRecentAsync);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -50,7 +54,7 @@ public sealed class TelegramBackfillScheduler(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
RunBackground("first_backfill", (provider, runLogger, ct) => RunFirstBackfillsAsync(provider, runLogger, dialogIds, ct));
|
RunBackground(FirstBackfillJob, (provider, runLogger, ct) => RunFirstBackfillsAsync(provider, runLogger, dialogIds, ct));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Выполняет работу в собственном scope; любые ошибки — warning в лог (как RatesRefreshScheduler).
|
// Выполняет работу в собственном scope; любые ошибки — warning в лог (как RatesRefreshScheduler).
|
||||||
|
|||||||
@@ -21,6 +21,11 @@ public sealed class SourceIngressGrpcService(
|
|||||||
IngressTenantResolver tenants,
|
IngressTenantResolver tenants,
|
||||||
ILogger<SourceIngressGrpcService> logger) : SourceIngressService.SourceIngressServiceBase
|
ILogger<SourceIngressGrpcService> logger) : SourceIngressService.SourceIngressServiceBase
|
||||||
{
|
{
|
||||||
|
// Исходы приёма источника для лога аудита.
|
||||||
|
private const string DuplicateOutcome = "duplicate";
|
||||||
|
private const string NoOpOutcome = "no-op";
|
||||||
|
private const string QueuedOutcome = "queued";
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// PushSource — запись источника в очередь пайплайна тенанта.
|
/// PushSource — запись источника в очередь пайплайна тенанта.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -57,7 +62,7 @@ public sealed class SourceIngressGrpcService(
|
|||||||
tenant.Id,
|
tenant.Id,
|
||||||
item.Source.Kind,
|
item.Source.Kind,
|
||||||
item.Source.ExternalId ?? "-",
|
item.Source.ExternalId ?? "-",
|
||||||
result.Duplicate ? "duplicate" : result.Id is null ? "no-op" : "queued");
|
result.Duplicate ? DuplicateOutcome : result.Id is null ? NoOpOutcome : QueuedOutcome);
|
||||||
|
|
||||||
return new PushSourceReply
|
return new PushSourceReply
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -26,6 +26,15 @@ public sealed class TelegramKeysService(IGlobalSettingsStore store, ISecretCiphe
|
|||||||
|
|
||||||
private const string EncryptedPrefix = "enc:";
|
private const string EncryptedPrefix = "enc:";
|
||||||
|
|
||||||
|
// Ключи JSON значения telegramKeys (camelCase, как пишет SaveAsync).
|
||||||
|
private const string ApiIdProperty = "apiId";
|
||||||
|
private const string ApiHashProperty = "apiHash";
|
||||||
|
|
||||||
|
// Геометрия маски секрета: короткий (≤8) → «x…»; иначе «1234…5678».
|
||||||
|
private const int MaskShortMaxLength = 8;
|
||||||
|
private const int MaskShortVisibleChars = 1;
|
||||||
|
private const int MaskEdgeVisibleChars = 4;
|
||||||
|
|
||||||
// Опции JSON значения telegramKeys: camelCase (как пишет SaveAsync) + терпимость регистра.
|
// Опции JSON значения telegramKeys: camelCase (как пишет SaveAsync) + терпимость регистра.
|
||||||
private static readonly JsonSerializerOptions KeysJsonOptions = new()
|
private static readonly JsonSerializerOptions KeysJsonOptions = new()
|
||||||
{
|
{
|
||||||
@@ -49,8 +58,8 @@ public sealed class TelegramKeysService(IGlobalSettingsStore store, ISecretCiphe
|
|||||||
{
|
{
|
||||||
using JsonDocument document = JsonDocument.Parse(row.ValueJson);
|
using JsonDocument document = JsonDocument.Parse(row.ValueJson);
|
||||||
JsonElement root = document.RootElement;
|
JsonElement root = document.RootElement;
|
||||||
string apiId = ReadString(root, "apiId");
|
string apiId = ReadString(root, ApiIdProperty);
|
||||||
string apiHash = ReadString(root, "apiHash");
|
string apiHash = ReadString(root, ApiHashProperty);
|
||||||
return new TgKeysSnapshot(apiId, cipher.Decrypt(apiHash));
|
return new TgKeysSnapshot(apiId, cipher.Decrypt(apiHash));
|
||||||
}
|
}
|
||||||
catch (JsonException)
|
catch (JsonException)
|
||||||
@@ -143,8 +152,11 @@ public sealed class TelegramKeysService(IGlobalSettingsStore store, ISecretCiphe
|
|||||||
return value.Length switch
|
return value.Length switch
|
||||||
{
|
{
|
||||||
0 => string.Empty,
|
0 => string.Empty,
|
||||||
<= 8 => string.Concat(value.AsSpan(0, 1), MaskEllipsis),
|
<= MaskShortMaxLength => string.Concat(value.AsSpan(0, MaskShortVisibleChars), MaskEllipsis),
|
||||||
_ => string.Concat(value.AsSpan(0, 4), MaskEllipsis, value.AsSpan(value.Length - 4)),
|
_ => string.Concat(
|
||||||
|
value.AsSpan(0, MaskEdgeVisibleChars),
|
||||||
|
MaskEllipsis,
|
||||||
|
value.AsSpan(value.Length - MaskEdgeVisibleChars)),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
namespace Deal.Contracts.Integrations.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Типы контактов заказчика
|
||||||
|
/// </summary>
|
||||||
|
public static class CardContactTypes
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Telegram (@username)
|
||||||
|
/// </summary>
|
||||||
|
public const string Telegram = "tg";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Телефон
|
||||||
|
/// </summary>
|
||||||
|
public const string Phone = "phone";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Email
|
||||||
|
/// </summary>
|
||||||
|
public const string Email = "email";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// WhatsApp
|
||||||
|
/// </summary>
|
||||||
|
public const string WhatsApp = "whatsapp";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// LinkedIn
|
||||||
|
/// </summary>
|
||||||
|
public const string LinkedIn = "linkedin";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Сайт
|
||||||
|
/// </summary>
|
||||||
|
public const string Site = "site";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Прочее (не опознано)
|
||||||
|
/// </summary>
|
||||||
|
public const string Other = "other";
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
namespace Deal.Contracts.Integrations.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Фазы входа в аккаунт Telegram
|
||||||
|
/// </summary>
|
||||||
|
public static class TelegramAuthPhases
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Аккаунт не подключён
|
||||||
|
/// </summary>
|
||||||
|
public const string Idle = "idle";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ожидание номера телефона
|
||||||
|
/// </summary>
|
||||||
|
public const string Phone = "phone";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ожидание кода
|
||||||
|
/// </summary>
|
||||||
|
public const string Code = "code";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ожидание облачного пароля
|
||||||
|
/// </summary>
|
||||||
|
public const string Password = "password";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ожидание сканирования QR
|
||||||
|
/// </summary>
|
||||||
|
public const string Qr = "qr";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Аккаунт авторизован
|
||||||
|
/// </summary>
|
||||||
|
public const string Ready = "ready";
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
namespace Deal.Contracts.Integrations.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Виды диалогов Telegram
|
||||||
|
/// </summary>
|
||||||
|
public static class TelegramDialogKinds
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Канал
|
||||||
|
/// </summary>
|
||||||
|
public const string Channel = "channel";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Группа
|
||||||
|
/// </summary>
|
||||||
|
public const string Group = "group";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Форум (темы внутри группы)
|
||||||
|
/// </summary>
|
||||||
|
public const string Forum = "forum";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Личный чат
|
||||||
|
/// </summary>
|
||||||
|
public const string Chat = "chat";
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
namespace Deal.Contracts.Integrations.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Имена операций Telegram-гейта для логов и диагностики
|
||||||
|
/// </summary>
|
||||||
|
public static class TelegramOperations
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Статус аккаунта
|
||||||
|
/// </summary>
|
||||||
|
public const string Status = "status";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Вход по номеру телефона
|
||||||
|
/// </summary>
|
||||||
|
public const string StartPhone = "start_phone";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Вход по QR
|
||||||
|
/// </summary>
|
||||||
|
public const string StartQr = "start_qr";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Подтверждение кода
|
||||||
|
/// </summary>
|
||||||
|
public const string SendCode = "send_code";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Подтверждение облачного пароля
|
||||||
|
/// </summary>
|
||||||
|
public const string SendPassword = "send_password";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Выход из аккаунта
|
||||||
|
/// </summary>
|
||||||
|
public const string Logout = "logout";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Обновление каталога диалогов
|
||||||
|
/// </summary>
|
||||||
|
public const string RefreshDialogs = "refresh_dialogs";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Включение/выключение мониторинга диалога
|
||||||
|
/// </summary>
|
||||||
|
public const string SetMonitor = "set_monitor";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Включение/выключение мониторинга всех диалогов
|
||||||
|
/// </summary>
|
||||||
|
public const string SetMonitorAll = "set_monitor_all";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Перечитать последние сообщения
|
||||||
|
/// </summary>
|
||||||
|
public const string Backfill = "backfill";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Чтение последних сообщений
|
||||||
|
/// </summary>
|
||||||
|
public const string ReadRecent = "read_recent";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Чтение исходного сообщения
|
||||||
|
/// </summary>
|
||||||
|
public const string ReadSource = "read_source";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Поиск источников
|
||||||
|
/// </summary>
|
||||||
|
public const string Search = "search";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Информация об источнике
|
||||||
|
/// </summary>
|
||||||
|
public const string GetInfo = "get_info";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Чтение сообщений для оценки
|
||||||
|
/// </summary>
|
||||||
|
public const string ReadForEval = "read_for_eval";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Вступление в источник
|
||||||
|
/// </summary>
|
||||||
|
public const string Join = "join";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Выход из источника
|
||||||
|
/// </summary>
|
||||||
|
public const string Leave = "leave";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Информация об источнике (Discovery)
|
||||||
|
/// </summary>
|
||||||
|
public const string DiscoveryInfo = "discovery_info";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Чтение сообщений источника (Discovery)
|
||||||
|
/// </summary>
|
||||||
|
public const string DiscoveryRead = "discovery_read";
|
||||||
|
}
|
||||||
@@ -6,6 +6,9 @@ namespace Deal.Infrastructure.Integrations.Extensions;
|
|||||||
// Расширения Uri для SSRF-гейта интеграций
|
// Расширения Uri для SSRF-гейта интеграций
|
||||||
internal static class UriExtensions
|
internal static class UriExtensions
|
||||||
{
|
{
|
||||||
|
// Имя loopback-хоста.
|
||||||
|
private const string LocalhostHost = "localhost";
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Проверяет, указывает ли URL на приватный/loopback/link-local адрес
|
/// Проверяет, указывает ли URL на приватный/loopback/link-local адрес
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -14,7 +17,7 @@ internal static class UriExtensions
|
|||||||
public static bool IsPrivateEndpoint(this Uri uri)
|
public static bool IsPrivateEndpoint(this Uri uri)
|
||||||
{
|
{
|
||||||
string host = uri.Host;
|
string host = uri.Host;
|
||||||
if (string.Equals(host, "localhost", StringComparison.OrdinalIgnoreCase))
|
if (string.Equals(host, LocalhostHost, StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
|
|
||||||
|
using Deal.SharedKernel.Utilities;
|
||||||
|
|
||||||
namespace Deal.Infrastructure.Integrations.Options;
|
namespace Deal.Infrastructure.Integrations.Options;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -86,13 +88,16 @@ public sealed class MtlsOptions
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Строковое представление включённого флага числом.
|
||||||
|
private const string OneLiteral = "1";
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Разбирает значение флага DEAL_MTLS_ENABLED
|
/// Разбирает значение флага DEAL_MTLS_ENABLED
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="rawValue">Сырое значение env (null/пусто — выключено).</param>
|
/// <param name="rawValue">Сырое значение env (null/пусто — выключено).</param>
|
||||||
public static bool IsEnabled(string? rawValue)
|
public static bool IsEnabled(string? rawValue)
|
||||||
=> string.Equals(rawValue, "1", StringComparison.Ordinal)
|
=> string.Equals(rawValue, OneLiteral, StringComparison.Ordinal)
|
||||||
|| string.Equals(rawValue, "true", StringComparison.OrdinalIgnoreCase);
|
|| string.Equals(rawValue, BoolText.True, StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
// Обрезает путь конфигурации (env-значения с пробелами/кавычками не передаются в файловые API).
|
// Обрезает путь конфигурации (env-значения с пробелами/кавычками не передаются в файловые API).
|
||||||
// rawValue: Сырое значение env.
|
// rawValue: Сырое значение env.
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ public sealed class GrpcTelegramClient : ITelegramGateway
|
|||||||
}
|
}
|
||||||
catch (Exception exception)
|
catch (Exception exception)
|
||||||
{
|
{
|
||||||
throw TranslateTransportFailure(exception, tenantId, "status");
|
throw TranslateTransportFailure(exception, tenantId, TelegramOperations.Status);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,7 +95,7 @@ public sealed class GrpcTelegramClient : ITelegramGateway
|
|||||||
}
|
}
|
||||||
catch (Exception exception)
|
catch (Exception exception)
|
||||||
{
|
{
|
||||||
throw TranslateTransportFailure(exception, tenantId, "start_phone");
|
throw TranslateTransportFailure(exception, tenantId, TelegramOperations.StartPhone);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,7 +118,7 @@ public sealed class GrpcTelegramClient : ITelegramGateway
|
|||||||
}
|
}
|
||||||
catch (Exception exception)
|
catch (Exception exception)
|
||||||
{
|
{
|
||||||
throw TranslateTransportFailure(exception, tenantId, "start_qr");
|
throw TranslateTransportFailure(exception, tenantId, TelegramOperations.StartQr);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,7 +135,7 @@ public sealed class GrpcTelegramClient : ITelegramGateway
|
|||||||
}
|
}
|
||||||
catch (Exception exception)
|
catch (Exception exception)
|
||||||
{
|
{
|
||||||
throw TranslateTransportFailure(exception, tenantId, "send_code");
|
throw TranslateTransportFailure(exception, tenantId, TelegramOperations.SendCode);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,7 +152,7 @@ public sealed class GrpcTelegramClient : ITelegramGateway
|
|||||||
}
|
}
|
||||||
catch (Exception exception)
|
catch (Exception exception)
|
||||||
{
|
{
|
||||||
throw TranslateTransportFailure(exception, tenantId, "send_password");
|
throw TranslateTransportFailure(exception, tenantId, TelegramOperations.SendPassword);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,7 +167,7 @@ public sealed class GrpcTelegramClient : ITelegramGateway
|
|||||||
}
|
}
|
||||||
catch (Exception exception)
|
catch (Exception exception)
|
||||||
{
|
{
|
||||||
throw TranslateTransportFailure(exception, tenantId, "logout");
|
throw TranslateTransportFailure(exception, tenantId, TelegramOperations.Logout);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -183,7 +183,7 @@ public sealed class GrpcTelegramClient : ITelegramGateway
|
|||||||
}
|
}
|
||||||
catch (Exception exception)
|
catch (Exception exception)
|
||||||
{
|
{
|
||||||
throw TranslateTransportFailure(exception, tenantId, "refresh_dialogs");
|
throw TranslateTransportFailure(exception, tenantId, TelegramOperations.RefreshDialogs);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,7 +202,7 @@ public sealed class GrpcTelegramClient : ITelegramGateway
|
|||||||
}
|
}
|
||||||
catch (Exception exception)
|
catch (Exception exception)
|
||||||
{
|
{
|
||||||
throw TranslateTransportFailure(exception, tenantId, "set_monitor");
|
throw TranslateTransportFailure(exception, tenantId, TelegramOperations.SetMonitor);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -218,7 +218,7 @@ public sealed class GrpcTelegramClient : ITelegramGateway
|
|||||||
}
|
}
|
||||||
catch (Exception exception)
|
catch (Exception exception)
|
||||||
{
|
{
|
||||||
throw TranslateTransportFailure(exception, tenantId, "set_monitor_all");
|
throw TranslateTransportFailure(exception, tenantId, TelegramOperations.SetMonitorAll);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -238,7 +238,7 @@ public sealed class GrpcTelegramClient : ITelegramGateway
|
|||||||
}
|
}
|
||||||
catch (Exception exception)
|
catch (Exception exception)
|
||||||
{
|
{
|
||||||
throw TranslateTransportFailure(exception, tenantId, "backfill");
|
throw TranslateTransportFailure(exception, tenantId, TelegramOperations.Backfill);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -260,7 +260,7 @@ public sealed class GrpcTelegramClient : ITelegramGateway
|
|||||||
}
|
}
|
||||||
catch (Exception exception)
|
catch (Exception exception)
|
||||||
{
|
{
|
||||||
throw TranslateTransportFailure(exception, tenantId, "read_recent");
|
throw TranslateTransportFailure(exception, tenantId, TelegramOperations.ReadRecent);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -283,7 +283,7 @@ public sealed class GrpcTelegramClient : ITelegramGateway
|
|||||||
}
|
}
|
||||||
catch (Exception exception)
|
catch (Exception exception)
|
||||||
{
|
{
|
||||||
throw TranslateTransportFailure(exception, tenantId, "read_source");
|
throw TranslateTransportFailure(exception, tenantId, TelegramOperations.ReadSource);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -303,7 +303,7 @@ public sealed class GrpcTelegramClient : ITelegramGateway
|
|||||||
}
|
}
|
||||||
catch (Exception exception)
|
catch (Exception exception)
|
||||||
{
|
{
|
||||||
throw TranslateTransportFailure(exception, tenantId, "search");
|
throw TranslateTransportFailure(exception, tenantId, TelegramOperations.Search);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -328,7 +328,7 @@ public sealed class GrpcTelegramClient : ITelegramGateway
|
|||||||
}
|
}
|
||||||
catch (Exception exception)
|
catch (Exception exception)
|
||||||
{
|
{
|
||||||
throw TranslateTransportFailure(exception, tenantId, "get_info");
|
throw TranslateTransportFailure(exception, tenantId, TelegramOperations.GetInfo);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -358,7 +358,7 @@ public sealed class GrpcTelegramClient : ITelegramGateway
|
|||||||
}
|
}
|
||||||
catch (Exception exception)
|
catch (Exception exception)
|
||||||
{
|
{
|
||||||
throw TranslateTransportFailure(exception, tenantId, "read_for_eval");
|
throw TranslateTransportFailure(exception, tenantId, TelegramOperations.ReadForEval);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -374,7 +374,7 @@ public sealed class GrpcTelegramClient : ITelegramGateway
|
|||||||
}
|
}
|
||||||
catch (Exception exception)
|
catch (Exception exception)
|
||||||
{
|
{
|
||||||
throw TranslateTransportFailure(exception, tenantId, "join");
|
throw TranslateTransportFailure(exception, tenantId, TelegramOperations.Join);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -390,7 +390,7 @@ public sealed class GrpcTelegramClient : ITelegramGateway
|
|||||||
}
|
}
|
||||||
catch (Exception exception)
|
catch (Exception exception)
|
||||||
{
|
{
|
||||||
throw TranslateTransportFailure(exception, tenantId, "leave");
|
throw TranslateTransportFailure(exception, tenantId, TelegramOperations.Leave);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -39,8 +39,6 @@ ContainersService containersService) : IColumnSuggester
|
|||||||
|
|
||||||
private const string KeywordsEmptyReason = "ИИ не смог выделить ключи — попробуйте ещё раз";
|
private const string KeywordsEmptyReason = "ИИ не смог выделить ключи — попробуйте ещё раз";
|
||||||
|
|
||||||
private const string RulesModeAny = "any";
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<SuggestColumnsResultDto> SuggestColumnsAsync(CancellationToken ct)
|
public async Task<SuggestColumnsResultDto> SuggestColumnsAsync(CancellationToken ct)
|
||||||
{
|
{
|
||||||
@@ -121,7 +119,7 @@ ContainersService containersService) : IColumnSuggester
|
|||||||
foreach (SuggestedColumnPlan plan in plans)
|
foreach (SuggestedColumnPlan plan in plans)
|
||||||
{
|
{
|
||||||
var rules = new ContainerRulesDto(
|
var rules = new ContainerRulesDto(
|
||||||
Mode: RulesModeAny,
|
Mode: ColumnRuleModes.Any,
|
||||||
Direction: Array.Empty<string>(),
|
Direction: Array.Empty<string>(),
|
||||||
Keywords: [plan.Word],
|
Keywords: [plan.Word],
|
||||||
Stack: Array.Empty<string>(),
|
Stack: Array.Empty<string>(),
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ namespace Deal.Infrastructure.Integrations.Services;
|
|||||||
|
|
||||||
public sealed class LocalTelegramGateway : ITelegramGateway
|
public sealed class LocalTelegramGateway : ITelegramGateway
|
||||||
{
|
{
|
||||||
|
// Причина пустого чтения: сообщений в источнике нет.
|
||||||
|
private const string NoHistoryReason = "no_history";
|
||||||
// Фаза idle-формы (аккаунт не подключён — сервиса нет).
|
// Фаза idle-формы (аккаунт не подключён — сервиса нет).
|
||||||
private const string IdlePhase = "idle";
|
private const string IdlePhase = "idle";
|
||||||
|
|
||||||
@@ -72,7 +74,7 @@ public sealed class LocalTelegramGateway : ITelegramGateway
|
|||||||
string dialogId,
|
string dialogId,
|
||||||
int limit,
|
int limit,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
=> Task.FromResult(new TelegramEvalReadDto(false, "no_history", []));
|
=> Task.FromResult(new TelegramEvalReadDto(false, NoHistoryReason, []));
|
||||||
|
|
||||||
Task ITelegramGateway.JoinAsync(string username, CancellationToken ct) => Task.CompletedTask;
|
Task ITelegramGateway.JoinAsync(string username, CancellationToken ct) => Task.CompletedTask;
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
|
using Deal.SharedKernel.Utilities;
|
||||||
|
|
||||||
namespace Deal.Infrastructure.Integrations.Storage.Extensions;
|
namespace Deal.Infrastructure.Integrations.Storage.Extensions;
|
||||||
|
|
||||||
// Расширения string для разбора конфигурационных значений.
|
// Расширения string для разбора конфигурационных значений.
|
||||||
internal static class StringExtensions
|
internal static class StringExtensions
|
||||||
{
|
{
|
||||||
|
// Строковое представление включённого флага числом.
|
||||||
|
private const string OneLiteral = "1";
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Разбирает строковое значение как булев флаг конфигурации
|
/// Разбирает строковое значение как булев флаг конфигурации
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -10,6 +15,6 @@ internal static class StringExtensions
|
|||||||
/// <returns>True — значение распознано как включённое.</returns>
|
/// <returns>True — значение распознано как включённое.</returns>
|
||||||
public static bool IsTrue(this string raw)
|
public static bool IsTrue(this string raw)
|
||||||
{
|
{
|
||||||
return string.Equals(raw, "true", StringComparison.OrdinalIgnoreCase) || raw == "1";
|
return string.Equals(raw, BoolText.True, StringComparison.OrdinalIgnoreCase) || raw == OneLiteral;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using Deal.Modules.Cards.Application.Models;
|
||||||
using NpgsqlTypes;
|
using NpgsqlTypes;
|
||||||
|
|
||||||
namespace Deal.Infrastructure.Persistence.Entities;
|
namespace Deal.Infrastructure.Persistence.Entities;
|
||||||
@@ -130,7 +131,7 @@ public sealed class CardEntity
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Предыдущая колонка
|
/// Предыдущая колонка
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string PrevCol { get; set; } = "inbox";
|
public string PrevCol { get; set; } = CardIds.Inbox;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Время помещения в архив
|
/// Время помещения в архив
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using Deal.Modules.Kanban.Application.Models;
|
||||||
using NpgsqlTypes;
|
using NpgsqlTypes;
|
||||||
|
|
||||||
namespace Deal.Infrastructure.Persistence.Entities;
|
namespace Deal.Infrastructure.Persistence.Entities;
|
||||||
@@ -35,12 +36,12 @@ public sealed class ContainerEntity
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Вид контейнера: board
|
/// Вид контейнера: board
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string Kind { get; set; } = "board";
|
public string Kind { get; set; } = ContainerKinds.Board;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Пространство: dashboard | selected
|
/// Пространство: dashboard | selected
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string Space { get; set; } = "dashboard";
|
public string Space { get; set; } = ContainerSpaces.Dashboard;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Свёрнутость колонки на дашборде
|
/// Свёрнутость колонки на дашборде
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using Deal.Modules.Discovery.Application.Models;
|
||||||
|
|
||||||
namespace Deal.Infrastructure.Persistence.Entities;
|
namespace Deal.Infrastructure.Persistence.Entities;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -28,7 +30,7 @@ public sealed class DiscCandidateEntity
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Тип источника: channel|group|forum.
|
/// Тип источника: channel|group|forum.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string Kind { get; set; } = "channel";
|
public string Kind { get; set; } = DiscoveryCandidateKinds.Channel;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Цвет источника из палитры DIALOG_HUES
|
/// Цвет источника из палитры DIALOG_HUES
|
||||||
@@ -63,7 +65,7 @@ public sealed class DiscCandidateEntity
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Статус кандидата
|
/// Статус кандидата
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string Status { get; set; } = "new";
|
public string Status { get; set; } = DiscoveryCandidateStatuses.New;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Вступили автоматически
|
/// Вступили автоматически
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using Deal.Modules.Discovery.Application.Models;
|
||||||
|
|
||||||
namespace Deal.Infrastructure.Persistence.Entities;
|
namespace Deal.Infrastructure.Persistence.Entities;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -55,7 +57,7 @@ public sealed class DiscTaskEntity
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Статус задачи: draft|running|paused|done|failed.
|
/// Статус задачи: draft|running|paused|done|failed.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string Status { get; set; } = "draft";
|
public string Status { get; set; } = DiscoveryTaskStatuses.Draft;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Индекс текущего ключа поиска
|
/// Индекс текущего ключа поиска
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using Deal.Modules.Tenants.Application.Models;
|
||||||
|
|
||||||
namespace Deal.Infrastructure.Persistence.Entities;
|
namespace Deal.Infrastructure.Persistence.Entities;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -20,7 +22,7 @@ public sealed class InviteEntity
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public Guid? TenantId { get; set; }
|
public Guid? TenantId { get; set; }
|
||||||
|
|
||||||
public string Status { get; set; } = "pending";
|
public string Status { get; set; } = InviteStatuses.Pending;
|
||||||
|
|
||||||
public DateTimeOffset ExpiresAt { get; set; }
|
public DateTimeOffset ExpiresAt { get; set; }
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using Deal.Modules.Tenants.Application.Models;
|
||||||
|
|
||||||
namespace Deal.Infrastructure.Persistence.Entities;
|
namespace Deal.Infrastructure.Persistence.Entities;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -14,7 +16,7 @@ public sealed class OperatorEntity
|
|||||||
|
|
||||||
public string PasswordHash { get; set; } = string.Empty;
|
public string PasswordHash { get; set; } = string.Empty;
|
||||||
|
|
||||||
public string Status { get; set; } = "active";
|
public string Status { get; set; } = TenantStatuses.Active;
|
||||||
|
|
||||||
public DateTimeOffset CreatedAt { get; set; }
|
public DateTimeOffset CreatedAt { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using Deal.Modules.Pipeline.Application.Models;
|
||||||
|
|
||||||
namespace Deal.Infrastructure.Persistence.Entities;
|
namespace Deal.Infrastructure.Persistence.Entities;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -38,7 +40,7 @@ public sealed class QueueItemEntity
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Статус строки: <c>new</c> — ждёт разбора воркером, <c>filtered</c> — прошла фильтры и ждёт ИИ/ML.
|
/// Статус строки: <c>new</c> — ждёт разбора воркером, <c>filtered</c> — прошла фильтры и ждёт ИИ/ML.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string Status { get; set; } = "new";
|
public string Status { get; set; } = PipelineQueueStatuses.New;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Признак возврата из отсева
|
/// Признак возврата из отсева
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
using NpgsqlTypes;
|
using NpgsqlTypes;
|
||||||
|
|
||||||
|
using Deal.Modules.Pipeline.Application.Models;
|
||||||
|
|
||||||
namespace Deal.Infrastructure.Persistence.Entities;
|
namespace Deal.Infrastructure.Persistence.Entities;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -50,7 +52,7 @@ public sealed class RejectedItemEntity
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Кто вынес решение
|
/// Кто вынес решение
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string Source { get; set; } = "stop";
|
public string Source { get; set; } = PipelineRejectSources.Rules;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Время получения исходного сообщения
|
/// Время получения исходного сообщения
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using Deal.Modules.Tenants.Application.Models;
|
||||||
|
|
||||||
namespace Deal.Infrastructure.Persistence.Entities;
|
namespace Deal.Infrastructure.Persistence.Entities;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -9,7 +11,7 @@ public sealed class TenantEntity
|
|||||||
|
|
||||||
public string Name { get; set; } = string.Empty;
|
public string Name { get; set; } = string.Empty;
|
||||||
|
|
||||||
public string Status { get; set; } = "active";
|
public string Status { get; set; } = TenantStatuses.Active;
|
||||||
|
|
||||||
public DateTimeOffset CreatedAt { get; set; }
|
public DateTimeOffset CreatedAt { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using Deal.Modules.Tenants.Application.Models;
|
||||||
|
|
||||||
namespace Deal.Infrastructure.Persistence.Entities;
|
namespace Deal.Infrastructure.Persistence.Entities;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -18,7 +20,7 @@ public sealed class TenantLimitEntity
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Тип периода: month|day.
|
/// Тип периода: month|day.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string Period { get; set; } = "month";
|
public string Period { get; set; } = TenantLimitPeriods.Month;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Начало текущего периода
|
/// Начало текущего периода
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using Deal.Modules.Tenants.Application.Models;
|
||||||
|
|
||||||
namespace Deal.Infrastructure.Persistence.Entities;
|
namespace Deal.Infrastructure.Persistence.Entities;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -16,7 +18,7 @@ public sealed class UserEntity
|
|||||||
|
|
||||||
public string PasswordHash { get; set; } = string.Empty;
|
public string PasswordHash { get; set; } = string.Empty;
|
||||||
|
|
||||||
public string Status { get; set; } = "active";
|
public string Status { get; set; } = TenantStatuses.Active;
|
||||||
|
|
||||||
public DateTimeOffset CreatedAt { get; set; }
|
public DateTimeOffset CreatedAt { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,6 +67,32 @@ public sealed class AuthStore(DealDbContext dbContext) : IAuthStore
|
|||||||
return entities;
|
return entities;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyDictionary<Guid, string>> FindOwnerLoginsByTenantIdsAsync(
|
||||||
|
IReadOnlyCollection<Guid> tenantIds,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (tenantIds.Count == 0)
|
||||||
|
{
|
||||||
|
return new Dictionary<Guid, string>();
|
||||||
|
}
|
||||||
|
|
||||||
|
var users = await dbContext.Users
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(u => tenantIds.Contains(u.TenantId))
|
||||||
|
.OrderBy(u => u.CreatedAt)
|
||||||
|
.ThenBy(u => u.Login)
|
||||||
|
.Select(u => new { u.TenantId, u.Login })
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
var owners = new Dictionary<Guid, string>();
|
||||||
|
foreach (var user in users)
|
||||||
|
{
|
||||||
|
owners.TryAdd(user.TenantId, user.Login);
|
||||||
|
}
|
||||||
|
|
||||||
|
return owners;
|
||||||
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task CreateSessionAsync(SessionDto session, CancellationToken ct)
|
public async Task CreateSessionAsync(SessionDto session, CancellationToken ct)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ public sealed partial class DiscoveryStore
|
|||||||
Username = row.Username,
|
Username = row.Username,
|
||||||
Kind = row.Kind,
|
Kind = row.Kind,
|
||||||
Hue = row.Hue,
|
Hue = row.Hue,
|
||||||
Status = "new",
|
Status = DiscoveryCandidateStatuses.New,
|
||||||
CreatedAt = now,
|
CreatedAt = now,
|
||||||
UpdatedAt = now,
|
UpdatedAt = now,
|
||||||
});
|
});
|
||||||
@@ -123,7 +123,7 @@ public sealed partial class DiscoveryStore
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
row.Status = "joined";
|
row.Status = DiscoveryCandidateStatuses.Joined;
|
||||||
row.AutoJoined = autoJoined;
|
row.AutoJoined = autoJoined;
|
||||||
row.UpdatedAt = DateTimeOffset.UtcNow;
|
row.UpdatedAt = DateTimeOffset.UtcNow;
|
||||||
await _dbContext.SaveChangesAsync(ct);
|
await _dbContext.SaveChangesAsync(ct);
|
||||||
@@ -135,7 +135,7 @@ public sealed partial class DiscoveryStore
|
|||||||
{
|
{
|
||||||
DiscCandidateEntity? row = await _dbContext.DiscCandidates
|
DiscCandidateEntity? row = await _dbContext.DiscCandidates
|
||||||
.FirstOrDefaultAsync(candidate => candidate.DialogId == dialogId, ct);
|
.FirstOrDefaultAsync(candidate => candidate.DialogId == dialogId, ct);
|
||||||
if (row is null || row.Status != "review")
|
if (row is null || row.Status != DiscoveryCandidateStatuses.Review)
|
||||||
{
|
{
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -156,7 +156,7 @@ public sealed partial class DiscoveryStore
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
row.Status = "rejected";
|
row.Status = DiscoveryCandidateStatuses.Rejected;
|
||||||
row.UpdatedAt = DateTimeOffset.UtcNow;
|
row.UpdatedAt = DateTimeOffset.UtcNow;
|
||||||
await _dbContext.SaveChangesAsync(ct);
|
await _dbContext.SaveChangesAsync(ct);
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ public sealed partial class DiscoveryStore
|
|||||||
SampleSize = row.SampleSize,
|
SampleSize = row.SampleSize,
|
||||||
PlanJoins = row.PlanJoins,
|
PlanJoins = row.PlanJoins,
|
||||||
AutoJoin = row.AutoJoin,
|
AutoJoin = row.AutoJoin,
|
||||||
Status = "draft",
|
Status = DiscoveryTaskStatuses.Draft,
|
||||||
CreatedAt = now,
|
CreatedAt = now,
|
||||||
UpdatedAt = now,
|
UpdatedAt = now,
|
||||||
});
|
});
|
||||||
@@ -101,7 +101,7 @@ public sealed partial class DiscoveryStore
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
row.Status = "running";
|
row.Status = DiscoveryTaskStatuses.Running;
|
||||||
if (resetProgress)
|
if (resetProgress)
|
||||||
{
|
{
|
||||||
row.SearchIdx = 0;
|
row.SearchIdx = 0;
|
||||||
@@ -127,7 +127,7 @@ public sealed partial class DiscoveryStore
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
row.Status = "paused";
|
row.Status = DiscoveryTaskStatuses.Paused;
|
||||||
row.UpdatedAt = DateTimeOffset.UtcNow;
|
row.UpdatedAt = DateTimeOffset.UtcNow;
|
||||||
await _dbContext.SaveChangesAsync(ct);
|
await _dbContext.SaveChangesAsync(ct);
|
||||||
return true;
|
return true;
|
||||||
@@ -143,7 +143,7 @@ public sealed partial class DiscoveryStore
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
row.Status = "done";
|
row.Status = DiscoveryTaskStatuses.Done;
|
||||||
row.UpdatedAt = DateTimeOffset.UtcNow;
|
row.UpdatedAt = DateTimeOffset.UtcNow;
|
||||||
await _dbContext.SaveChangesAsync(ct);
|
await _dbContext.SaveChangesAsync(ct);
|
||||||
return true;
|
return true;
|
||||||
@@ -211,7 +211,7 @@ public sealed partial class DiscoveryStore
|
|||||||
async Task<int> IDiscoveryStore.SumActivePlanAsync(string? excludeTaskId, CancellationToken ct)
|
async Task<int> IDiscoveryStore.SumActivePlanAsync(string? excludeTaskId, CancellationToken ct)
|
||||||
{
|
{
|
||||||
IQueryable<DiscTaskEntity> query = _dbContext.DiscTasks
|
IQueryable<DiscTaskEntity> query = _dbContext.DiscTasks
|
||||||
.Where(task => task.Status != "done" && task.Status != "failed");
|
.Where(task => task.Status != DiscoveryTaskStatuses.Done && task.Status != DiscoveryTaskStatuses.Failed);
|
||||||
if (excludeTaskId is not null)
|
if (excludeTaskId is not null)
|
||||||
{
|
{
|
||||||
query = query.Where(task => task.Id != excludeTaskId);
|
query = query.Where(task => task.Id != excludeTaskId);
|
||||||
|
|||||||
@@ -61,6 +61,23 @@ public sealed class TenantRepository(DealDbContext dbContext) : ITenantRepositor
|
|||||||
return entities;
|
return entities;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyDictionary<Guid, string>> FindNamesByIdsAsync(
|
||||||
|
IReadOnlyCollection<Guid> ids,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (ids.Count == 0)
|
||||||
|
{
|
||||||
|
return new Dictionary<Guid, string>();
|
||||||
|
}
|
||||||
|
|
||||||
|
var rows = await dbContext.Tenants
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(t => ids.Contains(t.Id))
|
||||||
|
.Select(t => new { t.Id, t.Name })
|
||||||
|
.ToListAsync(ct);
|
||||||
|
return rows.ToDictionary(row => row.Id, row => row.Name);
|
||||||
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<bool> UpdateStatusAsync(
|
public async Task<bool> UpdateStatusAsync(
|
||||||
Guid id,
|
Guid id,
|
||||||
|
|||||||
@@ -22,6 +22,6 @@ public sealed class CardMover(CardsService cardsService) : ICardMover
|
|||||||
CardResultDto result = CardsDefaultContainers.Contains(toContainerId)
|
CardResultDto result = CardsDefaultContainers.Contains(toContainerId)
|
||||||
? await cardsService.MoveStageCardAsync(cardId, toContainerId, ct)
|
? await cardsService.MoveStageCardAsync(cardId, toContainerId, ct)
|
||||||
: await cardsService.MoveDashboardCardAsync(cardId, toContainerId, ct);
|
: await cardsService.MoveDashboardCardAsync(cardId, toContainerId, ct);
|
||||||
return new CardMoveResultDto(result.Error);
|
return new CardMoveResultDto(result.Error, From: result.Card?.PrevCol, To: result.Card?.Col);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,4 +6,6 @@ namespace Deal.Modules.Cards.Application.Dtos;
|
|||||||
/// Результат перехода карточки единым механизмом <see cref="ICardMover"/>.
|
/// Результат перехода карточки единым механизмом <see cref="ICardMover"/>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="Error">Текст 400-ошибки либо null (успех).</param>
|
/// <param name="Error">Текст 400-ошибки либо null (успех).</param>
|
||||||
public sealed record CardMoveResultDto(string? Error);
|
/// <param name="From">Контейнер-источник после перехода; null — переход не выполнен.</param>
|
||||||
|
/// <param name="To">Контейнер-назначение после перехода; null — переход не выполнен.</param>
|
||||||
|
public sealed record CardMoveResultDto(string? Error, string? From = null, string? To = null);
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using Deal.Contracts.Integrations.Models;
|
||||||
|
|
||||||
namespace Deal.Modules.Cards.Application.Models;
|
namespace Deal.Modules.Cards.Application.Models;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -8,7 +10,7 @@ public sealed record CardContact
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Тип контакта: tg/phone/email/linkedin/whatsapp/site/other.
|
/// Тип контакта: tg/phone/email/linkedin/whatsapp/site/other.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string Type { get; init; } = "other";
|
public string Type { get; init; } = CardContactTypes.Other;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Значение: @username, +7…, name@mail, url.
|
/// Значение: @username, +7…, name@mail, url.
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ public sealed record CardFile
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Тип контента: image/video/audio/archive/document/other
|
/// Тип контента: image/video/audio/archive/document/other
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string Kind { get; init; } = "other";
|
public string Kind { get; init; } = CardFileKinds.Other;
|
||||||
|
|
||||||
public string ObjectKey { get; init; } = string.Empty;
|
public string ObjectKey { get; init; } = string.Empty;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
namespace Deal.Modules.Cards.Application.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Категории вложений карточки
|
||||||
|
/// </summary>
|
||||||
|
public static class CardFileKinds
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Изображение
|
||||||
|
/// </summary>
|
||||||
|
public const string Image = "image";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Видео
|
||||||
|
/// </summary>
|
||||||
|
public const string Video = "video";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Аудио
|
||||||
|
/// </summary>
|
||||||
|
public const string Audio = "audio";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Документ
|
||||||
|
/// </summary>
|
||||||
|
public const string Document = "document";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Архив
|
||||||
|
/// </summary>
|
||||||
|
public const string Archive = "archive";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Прочее (не опознано)
|
||||||
|
/// </summary>
|
||||||
|
public const string Other = "other";
|
||||||
|
}
|
||||||
@@ -19,4 +19,24 @@ public static class DiscoveryCandidateKinds
|
|||||||
/// Форум (группа с темами; topics заполняются оценкой).
|
/// Форум (группа с темами; topics заполняются оценкой).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public const string Forum = "forum";
|
public const string Forum = "forum";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Русское написание «канал» (как приходит из Telegram)
|
||||||
|
/// </summary>
|
||||||
|
public const string ChannelAliasRu = "канал";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Русское написание «форум»
|
||||||
|
/// </summary>
|
||||||
|
public const string ForumAliasRu = "форум";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Английское написание «chat»
|
||||||
|
/// </summary>
|
||||||
|
public const string ChatAlias = "chat";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Русское написание «чат»
|
||||||
|
/// </summary>
|
||||||
|
public const string ChatAliasRu = "чат";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ public sealed record DiscoveryCandidateRow
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Тип источника: channel|group|forum.
|
/// Тип источника: channel|group|forum.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string Kind { get; init; } = "channel";
|
public string Kind { get; init; } = DiscoveryCandidateKinds.Channel;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Цвет источника
|
/// Цвет источника
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
namespace Deal.Modules.Discovery.Application.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Источники решения о соответствии сообщения
|
||||||
|
/// </summary>
|
||||||
|
public static class DiscoveryFitOrigins
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Эвристика по ключевым словам
|
||||||
|
/// </summary>
|
||||||
|
public const string Heuristic = "heuristic";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Локальная ML-модель
|
||||||
|
/// </summary>
|
||||||
|
public const string Ml = "ml";
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
namespace Deal.Modules.Discovery.Application.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Языки источников Discovery
|
||||||
|
/// </summary>
|
||||||
|
public static class DiscoveryLanguages
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Русскоязычные источники
|
||||||
|
/// </summary>
|
||||||
|
public const string Russian = "ru";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Любой язык
|
||||||
|
/// </summary>
|
||||||
|
public const string Any = "any";
|
||||||
|
}
|
||||||
@@ -116,7 +116,7 @@ public sealed class DiscoveryEvaluator
|
|||||||
string raw = text ?? string.Empty;
|
string raw = text ?? string.Empty;
|
||||||
if (raw.Trim().Length < MinTextLength)
|
if (raw.Trim().Length < MinTextLength)
|
||||||
{
|
{
|
||||||
return new DiscoveryMessageFit(false, "слишком короткое", "heuristic");
|
return new DiscoveryMessageFit(false, "слишком короткое", DiscoveryFitOrigins.Heuristic);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (settingsSnapshot.GetBool(SettingsKeys.MlEnabled, SettingsDefaults.MlEnabled))
|
if (settingsSnapshot.GetBool(SettingsKeys.MlEnabled, SettingsDefaults.MlEnabled))
|
||||||
@@ -167,11 +167,11 @@ public sealed class DiscoveryEvaluator
|
|||||||
string kw = (keyword ?? string.Empty).Trim();
|
string kw = (keyword ?? string.Empty).Trim();
|
||||||
if (kw.Length > 0 && haystack.Contains(kw.ToLowerInvariant(), StringComparison.Ordinal))
|
if (kw.Length > 0 && haystack.Contains(kw.ToLowerInvariant(), StringComparison.Ordinal))
|
||||||
{
|
{
|
||||||
return new DiscoveryMessageFit(true, $"совпал ключ \"{kw}\"", "heuristic");
|
return new DiscoveryMessageFit(true, $"совпал ключ \"{kw}\"", DiscoveryFitOrigins.Heuristic);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return new DiscoveryMessageFit(false, "нет совпадений с ключами", "heuristic");
|
return new DiscoveryMessageFit(false, "нет совпадений с ключами", DiscoveryFitOrigins.Heuristic);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ public sealed class DiscoveryTasksService(IDiscoveryStore store, DiscoveryPlanGu
|
|||||||
await planGuard.AssertPlanAsync(planJoins, ct).ConfigureAwait(false);
|
await planGuard.AssertPlanAsync(planJoins, ct).ConfigureAwait(false);
|
||||||
await planGuard.AssertBudgetAsync(planJoins, excludeTaskId: null, ct).ConfigureAwait(false);
|
await planGuard.AssertBudgetAsync(planJoins, excludeTaskId: null, ct).ConfigureAwait(false);
|
||||||
|
|
||||||
string lang = draft.Lang is "ru" or "any" ? draft.Lang : "ru";
|
string lang = draft.Lang is DiscoveryLanguages.Russian or DiscoveryLanguages.Any ? draft.Lang : DiscoveryLanguages.Russian;
|
||||||
|
|
||||||
// Дефолты threshold/sampleSize — из типизированного снимка настроек (C30: один GetAllAsync на создание).
|
// Дефолты threshold/sampleSize — из типизированного снимка настроек (C30: один GetAllAsync на создание).
|
||||||
TenantSettingsSnapshot settingsSnapshot = await TenantSettingsSnapshot.LoadAsync(settings, ct);
|
TenantSettingsSnapshot settingsSnapshot = await TenantSettingsSnapshot.LoadAsync(settings, ct);
|
||||||
@@ -230,7 +230,7 @@ public sealed class DiscoveryTasksService(IDiscoveryStore store, DiscoveryPlanGu
|
|||||||
int? threshold = patch.Threshold is int value ? Clamp(value, min: 1, max: 100) : null;
|
int? threshold = patch.Threshold is int value ? Clamp(value, min: 1, max: 100) : null;
|
||||||
int? sampleSize = patch.SampleSize is int sample ? Math.Max(1, sample) : null;
|
int? sampleSize = patch.SampleSize is int sample ? Math.Max(1, sample) : null;
|
||||||
int? minSubscribers = patch.MinSubscribers is int min ? Math.Max(0, min) : null;
|
int? minSubscribers = patch.MinSubscribers is int min ? Math.Max(0, min) : null;
|
||||||
string? lang = patch.Lang is "ru" or "any" ? patch.Lang : patch.Lang is null ? null : "ru";
|
string? lang = patch.Lang is DiscoveryLanguages.Russian or DiscoveryLanguages.Any ? patch.Lang : patch.Lang is null ? null : DiscoveryLanguages.Russian;
|
||||||
string? name = patch.Name is null ? null : patch.Name.Trim();
|
string? name = patch.Name is null ? null : patch.Name.Trim();
|
||||||
string? description = patch.Description;
|
string? description = patch.Description;
|
||||||
return new DiscoveryTaskPatch
|
return new DiscoveryTaskPatch
|
||||||
|
|||||||
+10
-6
@@ -7,12 +7,16 @@ namespace Deal.Modules.Discovery.Application.Services;
|
|||||||
// источника (KindCode) и тексты сообщений для оценки (GroupTexts).
|
// источника (KindCode) и тексты сообщений для оценки (GroupTexts).
|
||||||
public sealed partial class DiscoveryWorkerService
|
public sealed partial class DiscoveryWorkerService
|
||||||
{
|
{
|
||||||
|
// Префиксы сообщения об ошибке Telegram при FloodWait.
|
||||||
|
private const string FloodPrefix = "flood:";
|
||||||
|
private const string FloodSpacePrefix = "flood ";
|
||||||
|
|
||||||
internal static bool IsFlood(Exception exception)
|
internal static bool IsFlood(Exception exception)
|
||||||
{
|
{
|
||||||
string message = exception.Message ?? string.Empty;
|
string message = exception.Message ?? string.Empty;
|
||||||
return message.StartsWith("flood:", StringComparison.OrdinalIgnoreCase)
|
return message.StartsWith(FloodPrefix, StringComparison.OrdinalIgnoreCase)
|
||||||
|| message.StartsWith("flood ", StringComparison.OrdinalIgnoreCase)
|
|| message.StartsWith(FloodSpacePrefix, StringComparison.OrdinalIgnoreCase)
|
||||||
|| message.Contains("flood:", StringComparison.OrdinalIgnoreCase);
|
|| message.Contains(FloodPrefix, StringComparison.OrdinalIgnoreCase);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string KindCode(string kind, bool isForum = false)
|
private static string KindCode(string kind, bool isForum = false)
|
||||||
@@ -24,9 +28,9 @@ public sealed partial class DiscoveryWorkerService
|
|||||||
|
|
||||||
return kind.Trim().ToLowerInvariant() switch
|
return kind.Trim().ToLowerInvariant() switch
|
||||||
{
|
{
|
||||||
DiscoveryCandidateKinds.Channel or "канал" => DiscoveryCandidateKinds.Channel,
|
DiscoveryCandidateKinds.Channel or DiscoveryCandidateKinds.ChannelAliasRu => DiscoveryCandidateKinds.Channel,
|
||||||
DiscoveryCandidateKinds.Forum or "форум" => DiscoveryCandidateKinds.Forum,
|
DiscoveryCandidateKinds.Forum or DiscoveryCandidateKinds.ForumAliasRu => DiscoveryCandidateKinds.Forum,
|
||||||
"chat" or "чат" => DiscoveryCandidateKinds.Group,
|
DiscoveryCandidateKinds.ChatAlias or DiscoveryCandidateKinds.ChatAliasRu => DiscoveryCandidateKinds.Group,
|
||||||
_ => DiscoveryCandidateKinds.Group,
|
_ => DiscoveryCandidateKinds.Group,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -56,7 +56,7 @@ public sealed partial class DiscoveryWorkerService
|
|||||||
foreach (TelegramDialogEntryDto item in results)
|
foreach (TelegramDialogEntryDto item in results)
|
||||||
{
|
{
|
||||||
string rawKind = (item.Kind ?? string.Empty).Trim().ToLowerInvariant();
|
string rawKind = (item.Kind ?? string.Empty).Trim().ToLowerInvariant();
|
||||||
if (rawKind == "чат" || rawKind == "chat")
|
if (rawKind == DiscoveryCandidateKinds.ChatAliasRu || rawKind == DiscoveryCandidateKinds.ChatAlias)
|
||||||
{
|
{
|
||||||
await _log.AddAsync(taskId, DiscoveryLogEvents.Skip, $"{item.Name}: личный чат/бот", ct)
|
await _log.AddAsync(taskId, DiscoveryLogEvents.Skip, $"{item.Name}: личный чат/бот", ct)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ public static class ColumnMatcher
|
|||||||
bool budgetOk = !budgetEnabled || BudgetInRange.IsInRange(AmountParser.Parse(text), rules.Budget!, rates);
|
bool budgetOk = !budgetEnabled || BudgetInRange.IsInRange(AmountParser.Parse(text), rules.Budget!, rates);
|
||||||
bool pricesOk = !pricesEnabled || BudgetInRange.IsInRange(AmountParser.Parse(text), rules.Prices!, rates);
|
bool pricesOk = !pricesEnabled || BudgetInRange.IsInRange(AmountParser.Parse(text), rules.Prices!, rates);
|
||||||
|
|
||||||
if (string.Equals(rules.Mode, "any", StringComparison.OrdinalIgnoreCase))
|
if (string.Equals(rules.Mode, ColumnRuleModes.Any, StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
// «любое»: совпасть должна хотя бы одна ВКЛЮЧЁННАЯ (непустая) группа.
|
// «любое»: совпасть должна хотя бы одна ВКЛЮЧЁННАЯ (непустая) группа.
|
||||||
return (keywords.Count > 0 && keywordsOk)
|
return (keywords.Count > 0 && keywordsOk)
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ public static class RulesDescriber
|
|||||||
return NoRulesText;
|
return NoRulesText;
|
||||||
}
|
}
|
||||||
|
|
||||||
string mode = string.Equals(rules.Mode, "any", StringComparison.OrdinalIgnoreCase)
|
string mode = string.Equals(rules.Mode, ColumnRuleModes.Any, StringComparison.OrdinalIgnoreCase)
|
||||||
? AnyModePrefix
|
? AnyModePrefix
|
||||||
: AllModePrefix;
|
: AllModePrefix;
|
||||||
return mode + " · " + string.Join("; ", parts);
|
return mode + " · " + string.Join("; ", parts);
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
namespace Deal.Modules.Kanban.Application.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Режимы сопоставления правил колонки
|
||||||
|
/// </summary>
|
||||||
|
public static class ColumnRuleModes
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Достаточно любого условия
|
||||||
|
/// </summary>
|
||||||
|
public const string Any = "any";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Нужны все условия
|
||||||
|
/// </summary>
|
||||||
|
public const string All = "all";
|
||||||
|
}
|
||||||
@@ -15,8 +15,8 @@ public sealed record ContainerCreateDto(
|
|||||||
string Name,
|
string Name,
|
||||||
string Description = "",
|
string Description = "",
|
||||||
string? Color = null,
|
string? Color = null,
|
||||||
string Space = "dashboard",
|
string Space = ContainerSpaces.Dashboard,
|
||||||
string Kind = "board",
|
string Kind = ContainerKinds.Board,
|
||||||
bool Suggested = false,
|
bool Suggested = false,
|
||||||
ContainerRulesDto? Rules = null,
|
ContainerRulesDto? Rules = null,
|
||||||
string Note = "");
|
string Note = "");
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
namespace Deal.Modules.Kanban.Application.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ключи wire-контракта канбана (ответы API и патчи карточки)
|
||||||
|
/// </summary>
|
||||||
|
public static class KanbanWireKeys
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Счётчик новых карточек
|
||||||
|
/// </summary>
|
||||||
|
public const string New = "new";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Счётчик в обучении ML
|
||||||
|
/// </summary>
|
||||||
|
public const string Learning = "learning";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Счётчик решённых ML
|
||||||
|
/// </summary>
|
||||||
|
public const string Ml = "ml";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Счётчик решённых ИИ
|
||||||
|
/// </summary>
|
||||||
|
public const string Ai = "ai";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Признак свёрнутой колонки
|
||||||
|
/// </summary>
|
||||||
|
public const string Collapsed = "collapsed";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ширина колонки
|
||||||
|
/// </summary>
|
||||||
|
public const string Width = "width";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Нижняя граница бюджета
|
||||||
|
/// </summary>
|
||||||
|
public const string From = "from";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Верхняя граница бюджета
|
||||||
|
/// </summary>
|
||||||
|
public const string To = "to";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Валюта бюджета
|
||||||
|
/// </summary>
|
||||||
|
public const string Currency = "cur";
|
||||||
|
}
|
||||||
@@ -361,9 +361,9 @@ public sealed partial class CardsService
|
|||||||
return ClearedBudget;
|
return ClearedBudget;
|
||||||
}
|
}
|
||||||
|
|
||||||
double? from = ReadNumber(element, "from");
|
double? from = ReadNumber(element, KanbanWireKeys.From);
|
||||||
double? to = ReadNumber(element, "to");
|
double? to = ReadNumber(element, KanbanWireKeys.To);
|
||||||
string cur = ReadText(element, "cur");
|
string cur = ReadText(element, KanbanWireKeys.Currency);
|
||||||
return new CardBudgetDto(from, to, cur);
|
return new CardBudgetDto(from, to, cur);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
namespace Deal.Modules.Pipeline.Application.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ключи JSON-схемы разбора карточки от ИИ
|
||||||
|
/// </summary>
|
||||||
|
public static class AiRawCardKeys
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Заголовок
|
||||||
|
/// </summary>
|
||||||
|
public const string Title = "title";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Колонка/доска
|
||||||
|
/// </summary>
|
||||||
|
public const string Board = "board";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Компания
|
||||||
|
/// </summary>
|
||||||
|
public const string Company = "company";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Формат работы
|
||||||
|
/// </summary>
|
||||||
|
public const string Format = "format";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// О задаче
|
||||||
|
/// </summary>
|
||||||
|
public const string Task = "task";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Требования
|
||||||
|
/// </summary>
|
||||||
|
public const string Requirements = "requirements";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Плюсы
|
||||||
|
/// </summary>
|
||||||
|
public const string Plus = "plus";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Условия
|
||||||
|
/// </summary>
|
||||||
|
public const string Conditions = "conditions";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Краткое описание
|
||||||
|
/// </summary>
|
||||||
|
public const string Summary = "summary";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Стек
|
||||||
|
/// </summary>
|
||||||
|
public const string Stack = "stack";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Бюджет
|
||||||
|
/// </summary>
|
||||||
|
public const string Budget = "budget";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Контакты
|
||||||
|
/// </summary>
|
||||||
|
public const string Contacts = "contacts";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Признак вакансии
|
||||||
|
/// </summary>
|
||||||
|
public const string IsVacancy = "is_vacancy";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Признак определённого типа заявки
|
||||||
|
/// </summary>
|
||||||
|
public const string IsVacancyKnown = "is_vacancy_known";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Признак спама
|
||||||
|
/// </summary>
|
||||||
|
public const string IsSpam = "is_spam";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Значение контакта
|
||||||
|
/// </summary>
|
||||||
|
public const string Value = "value";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Валюта бюджета
|
||||||
|
/// </summary>
|
||||||
|
public const string Currency = "currency";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Короткий код валюты бюджета
|
||||||
|
/// </summary>
|
||||||
|
public const string CurrencyShort = "cur";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Нижняя граница бюджета
|
||||||
|
/// </summary>
|
||||||
|
public const string From = "from";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Верхняя граница бюджета
|
||||||
|
/// </summary>
|
||||||
|
public const string To = "to";
|
||||||
|
}
|
||||||
@@ -16,20 +16,20 @@ public static class PipelineRejectConstants
|
|||||||
public static readonly IReadOnlyDictionary<string, string> StageLabels =
|
public static readonly IReadOnlyDictionary<string, string> StageLabels =
|
||||||
new Dictionary<string, string>(StringComparer.Ordinal)
|
new Dictionary<string, string>(StringComparer.Ordinal)
|
||||||
{
|
{
|
||||||
["length"] = "короткое сообщение",
|
[PipelineRejectStages.Length] = "короткое сообщение",
|
||||||
["stop"] = "стоп-фраза",
|
[PipelineRejectStages.Stop] = "стоп-фраза",
|
||||||
["resume"] = "резюме соискателя",
|
[PipelineRejectStages.Resume] = "резюме соискателя",
|
||||||
["type"] = "тип заявки",
|
[PipelineRejectStages.Type] = "тип заявки",
|
||||||
["budget"] = "нет суммы",
|
[PipelineRejectStages.Budget] = "нет суммы",
|
||||||
["exclude_kw"] = "исключение: слова/технологии",
|
[PipelineRejectStages.ExcludeKeywords] = "исключение: слова/технологии",
|
||||||
["exclude_location"] = "исключение: локация/язык",
|
[PipelineRejectStages.ExcludeLocation] = "исключение: локация/язык",
|
||||||
["exclude_type"] = "исключение: тип",
|
[PipelineRejectStages.ExcludeType] = "исключение: тип",
|
||||||
["exclude_budget"] = "исключение: бюджет",
|
[PipelineRejectStages.ExcludeBudget] = "исключение: бюджет",
|
||||||
["stale"] = "устарело",
|
[PipelineRejectStages.Stale] = "устарело",
|
||||||
["spam_ml"] = "спам (ML)",
|
[PipelineRejectStages.SpamMl] = "спам (ML)",
|
||||||
["spam_ai"] = "спам (ИИ)",
|
[PipelineRejectStages.SpamAi] = "спам (ИИ)",
|
||||||
["filter_ai"] = "ИИ-фильтр",
|
[PipelineRejectStages.FilterAi] = "ИИ-фильтр",
|
||||||
["dup"] = "повтор",
|
[PipelineRejectStages.Duplicate] = "повтор",
|
||||||
};
|
};
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -38,11 +38,11 @@ public static class PipelineRejectConstants
|
|||||||
public static readonly IReadOnlyDictionary<string, string> SourceLabels =
|
public static readonly IReadOnlyDictionary<string, string> SourceLabels =
|
||||||
new Dictionary<string, string>(StringComparer.Ordinal)
|
new Dictionary<string, string>(StringComparer.Ordinal)
|
||||||
{
|
{
|
||||||
["stop"] = "правила",
|
[PipelineRejectSources.Rules] = "правила",
|
||||||
["ml"] = "ML",
|
[PipelineRejectSources.Ml] = "ML",
|
||||||
["ai"] = "ИИ",
|
[PipelineRejectSources.Ai] = "ИИ",
|
||||||
["stale"] = "система",
|
[PipelineRejectSources.System] = "система",
|
||||||
["dup"] = "система",
|
[PipelineRejectSources.Duplicate] = "система",
|
||||||
};
|
};
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
namespace Deal.Modules.Pipeline.Application.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Источники решения об отсеве
|
||||||
|
/// </summary>
|
||||||
|
public static class PipelineRejectSources
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Правила входящих (стоп-фразы, тип и т.п.)
|
||||||
|
/// </summary>
|
||||||
|
public const string Rules = "stop";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Локальная ML-модель
|
||||||
|
/// </summary>
|
||||||
|
public const string Ml = "ml";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ИИ-фильтр
|
||||||
|
/// </summary>
|
||||||
|
public const string Ai = "ai";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Система (устаревание, дубликаты)
|
||||||
|
/// </summary>
|
||||||
|
public const string System = "system";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Дубликат
|
||||||
|
/// </summary>
|
||||||
|
public const string Duplicate = "dup";
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
namespace Deal.Modules.Pipeline.Application.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Этапы отсева входящих сообщений
|
||||||
|
/// </summary>
|
||||||
|
public static class PipelineRejectStages
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Слишком короткое сообщение
|
||||||
|
/// </summary>
|
||||||
|
public const string Length = "length";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Стоп-фраза
|
||||||
|
/// </summary>
|
||||||
|
public const string Stop = "stop";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Резюме соискателя
|
||||||
|
/// </summary>
|
||||||
|
public const string Resume = "resume";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Неподходящий тип заявки
|
||||||
|
/// </summary>
|
||||||
|
public const string Type = "type";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Нет суммы
|
||||||
|
/// </summary>
|
||||||
|
public const string Budget = "budget";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Исключение по словам/технологиям
|
||||||
|
/// </summary>
|
||||||
|
public const string ExcludeKeywords = "exclude_kw";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Исключение по локации/языку
|
||||||
|
/// </summary>
|
||||||
|
public const string ExcludeLocation = "exclude_location";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Исключение по типу
|
||||||
|
/// </summary>
|
||||||
|
public const string ExcludeType = "exclude_type";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Исключение по бюджету
|
||||||
|
/// </summary>
|
||||||
|
public const string ExcludeBudget = "exclude_budget";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Устаревшее сообщение
|
||||||
|
/// </summary>
|
||||||
|
public const string Stale = "stale";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Спам по ML
|
||||||
|
/// </summary>
|
||||||
|
public const string SpamMl = "spam_ml";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Спам по ИИ
|
||||||
|
/// </summary>
|
||||||
|
public const string SpamAi = "spam_ai";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Отсев ИИ-фильтром
|
||||||
|
/// </summary>
|
||||||
|
public const string FilterAi = "filter_ai";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Дубликат
|
||||||
|
/// </summary>
|
||||||
|
public const string Duplicate = "dup";
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
|
using Deal.Contracts.Integrations.Models;
|
||||||
using Deal.Modules.Kanban.Application.Models;
|
using Deal.Modules.Kanban.Application.Models;
|
||||||
|
|
||||||
namespace Deal.Modules.Pipeline.Application.Parse;
|
namespace Deal.Modules.Pipeline.Application.Parse;
|
||||||
@@ -65,12 +66,12 @@ public static class ContactsQualifier
|
|||||||
|
|
||||||
private static readonly IReadOnlyDictionary<string, int> PrimaryOrder = new Dictionary<string, int>(StringComparer.Ordinal)
|
private static readonly IReadOnlyDictionary<string, int> PrimaryOrder = new Dictionary<string, int>(StringComparer.Ordinal)
|
||||||
{
|
{
|
||||||
["tg"] = 0,
|
[CardContactTypes.Telegram] = 0,
|
||||||
["phone"] = 1,
|
[CardContactTypes.Phone] = 1,
|
||||||
["whatsapp"] = 2,
|
[CardContactTypes.WhatsApp] = 2,
|
||||||
["email"] = 3,
|
[CardContactTypes.Email] = 3,
|
||||||
["linkedin"] = 4,
|
[CardContactTypes.LinkedIn] = 4,
|
||||||
["site"] = 5,
|
[CardContactTypes.Site] = 5,
|
||||||
};
|
};
|
||||||
|
|
||||||
private const int UnknownTypePriority = 9;
|
private const int UnknownTypePriority = 9;
|
||||||
@@ -93,7 +94,7 @@ public static class ContactsQualifier
|
|||||||
string name = s[1..].Trim();
|
string name = s[1..].Trim();
|
||||||
if (ProfileHandleRe.IsMatch(name) && !name.EndsWith(BotSuffix, StringComparison.OrdinalIgnoreCase))
|
if (ProfileHandleRe.IsMatch(name) && !name.EndsWith(BotSuffix, StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
return new CardContactDto("tg", "@" + name);
|
return new CardContactDto(CardContactTypes.Telegram, "@" + name);
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
@@ -106,7 +107,7 @@ public static class ContactsQualifier
|
|||||||
if (!ReservedProfileNames.Contains(name.ToLowerInvariant())
|
if (!ReservedProfileNames.Contains(name.ToLowerInvariant())
|
||||||
&& !name.EndsWith(BotSuffix, StringComparison.OrdinalIgnoreCase))
|
&& !name.EndsWith(BotSuffix, StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
return new CardContactDto("tg", "@" + name);
|
return new CardContactDto(CardContactTypes.Telegram, "@" + name);
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
@@ -114,23 +115,23 @@ public static class ContactsQualifier
|
|||||||
|
|
||||||
if (EmailRe.IsMatch(s))
|
if (EmailRe.IsMatch(s))
|
||||||
{
|
{
|
||||||
return new CardContactDto("email", s.ToLowerInvariant());
|
return new CardContactDto(CardContactTypes.Email, s.ToLowerInvariant());
|
||||||
}
|
}
|
||||||
|
|
||||||
string digits = new string(s.Where(char.IsDigit).ToArray());
|
string digits = new string(s.Where(char.IsDigit).ToArray());
|
||||||
if (PhoneRe.IsMatch(s) && digits.Length is >= 10 and <= 15)
|
if (PhoneRe.IsMatch(s) && digits.Length is >= 10 and <= 15)
|
||||||
{
|
{
|
||||||
return new CardContactDto("phone", (s.StartsWith('+') ? "+" : string.Empty) + digits);
|
return new CardContactDto(CardContactTypes.Phone, (s.StartsWith('+') ? "+" : string.Empty) + digits);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (LinkedinRe.IsMatch(s))
|
if (LinkedinRe.IsMatch(s))
|
||||||
{
|
{
|
||||||
return new CardContactDto("linkedin", s);
|
return new CardContactDto(CardContactTypes.LinkedIn, s);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (WhatsappRe.IsMatch(s))
|
if (WhatsappRe.IsMatch(s))
|
||||||
{
|
{
|
||||||
return new CardContactDto("whatsapp", s);
|
return new CardContactDto(CardContactTypes.WhatsApp, s);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (s.StartsWith("http", StringComparison.OrdinalIgnoreCase))
|
if (s.StartsWith("http", StringComparison.OrdinalIgnoreCase))
|
||||||
@@ -144,7 +145,7 @@ public static class ContactsQualifier
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return new CardContactDto("site", s);
|
return new CardContactDto(CardContactTypes.Site, s);
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ using System.Text.Json.Nodes;
|
|||||||
using Deal.Contracts.Integrations.Models;
|
using Deal.Contracts.Integrations.Models;
|
||||||
using Deal.Modules.Kanban.Application.Models;
|
using Deal.Modules.Kanban.Application.Models;
|
||||||
using Deal.Modules.Kanban.Application.Services;
|
using Deal.Modules.Kanban.Application.Services;
|
||||||
|
using Deal.Modules.Pipeline.Application.Models;
|
||||||
using Deal.Modules.Pipeline.Application.Parse;
|
using Deal.Modules.Pipeline.Application.Parse;
|
||||||
|
using Deal.SharedKernel.Utilities;
|
||||||
|
|
||||||
namespace Deal.Modules.Pipeline.Application.Services;
|
namespace Deal.Modules.Pipeline.Application.Services;
|
||||||
|
|
||||||
@@ -19,9 +21,9 @@ public static class AiRawCardMapper
|
|||||||
|
|
||||||
private const string BudgetAmountPattern = @"[0-9.,]+\s*[кkКK]?";
|
private const string BudgetAmountPattern = @"[0-9.,]+\s*[кkКK]?";
|
||||||
|
|
||||||
private const string CurrencyFieldName = "currency";
|
// Строковые значения bool-полей, которые считаются «ложью».
|
||||||
|
private static readonly IReadOnlySet<string> FalsyAnswerValues =
|
||||||
private const string CurrencyFieldNameShort = "cur";
|
new HashSet<string>(StringComparer.Ordinal) { "0", BoolText.False, "no", "нет", "null", "none" };
|
||||||
|
|
||||||
// Регулярное выражение числа бюджета: число + необязательные «к»/валюта-суффиксы.
|
// Регулярное выражение числа бюджета: число + необязательные «к»/валюта-суффиксы.
|
||||||
private static readonly System.Text.RegularExpressions.Regex BudgetNumRe =
|
private static readonly System.Text.RegularExpressions.Regex BudgetNumRe =
|
||||||
@@ -39,7 +41,7 @@ public static class AiRawCardMapper
|
|||||||
JsonObject? root = JsonNode.Parse(json) as JsonObject
|
JsonObject? root = JsonNode.Parse(json) as JsonObject
|
||||||
?? throw new JsonException("Ответ ИИ-классификатора — не JSON-объект (ok=true, но схема нарушена).");
|
?? throw new JsonException("Ответ ИИ-классификатора — не JSON-объект (ok=true, но схема нарушена).");
|
||||||
|
|
||||||
string title = MessageTextCleaner.CleanShort(ReadRawString(root, "title"), MaxTitleCodePoints);
|
string title = MessageTextCleaner.CleanShort(ReadRawString(root, AiRawCardKeys.Title), MaxTitleCodePoints);
|
||||||
if (title.Length == 0)
|
if (title.Length == 0)
|
||||||
{
|
{
|
||||||
title = MessageTextCleaner.CleanShort(text, MaxFallbackTextCodePoints); // python L455 fallback
|
title = MessageTextCleaner.CleanShort(text, MaxFallbackTextCodePoints); // python L455 fallback
|
||||||
@@ -49,7 +51,7 @@ public static class AiRawCardMapper
|
|||||||
AiBudgetDto? budget = ReadBudget(root);
|
AiBudgetDto? budget = ReadBudget(root);
|
||||||
IReadOnlyList<AiContactDto> contacts = ReadContacts(root, text);
|
IReadOnlyList<AiContactDto> contacts = ReadContacts(root, text);
|
||||||
|
|
||||||
string? board = ReadRawString(root, "board").Trim();
|
string? board = ReadRawString(root, AiRawCardKeys.Board).Trim();
|
||||||
if (board.Length == 0)
|
if (board.Length == 0)
|
||||||
{
|
{
|
||||||
board = null;
|
board = null;
|
||||||
@@ -57,19 +59,19 @@ public static class AiRawCardMapper
|
|||||||
|
|
||||||
return new AiParsedCardDto(
|
return new AiParsedCardDto(
|
||||||
Title: title,
|
Title: title,
|
||||||
Company: ReadNullableString(root, "company"),
|
Company: ReadNullableString(root, AiRawCardKeys.Company),
|
||||||
Format: ReadNullableString(root, "format"),
|
Format: ReadNullableString(root, AiRawCardKeys.Format),
|
||||||
Task: ReadNullableString(root, "task"),
|
Task: ReadNullableString(root, AiRawCardKeys.Task),
|
||||||
Requirements: ReadItems(root, "requirements"),
|
Requirements: ReadItems(root, AiRawCardKeys.Requirements),
|
||||||
Plus: ReadItems(root, "plus"),
|
Plus: ReadItems(root, AiRawCardKeys.Plus),
|
||||||
Conditions: ReadNullableString(root, "conditions"),
|
Conditions: ReadNullableString(root, AiRawCardKeys.Conditions),
|
||||||
Summary: ReadNullableString(root, "summary"),
|
Summary: ReadNullableString(root, AiRawCardKeys.Summary),
|
||||||
Stack: stack,
|
Stack: stack,
|
||||||
Budget: budget,
|
Budget: budget,
|
||||||
Contacts: contacts,
|
Contacts: contacts,
|
||||||
IsVacancy: ReadBool(root, "is_vacancy"),
|
IsVacancy: ReadBool(root, AiRawCardKeys.IsVacancy),
|
||||||
IsVacancyKnown: ReadBool(root, "is_vacancy_known"), // false — воркер стемпит true после успешного ИИ
|
IsVacancyKnown: ReadBool(root, AiRawCardKeys.IsVacancyKnown), // false — воркер стемпит true после успешного ИИ
|
||||||
IsSpam: ReadBool(root, "is_spam"),
|
IsSpam: ReadBool(root, AiRawCardKeys.IsSpam),
|
||||||
Board: board);
|
Board: board);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,7 +116,7 @@ public static class AiRawCardMapper
|
|||||||
if (value.TryGetValue<string>(out string? text))
|
if (value.TryGetValue<string>(out string? text))
|
||||||
{
|
{
|
||||||
string lower = (text ?? string.Empty).Trim().ToLowerInvariant();
|
string lower = (text ?? string.Empty).Trim().ToLowerInvariant();
|
||||||
return lower.Length > 0 && lower is not ("0" or "false" or "no" or "нет" or "null" or "none");
|
return lower.Length > 0 && !FalsyAnswerValues.Contains(lower);
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
@@ -147,7 +149,7 @@ public static class AiRawCardMapper
|
|||||||
|
|
||||||
private static IReadOnlyList<string> ReadStack(JsonObject root)
|
private static IReadOnlyList<string> ReadStack(JsonObject root)
|
||||||
{
|
{
|
||||||
if (!root.TryGetPropertyValue("stack", out JsonNode? node) || node is null)
|
if (!root.TryGetPropertyValue(AiRawCardKeys.Stack, out JsonNode? node) || node is null)
|
||||||
{
|
{
|
||||||
return Array.Empty<string>();
|
return Array.Empty<string>();
|
||||||
}
|
}
|
||||||
@@ -161,19 +163,19 @@ public static class AiRawCardMapper
|
|||||||
|
|
||||||
private static AiBudgetDto? ReadBudget(JsonObject root)
|
private static AiBudgetDto? ReadBudget(JsonObject root)
|
||||||
{
|
{
|
||||||
if (!root.TryGetPropertyValue("budget", out JsonNode? node) || node is not JsonObject budget)
|
if (!root.TryGetPropertyValue(AiRawCardKeys.Budget, out JsonNode? node) || node is not JsonObject budget)
|
||||||
{
|
{
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
string currency = ReadRawString(budget, CurrencyFieldName);
|
string currency = ReadRawString(budget, AiRawCardKeys.Currency);
|
||||||
if (currency.Length == 0)
|
if (currency.Length == 0)
|
||||||
{
|
{
|
||||||
currency = ReadRawString(budget, CurrencyFieldNameShort); // ai.py L327: currency или cur
|
currency = ReadRawString(budget, AiRawCardKeys.CurrencyShort); // ai.py L327: currency или cur
|
||||||
}
|
}
|
||||||
|
|
||||||
double? from = ParseBudgetBound(budget, "from");
|
double? from = ParseBudgetBound(budget, AiRawCardKeys.From);
|
||||||
double? to = ParseBudgetBound(budget, "to");
|
double? to = ParseBudgetBound(budget, AiRawCardKeys.To);
|
||||||
CardBudgetDto? normalized = BudgetNormalizer.Normalize(new BudgetRangeDto(from, to, currency));
|
CardBudgetDto? normalized = BudgetNormalizer.Normalize(new BudgetRangeDto(from, to, currency));
|
||||||
return normalized is null
|
return normalized is null
|
||||||
? null
|
? null
|
||||||
@@ -226,7 +228,7 @@ public static class AiRawCardMapper
|
|||||||
private static IReadOnlyList<AiContactDto> ReadContacts(JsonObject root, string text)
|
private static IReadOnlyList<AiContactDto> ReadContacts(JsonObject root, string text)
|
||||||
{
|
{
|
||||||
List<string> candidates = new();
|
List<string> candidates = new();
|
||||||
if (root.TryGetPropertyValue("contacts", out JsonNode? node) && node is not null)
|
if (root.TryGetPropertyValue(AiRawCardKeys.Contacts, out JsonNode? node) && node is not null)
|
||||||
{
|
{
|
||||||
if (node is JsonValue)
|
if (node is JsonValue)
|
||||||
{
|
{
|
||||||
@@ -245,7 +247,7 @@ public static class AiRawCardMapper
|
|||||||
break;
|
break;
|
||||||
case JsonObject contactObject:
|
case JsonObject contactObject:
|
||||||
{
|
{
|
||||||
string value = ReadRawString(contactObject, "value"); // python L402–403: dict.get("value")
|
string value = ReadRawString(contactObject, AiRawCardKeys.Value);
|
||||||
if (value.Length > 0)
|
if (value.Length > 0)
|
||||||
{
|
{
|
||||||
candidates.Add(value);
|
candidates.Add(value);
|
||||||
|
|||||||
@@ -220,7 +220,7 @@ public sealed class MlReviewService(
|
|||||||
{
|
{
|
||||||
// TrashCardAsync(teach:true) сам шлёт обучающий сигнал «спам» — второй сигнал не нужен.
|
// TrashCardAsync(teach:true) сам шлёт обучающий сигнал «спам» — второй сигнал не нужен.
|
||||||
CardDto? trashed = await cards.TrashCardAsync(card.Id, teach: true, ct);
|
CardDto? trashed = await cards.TrashCardAsync(card.Id, teach: true, ct);
|
||||||
return new MlApplyResult(null, Ok: true, Learned: true, Moved: "trash", LeadId: trashed?.Id ?? card.Id);
|
return new MlApplyResult(null, Ok: true, Learned: true, Moved: CardIds.Trash, LeadId: trashed?.Id ?? card.Id);
|
||||||
}
|
}
|
||||||
|
|
||||||
await mlClient.PushAsync(text, MlLearningLabels.Spam, UserPushWeight, ct);
|
await mlClient.PushAsync(text, MlLearningLabels.Spam, UserPushWeight, ct);
|
||||||
|
|||||||
@@ -47,16 +47,16 @@ public sealed class PipelineProcessingService(
|
|||||||
// Вес снятия метки «спам»: реальное действие пользователя «это не спам» (delta=−1.0).
|
// Вес снятия метки «спам»: реальное действие пользователя «это не спам» (delta=−1.0).
|
||||||
private const double SpamUnlearnDelta = -1.0;
|
private const double SpamUnlearnDelta = -1.0;
|
||||||
|
|
||||||
private const string DuplicateSource = "dup";
|
private const string DuplicateSource = PipelineRejectSources.Duplicate;
|
||||||
|
|
||||||
// Имя сущности для текста ошибки «не найдено».
|
// Имя сущности для текста ошибки «не найдено».
|
||||||
private const string RejectedEntityName = "Запись отсева";
|
private const string RejectedEntityName = "Запись отсева";
|
||||||
|
|
||||||
private static readonly HashSet<string> SpamStages = new(StringComparer.Ordinal)
|
private static readonly HashSet<string> SpamStages = new(StringComparer.Ordinal)
|
||||||
{
|
{
|
||||||
"spam_ml",
|
PipelineRejectStages.SpamMl,
|
||||||
"spam_ai",
|
PipelineRejectStages.SpamAi,
|
||||||
"filter_ai",
|
PipelineRejectStages.FilterAi,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
namespace Deal.Modules.Settings.Application.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ключи полей настроек провайдеров/промптов
|
||||||
|
/// </summary>
|
||||||
|
public static class SettingsFieldKeys
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// API-ключ провайдера
|
||||||
|
/// </summary>
|
||||||
|
public const string ApiKey = "apiKey";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Базовый URL провайдера
|
||||||
|
/// </summary>
|
||||||
|
public const string BaseUrl = "baseUrl";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Модель провайдера
|
||||||
|
/// </summary>
|
||||||
|
public const string Model = "model";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Название пользовательского промпта
|
||||||
|
/// </summary>
|
||||||
|
public const string Name = "name";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Текст пользовательского промпта
|
||||||
|
/// </summary>
|
||||||
|
public const string Prompt = "prompt";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Описание пользовательского промпта
|
||||||
|
/// </summary>
|
||||||
|
public const string Description = "description";
|
||||||
|
}
|
||||||
+3
-3
@@ -26,14 +26,14 @@ public sealed partial class SettingsService
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
string name = ReadTrimmedField(item, "name", PromptNameMaxLength);
|
string name = ReadTrimmedField(item, SettingsFieldKeys.Name, PromptNameMaxLength);
|
||||||
string prompt = ReadTrimmedField(item, "prompt", PromptTextMaxLength);
|
string prompt = ReadTrimmedField(item, SettingsFieldKeys.Prompt, PromptTextMaxLength);
|
||||||
if (name.Length == 0 || prompt.Length == 0)
|
if (name.Length == 0 || prompt.Length == 0)
|
||||||
{
|
{
|
||||||
continue; // пустые name/prompt — дроп
|
continue; // пустые name/prompt — дроп
|
||||||
}
|
}
|
||||||
|
|
||||||
string description = ReadTrimmedField(item, "description", PromptDescriptionMaxLength);
|
string description = ReadTrimmedField(item, SettingsFieldKeys.Description, PromptDescriptionMaxLength);
|
||||||
string id = ReadPromptId(item);
|
string id = ReadPromptId(item);
|
||||||
clean.Add(new MyPromptDto(id, name, description, prompt));
|
clean.Add(new MyPromptDto(id, name, description, prompt));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,19 +46,19 @@ public sealed partial class SettingsService
|
|||||||
// каталогом — иначе тенант мог бы перенаправить ключ/запрос на произвольный внутренний адрес
|
// каталогом — иначе тенант мог бы перенаправить ключ/запрос на произвольный внутренний адрес
|
||||||
// (SSRF, Security review) и проверка подключения ушла бы на него.
|
// (SSRF, Security review) и проверка подключения ушла бы на него.
|
||||||
bool baseUrlMutable = meta is { Local: true } || providerId == CustomProviderId;
|
bool baseUrlMutable = meta is { Local: true } || providerId == CustomProviderId;
|
||||||
if (baseUrlMutable && TryReadFieldText(entry, "baseUrl", out string newBaseUrl))
|
if (baseUrlMutable && TryReadFieldText(entry, SettingsFieldKeys.BaseUrl, out string newBaseUrl))
|
||||||
{
|
{
|
||||||
baseUrl = newBaseUrl;
|
baseUrl = newBaseUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (TryReadFieldText(entry, "model", out string newModel))
|
if (TryReadFieldText(entry, SettingsFieldKeys.Model, out string newModel))
|
||||||
{
|
{
|
||||||
model = newModel;
|
model = newModel;
|
||||||
}
|
}
|
||||||
|
|
||||||
// apiKey: непустой, ≥8 симв., без префикса enc: и без маски (содержит "…") → шифруется;
|
// apiKey: непустой, ≥8 симв., без префикса enc: и без маски (содержит "…") → шифруется;
|
||||||
// иначе (пустой/маска) ключ не меняется.
|
// иначе (пустой/маска) ключ не меняется.
|
||||||
if (TryReadFieldText(entry, "apiKey", out string newKey)
|
if (TryReadFieldText(entry, SettingsFieldKeys.ApiKey, out string newKey)
|
||||||
&& newKey.Length >= ApiKeyMinLength
|
&& newKey.Length >= ApiKeyMinLength
|
||||||
&& !newKey.StartsWith(EncryptedValuePrefix, StringComparison.Ordinal)
|
&& !newKey.StartsWith(EncryptedValuePrefix, StringComparison.Ordinal)
|
||||||
&& !newKey.Contains(MaskEllipsis, StringComparison.Ordinal))
|
&& !newKey.Contains(MaskEllipsis, StringComparison.Ordinal))
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using Deal.Modules.Settings.Application.Models;
|
using Deal.Modules.Settings.Application.Models;
|
||||||
|
using Deal.SharedKernel.Utilities;
|
||||||
|
|
||||||
namespace Deal.Modules.Settings.Application.Services;
|
namespace Deal.Modules.Settings.Application.Services;
|
||||||
|
|
||||||
@@ -82,11 +83,11 @@ public sealed partial class SettingsService
|
|||||||
return true;
|
return true;
|
||||||
|
|
||||||
case JsonValueKind.True:
|
case JsonValueKind.True:
|
||||||
text = "true";
|
text = BoolText.True;
|
||||||
return true;
|
return true;
|
||||||
|
|
||||||
case JsonValueKind.False:
|
case JsonValueKind.False:
|
||||||
text = "false";
|
text = BoolText.False;
|
||||||
return true;
|
return true;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
@@ -218,9 +219,9 @@ public sealed partial class SettingsService
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
string apiKey = TryReadFieldText(provider.Value, "apiKey", out string storedKey) ? storedKey : string.Empty;
|
string apiKey = TryReadFieldText(provider.Value, SettingsFieldKeys.ApiKey, out string storedKey) ? storedKey : string.Empty;
|
||||||
string baseUrl = TryReadFieldText(provider.Value, "baseUrl", out string storedBase) ? storedBase : string.Empty;
|
string baseUrl = TryReadFieldText(provider.Value, SettingsFieldKeys.BaseUrl, out string storedBase) ? storedBase : string.Empty;
|
||||||
string model = TryReadFieldText(provider.Value, "model", out string storedModel) ? storedModel : string.Empty;
|
string model = TryReadFieldText(provider.Value, SettingsFieldKeys.Model, out string storedModel) ? storedModel : string.Empty;
|
||||||
merged[provider.Name] = new AiConfigSetting(apiKey, baseUrl, model);
|
merged[provider.Name] = new AiConfigSetting(apiKey, baseUrl, model);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,6 +41,16 @@ public interface IAuthStore
|
|||||||
/// <returns>Пользователи тенанта, упорядоченные по времени создания (пусто — пользователей нет).</returns>
|
/// <returns>Пользователи тенанта, упорядоченные по времени создания (пусто — пользователей нет).</returns>
|
||||||
public Task<IReadOnlyList<UserIdentityDto>> ListUsersByTenantIdAsync(Guid tenantId, CancellationToken ct);
|
public Task<IReadOnlyList<UserIdentityDto>> ListUsersByTenantIdAsync(Guid tenantId, CancellationToken ct);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Логины владельцев тенантов
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="tenantIds">Идентификаторы тенантов.</param>
|
||||||
|
/// <param name="ct">Токен отмены.</param>
|
||||||
|
/// <returns>Словарь tenantId → логин владельца (первый пользователь по времени создания); тенанты без пользователей не включаются.</returns>
|
||||||
|
public Task<IReadOnlyDictionary<Guid, string>> FindOwnerLoginsByTenantIdsAsync(
|
||||||
|
IReadOnlyCollection<Guid> tenantIds,
|
||||||
|
CancellationToken ct);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Сохраняет новую сессию.
|
/// Сохраняет новую сессию.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ public interface ITenantRepository
|
|||||||
public Task<IReadOnlyList<TenantRecordDto>> ListAsync(CancellationToken ct);
|
public Task<IReadOnlyList<TenantRecordDto>> ListAsync(CancellationToken ct);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Страница реестра тенантов (шардированный обход для 1000+ схем).
|
/// Возвращает страницу реестра тенантов (шардированный обход для 1000+ схем).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="offset">Сдвиг от начала (устойчивый порядок — CreatedAt, затем Id).</param>
|
/// <param name="offset">Сдвиг от начала (устойчивый порядок — CreatedAt, затем Id).</param>
|
||||||
/// <param name="limit">Размер страницы (≥1; валидирует потребитель).</param>
|
/// <param name="limit">Размер страницы (≥1; валидирует потребитель).</param>
|
||||||
@@ -37,6 +37,16 @@ public interface ITenantRepository
|
|||||||
int limit,
|
int limit,
|
||||||
CancellationToken ct);
|
CancellationToken ct);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Имена тенантов по идентификаторам
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="ids">Идентификаторы тенантов.</param>
|
||||||
|
/// <param name="ct">Токен отмены.</param>
|
||||||
|
/// <returns>Словарь id → имя; отсутствующие не включаются.</returns>
|
||||||
|
public Task<IReadOnlyDictionary<Guid, string>> FindNamesByIdsAsync(
|
||||||
|
IReadOnlyCollection<Guid> ids,
|
||||||
|
CancellationToken ct);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Устанавливает статус тенанта.
|
/// Устанавливает статус тенанта.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -1,8 +1,35 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
namespace Deal.Modules.Tenants.Application.Models;
|
namespace Deal.Modules.Tenants.Application.Models;
|
||||||
|
|
||||||
// Расширения записей аудита
|
/// <summary>
|
||||||
internal static class AuditRecordDtoExtensions
|
/// Расширения записей аудита
|
||||||
|
/// </summary>
|
||||||
|
public static class AuditRecordDtoExtensions
|
||||||
{
|
{
|
||||||
|
// Ключ служебного поля-контейнера изменений в деталях события.
|
||||||
|
private const string ChangesProperty = "changes";
|
||||||
|
|
||||||
|
// Ключи элемента изменения нового формата.
|
||||||
|
private const string FieldProperty = "field";
|
||||||
|
private const string FromProperty = "from";
|
||||||
|
private const string ToProperty = "to";
|
||||||
|
|
||||||
|
// Параметр события, дублирующий TenantId самой записи, — в изменения не попадает.
|
||||||
|
private const string TenantIdProperty = "tenantId";
|
||||||
|
|
||||||
|
// Префиксы парных ключей «было»/«стало» в устаревшем плоском формате деталей.
|
||||||
|
private const string OldPrefix = "old";
|
||||||
|
private const string NewPrefix = "new";
|
||||||
|
|
||||||
|
// Соответствие суффикса «old<X>» имени ключа нового значения в устаревшем формате (имена не совпадают).
|
||||||
|
private static readonly IReadOnlyDictionary<string, string> LegacyNewNameByOldSuffix =
|
||||||
|
new Dictionary<string, string>(StringComparer.Ordinal)
|
||||||
|
{
|
||||||
|
["Budget"] = "budgetTokens",
|
||||||
|
["Period"] = "period",
|
||||||
|
};
|
||||||
|
|
||||||
// События аудита «неудачный вход» (тенант/оператор).
|
// События аудита «неудачный вход» (тенант/оператор).
|
||||||
private static readonly string[] FailedLoginEvents =
|
private static readonly string[] FailedLoginEvents =
|
||||||
{
|
{
|
||||||
@@ -30,4 +57,144 @@ internal static class AuditRecordDtoExtensions
|
|||||||
/// <param name="record">Запись аудита.</param>
|
/// <param name="record">Запись аудита.</param>
|
||||||
/// <returns>True — событие из SuccessfulLoginEvents.</returns>
|
/// <returns>True — событие из SuccessfulLoginEvents.</returns>
|
||||||
public static bool IsSuccessfulLogin(this AuditRecordDto record) => SuccessfulLoginEvents.Contains(record.EventType);
|
public static bool IsSuccessfulLogin(this AuditRecordDto record) => SuccessfulLoginEvents.Contains(record.EventType);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Человекочитаемые изменения параметров события
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="record">Запись аудита.</param>
|
||||||
|
/// <returns>Изменения в порядке записи; пусто — деталей нет или они не разобраны.</returns>
|
||||||
|
public static IReadOnlyList<AuditChangeDto> AuditChanges(this AuditRecordDto record)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(record.DetailJson))
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using JsonDocument document = JsonDocument.Parse(record.DetailJson);
|
||||||
|
if (document.RootElement.ValueKind != JsonValueKind.Object)
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return document.RootElement.TryGetProperty(ChangesProperty, out JsonElement changes)
|
||||||
|
&& changes.ValueKind == JsonValueKind.Array
|
||||||
|
? ParseChanges(changes)
|
||||||
|
: ParseFlatDetails(document.RootElement);
|
||||||
|
}
|
||||||
|
catch (JsonException)
|
||||||
|
{
|
||||||
|
// Повреждённые детали — изменений нет; запись остаётся читаемой.
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Значение параметра события
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="record">Запись аудита.</param>
|
||||||
|
/// <param name="field">Код параметра.</param>
|
||||||
|
/// <returns>Значение «стало» параметра; при отсутствии — значение «было»; иначе null.</returns>
|
||||||
|
public static string? DetailValue(this AuditRecordDto record, string field)
|
||||||
|
{
|
||||||
|
foreach (AuditChangeDto change in record.AuditChanges())
|
||||||
|
{
|
||||||
|
if (string.Equals(change.Field, field, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return change.To ?? change.From;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Разбирает контейнер изменений нового формата («changes»).
|
||||||
|
// changes: Массив объектов { field, from, to }.
|
||||||
|
// Возвращает: Изменения; некорректные элементы пропускаются.
|
||||||
|
private static List<AuditChangeDto> ParseChanges(JsonElement changes)
|
||||||
|
{
|
||||||
|
var result = new List<AuditChangeDto>(changes.GetArrayLength());
|
||||||
|
foreach (JsonElement item in changes.EnumerateArray())
|
||||||
|
{
|
||||||
|
if (item.ValueKind != JsonValueKind.Object
|
||||||
|
|| !item.TryGetProperty(FieldProperty, out JsonElement field)
|
||||||
|
|| field.ValueKind != JsonValueKind.String)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
string? from = item.TryGetProperty(FromProperty, out JsonElement fromElement) ? Stringify(fromElement) : null;
|
||||||
|
string? to = item.TryGetProperty(ToProperty, out JsonElement toElement) ? Stringify(toElement) : null;
|
||||||
|
result.Add(new AuditChangeDto(field.GetString()!, from, to));
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Разбирает устаревший плоский формат деталей: пары old*/new* → «было → стало», прочее — «задано».
|
||||||
|
// root: Корневой объект деталей.
|
||||||
|
// Возвращает: Изменения в порядке свойств.
|
||||||
|
private static List<AuditChangeDto> ParseFlatDetails(JsonElement root)
|
||||||
|
{
|
||||||
|
List<JsonProperty> properties = root.EnumerateObject().ToList();
|
||||||
|
var consumed = new HashSet<string>(StringComparer.Ordinal);
|
||||||
|
var result = new List<AuditChangeDto>(properties.Count);
|
||||||
|
|
||||||
|
foreach (JsonProperty property in properties)
|
||||||
|
{
|
||||||
|
if (!consumed.Add(property.Name) || property.Name == TenantIdProperty)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
string? pairedNewName = null;
|
||||||
|
if (property.Name.StartsWith(OldPrefix, StringComparison.Ordinal)
|
||||||
|
&& property.Name.Length > OldPrefix.Length)
|
||||||
|
{
|
||||||
|
string suffix = property.Name[OldPrefix.Length..];
|
||||||
|
string defaultNewName = NewPrefix + suffix;
|
||||||
|
if (LegacyNewNameByOldSuffix.TryGetValue(suffix, out string? mapped)
|
||||||
|
&& root.TryGetProperty(mapped, out JsonElement mappedValue))
|
||||||
|
{
|
||||||
|
consumed.Add(mapped);
|
||||||
|
result.Add(new AuditChangeDto(LowerFirst(suffix), Stringify(property.Value), Stringify(mappedValue)));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
pairedNewName = root.TryGetProperty(defaultNewName, out _) ? defaultNewName : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pairedNewName is not null && root.TryGetProperty(pairedNewName, out JsonElement newValue))
|
||||||
|
{
|
||||||
|
consumed.Add(pairedNewName);
|
||||||
|
result.Add(new AuditChangeDto(
|
||||||
|
LowerFirst(property.Name[OldPrefix.Length..]),
|
||||||
|
Stringify(property.Value),
|
||||||
|
Stringify(newValue)));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
result.Add(new AuditChangeDto(property.Name, null, Stringify(property.Value)));
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Приводит значение JSON к строке отображения.
|
||||||
|
// element: Значение JSON.
|
||||||
|
// Возвращает: Строковое представление либо null для JSON null; массивы — списком через запятую.
|
||||||
|
private static string? Stringify(JsonElement element) => element.ValueKind switch
|
||||||
|
{
|
||||||
|
JsonValueKind.Null or JsonValueKind.Undefined => null,
|
||||||
|
JsonValueKind.String => element.GetString(),
|
||||||
|
JsonValueKind.Array => string.Join(", ", element.EnumerateArray().Select(Stringify)),
|
||||||
|
_ => element.GetRawText(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Понижает регистр первой буквы имени параметра (oldBudget → budget).
|
||||||
|
// value: Имя параметра.
|
||||||
|
// Возвращает: Имя с первой строчной буквой.
|
||||||
|
private static string LowerFirst(string value) =>
|
||||||
|
value.Length == 0 ? value : char.ToLowerInvariant(value[0]) + value[1..];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
namespace Deal.Modules.Tenants.Application.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Изменение параметра в деталях события аудита
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="Field">Код параметра (человекочитаемое имя — в ресурсах интерфейса).</param>
|
||||||
|
/// <param name="From">Значение до изменения; null — параметр задан впервые.</param>
|
||||||
|
/// <param name="To">Значение после изменения.</param>
|
||||||
|
public sealed record AuditChangeDto(
|
||||||
|
string Field,
|
||||||
|
string? From,
|
||||||
|
string? To);
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
|
||||||
|
namespace Deal.Modules.Tenants.Application.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Конструктор деталей события аудита
|
||||||
|
/// </summary>
|
||||||
|
public static class AuditDetails
|
||||||
|
{
|
||||||
|
// Строковые представления булевых значений (как в JSON).
|
||||||
|
private const string TrueLiteral = "true";
|
||||||
|
private const string FalseLiteral = "false";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Задаёт параметр события
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="field">Код параметра.</param>
|
||||||
|
/// <param name="value">Значение параметра.</param>
|
||||||
|
/// <returns>Изменение «параметр задан».</returns>
|
||||||
|
public static AuditChangeDto Set(string field, object? value) =>
|
||||||
|
new(field, null, Format(value));
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Задаёт изменение параметра «было → стало»
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="field">Код параметра.</param>
|
||||||
|
/// <param name="from">Значение до изменения.</param>
|
||||||
|
/// <param name="to">Значение после изменения.</param>
|
||||||
|
/// <returns>Изменение «было → стало».</returns>
|
||||||
|
public static AuditChangeDto Change(string field, object? from, object? to) =>
|
||||||
|
new(field, Format(from), Format(to));
|
||||||
|
|
||||||
|
// Приводит значение к строке отображения (инвариантная культура).
|
||||||
|
// value: Значение параметра.
|
||||||
|
// Возвращает: Строковое представление либо null.
|
||||||
|
private static string? Format(object? value) => value switch
|
||||||
|
{
|
||||||
|
null => null,
|
||||||
|
string text => text,
|
||||||
|
bool flag => flag ? TrueLiteral : FalseLiteral,
|
||||||
|
DateTimeOffset moment => moment.ToString("O", CultureInfo.InvariantCulture),
|
||||||
|
IFormattable formattable => formattable.ToString(null, CultureInfo.InvariantCulture),
|
||||||
|
_ => value.ToString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
namespace Deal.Modules.Tenants.Application.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Каталог кодов параметров в деталях события аудита
|
||||||
|
/// </summary>
|
||||||
|
public static class AuditFields
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Логин пользователя
|
||||||
|
/// </summary>
|
||||||
|
public const string Login = "login";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Email
|
||||||
|
/// </summary>
|
||||||
|
public const string Email = "email";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Отпечаток (хэш) кода приглашения
|
||||||
|
/// </summary>
|
||||||
|
public const string CodeHash = "codeHash";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Имя сущности (пользователь/колонка)
|
||||||
|
/// </summary>
|
||||||
|
public const string Name = "name";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Статус
|
||||||
|
/// </summary>
|
||||||
|
public const string Status = "status";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Бюджет токенов
|
||||||
|
/// </summary>
|
||||||
|
public const string Budget = "budget";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Период лимита
|
||||||
|
/// </summary>
|
||||||
|
public const string Period = "period";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Идентификатор приложения Telegram
|
||||||
|
/// </summary>
|
||||||
|
public const string ApiId = "apiId";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Признак заданного секрета приложения Telegram
|
||||||
|
/// </summary>
|
||||||
|
public const string ApiHashSet = "apiHashSet";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Логин, под которым выполнен вход от имени пользователя
|
||||||
|
/// </summary>
|
||||||
|
public const string TargetLogin = "targetLogin";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Карточка
|
||||||
|
/// </summary>
|
||||||
|
public const string CardId = "cardId";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Контейнер назначения карточки
|
||||||
|
/// </summary>
|
||||||
|
public const string Destination = "to";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Колонка карточки
|
||||||
|
/// </summary>
|
||||||
|
public const string Column = "col";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Контейнер (колонка)
|
||||||
|
/// </summary>
|
||||||
|
public const string ContainerId = "containerId";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Число обработанных карточек
|
||||||
|
/// </summary>
|
||||||
|
public const string Attempted = "attempted";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Число переклассифицированных карточек
|
||||||
|
/// </summary>
|
||||||
|
public const string Reclassified = "reclassified";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Число перемещённых карточек
|
||||||
|
/// </summary>
|
||||||
|
public const string Moved = "moved";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Число карточек, отправленных в корзину
|
||||||
|
/// </summary>
|
||||||
|
public const string Trashed = "trashed";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Изменённые поля настроек
|
||||||
|
/// </summary>
|
||||||
|
public const string Fields = "fields";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Признак действия «все каналы»
|
||||||
|
/// </summary>
|
||||||
|
public const string All = "all";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Число каналов
|
||||||
|
/// </summary>
|
||||||
|
public const string Count = "count";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Этап подключения Telegram
|
||||||
|
/// </summary>
|
||||||
|
public const string Phase = "phase";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Диалог Telegram
|
||||||
|
/// </summary>
|
||||||
|
public const string DialogId = "dialogId";
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
namespace Deal.Modules.Tenants.Application.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Запись аудита для чтения оператором
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="EventType">Тип события — константа каталога <c>AuditEvents</c>.</param>
|
||||||
|
/// <param name="ActorType">Тип актора — константа <c>AuditActorTypes</c>.</param>
|
||||||
|
/// <param name="ActorId">Идентификатор актора; null, если актор неизвестен.</param>
|
||||||
|
/// <param name="TenantId">Идентификатор пользователя события; null для операторских/системных событий.</param>
|
||||||
|
/// <param name="UserName">Логин реального пользователя (владелец пространства либо логин попытки); null, если не разрешён.</param>
|
||||||
|
/// <param name="TenantName">Имя пространства события; null, если не разрешено.</param>
|
||||||
|
/// <param name="Ip">IP-адрес клиента (без порта); null для серверных действий.</param>
|
||||||
|
/// <param name="Changes">Человекочитаемые изменения параметров события.</param>
|
||||||
|
/// <param name="DetailJson">Сырые детали события в JSON; null, если деталей нет.</param>
|
||||||
|
/// <param name="At">Время события (UTC).</param>
|
||||||
|
/// <param name="Id">Identity-идентификатор строки.</param>
|
||||||
|
public sealed record AuditRecordViewDto(
|
||||||
|
string EventType,
|
||||||
|
string ActorType,
|
||||||
|
Guid? ActorId,
|
||||||
|
Guid? TenantId,
|
||||||
|
string? UserName,
|
||||||
|
string? TenantName,
|
||||||
|
string? Ip,
|
||||||
|
IReadOnlyList<AuditChangeDto> Changes,
|
||||||
|
string? DetailJson,
|
||||||
|
DateTimeOffset At,
|
||||||
|
long Id);
|
||||||
@@ -7,11 +7,13 @@ namespace Deal.Modules.Tenants.Application.Models;
|
|||||||
/// <param name="Error">Код ошибки при Ok=false (см. константы); null при успехе.</param>
|
/// <param name="Error">Код ошибки при Ok=false (см. константы); null при успехе.</param>
|
||||||
/// <param name="Changed">true — статус реально изменён (пишется аудит tenant_status_changed); false — уже был таким.</param>
|
/// <param name="Changed">true — статус реально изменён (пишется аудит tenant_status_changed); false — уже был таким.</param>
|
||||||
/// <param name="Tenant">Запись тенанта (актуальный статус — в <see cref="TenantRecordDto.Status"/>); null при Ok=false.</param>
|
/// <param name="Tenant">Запись тенанта (актуальный статус — в <see cref="TenantRecordDto.Status"/>); null при Ok=false.</param>
|
||||||
|
/// <param name="PreviousStatus">Статус до изменения; null при Ok=false.</param>
|
||||||
public sealed record TenantStatusChangeResultDto(
|
public sealed record TenantStatusChangeResultDto(
|
||||||
bool Ok,
|
bool Ok,
|
||||||
string? Error,
|
string? Error,
|
||||||
bool Changed,
|
bool Changed,
|
||||||
TenantRecordDto? Tenant)
|
TenantRecordDto? Tenant,
|
||||||
|
string? PreviousStatus = null)
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Код ошибки: тенант не найден
|
/// Код ошибки: тенант не найден
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
using Deal.Modules.Tenants.Application.Abstractions;
|
using Deal.Modules.Tenants.Application.Abstractions;
|
||||||
using Deal.Modules.Tenants.Application.Models;
|
using Deal.Modules.Tenants.Application.Models;
|
||||||
using Deal.SharedKernel.Observability;
|
using Deal.SharedKernel.Observability;
|
||||||
@@ -20,8 +21,11 @@ public sealed class AuditService(IAuditLogStore store)
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public const int DefaultQueryLimit = 100;
|
public const int DefaultQueryLimit = 100;
|
||||||
|
|
||||||
// Опции JSON деталей: camelCase (конвенция DetailJson/JSON проекта).
|
// Опции JSON деталей: camelCase (конвенция DetailJson/JSON проекта), без null-полей.
|
||||||
private static readonly JsonSerializerOptions DetailJsonOptions = new(JsonSerializerDefaults.Web);
|
private static readonly JsonSerializerOptions DetailJsonOptions = new(JsonSerializerDefaults.Web)
|
||||||
|
{
|
||||||
|
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||||
|
};
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Записывает событие аудита
|
/// Записывает событие аудита
|
||||||
@@ -49,11 +53,12 @@ public sealed class AuditService(IAuditLogStore store)
|
|||||||
public Task<int> CountAsync(AuditQueryDto filter, CancellationToken ct) => store.CountAsync(filter, ct);
|
public Task<int> CountAsync(AuditQueryDto filter, CancellationToken ct) => store.CountAsync(filter, ct);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Сериализует детали события в JSON.
|
/// Сериализует изменения деталей события в JSON
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="details">Объект деталей (обычно анонимный: { login =... }).</param>
|
/// <param name="changes">Человекочитаемые изменения параметров события.</param>
|
||||||
/// <returns>JSON-строка деталей.</returns>
|
/// <returns>JSON-строка деталей вида <c>{ changes: [...] }</c>.</returns>
|
||||||
public static string ToDetailJson(object? details) => JsonSerializer.Serialize(details, DetailJsonOptions);
|
public static string ToDetailJson(IReadOnlyList<AuditChangeDto> changes) =>
|
||||||
|
JsonSerializer.Serialize(new { changes }, DetailJsonOptions);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Актор «пользователь тенанта» по разрешённой сессии
|
/// Актор «пользователь тенанта» по разрешённой сессии
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
using System.Text.Json;
|
|
||||||
using Deal.Modules.Tenants.Application.Abstractions;
|
using Deal.Modules.Tenants.Application.Abstractions;
|
||||||
using Deal.Modules.Tenants.Application.Models;
|
using Deal.Modules.Tenants.Application.Models;
|
||||||
|
|
||||||
@@ -338,33 +337,10 @@ public sealed class SuspiciousActivityService
|
|||||||
return counts;
|
return counts;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Извлекает логин из DetailJson записи (поле login); сбой разбора — null.
|
// Извлекает логин из деталей записи (параметр login); повреждённые детали — null.
|
||||||
// record: Запись аудита.
|
// record: Запись аудита.
|
||||||
// Возвращает: Логин либо null (деталей нет/не строка/повреждённый JSON).
|
// Возвращает: Логин либо null (деталей нет/параметр не задан).
|
||||||
private static string? ExtractLogin(AuditRecordDto record)
|
private static string? ExtractLogin(AuditRecordDto record) => record.DetailValue(AuditFields.Login);
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(record.DetailJson))
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
using JsonDocument document = JsonDocument.Parse(record.DetailJson);
|
|
||||||
if (document.RootElement.ValueKind == JsonValueKind.Object
|
|
||||||
&& document.RootElement.TryGetProperty("login", out JsonElement login)
|
|
||||||
&& login.ValueKind == JsonValueKind.String)
|
|
||||||
{
|
|
||||||
return login.GetString();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (JsonException)
|
|
||||||
{
|
|
||||||
// Повреждённые детали — логин неизвестен; запись в правиле не участвует.
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Уровень находки по превышению порога (≥2× порог — high, иначе medium).
|
// Уровень находки по превышению порога (≥2× порог — high, иначе medium).
|
||||||
// count: Фактическое значение правила.
|
// count: Фактическое значение правила.
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ public sealed class TenantAdminService(
|
|||||||
Id: ownerUserId,
|
Id: ownerUserId,
|
||||||
Login: normalizedEmail,
|
Login: normalizedEmail,
|
||||||
TenantId: tenantId,
|
TenantId: tenantId,
|
||||||
Status: "active",
|
Status: TenantStatuses.Active,
|
||||||
PasswordHash: passwordHasher.Hash(initialPassword)),
|
PasswordHash: passwordHasher.Hash(initialPassword)),
|
||||||
ct);
|
ct);
|
||||||
|
|
||||||
@@ -138,11 +138,16 @@ public sealed class TenantAdminService(
|
|||||||
// Идемпотентность: повторный suspend уже приостановленного — Ok без изменения (аудит не дублируется).
|
// Идемпотентность: повторный suspend уже приостановленного — Ok без изменения (аудит не дублируется).
|
||||||
if (tenant.Status == status)
|
if (tenant.Status == status)
|
||||||
{
|
{
|
||||||
return new TenantStatusChangeResultDto(Ok: true, Error: null, Changed: false, Tenant: tenant);
|
return new TenantStatusChangeResultDto(Ok: true, Error: null, Changed: false, Tenant: tenant, PreviousStatus: tenant.Status);
|
||||||
}
|
}
|
||||||
|
|
||||||
bool updated = await tenantRepository.UpdateStatusAsync(id, status, ct);
|
bool updated = await tenantRepository.UpdateStatusAsync(id, status, ct);
|
||||||
return new TenantStatusChangeResultDto(Ok: true, Error: null, Changed: updated, Tenant: tenant with { Status = status });
|
return new TenantStatusChangeResultDto(
|
||||||
|
Ok: true,
|
||||||
|
Error: null,
|
||||||
|
Changed: updated,
|
||||||
|
Tenant: tenant with { Status = status },
|
||||||
|
PreviousStatus: tenant.Status);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Новый одноразовый пароль владельца: 16 url-safe символов (общий UrlSafeToken, Security review C36).
|
// Новый одноразовый пароль владельца: 16 url-safe символов (общий UrlSafeToken, Security review C36).
|
||||||
|
|||||||
@@ -54,6 +54,51 @@ public static class DealMetrics
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public const string AiBudgetUsedRatioName = "deal.ai.budget.used.ratio";
|
public const string AiBudgetUsedRatioName = "deal.ai.budget.used.ratio";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Имя метрики успешных вызовов платного ИИ
|
||||||
|
/// </summary>
|
||||||
|
public const string AiCallsName = "deal.ai.calls";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Имя метрики токенов платного ИИ
|
||||||
|
/// </summary>
|
||||||
|
public const string AiTokensName = "deal.ai.tokens";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Имя метрики вызовов локального ML
|
||||||
|
/// </summary>
|
||||||
|
public const string MlCallsName = "deal.ml.calls";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Имя метрики оценки токенов локального ML
|
||||||
|
/// </summary>
|
||||||
|
public const string MlTokensName = "deal.ml.tokens";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Имя метрики событий аудита
|
||||||
|
/// </summary>
|
||||||
|
public const string AuditEventsName = "deal.audit.events";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Имя метрики событий подозрительной активности
|
||||||
|
/// </summary>
|
||||||
|
public const string SecurityEventsName = "deal.security.suspicious";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Имя метрики глубины очереди пайплайна
|
||||||
|
/// </summary>
|
||||||
|
public const string PipelineQueueDepthName = "deal.pipeline.queue.depth";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Имя метрики глубины очереди обучения ML
|
||||||
|
/// </summary>
|
||||||
|
public const string MlOutboxDepthName = "deal.ml.outbox.depth";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Имя метрики активных сессий
|
||||||
|
/// </summary>
|
||||||
|
public const string ActiveSessionsName = "deal.sessions.active";
|
||||||
|
|
||||||
// Meter прикладных метрик (static — один на процесс, как того требует System.Diagnostics.Metrics).
|
// Meter прикладных метрик (static — один на процесс, как того требует System.Diagnostics.Metrics).
|
||||||
private static readonly Meter Meter = new(MeterName);
|
private static readonly Meter Meter = new(MeterName);
|
||||||
|
|
||||||
@@ -61,44 +106,44 @@ public static class DealMetrics
|
|||||||
/// Успешные вызовы платного ИИ
|
/// Успешные вызовы платного ИИ
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static readonly Counter<long> AiCalls =
|
public static readonly Counter<long> AiCalls =
|
||||||
Meter.CreateCounter<long>("deal.ai.calls", description: "Успешные вызовы платного ИИ (ai-service).");
|
Meter.CreateCounter<long>(AiCallsName, description: "Успешные вызовы платного ИИ (ai-service).");
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Токены платного ИИ по видам
|
/// Токены платного ИИ по видам
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static readonly Counter<long> AiTokens =
|
public static readonly Counter<long> AiTokens =
|
||||||
Meter.CreateCounter<long>("deal.ai.tokens", description: "Токены платных ИИ-вызовов (prompt/completion).");
|
Meter.CreateCounter<long>(AiTokensName, description: "Токены платных ИИ-вызовов (prompt/completion).");
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Вызовы локального ML
|
/// Вызовы локального ML
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static readonly Counter<long> MlCalls =
|
public static readonly Counter<long> MlCalls =
|
||||||
Meter.CreateCounter<long>("deal.ml.calls", description: "Вызовы локального ML-предсказания.");
|
Meter.CreateCounter<long>(MlCallsName, description: "Вызовы локального ML-предсказания.");
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Оценка токенов локальных ML-вызовов
|
/// Оценка токенов локальных ML-вызовов
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static readonly Counter<long> MlTokens =
|
public static readonly Counter<long> MlTokens =
|
||||||
Meter.CreateCounter<long>("deal.ml.tokens", description: "Оценка токенов локальных ML-вызовов.");
|
Meter.CreateCounter<long>(MlTokensName, description: "Оценка токенов локальных ML-вызовов.");
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// События аудита по типам и акторам
|
/// События аудита по типам и акторам
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static readonly Counter<long> AuditEvents =
|
public static readonly Counter<long> AuditEvents =
|
||||||
Meter.CreateCounter<long>("deal.audit.events", description: "Записи аудита по типам и акторам.");
|
Meter.CreateCounter<long>(AuditEventsName, description: "Записи аудита по типам и акторам.");
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// События подозрительной активности по видам
|
/// События подозрительной активности по видам
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static readonly Counter<long> SecurityEvents =
|
public static readonly Counter<long> SecurityEvents =
|
||||||
Meter.CreateCounter<long>("deal.security.suspicious", description: "События подозрительной активности по видам (rate_limit, login_blocked).");
|
Meter.CreateCounter<long>(SecurityEventsName, description: "События подозрительной активности по видам (rate_limit, login_blocked).");
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Суммарная глубина очереди пайплайна
|
/// Суммарная глубина очереди пайплайна
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static readonly ObservableGauge<long> PipelineQueueDepth =
|
public static readonly ObservableGauge<long> PipelineQueueDepth =
|
||||||
Meter.CreateObservableGauge(
|
Meter.CreateObservableGauge(
|
||||||
"deal.pipeline.queue.depth",
|
PipelineQueueDepthName,
|
||||||
(Func<long>)(() => (long)Interlocked.Read(ref DealMetrics._pipelineQueueDepth)),
|
(Func<long>)(() => (long)Interlocked.Read(ref DealMetrics._pipelineQueueDepth)),
|
||||||
description: "Суммарная глубина очереди пайплайна (new+filtered) по всем тенантам.");
|
description: "Суммарная глубина очереди пайплайна (new+filtered) по всем тенантам.");
|
||||||
|
|
||||||
@@ -107,7 +152,7 @@ public static class DealMetrics
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public static readonly ObservableGauge<long> MlOutboxDepth =
|
public static readonly ObservableGauge<long> MlOutboxDepth =
|
||||||
Meter.CreateObservableGauge(
|
Meter.CreateObservableGauge(
|
||||||
"deal.ml.outbox.depth",
|
MlOutboxDepthName,
|
||||||
(Func<long>)(() => (long)Interlocked.Read(ref DealMetrics._mlOutboxDepth)),
|
(Func<long>)(() => (long)Interlocked.Read(ref DealMetrics._mlOutboxDepth)),
|
||||||
description: "Суммарная глубина очереди обучения ML (MlOutbox) по всем тенантам.");
|
description: "Суммарная глубина очереди обучения ML (MlOutbox) по всем тенантам.");
|
||||||
|
|
||||||
@@ -116,7 +161,7 @@ public static class DealMetrics
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public static readonly ObservableGauge<long> ActiveSessions =
|
public static readonly ObservableGauge<long> ActiveSessions =
|
||||||
Meter.CreateObservableGauge(
|
Meter.CreateObservableGauge(
|
||||||
"deal.sessions.active",
|
ActiveSessionsName,
|
||||||
(Func<long>)(() => (long)Interlocked.Read(ref DealMetrics._activeSessions)),
|
(Func<long>)(() => (long)Interlocked.Read(ref DealMetrics._activeSessions)),
|
||||||
description: "Активные непросроченные сессии пользователей и операторов.");
|
description: "Активные непросроченные сессии пользователей и операторов.");
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
namespace Deal.SharedKernel.Utilities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Строковые представления булевых значений
|
||||||
|
/// </summary>
|
||||||
|
public static class BoolText
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Истина
|
||||||
|
/// </summary>
|
||||||
|
public const string True = "true";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ложь
|
||||||
|
/// </summary>
|
||||||
|
public const string False = "false";
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
using Deal.Api.Services;
|
||||||
|
using Deal.Infrastructure.Data;
|
||||||
|
using Deal.Modules.Kanban.Application.Models;
|
||||||
|
using Deal.Modules.Tenants.Application.Models;
|
||||||
|
using Deal.SharedKernel.Tenants.Abstractions;
|
||||||
|
using Deal.Tests.Unit.Support;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
|
||||||
|
namespace Deal.Tests.Unit.Api;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Юнит-тесты резолвера ссылок аудита
|
||||||
|
/// </summary>
|
||||||
|
public sealed class AuditReferenceResolverTests
|
||||||
|
{
|
||||||
|
private static readonly Guid TenantGuid = Guid.Parse("00000000-0000-0000-0000-000000000001");
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Resolve_ResolvesCardTitleAndContainerName()
|
||||||
|
{
|
||||||
|
TestKanjStore kanban = new();
|
||||||
|
kanban.SeedCard(new CardDto { Id = "c_1", Title = "WPF заказ", Col = "b_1" });
|
||||||
|
kanban.SeedBoard(new ContainerDto { Id = "b_1", Name = "WPF" });
|
||||||
|
using ServiceProvider provider = BuildProvider(kanban);
|
||||||
|
var resolver = new AuditReferenceResolver(
|
||||||
|
provider.GetRequiredService<IServiceScopeFactory>(),
|
||||||
|
NullLogger<AuditReferenceResolver>.Instance);
|
||||||
|
|
||||||
|
var record = Record("""{"changes":[{"field":"cardId","to":"c_1"},{"field":"to","to":"b_1"}]}""");
|
||||||
|
IReadOnlyDictionary<string, string> names =
|
||||||
|
await ((IAuditReferenceResolver)resolver).ResolveAsync([record], CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal("WPF заказ", names["c_1"]);
|
||||||
|
Assert.Equal("WPF", names["b_1"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Resolve_UnknownIds_ReturnsOnlyKnownNames()
|
||||||
|
{
|
||||||
|
TestKanjStore kanban = new();
|
||||||
|
using ServiceProvider provider = BuildProvider(kanban);
|
||||||
|
var resolver = new AuditReferenceResolver(
|
||||||
|
provider.GetRequiredService<IServiceScopeFactory>(),
|
||||||
|
NullLogger<AuditReferenceResolver>.Instance);
|
||||||
|
|
||||||
|
var record = Record("""{"changes":[{"field":"cardId","to":"c_missing"}]}""");
|
||||||
|
IReadOnlyDictionary<string, string> names =
|
||||||
|
await ((IAuditReferenceResolver)resolver).ResolveAsync([record], CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Empty(names);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Resolve_WithoutTenant_ReturnsEmpty()
|
||||||
|
{
|
||||||
|
TestKanjStore kanban = new();
|
||||||
|
using ServiceProvider provider = BuildProvider(kanban);
|
||||||
|
var resolver = new AuditReferenceResolver(
|
||||||
|
provider.GetRequiredService<IServiceScopeFactory>(),
|
||||||
|
NullLogger<AuditReferenceResolver>.Instance);
|
||||||
|
|
||||||
|
var record = Record("""{"changes":[{"field":"cardId","to":"c_1"}]}""", tenantId: null);
|
||||||
|
IReadOnlyDictionary<string, string> names =
|
||||||
|
await ((IAuditReferenceResolver)resolver).ResolveAsync([record], CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Empty(names);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ServiceProvider BuildProvider(TestKanjStore kanban)
|
||||||
|
{
|
||||||
|
var services = new ServiceCollection();
|
||||||
|
services.AddSingleton<ITenantContext, TenantContext>();
|
||||||
|
services.AddSingleton(kanban.Store);
|
||||||
|
return services.BuildServiceProvider();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static AuditRecordDto Record(string detailJson, Guid? tenantId = null) =>
|
||||||
|
new(
|
||||||
|
AuditEvents.CardMoved,
|
||||||
|
AuditActorTypes.Tenant,
|
||||||
|
ActorId: null,
|
||||||
|
TenantId: tenantId ?? TenantGuid,
|
||||||
|
Ip: null,
|
||||||
|
DetailJson: detailJson);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user