26 Commits
Author SHA1 Message Date
rust 44d2ee61f7 Merge pull request 'Операторский аудит: понятные тексты действий и изменений вместо JSON' (#27) from t25_audit_readable into main
ci / build-test (push) Successful in 3m28s
Reviewed-on: #27
2026-09-13 21:01:40 +03:00
stepan 28cf8f23ca Починить сборку сервисов в Docker после ссылок на общие контракты
ci / build-test (pull_request) Successful in 2m57s
Dockerfile ai/ml/storage/telegram копируют core/Deal.SharedKernel (нужен Deal.Grpc.Hosting), telegram — ещё core/Deal.Contracts (каталоги операций и фаз Telegram).
2026-09-13 20:54:23 +03:00
stepan b190ac75b1 Использовать каталоги операций и фаз Telegram
ci / build-test (pull_request) Canceled after 2m33s
2026-09-13 20:51:43 +03:00
stepan b7c5e13fc3 Вынести виды файлов Storage в каталог 2026-09-13 20:51:42 +03:00
stepan 04d7a0f6fb Вынести wire-ключи и операции ИИ-сервиса в каталоги 2026-09-13 20:51:42 +03:00
stepan 1fbe29a838 Вынести магические строки статусов, видов и ключей в каталоги
Литералы статусов/видов/фаз и wire-ключей заменены каталогами: значения сущностей и репозиториев — TenantStatuses/TenantLimitPeriods/InviteStatuses/ContainerKinds/ContainerSpaces/CardIds/Discovery*/PipelineQueueStatuses; контакты и виды вложений — CardContactTypes/CardFileKinds; источники и этапы отсева — PipelineRejectStages/PipelineRejectSources; ключи промптов/провайдеров — SettingsFieldKeys; wire-ключи канбана — KanbanWireKeys; режимы правил — ColumnRuleModes; операции/фазы/виды диалогов/контакты Telegram и общий BoolText — в Deal.Contracts/Deal.SharedKernel; имена метрик — DealMetrics. Значения не менялись.
2026-09-13 20:51:36 +03:00
stepan 8dde35de49 Использовать каталог AuditFields в тестах
ci / build-test (pull_request) Successful in 2m59s
2026-09-13 20:32:24 +03:00
stepan 71bbdd4dd5 Вынести булевы литералы аудита в константы 2026-09-13 20:32:24 +03:00
stepan 482b345438 Вынести коды параметров аудита и ключи JSON в константы
Магические строки кодов деталей аудита заменены каталогом AuditFields (68 мест), ключи элемента изменения и разбора JSON — константами, ключи хранилища ключей Telegram и геометрия маски — именованными константами.
2026-09-13 20:32:11 +03:00
stepan 6a88032414 Уточнить документацию аудита: логин реального пользователя
ci / build-test (pull_request) Successful in 3m12s
2026-09-13 20:23:19 +03:00
stepan 525759b232 Проверить логин владельца в ответе аудита
Подставка хранилища пользователей умеет FindOwnerLoginsByTenantIdsAsync; тест аудита проверяет userName.
2026-09-13 20:23:19 +03:00
stepan a99bb4a713 Показывать реального пользователя в аудите и лимитах
Добавлен батч-резолв логинов владельцев пространств (IAuthStore.FindOwnerLoginsByTenantIdsAsync). Ответы аудита дополнены userName (владелец пространства либо логин попытки), ответы лимитов — ownerLogin; имя пространства остаётся вторичным.
2026-09-13 20:23:12 +03:00
stepan c46bbe9332 Вывести логин пользователя в консоли оператора
В аудите и лимитах основной строкой показывается логин реального пользователя, имя пространства и id — вторично.
2026-09-13 20:22:59 +03:00
stepan ac2bf4b302 Описать контракт деталей аудита в документации
ci / build-test (pull_request) Successful in 2m58s
2026-09-13 19:18:16 +03:00
stepan 4a6fa374c5 Обновить тесты аудита под контракт изменений
HTTP-тесты аудита и лимитов проверяют changes вместо плоского detailJson; подставки реестра умеют FindNamesByIdsAsync. Добавлены тесты разбора деталей (новый формат, старый плоский, повреждённый JSON) и проверка ответа аудита с именем пользователя и изменениями.
2026-09-13 19:18:16 +03:00
stepan ad5fa7c083 Показать понятный аудит в консоли оператора
Названия событий, акторов, параметров и значений вынесены в ресурсы ru.js (operator.event/actor/field/value) с заделом под языки. Таблица аудита показывает понятное название действия, сырой ключ вторично, изменения «было → стало» и сырой JSON под спойлером. В колонке пользователя основное — имя, id мелко под ним. Термин «тенант» в UI оператора заменён на «Пользователь».
2026-09-13 19:18:06 +03:00
stepan 02a4f9c749 Добавить контракт изменений деталей аудита
Детали событий описываются списком изменений «параметр: было → стало» (AuditChangeDto, AuditDetails), а не произвольным JSON. Запись везде идёт через конструктор AuditDetails; разбор старого плоского формата сохранён. Операторские ручки аудита отдают AuditRecordViewDto с changes и именем пользователя (батч-резолв через ITenantRepository.FindNamesByIdsAsync). Детектор подозрительной активности читает логин через новый разбор.
2026-09-13 19:17:57 +03:00
rust 585397b9a3 Merge pull request 'Перевести оставшиеся «не найдено» в доменных сервисах на DealException' (#26) from t21_notfound_exceptions into main
ci / build-test (push) Successful in 2m56s
Reviewed-on: #26
2026-09-13 18:58:39 +03:00
stepan 75de70e09c Перевести «не найдено» пользователя у оператора на исключение
ci / build-test (pull_request) Successful in 3m7s
TenantAdminService.GetAsync бросает NotFoundException вместо null; эндпоинт деталей без 404-проверки.
2026-09-13 18:49:18 +03:00
stepan 891a894aed Перевести «не найдено» ML/Pipeline на NotFoundException
MlReviewService.ApplyAsync (исходное сообщение) и PipelineProcessingService.ReturnAsync (запись отсева) бросают NotFoundException; эндпоинты без 404-проверок.
2026-09-13 18:44:33 +03:00
stepan f93fb0fdd3 Перевести «не найдено» кандидатов Discovery на исключения
DiscoveryCandidatesService (get/set/status/mark_joined/mark_rejected/add при отсутствии задачи) бросает NotFoundException; skip-ветки add оставлены null. Эндпоинты без локальных 404-проверок.
2026-09-13 18:27:18 +03:00
stepan d2d6b81aa0 Перевести «не найдено» карточек на NotFoundException
CardsService (get/move/links/reminders) и CardMover бросают NotFoundException вместо null/CardResultDto(null,null)/Exists=false. CardMoveResultDto — только Error; эндпоинты без локальных 404-проверок.
2026-09-13 18:23:53 +03:00
rust a9bfecf9cc Merge pull request 'Поднять dev-стек в Docker (фронт + observability) и починить подключение Telegram' (#24) from t23_dev_stack_telegram into main
ci / build-test (push) Successful in 2m55s
Reviewed-on: #24
2026-09-13 17:58:14 +03:00
stepan 13d7994511 Обновить тест статуса Telegram под новую семантику
ci / build-test (pull_request) Successful in 2m50s
connected = phase ready; живой account приоритетнее KV tgAccount.
2026-09-13 16:27:19 +03:00
stepan a9f2b3a1ef Починить QR-вход и реактивный статус Telegram
ci / build-test (pull_request) Failing after 1m58s
tgStart ждёт старт QR до показа картинки (устранена гонка с 404). Вкладка Telegram опрашивает статус и обновляет QR по таймеру, не останавливаясь до авторизации. Статус: account берётся из живого статуса сервиса, connected = phase ready (авторизация), а не транспорт.
2026-09-13 16:22:20 +03:00
stepan a9bcd7c5f7 Поднять фронт и observability в dev-стеке
Сервис frontend: сборка SPA (Vite) и отдача через Caddy, /api → core:5080. В профиль observability добавлены Loki/Promtail/Grafana (в dev их не было). Dev-Caddyfile отдаёт SPA с Cache-Control no-cache, чтобы пересборка подхватывалась обновлением страницы.
2026-09-13 16:22:16 +03:00
147 changed files with 2654 additions and 769 deletions
+19
View File
@@ -0,0 +1,19 @@
# Dev-edge «Дейла» (compose.dev.yml, сервис frontend): SPA + /api на core.
# Отличие от prod-Caddyfile: HTTP без TLS и без плейсхолдер-домена — для локального просмотра UI.
# Статика — собранный SPA (Vite) в /srv, неизвестные пути отдают index.html (история браузера).
:80 {
# API core: /api/* уходит на core:5080 без перезаписи (контракт /api неизменен).
# SSE (/api/events), файлы и QR-SVG проходят reverse_proxy потоково.
handle /api/* {
reverse_proxy core:5080
}
handle {
# SPA/ассеты в dev не кэшируем: пересборка фронта должна подхватываться по F5.
header Cache-Control "no-cache"
root * /srv
try_files {path} /index.html
file_server
}
}
+65
View File
@@ -254,6 +254,18 @@ services:
timeout: 3s
retries: 10
# Фронтенд (SPA) — сборка образа (Vite) и отдача через Caddy; /api → core:5080. UI — http://localhost:8080.
frontend:
build:
context: ..
dockerfile: src/frontend/Dockerfile
container_name: deal-frontend
ports:
- "8080:80"
depends_on:
core:
condition: service_healthy
# Prometheus (профиль observability, этап 12/пакет A) — сбор /metrics всех 4 процессов (:9464)
# внутри dev-сети. Подъём: docker compose -f deploy/compose.dev.yml --profile observability up -d.
# Конфиг — общий deploy/observability/prometheus.yml (те же имена сервисов и таргеты). UI — 9090.
@@ -332,6 +344,56 @@ services:
- /:/host/root:ro
pid: host
# Loki — хранилище логов (профиль observability), UI/API — :3100.
loki:
image: grafana/loki:3.4.2
container_name: deal-loki
profiles: ["observability"]
command: -config.file=/etc/loki/loki.yml
ports:
- "3100:3100"
volumes:
- ./observability/loki.yml:/etc/loki/loki.yml:ro
- deal_loki_data:/loki
# Promtail — сбор docker-логов deal-процессов в Loki (docker.sock, профиль observability).
promtail:
image: grafana/promtail:3.4.2
container_name: deal-promtail
profiles: ["observability"]
command: -config.file=/etc/promtail/promtail.yml
volumes:
- ./observability/promtail.yml:/etc/promtail/promtail.yml:ro
- /var/run/docker.sock:/var/run/docker.sock:ro
- deal_promtail_data:/var/lib/promtail
depends_on:
loki:
condition: service_started
# Grafana — UI логов/метрик/трейсов (профиль observability), локальный вход admin/admin.
grafana:
image: grafana/grafana:11.5.2
container_name: deal-grafana
profiles: ["observability"]
environment:
GF_SECURITY_ADMIN_USER: admin
GF_SECURITY_ADMIN_PASSWORD: admin
GF_USERS_ALLOW_SIGN_UP: "false"
GF_AUTH_ANONYMOUS_ENABLED: "false"
ports:
- "3001:3000"
volumes:
- ./observability/grafana/provisioning:/etc/grafana/provisioning:ro
- ./observability/grafana/dashboards:/var/lib/grafana/dashboards:ro
- deal_grafana_data:/var/lib/grafana
depends_on:
loki:
condition: service_started
prometheus:
condition: service_started
tempo:
condition: service_started
volumes:
deal_pgdata:
deal_minio_data:
@@ -340,3 +402,6 @@ volumes:
deal_api_data:
deal_prometheus_data:
deal_tempo_data:
deal_loki_data:
deal_promtail_data:
deal_grafana_data:
@@ -149,8 +149,14 @@
"actorType": "tenant",
"actorId": "1f2e3d4c-5b6a-7980-1234-56789abcdef0",
"tenantId": "aabbccdd-eeff-0011-2233-445566778899",
"userName": "owner@example.com",
"tenantName": "ООО «Ромашка»",
"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",
"id": 1042
}
@@ -162,7 +168,19 @@
```
- `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`.
+27
View File
@@ -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";
}
+4 -4
View File
@@ -150,7 +150,7 @@ public sealed class AiServiceImpl : AiService.AiServiceBase
}
catch (LlmCallException callError)
{
LogAiUnavailable("filter", tenantId, config, callError);
LogAiUnavailable(AiOperations.Filter, tenantId, config, callError);
throw ToUnavailable(callError);
}
}
@@ -191,7 +191,7 @@ public sealed class AiServiceImpl : AiService.AiServiceBase
}
catch (LlmCallException callError)
{
LogAiUnavailable("classify", tenantId, config, callError);
LogAiUnavailable(AiOperations.Classify, tenantId, config, callError);
throw ToUnavailable(callError);
}
}
@@ -226,7 +226,7 @@ public sealed class AiServiceImpl : AiService.AiServiceBase
}
catch (LlmCallException callError)
{
LogAiUnavailable("generate_keywords", tenantId, config, callError);
LogAiUnavailable(AiOperations.GenerateKeywords, tenantId, config, callError);
throw ToUnavailable(callError);
}
}
@@ -267,7 +267,7 @@ public sealed class AiServiceImpl : AiService.AiServiceBase
}
catch (LlmCallException callError)
{
LogAiUnavailable("evaluate_fit", tenantId, config, callError);
LogAiUnavailable(AiOperations.EvaluateFit, tenantId, config, callError);
throw ToUnavailable(callError);
}
}
+2
View File
@@ -12,6 +12,7 @@ WORKDIR /repo
# Restore-слой: только csproj/props (кэш слоёв Docker — restore не повторяется при правке исходников).
COPY src/contracts/Deal.Proto.csproj src/contracts/
COPY src/grpc-hosting/Deal.Grpc.Hosting/Deal.Grpc.Hosting.csproj src/grpc-hosting/Deal.Grpc.Hosting/
COPY src/core/Deal.SharedKernel/Deal.SharedKernel.csproj src/core/Deal.SharedKernel/
COPY src/ai-service/Directory.Build.props src/ai-service/
COPY src/ai-service/Deal.Ai/Deal.Ai.csproj src/ai-service/Deal.Ai/
RUN dotnet restore src/ai-service/Deal.Ai/Deal.Ai.csproj
@@ -19,6 +20,7 @@ RUN dotnet restore src/ai-service/Deal.Ai/Deal.Ai.csproj
# Исходники: контракты (.proto) + общая gRPC-обвязка + проект сервиса.
COPY src/contracts/ src/contracts/
COPY src/grpc-hosting/ src/grpc-hosting/
COPY src/core/Deal.SharedKernel/ src/core/Deal.SharedKernel/
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
@@ -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";
}
+25 -25
View File
@@ -100,12 +100,12 @@ public sealed class LlmHttpClient : IProviderClient
var body = new JsonObject
{
["model"] = config.Model,
["messages"] = new JsonArray(
ChatMessage("system", systemPrompt),
ChatMessage("user", userText)),
["temperature"] = Temperature,
["max_tokens"] = MaxResponseTokens,
[LlmWireKeys.Model] = config.Model,
[LlmWireKeys.Messages] = new JsonArray(
ChatMessage(LlmChatRoles.System, systemPrompt),
ChatMessage(LlmChatRoles.User, userText)),
[LlmWireKeys.Temperature] = Temperature,
[LlmWireKeys.MaxTokens] = MaxResponseTokens,
};
request.Content = JsonBody(body);
@@ -123,10 +123,10 @@ public sealed class LlmHttpClient : IProviderClient
var body = new JsonObject
{
["model"] = config.Model,
["max_tokens"] = MaxResponseTokens,
["system"] = systemPrompt,
["messages"] = new JsonArray(ChatMessage("user", userText)),
[LlmWireKeys.Model] = config.Model,
[LlmWireKeys.MaxTokens] = MaxResponseTokens,
[LlmWireKeys.System] = systemPrompt,
[LlmWireKeys.Messages] = new JsonArray(ChatMessage(LlmChatRoles.User, userText)),
};
request.Content = JsonBody(body);
@@ -156,7 +156,7 @@ public sealed class LlmHttpClient : IProviderClient
private static ProviderChatResult ReadOpenAiBody(string 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)
{
// Тип сбоя без содержимого тела (замечание code-review: тело ответа наружу/в лог не уходит).
@@ -164,14 +164,14 @@ public sealed class LlmHttpClient : IProviderClient
}
// choices[i] — объект выбора {message, finish_reason, …}; текст — в message.content.
JsonObject? message = (choices[0] as JsonObject)?["message"] as JsonObject;
string? content = ReadStringField(message, "content");
if (string.IsNullOrEmpty(content) && !string.IsNullOrEmpty(ReadStringField(message, "reasoning_content")))
JsonObject? message = (choices[0] as JsonObject)?[LlmWireKeys.Message] as JsonObject;
string? content = ReadStringField(message, LlmWireKeys.Content);
if (string.IsNullOrEmpty(content) && !string.IsNullOrEmpty(ReadStringField(message, LlmWireKeys.ReasoningContent)))
{
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)
@@ -179,18 +179,18 @@ public sealed class LlmHttpClient : IProviderClient
JsonObject? payload = ParseObjectOrThrow(body);
var text = new StringBuilder();
if (payload["content"] is JsonArray contentBlocks)
if (payload[LlmWireKeys.Content] is JsonArray contentBlocks)
{
foreach (JsonNode? blockNode in contentBlocks)
{
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.
@@ -202,9 +202,9 @@ public sealed class LlmHttpClient : IProviderClient
return null;
}
int? promptTokens = ReadIntField(usage, "prompt_tokens");
int? completionTokens = ReadIntField(usage, "completion_tokens");
int? totalTokens = ReadIntField(usage, "total_tokens");
int? promptTokens = ReadIntField(usage, LlmWireKeys.PromptTokens);
int? completionTokens = ReadIntField(usage, LlmWireKeys.CompletionTokens);
int? totalTokens = ReadIntField(usage, LlmWireKeys.TotalTokens);
return promptTokens is null || completionTokens is null || totalTokens is null
? null
: new ProviderUsage(promptTokens.Value, completionTokens.Value, totalTokens.Value);
@@ -219,8 +219,8 @@ public sealed class LlmHttpClient : IProviderClient
return null;
}
int? inputTokens = ReadIntField(usage, "input_tokens");
int? outputTokens = ReadIntField(usage, "output_tokens");
int? inputTokens = ReadIntField(usage, LlmWireKeys.InputTokens);
int? outputTokens = ReadIntField(usage, LlmWireKeys.OutputTokens);
return inputTokens is null || outputTokens is null
? null
: 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)
=> new()
{
["role"] = role,
["content"] = content,
[LlmWireKeys.Role] = role,
[LlmWireKeys.Content] = content,
};
// Создаёт JSON-содержимое запроса (application/json).
+92
View File
@@ -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)
{
return new AiConfigSetting(
ApiKey: ReadField(entry, "apiKey") ?? defaults.ApiKey,
BaseUrl: ReadField(entry, "baseUrl") ?? defaults.BaseUrl,
Model: ReadField(entry, "model") ?? defaults.Model);
ApiKey: ReadField(entry, SettingsFieldKeys.ApiKey) ?? defaults.ApiKey,
BaseUrl: ReadField(entry, SettingsFieldKeys.BaseUrl) ?? defaults.BaseUrl,
Model: ReadField(entry, SettingsFieldKeys.Model) ?? defaults.Model);
}
}
catch (JsonException)
+5 -5
View File
@@ -68,7 +68,7 @@ public static class AuthEndpoints
ActorId: result.UserId,
TenantId: result.TenantId,
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);
}
@@ -84,7 +84,7 @@ public static class AuthEndpoints
ActorId: null,
TenantId: null,
Ip: ClientIp(context),
DetailJson: AuditService.ToDetailJson(new { login = attemptedLogin })), ct);
DetailJson: AuditService.ToDetailJson([AuditDetails.Set(AuditFields.Login, attemptedLogin)])), ct);
}
return EndpointResults.Unauthorized(InvalidCredentialsDetail);
@@ -98,7 +98,7 @@ public static class AuthEndpoints
ActorId: result.UserId,
TenantId: result.TenantId,
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);
return Results.Ok(new { ok = true, login = result.Login });
@@ -125,12 +125,12 @@ public static class AuthEndpoints
ActorId: logout.OperatorId,
TenantId: logout.TenantId,
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)
{
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);
@@ -120,7 +120,7 @@ public static class CardDetailsEndpoints
CardsService service = context.RequestServices.GetRequiredService<CardsService>();
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);
}
@@ -202,9 +202,7 @@ public static class CardDetailsEndpoints
return EndpointResults.BadRequest(result.Error);
}
return result.Card is null
? EndpointResults.NotFound(CardNotFoundDetail)
: await ReadCardAsync(context, cardId, ct);
return await ReadCardAsync(context, cardId, ct);
}
// DELETE /api/cards/{cardId}/links/{linkId}: удалить ссылку. Ответ — карточка.
@@ -220,10 +218,8 @@ public static class CardDetailsEndpoints
}
CardsService service = context.RequestServices.GetRequiredService<CardsService>();
CardResultDto result = await service.RemoveLinkAsync(cardId, linkId, ct);
return result.Card is null
? EndpointResults.NotFound(CardNotFoundDetail)
: await ReadCardAsync(context, cardId, ct);
await service.RemoveLinkAsync(cardId, linkId, ct);
return await ReadCardAsync(context, cardId, ct);
}
// POST /api/cards/{cardId}/files: загрузка вложений (multipart/form-data, поле files).
@@ -240,10 +236,7 @@ public static class CardDetailsEndpoints
}
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
if (await cardsService.GetCardAsync(cardId, ct) is null)
{
return EndpointResults.NotFound(CardNotFoundDetail);
}
await cardsService.GetCardAsync(cardId, ct);
IFormCollection form;
try
@@ -357,9 +350,7 @@ public static class CardDetailsEndpoints
return EndpointResults.BadRequest(result.Error);
}
return result.Card is null
? EndpointResults.NotFound(CardNotFoundDetail)
: await ReadCardAsync(context, cardId, ct);
return await ReadCardAsync(context, cardId, ct);
}
// DELETE /api/cards/{cardId}/reminder: снять напоминание. Ответ — карточка.
@@ -374,9 +365,8 @@ public static class CardDetailsEndpoints
}
CardsService service = context.RequestServices.GetRequiredService<CardsService>();
return await service.ClearReminderAsync(cardId, ct)
? await ReadCardAsync(context, cardId, ct)
: EndpointResults.NotFound(CardNotFoundDetail);
await service.ClearReminderAsync(cardId, ct);
return await ReadCardAsync(context, cardId, ct);
}
// POST /api/cards/{cardId}/reminder/snooze: «напомнить позже» (now + 24 ч). Ответ — карточка.
@@ -391,9 +381,8 @@ public static class CardDetailsEndpoints
}
CardsService service = context.RequestServices.GetRequiredService<CardsService>();
return await service.SnoozeReminderAsync(cardId, ct)
? await ReadCardAsync(context, cardId, ct)
: EndpointResults.NotFound(CardNotFoundDetail);
await service.SnoozeReminderAsync(cardId, ct);
return await ReadCardAsync(context, cardId, ct);
}
// Читает карточку через единый сервис и возвращает её как ответ (404 — карточки нет).
@@ -412,11 +401,7 @@ public static class CardDetailsEndpoints
CancellationToken ct)
{
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
CardDto? card = await cardsService.GetCardAsync(cardId, ct);
if (card is null)
{
return EndpointResults.NotFound(CardNotFoundDetail);
}
CardDto card = await cardsService.GetCardAsync(cardId, ct);
SourceContent content = await cardsService.ResolveSourceAsync(card, ct);
return Results.Ok(content);
@@ -428,10 +413,8 @@ public static class CardDetailsEndpoints
CancellationToken ct)
{
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
CardDto? card = await cardsService.GetCardAsync(cardId, ct);
return card is null
? EndpointResults.NotFound(CardNotFoundDetail)
: Results.Ok(card);
CardDto card = await cardsService.GetCardAsync(cardId, ct);
return Results.Ok(card);
}
// Имя файла для Content-Disposition без кавычек «"».
+20 -28
View File
@@ -105,15 +105,15 @@ public static class CardsEndpoints
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
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)
{
wire[col] = column;
}
wire["learning"] = counts.Learning;
wire["ml"] = counts.Ml;
wire["ai"] = counts.Ai;
wire[KanbanWireKeys.Learning] = counts.Learning;
wire[KanbanWireKeys.Ml] = counts.Ml;
wire[KanbanWireKeys.Ai] = counts.Ai;
return Results.Ok(wire);
}
@@ -128,10 +128,8 @@ public static class CardsEndpoints
}
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
CardDto? card = await cardsService.GetCardAsync(cardId, ct);
return card is null
? EndpointResults.NotFound(CardNotFoundDetail)
: Results.Ok(card);
CardDto card = await cardsService.GetCardAsync(cardId, ct);
return Results.Ok(card);
}
private static async Task<IResult> MarkAllSeenAsync(HttpContext context, CancellationToken ct)
@@ -184,18 +182,11 @@ public static class CardsEndpoints
return EndpointResults.BadRequest(outcome.Error);
}
if (!outcome.Exists)
{
return EndpointResults.NotFound(CardNotFoundDetail);
}
await AuditAppender.AppendTenantAsync(context, AuditEvents.CardMoved, new { cardId, to = body.To }, ct);
await AuditAppender.AppendTenantAsync(context, AuditEvents.CardMoved, [AuditDetails.Set(AuditFields.CardId, cardId), AuditDetails.Set(AuditFields.Destination, body.To)], ct);
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
CardDto? unified = await cardsService.GetCardAsync(cardId, ct);
return unified is null
? EndpointResults.NotFound(CardNotFoundDetail)
: Results.Ok(unified);
CardDto unified = await cardsService.GetCardAsync(cardId, ct);
return Results.Ok(unified);
}
private static async Task<IResult> TrashAsync(
@@ -211,7 +202,7 @@ public static class CardsEndpoints
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
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 });
}
@@ -228,7 +219,7 @@ public static class CardsEndpoints
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
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 });
}
@@ -249,7 +240,7 @@ public static class CardsEndpoints
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 });
}
@@ -293,7 +284,7 @@ public static class CardsEndpoints
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 });
}
@@ -353,11 +344,7 @@ public static class CardsEndpoints
}
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
CardDto? card = await cardsService.GetCardAsync(cardId, ct);
if (card is null)
{
return EndpointResults.NotFound(CardNotFoundDetail);
}
CardDto card = await cardsService.GetCardAsync(cardId, ct);
CardReclassifier reclassifier = context.RequestServices.GetRequiredService<CardReclassifier>();
ReclassifyResultDto result = await reclassifier.ReclassifyCardAsync(card, ct);
@@ -414,7 +401,12 @@ public static class CardsEndpoints
return AuditAppender.AppendTenantAsync(
context,
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);
}
@@ -94,7 +94,7 @@ public static class ContainersEndpoints
Note: body.Note ?? string.Empty),
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 });
}
@@ -153,7 +153,7 @@ public static class ContainersEndpoints
patchBody.Policy),
ct);
await AuditAppender.AppendTenantAsync(context, AuditEvents.ContainerUpdated, new { id = updated.Id }, ct);
await AuditAppender.AppendTenantAsync(context, AuditEvents.ContainerUpdated, [AuditDetails.Set(AuditFields.ContainerId, updated.Id)], ct);
return Results.Ok(new { id = updated.Id });
}
@@ -171,7 +171,7 @@ public static class ContainersEndpoints
ContainersService containers = context.RequestServices.GetRequiredService<ContainersService>();
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);
}
@@ -189,7 +189,7 @@ public static class ContainersEndpoints
ContainersService containers = context.RequestServices.GetRequiredService<ContainersService>();
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 });
}
@@ -288,12 +288,12 @@ public static class ContainersEndpoints
var wire = new Dictionary<string, object>();
if (state.Collapsed is { } collapsed)
{
wire["collapsed"] = collapsed;
wire[KanbanWireKeys.Collapsed] = collapsed;
}
if (state.Width is not null)
{
wire["width"] = state.Width;
wire[KanbanWireKeys.Width] = state.Width;
}
return wire;
@@ -55,9 +55,6 @@ public static class DiscoveryEndpoints
// Путь лога задачи (GET).
private const string TaskLogPath = "/tasks/{task_id}/log";
// 404: кандидат не найден.
private const string CandidateNotFoundDetail = "Кандидат не найден";
private const string AlreadyJoinedDetail = "Уже вступили в этот источник";
private const string JoinedRejectDetail = "Уже вступили — удалите источник из каналов";
@@ -281,11 +278,7 @@ public static class DiscoveryEndpoints
}
DiscoveryCandidatesService candidates = context.RequestServices.GetRequiredService<DiscoveryCandidatesService>();
DiscoveryCandidateDto? row = await candidates.GetAsync(dialog_id, ct);
if (row is null)
{
return EndpointResults.NotFound(CandidateNotFoundDetail);
}
DiscoveryCandidateDto row = await candidates.GetAsync(dialog_id, ct);
if (row.Status == DiscoveryCandidateStatuses.Joined)
{
@@ -314,8 +307,8 @@ public static class DiscoveryEndpoints
try
{
DiscoveryCandidateDto? joined = await candidates.MarkJoinedAsync(dialog_id, auto: false, ct);
return joined is null ? EndpointResults.NotFound(CandidateNotFoundDetail) : Results.Ok(joined);
DiscoveryCandidateDto joined = await candidates.MarkJoinedAsync(dialog_id, auto: false, ct);
return Results.Ok(joined);
}
catch (DiscoveryValidationException exception)
{
@@ -334,11 +327,7 @@ public static class DiscoveryEndpoints
}
DiscoveryCandidatesService candidates = context.RequestServices.GetRequiredService<DiscoveryCandidatesService>();
DiscoveryCandidateDto? row = await candidates.GetAsync(dialog_id, ct);
if (row is null)
{
return EndpointResults.NotFound(CandidateNotFoundDetail);
}
DiscoveryCandidateDto row = await candidates.GetAsync(dialog_id, ct);
if (row.Status == DiscoveryCandidateStatuses.Joined)
{
@@ -347,8 +336,8 @@ public static class DiscoveryEndpoints
try
{
DiscoveryCandidateDto? rejected = await candidates.MarkRejectedAsync(dialog_id, ManualRejectReason, ct);
return rejected is null ? EndpointResults.NotFound(CandidateNotFoundDetail) : Results.Ok(rejected);
DiscoveryCandidateDto rejected = await candidates.MarkRejectedAsync(dialog_id, ManualRejectReason, ct);
return Results.Ok(rejected);
}
catch (DiscoveryValidationException exception)
{
+2 -1
View File
@@ -69,7 +69,8 @@ public static class JoinEndpoint
TenantId: result.TenantId,
Ip: ClientIp(context),
// Код инвайта — capability-токен: в аудит пишется только SHA-256-хэш (Security review).
DetailJson: AuditService.ToDetailJson(new { email = result.Login, codeHash = SessionTokens.HashToken(body.Code?.Trim() ?? string.Empty) })), ct);
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 });
}
+1 -8
View File
@@ -37,8 +37,6 @@ public static class MlEndpoints
private const string EnterTextDetail = "Введите текст";
private const string MessageNotFoundDetail = "Исходное сообщение не найдено";
/// <summary>
/// Регистрирует группу /api/ml
/// </summary>
@@ -141,12 +139,7 @@ public static class MlEndpoints
}
MlReviewService review = context.RequestServices.GetRequiredService<MlReviewService>();
MlApplyResult? result = await review.ApplyAsync(body.DialogId, body.MsgId, body.Action, ct);
if (result is null)
{
return EndpointResults.NotFound(MessageNotFoundDetail);
}
MlApplyResult result = await review.ApplyAsync(body.DialogId, body.MsgId, body.Action, ct);
if (result.Error is not null)
{
return EndpointResults.BadRequest(result.Error);
@@ -1,5 +1,6 @@
using Deal.Api.Extensions;
using Deal.Api.Services;
using Deal.Modules.Tenants.Application.Abstractions;
using Deal.Modules.Tenants.Application.Models;
using Deal.Modules.Tenants.Application.Services;
@@ -132,6 +133,8 @@ public static class OperatorAnalyticsEndpoints
int? offset,
HttpContext context,
AnalyticsService analyticsService,
ITenantRepository tenantRepository,
IAuthStore authStore,
CancellationToken ct)
{
if (context.GetCurrentOperator() is null)
@@ -141,7 +144,8 @@ public static class OperatorAnalyticsEndpoints
AnalyticsActivityDto activity = await analyticsService.ActivityAsync(
eventType, actorType, actorId, tenantId, from, to, limit, offset, ct);
return Results.Ok(activity);
IReadOnlyList<AuditRecordViewDto> view = await AuditViewFactory.ProjectAsync(activity.Items, tenantRepository, authStore, ct);
return Results.Ok(new { items = view, total = activity.Total, limit = activity.Limit, offset = activity.Offset });
}
/// <summary>
@@ -1,5 +1,6 @@
using Deal.Api.Extensions;
using Deal.Api.Services;
using Deal.Modules.Tenants.Application.Abstractions;
using Deal.Modules.Tenants.Application.Models;
using Deal.Modules.Tenants.Application.Services;
@@ -39,6 +40,8 @@ public static class OperatorAuditEndpoints
int? offset,
HttpContext context,
AuditService auditService,
ITenantRepository tenantRepository,
IAuthStore authStore,
CancellationToken ct)
{
var operatorIdentity = context.GetCurrentOperator();
@@ -51,7 +54,8 @@ public static class OperatorAuditEndpoints
eventType, actorType, tenantId, from, to, NormalizeLimit(limit), actorId, NormalizeOffset(offset));
IReadOnlyList<AuditRecordDto> items = await auditService.QueryAsync(filter, ct);
int total = await auditService.CountAsync(filter, ct);
return Results.Ok(new { items, total });
IReadOnlyList<AuditRecordViewDto> view = await AuditViewFactory.ProjectAsync(items, tenantRepository, authStore, ct);
return Results.Ok(new { items = view, total });
}
/// <summary>
@@ -64,7 +64,7 @@ public static class OperatorAuthEndpoints
ActorId: null,
TenantId: null,
Ip: ClientIp(context),
DetailJson: AuditService.ToDetailJson(new { login = attemptedLogin })), ct);
DetailJson: AuditService.ToDetailJson([AuditDetails.Set(AuditFields.Login, attemptedLogin)])), ct);
}
return EndpointResults.Unauthorized(InvalidCredentialsDetail);
@@ -78,7 +78,7 @@ public static class OperatorAuthEndpoints
ActorId: result.OperatorId,
TenantId: null,
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);
return Results.Ok(new { ok = true, login = result.Login });
@@ -100,7 +100,7 @@ public static class OperatorAuthEndpoints
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 });
@@ -92,7 +92,8 @@ public static class OperatorInvitesEndpoints
Ip: ClientIp(context),
// Код инвайта — capability-токен (по нему активируется приглашение): в аудит пишется
// только его 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;
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,
TenantId: null,
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 });
}
@@ -58,6 +58,7 @@ public static class OperatorLimitsEndpoints
private static async Task<IResult> ListSummaryAsync(
HttpContext context,
ITenantRepository tenantRepository,
IAuthStore authStore,
ITenantLimitStore limitStore,
CancellationToken ct)
{
@@ -68,6 +69,8 @@ public static class OperatorLimitsEndpoints
}
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);
foreach (TenantRecordDto tenant in tenants)
{
@@ -75,6 +78,7 @@ public static class OperatorLimitsEndpoints
items.Add(new
{
tenantId = tenant.Id,
ownerLogin = owners.GetValueOrDefault(tenant.Id),
name = tenant.Name,
budget = state.BudgetTokens,
period = state.Period,
@@ -92,6 +96,7 @@ public static class OperatorLimitsEndpoints
Guid id,
HttpContext context,
ITenantRepository tenantRepository,
IAuthStore authStore,
ITenantLimitStore limitStore,
CancellationToken ct)
{
@@ -108,7 +113,7 @@ public static class OperatorLimitsEndpoints
}
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).
@@ -117,6 +122,7 @@ public static class OperatorLimitsEndpoints
OperatorLimitUpdateRequest? body,
HttpContext context,
ITenantRepository tenantRepository,
IAuthStore authStore,
ITenantLimitStore limitStore,
AuditService auditService,
CancellationToken ct)
@@ -154,10 +160,11 @@ public static class OperatorLimitsEndpoints
BudgetStateDto current = await limitStore.GetStateAsync(id, ct);
long newBudget = body.Budget ?? current.BudgetTokens;
string newPeriod = body.Period ?? current.Period;
string? ownerLogin = await ResolveOwnerLoginAsync(authStore, id, ct);
if (newBudget == current.BudgetTokens && newPeriod == current.Period)
{
// Идемпотентный повторный 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);
@@ -167,16 +174,10 @@ public static class OperatorLimitsEndpoints
ActorId: operatorIdentity.OperatorId,
TenantId: id,
Ip: ClientIp(context),
DetailJson: AuditService.ToDetailJson(new
{
tenantId = id,
oldBudget = current.BudgetTokens,
oldPeriod = current.Period,
budgetTokens = newBudget,
period = newPeriod,
})), ct);
DetailJson: AuditService.ToDetailJson(
[AuditDetails.Change(AuditFields.Budget, current.BudgetTokens, newBudget), AuditDetails.Change(AuditFields.Period, current.Period, newPeriod)])), ct);
return Results.Ok(BuildDetailDto(tenant.Name, updated));
return Results.Ok(BuildDetailDto(tenant.Name, ownerLogin, updated));
}
/// <summary>
@@ -197,11 +198,13 @@ public static class OperatorLimitsEndpoints
// Форма деталей лимита тенанта (GET и ответ PATCH — единая).
// name: Имя тенанта (реестр).
// ownerLogin: Логин владельца пространства; null, если пользователей нет.
// 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,
ownerLogin,
name,
status = state.Status,
allowed = state.Allowed,
@@ -215,6 +218,17 @@ public static class OperatorLimitsEndpoints
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, если недоступен).
// context: Контекст запроса.
// Возвращает: Строковое представление IP или null.
@@ -128,7 +128,8 @@ public static class OperatorSettingsEndpoints
ActorId: operatorIdentity.OperatorId,
TenantId: null,
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);
return Results.Ok(snapshot);
@@ -113,7 +113,8 @@ public static class OperatorTenantsEndpoints
ActorId: operatorIdentity.OperatorId,
TenantId: createdTenant.Id,
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)
{
@@ -144,12 +145,7 @@ public static class OperatorTenantsEndpoints
return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail);
}
TenantDetailDto? tenant = await tenantAdminService.GetAsync(id, ct);
if (tenant is null)
{
return EndpointResults.NotFound(TenantNotFoundDetail);
}
TenantDetailDto tenant = await tenantAdminService.GetAsync(id, ct);
return Results.Ok(tenant);
}
@@ -207,7 +203,8 @@ public static class OperatorTenantsEndpoints
ActorId: operatorIdentity.OperatorId,
TenantId: result.Tenant.Id,
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 });
@@ -247,7 +244,7 @@ public static class OperatorTenantsEndpoints
ActorId: operatorIdentity.OperatorId,
TenantId: result.TenantId,
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-сессию (JS не может записать httpOnly-куку).
@@ -33,8 +33,6 @@ public static class PipelineEndpoints
// Путь возврата записи отсева в обработку (POST).
private const string RejectedReturnPath = "/rejected/{rejId}/return";
private const string RejectedNotFoundDetail = "Запись не найдена";
private const int DefaultPageSize = 100;
/// <summary>
@@ -140,11 +138,7 @@ public static class PipelineEndpoints
}
PipelineProcessingService processing = context.RequestServices.GetRequiredService<PipelineProcessingService>();
RejectReturnResultDto? result = await processing.ReturnAsync(rejId, body.Reason ?? string.Empty, ct);
if (result is null)
{
return EndpointResults.NotFound(RejectedNotFoundDetail);
}
RejectReturnResultDto result = await processing.ReturnAsync(rejId, body.Reason ?? string.Empty, ct);
return result.Error is not null
? EndpointResults.BadRequest(result.Error)
@@ -74,7 +74,7 @@ public static class SettingsEndpoints
SettingsService settingsService = context.RequestServices.GetRequiredService<SettingsService>();
PublicSettingsDto result = await settingsService.ApplyPatchAsync(body, ct);
await AuditAppender.AppendTenantAsync(context, AuditEvents.SettingsUpdated, new { fields = body.Keys }, ct);
await AuditAppender.AppendTenantAsync(context, AuditEvents.SettingsUpdated, [AuditDetails.Set(AuditFields.Fields, string.Join(", ", body.Keys))], ct);
if (ShouldScheduleRatesRefresh(body))
{
@@ -67,7 +67,12 @@ public static class TelegramEndpoints
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;
@@ -170,7 +175,7 @@ public static class TelegramEndpoints
TelegramAuthResultDto result = await gateway.StartQrAsync(apiId, keys.ApiHash, ct);
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 });
@@ -197,7 +202,7 @@ public static class TelegramEndpoints
string phase = await gateway.SendCodeAsync((body.Code ?? string.Empty).Trim(), ct);
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 });
@@ -224,7 +229,7 @@ public static class TelegramEndpoints
string phase = await gateway.SendPasswordAsync(body.Password ?? string.Empty, ct);
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 });
@@ -343,7 +348,7 @@ public static class TelegramEndpoints
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 });
@@ -393,7 +398,7 @@ public static class TelegramEndpoints
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 });
@@ -482,9 +487,9 @@ public static class TelegramEndpoints
{
return kind switch
{
"channel" => "канал",
"group" or "forum" => "группа",
"chat" => "чат",
TelegramDialogKinds.Channel => RussianChannelLabel,
TelegramDialogKinds.Group or TelegramDialogKinds.Forum => RussianGroupLabel,
TelegramDialogKinds.Chat => RussianChatLabel,
_ => kind,
};
}
+15 -13
View File
@@ -13,12 +13,14 @@ public static class AuditAppender
/// <summary>
/// Пишет событие действия пользователя тенанта
/// </summary>
/// <param name="context">Контекст запроса (сессия пользователя, IP).</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(
HttpContext context,
string eventType,
object? details,
IReadOnlyList<AuditChangeDto> changes,
CancellationToken ct)
{
CurrentUser? user = context.GetCurrentUser();
@@ -35,19 +37,21 @@ public static class AuditAppender
ActorId: user.UserId,
TenantId: user.TenantId,
Ip: ClientIp(context),
DetailJson: DetailJson(details)),
DetailJson: DetailJson(changes)),
ct);
}
/// <summary>
/// Пишет событие действия оператора
/// </summary>
/// <param name="context">Контекст запроса (операторская сессия, IP).</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(
HttpContext context,
string eventType,
object? details,
IReadOnlyList<AuditChangeDto> changes,
CancellationToken ct)
{
CurrentOperator? operatorIdentity = context.GetCurrentOperator();
@@ -64,18 +68,16 @@ public static class AuditAppender
ActorId: operatorIdentity.OperatorId,
TenantId: null,
Ip: ClientIp(context),
DetailJson: DetailJson(details)),
DetailJson: DetailJson(changes)),
ct);
}
// Сериализует детали события (null — деталей нет).
// details: Объект деталей или null.
// Возвращает: JSON деталей (camelCase) или null.
private static string? DetailJson(object? details) =>
details is null ? null : AuditService.ToDetailJson(details);
// Сериализует детали события (пустой список — деталей нет).
// changes: Изменения параметров события.
// Возвращает: JSON деталей или null.
private static string? DetailJson(IReadOnlyList<AuditChangeDto> changes) =>
changes.Count == 0 ? null : AuditService.ToDetailJson(changes);
// IP-адрес клиента для аудита (без порта; null, если недоступен).
// context: Контекст запроса.
// Возвращает: Строковое представление IP или null.
private static string? ClientIp(HttpContext context) => context.Connection.RemoteIpAddress?.ToString();
}
@@ -0,0 +1,94 @@
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="ct">Токен отмены.</param>
/// <returns>Записи для чтения оператором.</returns>
public static async Task<IReadOnlyList<AuditRecordViewDto>> ProjectAsync(
IReadOnlyList<AuditRecordDto> records,
ITenantRepository tenantRepository,
IAuthStore authStore,
CancellationToken ct)
{
Guid[] tenantIds = DistinctTenantIds(records);
IReadOnlyDictionary<Guid, string> names = await ResolveNamesAsync(tenantIds, tenantRepository, ct);
IReadOnlyDictionary<Guid, string> owners = await ResolveOwnerLoginsAsync(tenantIds, authStore, 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,
record.AuditChanges(),
record.DetailJson,
record.At,
record.Id));
}
return view;
}
// Логин реального пользователя: владелец пространства, иначе логин/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);
}
@@ -12,6 +12,10 @@ public sealed class TelegramBackfillScheduler(
IHostApplicationLifetime applicationLifetime,
ILogger<TelegramBackfillScheduler> logger)
{
// Имена фоновых задач (метки запуска).
private const string BackfillMonitoredJob = "backfill_monitored";
private const string FirstBackfillJob = "first_backfill";
// Флаг in-flight «Перечитать» всех каналов (Interlocked): повторный вызов не плодит
// параллельные полные перечитывания (Security review, как RatesRefreshScheduler).
private int _readRecentInProgress;
@@ -27,7 +31,7 @@ public sealed class TelegramBackfillScheduler(
return;
}
RunBackground("backfill_monitored", RunReadRecentAsync);
RunBackground(BackfillMonitoredJob, RunReadRecentAsync);
}
/// <summary>
@@ -50,7 +54,7 @@ public sealed class TelegramBackfillScheduler(
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).
@@ -21,6 +21,11 @@ public sealed class SourceIngressGrpcService(
IngressTenantResolver tenants,
ILogger<SourceIngressGrpcService> logger) : SourceIngressService.SourceIngressServiceBase
{
// Исходы приёма источника для лога аудита.
private const string DuplicateOutcome = "duplicate";
private const string NoOpOutcome = "no-op";
private const string QueuedOutcome = "queued";
/// <summary>
/// PushSource — запись источника в очередь пайплайна тенанта.
/// </summary>
@@ -57,7 +62,7 @@ public sealed class SourceIngressGrpcService(
tenant.Id,
item.Source.Kind,
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
{
@@ -26,6 +26,15 @@ public sealed class TelegramKeysService(IGlobalSettingsStore store, ISecretCiphe
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) + терпимость регистра.
private static readonly JsonSerializerOptions KeysJsonOptions = new()
{
@@ -49,8 +58,8 @@ public sealed class TelegramKeysService(IGlobalSettingsStore store, ISecretCiphe
{
using JsonDocument document = JsonDocument.Parse(row.ValueJson);
JsonElement root = document.RootElement;
string apiId = ReadString(root, "apiId");
string apiHash = ReadString(root, "apiHash");
string apiId = ReadString(root, ApiIdProperty);
string apiHash = ReadString(root, ApiHashProperty);
return new TgKeysSnapshot(apiId, cipher.Decrypt(apiHash));
}
catch (JsonException)
@@ -143,8 +152,11 @@ public sealed class TelegramKeysService(IGlobalSettingsStore store, ISecretCiphe
return value.Length switch
{
0 => string.Empty,
<= 8 => string.Concat(value.AsSpan(0, 1), MaskEllipsis),
_ => string.Concat(value.AsSpan(0, 4), MaskEllipsis, value.AsSpan(value.Length - 4)),
<= MaskShortMaxLength => string.Concat(value.AsSpan(0, MaskShortVisibleChars), MaskEllipsis),
_ => string.Concat(
value.AsSpan(0, MaskEdgeVisibleChars),
MaskEllipsis,
value.AsSpan(value.Length - MaskEdgeVisibleChars)),
};
}
}
@@ -22,6 +22,7 @@ public sealed class TgStatusService(
TelegramKeysService keys)
{
private const string IdlePhase = "idle";
private const string ReadyPhase = "ready";
// Опции JSON KV-значений статуса: camelCase (как пишет ингресс) + терпимость регистра.
private static readonly JsonSerializerOptions KvJsonOptions = new()
@@ -37,13 +38,19 @@ public sealed class TgStatusService(
public async Task<TgStatusDto> GetAsync(CancellationToken ct)
{
TelegramAccountStatusDto live = await ReadLiveAsync(ct).ConfigureAwait(false);
string account = await ReadAccountAsync(ct).ConfigureAwait(false);
// «Подключён» для UI = авторизован (phase ready). Транспортный connected сервиса
// означает лишь живость соединения и не гарантирует вход — в UI он даёт «зависание».
bool authorized = string.Equals(live.Phase, ReadyPhase, StringComparison.Ordinal);
// Живой account (имя из Telegram) приоритетнее KV: при QR-входе KV ещё не заполнен.
string account = string.IsNullOrEmpty(live.Account)
? await ReadAccountAsync(ct).ConfigureAwait(false)
: live.Account;
int monitored = (await dialogs.ListMonitoredIdsAsync(ct).ConfigureAwait(false)).Count;
TgKeysSnapshot snapshot = await keys.GetAsync(ct).ConfigureAwait(false);
return new TgStatusDto(
Phase: live.Phase,
Connected: live.Connected,
Connected: authorized,
Listener: live.Listener,
Account: account,
Monitored: monitored,
@@ -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-гейта интеграций
internal static class UriExtensions
{
// Имя loopback-хоста.
private const string LocalhostHost = "localhost";
/// <summary>
/// Проверяет, указывает ли URL на приватный/loopback/link-local адрес
/// </summary>
@@ -14,7 +17,7 @@ internal static class UriExtensions
public static bool IsPrivateEndpoint(this Uri uri)
{
string host = uri.Host;
if (string.Equals(host, "localhost", StringComparison.OrdinalIgnoreCase))
if (string.Equals(host, LocalhostHost, StringComparison.OrdinalIgnoreCase))
{
return true;
}
@@ -1,5 +1,7 @@
using Microsoft.Extensions.Configuration;
using Deal.SharedKernel.Utilities;
namespace Deal.Infrastructure.Integrations.Options;
/// <summary>
@@ -86,13 +88,16 @@ public sealed class MtlsOptions
};
}
// Строковое представление включённого флага числом.
private const string OneLiteral = "1";
/// <summary>
/// Разбирает значение флага DEAL_MTLS_ENABLED
/// </summary>
/// <param name="rawValue">Сырое значение env (null/пусто — выключено).</param>
public static bool IsEnabled(string? rawValue)
=> string.Equals(rawValue, "1", StringComparison.Ordinal)
|| string.Equals(rawValue, "true", StringComparison.OrdinalIgnoreCase);
=> string.Equals(rawValue, OneLiteral, StringComparison.Ordinal)
|| string.Equals(rawValue, BoolText.True, StringComparison.OrdinalIgnoreCase);
// Обрезает путь конфигурации (env-значения с пробелами/кавычками не передаются в файловые API).
// rawValue: Сырое значение env.
@@ -73,7 +73,7 @@ public sealed class GrpcTelegramClient : ITelegramGateway
}
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)
{
throw TranslateTransportFailure(exception, tenantId, "start_phone");
throw TranslateTransportFailure(exception, tenantId, TelegramOperations.StartPhone);
}
}
@@ -118,7 +118,7 @@ public sealed class GrpcTelegramClient : ITelegramGateway
}
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)
{
throw TranslateTransportFailure(exception, tenantId, "send_code");
throw TranslateTransportFailure(exception, tenantId, TelegramOperations.SendCode);
}
}
@@ -152,7 +152,7 @@ public sealed class GrpcTelegramClient : ITelegramGateway
}
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)
{
throw TranslateTransportFailure(exception, tenantId, "logout");
throw TranslateTransportFailure(exception, tenantId, TelegramOperations.Logout);
}
}
@@ -183,7 +183,7 @@ public sealed class GrpcTelegramClient : ITelegramGateway
}
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)
{
throw TranslateTransportFailure(exception, tenantId, "set_monitor");
throw TranslateTransportFailure(exception, tenantId, TelegramOperations.SetMonitor);
}
}
@@ -218,7 +218,7 @@ public sealed class GrpcTelegramClient : ITelegramGateway
}
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)
{
throw TranslateTransportFailure(exception, tenantId, "backfill");
throw TranslateTransportFailure(exception, tenantId, TelegramOperations.Backfill);
}
}
@@ -260,7 +260,7 @@ public sealed class GrpcTelegramClient : ITelegramGateway
}
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)
{
throw TranslateTransportFailure(exception, tenantId, "read_source");
throw TranslateTransportFailure(exception, tenantId, TelegramOperations.ReadSource);
}
}
@@ -303,7 +303,7 @@ public sealed class GrpcTelegramClient : ITelegramGateway
}
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)
{
throw TranslateTransportFailure(exception, tenantId, "get_info");
throw TranslateTransportFailure(exception, tenantId, TelegramOperations.GetInfo);
}
}
@@ -358,7 +358,7 @@ public sealed class GrpcTelegramClient : ITelegramGateway
}
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)
{
throw TranslateTransportFailure(exception, tenantId, "join");
throw TranslateTransportFailure(exception, tenantId, TelegramOperations.Join);
}
}
@@ -390,7 +390,7 @@ public sealed class GrpcTelegramClient : ITelegramGateway
}
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 RulesModeAny = "any";
/// <inheritdoc />
public async Task<SuggestColumnsResultDto> SuggestColumnsAsync(CancellationToken ct)
{
@@ -121,7 +119,7 @@ ContainersService containersService) : IColumnSuggester
foreach (SuggestedColumnPlan plan in plans)
{
var rules = new ContainerRulesDto(
Mode: RulesModeAny,
Mode: ColumnRuleModes.Any,
Direction: Array.Empty<string>(),
Keywords: [plan.Word],
Stack: Array.Empty<string>(),
@@ -5,6 +5,8 @@ namespace Deal.Infrastructure.Integrations.Services;
public sealed class LocalTelegramGateway : ITelegramGateway
{
// Причина пустого чтения: сообщений в источнике нет.
private const string NoHistoryReason = "no_history";
// Фаза idle-формы (аккаунт не подключён — сервиса нет).
private const string IdlePhase = "idle";
@@ -72,7 +74,7 @@ public sealed class LocalTelegramGateway : ITelegramGateway
string dialogId,
int limit,
CancellationToken ct)
=> Task.FromResult(new TelegramEvalReadDto(false, "no_history", []));
=> Task.FromResult(new TelegramEvalReadDto(false, NoHistoryReason, []));
Task ITelegramGateway.JoinAsync(string username, CancellationToken ct) => Task.CompletedTask;
@@ -1,8 +1,13 @@
using Deal.SharedKernel.Utilities;
namespace Deal.Infrastructure.Integrations.Storage.Extensions;
// Расширения string для разбора конфигурационных значений.
internal static class StringExtensions
{
// Строковое представление включённого флага числом.
private const string OneLiteral = "1";
/// <summary>
/// Разбирает строковое значение как булев флаг конфигурации
/// </summary>
@@ -10,6 +15,6 @@ internal static class StringExtensions
/// <returns>True — значение распознано как включённое.</returns>
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;
namespace Deal.Infrastructure.Persistence.Entities;
@@ -130,7 +131,7 @@ public sealed class CardEntity
/// <summary>
/// Предыдущая колонка
/// </summary>
public string PrevCol { get; set; } = "inbox";
public string PrevCol { get; set; } = CardIds.Inbox;
/// <summary>
/// Время помещения в архив
@@ -1,3 +1,4 @@
using Deal.Modules.Kanban.Application.Models;
using NpgsqlTypes;
namespace Deal.Infrastructure.Persistence.Entities;
@@ -35,12 +36,12 @@ public sealed class ContainerEntity
/// <summary>
/// Вид контейнера: board
/// </summary>
public string Kind { get; set; } = "board";
public string Kind { get; set; } = ContainerKinds.Board;
/// <summary>
/// Пространство: dashboard | selected
/// </summary>
public string Space { get; set; } = "dashboard";
public string Space { get; set; } = ContainerSpaces.Dashboard;
/// <summary>
/// Свёрнутость колонки на дашборде
@@ -1,3 +1,5 @@
using Deal.Modules.Discovery.Application.Models;
namespace Deal.Infrastructure.Persistence.Entities;
/// <summary>
@@ -28,7 +30,7 @@ public sealed class DiscCandidateEntity
/// <summary>
/// Тип источника: channel|group|forum.
/// </summary>
public string Kind { get; set; } = "channel";
public string Kind { get; set; } = DiscoveryCandidateKinds.Channel;
/// <summary>
/// Цвет источника из палитры DIALOG_HUES
@@ -63,7 +65,7 @@ public sealed class DiscCandidateEntity
/// <summary>
/// Статус кандидата
/// </summary>
public string Status { get; set; } = "new";
public string Status { get; set; } = DiscoveryCandidateStatuses.New;
/// <summary>
/// Вступили автоматически
@@ -1,3 +1,5 @@
using Deal.Modules.Discovery.Application.Models;
namespace Deal.Infrastructure.Persistence.Entities;
/// <summary>
@@ -55,7 +57,7 @@ public sealed class DiscTaskEntity
/// <summary>
/// Статус задачи: draft|running|paused|done|failed.
/// </summary>
public string Status { get; set; } = "draft";
public string Status { get; set; } = DiscoveryTaskStatuses.Draft;
/// <summary>
/// Индекс текущего ключа поиска
@@ -1,3 +1,5 @@
using Deal.Modules.Tenants.Application.Models;
namespace Deal.Infrastructure.Persistence.Entities;
/// <summary>
@@ -20,7 +22,7 @@ public sealed class InviteEntity
/// </summary>
public Guid? TenantId { get; set; }
public string Status { get; set; } = "pending";
public string Status { get; set; } = InviteStatuses.Pending;
public DateTimeOffset ExpiresAt { get; set; }
@@ -1,3 +1,5 @@
using Deal.Modules.Tenants.Application.Models;
namespace Deal.Infrastructure.Persistence.Entities;
/// <summary>
@@ -14,7 +16,7 @@ public sealed class OperatorEntity
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; }
}
@@ -1,3 +1,5 @@
using Deal.Modules.Pipeline.Application.Models;
namespace Deal.Infrastructure.Persistence.Entities;
/// <summary>
@@ -38,7 +40,7 @@ public sealed class QueueItemEntity
/// <summary>
/// Статус строки: <c>new</c> — ждёт разбора воркером, <c>filtered</c> — прошла фильтры и ждёт ИИ/ML.
/// </summary>
public string Status { get; set; } = "new";
public string Status { get; set; } = PipelineQueueStatuses.New;
/// <summary>
/// Признак возврата из отсева
@@ -1,5 +1,7 @@
using NpgsqlTypes;
using Deal.Modules.Pipeline.Application.Models;
namespace Deal.Infrastructure.Persistence.Entities;
/// <summary>
@@ -50,7 +52,7 @@ public sealed class RejectedItemEntity
/// <summary>
/// Кто вынес решение
/// </summary>
public string Source { get; set; } = "stop";
public string Source { get; set; } = PipelineRejectSources.Rules;
/// <summary>
/// Время получения исходного сообщения
@@ -1,3 +1,5 @@
using Deal.Modules.Tenants.Application.Models;
namespace Deal.Infrastructure.Persistence.Entities;
/// <summary>
@@ -9,7 +11,7 @@ public sealed class TenantEntity
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; }
}
@@ -1,3 +1,5 @@
using Deal.Modules.Tenants.Application.Models;
namespace Deal.Infrastructure.Persistence.Entities;
/// <summary>
@@ -18,7 +20,7 @@ public sealed class TenantLimitEntity
/// <summary>
/// Тип периода: month|day.
/// </summary>
public string Period { get; set; } = "month";
public string Period { get; set; } = TenantLimitPeriods.Month;
/// <summary>
/// Начало текущего периода
@@ -1,3 +1,5 @@
using Deal.Modules.Tenants.Application.Models;
namespace Deal.Infrastructure.Persistence.Entities;
/// <summary>
@@ -16,7 +18,7 @@ public sealed class UserEntity
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; }
}
@@ -67,6 +67,32 @@ public sealed class AuthStore(DealDbContext dbContext) : IAuthStore
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 />
public async Task CreateSessionAsync(SessionDto session, CancellationToken ct)
{
@@ -59,7 +59,7 @@ public sealed partial class DiscoveryStore
Username = row.Username,
Kind = row.Kind,
Hue = row.Hue,
Status = "new",
Status = DiscoveryCandidateStatuses.New,
CreatedAt = now,
UpdatedAt = now,
});
@@ -123,7 +123,7 @@ public sealed partial class DiscoveryStore
return false;
}
row.Status = "joined";
row.Status = DiscoveryCandidateStatuses.Joined;
row.AutoJoined = autoJoined;
row.UpdatedAt = DateTimeOffset.UtcNow;
await _dbContext.SaveChangesAsync(ct);
@@ -135,7 +135,7 @@ public sealed partial class DiscoveryStore
{
DiscCandidateEntity? row = await _dbContext.DiscCandidates
.FirstOrDefaultAsync(candidate => candidate.DialogId == dialogId, ct);
if (row is null || row.Status != "review")
if (row is null || row.Status != DiscoveryCandidateStatuses.Review)
{
return null;
}
@@ -156,7 +156,7 @@ public sealed partial class DiscoveryStore
return false;
}
row.Status = "rejected";
row.Status = DiscoveryCandidateStatuses.Rejected;
row.UpdatedAt = DateTimeOffset.UtcNow;
await _dbContext.SaveChangesAsync(ct);
return true;
@@ -45,7 +45,7 @@ public sealed partial class DiscoveryStore
SampleSize = row.SampleSize,
PlanJoins = row.PlanJoins,
AutoJoin = row.AutoJoin,
Status = "draft",
Status = DiscoveryTaskStatuses.Draft,
CreatedAt = now,
UpdatedAt = now,
});
@@ -101,7 +101,7 @@ public sealed partial class DiscoveryStore
return false;
}
row.Status = "running";
row.Status = DiscoveryTaskStatuses.Running;
if (resetProgress)
{
row.SearchIdx = 0;
@@ -127,7 +127,7 @@ public sealed partial class DiscoveryStore
return false;
}
row.Status = "paused";
row.Status = DiscoveryTaskStatuses.Paused;
row.UpdatedAt = DateTimeOffset.UtcNow;
await _dbContext.SaveChangesAsync(ct);
return true;
@@ -143,7 +143,7 @@ public sealed partial class DiscoveryStore
return false;
}
row.Status = "done";
row.Status = DiscoveryTaskStatuses.Done;
row.UpdatedAt = DateTimeOffset.UtcNow;
await _dbContext.SaveChangesAsync(ct);
return true;
@@ -211,7 +211,7 @@ public sealed partial class DiscoveryStore
async Task<int> IDiscoveryStore.SumActivePlanAsync(string? excludeTaskId, CancellationToken ct)
{
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)
{
query = query.Where(task => task.Id != excludeTaskId);
@@ -61,6 +61,23 @@ public sealed class TenantRepository(DealDbContext dbContext) : ITenantRepositor
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 />
public async Task<bool> UpdateStatusAsync(
Guid id,
@@ -22,8 +22,6 @@ public sealed class CardMover(CardsService cardsService) : ICardMover
CardResultDto result = CardsDefaultContainers.Contains(toContainerId)
? await cardsService.MoveStageCardAsync(cardId, toContainerId, ct)
: await cardsService.MoveDashboardCardAsync(cardId, toContainerId, ct);
return result.Error is not null
? new CardMoveResultDto(result.Error, Exists: true)
: new CardMoveResultDto(null, Exists: result.Card is not null);
return new CardMoveResultDto(result.Error);
}
}
@@ -1,5 +1,6 @@
using Deal.Modules.Cards.Application.Dtos;
using Deal.Modules.Cards.Application.Models;
using Deal.SharedKernel.Errors;
namespace Deal.Modules.Cards.Application.Abstractions;
@@ -14,7 +15,8 @@ public interface ICardMover
/// <param name="cardId">Id карточки.</param>
/// <param name="toContainerId">Id контейнера назначения (стадия «Выбранных» либо дашборд-контейнер).</param>
/// <param name="ctx">Контекст перехода (инициатор, причина, обучение).</param>
/// <returns>Результат: Error (400-текст отказа) | Exists=false (карточки нет, 404) | успех (Exists=true).</returns>
/// <returns>Результат: Error (400-текст отказа) либо null (успех).</returns>
/// <exception cref="NotFoundException">Карточка не найдена.</exception>
public Task<CardMoveResultDto> MoveAsync(
string cardId,
string toContainerId,
@@ -5,6 +5,5 @@ namespace Deal.Modules.Cards.Application.Dtos;
/// <summary>
/// Результат перехода карточки единым механизмом <see cref="ICardMover"/>.
/// </summary>
/// <param name="Error">Текст 400-ошибки либо null.</param>
/// <param name="Exists">True — карточка найдена и переход выполнен (либо перенос был no-op).</param>
public sealed record CardMoveResultDto(string? Error, bool Exists);
/// <param name="Error">Текст 400-ошибки либо null (успех).</param>
public sealed record CardMoveResultDto(string? Error);
@@ -1,3 +1,5 @@
using Deal.Contracts.Integrations.Models;
namespace Deal.Modules.Cards.Application.Models;
/// <summary>
@@ -8,7 +10,7 @@ public sealed record CardContact
/// <summary>
/// Тип контакта: tg/phone/email/linkedin/whatsapp/site/other.
/// </summary>
public string Type { get; init; } = "other";
public string Type { get; init; } = CardContactTypes.Other;
/// <summary>
/// Значение: @username, +7…, name@mail, url.
@@ -23,7 +23,7 @@ public sealed record CardFile
/// <summary>
/// Тип контента: image/video/audio/archive/document/other
/// </summary>
public string Kind { get; init; } = "other";
public string Kind { get; init; } = CardFileKinds.Other;
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 заполняются оценкой).
/// </summary>
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>
/// Тип источника: channel|group|forum.
/// </summary>
public string Kind { get; init; } = "channel";
public string Kind { get; init; } = DiscoveryCandidateKinds.Channel;
/// <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";
}
@@ -2,6 +2,7 @@ using Deal.Contracts.Integrations.Models;
using Deal.Modules.Discovery.Application.Abstractions;
using Deal.Modules.Discovery.Application.Exceptions;
using Deal.Modules.Discovery.Application.Models;
using Deal.SharedKernel.Errors;
namespace Deal.Modules.Discovery.Application.Services;
@@ -13,6 +14,10 @@ public sealed class DiscoveryCandidatesService(
DiscoveryLogService log,
DiscoveryBlacklistService blacklist)
{
private const string CandidateEntityName = "Кандидат";
private const string TaskEntityName = "Задача поиска";
/// <summary>
/// 400 mark_rejected
/// </summary>
@@ -53,10 +58,12 @@ public sealed class DiscoveryCandidatesService(
/// Кандидат по dialog_id.
/// </summary>
/// <param name="dialogId">Подписанный id источника.</param>
/// <returns>Кандидат или null (404 «Кандидат не найден»).</returns>
public Task<DiscoveryCandidateDto?> GetAsync(string dialogId, CancellationToken ct)
/// <returns>Кандидат.</returns>
/// <exception cref="NotFoundException">Кандидат не найден.</exception>
public async Task<DiscoveryCandidateDto> GetAsync(string dialogId, CancellationToken ct)
{
return store.GetCandidateAsync(dialogId, ct);
return await store.GetCandidateAsync(dialogId, ct).ConfigureAwait(false)
?? throw new NotFoundException(CandidateEntityName, dialogId);
}
/// <summary>
@@ -78,11 +85,8 @@ public sealed class DiscoveryCandidatesService(
string hue,
CancellationToken ct)
{
DiscoveryTaskDto? task = await store.GetTaskAsync(taskId, ct).ConfigureAwait(false);
if (task is null)
{
return null;
}
DiscoveryTaskDto task = await store.GetTaskAsync(taskId, ct).ConfigureAwait(false)
?? throw new NotFoundException(TaskEntityName, taskId);
if (await store.IsDialogMonitoredAsync(dialogId, ct).ConfigureAwait(false))
{
@@ -125,7 +129,8 @@ public sealed class DiscoveryCandidatesService(
};
await store.CreateCandidateAsync(row, ct).ConfigureAwait(false);
await store.BumpTaskCounterAsync(taskId, DiscoveryCounterField.Found, 1, ct).ConfigureAwait(false);
return await store.GetCandidateAsync(dialogId, ct).ConfigureAwait(false);
return await store.GetCandidateAsync(dialogId, ct).ConfigureAwait(false)
?? throw new NotFoundException(CandidateEntityName, dialogId);
}
/// <summary>
@@ -134,18 +139,21 @@ public sealed class DiscoveryCandidatesService(
/// <param name="taskId">Id задачи.</param>
/// <param name="dialogId">Подписанный id источника.</param>
/// <param name="patch">Изменяемые поля (null — не менять).</param>
/// <returns>Обновлённый кандидат либо null — задачи/кандидата нет (или кандидат другой задачи).</returns>
public async Task<DiscoveryCandidateDto?> SetAsync(
/// <returns>Обновлённый кандидат.</returns>
/// <exception cref="NotFoundException">Задача или кандидат не найдены.</exception>
public async Task<DiscoveryCandidateDto> SetAsync(
string taskId,
string dialogId,
DiscoveryCandidatePatch patch,
CancellationToken ct)
{
DiscoveryTaskDto? task = await store.GetTaskAsync(taskId, ct).ConfigureAwait(false);
DiscoveryCandidateDto? current = await store.GetCandidateAsync(dialogId, ct).ConfigureAwait(false);
if (task is null || current is null || current.TaskId != taskId)
_ = await store.GetTaskAsync(taskId, ct).ConfigureAwait(false)
?? throw new NotFoundException(TaskEntityName, taskId);
DiscoveryCandidateDto current = await store.GetCandidateAsync(dialogId, ct).ConfigureAwait(false)
?? throw new NotFoundException(CandidateEntityName, dialogId);
if (current.TaskId != taskId)
{
return null;
throw new NotFoundException(CandidateEntityName, dialogId);
}
DiscoveryCandidatePatch normalized = NormalizePatch(patch, current);
@@ -159,7 +167,8 @@ public sealed class DiscoveryCandidatesService(
}
await store.PatchCandidateAsync(dialogId, normalized, ct).ConfigureAwait(false);
return await store.GetCandidateAsync(dialogId, ct).ConfigureAwait(false);
return await store.GetCandidateAsync(dialogId, ct).ConfigureAwait(false)
?? throw new NotFoundException(CandidateEntityName, dialogId);
}
/// <summary>
@@ -167,9 +176,10 @@ public sealed class DiscoveryCandidatesService(
/// </summary>
/// <param name="dialogId">Подписанный id источника.</param>
/// <param name="status">Новый статус: new|review.</param>
/// <returns>Обновлённый кандидат либо null (кандидата нет).</returns>
/// <returns>Обновлённый кандидат.</returns>
/// <exception cref="NotFoundException">Кандидат не найден.</exception>
/// <exception cref="DiscoveryValidationException">Статус не new/review.</exception>
public async Task<DiscoveryCandidateDto?> SetStatusAsync(
public async Task<DiscoveryCandidateDto> SetStatusAsync(
string dialogId,
string status,
CancellationToken ct)
@@ -179,11 +189,8 @@ public sealed class DiscoveryCandidatesService(
throw new DiscoveryValidationException(string.Format(TransitionNotAllowedFormat, status));
}
DiscoveryCandidateDto? current = await store.GetCandidateAsync(dialogId, ct).ConfigureAwait(false);
if (current is null)
{
return null;
}
DiscoveryCandidateDto current = await store.GetCandidateAsync(dialogId, ct).ConfigureAwait(false)
?? throw new NotFoundException(CandidateEntityName, dialogId);
await store.SetCandidateStatusAsync(dialogId, status, ct).ConfigureAwait(false);
if (status == DiscoveryCandidateStatuses.Review)
@@ -195,7 +202,8 @@ public sealed class DiscoveryCandidatesService(
ct).ConfigureAwait(false);
}
return await store.GetCandidateAsync(dialogId, ct).ConfigureAwait(false);
return await store.GetCandidateAsync(dialogId, ct).ConfigureAwait(false)
?? throw new NotFoundException(CandidateEntityName, dialogId);
}
/// <summary>
@@ -203,17 +211,15 @@ public sealed class DiscoveryCandidatesService(
/// </summary>
/// <param name="dialogId">Подписанный id источника.</param>
/// <param name="auto">True — авто-вступление воркера; false — ручное.</param>
/// <returns>Кандидат в joined либо null (кандидата нет). Повторный вызов для joined — идемпотентен.</returns>
public async Task<DiscoveryCandidateDto?> MarkJoinedAsync(
/// <returns>Кандидат в joined. Повторный вызов для joined — идемпотентен.</returns>
/// <exception cref="NotFoundException">Кандидат не найден.</exception>
public async Task<DiscoveryCandidateDto> MarkJoinedAsync(
string dialogId,
bool auto,
CancellationToken ct)
{
DiscoveryCandidateDto? current = await store.GetCandidateAsync(dialogId, ct).ConfigureAwait(false);
if (current is null)
{
return null;
}
DiscoveryCandidateDto current = await store.GetCandidateAsync(dialogId, ct).ConfigureAwait(false)
?? throw new NotFoundException(CandidateEntityName, dialogId);
if (current.Status == DiscoveryCandidateStatuses.Joined)
{
@@ -224,7 +230,8 @@ public sealed class DiscoveryCandidatesService(
await store.BumpTaskCounterAsync(current.TaskId, DiscoveryCounterField.Joined, 1, ct).ConfigureAwait(false);
string logEvent = auto ? DiscoveryLogEvents.JoinAuto : DiscoveryLogEvents.JoinManual;
await log.AddAsync(current.TaskId, logEvent, string.Format(JoinedLogFormat, dialogId), ct).ConfigureAwait(false);
return await store.GetCandidateAsync(dialogId, ct).ConfigureAwait(false);
return await store.GetCandidateAsync(dialogId, ct).ConfigureAwait(false)
?? throw new NotFoundException(CandidateEntityName, dialogId);
}
/// <summary>
@@ -232,18 +239,16 @@ public sealed class DiscoveryCandidatesService(
/// </summary>
/// <param name="dialogId">Подписанный id источника.</param>
/// <param name="reason">Причина отклонения (в лог/чёрный список; «отклонено вручную» — эндпоинт reject).</param>
/// <returns>Кандидат в rejected либо null (кандидата нет). Повторный вызов для rejected — идемпотентен.</returns>
/// <returns>Кандидат в rejected. Повторный вызов для rejected — идемпотентен.</returns>
/// <exception cref="NotFoundException">Кандидат не найден.</exception>
/// <exception cref="DiscoveryValidationException">Источник уже joined — отклонить нельзя.</exception>
public async Task<DiscoveryCandidateDto?> MarkRejectedAsync(
public async Task<DiscoveryCandidateDto> MarkRejectedAsync(
string dialogId,
string reason,
CancellationToken ct)
{
DiscoveryCandidateDto? current = await store.GetCandidateAsync(dialogId, ct).ConfigureAwait(false);
if (current is null)
{
return null;
}
DiscoveryCandidateDto current = await store.GetCandidateAsync(dialogId, ct).ConfigureAwait(false)
?? throw new NotFoundException(CandidateEntityName, dialogId);
if (current.Status == DiscoveryCandidateStatuses.Joined)
{
@@ -260,7 +265,8 @@ public sealed class DiscoveryCandidatesService(
string text = string.IsNullOrEmpty(reason) ? string.Format(RejectedLogFormat, dialogId) : reason;
await log.AddAsync(current.TaskId, DiscoveryLogEvents.Reject, text, ct).ConfigureAwait(false);
await blacklist.AddAsync(dialogId, current.Name, reason ?? string.Empty, ct).ConfigureAwait(false);
return await store.GetCandidateAsync(dialogId, ct).ConfigureAwait(false);
return await store.GetCandidateAsync(dialogId, ct).ConfigureAwait(false)
?? throw new NotFoundException(CandidateEntityName, dialogId);
}
/// <summary>
@@ -116,7 +116,7 @@ public sealed class DiscoveryEvaluator
string raw = text ?? string.Empty;
if (raw.Trim().Length < MinTextLength)
{
return new DiscoveryMessageFit(false, "слишком короткое", "heuristic");
return new DiscoveryMessageFit(false, "слишком короткое", DiscoveryFitOrigins.Heuristic);
}
if (settingsSnapshot.GetBool(SettingsKeys.MlEnabled, SettingsDefaults.MlEnabled))
@@ -167,11 +167,11 @@ public sealed class DiscoveryEvaluator
string kw = (keyword ?? string.Empty).Trim();
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>
@@ -65,7 +65,7 @@ public sealed class DiscoveryTasksService(IDiscoveryStore store, DiscoveryPlanGu
await planGuard.AssertPlanAsync(planJoins, 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 на создание).
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? sampleSize = patch.SampleSize is int sample ? Math.Max(1, sample) : 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? description = patch.Description;
return new DiscoveryTaskPatch
@@ -7,12 +7,16 @@ namespace Deal.Modules.Discovery.Application.Services;
// источника (KindCode) и тексты сообщений для оценки (GroupTexts).
public sealed partial class DiscoveryWorkerService
{
// Префиксы сообщения об ошибке Telegram при FloodWait.
private const string FloodPrefix = "flood:";
private const string FloodSpacePrefix = "flood ";
internal static bool IsFlood(Exception exception)
{
string message = exception.Message ?? string.Empty;
return message.StartsWith("flood:", StringComparison.OrdinalIgnoreCase)
|| message.StartsWith("flood ", StringComparison.OrdinalIgnoreCase)
|| message.Contains("flood:", StringComparison.OrdinalIgnoreCase);
return message.StartsWith(FloodPrefix, StringComparison.OrdinalIgnoreCase)
|| message.StartsWith(FloodSpacePrefix, StringComparison.OrdinalIgnoreCase)
|| message.Contains(FloodPrefix, StringComparison.OrdinalIgnoreCase);
}
private static string KindCode(string kind, bool isForum = false)
@@ -24,9 +28,9 @@ public sealed partial class DiscoveryWorkerService
return kind.Trim().ToLowerInvariant() switch
{
DiscoveryCandidateKinds.Channel or "канал" => DiscoveryCandidateKinds.Channel,
DiscoveryCandidateKinds.Forum or "форум" => DiscoveryCandidateKinds.Forum,
"chat" or "чат" => DiscoveryCandidateKinds.Group,
DiscoveryCandidateKinds.Channel or DiscoveryCandidateKinds.ChannelAliasRu => DiscoveryCandidateKinds.Channel,
DiscoveryCandidateKinds.Forum or DiscoveryCandidateKinds.ForumAliasRu => DiscoveryCandidateKinds.Forum,
DiscoveryCandidateKinds.ChatAlias or DiscoveryCandidateKinds.ChatAliasRu => DiscoveryCandidateKinds.Group,
_ => DiscoveryCandidateKinds.Group,
};
}
@@ -56,7 +56,7 @@ public sealed partial class DiscoveryWorkerService
foreach (TelegramDialogEntryDto item in results)
{
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)
.ConfigureAwait(false);
@@ -56,7 +56,7 @@ public static class ColumnMatcher
bool budgetOk = !budgetEnabled || BudgetInRange.IsInRange(AmountParser.Parse(text), rules.Budget!, 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)
@@ -69,7 +69,7 @@ public static class RulesDescriber
return NoRulesText;
}
string mode = string.Equals(rules.Mode, "any", StringComparison.OrdinalIgnoreCase)
string mode = string.Equals(rules.Mode, ColumnRuleModes.Any, StringComparison.OrdinalIgnoreCase)
? AnyModePrefix
: AllModePrefix;
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 Description = "",
string? Color = null,
string Space = "dashboard",
string Kind = "board",
string Space = ContainerSpaces.Dashboard,
string Kind = ContainerKinds.Board,
bool Suggested = false,
ContainerRulesDto? Rules = null,
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";
}
@@ -27,9 +27,16 @@ public sealed partial class CardsService
/// </summary>
/// <param name="cardId">Id карточки (<c>c_...</c>).</param>
/// <returns>Карточка или null — строки нет (эндпоинт отвечает 404 «Карточка не найдена»).</returns>
public Task<CardDto?> GetCardAsync(string cardId, CancellationToken ct)
/// <summary>
/// Одна карточка по id.
/// </summary>
/// <param name="cardId">Id карточки (<c>c_...</c>).</param>
/// <returns>Карточка.</returns>
/// <exception cref="NotFoundException">Карточка не найдена.</exception>
public async Task<CardDto> GetCardAsync(string cardId, CancellationToken ct)
{
return _store.GetCardAsync(cardId, ct);
return await _store.GetCardAsync(cardId, ct)
?? throw new NotFoundException(CardEntityName, cardId);
}
@@ -57,7 +64,7 @@ public sealed partial class CardsService
CardDto? card = await _store.GetCardAsync(cardId, ct);
if (card is null)
{
return new CardResultDto(null, null);
throw new NotFoundException(CardEntityName, cardId);
}
if (card.Col == CardIds.Archive || card.Col == CardIds.Trash)
@@ -81,7 +88,10 @@ public sealed partial class CardsService
await _mlClient.PushAsync(text, toCol, PushWeightUser, ct);
}
return new CardResultDto(null, await _store.GetCardAsync(cardId, ct));
return new CardResultDto(
null,
await _store.GetCardAsync(cardId, ct)
?? throw new NotFoundException(CardEntityName, cardId));
}
/// <summary>
@@ -1,5 +1,6 @@
using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Settings.Application.Models;
using Deal.SharedKernel.Errors;
namespace Deal.Modules.Kanban.Application.Services;
@@ -22,17 +23,15 @@ public sealed partial class CardsService
/// </summary>
/// <param name="cardId">Id карточки (<c>c_...</c>).</param>
/// <param name="atMs">Время напоминания, epoch-ms.</param>
/// <returns>Результат: Error <see cref="RemindersDisabledDetail"/> (400) | Card=null без Error (404) | Card — карточка с напоминанием.</returns>
/// <returns>Результат: Error <see cref="RemindersDisabledDetail"/> (400) | Card — карточка с напоминанием.</returns>
/// <exception cref="NotFoundException">Карточка не найдена.</exception>
public async Task<CardResultDto> SetReminderAsync(
string cardId,
long atMs,
CancellationToken ct)
{
CardDto? card = await _store.GetCardAsync(cardId, ct);
if (card is null)
{
return new CardResultDto(null, null);
}
CardDto card = await _store.GetCardAsync(cardId, ct)
?? throw new NotFoundException(CardEntityName, cardId);
if (!await ReadRemindersEnabledAsync(ct))
{
@@ -41,7 +40,7 @@ public sealed partial class CardsService
await _store.SetReminderAsync(cardId, atMs, ct);
CardDto saved = await _store.GetCardAsync(cardId, ct)
?? throw new InvalidOperationException("Карточка не прочиталась после установки напоминания: " + cardId);
?? throw new NotFoundException(CardEntityName, cardId);
return new CardResultDto(null, saved);
}
@@ -49,35 +48,27 @@ public sealed partial class CardsService
/// Снимает напоминание карточки.
/// </summary>
/// <param name="cardId">Id карточки (<c>c_...</c>).</param>
/// <returns>True — карточка есть и напоминание снято; false — карточки нет (404).</returns>
public async Task<bool> ClearReminderAsync(string cardId, CancellationToken ct)
/// <exception cref="NotFoundException">Карточка не найдена.</exception>
public async Task ClearReminderAsync(string cardId, CancellationToken ct)
{
CardDto? card = await _store.GetCardAsync(cardId, ct);
if (card is null)
{
return false;
}
_ = await _store.GetCardAsync(cardId, ct)
?? throw new NotFoundException(CardEntityName, cardId);
await _store.ClearReminderAsync(cardId, ct);
return true;
}
/// <summary>
/// «Напомнить позже»
/// </summary>
/// <param name="cardId">Id карточки (<c>c_...</c>).</param>
/// <returns>True — карточка есть и напоминание отложено; false — карточки нет (404).</returns>
public async Task<bool> SnoozeReminderAsync(string cardId, CancellationToken ct)
/// <exception cref="NotFoundException">Карточка не найдена.</exception>
public async Task SnoozeReminderAsync(string cardId, CancellationToken ct)
{
CardDto? card = await _store.GetCardAsync(cardId, ct);
if (card is null)
{
return false;
}
_ = await _store.GetCardAsync(cardId, ct)
?? throw new NotFoundException(CardEntityName, cardId);
long snoozedAtMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() + ReminderSnoozeMs;
await _store.SetReminderAsync(cardId, snoozedAtMs, ct);
return true;
}
/// <summary>
@@ -12,6 +12,9 @@ namespace Deal.Modules.Kanban.Application.Services;
/// </summary>
public sealed partial class CardsService
{
// Имя сущности для текста ошибки «не найдено».
private const string CardLinkEntityName = "Ссылка карточки";
/// <summary>
/// 400 перенос по стадии
/// </summary>
@@ -159,18 +162,16 @@ public sealed partial class CardsService
/// <param name="cardId">Id карточки (<c>c_...</c>).</param>
/// <param name="name">Название ссылки; пустое после Trim → name = url.</param>
/// <param name="url">URL ссылки (без схемы — добавится https://).</param>
/// <returns>Результат: Error (400 «Пустая ссылка») | Card=null без Error (404) | Card — карточка со ссылкой.</returns>
/// <returns>Результат: Error (400 «Пустая ссылка») | Card — карточка со ссылкой.</returns>
/// <exception cref="NotFoundException">Карточка не найдена.</exception>
public async Task<CardResultDto> AddLinkAsync(
string cardId,
string name,
string url,
CancellationToken ct)
{
CardDto? card = await _store.GetCardAsync(cardId, ct);
if (card is null)
{
return new CardResultDto(null, null);
}
CardDto card = await _store.GetCardAsync(cardId, ct)
?? throw new NotFoundException(CardEntityName, cardId);
string normalizedUrl = (url ?? string.Empty).Trim();
if (normalizedUrl.Length == 0)
@@ -191,11 +192,11 @@ public sealed partial class CardsService
normalizedUrl);
if (!await _store.AddLinkAsync(cardId, link, ct))
{
return new CardResultDto(null, null);
throw new NotFoundException(CardEntityName, cardId);
}
CardDto saved = await _store.GetCardAsync(cardId, ct)
?? throw new InvalidOperationException("Карточка не прочиталась после добавления ссылки: " + cardId);
?? throw new NotFoundException(CardEntityName, cardId);
return new CardResultDto(null, saved);
}
@@ -204,7 +205,8 @@ public sealed partial class CardsService
/// </summary>
/// <param name="cardId">Id карточки (<c>c_...</c>).</param>
/// <param name="linkId">Id удаляемой ссылки (<c>pl_...</c>).</param>
/// <returns>Результат: Card=null без Error (404) | Card — карточка без ссылки.</returns>
/// <returns>Карточка без ссылки.</returns>
/// <exception cref="NotFoundException">Карточка или ссылка не найдены.</exception>
public async Task<CardResultDto> RemoveLinkAsync(
string cardId,
string linkId,
@@ -212,11 +214,11 @@ public sealed partial class CardsService
{
if (!await _store.RemoveLinkAsync(cardId, linkId, ct))
{
return new CardResultDto(null, null);
throw new NotFoundException(CardLinkEntityName, linkId);
}
CardDto saved = await _store.GetCardAsync(cardId, ct)
?? throw new InvalidOperationException("Карточка не прочиталась после удаления ссылки: " + cardId);
?? throw new NotFoundException(CardEntityName, cardId);
return new CardResultDto(null, saved);
}
@@ -225,7 +227,8 @@ public sealed partial class CardsService
/// </summary>
/// <param name="cardId">Id карточки (<c>c_...</c>).</param>
/// <param name="containerId">Новый контейнер-стадия — id каталога <see cref="CardsDefaultContainers"/>.</param>
/// <returns>Результат: Error «Неизвестная стадия» (400) | Card=null без Error (карточки нет, 404) | Card — карточка после переноса.</returns>
/// <returns>Результат: Error «Неизвестная стадия» (400) | Card — карточка после переноса.</returns>
/// <exception cref="NotFoundException">Карточка не найдена.</exception>
public async Task<CardResultDto> MoveStageCardAsync(
string cardId,
string containerId,
@@ -241,11 +244,11 @@ public sealed partial class CardsService
bool moved = await _store.MoveCardStageAsync(cardId, containerId, entry, nowMs, ct);
if (!moved)
{
return new CardResultDto(null, null);
throw new NotFoundException(CardEntityName, cardId);
}
CardDto card = await _store.GetCardAsync(cardId, ct)
?? throw new InvalidOperationException("Карточка не прочиталась после move: " + cardId);
?? throw new NotFoundException(CardEntityName, cardId);
return new CardResultDto(null, card);
}
@@ -358,9 +361,9 @@ public sealed partial class CardsService
return ClearedBudget;
}
double? from = ReadNumber(element, "from");
double? to = ReadNumber(element, "to");
string cur = ReadText(element, "cur");
double? from = ReadNumber(element, KanbanWireKeys.From);
double? to = ReadNumber(element, KanbanWireKeys.To);
string cur = ReadText(element, KanbanWireKeys.Currency);
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 =
new Dictionary<string, string>(StringComparer.Ordinal)
{
["length"] = "короткое сообщение",
["stop"] = "стоп-фраза",
["resume"] = "резюме соискателя",
["type"] = "тип заявки",
["budget"] = "нет суммы",
["exclude_kw"] = "исключение: слова/технологии",
["exclude_location"] = "исключение: локация/язык",
["exclude_type"] = "исключение: тип",
["exclude_budget"] = "исключение: бюджет",
["stale"] = "устарело",
["spam_ml"] = "спам (ML)",
["spam_ai"] = "спам (ИИ)",
["filter_ai"] = "ИИ-фильтр",
["dup"] = "повтор",
[PipelineRejectStages.Length] = "короткое сообщение",
[PipelineRejectStages.Stop] = "стоп-фраза",
[PipelineRejectStages.Resume] = "резюме соискателя",
[PipelineRejectStages.Type] = "тип заявки",
[PipelineRejectStages.Budget] = "нет суммы",
[PipelineRejectStages.ExcludeKeywords] = "исключение: слова/технологии",
[PipelineRejectStages.ExcludeLocation] = "исключение: локация/язык",
[PipelineRejectStages.ExcludeType] = "исключение: тип",
[PipelineRejectStages.ExcludeBudget] = "исключение: бюджет",
[PipelineRejectStages.Stale] = "устарело",
[PipelineRejectStages.SpamMl] = "спам (ML)",
[PipelineRejectStages.SpamAi] = "спам (ИИ)",
[PipelineRejectStages.FilterAi] = "ИИ-фильтр",
[PipelineRejectStages.Duplicate] = "повтор",
};
/// <summary>
@@ -38,11 +38,11 @@ public static class PipelineRejectConstants
public static readonly IReadOnlyDictionary<string, string> SourceLabels =
new Dictionary<string, string>(StringComparer.Ordinal)
{
["stop"] = "правила",
["ml"] = "ML",
["ai"] = "ИИ",
["stale"] = "система",
["dup"] = "система",
[PipelineRejectSources.Rules] = "правила",
[PipelineRejectSources.Ml] = "ML",
[PipelineRejectSources.Ai] = "ИИ",
[PipelineRejectSources.System] = "система",
[PipelineRejectSources.Duplicate] = "система",
};
/// <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 Deal.Contracts.Integrations.Models;
using Deal.Modules.Kanban.Application.Models;
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)
{
["tg"] = 0,
["phone"] = 1,
["whatsapp"] = 2,
["email"] = 3,
["linkedin"] = 4,
["site"] = 5,
[CardContactTypes.Telegram] = 0,
[CardContactTypes.Phone] = 1,
[CardContactTypes.WhatsApp] = 2,
[CardContactTypes.Email] = 3,
[CardContactTypes.LinkedIn] = 4,
[CardContactTypes.Site] = 5,
};
private const int UnknownTypePriority = 9;
@@ -93,7 +94,7 @@ public static class ContactsQualifier
string name = s[1..].Trim();
if (ProfileHandleRe.IsMatch(name) && !name.EndsWith(BotSuffix, StringComparison.OrdinalIgnoreCase))
{
return new CardContactDto("tg", "@" + name);
return new CardContactDto(CardContactTypes.Telegram, "@" + name);
}
return null;
@@ -106,7 +107,7 @@ public static class ContactsQualifier
if (!ReservedProfileNames.Contains(name.ToLowerInvariant())
&& !name.EndsWith(BotSuffix, StringComparison.OrdinalIgnoreCase))
{
return new CardContactDto("tg", "@" + name);
return new CardContactDto(CardContactTypes.Telegram, "@" + name);
}
return null;
@@ -114,23 +115,23 @@ public static class ContactsQualifier
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());
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))
{
return new CardContactDto("linkedin", s);
return new CardContactDto(CardContactTypes.LinkedIn, s);
}
if (WhatsappRe.IsMatch(s))
{
return new CardContactDto("whatsapp", s);
return new CardContactDto(CardContactTypes.WhatsApp, s);
}
if (s.StartsWith("http", StringComparison.OrdinalIgnoreCase))
@@ -144,7 +145,7 @@ public static class ContactsQualifier
return null;
}
return new CardContactDto("site", s);
return new CardContactDto(CardContactTypes.Site, s);
}
return null;
@@ -4,7 +4,9 @@ using System.Text.Json.Nodes;
using Deal.Contracts.Integrations.Models;
using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Kanban.Application.Services;
using Deal.Modules.Pipeline.Application.Models;
using Deal.Modules.Pipeline.Application.Parse;
using Deal.SharedKernel.Utilities;
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 CurrencyFieldName = "currency";
private const string CurrencyFieldNameShort = "cur";
// Строковые значения bool-полей, которые считаются «ложью».
private static readonly IReadOnlySet<string> FalsyAnswerValues =
new HashSet<string>(StringComparer.Ordinal) { "0", BoolText.False, "no", "нет", "null", "none" };
// Регулярное выражение числа бюджета: число + необязательные «к»/валюта-суффиксы.
private static readonly System.Text.RegularExpressions.Regex BudgetNumRe =
@@ -39,7 +41,7 @@ public static class AiRawCardMapper
JsonObject? root = JsonNode.Parse(json) as JsonObject
?? 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)
{
title = MessageTextCleaner.CleanShort(text, MaxFallbackTextCodePoints); // python L455 fallback
@@ -49,7 +51,7 @@ public static class AiRawCardMapper
AiBudgetDto? budget = ReadBudget(root);
IReadOnlyList<AiContactDto> contacts = ReadContacts(root, text);
string? board = ReadRawString(root, "board").Trim();
string? board = ReadRawString(root, AiRawCardKeys.Board).Trim();
if (board.Length == 0)
{
board = null;
@@ -57,19 +59,19 @@ public static class AiRawCardMapper
return new AiParsedCardDto(
Title: title,
Company: ReadNullableString(root, "company"),
Format: ReadNullableString(root, "format"),
Task: ReadNullableString(root, "task"),
Requirements: ReadItems(root, "requirements"),
Plus: ReadItems(root, "plus"),
Conditions: ReadNullableString(root, "conditions"),
Summary: ReadNullableString(root, "summary"),
Company: ReadNullableString(root, AiRawCardKeys.Company),
Format: ReadNullableString(root, AiRawCardKeys.Format),
Task: ReadNullableString(root, AiRawCardKeys.Task),
Requirements: ReadItems(root, AiRawCardKeys.Requirements),
Plus: ReadItems(root, AiRawCardKeys.Plus),
Conditions: ReadNullableString(root, AiRawCardKeys.Conditions),
Summary: ReadNullableString(root, AiRawCardKeys.Summary),
Stack: stack,
Budget: budget,
Contacts: contacts,
IsVacancy: ReadBool(root, "is_vacancy"),
IsVacancyKnown: ReadBool(root, "is_vacancy_known"), // false — воркер стемпит true после успешного ИИ
IsSpam: ReadBool(root, "is_spam"),
IsVacancy: ReadBool(root, AiRawCardKeys.IsVacancy),
IsVacancyKnown: ReadBool(root, AiRawCardKeys.IsVacancyKnown), // false — воркер стемпит true после успешного ИИ
IsSpam: ReadBool(root, AiRawCardKeys.IsSpam),
Board: board);
}
@@ -114,7 +116,7 @@ public static class AiRawCardMapper
if (value.TryGetValue<string>(out string? text))
{
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;
@@ -147,7 +149,7 @@ public static class AiRawCardMapper
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>();
}
@@ -161,19 +163,19 @@ public static class AiRawCardMapper
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;
}
string currency = ReadRawString(budget, CurrencyFieldName);
string currency = ReadRawString(budget, AiRawCardKeys.Currency);
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? to = ParseBudgetBound(budget, "to");
double? from = ParseBudgetBound(budget, AiRawCardKeys.From);
double? to = ParseBudgetBound(budget, AiRawCardKeys.To);
CardBudgetDto? normalized = BudgetNormalizer.Normalize(new BudgetRangeDto(from, to, currency));
return normalized is null
? null
@@ -226,7 +228,7 @@ public static class AiRawCardMapper
private static IReadOnlyList<AiContactDto> ReadContacts(JsonObject root, string text)
{
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)
{
@@ -245,7 +247,7 @@ public static class AiRawCardMapper
break;
case JsonObject contactObject:
{
string value = ReadRawString(contactObject, "value"); // python L402403: dict.get("value")
string value = ReadRawString(contactObject, AiRawCardKeys.Value);
if (value.Length > 0)
{
candidates.Add(value);
@@ -8,6 +8,7 @@ using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Kanban.Application.Services;
using Deal.Modules.Pipeline.Application.Abstractions;
using Deal.Modules.Pipeline.Application.Models;
using Deal.SharedKernel.Errors;
namespace Deal.Modules.Pipeline.Application.Services;
@@ -34,6 +35,9 @@ public sealed class MlReviewService(
// Размер одного чтения из очереди/отсева при объединении кандидатов.
private const int MaxScan = 500;
// Имя сущности для текста ошибки «не найдено».
private const string MessageEntityName = "Исходное сообщение";
private const int TextPreviewLength = 600;
/// <summary>
@@ -157,8 +161,9 @@ public sealed class MlReviewService(
/// <param name="dialogId">Оригинал источника (OriginRef) записи.</param>
/// <param name="msgId">Внешний id записи в источнике.</param>
/// <param name="action">Действие: <c>skip</c> | <c>spam</c> | <c>board:&lt;id&gt;</c>.</param>
/// <returns>Результат решения; null — исходная запись не найдена (404-семантика эндпоинта).</returns>
public async Task<MlApplyResult?> ApplyAsync(
/// <returns>Результат решения.</returns>
/// <exception cref="NotFoundException">Исходная запись не найдена.</exception>
public async Task<MlApplyResult> ApplyAsync(
string dialogId,
long msgId,
string? action,
@@ -168,17 +173,14 @@ public sealed class MlReviewService(
string dialog = (dialogId ?? string.Empty).Trim();
string externalId = msgId.ToString(CultureInfo.InvariantCulture);
SourceRef? source = await ResolveSourceAsync(dialog, externalId, ct);
if (source is null)
{
return null; // 404: исходная запись не найдена
}
SourceRef source = await ResolveSourceAsync(dialog, externalId, ct)
?? throw new NotFoundException(MessageEntityName, externalId);
CardDto? card = await cardStore.GetCardBySourceAsync(source, ct);
string? text = await FindTextAsync(dialog, externalId, card, ct);
if (string.IsNullOrWhiteSpace(text))
{
return null; // 404: исходная запись не найдена
throw new NotFoundException(MessageEntityName, externalId);
}
if (normalized == ActionSkip)
@@ -218,7 +220,7 @@ public sealed class MlReviewService(
{
// TrashCardAsync(teach:true) сам шлёт обучающий сигнал «спам» — второй сигнал не нужен.
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);
@@ -4,6 +4,7 @@ using Deal.Modules.Cards.Application.Sources;
using Deal.Modules.Pipeline.Application.Abstractions;
using Deal.Modules.Pipeline.Application.Models;
using Deal.Modules.Pipeline.Application.Parse;
using Deal.SharedKernel.Errors;
namespace Deal.Modules.Pipeline.Application.Services;
@@ -46,13 +47,16 @@ public sealed class PipelineProcessingService(
// Вес снятия метки «спам»: реальное действие пользователя «это не спам» (delta=1.0).
private const double SpamUnlearnDelta = -1.0;
private const string DuplicateSource = "dup";
private const string DuplicateSource = PipelineRejectSources.Duplicate;
// Имя сущности для текста ошибки «не найдено».
private const string RejectedEntityName = "Запись отсева";
private static readonly HashSet<string> SpamStages = new(StringComparer.Ordinal)
{
"spam_ml",
"spam_ai",
"filter_ai",
PipelineRejectStages.SpamMl,
PipelineRejectStages.SpamAi,
PipelineRejectStages.FilterAi,
};
@@ -154,17 +158,15 @@ public sealed class PipelineProcessingService(
/// </summary>
/// <param name="rejectedId">Id записи отсева (<c>r_...</c>).</param>
/// <param name="reason">Причина возврата (trim, ≤500; пишется на запись для аудита).</param>
/// <returns>null — записи нет (404); иначе результат: Error (400) либо {id, returned:true, returnedAt}.</returns>
public async Task<RejectReturnResultDto?> ReturnAsync(
/// <returns>Результат: Error (400) либо {id, returned:true, returnedAt}.</returns>
/// <exception cref="NotFoundException">Запись отсева не найдена.</exception>
public async Task<RejectReturnResultDto> ReturnAsync(
string rejectedId,
string reason,
CancellationToken ct)
{
RejectedItemDto? row = await store.GetAsync(rejectedId, ct);
if (row is null)
{
return null;
}
RejectedItemDto row = await store.GetAsync(rejectedId, ct)
?? throw new NotFoundException(RejectedEntityName, rejectedId);
if (row.Returned)
{
@@ -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";
}
@@ -26,14 +26,14 @@ public sealed partial class SettingsService
continue;
}
string name = ReadTrimmedField(item, "name", PromptNameMaxLength);
string prompt = ReadTrimmedField(item, "prompt", PromptTextMaxLength);
string name = ReadTrimmedField(item, SettingsFieldKeys.Name, PromptNameMaxLength);
string prompt = ReadTrimmedField(item, SettingsFieldKeys.Prompt, PromptTextMaxLength);
if (name.Length == 0 || prompt.Length == 0)
{
continue; // пустые name/prompt — дроп
}
string description = ReadTrimmedField(item, "description", PromptDescriptionMaxLength);
string description = ReadTrimmedField(item, SettingsFieldKeys.Description, PromptDescriptionMaxLength);
string id = ReadPromptId(item);
clean.Add(new MyPromptDto(id, name, description, prompt));
}
@@ -46,19 +46,19 @@ public sealed partial class SettingsService
// каталогом — иначе тенант мог бы перенаправить ключ/запрос на произвольный внутренний адрес
// (SSRF, Security review) и проверка подключения ушла бы на него.
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;
}
if (TryReadFieldText(entry, "model", out string newModel))
if (TryReadFieldText(entry, SettingsFieldKeys.Model, out string newModel))
{
model = newModel;
}
// apiKey: непустой, ≥8 симв., без префикса enc: и без маски (содержит "…") → шифруется;
// иначе (пустой/маска) ключ не меняется.
if (TryReadFieldText(entry, "apiKey", out string newKey)
if (TryReadFieldText(entry, SettingsFieldKeys.ApiKey, out string newKey)
&& newKey.Length >= ApiKeyMinLength
&& !newKey.StartsWith(EncryptedValuePrefix, StringComparison.Ordinal)
&& !newKey.Contains(MaskEllipsis, StringComparison.Ordinal))
@@ -1,6 +1,7 @@
using System.Globalization;
using System.Text.Json;
using Deal.Modules.Settings.Application.Models;
using Deal.SharedKernel.Utilities;
namespace Deal.Modules.Settings.Application.Services;
@@ -82,11 +83,11 @@ public sealed partial class SettingsService
return true;
case JsonValueKind.True:
text = "true";
text = BoolText.True;
return true;
case JsonValueKind.False:
text = "false";
text = BoolText.False;
return true;
default:
@@ -218,9 +219,9 @@ public sealed partial class SettingsService
continue;
}
string apiKey = TryReadFieldText(provider.Value, "apiKey", out string storedKey) ? storedKey : string.Empty;
string baseUrl = TryReadFieldText(provider.Value, "baseUrl", out string storedBase) ? storedBase : string.Empty;
string model = TryReadFieldText(provider.Value, "model", out string storedModel) ? storedModel : string.Empty;
string apiKey = TryReadFieldText(provider.Value, SettingsFieldKeys.ApiKey, out string storedKey) ? storedKey : string.Empty;
string baseUrl = TryReadFieldText(provider.Value, SettingsFieldKeys.BaseUrl, out string storedBase) ? storedBase : string.Empty;
string model = TryReadFieldText(provider.Value, SettingsFieldKeys.Model, out string storedModel) ? storedModel : string.Empty;
merged[provider.Name] = new AiConfigSetting(apiKey, baseUrl, model);
}
}
@@ -41,6 +41,16 @@ public interface IAuthStore
/// <returns>Пользователи тенанта, упорядоченные по времени создания (пусто — пользователей нет).</returns>
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>
@@ -27,7 +27,7 @@ public interface ITenantRepository
public Task<IReadOnlyList<TenantRecordDto>> ListAsync(CancellationToken ct);
/// <summary>
/// Страница реестра тенантов (шардированный обход для 1000+ схем).
/// Возвращает страницу реестра тенантов (шардированный обход для 1000+ схем).
/// </summary>
/// <param name="offset">Сдвиг от начала (устойчивый порядок — CreatedAt, затем Id).</param>
/// <param name="limit">Размер страницы (≥1; валидирует потребитель).</param>
@@ -37,6 +37,16 @@ public interface ITenantRepository
int limit,
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>
@@ -1,8 +1,27 @@
using System.Text.Json;
namespace Deal.Modules.Tenants.Application.Models;
// Расширения записей аудита
internal static class AuditRecordDtoExtensions
/// <summary>
/// Расширения записей аудита
/// </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";
// События аудита «неудачный вход» (тенант/оператор).
private static readonly string[] FailedLoginEvents =
{
@@ -30,4 +49,130 @@ internal static class AuditRecordDtoExtensions
/// <param name="record">Запись аудита.</param>
/// <returns>True — событие из SuccessfulLoginEvents.</returns>
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 = property.Name.StartsWith(OldPrefix, StringComparison.Ordinal)
&& property.Name.Length > OldPrefix.Length
? NewPrefix + property.Name[OldPrefix.Length..]
: 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(),
_ => 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);

Some files were not shown because too many files have changed in this diff Show More