75 Commits
Author SHA1 Message Date
rust dda314cddc Merge pull request 'Аудит: читаемые имена и «было → стало» вместо сырых данных' (#28) from t25_audit_readable into main
ci / build-test (push) Successful in 2m48s
Reviewed-on: #28
2026-09-14 23:36:10 +03:00
stepan 16f4270995 Скрывать в аудите изменения без изменения значения
ci / build-test (pull_request) Successful in 2m59s
2026-09-13 21:45:26 +03:00
stepan ac9d92d196 Подписывать настройки и колонки в аудите по-русски
ci / build-test (pull_request) Successful in 2m59s
2026-09-13 21:39:03 +03:00
stepan fb964f9b4f Показывать в аудите имена карточек и колонок вместо идентификаторов 2026-09-13 21:39:03 +03:00
stepan f7459069d1 Нормализовать список изменённых настроек в аудите 2026-09-13 21:39:02 +03:00
stepan 37b7524665 Исправить пробел в строке изменения аудита
ci / build-test (pull_request) Successful in 2m59s
2026-09-13 21:21:02 +03:00
stepan d0e202fbc6 Показывать в аудите значения изменённых настроек
settings_updated пишет по каждому ключу «поле: было → стало» (скаляры и списки; секреты и снимки состояния — пометкой «изменено»), длинные значения обрезаются.
2026-09-13 21:21:02 +03:00
stepan 905129effa Добавить подпись поля идентификатора в аудите
ci / build-test (pull_request) Successful in 3m0s
2026-09-13 21:08:15 +03:00
stepan f31989a133 Показывать в аудите изменение «было → стало»
Перемещение карточки пишет прежнюю и новую колонку (CardMoveResultDto.From/To), изменение колонки — переименование «было → стало», разбор старого плоского формата спаривает oldBudget/budgetTokens и oldPeriod/period. Добавлены тесты разбора.
2026-09-13 21:08:15 +03:00
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
rust beaf20df42 Merge pull request 'Запретить возврат null, унифицировать исключения и вынести тексты в ресурсы' (#20) from t16_null_exceptions_resources into main
ci / build-test (push) Successful in 3m30s
Reviewed-on: #20
2026-09-13 14:54:26 +03:00
rust 4526532b1a Merge branch 'main' into t16_null_exceptions_resources
ci / build-test (pull_request) Successful in 3m1s
2026-09-13 14:51:18 +03:00
rust 79793a7635 Merge pull request 'Проверить явную реализацию интерфейсов и дублирование summary' (#19) from t15_explicit_ifaces into main
ci / build-test (push) Successful in 3m23s
Reviewed-on: #19
2026-09-13 14:50:23 +03:00
stepan 8661faea70 Перевести задачи Discovery на NotFoundException
ci / build-test (pull_request) Successful in 2m52s
DiscoveryTasksService (get/patch/delete/start/pause) бросает NotFoundException вместо null; эндпоинты отдают 404 через общий обработчик.
2026-09-13 14:32:27 +03:00
stepan 685c5fdf46 Перевести «не найдено» карточек и колонок на исключения
CardsService (карточка/файл) и ContainersService бросают NotFoundException вместо возврата null; эндпоинты больше не проверяют null — 404 отдаёт общий обработчик. Тесты обновлены под новое поведение.
2026-09-13 14:32:24 +03:00
stepan 7c43c40282 Ввести доменные исключения и ресурсы текстов ошибок
DealException/NotFoundException/Validation/Conflict/ServiceUnavailable с кодом ошибки, тексты в ErrorMessages.resx. Общий HTTP-обработчик (DealExceptionHandler) и маппинг в gRPC-интерцепторе: доменные ошибки → статус, прочие → обобщённый текст без стектрейса. Правила закреплены в код-стайле §10.
2026-09-13 14:32:20 +03:00
stepan b8570e3197 Убрать XML-доки из реализаций интерфейсов
ci / build-test (pull_request) Successful in 2m53s
Описание живёт только в интерфейсе: удалены классовые summary и
док-блоки членов-реализаций (28 файлов). Правило уточнено в §11.
2026-09-13 14:15:23 +03:00
stepan 0d2204219a Явные реализации интерфейсов в проде
TenantContext, LocalTelegramGateway, DefaultPasswordHasher и
WTelegramSessionClient переведены на явные реализации; типы в тестах
приведены к ITenantContext.
2026-09-13 14:15:23 +03:00
rust a2da86ced7 Merge pull request 'Внедрить современный observability-стек (логи, метрики, трейсы, ресурсы)' (#18) from t17_observability into main
ci / build-test (push) Successful in 3m32s
Reviewed-on: #18
2026-09-13 14:05:07 +03:00
stepan 1c0c35946d Обновить документацию observability
ci / build-test (pull_request) Successful in 2m52s
2026-09-13 13:47:31 +03:00
stepan a6340ab732 Отключить ресурсные алерты по умолчанию
Правила вынесены в prometheus-resource-rules.yml (не в rule_files);
пороги — через env DEAL_ALERT_* при включении.
2026-09-13 13:47:31 +03:00
stepan 7ddde4e365 Трейсинг OTLP — опт-ин; storage-service в prod
- endpoint коллектора по умолчанию пуст: трейсы включаются только при
  заданном DEAL_OTEL_ENDPOINT;
- storage-service добавлен в prod-compose (mTLS, OTel env, healthcheck);
- node-exporter: убран rslave (несовместим с Docker Desktop).
2026-09-13 13:47:31 +03:00
stepan 7ffde52202 Обновить техдокументацию по observability
ci / build-test (pull_request) Successful in 2m50s
2026-09-13 04:47:27 +03:00
stepan 881fb837b5 Развернуть observability-стек: Collector, Tempo, cAdvisor, node-exporter
Профиль observability: otel-collector (приём OTLP) -> tempo (трейсы),
cadvisor/node-exporter (ресурсы) в Prometheus, дашборды Deal-Traces и
Deal-Resources, datasource Tempo и связь логов с трейсами, алерты по
ресурсам; OTel-env у всех процессов.
2026-09-13 04:47:27 +03:00
stepan 99828857ef Добавить трейсинг OpenTelemetry (OTLP) во все сервисы
Общая настройка DealTracingHosting (Deal.Grpc.Hosting + Deal.Api):
AspNetCore/Http/GrpcNetClient инструментация, OTLP-экспорт во внешний
коллектор, имя сервиса из env. Логи обогащаются TraceId/SpanId.
2026-09-13 04:47:27 +03:00
rust ae7014ccda Merge pull request 'Внедрить фабрики объектов вместо прямых new' (#14) from t12_object_factories into main
ci / build-test (push) Successful in 2m48s
Reviewed-on: #14
2026-09-13 04:25:18 +03:00
stepan 94fdfeb4e5 Реализации интерфейсов — без XML-доков (код-стайл)
ci / build-test (pull_request) Successful in 2m48s
2026-09-13 04:17:44 +03:00
stepan a9d26b6ffc Убрать XML-доки из реализаций фабрик 2026-09-13 04:17:43 +03:00
stepan 58df018ea4 Запретить самари конструкторов в код-стайле
ci / build-test (pull_request) Successful in 2m52s
2026-09-13 04:10:21 +03:00
stepan 418f6efe39 Убрать самари конструкторов фабрик 2026-09-13 04:10:21 +03:00
stepan c7789c0b21 Сделать реализацию интерфейсов фабрик явной
ci / build-test (pull_request) Successful in 3m9s
По код-стайлу §11 метод Create реализуется явно
(ISecretCipher ISecretCipherFactory.Create()), интерфейс вызывается
только по порту; тест фабрики типизирован интерфейсом.
2026-09-13 03:59:30 +03:00
stepan 3bcf4074b5 Стабилизировать ingress-тесты без глобального env
ci / build-test (pull_request) Successful in 2m50s
TelegramIngressTestHost больше не мутирует процессную переменную
DEAL_SERVICE_TOKEN: токен задаётся in-memory конфигурацией хоста.
Три ingress-тест-класса шли параллельно и перетирали env друг другу.
2026-09-13 03:54:36 +03:00
stepan f788b6d39b Зафиксировать правило фабрик и билдеров в код-стайле
ci / build-test (pull_request) Successful in 2m49s
2026-09-13 03:41:39 +03:00
stepan d172b6408b Внедрить фабрики объектов вместо прямого new
Создание сервисов с порт-интерфейсом перенесено в IXxxFactory:
шифр секретов, хранилище лимитов, ИИ-классификатор, ИИ-инструменты,
файловое хранилище (Local/MinIO). Регистраторы и DiscoveryWorkerService
больше не создают реализации напрямую. Добавлен тест FileStorageFactory.
2026-09-13 03:41:39 +03:00
rust b58ac08717 Merge pull request 'Перевести тестовые фейки на моки NSubstitute' (#13) from t1_NSubstitute_tests into main
ci / build-test (push) Successful in 3m8s
Reviewed-on: #13
2026-09-13 03:28:11 +03:00
stepan 93cadcb689 Перевести FakeKanjStore на NSubstitute
ci / build-test (pull_request) Successful in 3m13s
2026-09-13 03:00:01 +03:00
stepan 3cf95ba303 Перевести FakePipelineStore на NSubstitute 2026-09-13 02:56:43 +03:00
stepan d07875f146 Перевести FakeAuthStore на NSubstitute 2026-09-13 02:54:04 +03:00
stepan a3b193e618 Перевести FakeMlLearningStore на NSubstitute
Подставка IMlLearningStore переведена на NSubstitute (TestMlLearningStore.Store); динамический CountOutboxAsync; обновлены 5 потребителей.
2026-09-13 02:49:08 +03:00
stepan d51d88ed65 Перевести FakeSettingsStore на NSubstitute (42 файла потребителей)
Хелпер Support/TestSettingsStore: словарь настроек, GetAsync/GetAllAsync/SetAsync/
RemoveAsync читают состояние на момент вызова (GetAllAsync — ленивый, ключ для
снапшот-сервисов). DI получает .Store, тесты сохраняют Preload/GetStoredJson.
2026-09-13 01:22:10 +03:00
stepan 3ccbe4c9f5 Перевести FakeMlClient на NSubstitute
Хелпер Support/TestMlClient: Status/Predict сценарии через свойства, Pushed
журнал, PredictCalls через When/Do, ResetAsync бросает NotSupportedException.
Потребители (17 файлов) перетипизированы на .Client, фейк удалён,
тесты 1340 зелёные.
2026-09-13 01:07:43 +03:00
stepan c5fd516a36 Перевести FakeTenantLimitStore на NSubstitute
Хелпер Support/TestTenantLimitStore: строки лимитов с полной бюджетной
семантикой (GetOrCreate/GetState/AddUsage/UpdateBudget, Warned80/
NotifiedExhausted, сброс истёкших периодов, сценарий сбоя чтения), DI получает
.Store. Потребители (13 файлов) перетипизированы, фейк удалён,
тесты 1340 зелёные.
2026-09-13 01:02:35 +03:00
stepan a63bff7904 Перевести FakeOperatorAuthStore на NSubstitute
Хелпер Support/TestOperatorAuthStore: операторы/сессии/журнал Calls, поиск и
мутации через Returns/When-Do. Потребители (14 файлов) перетипизированы на
.Store, фейк удалён, тесты 1340 зелёные.
2026-09-13 00:47:05 +03:00
stepan 79b1893b28 Перевести FakeAuditLogStore на NSubstitute
Хелпер Support/TestAuditLogStore: журнал + фильтры Query/Count, PurgeOlderThan,
Id присваивается при добавлении. Потребители (13 файлов) перетипизированы на
.Store, фейк удалён, тесты 1340 зелёные.
2026-09-13 00:32:51 +03:00
stepan be8e8654df Перевести FakePipelineStore на NSubstitute
Хелпер Support/TestPipelineStore: очередь/отсев/дедуп с полной семантикой
(upsert с сохранением Returned, LIKE-поиск, атомарный claim, purge), сценарий
claim-гонки параметром (claimRace/claimResult), счётчики ClaimCalls/
DeleteClaimCalls через When/Do. Потребители (12 файлов) перетипизированы на
.Store, фейк удалён, тесты 1340 зелёные.
2026-09-13 00:25:49 +03:00
stepan 56a4824fd8 Отменять незавершённый прогон при новом коммите того же рефа
concurrency group по ref + cancel-in-progress: свежий коммит в ветку МР
(или мерж в main) отменяет ещё не закончившийся предыдущий прогон.
Завершённые прогоны не затрагиваются.
2026-09-13 00:25:49 +03:00
stepan 01cd29efed Ограничить CI: только МР и влитие в main
push фильтрован по main (мерж МР триггерит прогон), pull_request —
opened/synchronize/reopened (новые коммиты в ветку МР перезапускают прогон).
Обычные коммиты в ветки без МР больше не запускают CI.
2026-09-13 00:25:49 +03:00
rust 9db914ad00 Merge pull request 'Перенести документацию в вики Gitea' (#11) from t10_Docs_to_wiki into main
ci / build-test (push) Successful in 3m1s
Reviewed-on: #11
2026-09-13 00:19:37 +03:00
stepan 45065d3202 Восстановить docs/ как зеркало для агентов (ревью МР #11)
ci / build-test (push) Successful in 2m44s
ci / build-test (pull_request) Successful in 2m46s
Ревью rust: перенос в вики не должен удалять из репозитория то, что нужно
агенту для работы (бэклог, статус, планы, код-стайл, спеки). Дублирование
вики и репы разрешено и обязательно: вики — актуальные версии для людей,
docs/ — зеркало для контекста агентов. README разведён по ролям.
2026-09-13 00:03:43 +03:00
stepan 3a26f8d4a5 Убрать docs/ из репозитория — документация перенесена в вики
README ссылается на вики-страницы (ТЗ, техдок, api-карта, инструкция,
код-стайл, бэклог, статус). Код не затронут.
2026-09-13 00:03:43 +03:00
302 changed files with 7257 additions and 4258 deletions
+17
View File
@@ -1,10 +1,27 @@
name: ci name: ci
# Прогоняем CI в двух случаях:
# 1) МР: создан, переоткрыт или в ветку МР запушены новые коммиты (synchronize).
# 2) Влитие в main (пуш в main — в т.ч. мерж МР).
# Обычные коммиты в ветки без открытого МР CI не запускают.
on: on:
push: push:
branches:
- main
pull_request: pull_request:
types:
- opened
- synchronize
- reopened
workflow_dispatch: workflow_dispatch:
# Новый прогон того же рефа (та же ветка МР / тот же main) отменяет незавершённый предыдущий:
# коммит-А стартовал, через пару минут коммит-Б — прогон А отменяется, если ещё не закончил.
# Завершённые (успех/фейл) прогоны не трогаются.
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs: jobs:
build-test: build-test:
runs-on: ubuntu-latest runs-on: ubuntu-latest
+3
View File
@@ -42,3 +42,6 @@ deploy/gitea-runner/data/
# Служебные скрипты не входят в репозиторий (правило: в репе только код) — живут только локально # Служебные скрипты не входят в репозиторий (правило: в репе только код) — живут только локально
scripts/ scripts/
# Локальный клон вики
.wiki-clone/
+12 -9
View File
@@ -2,18 +2,21 @@
SaaS-мониторинг Telegram: реальные заказы и клиенты вместо рекламы и дубликатов. SaaS-мониторинг Telegram: реальные заказы и клиенты вместо рекламы и дубликатов.
## Документация (актуальное) ## Документация
- **ТЗ**: `docs/spec/ТЗ-дейл-новая-архитектура.md` Актуальные версии для людей — в [вики проекта](https://gitea.khomegeneric.keenetic.pro/rust/Deal/wiki/Home);
- **Инструкция пользователя**: `docs/user-guide/Инструкция-пользователя-Дейл.md` в `docs/` — зеркала для работы агентов (дублирование разрешено и нужно).
- **Техническая документация** (стек, развёртывание, эксплуатация): `docs/technical/Техническая-документация-Дейл.md`
- **Карта API**: `docs/api/api-map.md` - **ТЗ**: [https://gitea.khomegeneric.keenetic.pro/rust/Deal/wiki/ТЗ](https://gitea.khomegeneric.keenetic.pro/rust/Deal/wiki/ТЗ)
- **Статус и борд состояния**: `docs/superpowers/STATUS.md` - **Инструкция пользователя**: [https://gitea.khomegeneric.keenetic.pro/rust/Deal/wiki/Инструкция-пользователя](https://gitea.khomegeneric.keenetic.pro/rust/Deal/wiki/Инструкция-пользователя)
- **Бэклог (техдолг и отложенное)**: `docs/superpowers/backlog.md` - **Техническая документация** (стек, развёртывание, эксплуатация): [https://gitea.khomegeneric.keenetic.pro/rust/Deal/wiki/Техническая-документация](https://gitea.khomegeneric.keenetic.pro/rust/Deal/wiki/Техническая-документация)
- **Код-стайл (полный свод правил)**: `docs/spec/Код-стайл-Дейл.md` - **Карта API**: [https://gitea.khomegeneric.keenetic.pro/rust/Deal/wiki/API-карта](https://gitea.khomegeneric.keenetic.pro/rust/Deal/wiki/API-карта)
- **Статус и борд состояния**: [https://gitea.khomegeneric.keenetic.pro/rust/Deal/wiki/Статус-разработки](https://gitea.khomegeneric.keenetic.pro/rust/Deal/wiki/Статус-разработки)
- **Бэклог (техдолг и отложенное)**: [https://gitea.khomegeneric.keenetic.pro/rust/Deal/wiki/Бэклог](https://gitea.khomegeneric.keenetic.pro/rust/Deal/wiki/Бэклог)
- **Код-стайл (полный свод правил)**: [https://gitea.khomegeneric.keenetic.pro/rust/Deal/wiki/Код-стайл](https://gitea.khomegeneric.keenetic.pro/rust/Deal/wiki/Код-стайл)
Исходники: `src/` (`core`, `frontend`, `ai-service`, `ml-service`, `telegram-service`, `contracts`, `grpc-hosting`). Исходники: `src/` (`core`, `frontend`, `ai-service`, `ml-service`, `telegram-service`, `contracts`, `grpc-hosting`).
Планы и ledgers этапов: `docs/superpowers/plans/`, `.superpowers/sdd/`. Планы и ledgers этапов — в вики (раздел «Историческое» в Sidebar).
Легаси-прототип LeadRadar и служебные скрипты в репозиторий не входят — живут локально, вне кода. Легаси-прототип LeadRadar и служебные скрипты в репозиторий не входят — живут локально, вне кода.
## Запуск dev-окружения ## Запуск dev-окружения
+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
}
}
+137
View File
@@ -108,6 +108,9 @@ services:
Storage__Minio__SecretKey: deal_minio_secret Storage__Minio__SecretKey: deal_minio_secret
Storage__Minio__Bucket: deal-files Storage__Minio__Bucket: deal-files
Storage__Minio__Secure: "false" Storage__Minio__Secure: "false"
# Трейсинг OTel → коллектор профиля observability (без коллектора трейсы не экспортируются).
OTEL_EXPORTER_OTLP_ENDPOINT: ${DEAL_OTEL_ENDPOINT:-}
OTEL_SERVICE_NAME: core
ports: ports:
- "5080:5080" - "5080:5080"
- "5082:5082" # gRPC-ингресс telegram-service (сервисы ходят на http://core:5082 внутри сети) - "5082:5082" # gRPC-ингресс telegram-service (сервисы ходят на http://core:5082 внутри сети)
@@ -146,6 +149,8 @@ services:
DEAL_TELEGRAM_SESSION_KEY: ${DEAL_TELEGRAM_SESSION_KEY:-ZmVkY2JhOTg3NjU0MzIxMGZlZGNiYTk4NzY1NDMyMTA=} DEAL_TELEGRAM_SESSION_KEY: ${DEAL_TELEGRAM_SESSION_KEY:-ZmVkY2JhOTg3NjU0MzIxMGZlZGNiYTk4NzY1NDMyMTA=}
DEAL_TELEGRAM_SESSION_DIR: /data/sessions DEAL_TELEGRAM_SESSION_DIR: /data/sessions
DEAL_LOGS_DIR: /tmp/logs DEAL_LOGS_DIR: /tmp/logs
OTEL_EXPORTER_OTLP_ENDPOINT: ${DEAL_OTEL_ENDPOINT:-}
OTEL_SERVICE_NAME: telegram-service
SERVICES__CORE__INGRESS: ${DEAL_CORE_INGRESS:-http://core:5082} SERVICES__CORE__INGRESS: ${DEAL_CORE_INGRESS:-http://core:5082}
ports: ports:
- "5101:5101" - "5101:5101"
@@ -176,6 +181,8 @@ services:
GRPC_PORT: "5102" GRPC_PORT: "5102"
DEAL_SERVICE_TOKEN: ${DEAL_SERVICE_TOKEN:-deal_dev_service_token} DEAL_SERVICE_TOKEN: ${DEAL_SERVICE_TOKEN:-deal_dev_service_token}
DEAL_LOGS_DIR: /tmp/logs DEAL_LOGS_DIR: /tmp/logs
OTEL_EXPORTER_OTLP_ENDPOINT: ${DEAL_OTEL_ENDPOINT:-}
OTEL_SERVICE_NAME: ai-service
ports: ports:
- "5102:5102" - "5102:5102"
healthcheck: healthcheck:
@@ -203,6 +210,8 @@ services:
DEAL_SERVICE_TOKEN: ${DEAL_SERVICE_TOKEN:-deal_dev_service_token} DEAL_SERVICE_TOKEN: ${DEAL_SERVICE_TOKEN:-deal_dev_service_token}
DEAL_ML_DATA_DIR: /data/ml # файлы моделей data/ml/<tenantId>.sqlite на volume deal_ml_data (Ruling 4/12) DEAL_ML_DATA_DIR: /data/ml # файлы моделей data/ml/<tenantId>.sqlite на volume deal_ml_data (Ruling 4/12)
DEAL_LOGS_DIR: /tmp/logs DEAL_LOGS_DIR: /tmp/logs
OTEL_EXPORTER_OTLP_ENDPOINT: ${DEAL_OTEL_ENDPOINT:-}
OTEL_SERVICE_NAME: ml-service
ports: ports:
- "5103:5103" - "5103:5103"
volumes: volumes:
@@ -233,6 +242,8 @@ services:
DEAL_STORAGE_BUCKET: deal-attachments DEAL_STORAGE_BUCKET: deal-attachments
DEAL_STORAGE_SECURE: "false" DEAL_STORAGE_SECURE: "false"
DEAL_LOGS_DIR: /tmp/logs DEAL_LOGS_DIR: /tmp/logs
OTEL_EXPORTER_OTLP_ENDPOINT: ${DEAL_OTEL_ENDPOINT:-}
OTEL_SERVICE_NAME: storage-service
ports: ports:
- "5104:5104" - "5104:5104"
depends_on: depends_on:
@@ -243,6 +254,18 @@ services:
timeout: 3s timeout: 3s
retries: 10 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) # Prometheus (профиль observability, этап 12/пакет A) — сбор /metrics всех 4 процессов (:9464)
# внутри dev-сети. Подъём: docker compose -f deploy/compose.dev.yml --profile observability up -d. # внутри dev-сети. Подъём: docker compose -f deploy/compose.dev.yml --profile observability up -d.
# Конфиг — общий deploy/observability/prometheus.yml (те же имена сервисов и таргеты). UI — 9090. # Конфиг — общий deploy/observability/prometheus.yml (те же имена сервисов и таргеты). UI — 9090.
@@ -261,6 +284,116 @@ services:
- ./observability/prometheus-rules.yml:/etc/prometheus/prometheus-rules.yml:ro - ./observability/prometheus-rules.yml:/etc/prometheus/prometheus-rules.yml:ro
- deal_prometheus_data:/prometheus - deal_prometheus_data:/prometheus
# OpenTelemetry Collector — приём трейсов Deal-процессов (OTLP) → Tempo (профиль observability).
otel-collector:
image: otel/opentelemetry-collector-contrib:0.160.0
container_name: deal-otel-collector
profiles: ["observability"]
command: ["--config=/etc/otelcol-contrib/config.yaml"]
ports:
- "4317:4317"
- "4318:4318"
volumes:
- ./observability/otel-collector.yml:/etc/otelcol-contrib/config.yaml:ro
depends_on:
tempo:
condition: service_started
# Tempo — хранилище трейсов (OTLP от коллектора), UI/API — :3200 (профиль observability).
tempo:
image: grafana/tempo:2.8.1
container_name: deal-tempo
profiles: ["observability"]
command: ["-config.file=/etc/tempo.yml"]
ports:
- "3200:3200"
volumes:
- ./observability/tempo.yml:/etc/tempo.yml:ro
- deal_tempo_data:/var/tempo
# cAdvisor — ресурсы контейнеров (CPU/RAM/сеть/диск); scrape — job cadvisor.
cadvisor:
image: gcr.io/cadvisor/cadvisor:v0.52.1
container_name: deal-cadvisor
profiles: ["observability"]
privileged: true
devices:
- /dev/kmsg:/dev/kmsg
volumes:
- /:/rootfs:ro
- /var/run:/var/run:ro
- /sys:/sys:ro
- /var/lib/docker/:/var/lib/docker:ro
- /dev/disk/:/dev/disk:ro
# node-exporter — ресурсы хоста (CPU/RAM/диск/сеть); scrape — job node-exporter.
node-exporter:
image: prom/node-exporter:v1.9.1
container_name: deal-node-exporter
profiles: ["observability"]
command:
- --path.procfs=/host/proc
- --path.sysfs=/host/sys
- --path.rootfs=/host/root
- --collector.filesystem.mount-points-exclude=^/(sys|proc|dev|host|etc)($|/)
ports:
- "9100:9100"
volumes:
- /proc:/host/proc:ro
- /sys:/host/sys:ro
- /:/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: volumes:
deal_pgdata: deal_pgdata:
deal_minio_data: deal_minio_data:
@@ -268,3 +401,7 @@ volumes:
deal_ml_data: deal_ml_data:
deal_api_data: deal_api_data:
deal_prometheus_data: deal_prometheus_data:
deal_tempo_data:
deal_loki_data:
deal_promtail_data:
deal_grafana_data:
+111 -5
View File
@@ -6,12 +6,12 @@
# Состав (всё в одной внутренней сети compose, наружу — ТОЛЬКО caddy :80/:443): # Состав (всё в одной внутренней сети compose, наружу — ТОЛЬКО caddy :80/:443):
# postgres, minio — хранилища БЕЗ host-портов (volume'ы); # postgres, minio — хранилища БЕЗ host-портов (volume'ы);
# core (:5080 HTTP + :5082 gRPC-ингресс), telegram-service (:5101), ai-service (:5102), # core (:5080 HTTP + :5082 gRPC-ингресс), telegram-service (:5101), ai-service (:5102),
# ml-service (:5103) — процессы «Дейла»; mTLS-транспорт — по env Ruling 6 (см. ниже); # ml-service (:5103), storage-service (:5104) — процессы «Дейла»; mTLS-транспорт — по env Ruling 6 (см. ниже);
# caddy — edge: TLS-терминация, статика фронта, reverse_proxy /api → core. # caddy — edge: TLS-терминация, статика фронта, reverse_proxy /api → core.
# loki/promtail/grafana/prometheus — observability (Ruling 7; метрики — этап 12, пакет A): ПРОФИЛЬ # observability (ПРОФИЛЬ `observability`) — современный стек: otel-collector (приём трейсов OTLP),
# `observability` — поднимается только: docker compose --profile observability up -d # tempo (хранилище трейсов), loki/promtail (логи), prometheus (метрики),
# (или ... up -d --profile observability). Prometheus scrape'ит /metrics # cadvisor/node-exporter (потребление ресурсов контейнеров/хоста), grafana (UI).
# (порт 9464) всех 4 процессов; Grafana — логи (Loki) и метрики (Prometheus). # Подъём: docker compose --profile observability up -d.
# #
# Секреты — ТОЛЬКО из env: шаблон deploy/.env.prod.example → скопируйте в deploy/.env.prod, # Секреты — ТОЛЬКО из env: шаблон deploy/.env.prod.example → скопируйте в deploy/.env.prod,
# заполните значения и запускайте с --env-file: # заполните значения и запускайте с --env-file:
@@ -125,6 +125,7 @@ services:
Services__Ai__Endpoint: ${DEAL_AI_ENDPOINT:-http://ai-service:5102} Services__Ai__Endpoint: ${DEAL_AI_ENDPOINT:-http://ai-service:5102}
Services__Telegram__UseLocal: "false" Services__Telegram__UseLocal: "false"
Services__Telegram__Endpoint: ${DEAL_TELEGRAM_ENDPOINT:-http://telegram-service:5101} Services__Telegram__Endpoint: ${DEAL_TELEGRAM_ENDPOINT:-http://telegram-service:5101}
Services__Storage__Endpoint: ${DEAL_STORAGE_SERVICE_ENDPOINT:-http://storage-service:5104}
# Файлы — MinIO (внутренний http; TLS minio — вне этапа, при желании Storage__Minio__Secure=true # Файлы — MinIO (внутренний http; TLS minio — вне этапа, при желании Storage__Minio__Secure=true
# + endpoint https и сертификаты). # + endpoint https и сертификаты).
Storage__Minio__Endpoint: minio:9000 Storage__Minio__Endpoint: minio:9000
@@ -132,6 +133,9 @@ services:
Storage__Minio__SecretKey: ${MINIO_ROOT_PASSWORD:?MINIO_ROOT_PASSWORD не задан} Storage__Minio__SecretKey: ${MINIO_ROOT_PASSWORD:?MINIO_ROOT_PASSWORD не задан}
Storage__Minio__Bucket: deal-files Storage__Minio__Bucket: deal-files
Storage__Minio__Secure: "false" Storage__Minio__Secure: "false"
# Трейсинг OTel → коллектор профиля observability; без коллектора трейсы не экспортируются.
OTEL_EXPORTER_OTLP_ENDPOINT: ${DEAL_OTEL_ENDPOINT:-}
OTEL_SERVICE_NAME: core
# mTLS внутреннего gRPC (Ruling 6; пути — /etc/deal/certs, см. volume ниже). # mTLS внутреннего gRPC (Ruling 6; пути — /etc/deal/certs, см. volume ниже).
DEAL_MTLS_ENABLED: ${DEAL_MTLS_ENABLED:-0} DEAL_MTLS_ENABLED: ${DEAL_MTLS_ENABLED:-0}
DEAL_MTLS_CA_PEM: /etc/deal/certs/ca.pem DEAL_MTLS_CA_PEM: /etc/deal/certs/ca.pem
@@ -172,6 +176,8 @@ services:
DEAL_TELEGRAM_SESSION_KEY: ${DEAL_TELEGRAM_SESSION_KEY:?DEAL_TELEGRAM_SESSION_KEY не задан (ключ AES-GCM сессий)} DEAL_TELEGRAM_SESSION_KEY: ${DEAL_TELEGRAM_SESSION_KEY:?DEAL_TELEGRAM_SESSION_KEY не задан (ключ AES-GCM сессий)}
DEAL_TELEGRAM_SESSION_DIR: /data/sessions DEAL_TELEGRAM_SESSION_DIR: /data/sessions
DEAL_LOGS_DIR: /tmp/logs DEAL_LOGS_DIR: /tmp/logs
OTEL_EXPORTER_OTLP_ENDPOINT: ${DEAL_OTEL_ENDPOINT:-}
OTEL_SERVICE_NAME: telegram-service
SERVICES__CORE__INGRESS: ${DEAL_CORE_INGRESS:-http://core:5082} SERVICES__CORE__INGRESS: ${DEAL_CORE_INGRESS:-http://core:5082}
DEAL_MTLS_ENABLED: ${DEAL_MTLS_ENABLED:-0} DEAL_MTLS_ENABLED: ${DEAL_MTLS_ENABLED:-0}
DEAL_MTLS_CA_PEM: /etc/deal/certs/ca.pem DEAL_MTLS_CA_PEM: /etc/deal/certs/ca.pem
@@ -201,6 +207,8 @@ services:
GRPC_PORT: "5102" GRPC_PORT: "5102"
DEAL_SERVICE_TOKEN: ${DEAL_SERVICE_TOKEN:?DEAL_SERVICE_TOKEN не задан} DEAL_SERVICE_TOKEN: ${DEAL_SERVICE_TOKEN:?DEAL_SERVICE_TOKEN не задан}
DEAL_LOGS_DIR: /tmp/logs DEAL_LOGS_DIR: /tmp/logs
OTEL_EXPORTER_OTLP_ENDPOINT: ${DEAL_OTEL_ENDPOINT:-}
OTEL_SERVICE_NAME: ai-service
DEAL_MTLS_ENABLED: ${DEAL_MTLS_ENABLED:-0} DEAL_MTLS_ENABLED: ${DEAL_MTLS_ENABLED:-0}
DEAL_MTLS_CA_PEM: /etc/deal/certs/ca.pem DEAL_MTLS_CA_PEM: /etc/deal/certs/ca.pem
DEAL_MTLS_SERVER_CERT_PFX: /etc/deal/certs/ai-service-server.pfx DEAL_MTLS_SERVER_CERT_PFX: /etc/deal/certs/ai-service-server.pfx
@@ -229,6 +237,8 @@ services:
DEAL_SERVICE_TOKEN: ${DEAL_SERVICE_TOKEN:?DEAL_SERVICE_TOKEN не задан} DEAL_SERVICE_TOKEN: ${DEAL_SERVICE_TOKEN:?DEAL_SERVICE_TOKEN не задан}
DEAL_ML_DATA_DIR: /data/ml DEAL_ML_DATA_DIR: /data/ml
DEAL_LOGS_DIR: /tmp/logs DEAL_LOGS_DIR: /tmp/logs
OTEL_EXPORTER_OTLP_ENDPOINT: ${DEAL_OTEL_ENDPOINT:-}
OTEL_SERVICE_NAME: ml-service
DEAL_MTLS_ENABLED: ${DEAL_MTLS_ENABLED:-0} DEAL_MTLS_ENABLED: ${DEAL_MTLS_ENABLED:-0}
DEAL_MTLS_CA_PEM: /etc/deal/certs/ca.pem DEAL_MTLS_CA_PEM: /etc/deal/certs/ca.pem
DEAL_MTLS_SERVER_CERT_PFX: /etc/deal/certs/ml-service-server.pfx DEAL_MTLS_SERVER_CERT_PFX: /etc/deal/certs/ml-service-server.pfx
@@ -245,6 +255,44 @@ services:
retries: 10 retries: 10
restart: unless-stopped restart: unless-stopped
# storage-service — общий gRPC-сервис данных (вложения источников), :5104. Бэкенд — MinIO.
# Без host-портов; защита — общий service-token (+ mTLS при DEAL_MTLS_ENABLED=1).
storage-service:
build:
context: ..
dockerfile: src/storage-service/Deal.Storage/Dockerfile
<<: *service-hardening
mem_limit: 512m
cpus: 1.0
environment:
GRPC_PORT: "5104"
DEAL_SERVICE_TOKEN: ${DEAL_SERVICE_TOKEN:?DEAL_SERVICE_TOKEN не задан}
DEAL_STORAGE_ENDPOINT: minio:9000
DEAL_STORAGE_ACCESS_KEY: ${MINIO_ROOT_USER:?MINIO_ROOT_USER не задан}
DEAL_STORAGE_SECRET_KEY: ${MINIO_ROOT_PASSWORD:?MINIO_ROOT_PASSWORD не задан}
DEAL_STORAGE_BUCKET: deal-attachments
DEAL_STORAGE_SECURE: "false"
DEAL_LOGS_DIR: /tmp/logs
OTEL_EXPORTER_OTLP_ENDPOINT: ${DEAL_OTEL_ENDPOINT:-}
OTEL_SERVICE_NAME: storage-service
DEAL_MTLS_ENABLED: ${DEAL_MTLS_ENABLED:-0}
DEAL_MTLS_CA_PEM: /etc/deal/certs/ca.pem
DEAL_MTLS_SERVER_CERT_PFX: /etc/deal/certs/storage-service-server.pfx
DEAL_MTLS_SERVER_CERT_PASSWORD: ${DEAL_MTLS_CERT_PASSWORD:-}
DEAL_MTLS_CLIENT_CERT_PFX: /etc/deal/certs/deal-client.pfx
DEAL_MTLS_CLIENT_CERT_PASSWORD: ${DEAL_MTLS_CERT_PASSWORD:-}
volumes:
- ${DEAL_CERTS_DIR:-./certs}:/etc/deal/certs:ro
depends_on:
minio:
condition: service_started
healthcheck:
test: ["CMD-SHELL", "if [ \"$$DEAL_MTLS_ENABLED\" = \"1\" ]; then /bin/grpc_health_probe -addr=localhost:5104 -tls -tls-ca-cert=/etc/deal/certs/ca.pem -tls-client-cert=/etc/deal/certs/deal-client.crt -tls-client-key=/etc/deal/certs/deal-client.key -tls-server-name=localhost; else /bin/grpc_health_probe -addr=localhost:5104; fi"]
interval: 5s
timeout: 3s
retries: 10
restart: unless-stopped
# caddy — edge: наружу только :80/:443. TLS — плейсхолдер tls internal (см. Caddyfile: домен, # caddy — edge: наружу только :80/:443. TLS — плейсхолдер tls internal (см. Caddyfile: домен,
# реальный сертификат/Cloudflare, CSP/HSTS). Статика — ../src/frontend/dist (СОБРАТЬ ДО up). # реальный сертификат/Cloudflare, CSP/HSTS). Статика — ../src/frontend/dist (СОБРАТЬ ДО up).
caddy: caddy:
@@ -334,6 +382,63 @@ services:
condition: service_started condition: service_started
prometheus: prometheus:
condition: service_started condition: service_started
tempo:
condition: service_started
restart: unless-stopped
# ── Трейсы и ресурсы (observability-стек) ─────────────────────────────────
# OpenTelemetry Collector — приёмник трейсов Deal-процессов (OTLP gRPC :4317 / HTTP :4318),
# батчит и перекладывает в Tempo. Наружу порты не публикуются (внутри compose-сети).
otel-collector:
image: otel/opentelemetry-collector-contrib:0.160.0
profiles: ["observability"]
command: ["--config=/etc/otelcol-contrib/config.yaml"]
volumes:
- ./observability/otel-collector.yml:/etc/otelcol-contrib/config.yaml:ro
depends_on:
tempo:
condition: service_started
restart: unless-stopped
# Tempo — хранилище трейсов (OTLP от коллектора). Retention блоков — 7 суток (см. tempo.yml).
tempo:
image: grafana/tempo:2.8.1
profiles: ["observability"]
command: ["-config.file=/etc/tempo.yml"]
volumes:
- ./observability/tempo.yml:/etc/tempo.yml:ro
- deal_tempo_data:/var/tempo
restart: unless-stopped
# cAdvisor — ресурсы контейнеров (CPU/RAM/сеть/диск); scrape — job cadvisor в prometheus.yml.
cadvisor:
image: gcr.io/cadvisor/cadvisor:v0.52.1
profiles: ["observability"]
privileged: true
devices:
- /dev/kmsg:/dev/kmsg
volumes:
- /:/rootfs:ro
- /var/run:/var/run:ro
- /sys:/sys:ro
- /var/lib/docker/:/var/lib/docker:ro
- /dev/disk/:/dev/disk:ro
restart: unless-stopped
# node-exporter — ресурсы хоста (CPU/RAM/диск/сеть); scrape — job node-exporter в prometheus.yml.
node-exporter:
image: prom/node-exporter:v1.9.1
profiles: ["observability"]
command:
- --path.procfs=/host/proc
- --path.sysfs=/host/sys
- --path.rootfs=/host/root
- --collector.filesystem.mount-points-exclude=^/(sys|proc|dev|host|etc)($|/)
volumes:
- /proc:/host/proc:ro
- /sys:/host/sys:ro
- /:/host/root:ro
pid: host
restart: unless-stopped restart: unless-stopped
volumes: volumes:
@@ -343,6 +448,7 @@ volumes:
deal_ml_data: deal_ml_data:
deal_api_data: deal_api_data:
deal_caddy_data: deal_caddy_data:
deal_tempo_data:
deal_caddy_config: deal_caddy_config:
deal_loki_data: deal_loki_data:
deal_promtail_data: deal_promtail_data:
+53
View File
@@ -0,0 +1,53 @@
# Observability-стек «Дейла»
Современный (vendor-neutral) стек мониторинга. Поднимается **профилем `observability`** (в prod —
`deploy/compose.prod.yml`, в dev — `deploy/compose.dev.yml`); наружу порты не публикуются (prod —
доступ оператору по SSH-туннелю).
## Состав и поток данных
| Слой | Сервис | Конфиг | Поток |
|---|---|---|---|
| Трейсы (приём) | `otel-collector` | `otel-collector.yml` | OTLP от сервисов (`:4317`) → Tempo |
| Трейсы (хранение) | `tempo` | `tempo.yml` | OTLP от коллектора, retention 7 сут. |
| Логи | `loki` + `promtail` | `loki.yml`, `promtail.yml` | docker-логи → Loki |
| Метрики | `prometheus` | `prometheus.yml`, `prometheus-rules.yml` | scrape `/metrics` процессов и `cadvisor`/`node-exporter` |
| Ресурсы контейнеров | `cadvisor` | — | Prometheus |
| Ресурсы хоста | `node-exporter` | — | Prometheus |
| Визуализация | `grafana` | `grafana/provisioning/**` | Loki + Prometheus + Tempo |
## Подъём
```bash
# prod (нужен deploy/.env.prod с DEAL_GRAFANA_ADMIN_PASSWORD)
docker compose --env-file deploy/.env.prod -f deploy/compose.prod.yml --profile observability up -d
# dev
docker compose -f deploy/compose.dev.yml --profile observability up -d
```
Проверка: Prometheus `/targets` (job `deal` — 4 процесса UP, `cadvisor`, `node-exporter`, `tempo`) →
Grafana → папка «Дейл» → `Deal-Metrics-Overview` / `Deal-Logs` / `Deal-Traces` / `Deal-Resources`.
## Подключение сервисов (код)
Общая настройка — `Deal.Grpc.Hosting` (сервисы) и `Deal.Api/Observability` (ядро):
- **метрики**: `DealMetricsHosting` — OTel → Prometheus, отдельный HTTP/1.1-эндпоинт `/metrics:9464`
(env `METRICS_PORT`);
- **трейсы**: `DealTracingHosting` — OTel → OTLP, **опт-ин** через env `OTEL_EXPORTER_OTLP_ENDPOINT`
(адрес коллектора; без него трейсинг выключен), имя сервиса — `OTEL_SERVICE_NAME`;
- **логи**: Serilog JSON обогащается `TraceId`/`SpanId` (`TraceContextEnricher`) для связи с трейсами.
## Дашборды и алерты
Дашборды — как код: `grafana/dashboards/*.json` (правки только в репозитории, UI не сохраняет).
Алерты — `prometheus-rules.yml` (доступность, ошибки/5xx, очереди). Алерты по **ресурсам** вынесены в
`prometheus-resource-rules.yml` и **отключены по умолчанию**; включаются добавлением файла в `rule_files`,
пороги — через env `DEAL_ALERT_*` (см. шапку файла).
## Обновление версий образов
Версии зафиксированы в compose-файлах. При обновлении — свежие стабильные теги:
`otel/opentelemetry-collector-contrib`, `grafana/tempo`, `prom/node-exporter`,
`gcr.io/cadvisor/cadvisor`, `grafana/loki`, `grafana/promtail`, `prom/prometheus`, `grafana/grafana`.
@@ -0,0 +1,289 @@
{
"annotations": {
"list": []
},
"editable": true,
"graphTooltip": 0,
"id": null,
"links": [],
"panels": [
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"description": "Потребление CPU контейнерами Deal (cAdvisor). Значение — ядра, занятые контейнером за 5 минут.",
"fieldConfig": {
"defaults": {
"custom": {
"drawStyle": "line",
"fillOpacity": 15,
"lineWidth": 1,
"showPoints": "never"
},
"unit": "short"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 0
},
"id": 1,
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "desc"
}
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"expr": "sum by (name) (rate(container_cpu_usage_seconds_total{name=~\".*deal.*|.*core.*|.*service.*\"}[5m]))",
"legendFormat": "{{name}}",
"refId": "A"
}
],
"title": "CPU контейнеров (cAdvisor)",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"description": "Рабочий набор памяти контейнеров Deal (cAdvisor). Лимиты заданы mem_limit в compose.prod.yml.",
"fieldConfig": {
"defaults": {
"custom": {
"drawStyle": "line",
"fillOpacity": 15,
"lineWidth": 1,
"showPoints": "never"
},
"unit": "bytes"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 0
},
"id": 2,
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "desc"
}
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"expr": "sum by (name) (container_memory_working_set_bytes{name=~\".*deal.*|.*core.*|.*service.*\"})",
"legendFormat": "{{name}}",
"refId": "A"
}
],
"title": "Память контейнеров (cAdvisor)",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"description": "Загрузка CPU хоста (node-exporter), 0100%.",
"fieldConfig": {
"defaults": {
"custom": {
"drawStyle": "line",
"fillOpacity": 15,
"lineWidth": 1,
"showPoints": "never"
},
"max": 100,
"min": 0,
"unit": "percent"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 8
},
"id": 3,
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "desc"
}
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"expr": "100 - (avg(rate(node_cpu_seconds_total{mode=\"idle\"}[5m])) * 100)",
"legendFormat": "CPU хоста",
"refId": "A"
}
],
"title": "CPU хоста (node-exporter)",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"description": "Занятая память хоста (node-exporter), проценты от общего объёма.",
"fieldConfig": {
"defaults": {
"custom": {
"drawStyle": "line",
"fillOpacity": 15,
"lineWidth": 1,
"showPoints": "never"
},
"max": 100,
"min": 0,
"unit": "percent"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 8
},
"id": 4,
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "desc"
}
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"expr": "(1 - node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes) * 100",
"legendFormat": "Память хоста",
"refId": "A"
}
],
"title": "Память хоста (node-exporter)",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"description": "Свободное место на разделах хоста (node-exporter), проценты.",
"fieldConfig": {
"defaults": {
"custom": {
"drawStyle": "line",
"fillOpacity": 15,
"lineWidth": 1,
"showPoints": "never"
},
"max": 100,
"min": 0,
"unit": "percent"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 24,
"x": 0,
"y": 16
},
"id": 5,
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "desc"
}
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"expr": "min by (mountpoint) (node_filesystem_avail_bytes{fstype!~\"tmpfs|overlay\"} / node_filesystem_size_bytes{fstype!~\"tmpfs|overlay\"}) * 100",
"legendFormat": "{{mountpoint}}",
"refId": "A"
}
],
"title": "Свободное место на диске (node-exporter)",
"type": "timeseries"
}
],
"refresh": "30s",
"schemaVersion": 39,
"tags": [
"deal",
"resources"
],
"templating": {
"list": []
},
"time": {
"from": "now-6h",
"to": "now"
},
"timezone": "browser",
"title": "Deal — Ресурсы",
"uid": "deal-resources",
"version": 1
}
@@ -0,0 +1,56 @@
{
"annotations": {
"list": []
},
"editable": true,
"graphTooltip": 0,
"id": null,
"links": [],
"panels": [
{
"datasource": {
"type": "tempo",
"uid": "tempo"
},
"description": "Поиск трейсов Deal (Tempo, TraceQL). Источник — OTLP от сервисов через otel-collector. Клик по трейсу — спаны по сервисам; из спана можно перейти к логам (Loki) по traceId.",
"gridPos": {
"h": 22,
"w": 24,
"x": 0,
"y": 0
},
"id": 1,
"options": {},
"targets": [
{
"datasource": {
"type": "tempo",
"uid": "tempo"
},
"query": "{}",
"queryType": "traceql",
"refId": "A"
}
],
"title": "Трейсы (Tempo)",
"type": "traces"
}
],
"refresh": "30s",
"schemaVersion": 39,
"tags": [
"deal",
"traces"
],
"templating": {
"list": []
},
"time": {
"from": "now-1h",
"to": "now"
},
"timezone": "browser",
"title": "Deal — Трейсы",
"uid": "deal-traces",
"version": 1
}
@@ -13,6 +13,12 @@ datasources:
isDefault: true isDefault: true
jsonData: jsonData:
maxLines: 1000 maxLines: 1000
# Клик по TraceId в логе открывает трейс в Tempo (обогащение логов TraceContextEnricher).
derivedFields:
- name: TraceID
matcherRegex: '"TraceId":"([0-9a-f]+)"'
datasourceUid: tempo
url: "$${__value.raw}"
- name: Prometheus - name: Prometheus
# UID фиксирован: на него ссылаются панели дашборда Deal-Metrics-Overview (datasource uid: prometheus). # UID фиксирован: на него ссылаются панели дашборда Deal-Metrics-Overview (datasource uid: prometheus).
@@ -26,3 +32,24 @@ datasources:
# Prometheus хранит OTel-гистограммы в нативных bucket'ах — используем нативные histogram_quantile. # Prometheus хранит OTel-гистограммы в нативных bucket'ах — используем нативные histogram_quantile.
httpMethod: POST httpMethod: POST
timeInterval: 15s timeInterval: 15s
- name: Tempo
# UID фиксирован: на него ссылаются панели Deal-Traces и derivedFields логов (datasource uid: tempo).
uid: tempo
type: tempo
access: proxy
url: http://tempo:3200
isDefault: false
jsonData:
# Из спана трейса — к логам того же сервиса и traceId (обратная корреляция Loki ↔ Tempo).
tracesToLogsV2:
datasourceUid: loki
filterByTraceID: true
filterBySpanID: false
spanStartTimeShift: -1m
spanEndTimeShift: 1m
# Карта сервисов и граф узлов — из метрик Prometheus.
serviceMap:
datasourceUid: prometheus
nodeGraph:
enabled: true
+40
View File
@@ -0,0 +1,40 @@
# OpenTelemetry Collector — приёмник трейсов Deal-процессов (профиль observability).
#
# Процессы шлют OTLP (gRPC :4317 / HTTP :4318) на этот сервис; коллектор батчит и перекладывает
# трейсы в Tempo (OTLP). Запускается только профилем observability (deploy/compose.prod.yml);
# наружу порты не публикуются (внутри compose-сети).
#
# Конфиг монтируется в /etc/otelcol-contrib/config.yaml. Health-эндпоинт :13133 — для healthcheck.
receivers:
otlp:
protocols:
grpc:
endpoint: 0.0.0.0:4317
http:
endpoint: 0.0.0.0:4318
processors:
# Батчинг снижает число сетевых вызовов к Tempo (стандартный приём OTel).
batch: {}
exporters:
otlp/tempo:
endpoint: tempo:4317
tls:
insecure: true
extensions:
health_check:
endpoint: 0.0.0.0:13133
service:
extensions: [health_check]
telemetry:
logs:
level: warn
pipelines:
traces:
receivers: [otlp]
processors: [batch]
exporters: [otlp/tempo]
@@ -0,0 +1,73 @@
# Правила алертов Prometheus по потреблению ресурсов — ОТКЛЮЧЕНЫ ПО УМОЛЧАНИЮ.
#
# Заготовка: включить, добавив эту строку в `rule_files` файла prometheus.yml:
# rule_files:
# - prometheus-rules.yml
# - prometheus-resource-rules.yml
#
# Пороги выносятся в env (DEAL_ALERT_*); Prometheus не раскрывает значения env в конфиге, поэтому при
# включении файл рендерится из шаблона (подстановка порогов) или пороги проставляются вручную:
# DEAL_ALERT_HOST_CPU_PERCENT — загрузка CPU хоста, % (дефолт 90)
# DEAL_ALERT_HOST_MEMORY_PERCENT — занятая память хоста, % (дефолт 90)
# DEAL_ALERT_HOST_DISK_PERCENT — минимум свободного места, % (дефолт 15)
# DEAL_ALERT_CONTAINER_CPU — CPU контейнера (ядра) (дефолт 1.5)
# DEAL_ALERT_CONTAINER_MEMORY_PERCENT — память контейнера к лимиту, %(дефолт 90)
#
# Источники: node-exporter (хост) и cAdvisor (контейнеры), scrape — jobs node-exporter/cadvisor.
groups:
- name: deal-resources
rules:
# Хост: загрузка CPU. Дефолт порога — 90%.
- alert: DealHostHighCpu
expr: 100 - (avg(rate(node_cpu_seconds_total{mode="idle"}[5m])) * 100) > 90
for: 10m
labels:
severity: warning
annotations:
summary: "Высокая загрузка CPU хоста"
description: "Средняя загрузка CPU хоста выше 90% за 5 минут более 10 минут."
# Хост: занятая память. Дефолт порога — 90%.
- alert: DealHostHighMemory
expr: (1 - node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes) > 0.9
for: 10m
labels:
severity: warning
annotations:
summary: "Высокое потребление памяти хостом"
description: "Свободной памяти меньше 10% более 10 минут."
# Хост: свободное место на разделе. Дефолт порога — 15%.
- alert: DealHostDiskLow
expr: |
min by (mountpoint) (node_filesystem_avail_bytes{fstype!~"tmpfs|overlay"} / node_filesystem_size_bytes{fstype!~"tmpfs|overlay"}) < 0.15
for: 15m
labels:
severity: warning
annotations:
summary: "Мало места на диске ({{ $labels.mountpoint }})"
description: "Свободно менее 15% на разделе {{ $labels.mountpoint }} более 15 минут."
# Контейнеры Deal: CPU (ядра). Дефолт порога — 1.5.
- alert: DealContainerHighCpu
expr: |
sum by (name) (rate(container_cpu_usage_seconds_total{name=~".*deal.*|.*core.*|.*service.*"}[5m])) > 1.5
for: 10m
labels:
severity: warning
annotations:
summary: "Высокий CPU контейнера {{ $labels.name }}"
description: "Контейнер {{ $labels.name }} держит > 1.5 CPU за 5 минут более 10 минут."
# Контейнеры Deal: память к лимиту. Дефолт порога — 90%.
- alert: DealContainerHighMemory
expr: |
(container_memory_working_set_bytes{name=~".*deal.*|.*core.*|.*service.*"}
/ clamp_min(container_spec_memory_limit_bytes{name=~".*deal.*|.*core.*|.*service.*"}, 1)) > 0.9
for: 10m
labels:
severity: warning
annotations:
summary: "Контейнер {{ $labels.name }} близок к лимиту памяти"
description: "Контейнер {{ $labels.name }} использует более 90% лимита памяти более 10 минут."
@@ -79,3 +79,4 @@ groups:
annotations: annotations:
summary: "Растёт очередь обучения ML (outbox)" summary: "Растёт очередь обучения ML (outbox)"
description: "Суммарная глубина MlOutbox держится выше 100 более 15 минут ({{ $value }})." description: "Суммарная глубина MlOutbox держится выше 100 более 15 минут ({{ $value }})."
+15
View File
@@ -46,3 +46,18 @@ scrape_configs:
- targets: ["ml-service:9464"] - targets: ["ml-service:9464"]
labels: labels:
service: ml-service service: ml-service
# Ресурсы контейнеров (cAdvisor) и хоста (node-exporter) — этап «observability-стек».
# cAdvisor отдаёт метрики контейнеров (CPU/RAM/сеть/диск), node-exporter — хоста.
- job_name: cadvisor
static_configs:
- targets: ["cadvisor:8080"]
- job_name: node-exporter
static_configs:
- targets: ["node-exporter:9100"]
# Темпо (хранилище трейсов) — само-мониторинг: метрики приёма/отдачи трейсов.
- job_name: tempo
static_configs:
- targets: ["tempo:3200"]
+28
View File
@@ -0,0 +1,28 @@
# Grafana Tempo — хранилище трейсов Deal (профиль observability).
#
# Принимает OTLP-трейсы от OpenTelemetry Collector (grpc :4317), хранит локально (volume
# deal_tempo_data), отдаёт запросы Grafana на :3200 (внутри compose-сети). Block retention — 7 суток
# (трейсы объёмны; логи/метрики живут дольше).
server:
http_listen_port: 3200
log_level: warn
distributor:
receivers:
otlp:
protocols:
grpc:
endpoint: 0.0.0.0:4317
storage:
trace:
backend: local
local:
path: /var/tempo/traces
wal:
path: /var/tempo/wal
compactor:
compaction:
block_retention: 168h
@@ -149,8 +149,14 @@
"actorType": "tenant", "actorType": "tenant",
"actorId": "1f2e3d4c-5b6a-7980-1234-56789abcdef0", "actorId": "1f2e3d4c-5b6a-7980-1234-56789abcdef0",
"tenantId": "aabbccdd-eeff-0011-2233-445566778899", "tenantId": "aabbccdd-eeff-0011-2233-445566778899",
"userName": "owner@example.com",
"tenantName": "ООО «Ромашка»",
"ip": "203.0.113.7", "ip": "203.0.113.7",
"detailJson": "{\"cardId\":\"c_1a2b3c4d5e6f\",\"to\":\"planned\"}", "changes": [
{ "field": "cardId", "to": "c_1a2b3c4d5e6f" },
{ "field": "to", "to": "planned" }
],
"detailJson": "{\"changes\":[{\"field\":\"cardId\",\"to\":\"c_1a2b3c4d5e6f\"},{\"field\":\"to\",\"to\":\"planned\"}]}",
"at": "2026-09-10T15:22:46.123Z", "at": "2026-09-10T15:22:46.123Z",
"id": 1042 "id": 1042
} }
@@ -162,7 +168,19 @@
``` ```
- `items` — новые сверху (`at` DESC). `total` — полное число по фильтру (без `limit`/`offset`). - `items` — новые сверху (`at` DESC). `total` — полное число по фильтру (без `limit`/`offset`).
- `detailJson`**строка** JSON деталей события (без секретов), может быть `null`. - `userName` — логин реального пользователя (владелец пространства либо логин попытки); `null`, если не разрешён.
- `tenantName` — имя пространства события (join с реестром); `null`, если не разрешено.
- `changes` — человекочитаемые изменения параметров события (см. «Детали события»); `[]`, если деталей нет.
- `detailJson`**строка** сырого JSON деталей события (без секретов) для спойлера; может быть `null`.
### Детали события (`changes`)
Детали события хранятся как JSON вида `{ "changes": [ { "field": "<код>", "from": "<было>", "to": "<стало>" } ] }`,
где `field` — стабильный код параметра, `from` — предыдущее значение (`null` — параметр задан впервые),
`to` — новое. Сериализация деталей — `AuditService.ToDetailJson`, форма изменения — `AuditDetails.Set`/
`AuditDetails.Change`. Ядро отдаёт `changes` как есть; человекочитаемые названия событий, акторов, параметров
и значений — в ресурсах интерфейса (`ru.js`: `operator.event`/`operator.actor`/`operator.field`/`operator.value`).
Прежние записи плоского формата (`{ "login": "..." }`, пары `old*`/`new*`) читаются обратно совместимо.
**Коды**: `200`, `401`. **Коды**: `200`, `401`.
+44 -7
View File
@@ -103,6 +103,25 @@
- Асинхронность: суффикс `Async`, `CancellationToken` пробрасывать до конца; `.Result` / `.Wait()` - Асинхронность: суффикс `Async`, `CancellationToken` пробрасывать до конца; `.Result` / `.Wait()`
запрещены — только `await`. запрещены — только `await`.
### 4.1. Фабрики и билдеры
- **Нетривиальные объекты с интерфейсом создаются только фабриками.** Реализация сервиса/адаптера,
у которого есть порт-интерфейс, не создаётся прямым `new` в прикладном коде или композиционном корне —
только внутри фабрики. Форма пары: `IXxxFactory` (порт фабрики) + `XxxFactory` (реализация), метод
`Create(...)` возвращает **интерфейс** готового объекта (`ISecretCipher`, `IFileStorage`, …).
- Фабрика сама регистрируется в DI (`AddScoped`/`AddSingleton<IXxxFactory, XxxFactory>()`) — контейнер
конструирует её без `new`; зависимости фабрики — тоже DI.
- **Билдер** (`IXxxBuilder`/`XxxBuilder`) добавляется, когда объект собирается итеративно из многих частей
или опций; фабрика делегирует сборку билдеру, а не повторяет её.
- Исключения из правила (прямой `new` допустим):
- DTO, рекорды, value-объекты, `Options`/`Settings`-снимки;
- исключения (`*Exception`) и примитивы/BCL-типы (`StringBuilder`, `NpgsqlConnection`, `MinioClient`, …);
- статические классы и хэлперы без состояния (фабрику для них не заводим);
- EF-конфигурации (`IEntityTypeConfiguration`) — это метаданные модели, а не прикладные объекты;
- обёртки ресурсов без порт-интерфейса (gRPC-соединения с `IDisposable`);
- объекты без порт-интерфейса, создаваемые контейнером (`AddScoped<Concrete>()`).
- Тесты могут конструировать проверяемый тип прямым `new` — это часть самого теста, а не прикладного кода.
## 5. Комментирование кода ## 5. Комментирование кода
Все комментарии — на русском языке. Все комментарии — на русском языке.
@@ -123,6 +142,8 @@
- **`<param>`/`<returns>`** — только если смысл не очевиден из имени/типа; не переписывать сигнатуру. - **`<param>`/`<returns>`** — только если смысл не очевиден из имени/типа; не переписывать сигнатуру.
- **`<summary>` — только блочный.** Открывающий `<summary>` и закрывающий `</summary>` — **каждый на - **`<summary>` — только блочный.** Открывающий `<summary>` и закрывающий `</summary>` — **каждый на
своей строке**; запись в одну строку (`/// <summary>текст</summary>`) **не допускается**. **[изм.]** своей строке**; запись в одну строку (`/// <summary>текст</summary>`) **не допускается**. **[изм.]**
- **Конструкторы не документируем** — `<summary>`/`<param>` на них не нужны: назначение очевидно из
типа и сигнатуры. В частности, не документируем конструкторы классов, реализующих интерфейс. **[изм.]**
Правильно: Правильно:
```csharp ```csharp
@@ -221,21 +242,37 @@
- `try-catch` — только для непредвиденных ошибок, не для управления ходом программы. - `try-catch` — только для непредвиденных ошибок, не для управления ходом программы.
- При пробрасывании выше — `throw;`, а **не** `throw ex;`. - При пробрасывании выше — `throw;`, а **не** `throw ex;`.
- Свои исключения наследовать от `Exception`. - **Свои доменные исключения наследовать от `DealException`** (`Deal.SharedKernel.Errors`) — базовый тип
хранит код ошибки (`ErrorCode`) и умеет брать текст из ресурсов. Состав: `NotFoundException`,
`ValidationException`, `ConflictException`, `ServiceUnavailableException`; новые — по тому же образцу.
- **Не возвращать `null` как штатный результат «не найдено»/ошибки.** Доменный сервис, у которого объект
не найден, бросает `NotFoundException` (эндпоинт отдаёт 404 через общий обработчик, а не проверкой
`is null` в каждом хендлере). `null` допустим только для **опциональных значений** — парсеры/извлечение
полей, выборки-запросы («нет строки» — нормальный результат), `Try*`-паттерн; такие методы должны быть
nullable-аннотированы и явно описаны в XML-doc.
- Исключение создавать всегда, когда функция не может быть выполнена (неверные параметры, нет доступа к - Исключение создавать всегда, когда функция не может быть выполнена (неверные параметры, нет доступа к
БД, неизвестные идентификаторы и т.п.). БД, неизвестные идентификаторы и т.п.).
- Все исключения должны быть залогированы или показаны пользователю; пустые `catch` запрещены. - Все исключения должны быть залогированы или показаны пользователю; **пустые `catch` запрещены**.
- В лог об ошибке, как правило, писать `StackTrace`. - **Единый формат лога ошибки:** понятный русский текст + структурированный контекст (операция, `tenantId`,
id сущности, `traceId`). Стектрейс пишется **только в лог**; в ответ/сообщение клиенту он не попадает —
наружу отдаётся обобщённый текст и код (обработчики на границах: `DealExceptionHandler`, gRPC-интерцептор).
- **Тексты исключений/ошибок не хардкодить** — держать в ресурсах (`ErrorMessages.resx`, доступ через
`ErrorResources.Format(ErrorResourceKeys.*)` и шаблоны `DealException`), чтобы переводы добавлялись
отдельной культурой (`.resx`-спутник) без правок кода.
## 11. Интерфейсы ## 11. Интерфейсы
- **Не дублировать `<summary>` интерфейса в реализации.** Если член объявлен в интерфейсе с XML-doc, - **В реализациях интерфейсов XML-doc не пишем вообще.** Если тип или член объявлен в интерфейсе,
в классе-реализации достаточно `/// <inheritdoc/>` (или вообще ничего, если doc наследуется настройкой). класс-реализация не документируется: ни `<summary>`, ни `<inheritdoc/>` (и ни `<param>` на
Текст описания пишется **один раз** — у интерфейса. конструкторе). Описание живёт **один раз** — в интерфейсе; реализации вызываются только через порт.
Под этот запрет попадает и сам класс-реализация (его `<summary>` тоже лишний — есть у интерфейса).
- **XML-doc уместен только там, где нет интерфейса:** public-типы/члены без порта (статика, константы,
extension-классы), `protected`-члены и DTO/модели. **[изм. 2026-09-13, решение владельца]**
- **Явная реализация интерфейсов — по умолчанию** (`Task ICardStore.GetAsync(...)`). **[изм. 2026-09-11, - **Явная реализация интерфейсов — по умолчанию** (`Task ICardStore.GetAsync(...)`). **[изм. 2026-09-11,
решение владельца]** Классы напрямую не вызываются — только через интерфейсы; исключения: DTO/модели решение владельца]** Классы напрямую не вызываются — только через интерфейсы; исключения: DTO/модели
(напр. `Card` и семейство `I*Card`), хелперы, extension-классы. Весь прод-код уже переведён на явные (напр. `Card` и семейство `I*Card`), хелперы, extension-классы. Весь прод-код уже переведён на явные
реализации (codemod `scripts/make_explicit.py`, идемпотентный). реализации (codemod `scripts/make_explicit.py` и `scripts/strip_implementation_docs.py` — идемпотентны,
`--apply` применяет правки, без флага — dry-run-отчёт).
- Один публичный тип интерфейса = один файл (как и для классов); имя файла = имя типа. - Один публичный тип интерфейса = один файл (как и для классов); имя файла = имя типа.
- **Маркерные классы не используются** — если нужен маркер, это маркерный интерфейс - **Маркерные классы не используются** — если нужен маркер, это маркерный интерфейс
(`IKanbanModule`, `ISharedKernel` и т.п.). **[изм. 2026-09-11]** (`IKanbanModule`, `ISharedKernel` и т.п.). **[изм. 2026-09-11]**
@@ -22,7 +22,7 @@
| Файлы | MinIO (S3-совместимое хранилище) | | Файлы | MinIO (S3-совместимое хранилище) |
| Фронтенд | Vue 3 + Vite + Tailwind | | Фронтенд | Vue 3 + Vite + Tailwind |
| Межсервисно | gRPC + Protobuf (mTLS — за флагом `DEAL_MTLS_*`, §10/§13.8) | | Межсервисно | gRPC + Protobuf (mTLS — за флагом `DEAL_MTLS_*`, §10/§13.8) |
| Наблюдаемость | Serilog (JSON: консоль + rolling-файл) → Promtail → Loki → Grafana; метрики OTel → Prometheus Grafana | | Наблюдаемость | Serilog (JSON: консоль + rolling-файл) → Promtail → Loki → Grafana; метрики OTel → Prometheus; трейсы OTel → Collector → Tempo; ресурсы cAdvisor/node-exporter → Prometheus; единый UI — Grafana |
| Прокси/edge | Caddy (TLS, security-заголовки); Cloudflare/k8s — вне этапа (§10/§11) | | Прокси/edge | Caddy (TLS, security-заголовки); Cloudflare/k8s — вне этапа (§10/§11) |
| Контейнеры | Docker / docker compose (VPS); k8s — позже | | Контейнеры | Docker / docker compose (VPS); k8s — позже |
| Бэкапы | Ежедневные: pg_dump + MinIO + сессии | | Бэкапы | Ежедневные: pg_dump + MinIO + сессии |
@@ -236,7 +236,9 @@ settings(Key varchar(200) PK, ValueJson text, UpdatedAt timestamptz) --
(нужен `DEAL_GRAFANA_ADMIN_PASSWORD` в `.env.prod`; порты Grafana/Prometheus — только loopback). Остановка — (нужен `DEAL_GRAFANA_ADMIN_PASSWORD` в `.env.prod`; порты Grafana/Prometheus — только loopback). Остановка —
`docker compose -f deploy/compose.prod.yml --profile observability down`. `docker compose -f deploy/compose.prod.yml --profile observability down`.
- **Провижининг Grafana — как код** (`deploy/observability/grafana/provisioning`, монтируется в - **Провижининг Grafana — как код** (`deploy/observability/grafana/provisioning`, монтируется в
контейнер): `datasources/datasources.yml` — датасорс Loki (uid `loki`, URL `http://loki:3100`); контейнер): `datasources/datasources.yml` — датасорсы Loki (uid `loki`, default), Prometheus
(uid `prometheus`) и Tempo (uid `tempo`); у Loki — `derivedFields` TraceID → Tempo (клик по traceId
в логе открывает трейс), у Tempo — `tracesToLogsV2` → Loki и `serviceMap`/`nodeGraph` по метрикам;
`dashboards/dashboards.yml` — папка `Дейл` из `/var/lib/grafana/dashboards`. Дашборды — файлы `dashboards/dashboards.yml` — папка `Дейл` из `/var/lib/grafana/dashboards`. Дашборды — файлы
`deploy/observability/grafana/dashboards/*.json`: правки только в репозитории, UI-изменения не `deploy/observability/grafana/dashboards/*.json`: правки только в репозитории, UI-изменения не
сохраняются (`allowUiUpdates: false`). сохраняются (`allowUiUpdates: false`).
@@ -251,7 +253,10 @@ settings(Key varchar(200) PK, ValueJson text, UpdatedAt timestamptz) --
access-лога core) и активация инвайтов (`/api/join`); access-лога core) и активация инвайтов (`/api/join`);
- `Deal-Errors` — HTTP 5xx, необработанные исключения (`@x`), Error/Fatal, ошибки gRPC и общая лента; - `Deal-Errors` — HTTP 5xx, необработанные исключения (`@x`), Error/Fatal, ошибки gRPC и общая лента;
- `Deal-Rps` — нагрузка HTTP+gRPC (RPS), top-путей/методов и p50/p95 длительности запроса; - `Deal-Rps` — нагрузка HTTP+gRPC (RPS), top-путей/методов и p50/p95 длительности запроса;
- `Deal-Logs` — обзор логов с фильтрами по сервису и уровню, активность по тенантам (AI/ML/Telegram). - `Deal-Logs` — обзор логов с фильтрами по сервису и уровню, активность по тенантам (AI/ML/Telegram);
- `Deal-Traces` — поиск трейсов (Tempo, TraceQL), спаны по сервисам, переход к логам по traceId;
- `Deal-Resources` — потребление ресурсов контейнерами (cAdvisor) и хостом (node-exporter): CPU/RAM,
свободное место на дисках.
**Актор в логах:** с BL-LOG-ACTOR access-лог включает `actor` (login пользователя/оператора) и **Актор в логах:** с BL-LOG-ACTOR access-лог включает `actor` (login пользователя/оператора) и
`tenant`; полная лента действий с деталями — `public.audit_log` (append-only) через `tenant`; полная лента действий с деталями — `public.audit_log` (append-only) через
`GET /api/operator/audit` / экран «Аудит» оператор-консоли. `GET /api/operator/audit` / экран «Аудит» оператор-консоли.
@@ -295,9 +300,37 @@ settings(Key varchar(200) PK, ValueJson text, UpdatedAt timestamptz) --
`deal_ml_outbox_depth`), пропажа метрик ядра (`absent(deal_sessions_active)`). Замечание: правила `deal_ml_outbox_depth`), пропажа метрик ядра (`absent(deal_sessions_active)`). Замечание: правила
бюджета токенов нет — метрика бюджета в Prometheus отсутствует (см. §6/§10), поэтому алерт не вводится. бюджета токенов нет — метрика бюджета в Prometheus отсутствует (см. §6/§10), поэтому алерт не вводится.
- Как поднять/проверить: `docker compose --env-file deploy/.env.prod -f deploy/compose.prod.yml - Как поднять/проверить: `docker compose --env-file deploy/.env.prod -f deploy/compose.prod.yml
--profile observability up -d` → Prometheus `/targets` (все 4 UP) → Grafana → папка «Дейл» → --profile observability up -d` → Prometheus `/targets` (все UP) → Grafana → папка «Дейл» →
`Deal-Metrics-Overview`. Быстрая проверка экспортёра без Grafana: `curl http://<процесс>:9464/metrics` `Deal-Metrics-Overview`/`Deal-Resources`/`Deal-Traces`. Быстрая проверка экспортёра без Grafana:
изнутри сети. `curl http://<процесс>:9464/metrics` изнутри сети.
### Трейсы (OpenTelemetry Collector + Tempo)
- **Экспорт из 5 процессов**: OpenTelemetry SDK → OTLP → **OpenTelemetry Collector** (`otel-collector:
4317`) → **Tempo** (`tempo:4317`, хранилище трейсов, retention 7 суток). Настройка — общая в
`Deal.Grpc.Hosting` (`DealTracingHosting`) для telegram/ai/ml/storage и `Deal.Api/Observability/
DealTracingHosting.cs` для ядра. Инструментируется входящий HTTP/gRPC (AspNetCore), исходящие
HTTP-клиенты и gRPC-клиенты (GrpcNetClient) — трейсы сквозные от входа до БД/внешних сервисов.
- **Включение — опт-ин через env** `OTEL_EXPORTER_OTLP_ENDPOINT` (адрес коллектора, напр.
`http://otel-collector:4317`); без него трейсинг выключен. В compose env задан пустым
(`${DEAL_OTEL_ENDPOINT:-}`) — чтобы включить, задайте `DEAL_OTEL_ENDPOINT` в `.env`. Имя сервиса в
трейсах — `OTEL_SERVICE_NAME` (дефолт по процессу: `core`, `telegram-service`, `ai-service`,
`ml-service`, `storage-service`).
- **Корреляция с логами**: Serilog обогащается `TraceId`/`SpanId` из `Activity.Current`
(`TraceContextEnricher`) — в Loki-логе есть `TraceId`, а датасорс Loki `derivedFields` даёт переход
из лога в трейс Tempo (и обратно — `tracesToLogsV2`).
- Сервисы профиля: `otel-collector` (`otel/opentelemetry-collector-contrib:0.160.0`, конфиг
`deploy/observability/otel-collector.yml`) и `tempo` (`grafana/tempo:2.8.1`, конфиг
`deploy/observability/tempo.yml`, volume `deal_tempo_data`). Наружу порты не публикуются (dev — для отладки).
### Ресурсы (cAdvisor + node-exporter)
- **cAdvisor** (`gcr.io/cadvisor/cadvisor:v0.52.1`) — потребление ресурсов **контейнерами**
(CPU/RAM/сеть/диск); **node-exporter** (`prom/node-exporter:v1.9.1`) — ресурсы **хоста** (CPU/RAM/
диски/сеть). Оба scrape'ит Prometheus (jobs `cadvisor`, `node-exporter` в `prometheus.yml`).
- Дашборд `Deal-Resources` (uid `deal-resources`): CPU/RAM контейнеров, CPU/RAM хоста, свободное место
на дисках. Правила алертов по ресурсам — в отдельном файле `prometheus-resource-rules.yml`,
**отключены по умолчанию** (не входят в `rule_files`); пороги — через env `DEAL_ALERT_*` при включении.
--- ---
@@ -330,10 +363,11 @@ settings(Key varchar(200) PK, ValueJson text, UpdatedAt timestamptz) --
- Одна внутренняя сеть; наружу — только **caddy** (80/443): TLS (шапка `deploy/caddy/Caddyfile` — - Одна внутренняя сеть; наружу — только **caddy** (80/443): TLS (шапка `deploy/caddy/Caddyfile` —
`tls internal` для dev/интранет, для реального домена заменить на Cloudflare-origin/сертификаты), `tls internal` для dev/интранет, для реального домена заменить на Cloudflare-origin/сертификаты),
статика `src/frontend/dist`, `reverse_proxy /api → core:5080`, security-заголовки (CSP/HSTS — здесь). статика `src/frontend/dist`, `reverse_proxy /api → core:5080`, security-заголовки (CSP/HSTS — здесь).
- `core` (:5080 http + :5082 gRPC-ингресс), `telegram/ai/ml-service` (mTLS-env, Ruling 6), - `core` (:5080 http + :5082 gRPC-ингресс), `telegram/ai/ml/storage-service` (mTLS-env, Ruling 6),
`postgres`/`minio` **без host-портов**; healthcheck'и — `grpc_health_probe` (при mTLS — TLS-проба с `postgres`/`minio` **без host-портов**; healthcheck'и — `grpc_health_probe` (при mTLS — TLS-проба с
PEM `deal-client.crt/.key`)/`pg_isready`. PEM `deal-client.crt/.key`)/`pg_isready`.
- Профиль `observability`: `loki`/`promtail`/`grafana` + `prometheus` (метрики — этап 12, пакет A; см. §7). - Профиль `observability`: `otel-collector`/`tempo` (трейсы), `loki`/`promtail` (логи),
`prometheus`/`cadvisor`/`node-exporter` (метрики и ресурсы), `grafana` (UI); см. §7.
Секреты — только из `.env.prod` Секреты — только из `.env.prod`
(шаблон `deploy/.env.prod.example`, без дефолтных паролей; отсутствие → fail-fast `:?`). (шаблон `deploy/.env.prod.example`, без дефолтных паролей; отсутствие → fail-fast `:?`).
Rate limiting включён (`RateLimit__Enabled: true`), CORS — явный `Security__AllowedOrigins` Rate limiting включён (`RateLimit__Enabled: true`), CORS — явный `Security__AllowedOrigins`
@@ -1152,11 +1186,12 @@ docker compose -f deploy/compose.dev.yml down # погасить ст
(RpcCallLoggingInterceptor; gRPC-health не логируется). **Метрики** — OTel → Prometheus: `/metrics` (RpcCallLoggingInterceptor; gRPC-health не логируется). **Метрики** — OTel → Prometheus: `/metrics`
(HTTP/1.1 :9464) + прикладные `deal.*` (токены/вызовы AI/ML, аудит, глубины очередей, сессии) — см. §7. (HTTP/1.1 :9464) + прикладные `deal.*` (токены/вызовы AI/ML, аудит, глубины очередей, сессии) — см. §7.
PROD-стек: docker-логи → Promtail → Loki (retention 7 сут.) → Grafana (`127.0.0.1:3001`, SSH-туннель), PROD-стек: docker-логи → Promtail → Loki (retention 7 сут.) → Grafana (`127.0.0.1:3001`, SSH-туннель),
метрики → Prometheus (`127.0.0.1:9090`) → Grafana; профиль `observability` compose.prod. метрики → Prometheus (`127.0.0.1:9090`) → Grafana, трейсы OTel → otel-collector → Tempo, ресурсы
cAdvisor/node-exporter → Prometheus; профиль `observability` compose.prod.
- **compose.prod** (Ruling 9): `deploy/compose.prod.yml` — postgres/minio (без host-портов), core + telegram/ai/ml - **compose.prod** (Ruling 9): `deploy/compose.prod.yml` — postgres/minio (без host-портов), core + telegram/ai/ml
(mTLS env; healthcheck — `grpc_health_probe`, при mTLS — TLS-проба с PEM), `caddy` (80/443: статика (mTLS env; healthcheck — `grpc_health_probe`, при mTLS — TLS-проба с PEM), `caddy` (80/443: статика
`src/frontend/dist` + `reverse_proxy /api → core:5080`, security-заголовки; домен/TLS/Cloudflare — шапка `src/frontend/dist` + `reverse_proxy /api → core:5080`, security-заголовки; домен/TLS/Cloudflare — шапка
`deploy/caddy/Caddyfile`), профиль `observability` (loki/promtail/grafana/prometheus). Секреты — только из `.env.prod` `deploy/caddy/Caddyfile`), профиль `observability` (otel-collector/tempo/loki/promtail/prometheus/cadvisor/node-exporter/grafana). Секреты — только из `.env.prod`
(шаблон `deploy/.env.prod.example`, без дефолтных паролей, fail-fast `:?`). Запуск: (шаблон `deploy/.env.prod.example`, без дефолтных паролей, fail-fast `:?`). Запуск:
`docker compose --env-file deploy/.env.prod -f deploy/compose.prod.yml up -d --build` (+ `--profile observability`); `docker compose --env-file deploy/.env.prod -f deploy/compose.prod.yml up -d --build` (+ `--profile observability`);
авто-проверка — `... config` rc=0. авто-проверка — `... config` rc=0.
@@ -1305,7 +1340,8 @@ docker compose -f deploy/compose.dev.yml start core telegram-service ml-service
(`container_created`, `container_updated`, `container_deleted`), `settings_updated`, `channel_enabled`, (`container_created`, `container_updated`, `container_deleted`), `settings_updated`, `channel_enabled`,
`telegram_linked` (таблица — в контракте; `channel_created` зарезервирован, но не эмитится). `telegram_linked` (таблица — в контракте; `channel_created` зарезервирован, но не эмитится).
- **Наблюдаемость** (Grafana provisioning + promtail-лейблы, дашборды `Deal-Auth/Errors/Rps/Logs`; с этапа 12 — - **Наблюдаемость** (Grafana provisioning + promtail-лейблы, дашборды `Deal-Auth/Errors/Rps/Logs`; с этапа 12 —
метрики OTel → Prometheus и дашборд `Deal-Metrics-Overview`, см. §7). метрики OTel → Prometheus и дашборд `Deal-Metrics-Overview`; трейсы OTel → Collector → Tempo (`Deal-Traces`)
и ресурсы cAdvisor/node-exporter (`Deal-Resources`), см. §7).
- **Как открыть (dev):** `docker compose -f deploy/compose.dev.yml up -d --build` (или core на `:5080` - **Как открыть (dev):** `docker compose -f deploy/compose.dev.yml up -d --build` (или core на `:5080`
с Postgres `:5433`, AI в Local-режиме) → фронт `cd src/frontend && npm run dev` (`:5173`, прокси `/api`) с Postgres `:5433`, AI в Local-режиме) → фронт `cd src/frontend && npm run dev` (`:5173`, прокси `/api`)
→ **оператор:** `http://localhost:5173/#/operator`, вход `operator`/`operator` (dev-дефолт; в Production — → **оператор:** `http://localhost:5173/#/operator`, вход `operator`/`operator` (dev-дефолт; в Production —
+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) catch (LlmCallException callError)
{ {
LogAiUnavailable("filter", tenantId, config, callError); LogAiUnavailable(AiOperations.Filter, tenantId, config, callError);
throw ToUnavailable(callError); throw ToUnavailable(callError);
} }
} }
@@ -191,7 +191,7 @@ public sealed class AiServiceImpl : AiService.AiServiceBase
} }
catch (LlmCallException callError) catch (LlmCallException callError)
{ {
LogAiUnavailable("classify", tenantId, config, callError); LogAiUnavailable(AiOperations.Classify, tenantId, config, callError);
throw ToUnavailable(callError); throw ToUnavailable(callError);
} }
} }
@@ -226,7 +226,7 @@ public sealed class AiServiceImpl : AiService.AiServiceBase
} }
catch (LlmCallException callError) catch (LlmCallException callError)
{ {
LogAiUnavailable("generate_keywords", tenantId, config, callError); LogAiUnavailable(AiOperations.GenerateKeywords, tenantId, config, callError);
throw ToUnavailable(callError); throw ToUnavailable(callError);
} }
} }
@@ -267,7 +267,7 @@ public sealed class AiServiceImpl : AiService.AiServiceBase
} }
catch (LlmCallException callError) catch (LlmCallException callError)
{ {
LogAiUnavailable("evaluate_fit", tenantId, config, callError); LogAiUnavailable(AiOperations.EvaluateFit, tenantId, config, callError);
throw ToUnavailable(callError); throw ToUnavailable(callError);
} }
} }
+2
View File
@@ -12,6 +12,7 @@ WORKDIR /repo
# Restore-слой: только csproj/props (кэш слоёв Docker — restore не повторяется при правке исходников). # Restore-слой: только csproj/props (кэш слоёв Docker — restore не повторяется при правке исходников).
COPY src/contracts/Deal.Proto.csproj src/contracts/ COPY src/contracts/Deal.Proto.csproj src/contracts/
COPY src/grpc-hosting/Deal.Grpc.Hosting/Deal.Grpc.Hosting.csproj src/grpc-hosting/Deal.Grpc.Hosting/ COPY src/grpc-hosting/Deal.Grpc.Hosting/Deal.Grpc.Hosting.csproj src/grpc-hosting/Deal.Grpc.Hosting/
COPY src/core/Deal.SharedKernel/Deal.SharedKernel.csproj src/core/Deal.SharedKernel/
COPY src/ai-service/Directory.Build.props src/ai-service/ COPY src/ai-service/Directory.Build.props src/ai-service/
COPY src/ai-service/Deal.Ai/Deal.Ai.csproj src/ai-service/Deal.Ai/ COPY src/ai-service/Deal.Ai/Deal.Ai.csproj src/ai-service/Deal.Ai/
RUN dotnet restore src/ai-service/Deal.Ai/Deal.Ai.csproj RUN dotnet restore src/ai-service/Deal.Ai/Deal.Ai.csproj
@@ -19,6 +20,7 @@ RUN dotnet restore src/ai-service/Deal.Ai/Deal.Ai.csproj
# Исходники: контракты (.proto) + общая gRPC-обвязка + проект сервиса. # Исходники: контракты (.proto) + общая gRPC-обвязка + проект сервиса.
COPY src/contracts/ src/contracts/ COPY src/contracts/ src/contracts/
COPY src/grpc-hosting/ src/grpc-hosting/ COPY src/grpc-hosting/ src/grpc-hosting/
COPY src/core/Deal.SharedKernel/ src/core/Deal.SharedKernel/
COPY src/ai-service/Deal.Ai/ src/ai-service/Deal.Ai/ COPY src/ai-service/Deal.Ai/ src/ai-service/Deal.Ai/
RUN dotnet publish src/ai-service/Deal.Ai/Deal.Ai.csproj -c Release -o /app/publish RUN dotnet publish src/ai-service/Deal.Ai/Deal.Ai.csproj -c Release -o /app/publish
@@ -0,0 +1,17 @@
namespace Deal.Ai.Llm;
/// <summary>
/// Роли сообщений чата в wire-формате ИИ-провайдеров
/// </summary>
public static class LlmChatRoles
{
/// <summary>
/// Роль системного сообщения.
/// </summary>
public const string System = "system";
/// <summary>
/// Роль пользовательского сообщения.
/// </summary>
public const string User = "user";
}
+25 -35
View File
@@ -4,9 +4,6 @@ using System.Text.Json.Nodes;
namespace Deal.Ai.Llm; namespace Deal.Ai.Llm;
/// <summary>
/// HTTP-реализация <see cref="IProviderClient"/>
/// </summary>
public sealed class LlmHttpClient : IProviderClient public sealed class LlmHttpClient : IProviderClient
{ {
// Относительный путь OpenAI-совместимого эндпоинта (база уже без хвостового «/»). // Относительный путь OpenAI-совместимого эндпоинта (база уже без хвостового «/»).
@@ -58,13 +55,6 @@ public sealed class LlmHttpClient : IProviderClient
_anthropicCallTimeout = anthropicCallTimeout; _anthropicCallTimeout = anthropicCallTimeout;
} }
/// <summary>
/// Выполняет один вызов модели по выбранной схеме API.
/// </summary>
/// <param name="config">Конфиг провайдера (стиль — <c>ApiStyle</c>).</param>
/// <param name="systemPrompt">Системный промпт.</param>
/// <param name="userText">Пользовательское сообщение/контекст.</param>
/// <returns>Текст ответа и usage API-ответа (null при его отсутствии).</returns>
async Task<ProviderChatResult> IProviderClient.ChatAsync( async Task<ProviderChatResult> IProviderClient.ChatAsync(
LlmConfig config, LlmConfig config,
string systemPrompt, string systemPrompt,
@@ -110,12 +100,12 @@ public sealed class LlmHttpClient : IProviderClient
var body = new JsonObject var body = new JsonObject
{ {
["model"] = config.Model, [LlmWireKeys.Model] = config.Model,
["messages"] = new JsonArray( [LlmWireKeys.Messages] = new JsonArray(
ChatMessage("system", systemPrompt), ChatMessage(LlmChatRoles.System, systemPrompt),
ChatMessage("user", userText)), ChatMessage(LlmChatRoles.User, userText)),
["temperature"] = Temperature, [LlmWireKeys.Temperature] = Temperature,
["max_tokens"] = MaxResponseTokens, [LlmWireKeys.MaxTokens] = MaxResponseTokens,
}; };
request.Content = JsonBody(body); request.Content = JsonBody(body);
@@ -133,10 +123,10 @@ public sealed class LlmHttpClient : IProviderClient
var body = new JsonObject var body = new JsonObject
{ {
["model"] = config.Model, [LlmWireKeys.Model] = config.Model,
["max_tokens"] = MaxResponseTokens, [LlmWireKeys.MaxTokens] = MaxResponseTokens,
["system"] = systemPrompt, [LlmWireKeys.System] = systemPrompt,
["messages"] = new JsonArray(ChatMessage("user", userText)), [LlmWireKeys.Messages] = new JsonArray(ChatMessage(LlmChatRoles.User, userText)),
}; };
request.Content = JsonBody(body); request.Content = JsonBody(body);
@@ -166,7 +156,7 @@ public sealed class LlmHttpClient : IProviderClient
private static ProviderChatResult ReadOpenAiBody(string body) private static ProviderChatResult ReadOpenAiBody(string body)
{ {
JsonObject? payload = ParseObjectOrThrow(body); JsonObject? payload = ParseObjectOrThrow(body);
JsonArray? choices = payload["choices"] as JsonArray; JsonArray? choices = payload[LlmWireKeys.Choices] as JsonArray;
if (choices is null || choices.Count == 0) if (choices is null || choices.Count == 0)
{ {
// Тип сбоя без содержимого тела (замечание code-review: тело ответа наружу/в лог не уходит). // Тип сбоя без содержимого тела (замечание code-review: тело ответа наружу/в лог не уходит).
@@ -174,14 +164,14 @@ public sealed class LlmHttpClient : IProviderClient
} }
// choices[i] — объект выбора {message, finish_reason, …}; текст — в message.content. // choices[i] — объект выбора {message, finish_reason, …}; текст — в message.content.
JsonObject? message = (choices[0] as JsonObject)?["message"] as JsonObject; JsonObject? message = (choices[0] as JsonObject)?[LlmWireKeys.Message] as JsonObject;
string? content = ReadStringField(message, "content"); string? content = ReadStringField(message, LlmWireKeys.Content);
if (string.IsNullOrEmpty(content) && !string.IsNullOrEmpty(ReadStringField(message, "reasoning_content"))) if (string.IsNullOrEmpty(content) && !string.IsNullOrEmpty(ReadStringField(message, LlmWireKeys.ReasoningContent)))
{ {
throw new LlmHttpException("Модель вернула только reasoning без ответа"); throw new LlmHttpException("Модель вернула только reasoning без ответа");
} }
return new ProviderChatResult(content ?? string.Empty, ReadOpenAiUsage(payload["usage"])); return new ProviderChatResult(content ?? string.Empty, ReadOpenAiUsage(payload[LlmWireKeys.Usage]));
} }
private static ProviderChatResult ReadAnthropicBody(string body) private static ProviderChatResult ReadAnthropicBody(string body)
@@ -189,18 +179,18 @@ public sealed class LlmHttpClient : IProviderClient
JsonObject? payload = ParseObjectOrThrow(body); JsonObject? payload = ParseObjectOrThrow(body);
var text = new StringBuilder(); var text = new StringBuilder();
if (payload["content"] is JsonArray contentBlocks) if (payload[LlmWireKeys.Content] is JsonArray contentBlocks)
{ {
foreach (JsonNode? blockNode in contentBlocks) foreach (JsonNode? blockNode in contentBlocks)
{ {
if (blockNode is JsonObject block) if (blockNode is JsonObject block)
{ {
text.Append(ReadStringField(block, "text")); text.Append(ReadStringField(block, LlmWireKeys.Text));
} }
} }
} }
return new ProviderChatResult(text.ToString(), ReadAnthropicUsage(payload["usage"])); return new ProviderChatResult(text.ToString(), ReadAnthropicUsage(payload[LlmWireKeys.Usage]));
} }
// Возвращает usage OpenAI-совместимого ответа (prompt/completion/total_tokens) либо null. // Возвращает usage OpenAI-совместимого ответа (prompt/completion/total_tokens) либо null.
@@ -212,9 +202,9 @@ public sealed class LlmHttpClient : IProviderClient
return null; return null;
} }
int? promptTokens = ReadIntField(usage, "prompt_tokens"); int? promptTokens = ReadIntField(usage, LlmWireKeys.PromptTokens);
int? completionTokens = ReadIntField(usage, "completion_tokens"); int? completionTokens = ReadIntField(usage, LlmWireKeys.CompletionTokens);
int? totalTokens = ReadIntField(usage, "total_tokens"); int? totalTokens = ReadIntField(usage, LlmWireKeys.TotalTokens);
return promptTokens is null || completionTokens is null || totalTokens is null return promptTokens is null || completionTokens is null || totalTokens is null
? null ? null
: new ProviderUsage(promptTokens.Value, completionTokens.Value, totalTokens.Value); : new ProviderUsage(promptTokens.Value, completionTokens.Value, totalTokens.Value);
@@ -229,8 +219,8 @@ public sealed class LlmHttpClient : IProviderClient
return null; return null;
} }
int? inputTokens = ReadIntField(usage, "input_tokens"); int? inputTokens = ReadIntField(usage, LlmWireKeys.InputTokens);
int? outputTokens = ReadIntField(usage, "output_tokens"); int? outputTokens = ReadIntField(usage, LlmWireKeys.OutputTokens);
return inputTokens is null || outputTokens is null return inputTokens is null || outputTokens is null
? null ? null
: new ProviderUsage(inputTokens.Value, outputTokens.Value, inputTokens.Value + outputTokens.Value); : new ProviderUsage(inputTokens.Value, outputTokens.Value, inputTokens.Value + outputTokens.Value);
@@ -264,8 +254,8 @@ public sealed class LlmHttpClient : IProviderClient
private static JsonObject ChatMessage(string role, string content) private static JsonObject ChatMessage(string role, string content)
=> new() => new()
{ {
["role"] = role, [LlmWireKeys.Role] = role,
["content"] = content, [LlmWireKeys.Content] = content,
}; };
// Создаёт JSON-содержимое запроса (application/json). // Создаёт JSON-содержимое запроса (application/json).
+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";
}
+1
View File
@@ -18,6 +18,7 @@ WebApplication app = AiServiceHost.Create(
{ {
DealLogging.Configure(builder, aiProcessName); DealLogging.Configure(builder, aiProcessName);
DealMetricsHosting.AddDealMetrics(builder, metricsPort); DealMetricsHosting.AddDealMetrics(builder, metricsPort);
DealTracingHosting.AddDealTracing(builder, aiProcessName);
}); });
DealMetricsHosting.MapDealMetrics(app); DealMetricsHosting.MapDealMetrics(app);
+1
View File
@@ -42,6 +42,7 @@
Версии — 1.17.x (Prometheus-экспортёр и gRPC-клиент только pre-release-линией; остальные — Версии — 1.17.x (Prometheus-экспортёр и gRPC-клиент только pre-release-линией; остальные —
1.17.0 stable). Настройка — Deal.Api/Observability/DealMetricsHosting.cs. --> 1.17.0 stable). Настройка — Deal.Api/Observability/DealMetricsHosting.cs. -->
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.17.0" /> <PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.17.0" />
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.17.0" />
<PackageReference Include="OpenTelemetry.Exporter.Prometheus.AspNetCore" Version="1.17.0-beta.1" /> <PackageReference Include="OpenTelemetry.Exporter.Prometheus.AspNetCore" Version="1.17.0-beta.1" />
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.17.0" /> <PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.17.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.17.0" /> <PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.17.0" />
@@ -127,9 +127,9 @@ public static class AiCheckEndpoint
&& entry.ValueKind == JsonValueKind.Object) && entry.ValueKind == JsonValueKind.Object)
{ {
return new AiConfigSetting( return new AiConfigSetting(
ApiKey: ReadField(entry, "apiKey") ?? defaults.ApiKey, ApiKey: ReadField(entry, SettingsFieldKeys.ApiKey) ?? defaults.ApiKey,
BaseUrl: ReadField(entry, "baseUrl") ?? defaults.BaseUrl, BaseUrl: ReadField(entry, SettingsFieldKeys.BaseUrl) ?? defaults.BaseUrl,
Model: ReadField(entry, "model") ?? defaults.Model); Model: ReadField(entry, SettingsFieldKeys.Model) ?? defaults.Model);
} }
} }
catch (JsonException) catch (JsonException)
+5 -5
View File
@@ -68,7 +68,7 @@ public static class AuthEndpoints
ActorId: result.UserId, ActorId: result.UserId,
TenantId: result.TenantId, TenantId: result.TenantId,
Ip: ClientIp(context), Ip: ClientIp(context),
DetailJson: AuditService.ToDetailJson(new { login = NormalizeLogin(body.Login) })), ct); DetailJson: AuditService.ToDetailJson([AuditDetails.Set(AuditFields.Login, NormalizeLogin(body.Login))])), ct);
return EndpointResults.Forbidden(TenantSuspendedDetail); return EndpointResults.Forbidden(TenantSuspendedDetail);
} }
@@ -84,7 +84,7 @@ public static class AuthEndpoints
ActorId: null, ActorId: null,
TenantId: null, TenantId: null,
Ip: ClientIp(context), Ip: ClientIp(context),
DetailJson: AuditService.ToDetailJson(new { login = attemptedLogin })), ct); DetailJson: AuditService.ToDetailJson([AuditDetails.Set(AuditFields.Login, attemptedLogin)])), ct);
} }
return EndpointResults.Unauthorized(InvalidCredentialsDetail); return EndpointResults.Unauthorized(InvalidCredentialsDetail);
@@ -98,7 +98,7 @@ public static class AuthEndpoints
ActorId: result.UserId, ActorId: result.UserId,
TenantId: result.TenantId, TenantId: result.TenantId,
Ip: ClientIp(context), Ip: ClientIp(context),
DetailJson: AuditService.ToDetailJson(new { login = result.Login })), ct); DetailJson: AuditService.ToDetailJson([AuditDetails.Set(AuditFields.Login, result.Login)])), ct);
SessionCookieWriter.Append(context, cookieOptions.Value, result.Token); SessionCookieWriter.Append(context, cookieOptions.Value, result.Token);
return Results.Ok(new { ok = true, login = result.Login }); return Results.Ok(new { ok = true, login = result.Login });
@@ -125,12 +125,12 @@ public static class AuthEndpoints
ActorId: logout.OperatorId, ActorId: logout.OperatorId,
TenantId: logout.TenantId, TenantId: logout.TenantId,
Ip: ClientIp(context), Ip: ClientIp(context),
DetailJson: AuditService.ToDetailJson(new { login = logout.Login })), ct); DetailJson: AuditService.ToDetailJson([AuditDetails.Set(AuditFields.Login, logout.Login)])), ct);
} }
if (user is not null) if (user is not null)
{ {
await AuditAppender.AppendTenantAsync(context, AuditEvents.TenantLogout, new { login = user.Login }, ct); await AuditAppender.AppendTenantAsync(context, AuditEvents.TenantLogout, [AuditDetails.Set(AuditFields.Login, user.Login)], ct);
} }
context.Response.Cookies.Delete(cookieName); context.Response.Cookies.Delete(cookieName);
@@ -120,7 +120,7 @@ public static class CardDetailsEndpoints
CardsService service = context.RequestServices.GetRequiredService<CardsService>(); CardsService service = context.RequestServices.GetRequiredService<CardsService>();
CardDto created = await service.CreateLocalCardAsync(ToCreateLocalDto(body), ct); CardDto created = await service.CreateLocalCardAsync(ToCreateLocalDto(body), ct);
await AuditAppender.AppendTenantAsync(context, AuditEvents.CardCreated, new { cardId = created.Id }, ct); await AuditAppender.AppendTenantAsync(context, AuditEvents.CardCreated, [AuditDetails.Set(AuditFields.CardId, created.Id)], ct);
return await ReadCardAsync(context, created.Id, ct); return await ReadCardAsync(context, created.Id, ct);
} }
@@ -136,10 +136,8 @@ public static class CardDetailsEndpoints
} }
CardsService service = context.RequestServices.GetRequiredService<CardsService>(); CardsService service = context.RequestServices.GetRequiredService<CardsService>();
CardDto? card = await service.TakeCardAsync(body.CardId ?? body.LeadId ?? string.Empty, ct); CardDto card = await service.TakeCardAsync(body.CardId ?? body.LeadId ?? string.Empty, ct);
return card is null return await ReadCardAsync(context, card.Id, ct);
? EndpointResults.NotFound(CardNotFoundDetail)
: await ReadCardAsync(context, card.Id, ct);
} }
// POST /api/cards/clear-rejected: полная очистка терминальной стадии «Отклонено». // POST /api/cards/clear-rejected: полная очистка терминальной стадии «Отклонено».
@@ -204,9 +202,7 @@ public static class CardDetailsEndpoints
return EndpointResults.BadRequest(result.Error); return EndpointResults.BadRequest(result.Error);
} }
return result.Card is null return await ReadCardAsync(context, cardId, ct);
? EndpointResults.NotFound(CardNotFoundDetail)
: await ReadCardAsync(context, cardId, ct);
} }
// DELETE /api/cards/{cardId}/links/{linkId}: удалить ссылку. Ответ — карточка. // DELETE /api/cards/{cardId}/links/{linkId}: удалить ссылку. Ответ — карточка.
@@ -222,10 +218,8 @@ public static class CardDetailsEndpoints
} }
CardsService service = context.RequestServices.GetRequiredService<CardsService>(); CardsService service = context.RequestServices.GetRequiredService<CardsService>();
CardResultDto result = await service.RemoveLinkAsync(cardId, linkId, ct); await service.RemoveLinkAsync(cardId, linkId, ct);
return result.Card is null return await ReadCardAsync(context, cardId, ct);
? EndpointResults.NotFound(CardNotFoundDetail)
: await ReadCardAsync(context, cardId, ct);
} }
// POST /api/cards/{cardId}/files: загрузка вложений (multipart/form-data, поле files). // POST /api/cards/{cardId}/files: загрузка вложений (multipart/form-data, поле files).
@@ -242,10 +236,7 @@ public static class CardDetailsEndpoints
} }
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>(); CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
if (await cardsService.GetCardAsync(cardId, ct) is null) await cardsService.GetCardAsync(cardId, ct);
{
return EndpointResults.NotFound(CardNotFoundDetail);
}
IFormCollection form; IFormCollection form;
try try
@@ -261,11 +252,7 @@ public static class CardDetailsEndpoints
foreach (IFormFile file in form.Files) foreach (IFormFile file in form.Files)
{ {
await using Stream content = file.OpenReadStream(); await using Stream content = file.OpenReadStream();
CardFileDto? entry = await cardsService.AddFileAsync(cardId, file.FileName, file.ContentType, content, file.Length, ct); await cardsService.AddFileAsync(cardId, file.FileName, file.ContentType, content, file.Length, ct);
if (entry is null)
{
return EndpointResults.NotFound(CardNotFoundDetail);
}
} }
return await ReadCardAsync(context, cardId, ct); return await ReadCardAsync(context, cardId, ct);
@@ -286,11 +273,7 @@ public static class CardDetailsEndpoints
} }
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>(); CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
CardFileDto? entry = await cardsService.GetFileEntryAsync(cardId, fileId, ct); CardFileDto entry = await cardsService.GetFileEntryAsync(cardId, fileId, ct);
if (entry is null)
{
return EndpointResults.NotFound(CardNotFoundDetail);
}
if (string.IsNullOrWhiteSpace(entry.ObjectKey)) if (string.IsNullOrWhiteSpace(entry.ObjectKey))
{ {
@@ -339,10 +322,8 @@ public static class CardDetailsEndpoints
} }
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>(); CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
CardDto? card = await cardsService.RemoveFileAsync(cardId, fileId, ct); await cardsService.RemoveFileAsync(cardId, fileId, ct);
return card is null return await ReadCardAsync(context, cardId, ct);
? EndpointResults.NotFound(CardNotFoundDetail)
: await ReadCardAsync(context, cardId, ct);
} }
// POST /api/cards/{cardId}/reminder {at: epoch-ms}: установить напоминание. Ответ — карточка. // POST /api/cards/{cardId}/reminder {at: epoch-ms}: установить напоминание. Ответ — карточка.
@@ -369,9 +350,7 @@ public static class CardDetailsEndpoints
return EndpointResults.BadRequest(result.Error); return EndpointResults.BadRequest(result.Error);
} }
return result.Card is null return await ReadCardAsync(context, cardId, ct);
? EndpointResults.NotFound(CardNotFoundDetail)
: await ReadCardAsync(context, cardId, ct);
} }
// DELETE /api/cards/{cardId}/reminder: снять напоминание. Ответ — карточка. // DELETE /api/cards/{cardId}/reminder: снять напоминание. Ответ — карточка.
@@ -386,9 +365,8 @@ public static class CardDetailsEndpoints
} }
CardsService service = context.RequestServices.GetRequiredService<CardsService>(); CardsService service = context.RequestServices.GetRequiredService<CardsService>();
return await service.ClearReminderAsync(cardId, ct) await service.ClearReminderAsync(cardId, ct);
? await ReadCardAsync(context, cardId, ct) return await ReadCardAsync(context, cardId, ct);
: EndpointResults.NotFound(CardNotFoundDetail);
} }
// POST /api/cards/{cardId}/reminder/snooze: «напомнить позже» (now + 24 ч). Ответ — карточка. // POST /api/cards/{cardId}/reminder/snooze: «напомнить позже» (now + 24 ч). Ответ — карточка.
@@ -403,9 +381,8 @@ public static class CardDetailsEndpoints
} }
CardsService service = context.RequestServices.GetRequiredService<CardsService>(); CardsService service = context.RequestServices.GetRequiredService<CardsService>();
return await service.SnoozeReminderAsync(cardId, ct) await service.SnoozeReminderAsync(cardId, ct);
? await ReadCardAsync(context, cardId, ct) return await ReadCardAsync(context, cardId, ct);
: EndpointResults.NotFound(CardNotFoundDetail);
} }
// Читает карточку через единый сервис и возвращает её как ответ (404 — карточки нет). // Читает карточку через единый сервис и возвращает её как ответ (404 — карточки нет).
@@ -424,11 +401,7 @@ public static class CardDetailsEndpoints
CancellationToken ct) CancellationToken ct)
{ {
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>(); CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
CardDto? card = await cardsService.GetCardAsync(cardId, ct); CardDto card = await cardsService.GetCardAsync(cardId, ct);
if (card is null)
{
return EndpointResults.NotFound(CardNotFoundDetail);
}
SourceContent content = await cardsService.ResolveSourceAsync(card, ct); SourceContent content = await cardsService.ResolveSourceAsync(card, ct);
return Results.Ok(content); return Results.Ok(content);
@@ -440,10 +413,8 @@ public static class CardDetailsEndpoints
CancellationToken ct) CancellationToken ct)
{ {
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>(); CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
CardDto? card = await cardsService.GetCardAsync(cardId, ct); CardDto card = await cardsService.GetCardAsync(cardId, ct);
return card is null return Results.Ok(card);
? EndpointResults.NotFound(CardNotFoundDetail)
: Results.Ok(card);
} }
// Имя файла для Content-Disposition без кавычек «"». // Имя файла для Content-Disposition без кавычек «"».
+29 -38
View File
@@ -105,15 +105,15 @@ public static class CardsEndpoints
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>(); CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
CardCountsDto counts = await cardsService.CountsAsync(ct); CardCountsDto counts = await cardsService.CountsAsync(ct);
var wire = new Dictionary<string, object> { ["new"] = counts.New }; var wire = new Dictionary<string, object> { [KanbanWireKeys.New] = counts.New };
foreach ((string col, CardColumnCountDto column) in counts.Columns) foreach ((string col, CardColumnCountDto column) in counts.Columns)
{ {
wire[col] = column; wire[col] = column;
} }
wire["learning"] = counts.Learning; wire[KanbanWireKeys.Learning] = counts.Learning;
wire["ml"] = counts.Ml; wire[KanbanWireKeys.Ml] = counts.Ml;
wire["ai"] = counts.Ai; wire[KanbanWireKeys.Ai] = counts.Ai;
return Results.Ok(wire); return Results.Ok(wire);
} }
@@ -128,10 +128,8 @@ public static class CardsEndpoints
} }
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>(); CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
CardDto? card = await cardsService.GetCardAsync(cardId, ct); CardDto card = await cardsService.GetCardAsync(cardId, ct);
return card is null return Results.Ok(card);
? EndpointResults.NotFound(CardNotFoundDetail)
: Results.Ok(card);
} }
private static async Task<IResult> MarkAllSeenAsync(HttpContext context, CancellationToken ct) private static async Task<IResult> MarkAllSeenAsync(HttpContext context, CancellationToken ct)
@@ -184,18 +182,18 @@ public static class CardsEndpoints
return EndpointResults.BadRequest(outcome.Error); return EndpointResults.BadRequest(outcome.Error);
} }
if (!outcome.Exists) await AuditAppender.AppendTenantAsync(
{ context,
return EndpointResults.NotFound(CardNotFoundDetail); AuditEvents.CardMoved,
} [
AuditDetails.Set(AuditFields.CardId, cardId),
await AuditAppender.AppendTenantAsync(context, AuditEvents.CardMoved, new { cardId, to = body.To }, ct); AuditDetails.Change(AuditFields.ContainerId, outcome.From, outcome.To),
],
ct);
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>(); CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
CardDto? unified = await cardsService.GetCardAsync(cardId, ct); CardDto unified = await cardsService.GetCardAsync(cardId, ct);
return unified is null return Results.Ok(unified);
? EndpointResults.NotFound(CardNotFoundDetail)
: Results.Ok(unified);
} }
private static async Task<IResult> TrashAsync( private static async Task<IResult> TrashAsync(
@@ -209,13 +207,9 @@ public static class CardsEndpoints
} }
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>(); CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
CardDto? card = await cardsService.TrashCardAsync(cardId, ct); await cardsService.TrashCardAsync(cardId, ct);
if (card is null)
{
return EndpointResults.NotFound(CardNotFoundDetail);
}
await AuditAppender.AppendTenantAsync(context, AuditEvents.CardTrashed, new { cardId }, ct); await AuditAppender.AppendTenantAsync(context, AuditEvents.CardTrashed, [AuditDetails.Set(AuditFields.CardId, cardId)], ct);
return Results.Ok(new { ok = true }); return Results.Ok(new { ok = true });
} }
@@ -230,13 +224,9 @@ public static class CardsEndpoints
} }
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>(); CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
string? col = await cardsService.RestoreCardAsync(cardId, ct); string col = await cardsService.RestoreCardAsync(cardId, ct);
if (col is null)
{
return EndpointResults.NotFound(CardNotFoundDetail);
}
await AuditAppender.AppendTenantAsync(context, AuditEvents.CardRestored, new { cardId, col }, ct); await AuditAppender.AppendTenantAsync(context, AuditEvents.CardRestored, [AuditDetails.Set(AuditFields.CardId, cardId), AuditDetails.Set(AuditFields.Column, col)], ct);
return Results.Ok(new { ok = true, col }); return Results.Ok(new { ok = true, col });
} }
@@ -257,7 +247,7 @@ public static class CardsEndpoints
return EndpointResults.NotFound(CardNotFoundDetail); return EndpointResults.NotFound(CardNotFoundDetail);
} }
await AuditAppender.AppendTenantAsync(context, AuditEvents.CardDeleted, new { cardId }, ct); await AuditAppender.AppendTenantAsync(context, AuditEvents.CardDeleted, [AuditDetails.Set(AuditFields.CardId, cardId)], ct);
return Results.Ok(new { ok = true }); return Results.Ok(new { ok = true });
} }
@@ -301,7 +291,7 @@ public static class CardsEndpoints
return EndpointResults.NotFound(CardNotFoundDetail); return EndpointResults.NotFound(CardNotFoundDetail);
} }
await AuditAppender.AppendTenantAsync(context, AuditEvents.CardCommentAdded, new { cardId }, ct); await AuditAppender.AppendTenantAsync(context, AuditEvents.CardCommentAdded, [AuditDetails.Set(AuditFields.CardId, cardId)], ct);
return Results.Ok(new { comments = result.Comments }); return Results.Ok(new { comments = result.Comments });
} }
@@ -361,11 +351,7 @@ public static class CardsEndpoints
} }
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>(); CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
CardDto? card = await cardsService.GetCardAsync(cardId, ct); CardDto card = await cardsService.GetCardAsync(cardId, ct);
if (card is null)
{
return EndpointResults.NotFound(CardNotFoundDetail);
}
CardReclassifier reclassifier = context.RequestServices.GetRequiredService<CardReclassifier>(); CardReclassifier reclassifier = context.RequestServices.GetRequiredService<CardReclassifier>();
ReclassifyResultDto result = await reclassifier.ReclassifyCardAsync(card, ct); ReclassifyResultDto result = await reclassifier.ReclassifyCardAsync(card, ct);
@@ -422,7 +408,12 @@ public static class CardsEndpoints
return AuditAppender.AppendTenantAsync( return AuditAppender.AppendTenantAsync(
context, context,
AuditEvents.CardReclassified, AuditEvents.CardReclassified,
new { attempted = result.Attempted, reclassified = result.Reclassified, moved = result.Moved, trashed = result.Trashed }, [
AuditDetails.Set(AuditFields.Attempted, result.Attempted),
AuditDetails.Set(AuditFields.Reclassified, result.Reclassified),
AuditDetails.Set(AuditFields.Moved, result.Moved),
AuditDetails.Set(AuditFields.Trashed, result.Trashed),
],
ct); ct);
} }
@@ -19,9 +19,6 @@ public static class ContainersEndpoints
// OpenAPI-тег группы. // OpenAPI-тег группы.
private const string OpenApiTag = "containers"; private const string OpenApiTag = "containers";
// 404 PATCH/accept: контейнер не найден.
private const string ContainerNotFoundDetail = "Контейнер не найден";
// 400: отсутствующий/явный null name контейнера. // 400: отсутствующий/явный null name контейнера.
private const string ContainerNameRequiredDetail = "Укажите название колонки"; private const string ContainerNameRequiredDetail = "Укажите название колонки";
@@ -97,7 +94,7 @@ public static class ContainersEndpoints
Note: body.Note ?? string.Empty), Note: body.Note ?? string.Empty),
ct); ct);
await AuditAppender.AppendTenantAsync(context, AuditEvents.ContainerCreated, new { id = created.Id, name = created.Name }, ct); await AuditAppender.AppendTenantAsync(context, AuditEvents.ContainerCreated, [AuditDetails.Set(AuditFields.ContainerId, created.Id), AuditDetails.Set(AuditFields.Name, created.Name)], ct);
return Results.Ok(new { id = created.Id }); return Results.Ok(new { id = created.Id });
} }
@@ -143,7 +140,8 @@ public static class ContainersEndpoints
} }
ContainersService containers = context.RequestServices.GetRequiredService<ContainersService>(); ContainersService containers = context.RequestServices.GetRequiredService<ContainersService>();
ContainerDto? updated = await containers.PatchAsync( ContainerDto before = await containers.GetAsync(containerId, ct);
ContainerDto updated = await containers.PatchAsync(
containerId, containerId,
new ContainerPatchDto( new ContainerPatchDto(
patchBody.Name, patchBody.Name,
@@ -155,12 +153,14 @@ public static class ContainersEndpoints
NormalizeWireRules(patchBody.Rules), NormalizeWireRules(patchBody.Rules),
patchBody.Policy), patchBody.Policy),
ct); ct);
if (updated is null)
{
return EndpointResults.NotFound(ContainerNotFoundDetail);
}
await AuditAppender.AppendTenantAsync(context, AuditEvents.ContainerUpdated, new { id = updated.Id }, ct); await AuditAppender.AppendTenantAsync(
context,
AuditEvents.ContainerUpdated,
string.Equals(before.Name, updated.Name, StringComparison.Ordinal)
? [AuditDetails.Set(AuditFields.ContainerId, updated.Id)]
: [AuditDetails.Change(AuditFields.Name, before.Name, updated.Name)],
ct);
return Results.Ok(new { id = updated.Id }); return Results.Ok(new { id = updated.Id });
} }
@@ -176,13 +176,9 @@ public static class ContainersEndpoints
} }
ContainersService containers = context.RequestServices.GetRequiredService<ContainersService>(); ContainersService containers = context.RequestServices.GetRequiredService<ContainersService>();
ContainerDto? accepted = await containers.AcceptSuggestedAsync(containerId, ct); ContainerDto accepted = await containers.AcceptSuggestedAsync(containerId, ct);
if (accepted is null)
{
return EndpointResults.NotFound(ContainerNotFoundDetail);
}
await AuditAppender.AppendTenantAsync(context, AuditEvents.ContainerUpdated, new { id = accepted.Id }, ct); await AuditAppender.AppendTenantAsync(context, AuditEvents.ContainerUpdated, [AuditDetails.Set(AuditFields.ContainerId, accepted.Id)], ct);
return Results.Ok(accepted); return Results.Ok(accepted);
} }
@@ -200,7 +196,7 @@ public static class ContainersEndpoints
ContainersService containers = context.RequestServices.GetRequiredService<ContainersService>(); ContainersService containers = context.RequestServices.GetRequiredService<ContainersService>();
int moved = await containers.DeleteAsync(containerId, ct); int moved = await containers.DeleteAsync(containerId, ct);
await AuditAppender.AppendTenantAsync(context, AuditEvents.ContainerDeleted, new { id = containerId }, ct); await AuditAppender.AppendTenantAsync(context, AuditEvents.ContainerDeleted, [AuditDetails.Set(AuditFields.ContainerId, containerId)], ct);
return Results.Ok(new { ok = true, movedToInbox = moved }); return Results.Ok(new { ok = true, movedToInbox = moved });
} }
@@ -299,12 +295,12 @@ public static class ContainersEndpoints
var wire = new Dictionary<string, object>(); var wire = new Dictionary<string, object>();
if (state.Collapsed is { } collapsed) if (state.Collapsed is { } collapsed)
{ {
wire["collapsed"] = collapsed; wire[KanbanWireKeys.Collapsed] = collapsed;
} }
if (state.Width is not null) if (state.Width is not null)
{ {
wire["width"] = state.Width; wire[KanbanWireKeys.Width] = state.Width;
} }
return wire; return wire;
@@ -55,10 +55,6 @@ public static class DiscoveryEndpoints
// Путь лога задачи (GET). // Путь лога задачи (GET).
private const string TaskLogPath = "/tasks/{task_id}/log"; private const string TaskLogPath = "/tasks/{task_id}/log";
private const string TaskNotFoundDetail = "Задача не найдена";
private const string CandidateNotFoundDetail = "Кандидат не найден";
private const string AlreadyJoinedDetail = "Уже вступили в этот источник"; private const string AlreadyJoinedDetail = "Уже вступили в этот источник";
private const string JoinedRejectDetail = "Уже вступили — удалите источник из каналов"; private const string JoinedRejectDetail = "Уже вступили — удалите источник из каналов";
@@ -149,8 +145,8 @@ public static class DiscoveryEndpoints
try try
{ {
DiscoveryTasksService tasks = context.RequestServices.GetRequiredService<DiscoveryTasksService>(); DiscoveryTasksService tasks = context.RequestServices.GetRequiredService<DiscoveryTasksService>();
DiscoveryTaskDto? task = await tasks.PatchAsync(task_id, ToPatch(body), ct); DiscoveryTaskDto task = await tasks.PatchAsync(task_id, ToPatch(body), ct);
return task is null ? EndpointResults.NotFound(TaskNotFoundDetail) : Results.Ok(task); return Results.Ok(task);
} }
catch (DiscoveryValidationException exception) catch (DiscoveryValidationException exception)
{ {
@@ -169,8 +165,8 @@ public static class DiscoveryEndpoints
} }
DiscoveryTasksService tasks = context.RequestServices.GetRequiredService<DiscoveryTasksService>(); DiscoveryTasksService tasks = context.RequestServices.GetRequiredService<DiscoveryTasksService>();
bool deleted = await tasks.DeleteAsync(task_id, ct); await tasks.DeleteAsync(task_id, ct);
return deleted ? Results.Ok(new { ok = true }) : EndpointResults.NotFound(TaskNotFoundDetail); return Results.Ok(new { ok = true });
} }
private static async Task<IResult> StartTaskAsync( private static async Task<IResult> StartTaskAsync(
@@ -186,8 +182,8 @@ public static class DiscoveryEndpoints
try try
{ {
DiscoveryTasksService tasks = context.RequestServices.GetRequiredService<DiscoveryTasksService>(); DiscoveryTasksService tasks = context.RequestServices.GetRequiredService<DiscoveryTasksService>();
DiscoveryTaskDto? task = await tasks.StartAsync(task_id, ct); DiscoveryTaskDto task = await tasks.StartAsync(task_id, ct);
return task is null ? EndpointResults.NotFound(TaskNotFoundDetail) : Results.Ok(task); return Results.Ok(task);
} }
catch (DiscoveryValidationException exception) catch (DiscoveryValidationException exception)
{ {
@@ -206,8 +202,8 @@ public static class DiscoveryEndpoints
} }
DiscoveryTasksService tasks = context.RequestServices.GetRequiredService<DiscoveryTasksService>(); DiscoveryTasksService tasks = context.RequestServices.GetRequiredService<DiscoveryTasksService>();
DiscoveryTaskDto? task = await tasks.PauseAsync(task_id, ct); DiscoveryTaskDto task = await tasks.PauseAsync(task_id, ct);
return task is null ? EndpointResults.NotFound(TaskNotFoundDetail) : Results.Ok(task); return Results.Ok(task);
} }
private static async Task<IResult> GenerateKeywordsAsync( private static async Task<IResult> GenerateKeywordsAsync(
@@ -221,11 +217,7 @@ public static class DiscoveryEndpoints
} }
DiscoveryTasksService tasks = context.RequestServices.GetRequiredService<DiscoveryTasksService>(); DiscoveryTasksService tasks = context.RequestServices.GetRequiredService<DiscoveryTasksService>();
DiscoveryTaskDto? task = await tasks.GetAsync(task_id, ct); DiscoveryTaskDto task = await tasks.GetAsync(task_id, ct);
if (task is null)
{
return EndpointResults.NotFound(TaskNotFoundDetail);
}
ISettingsStore settings = context.RequestServices.GetRequiredService<ISettingsStore>(); ISettingsStore settings = context.RequestServices.GetRequiredService<ISettingsStore>();
if (!await ReadAiEnabledAsync(settings, ct)) if (!await ReadAiEnabledAsync(settings, ct))
@@ -268,11 +260,7 @@ public static class DiscoveryEndpoints
} }
DiscoveryTasksService tasks = context.RequestServices.GetRequiredService<DiscoveryTasksService>(); DiscoveryTasksService tasks = context.RequestServices.GetRequiredService<DiscoveryTasksService>();
DiscoveryTaskDto? task = await tasks.GetAsync(task_id, ct); DiscoveryTaskDto task = await tasks.GetAsync(task_id, ct);
if (task is null)
{
return EndpointResults.NotFound(TaskNotFoundDetail);
}
DiscoveryCandidatesService candidates = context.RequestServices.GetRequiredService<DiscoveryCandidatesService>(); DiscoveryCandidatesService candidates = context.RequestServices.GetRequiredService<DiscoveryCandidatesService>();
IReadOnlyList<DiscoveryCandidateDto> items = await candidates.ListAsync(task_id, status, ct); IReadOnlyList<DiscoveryCandidateDto> items = await candidates.ListAsync(task_id, status, ct);
@@ -290,11 +278,7 @@ public static class DiscoveryEndpoints
} }
DiscoveryCandidatesService candidates = context.RequestServices.GetRequiredService<DiscoveryCandidatesService>(); DiscoveryCandidatesService candidates = context.RequestServices.GetRequiredService<DiscoveryCandidatesService>();
DiscoveryCandidateDto? row = await candidates.GetAsync(dialog_id, ct); DiscoveryCandidateDto row = await candidates.GetAsync(dialog_id, ct);
if (row is null)
{
return EndpointResults.NotFound(CandidateNotFoundDetail);
}
if (row.Status == DiscoveryCandidateStatuses.Joined) if (row.Status == DiscoveryCandidateStatuses.Joined)
{ {
@@ -323,8 +307,8 @@ public static class DiscoveryEndpoints
try try
{ {
DiscoveryCandidateDto? joined = await candidates.MarkJoinedAsync(dialog_id, auto: false, ct); DiscoveryCandidateDto joined = await candidates.MarkJoinedAsync(dialog_id, auto: false, ct);
return joined is null ? EndpointResults.NotFound(CandidateNotFoundDetail) : Results.Ok(joined); return Results.Ok(joined);
} }
catch (DiscoveryValidationException exception) catch (DiscoveryValidationException exception)
{ {
@@ -343,11 +327,7 @@ public static class DiscoveryEndpoints
} }
DiscoveryCandidatesService candidates = context.RequestServices.GetRequiredService<DiscoveryCandidatesService>(); DiscoveryCandidatesService candidates = context.RequestServices.GetRequiredService<DiscoveryCandidatesService>();
DiscoveryCandidateDto? row = await candidates.GetAsync(dialog_id, ct); DiscoveryCandidateDto row = await candidates.GetAsync(dialog_id, ct);
if (row is null)
{
return EndpointResults.NotFound(CandidateNotFoundDetail);
}
if (row.Status == DiscoveryCandidateStatuses.Joined) if (row.Status == DiscoveryCandidateStatuses.Joined)
{ {
@@ -356,8 +336,8 @@ public static class DiscoveryEndpoints
try try
{ {
DiscoveryCandidateDto? rejected = await candidates.MarkRejectedAsync(dialog_id, ManualRejectReason, ct); DiscoveryCandidateDto rejected = await candidates.MarkRejectedAsync(dialog_id, ManualRejectReason, ct);
return rejected is null ? EndpointResults.NotFound(CandidateNotFoundDetail) : Results.Ok(rejected); return Results.Ok(rejected);
} }
catch (DiscoveryValidationException exception) catch (DiscoveryValidationException exception)
{ {
@@ -403,11 +383,7 @@ public static class DiscoveryEndpoints
} }
DiscoveryTasksService tasks = context.RequestServices.GetRequiredService<DiscoveryTasksService>(); DiscoveryTasksService tasks = context.RequestServices.GetRequiredService<DiscoveryTasksService>();
DiscoveryTaskDto? task = await tasks.GetAsync(task_id, ct); DiscoveryTaskDto task = await tasks.GetAsync(task_id, ct);
if (task is null)
{
return EndpointResults.NotFound(TaskNotFoundDetail);
}
DiscoveryLogService log = context.RequestServices.GetRequiredService<DiscoveryLogService>(); DiscoveryLogService log = context.RequestServices.GetRequiredService<DiscoveryLogService>();
IReadOnlyList<DiscoveryLogDto> items = await log.TaskLogAsync(task_id, ct); IReadOnlyList<DiscoveryLogDto> items = await log.TaskLogAsync(task_id, ct);
+2 -1
View File
@@ -69,7 +69,8 @@ public static class JoinEndpoint
TenantId: result.TenantId, TenantId: result.TenantId,
Ip: ClientIp(context), Ip: ClientIp(context),
// Код инвайта — capability-токен: в аудит пишется только SHA-256-хэш (Security review). // Код инвайта — capability-токен: в аудит пишется только SHA-256-хэш (Security review).
DetailJson: AuditService.ToDetailJson(new { email = result.Login, codeHash = SessionTokens.HashToken(body.Code?.Trim() ?? string.Empty) })), ct); DetailJson: AuditService.ToDetailJson(
[AuditDetails.Set(AuditFields.Email, result.Login), AuditDetails.Set(AuditFields.CodeHash, SessionTokens.HashToken(body.Code?.Trim() ?? string.Empty))])), ct);
return Results.Ok(new { ok = true, login = result.Login }); return Results.Ok(new { ok = true, login = result.Login });
} }
+1 -8
View File
@@ -37,8 +37,6 @@ public static class MlEndpoints
private const string EnterTextDetail = "Введите текст"; private const string EnterTextDetail = "Введите текст";
private const string MessageNotFoundDetail = "Исходное сообщение не найдено";
/// <summary> /// <summary>
/// Регистрирует группу /api/ml /// Регистрирует группу /api/ml
/// </summary> /// </summary>
@@ -141,12 +139,7 @@ public static class MlEndpoints
} }
MlReviewService review = context.RequestServices.GetRequiredService<MlReviewService>(); MlReviewService review = context.RequestServices.GetRequiredService<MlReviewService>();
MlApplyResult? result = await review.ApplyAsync(body.DialogId, body.MsgId, body.Action, ct); MlApplyResult result = await review.ApplyAsync(body.DialogId, body.MsgId, body.Action, ct);
if (result is null)
{
return EndpointResults.NotFound(MessageNotFoundDetail);
}
if (result.Error is not null) if (result.Error is not null)
{ {
return EndpointResults.BadRequest(result.Error); return EndpointResults.BadRequest(result.Error);
@@ -1,5 +1,6 @@
using Deal.Api.Extensions; using Deal.Api.Extensions;
using Deal.Api.Services; using Deal.Api.Services;
using Deal.Modules.Tenants.Application.Abstractions;
using Deal.Modules.Tenants.Application.Models; using Deal.Modules.Tenants.Application.Models;
using Deal.Modules.Tenants.Application.Services; using Deal.Modules.Tenants.Application.Services;
@@ -132,6 +133,9 @@ public static class OperatorAnalyticsEndpoints
int? offset, int? offset,
HttpContext context, HttpContext context,
AnalyticsService analyticsService, AnalyticsService analyticsService,
ITenantRepository tenantRepository,
IAuthStore authStore,
IAuditReferenceResolver referenceResolver,
CancellationToken ct) CancellationToken ct)
{ {
if (context.GetCurrentOperator() is null) if (context.GetCurrentOperator() is null)
@@ -141,7 +145,8 @@ public static class OperatorAnalyticsEndpoints
AnalyticsActivityDto activity = await analyticsService.ActivityAsync( AnalyticsActivityDto activity = await analyticsService.ActivityAsync(
eventType, actorType, actorId, tenantId, from, to, limit, offset, ct); eventType, actorType, actorId, tenantId, from, to, limit, offset, ct);
return Results.Ok(activity); IReadOnlyList<AuditRecordViewDto> view = await AuditViewFactory.ProjectAsync(activity.Items, tenantRepository, authStore, referenceResolver, ct);
return Results.Ok(new { items = view, total = activity.Total, limit = activity.Limit, offset = activity.Offset });
} }
/// <summary> /// <summary>
@@ -1,5 +1,6 @@
using Deal.Api.Extensions; using Deal.Api.Extensions;
using Deal.Api.Services; using Deal.Api.Services;
using Deal.Modules.Tenants.Application.Abstractions;
using Deal.Modules.Tenants.Application.Models; using Deal.Modules.Tenants.Application.Models;
using Deal.Modules.Tenants.Application.Services; using Deal.Modules.Tenants.Application.Services;
@@ -39,6 +40,9 @@ public static class OperatorAuditEndpoints
int? offset, int? offset,
HttpContext context, HttpContext context,
AuditService auditService, AuditService auditService,
ITenantRepository tenantRepository,
IAuthStore authStore,
IAuditReferenceResolver referenceResolver,
CancellationToken ct) CancellationToken ct)
{ {
var operatorIdentity = context.GetCurrentOperator(); var operatorIdentity = context.GetCurrentOperator();
@@ -51,7 +55,8 @@ public static class OperatorAuditEndpoints
eventType, actorType, tenantId, from, to, NormalizeLimit(limit), actorId, NormalizeOffset(offset)); eventType, actorType, tenantId, from, to, NormalizeLimit(limit), actorId, NormalizeOffset(offset));
IReadOnlyList<AuditRecordDto> items = await auditService.QueryAsync(filter, ct); IReadOnlyList<AuditRecordDto> items = await auditService.QueryAsync(filter, ct);
int total = await auditService.CountAsync(filter, ct); int total = await auditService.CountAsync(filter, ct);
return Results.Ok(new { items, total }); IReadOnlyList<AuditRecordViewDto> view = await AuditViewFactory.ProjectAsync(items, tenantRepository, authStore, referenceResolver, ct);
return Results.Ok(new { items = view, total });
} }
/// <summary> /// <summary>
@@ -64,7 +64,7 @@ public static class OperatorAuthEndpoints
ActorId: null, ActorId: null,
TenantId: null, TenantId: null,
Ip: ClientIp(context), Ip: ClientIp(context),
DetailJson: AuditService.ToDetailJson(new { login = attemptedLogin })), ct); DetailJson: AuditService.ToDetailJson([AuditDetails.Set(AuditFields.Login, attemptedLogin)])), ct);
} }
return EndpointResults.Unauthorized(InvalidCredentialsDetail); return EndpointResults.Unauthorized(InvalidCredentialsDetail);
@@ -78,7 +78,7 @@ public static class OperatorAuthEndpoints
ActorId: result.OperatorId, ActorId: result.OperatorId,
TenantId: null, TenantId: null,
Ip: ClientIp(context), Ip: ClientIp(context),
DetailJson: AuditService.ToDetailJson(new { login = result.Login })), ct); DetailJson: AuditService.ToDetailJson([AuditDetails.Set(AuditFields.Login, result.Login)])), ct);
SetOperatorSessionCookie(context, cookieOptions.Value, result.Token); SetOperatorSessionCookie(context, cookieOptions.Value, result.Token);
return Results.Ok(new { ok = true, login = result.Login }); return Results.Ok(new { ok = true, login = result.Login });
@@ -100,7 +100,7 @@ public static class OperatorAuthEndpoints
if (operatorIdentity is not null) if (operatorIdentity is not null)
{ {
await AuditAppender.AppendOperatorAsync(context, AuditEvents.OperatorLogout, new { login = operatorIdentity.Login }, ct); await AuditAppender.AppendOperatorAsync(context, AuditEvents.OperatorLogout, [AuditDetails.Set(AuditFields.Login, operatorIdentity.Login)], ct);
} }
return Results.Ok(new { ok = true }); return Results.Ok(new { ok = true });
@@ -92,7 +92,8 @@ public static class OperatorInvitesEndpoints
Ip: ClientIp(context), Ip: ClientIp(context),
// Код инвайта — capability-токен (по нему активируется приглашение): в аудит пишется // Код инвайта — capability-токен (по нему активируется приглашение): в аудит пишется
// только его SHA-256-хэш, чтобы утечка ленты не давала рабочие коды (Security review). // только его SHA-256-хэш, чтобы утечка ленты не давала рабочие коды (Security review).
DetailJson: AuditService.ToDetailJson(new { email = result.Invite.Email, codeHash = SessionTokens.HashToken(result.Invite.Code) })), ct); DetailJson: AuditService.ToDetailJson(
[AuditDetails.Set(AuditFields.Email, result.Invite.Email), AuditDetails.Set(AuditFields.CodeHash, SessionTokens.HashToken(result.Invite.Code))])), ct);
InviteDto invite = result.Invite; InviteDto invite = result.Invite;
return Results.Ok(new { invite.Code, invite.Email, invite.TenantId, invite.ExpiresAt, invite.Status }); return Results.Ok(new { invite.Code, invite.Email, invite.TenantId, invite.ExpiresAt, invite.Status });
@@ -126,7 +127,8 @@ public static class OperatorInvitesEndpoints
ActorId: operatorIdentity.OperatorId, ActorId: operatorIdentity.OperatorId,
TenantId: null, TenantId: null,
Ip: ClientIp(context), Ip: ClientIp(context),
DetailJson: AuditService.ToDetailJson(new { email = result.Invite!.Email, codeHash = SessionTokens.HashToken(result.Invite.Code) })), ct); DetailJson: AuditService.ToDetailJson(
[AuditDetails.Set(AuditFields.Email, result.Invite!.Email), AuditDetails.Set(AuditFields.CodeHash, SessionTokens.HashToken(result.Invite.Code))])), ct);
return Results.Ok(new { ok = true }); return Results.Ok(new { ok = true });
} }
@@ -58,6 +58,7 @@ public static class OperatorLimitsEndpoints
private static async Task<IResult> ListSummaryAsync( private static async Task<IResult> ListSummaryAsync(
HttpContext context, HttpContext context,
ITenantRepository tenantRepository, ITenantRepository tenantRepository,
IAuthStore authStore,
ITenantLimitStore limitStore, ITenantLimitStore limitStore,
CancellationToken ct) CancellationToken ct)
{ {
@@ -68,6 +69,8 @@ public static class OperatorLimitsEndpoints
} }
IReadOnlyList<TenantRecordDto> tenants = await tenantRepository.ListAsync(ct); IReadOnlyList<TenantRecordDto> tenants = await tenantRepository.ListAsync(ct);
IReadOnlyDictionary<Guid, string> owners = await authStore.FindOwnerLoginsByTenantIdsAsync(
tenants.Select(tenant => tenant.Id).ToArray(), ct);
var items = new List<object>(tenants.Count); var items = new List<object>(tenants.Count);
foreach (TenantRecordDto tenant in tenants) foreach (TenantRecordDto tenant in tenants)
{ {
@@ -75,6 +78,7 @@ public static class OperatorLimitsEndpoints
items.Add(new items.Add(new
{ {
tenantId = tenant.Id, tenantId = tenant.Id,
ownerLogin = owners.GetValueOrDefault(tenant.Id),
name = tenant.Name, name = tenant.Name,
budget = state.BudgetTokens, budget = state.BudgetTokens,
period = state.Period, period = state.Period,
@@ -92,6 +96,7 @@ public static class OperatorLimitsEndpoints
Guid id, Guid id,
HttpContext context, HttpContext context,
ITenantRepository tenantRepository, ITenantRepository tenantRepository,
IAuthStore authStore,
ITenantLimitStore limitStore, ITenantLimitStore limitStore,
CancellationToken ct) CancellationToken ct)
{ {
@@ -108,7 +113,7 @@ public static class OperatorLimitsEndpoints
} }
BudgetStateDto state = await limitStore.GetStateAsync(id, ct); BudgetStateDto state = await limitStore.GetStateAsync(id, ct);
return Results.Ok(BuildDetailDto(tenant.Name, state)); return Results.Ok(BuildDetailDto(tenant.Name, await ResolveOwnerLoginAsync(authStore, id, ct), state));
} }
// PATCH /api/operator/tenants/{id}/limit: смена бюджета/периода (сброс флагов + аудит tenant_limit_changed). // PATCH /api/operator/tenants/{id}/limit: смена бюджета/периода (сброс флагов + аудит tenant_limit_changed).
@@ -117,6 +122,7 @@ public static class OperatorLimitsEndpoints
OperatorLimitUpdateRequest? body, OperatorLimitUpdateRequest? body,
HttpContext context, HttpContext context,
ITenantRepository tenantRepository, ITenantRepository tenantRepository,
IAuthStore authStore,
ITenantLimitStore limitStore, ITenantLimitStore limitStore,
AuditService auditService, AuditService auditService,
CancellationToken ct) CancellationToken ct)
@@ -154,10 +160,11 @@ public static class OperatorLimitsEndpoints
BudgetStateDto current = await limitStore.GetStateAsync(id, ct); BudgetStateDto current = await limitStore.GetStateAsync(id, ct);
long newBudget = body.Budget ?? current.BudgetTokens; long newBudget = body.Budget ?? current.BudgetTokens;
string newPeriod = body.Period ?? current.Period; string newPeriod = body.Period ?? current.Period;
string? ownerLogin = await ResolveOwnerLoginAsync(authStore, id, ct);
if (newBudget == current.BudgetTokens && newPeriod == current.Period) if (newBudget == current.BudgetTokens && newPeriod == current.Period)
{ {
// Идемпотентный повторный PATCH: без изменения хранилища и без дубля аудита. // Идемпотентный повторный PATCH: без изменения хранилища и без дубля аудита.
return Results.Ok(BuildDetailDto(tenant.Name, current)); return Results.Ok(BuildDetailDto(tenant.Name, ownerLogin, current));
} }
BudgetStateDto updated = await limitStore.UpdateBudgetAsync(id, newBudget, newPeriod, ct); BudgetStateDto updated = await limitStore.UpdateBudgetAsync(id, newBudget, newPeriod, ct);
@@ -167,16 +174,10 @@ public static class OperatorLimitsEndpoints
ActorId: operatorIdentity.OperatorId, ActorId: operatorIdentity.OperatorId,
TenantId: id, TenantId: id,
Ip: ClientIp(context), Ip: ClientIp(context),
DetailJson: AuditService.ToDetailJson(new DetailJson: AuditService.ToDetailJson(
{ [AuditDetails.Change(AuditFields.Budget, current.BudgetTokens, newBudget), AuditDetails.Change(AuditFields.Period, current.Period, newPeriod)])), ct);
tenantId = id,
oldBudget = current.BudgetTokens,
oldPeriod = current.Period,
budgetTokens = newBudget,
period = newPeriod,
})), ct);
return Results.Ok(BuildDetailDto(tenant.Name, updated)); return Results.Ok(BuildDetailDto(tenant.Name, ownerLogin, updated));
} }
/// <summary> /// <summary>
@@ -197,11 +198,13 @@ public static class OperatorLimitsEndpoints
// Форма деталей лимита тенанта (GET и ответ PATCH — единая). // Форма деталей лимита тенанта (GET и ответ PATCH — единая).
// name: Имя тенанта (реестр). // name: Имя тенанта (реестр).
// ownerLogin: Логин владельца пространства; null, если пользователей нет.
// state: Состояние бюджета (после ленивого reset). // state: Состояние бюджета (после ленивого reset).
// Возвращает: Объект ответа: лимит + расход + флаги порогов + статус тенанта. // Возвращает: Объект ответа: лимит + расход + флаги порогов + статус тенанта.
private static object BuildDetailDto(string name, BudgetStateDto state) => new private static object BuildDetailDto(string name, string? ownerLogin, BudgetStateDto state) => new
{ {
tenantId = state.TenantId, tenantId = state.TenantId,
ownerLogin,
name, name,
status = state.Status, status = state.Status,
allowed = state.Allowed, allowed = state.Allowed,
@@ -215,6 +218,17 @@ public static class OperatorLimitsEndpoints
notifiedExhausted = state.NotifiedExhausted, notifiedExhausted = state.NotifiedExhausted,
}; };
// Логин владельца пространства (первый пользователь по времени создания).
// authStore: Хранилище пользователей.
// tenantId: Идентификатор пространства.
// ct: Токен отмены.
// Возвращает: Логин либо null.
private static async Task<string?> ResolveOwnerLoginAsync(IAuthStore authStore, Guid tenantId, CancellationToken ct)
{
IReadOnlyDictionary<Guid, string> owners = await authStore.FindOwnerLoginsByTenantIdsAsync([tenantId], ct);
return owners.GetValueOrDefault(tenantId);
}
// IP-адрес клиента для аудита (без порта; null, если недоступен). // IP-адрес клиента для аудита (без порта; null, если недоступен).
// context: Контекст запроса. // context: Контекст запроса.
// Возвращает: Строковое представление IP или null. // Возвращает: Строковое представление IP или null.
@@ -128,7 +128,8 @@ public static class OperatorSettingsEndpoints
ActorId: operatorIdentity.OperatorId, ActorId: operatorIdentity.OperatorId,
TenantId: null, TenantId: null,
Ip: ClientIp(context), Ip: ClientIp(context),
DetailJson: AuditService.ToDetailJson(new { apiId = effectiveApiId, apiHashSet = true })), ct); DetailJson: AuditService.ToDetailJson(
[AuditDetails.Set(AuditFields.ApiId, effectiveApiId), AuditDetails.Set(AuditFields.ApiHashSet, true)])), ct);
TelegramKeysMaskedDto snapshot = await keys.GetMaskedAsync(ct); TelegramKeysMaskedDto snapshot = await keys.GetMaskedAsync(ct);
return Results.Ok(snapshot); return Results.Ok(snapshot);
@@ -113,7 +113,8 @@ public static class OperatorTenantsEndpoints
ActorId: operatorIdentity.OperatorId, ActorId: operatorIdentity.OperatorId,
TenantId: createdTenant.Id, TenantId: createdTenant.Id,
Ip: ClientIp(context), Ip: ClientIp(context),
DetailJson: AuditService.ToDetailJson(new { tenantId = createdTenant.Id, name = createdTenant.Name, email = result.OwnerLogin })), ct); DetailJson: AuditService.ToDetailJson(
[AuditDetails.Set(AuditFields.Name, createdTenant.Name), AuditDetails.Set(AuditFields.Email, result.OwnerLogin)])), ct);
if (result.OwnerLogin is not null) if (result.OwnerLogin is not null)
{ {
@@ -144,12 +145,7 @@ public static class OperatorTenantsEndpoints
return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail); return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail);
} }
TenantDetailDto? tenant = await tenantAdminService.GetAsync(id, ct); TenantDetailDto tenant = await tenantAdminService.GetAsync(id, ct);
if (tenant is null)
{
return EndpointResults.NotFound(TenantNotFoundDetail);
}
return Results.Ok(tenant); return Results.Ok(tenant);
} }
@@ -207,7 +203,8 @@ public static class OperatorTenantsEndpoints
ActorId: operatorIdentity.OperatorId, ActorId: operatorIdentity.OperatorId,
TenantId: result.Tenant.Id, TenantId: result.Tenant.Id,
Ip: ClientIp(context), Ip: ClientIp(context),
DetailJson: AuditService.ToDetailJson(new { tenantId = result.Tenant.Id, status = result.Tenant.Status })), ct); DetailJson: AuditService.ToDetailJson(
[AuditDetails.Change(AuditFields.Status, result.PreviousStatus, result.Tenant.Status)])), ct);
} }
return Results.Ok(new { ok = true, status = result.Tenant.Status }); return Results.Ok(new { ok = true, status = result.Tenant.Status });
@@ -247,7 +244,7 @@ public static class OperatorTenantsEndpoints
ActorId: operatorIdentity.OperatorId, ActorId: operatorIdentity.OperatorId,
TenantId: result.TenantId, TenantId: result.TenantId,
Ip: ClientIp(context), Ip: ClientIp(context),
DetailJson: AuditService.ToDetailJson(new { targetLogin = result.Login, tenantId = result.TenantId })), ct); DetailJson: AuditService.ToDetailJson([AuditDetails.Set(AuditFields.TargetLogin, result.Login)])), ct);
// Токен — это tenant-сессия (как после /api/auth/login): СТАВИМ ту же httpOnly-куку deal_session // Токен — это tenant-сессия (как после /api/auth/login): СТАВИМ ту же httpOnly-куку deal_session
// на ответ, чтобы браузер оператора сразу получил tenant-сессию (JS не может записать httpOnly-куку). // на ответ, чтобы браузер оператора сразу получил tenant-сессию (JS не может записать httpOnly-куку).
@@ -33,8 +33,6 @@ public static class PipelineEndpoints
// Путь возврата записи отсева в обработку (POST). // Путь возврата записи отсева в обработку (POST).
private const string RejectedReturnPath = "/rejected/{rejId}/return"; private const string RejectedReturnPath = "/rejected/{rejId}/return";
private const string RejectedNotFoundDetail = "Запись не найдена";
private const int DefaultPageSize = 100; private const int DefaultPageSize = 100;
/// <summary> /// <summary>
@@ -140,11 +138,7 @@ public static class PipelineEndpoints
} }
PipelineProcessingService processing = context.RequestServices.GetRequiredService<PipelineProcessingService>(); PipelineProcessingService processing = context.RequestServices.GetRequiredService<PipelineProcessingService>();
RejectReturnResultDto? result = await processing.ReturnAsync(rejId, body.Reason ?? string.Empty, ct); RejectReturnResultDto result = await processing.ReturnAsync(rejId, body.Reason ?? string.Empty, ct);
if (result is null)
{
return EndpointResults.NotFound(RejectedNotFoundDetail);
}
return result.Error is not null return result.Error is not null
? EndpointResults.BadRequest(result.Error) ? EndpointResults.BadRequest(result.Error)
@@ -5,6 +5,7 @@ using Deal.Modules.Settings.Application.Abstractions;
using Deal.Modules.Settings.Application.Models; using Deal.Modules.Settings.Application.Models;
using Deal.Modules.Settings.Application.Services; using Deal.Modules.Settings.Application.Services;
using Deal.Modules.Tenants.Application.Models; using Deal.Modules.Tenants.Application.Models;
using Deal.SharedKernel.Utilities;
namespace Deal.Api.Endpoints; namespace Deal.Api.Endpoints;
@@ -18,6 +19,15 @@ public static class SettingsEndpoints
private const string SettingsOpenApiTag = "settings"; private const string SettingsOpenApiTag = "settings";
private const string InvalidBodyDetail = "Тело запроса должно быть JSON-объектом"; private const string InvalidBodyDetail = "Тело запроса должно быть JSON-объектом";
// Максимальная длина значения в деталях аудита (промпты/списки бывают длинными).
private const int MaxAuditValueLength = 120;
// Пометка для составных настроек, чьи значения в аудит не пишутся (секреты, снимки состояния).
private const string ChangedMarker = "изменено";
// Опции сериализации снимка настроек в деталях аудита (camelCase, как на wire).
private static readonly JsonSerializerOptions AuditJsonOptions = new(JsonSerializerDefaults.Web);
/// <summary> /// <summary>
/// Регистрирует GET/PATCH /api/settings. /// Регистрирует GET/PATCH /api/settings.
/// </summary> /// </summary>
@@ -72,9 +82,10 @@ public static class SettingsEndpoints
} }
SettingsService settingsService = context.RequestServices.GetRequiredService<SettingsService>(); SettingsService settingsService = context.RequestServices.GetRequiredService<SettingsService>();
PublicSettingsDto before = await settingsService.GetPublicAsync(ct);
PublicSettingsDto result = await settingsService.ApplyPatchAsync(body, ct); PublicSettingsDto result = await settingsService.ApplyPatchAsync(body, ct);
await AuditAppender.AppendTenantAsync(context, AuditEvents.SettingsUpdated, new { fields = body.Keys }, ct); await AuditAppender.AppendTenantAsync(context, AuditEvents.SettingsUpdated, BuildSettingsChanges(before, result, body.Keys), ct);
if (ShouldScheduleRatesRefresh(body)) if (ShouldScheduleRatesRefresh(body))
{ {
@@ -84,6 +95,60 @@ public static class SettingsEndpoints
return Results.Ok(result); return Results.Ok(result);
} }
// Строит изменения настроек «поле: было → стало» по применённому PATCH.
// before: Снимок настроек до применения.
// after: Снимок настроек после применения.
// keys: Ключи PATCH-тела (изменённые).
// Возвращает: Изменения по каждому ключу (составные — пометкой «изменено»).
private static IReadOnlyList<AuditChangeDto> BuildSettingsChanges(
PublicSettingsDto before,
PublicSettingsDto after,
IEnumerable<string> keys)
{
using JsonDocument beforeDoc = JsonDocument.Parse(JsonSerializer.Serialize(before, AuditJsonOptions));
using JsonDocument afterDoc = JsonDocument.Parse(JsonSerializer.Serialize(after, AuditJsonOptions));
var changes = new List<AuditChangeDto>();
var keyList = new List<string>();
foreach (string key in keys)
{
keyList.Add(key);
SettingKind? kind = SettingsKeys.FindPublicKind(key);
if (kind is SettingKind.Dict or SettingKind.MyPrompts or SettingKind.AiConfigs)
{
changes.Add(AuditDetails.Set(key, ChangedMarker));
continue;
}
changes.Add(AuditDetails.Change(
key,
ReadSettingText(beforeDoc.RootElement, key),
ReadSettingText(afterDoc.RootElement, key)));
}
return changes.Count > 0
? changes
: [AuditDetails.Set(AuditFields.Fields, string.Join(", ", keyList))];
}
// Читает значение ключа снимка настроек как текст для деталей аудита.
private static string? ReadSettingText(JsonElement root, string key) =>
root.TryGetProperty(key, out JsonElement value) ? ToText(value) : null;
// Приводит значение JSON к строке отображения (длинные значения обрезаются).
private static string? ToText(JsonElement value) => value.ValueKind switch
{
JsonValueKind.Null or JsonValueKind.Undefined => null,
JsonValueKind.String => Truncate(value.GetString()),
JsonValueKind.True => BoolText.True,
JsonValueKind.False => BoolText.False,
JsonValueKind.Array => Truncate(string.Join(", ", value.EnumerateArray().Select(ToText))),
_ => Truncate(value.GetRawText()),
};
// Обрезает длинное значение деталей аудита.
private static string? Truncate(string? value) =>
value is not null && value.Length > MaxAuditValueLength ? value[..MaxAuditValueLength] + "…" : value;
private static bool ShouldScheduleRatesRefresh(Dictionary<string, JsonElement> body) private static bool ShouldScheduleRatesRefresh(Dictionary<string, JsonElement> body)
{ {
if (!body.TryGetValue(SettingsKeys.RateSource, out JsonElement element)) if (!body.TryGetValue(SettingsKeys.RateSource, out JsonElement element))
@@ -67,7 +67,12 @@ public static class TelegramEndpoints
private const string NotConnectedReason = "not-connected"; private const string NotConnectedReason = "not-connected";
private const string ReadyPhase = "ready"; private const string ReadyPhase = TelegramAuthPhases.Ready;
// Русские подписи видов диалогов для операторского UI.
private const string RussianChannelLabel = "канал";
private const string RussianGroupLabel = "группа";
private const string RussianChatLabel = "чат";
private const int PreviewDefaultLimit = 24; private const int PreviewDefaultLimit = 24;
@@ -170,7 +175,7 @@ public static class TelegramEndpoints
TelegramAuthResultDto result = await gateway.StartQrAsync(apiId, keys.ApiHash, ct); TelegramAuthResultDto result = await gateway.StartQrAsync(apiId, keys.ApiHash, ct);
if (result.Phase == ReadyPhase) if (result.Phase == ReadyPhase)
{ {
await AuditAppender.AppendTenantAsync(context, AuditEvents.TelegramLinked, new { phase = result.Phase }, ct); await AuditAppender.AppendTenantAsync(context, AuditEvents.TelegramLinked, [AuditDetails.Set(AuditFields.Phase, result.Phase)], ct);
} }
return Results.Ok(new { phase = result.Phase, qrUrl = result.QrUrl ?? string.Empty }); return Results.Ok(new { phase = result.Phase, qrUrl = result.QrUrl ?? string.Empty });
@@ -197,7 +202,7 @@ public static class TelegramEndpoints
string phase = await gateway.SendCodeAsync((body.Code ?? string.Empty).Trim(), ct); string phase = await gateway.SendCodeAsync((body.Code ?? string.Empty).Trim(), ct);
if (phase == ReadyPhase) if (phase == ReadyPhase)
{ {
await AuditAppender.AppendTenantAsync(context, AuditEvents.TelegramLinked, new { phase }, ct); await AuditAppender.AppendTenantAsync(context, AuditEvents.TelegramLinked, [AuditDetails.Set(AuditFields.Phase, phase)], ct);
} }
return Results.Ok(new { phase }); return Results.Ok(new { phase });
@@ -224,7 +229,7 @@ public static class TelegramEndpoints
string phase = await gateway.SendPasswordAsync(body.Password ?? string.Empty, ct); string phase = await gateway.SendPasswordAsync(body.Password ?? string.Empty, ct);
if (phase == ReadyPhase) if (phase == ReadyPhase)
{ {
await AuditAppender.AppendTenantAsync(context, AuditEvents.TelegramLinked, new { phase }, ct); await AuditAppender.AppendTenantAsync(context, AuditEvents.TelegramLinked, [AuditDetails.Set(AuditFields.Phase, phase)], ct);
} }
return Results.Ok(new { phase }); return Results.Ok(new { phase });
@@ -343,7 +348,7 @@ public static class TelegramEndpoints
if (body.Enabled) if (body.Enabled)
{ {
await AuditAppender.AppendTenantAsync(context, AuditEvents.ChannelEnabled, new { all = true, count = result.Count }, ct); await AuditAppender.AppendTenantAsync(context, AuditEvents.ChannelEnabled, [AuditDetails.Set(AuditFields.All, true), AuditDetails.Set(AuditFields.Count, result.Count)], ct);
} }
return Results.Ok(new { ok = true, count = result.Count, enabled = body.Enabled }); return Results.Ok(new { ok = true, count = result.Count, enabled = body.Enabled });
@@ -393,7 +398,7 @@ public static class TelegramEndpoints
if (result.Enabled) if (result.Enabled)
{ {
await AuditAppender.AppendTenantAsync(context, AuditEvents.ChannelEnabled, new { dialogId = dialog_id }, ct); await AuditAppender.AppendTenantAsync(context, AuditEvents.ChannelEnabled, [AuditDetails.Set(AuditFields.DialogId, dialog_id)], ct);
} }
return Results.Ok(new { ok = true, enabled = result.Enabled }); return Results.Ok(new { ok = true, enabled = result.Enabled });
@@ -482,9 +487,9 @@ public static class TelegramEndpoints
{ {
return kind switch return kind switch
{ {
"channel" => "канал", TelegramDialogKinds.Channel => RussianChannelLabel,
"group" or "forum" => "группа", TelegramDialogKinds.Group or TelegramDialogKinds.Forum => RussianGroupLabel,
"chat" => "чат", TelegramDialogKinds.Chat => RussianChatLabel,
_ => kind, _ => kind,
}; };
} }
+2 -1
View File
@@ -62,7 +62,8 @@ internal static class DealLogging
.MinimumLevel.Is(ParseMinimumLevel(configuration[MinimumLevelEnvKey])) .MinimumLevel.Is(ParseMinimumLevel(configuration[MinimumLevelEnvKey]))
.MinimumLevel.Override(EntityFrameworkCoreCategory, LogEventLevel.Warning) .MinimumLevel.Override(EntityFrameworkCoreCategory, LogEventLevel.Warning)
.MinimumLevel.Override(GrpcCategory, LogEventLevel.Information) .MinimumLevel.Override(GrpcCategory, LogEventLevel.Information)
.Enrich.FromLogContext(); .Enrich.FromLogContext()
.Enrich.With<TraceContextEnricher>();
string logsDirectory = ResolveLogsDirectory(environment.ContentRootPath, configuration[LogsDirectoryEnvKey]); string logsDirectory = ResolveLogsDirectory(environment.ContentRootPath, configuration[LogsDirectoryEnvKey]);
Directory.CreateDirectory(logsDirectory); Directory.CreateDirectory(logsDirectory);
@@ -0,0 +1,20 @@
using System.Diagnostics;
using Serilog.Core;
using Serilog.Events;
namespace Deal.Api.Logging;
internal sealed class TraceContextEnricher : ILogEventEnricher
{
void ILogEventEnricher.Enrich(LogEvent logEvent, ILogEventPropertyFactory propertyFactory)
{
Activity? activity = Activity.Current;
if (activity is null)
{
return;
}
logEvent.AddPropertyIfAbsent(propertyFactory.CreateProperty("TraceId", activity.TraceId.ToString()));
logEvent.AddPropertyIfAbsent(propertyFactory.CreateProperty("SpanId", activity.SpanId.ToString()));
}
}
@@ -0,0 +1,83 @@
using Deal.SharedKernel.Errors;
using Deal.SharedKernel.Resources;
using Deal.SharedKernel.Tenants.Abstractions;
using Microsoft.AspNetCore.Diagnostics;
namespace Deal.Api.Middleware;
public sealed class DealExceptionHandler(ILogger<DealExceptionHandler> logger) : IExceptionHandler
{
public async ValueTask<bool> TryHandleAsync(
HttpContext httpContext,
Exception exception,
CancellationToken cancellationToken)
{
(int statusCode, string errorCode, string detail) = Resolve(exception);
LogFailure(httpContext, exception, statusCode, errorCode);
httpContext.Response.StatusCode = statusCode;
await httpContext.Response.WriteAsJsonAsync(
new { detail, code = errorCode },
cancellationToken);
return true;
}
// Доменные ошибки отдаются по коду; прочие — обобщённый 500 без деталей и стектрейса.
private static (int StatusCode, string ErrorCode, string Detail) Resolve(Exception exception)
=> exception is DealException dealException
? (MapStatusCode(dealException.ErrorCode), dealException.ErrorCode, dealException.Message)
: (StatusCodes.Status500InternalServerError,
DealErrorCodes.Internal,
ErrorResources.Format(ErrorResourceKeys.UnexpectedError));
// Код ошибки Deal → статус HTTP.
private static int MapStatusCode(string errorCode) => errorCode switch
{
DealErrorCodes.NotFound => StatusCodes.Status404NotFound,
DealErrorCodes.Validation => StatusCodes.Status400BadRequest,
DealErrorCodes.Conflict => StatusCodes.Status409Conflict,
DealErrorCodes.Unavailable => StatusCodes.Status503ServiceUnavailable,
_ => StatusCodes.Status500InternalServerError,
};
// Доменные ошибки — Warning без стектрейса; непредвиденные — Error со стектрейсом (только в лог).
private void LogFailure(
HttpContext context,
Exception exception,
int statusCode,
string errorCode)
{
string method = context.Request.Method;
string path = context.Request.Path.Value ?? "/";
string tenantId = ResolveTenantId(context);
if (exception is DealException dealException)
{
logger.LogWarning(
"HTTP {Method} {Path} -> {StatusCode} {ErrorCode}; tenant={TenantId} trace={TraceId}: {Message}",
method,
path,
statusCode,
errorCode,
tenantId,
context.TraceIdentifier,
dealException.Message);
return;
}
logger.LogError(
exception,
"HTTP {Method} {Path} -> {StatusCode} {ErrorCode}; tenant={TenantId} trace={TraceId}",
method,
path,
statusCode,
errorCode,
tenantId,
context.TraceIdentifier);
}
// Идентификатор тенанта запроса; вне tenant-запроса — "-".
private static string ResolveTenantId(HttpContext context)
{
ITenantContext? tenantContext = context.RequestServices?.GetService<ITenantContext>();
return tenantContext?.TenantId?.Value ?? "-";
}
}
@@ -0,0 +1,54 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
using OpenTelemetry.Resources;
using OpenTelemetry.Trace;
namespace Deal.Api.Observability;
/// <summary>
/// Настройка трейсинга ядра Deal.Api
/// </summary>
public static class DealTracingHosting
{
/// <summary>
/// Env-ключ OTLP-endpoint коллектора
/// </summary>
public const string OtlpEndpointEnvKey = "OTEL_EXPORTER_OTLP_ENDPOINT";
/// <summary>
/// Env-ключ имени сервиса в трейсах
/// </summary>
public const string ServiceNameEnvKey = "OTEL_SERVICE_NAME";
/// <summary>
/// Регистрирует трейсинг OpenTelemetry с экспортом OTLP
/// </summary>
/// <param name="builder">Билдер ядра.</param>
/// <param name="defaultServiceName">Имя сервиса, если env OTEL_SERVICE_NAME не задан.</param>
public static void AddDealTracing(WebApplicationBuilder builder, string defaultServiceName)
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentException.ThrowIfNullOrWhiteSpace(defaultServiceName);
// Трейсинг выключен без OTLP-endpoint (dev без профиля observability): иначе экспортёр
// вхолостую спамит ошибками соединения.
string? endpoint = Environment.GetEnvironmentVariable(OtlpEndpointEnvKey);
if (string.IsNullOrWhiteSpace(endpoint))
{
return;
}
string serviceName = Environment.GetEnvironmentVariable(ServiceNameEnvKey) is { Length: > 0 } configured
? configured
: defaultServiceName;
builder.Services
.AddOpenTelemetry()
.WithTracing(tracing => tracing
.SetResourceBuilder(ResourceBuilder.CreateDefault().AddService(serviceName))
.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddGrpcClientInstrumentation()
.AddOtlpExporter(options => options.Endpoint = new Uri(endpoint)));
}
}
+6
View File
@@ -66,6 +66,7 @@ DealLogging.Configure(builder, coreProcessName);
int metricsPort = DealMetricsHosting.ResolveMetricsPort(DealMetricsHosting.DefaultMetricsPort); int metricsPort = DealMetricsHosting.ResolveMetricsPort(DealMetricsHosting.DefaultMetricsPort);
DealMetricsHosting.AddDealMetrics(builder, metricsPort); DealMetricsHosting.AddDealMetrics(builder, metricsPort);
DealTracingHosting.AddDealTracing(builder, coreProcessName);
// Строка подключения Postgres — ТОЛЬКО из конфигурации (env/appsettings): dev-пароль в коде отсутствует // Строка подключения Postgres — ТОЛЬКО из конфигурации (env/appsettings): dev-пароль в коде отсутствует
// (Security review). Отсутствие строки = fail-fast на старте, а не тихий уход на несуществующую dev-БД. // (Security review). Отсутствие строки = fail-fast на старте, а не тихий уход на несуществующую dev-БД.
@@ -132,6 +133,8 @@ TokenLimitDefaults tenantLimitDefaults = new(
builder.Services.AddDealPersistence(tenantLimitDefaults); builder.Services.AddDealPersistence(tenantLimitDefaults);
builder.Services.AddDealSecurity(builder.Environment.ContentRootPath); builder.Services.AddDealSecurity(builder.Environment.ContentRootPath);
builder.Services.AddExceptionHandler<DealExceptionHandler>();
builder.Services.AddProblemDetails();
MlServiceOptions mlOptions = builder.Configuration.GetSection(servicesSectionName).Get<MlServiceOptions>() ?? new MlServiceOptions(); MlServiceOptions mlOptions = builder.Configuration.GetSection(servicesSectionName).Get<MlServiceOptions>() ?? new MlServiceOptions();
builder.Services.AddSingleton(mlOptions); builder.Services.AddSingleton(mlOptions);
@@ -162,6 +165,8 @@ builder.Services.AddSingleton<TelegramBackfillScheduler>();
builder.Services.AddScoped<AdminTickOrchestrator>(); builder.Services.AddScoped<AdminTickOrchestrator>();
builder.Services.AddScoped<IAuditReferenceResolver, AuditReferenceResolver>();
builder.Services.AddScoped<FtsMaintenance>(); builder.Services.AddScoped<FtsMaintenance>();
builder.Services.AddSingleton<SseBroker>(); builder.Services.AddSingleton<SseBroker>();
@@ -325,6 +330,7 @@ if (forwardedHeadersConfig.Enabled)
app.UseForwardedHeaders(BuildForwardedHeadersOptions(forwardedHeadersConfig)); app.UseForwardedHeaders(BuildForwardedHeadersOptions(forwardedHeadersConfig));
} }
app.UseExceptionHandler();
app.UseMiddleware<HttpAccessLogMiddleware>(); app.UseMiddleware<HttpAccessLogMiddleware>();
app.UseCors(corsPolicyName); app.UseCors(corsPolicyName);
+15 -13
View File
@@ -13,12 +13,14 @@ public static class AuditAppender
/// <summary> /// <summary>
/// Пишет событие действия пользователя тенанта /// Пишет событие действия пользователя тенанта
/// </summary> /// </summary>
/// <param name="context">Контекст запроса (сессия пользователя, IP).</param>
/// <param name="eventType">Тип события — константа <see cref="AuditEvents"/>.</param> /// <param name="eventType">Тип события — константа <see cref="AuditEvents"/>.</param>
/// <param name="details">Минимальные детали события (обычно анонимный объект) или null.</param> /// <param name="changes">Изменения параметров события.</param>
/// <param name="ct">Токен отмены.</param>
public static async Task AppendTenantAsync( public static async Task AppendTenantAsync(
HttpContext context, HttpContext context,
string eventType, string eventType,
object? details, IReadOnlyList<AuditChangeDto> changes,
CancellationToken ct) CancellationToken ct)
{ {
CurrentUser? user = context.GetCurrentUser(); CurrentUser? user = context.GetCurrentUser();
@@ -35,19 +37,21 @@ public static class AuditAppender
ActorId: user.UserId, ActorId: user.UserId,
TenantId: user.TenantId, TenantId: user.TenantId,
Ip: ClientIp(context), Ip: ClientIp(context),
DetailJson: DetailJson(details)), DetailJson: DetailJson(changes)),
ct); ct);
} }
/// <summary> /// <summary>
/// Пишет событие действия оператора /// Пишет событие действия оператора
/// </summary> /// </summary>
/// <param name="context">Контекст запроса (операторская сессия, IP).</param>
/// <param name="eventType">Тип события — константа <see cref="AuditEvents"/>.</param> /// <param name="eventType">Тип события — константа <see cref="AuditEvents"/>.</param>
/// <param name="details">Минимальные детали события (обычно анонимный объект) или null.</param> /// <param name="changes">Изменения параметров события.</param>
/// <param name="ct">Токен отмены.</param>
public static async Task AppendOperatorAsync( public static async Task AppendOperatorAsync(
HttpContext context, HttpContext context,
string eventType, string eventType,
object? details, IReadOnlyList<AuditChangeDto> changes,
CancellationToken ct) CancellationToken ct)
{ {
CurrentOperator? operatorIdentity = context.GetCurrentOperator(); CurrentOperator? operatorIdentity = context.GetCurrentOperator();
@@ -64,18 +68,16 @@ public static class AuditAppender
ActorId: operatorIdentity.OperatorId, ActorId: operatorIdentity.OperatorId,
TenantId: null, TenantId: null,
Ip: ClientIp(context), Ip: ClientIp(context),
DetailJson: DetailJson(details)), DetailJson: DetailJson(changes)),
ct); ct);
} }
// Сериализует детали события (null — деталей нет). // Сериализует детали события (пустой список — деталей нет).
// details: Объект деталей или null. // changes: Изменения параметров события.
// Возвращает: JSON деталей (camelCase) или null. // Возвращает: JSON деталей или null.
private static string? DetailJson(object? details) => private static string? DetailJson(IReadOnlyList<AuditChangeDto> changes) =>
details is null ? null : AuditService.ToDetailJson(details); changes.Count == 0 ? null : AuditService.ToDetailJson(changes);
// IP-адрес клиента для аудита (без порта; null, если недоступен). // IP-адрес клиента для аудита (без порта; null, если недоступен).
// context: Контекст запроса.
// Возвращает: Строковое представление IP или null.
private static string? ClientIp(HttpContext context) => context.Connection.RemoteIpAddress?.ToString(); private static string? ClientIp(HttpContext context) => context.Connection.RemoteIpAddress?.ToString();
} }
@@ -0,0 +1,133 @@
using Deal.Modules.Cards.Application.Models;
using Deal.Modules.Kanban.Application.Abstractions;
using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Tenants.Application.Models;
using Deal.SharedKernel.Tenants.Abstractions;
using Deal.SharedKernel.Tenants.Models;
namespace Deal.Api.Services;
/// <summary>
/// Резолвер ссылок аудита на данные тенанта
/// </summary>
/// <param name="scopeFactory">Фабрика scope для чтения схемы тенанта.</param>
/// <param name="logger">Логгер сбоев разрешения ссылок.</param>
public sealed class AuditReferenceResolver(
IServiceScopeFactory scopeFactory,
ILogger<AuditReferenceResolver> logger) : IAuditReferenceResolver
{
async Task<IReadOnlyDictionary<string, string>> IAuditReferenceResolver.ResolveAsync(
IReadOnlyList<AuditRecordDto> records,
CancellationToken ct)
{
var names = new Dictionary<string, string>(StringComparer.Ordinal);
foreach (IGrouping<Guid, AuditRecordDto> group in GroupByTenant(records))
{
await ResolveTenantAsync(group.Key, group, names, ct);
}
return names;
}
private static IEnumerable<IGrouping<Guid, AuditRecordDto>> GroupByTenant(IReadOnlyList<AuditRecordDto> records) =>
records
.Where(record => record.TenantId is not null)
.GroupBy(record => record.TenantId!.Value);
private async Task ResolveTenantAsync(
Guid tenantId,
IEnumerable<AuditRecordDto> records,
Dictionary<string, string> names,
CancellationToken ct)
{
var cardIds = new HashSet<string>(StringComparer.Ordinal);
var boardIds = new HashSet<string>(StringComparer.Ordinal);
foreach (AuditRecordDto record in records)
{
foreach (AuditChangeDto change in record.AuditChanges())
{
Collect(change.From, cardIds, boardIds);
Collect(change.To, cardIds, boardIds);
}
}
if (cardIds.Count == 0 && boardIds.Count == 0)
{
return;
}
await using AsyncServiceScope scope = scopeFactory.CreateAsyncScope();
ITenantContext tenantContext = scope.ServiceProvider.GetRequiredService<ITenantContext>();
try
{
tenantContext.SetTenant(new TenantId(tenantId.ToString("N")));
ICardStore store = scope.ServiceProvider.GetRequiredService<ICardStore>();
await ResolveCardsAsync(store, cardIds, names, ct);
await ResolveContainersAsync(store, boardIds, names, ct);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception exception)
{
logger.LogWarning(exception, "Резолвер аудита: ссылки тенанта {TenantId} не разрешены", tenantId);
}
finally
{
tenantContext.Reset();
}
}
private static void Collect(
string? value,
HashSet<string> cardIds,
HashSet<string> boardIds)
{
if (string.IsNullOrEmpty(value))
{
return;
}
if (value.StartsWith(CardIds.CardPrefix, StringComparison.Ordinal))
{
cardIds.Add(value);
}
else if (value.StartsWith(KanbanIdPrefixes.Board, StringComparison.Ordinal))
{
boardIds.Add(value);
}
}
private static async Task ResolveCardsAsync(
ICardStore store,
HashSet<string> cardIds,
Dictionary<string, string> names,
CancellationToken ct)
{
foreach (string cardId in cardIds)
{
CardDto? card = await store.GetCardAsync(cardId, ct);
if (card is not null && !string.IsNullOrWhiteSpace(card.Title))
{
names[cardId] = card.Title;
}
}
}
private static async Task ResolveContainersAsync(
ICardStore store,
HashSet<string> boardIds,
Dictionary<string, string> names,
CancellationToken ct)
{
foreach (string boardId in boardIds)
{
ContainerDto? container = await store.GetContainerAsync(boardId, ct);
if (container is not null && !string.IsNullOrWhiteSpace(container.Name))
{
names[boardId] = container.Name;
}
}
}
}
@@ -0,0 +1,138 @@
using Deal.Modules.Tenants.Application.Abstractions;
using Deal.Modules.Tenants.Application.Models;
namespace Deal.Api.Services;
/// <summary>
/// Проекция записей аудита для операторской консоли
/// </summary>
public static class AuditViewFactory
{
/// <summary>
/// Дополняет записи аудита реальными пользователями и разбирает изменения деталей
/// </summary>
/// <param name="records">Записи аудита.</param>
/// <param name="tenantRepository">Реестр пространств для разрешения имён.</param>
/// <param name="authStore">Хранилище пользователей для разрешения логинов владельцев.</param>
/// <param name="referenceResolver">Резолвер ссылок на карточки и колонки.</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>Записи для чтения оператором.</returns>
public static async Task<IReadOnlyList<AuditRecordViewDto>> ProjectAsync(
IReadOnlyList<AuditRecordDto> records,
ITenantRepository tenantRepository,
IAuthStore authStore,
IAuditReferenceResolver referenceResolver,
CancellationToken ct)
{
Guid[] tenantIds = DistinctTenantIds(records);
IReadOnlyDictionary<Guid, string> names = await ResolveNamesAsync(tenantIds, tenantRepository, ct);
IReadOnlyDictionary<Guid, string> owners = await ResolveOwnerLoginsAsync(tenantIds, authStore, ct);
IReadOnlyDictionary<string, string> references = await referenceResolver.ResolveAsync(records, ct);
var view = new List<AuditRecordViewDto>(records.Count);
foreach (AuditRecordDto record in records)
{
string? tenantName = record.TenantId is { } tenantId && names.TryGetValue(tenantId, out string? name)
? name
: null;
view.Add(new AuditRecordViewDto(
record.EventType,
record.ActorType,
record.ActorId,
record.TenantId,
ResolveUserName(record, owners),
tenantName,
record.Ip,
ResolveChanges(record, references),
record.DetailJson,
record.At,
record.Id));
}
return view;
}
// Заменяет идентификаторы ссылок изменения значениями из карты имён и убирает пустые изменения.
// record: Запись аудита.
// references: Имена по идентификаторам ссылок.
// Возвращает: Изменения с читаемыми значениями ссылок без пар «без изменения».
private static IReadOnlyList<AuditChangeDto> ResolveChanges(
AuditRecordDto record,
IReadOnlyDictionary<string, string> references)
{
IReadOnlyList<AuditChangeDto> changes = record.AuditChanges();
var resolved = new List<AuditChangeDto>(changes.Count);
foreach (AuditChangeDto change in changes)
{
string? from = ResolveReference(references, change.From);
string? to = ResolveReference(references, change.To);
if (IsUnchanged(from, to))
{
continue;
}
resolved.Add(change with { From = from, To = to });
}
return resolved;
}
// Сравнивает значения «было» и «стало».
// from: Значение до изменения.
// to: Значение после изменения.
// Возвращает: True — значения заданы и совпадают.
private static bool IsUnchanged(string? from, string? to) =>
from is not null && to is not null && string.Equals(from, to, StringComparison.Ordinal);
// Подставляет имя вместо идентификатора ссылки, если оно известно.
// references: Имена по идентификаторам ссылок.
// value: Значение изменения.
// Возвращает: Имя ссылки либо исходное значение.
private static string? ResolveReference(
IReadOnlyDictionary<string, string> references,
string? value) =>
value is not null && references.TryGetValue(value, out string? name) ? name : value;
// Логин реального пользователя: владелец пространства, иначе логин/email из деталей события.
// record: Запись аудита.
// owners: Логины владельцев по идентификаторам пространств.
// Возвращает: Логин либо null.
private static string? ResolveUserName(
AuditRecordDto record,
IReadOnlyDictionary<Guid, string> owners)
{
if (record.TenantId is { } tenantId && owners.TryGetValue(tenantId, out string? owner))
{
return owner;
}
return record.DetailValue(AuditFields.Login) ?? record.DetailValue(AuditFields.Email);
}
// Уникальные идентификаторы пространств выборки.
private static Guid[] DistinctTenantIds(IReadOnlyList<AuditRecordDto> records) =>
records
.Select(record => record.TenantId)
.Where(id => id is not null)
.Select(id => id!.Value)
.Distinct()
.ToArray();
// Разрешает имена пространств выборкой по уникальным идентификаторам.
private static async Task<IReadOnlyDictionary<Guid, string>> ResolveNamesAsync(
Guid[] tenantIds,
ITenantRepository tenantRepository,
CancellationToken ct) =>
tenantIds.Length == 0
? new Dictionary<Guid, string>()
: await tenantRepository.FindNamesByIdsAsync(tenantIds, ct);
// Разрешает логины владельцев пространств выборкой по уникальным идентификаторам.
private static async Task<IReadOnlyDictionary<Guid, string>> ResolveOwnerLoginsAsync(
Guid[] tenantIds,
IAuthStore authStore,
CancellationToken ct) =>
tenantIds.Length == 0
? new Dictionary<Guid, string>()
: await authStore.FindOwnerLoginsByTenantIdsAsync(tenantIds, ct);
}
@@ -0,0 +1,19 @@
using Deal.Modules.Tenants.Application.Models;
namespace Deal.Api.Services;
/// <summary>
/// Разрешает идентификаторы ссылок аудита в читаемые имена
/// </summary>
public interface IAuditReferenceResolver
{
/// <summary>
/// Разрешает ссылки на карточки и колонки в их заголовки и имена
/// </summary>
/// <param name="records">Записи аудита выборки.</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>Имена по идентификаторам ссылок; неразрешённые идентификаторы отсутствуют.</returns>
public Task<IReadOnlyDictionary<string, string>> ResolveAsync(
IReadOnlyList<AuditRecordDto> records,
CancellationToken ct);
}
@@ -12,6 +12,10 @@ public sealed class TelegramBackfillScheduler(
IHostApplicationLifetime applicationLifetime, IHostApplicationLifetime applicationLifetime,
ILogger<TelegramBackfillScheduler> logger) ILogger<TelegramBackfillScheduler> logger)
{ {
// Имена фоновых задач (метки запуска).
private const string BackfillMonitoredJob = "backfill_monitored";
private const string FirstBackfillJob = "first_backfill";
// Флаг in-flight «Перечитать» всех каналов (Interlocked): повторный вызов не плодит // Флаг in-flight «Перечитать» всех каналов (Interlocked): повторный вызов не плодит
// параллельные полные перечитывания (Security review, как RatesRefreshScheduler). // параллельные полные перечитывания (Security review, как RatesRefreshScheduler).
private int _readRecentInProgress; private int _readRecentInProgress;
@@ -27,7 +31,7 @@ public sealed class TelegramBackfillScheduler(
return; return;
} }
RunBackground("backfill_monitored", RunReadRecentAsync); RunBackground(BackfillMonitoredJob, RunReadRecentAsync);
} }
/// <summary> /// <summary>
@@ -50,7 +54,7 @@ public sealed class TelegramBackfillScheduler(
return; return;
} }
RunBackground("first_backfill", (provider, runLogger, ct) => RunFirstBackfillsAsync(provider, runLogger, dialogIds, ct)); RunBackground(FirstBackfillJob, (provider, runLogger, ct) => RunFirstBackfillsAsync(provider, runLogger, dialogIds, ct));
} }
// Выполняет работу в собственном scope; любые ошибки — warning в лог (как RatesRefreshScheduler). // Выполняет работу в собственном scope; любые ошибки — warning в лог (как RatesRefreshScheduler).
@@ -21,6 +21,11 @@ public sealed class SourceIngressGrpcService(
IngressTenantResolver tenants, IngressTenantResolver tenants,
ILogger<SourceIngressGrpcService> logger) : SourceIngressService.SourceIngressServiceBase ILogger<SourceIngressGrpcService> logger) : SourceIngressService.SourceIngressServiceBase
{ {
// Исходы приёма источника для лога аудита.
private const string DuplicateOutcome = "duplicate";
private const string NoOpOutcome = "no-op";
private const string QueuedOutcome = "queued";
/// <summary> /// <summary>
/// PushSource — запись источника в очередь пайплайна тенанта. /// PushSource — запись источника в очередь пайплайна тенанта.
/// </summary> /// </summary>
@@ -57,7 +62,7 @@ public sealed class SourceIngressGrpcService(
tenant.Id, tenant.Id,
item.Source.Kind, item.Source.Kind,
item.Source.ExternalId ?? "-", item.Source.ExternalId ?? "-",
result.Duplicate ? "duplicate" : result.Id is null ? "no-op" : "queued"); result.Duplicate ? DuplicateOutcome : result.Id is null ? NoOpOutcome : QueuedOutcome);
return new PushSourceReply return new PushSourceReply
{ {
@@ -26,6 +26,15 @@ public sealed class TelegramKeysService(IGlobalSettingsStore store, ISecretCiphe
private const string EncryptedPrefix = "enc:"; private const string EncryptedPrefix = "enc:";
// Ключи JSON значения telegramKeys (camelCase, как пишет SaveAsync).
private const string ApiIdProperty = "apiId";
private const string ApiHashProperty = "apiHash";
// Геометрия маски секрета: короткий (≤8) → «x…»; иначе «1234…5678».
private const int MaskShortMaxLength = 8;
private const int MaskShortVisibleChars = 1;
private const int MaskEdgeVisibleChars = 4;
// Опции JSON значения telegramKeys: camelCase (как пишет SaveAsync) + терпимость регистра. // Опции JSON значения telegramKeys: camelCase (как пишет SaveAsync) + терпимость регистра.
private static readonly JsonSerializerOptions KeysJsonOptions = new() private static readonly JsonSerializerOptions KeysJsonOptions = new()
{ {
@@ -49,8 +58,8 @@ public sealed class TelegramKeysService(IGlobalSettingsStore store, ISecretCiphe
{ {
using JsonDocument document = JsonDocument.Parse(row.ValueJson); using JsonDocument document = JsonDocument.Parse(row.ValueJson);
JsonElement root = document.RootElement; JsonElement root = document.RootElement;
string apiId = ReadString(root, "apiId"); string apiId = ReadString(root, ApiIdProperty);
string apiHash = ReadString(root, "apiHash"); string apiHash = ReadString(root, ApiHashProperty);
return new TgKeysSnapshot(apiId, cipher.Decrypt(apiHash)); return new TgKeysSnapshot(apiId, cipher.Decrypt(apiHash));
} }
catch (JsonException) catch (JsonException)
@@ -143,8 +152,11 @@ public sealed class TelegramKeysService(IGlobalSettingsStore store, ISecretCiphe
return value.Length switch return value.Length switch
{ {
0 => string.Empty, 0 => string.Empty,
<= 8 => string.Concat(value.AsSpan(0, 1), MaskEllipsis), <= MaskShortMaxLength => string.Concat(value.AsSpan(0, MaskShortVisibleChars), MaskEllipsis),
_ => string.Concat(value.AsSpan(0, 4), MaskEllipsis, value.AsSpan(value.Length - 4)), _ => string.Concat(
value.AsSpan(0, MaskEdgeVisibleChars),
MaskEllipsis,
value.AsSpan(value.Length - MaskEdgeVisibleChars)),
}; };
} }
} }
@@ -22,6 +22,7 @@ public sealed class TgStatusService(
TelegramKeysService keys) TelegramKeysService keys)
{ {
private const string IdlePhase = "idle"; private const string IdlePhase = "idle";
private const string ReadyPhase = "ready";
// Опции JSON KV-значений статуса: camelCase (как пишет ингресс) + терпимость регистра. // Опции JSON KV-значений статуса: camelCase (как пишет ингресс) + терпимость регистра.
private static readonly JsonSerializerOptions KvJsonOptions = new() private static readonly JsonSerializerOptions KvJsonOptions = new()
@@ -37,13 +38,19 @@ public sealed class TgStatusService(
public async Task<TgStatusDto> GetAsync(CancellationToken ct) public async Task<TgStatusDto> GetAsync(CancellationToken ct)
{ {
TelegramAccountStatusDto live = await ReadLiveAsync(ct).ConfigureAwait(false); 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; int monitored = (await dialogs.ListMonitoredIdsAsync(ct).ConfigureAwait(false)).Count;
TgKeysSnapshot snapshot = await keys.GetAsync(ct).ConfigureAwait(false); TgKeysSnapshot snapshot = await keys.GetAsync(ct).ConfigureAwait(false);
return new TgStatusDto( return new TgStatusDto(
Phase: live.Phase, Phase: live.Phase,
Connected: live.Connected, Connected: authorized,
Listener: live.Listener, Listener: live.Listener,
Account: account, Account: account,
Monitored: monitored, 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";
}
@@ -3,22 +3,17 @@ using Deal.SharedKernel.Tenants.Models;
namespace Deal.Infrastructure.Data; namespace Deal.Infrastructure.Data;
/// <summary>
/// Контекст тенанта на AsyncLocal
/// </summary>
public sealed class TenantContext : ITenantContext public sealed class TenantContext : ITenantContext
{ {
private static readonly AsyncLocal<TenantId?> Current = new(); private static readonly AsyncLocal<TenantId?> Current = new();
public TenantId? TenantId => Current.Value; TenantId? ITenantContext.TenantId => Current.Value;
public bool HasTenant => Current.Value is not null; bool ITenantContext.HasTenant => Current.Value is not null;
public string? SchemaName => Current.Value?.SchemaName; string? ITenantContext.SchemaName => Current.Value?.SchemaName;
/// <inheritdoc />
void ITenantContext.SetTenant(TenantId tenantId) => Current.Value = tenantId; void ITenantContext.SetTenant(TenantId tenantId) => Current.Value = tenantId;
/// <inheritdoc />
void ITenantContext.Reset() => Current.Value = null; void ITenantContext.Reset() => Current.Value = null;
} }
@@ -0,0 +1,15 @@
using Deal.Contracts.Integrations.Abstractions;
namespace Deal.Infrastructure.Integrations.Abstractions;
/// <summary>
/// Фабрика ИИ-классификатора с бюджетным гейтом
/// </summary>
public interface IAiClassifierFactory
{
/// <summary>
/// Создаёт ИИ-классификатор
/// </summary>
/// <returns>Готовый порт классификации.</returns>
public IAiClassifier Create();
}
@@ -0,0 +1,15 @@
using Deal.Contracts.Integrations.Abstractions;
namespace Deal.Infrastructure.Integrations.Abstractions;
/// <summary>
/// Фабрика ИИ-инструментов с бюджетным гейтом
/// </summary>
public interface IAiToolsFactory
{
/// <summary>
/// Создаёт ИИ-инструменты
/// </summary>
/// <returns>Готовый порт ИИ-инструментов.</returns>
public IAiTools Create();
}
@@ -6,6 +6,9 @@ namespace Deal.Infrastructure.Integrations.Extensions;
// Расширения Uri для SSRF-гейта интеграций // Расширения Uri для SSRF-гейта интеграций
internal static class UriExtensions internal static class UriExtensions
{ {
// Имя loopback-хоста.
private const string LocalhostHost = "localhost";
/// <summary> /// <summary>
/// Проверяет, указывает ли URL на приватный/loopback/link-local адрес /// Проверяет, указывает ли URL на приватный/loopback/link-local адрес
/// </summary> /// </summary>
@@ -14,7 +17,7 @@ internal static class UriExtensions
public static bool IsPrivateEndpoint(this Uri uri) public static bool IsPrivateEndpoint(this Uri uri)
{ {
string host = uri.Host; string host = uri.Host;
if (string.Equals(host, "localhost", StringComparison.OrdinalIgnoreCase)) if (string.Equals(host, LocalhostHost, StringComparison.OrdinalIgnoreCase))
{ {
return true; return true;
} }
@@ -1,5 +1,7 @@
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Deal.SharedKernel.Utilities;
namespace Deal.Infrastructure.Integrations.Options; namespace Deal.Infrastructure.Integrations.Options;
/// <summary> /// <summary>
@@ -86,13 +88,16 @@ public sealed class MtlsOptions
}; };
} }
// Строковое представление включённого флага числом.
private const string OneLiteral = "1";
/// <summary> /// <summary>
/// Разбирает значение флага DEAL_MTLS_ENABLED /// Разбирает значение флага DEAL_MTLS_ENABLED
/// </summary> /// </summary>
/// <param name="rawValue">Сырое значение env (null/пусто — выключено).</param> /// <param name="rawValue">Сырое значение env (null/пусто — выключено).</param>
public static bool IsEnabled(string? rawValue) public static bool IsEnabled(string? rawValue)
=> string.Equals(rawValue, "1", StringComparison.Ordinal) => string.Equals(rawValue, OneLiteral, StringComparison.Ordinal)
|| string.Equals(rawValue, "true", StringComparison.OrdinalIgnoreCase); || string.Equals(rawValue, BoolText.True, StringComparison.OrdinalIgnoreCase);
// Обрезает путь конфигурации (env-значения с пробелами/кавычками не передаются в файловые API). // Обрезает путь конфигурации (env-значения с пробелами/кавычками не передаются в файловые API).
// rawValue: Сырое значение env. // rawValue: Сырое значение env.
@@ -0,0 +1,38 @@
using Deal.Contracts.Integrations.Abstractions;
using Deal.Infrastructure.Integrations.Abstractions;
using Deal.Modules.Tenants.Application.Abstractions;
using Deal.SharedKernel.Tenants.Abstractions;
using Microsoft.Extensions.Logging;
namespace Deal.Infrastructure.Integrations.Services;
public sealed class AiClassifierFactory : IAiClassifierFactory
{
private readonly GrpcAiClassifier _paidClassifier;
private readonly LocalAiClassifier _localClassifier;
private readonly ITenantLimitStore _tenantLimits;
private readonly ITenantContext _tenantContext;
private readonly ILogger<BudgetedAiClassifier> _logger;
public AiClassifierFactory(
GrpcAiClassifier paidClassifier,
LocalAiClassifier localClassifier,
ITenantLimitStore tenantLimits,
ITenantContext tenantContext,
ILogger<BudgetedAiClassifier> logger)
{
ArgumentNullException.ThrowIfNull(paidClassifier);
ArgumentNullException.ThrowIfNull(localClassifier);
ArgumentNullException.ThrowIfNull(tenantLimits);
ArgumentNullException.ThrowIfNull(tenantContext);
ArgumentNullException.ThrowIfNull(logger);
_paidClassifier = paidClassifier;
_localClassifier = localClassifier;
_tenantLimits = tenantLimits;
_tenantContext = tenantContext;
_logger = logger;
}
IAiClassifier IAiClassifierFactory.Create() =>
new BudgetedAiClassifier(_paidClassifier, _localClassifier, _tenantLimits, _tenantContext, _logger);
}
@@ -6,9 +6,6 @@ using Deal.Modules.Settings.Application.Models;
namespace Deal.Infrastructure.Integrations.Services; namespace Deal.Infrastructure.Integrations.Services;
/// <summary>
/// HTTP-реализация проверки подключения к AI-провайдеру.
/// </summary>
public sealed class AiConnectionChecker : IAiConnectionChecker public sealed class AiConnectionChecker : IAiConnectionChecker
{ {
/// <summary> /// <summary>
@@ -78,7 +75,6 @@ public sealed class AiConnectionChecker : IAiConnectionChecker
_httpClient = httpClient; _httpClient = httpClient;
} }
/// <inheritdoc />
async Task<AiCheckResultDto> IAiConnectionChecker.CheckAsync(AiCheckRequest request, CancellationToken ct) async Task<AiCheckResultDto> IAiConnectionChecker.CheckAsync(AiCheckRequest request, CancellationToken ct)
{ {
ArgumentNullException.ThrowIfNull(request); ArgumentNullException.ThrowIfNull(request);
@@ -0,0 +1,33 @@
using Deal.Contracts.Integrations.Abstractions;
using Deal.Infrastructure.Integrations.Abstractions;
using Deal.Modules.Tenants.Application.Abstractions;
using Deal.SharedKernel.Tenants.Abstractions;
using Microsoft.Extensions.Logging;
namespace Deal.Infrastructure.Integrations.Services;
public sealed class AiToolsFactory : IAiToolsFactory
{
private readonly GrpcAiTools _paidTools;
private readonly ITenantLimitStore _tenantLimits;
private readonly ITenantContext _tenantContext;
private readonly ILogger<BudgetedAiTools> _logger;
public AiToolsFactory(
GrpcAiTools paidTools,
ITenantLimitStore tenantLimits,
ITenantContext tenantContext,
ILogger<BudgetedAiTools> logger)
{
ArgumentNullException.ThrowIfNull(paidTools);
ArgumentNullException.ThrowIfNull(tenantLimits);
ArgumentNullException.ThrowIfNull(tenantContext);
ArgumentNullException.ThrowIfNull(logger);
_paidTools = paidTools;
_tenantLimits = tenantLimits;
_tenantContext = tenantContext;
_logger = logger;
}
IAiTools IAiToolsFactory.Create() => new BudgetedAiTools(_paidTools, _tenantLimits, _tenantContext, _logger);
}
@@ -9,9 +9,6 @@ using Microsoft.Extensions.Logging;
namespace Deal.Infrastructure.Integrations.Services; namespace Deal.Infrastructure.Integrations.Services;
/// <summary>
/// Декоратор бюджетного гейта порта <see cref="IAiClassifier"/>
/// </summary>
public sealed class BudgetedAiClassifier : IAiClassifier public sealed class BudgetedAiClassifier : IAiClassifier
{ {
// Текст ошибки вызова вне tenant-контекста (гейт читает лимиты по тенанту). // Текст ошибки вызова вне tenant-контекста (гейт читает лимиты по тенанту).
@@ -54,7 +51,6 @@ public sealed class BudgetedAiClassifier : IAiClassifier
_logger = logger; _logger = logger;
} }
/// <inheritdoc />
async Task<AiFilterResultDto> IAiClassifier.FilterAsync(string text, CancellationToken ct) async Task<AiFilterResultDto> IAiClassifier.FilterAsync(string text, CancellationToken ct)
{ {
if (await IsPaidAllowedAsync(ct)) if (await IsPaidAllowedAsync(ct))
@@ -67,7 +63,6 @@ public sealed class BudgetedAiClassifier : IAiClassifier
return await _localClassifier.FilterAsync(text, ct); return await _localClassifier.FilterAsync(text, ct);
} }
/// <inheritdoc />
async Task<AiParsedCardDto> IAiClassifier.ClassifyAsync(string text, CancellationToken ct) async Task<AiParsedCardDto> IAiClassifier.ClassifyAsync(string text, CancellationToken ct)
{ {
if (await IsPaidAllowedAsync(ct)) if (await IsPaidAllowedAsync(ct))
@@ -9,9 +9,6 @@ using Microsoft.Extensions.Logging;
namespace Deal.Infrastructure.Integrations.Services; namespace Deal.Infrastructure.Integrations.Services;
/// <summary>
/// Декоратор бюджетного гейта порта <see cref="IAiTools"/>
/// </summary>
public sealed class BudgetedAiTools : IAiTools public sealed class BudgetedAiTools : IAiTools
{ {
private const string ExhaustedKeywordsError = "ИИ-бюджет исчерпан — генерация ключевых слов недоступна"; private const string ExhaustedKeywordsError = "ИИ-бюджет исчерпан — генерация ключевых слов недоступна";
@@ -50,7 +47,6 @@ public sealed class BudgetedAiTools : IAiTools
_logger = logger; _logger = logger;
} }
/// <inheritdoc />
async Task<AiGenerateKeywordsResultDto> IAiTools.GenerateKeywordsAsync(string description, CancellationToken ct) async Task<AiGenerateKeywordsResultDto> IAiTools.GenerateKeywordsAsync(string description, CancellationToken ct)
{ {
BudgetStateDto state = await GateStateAsync(ct); BudgetStateDto state = await GateStateAsync(ct);
@@ -69,7 +65,6 @@ public sealed class BudgetedAiTools : IAiTools
Error: state.Status == TenantStatuses.Suspended ? SuspendedKeywordsError : ExhaustedKeywordsError); Error: state.Status == TenantStatuses.Suspended ? SuspendedKeywordsError : ExhaustedKeywordsError);
} }
/// <inheritdoc />
async Task<AiEvaluateFitResultDto> IAiTools.EvaluateFitAsync( async Task<AiEvaluateFitResultDto> IAiTools.EvaluateFitAsync(
string text, string text,
string description, string description,
@@ -5,9 +5,6 @@ using Microsoft.Extensions.Logging;
namespace Deal.Infrastructure.Integrations.Services; namespace Deal.Infrastructure.Integrations.Services;
/// <summary>
/// HTTP-источник курсов ЦБ РФ
/// </summary>
public sealed class CbrRateSource : IRatesSource public sealed class CbrRateSource : IRatesSource
{ {
/// <summary> /// <summary>
@@ -47,7 +44,6 @@ public sealed class CbrRateSource : IRatesSource
_logger = logger; _logger = logger;
} }
/// <inheritdoc />
async Task<Dictionary<string, double>?> IRatesSource.FetchAsync(CancellationToken ct) async Task<Dictionary<string, double>?> IRatesSource.FetchAsync(CancellationToken ct)
{ {
try try
@@ -11,9 +11,6 @@ using Microsoft.Extensions.Logging;
namespace Deal.Infrastructure.Integrations.Services; namespace Deal.Infrastructure.Integrations.Services;
/// <summary>
/// gRPC-адаптер порта <see cref="IAiClassifier"/> к автономному ai-service.
/// </summary>
public sealed class GrpcAiClassifier : IAiClassifier public sealed class GrpcAiClassifier : IAiClassifier
{ {
/// <summary> /// <summary>
@@ -70,7 +67,6 @@ public sealed class GrpcAiClassifier : IAiClassifier
_logger = logger; _logger = logger;
} }
/// <inheritdoc />
async Task<AiFilterResultDto> IAiClassifier.FilterAsync(string text, CancellationToken ct) async Task<AiFilterResultDto> IAiClassifier.FilterAsync(string text, CancellationToken ct)
{ {
TenantId tenantId = RequireTenant(); TenantId tenantId = RequireTenant();
@@ -106,7 +102,6 @@ public sealed class GrpcAiClassifier : IAiClassifier
} }
} }
/// <inheritdoc />
async Task<AiParsedCardDto> IAiClassifier.ClassifyAsync(string text, CancellationToken ct) async Task<AiParsedCardDto> IAiClassifier.ClassifyAsync(string text, CancellationToken ct)
{ {
TenantId tenantId = RequireTenant(); TenantId tenantId = RequireTenant();
@@ -10,9 +10,6 @@ using Microsoft.Extensions.Logging;
namespace Deal.Infrastructure.Integrations.Services; namespace Deal.Infrastructure.Integrations.Services;
/// <summary>
/// gRPC-адаптер порта <see cref="IAiTools"/> к автономному ai-service.
/// </summary>
public sealed class GrpcAiTools : IAiTools public sealed class GrpcAiTools : IAiTools
{ {
/// <summary> /// <summary>
@@ -70,7 +67,6 @@ public sealed class GrpcAiTools : IAiTools
_logger = logger; _logger = logger;
} }
/// <inheritdoc />
async Task<AiGenerateKeywordsResultDto> IAiTools.GenerateKeywordsAsync(string description, CancellationToken ct) async Task<AiGenerateKeywordsResultDto> IAiTools.GenerateKeywordsAsync(string description, CancellationToken ct)
{ {
TenantId tenantId = RequireTenant(); TenantId tenantId = RequireTenant();
@@ -104,7 +100,6 @@ public sealed class GrpcAiTools : IAiTools
} }
} }
/// <inheritdoc />
async Task<AiEvaluateFitResultDto> IAiTools.EvaluateFitAsync( async Task<AiEvaluateFitResultDto> IAiTools.EvaluateFitAsync(
string text, string text,
string description, string description,
@@ -16,9 +16,6 @@ using Microsoft.Extensions.Logging;
namespace Deal.Infrastructure.Integrations.Services; namespace Deal.Infrastructure.Integrations.Services;
/// <summary>
/// gRPC-адаптер порта IMlClient к автономному ml-service.
/// </summary>
public sealed class GrpcMlClient : IMlClient, IMlTrainClient public sealed class GrpcMlClient : IMlClient, IMlTrainClient
{ {
/// <summary> /// <summary>
@@ -97,7 +94,6 @@ public sealed class GrpcMlClient : IMlClient, IMlTrainClient
_logger = logger; _logger = logger;
} }
/// <inheritdoc />
async Task<MlStatusResponseDto> IMlClient.StatusAsync(CancellationToken ct) async Task<MlStatusResponseDto> IMlClient.StatusAsync(CancellationToken ct)
{ {
TenantId tenantId = RequireTenant(); TenantId tenantId = RequireTenant();
@@ -122,7 +118,6 @@ public sealed class GrpcMlClient : IMlClient, IMlTrainClient
return new MlStatusResponseDto(Enabled: enabled, Service: snapshot.Service, Reachable: snapshot.Reachable, Stats: stats); return new MlStatusResponseDto(Enabled: enabled, Service: snapshot.Service, Reachable: snapshot.Reachable, Stats: stats);
} }
/// <inheritdoc />
async Task<MlPredictResultDto> IMlClient.PredictAsync(string text, CancellationToken ct) async Task<MlPredictResultDto> IMlClient.PredictAsync(string text, CancellationToken ct)
{ {
TenantId tenantId = RequireTenant(); TenantId tenantId = RequireTenant();
@@ -143,7 +138,6 @@ public sealed class GrpcMlClient : IMlClient, IMlTrainClient
} }
} }
/// <inheritdoc />
async Task<MlResetResultDto> IMlClient.ResetAsync(CancellationToken ct) async Task<MlResetResultDto> IMlClient.ResetAsync(CancellationToken ct)
{ {
TenantId tenantId = RequireTenant(); TenantId tenantId = RequireTenant();
@@ -171,7 +165,6 @@ public sealed class GrpcMlClient : IMlClient, IMlTrainClient
return new MlResetResultDto(Ok: true, Error: null); return new MlResetResultDto(Ok: true, Error: null);
} }
/// <inheritdoc />
async Task IMlClient.PushAsync( async Task IMlClient.PushAsync(
string text, string text,
string label, string label,
@@ -181,7 +174,6 @@ public sealed class GrpcMlClient : IMlClient, IMlTrainClient
await MlOutboxQueue.PushAsync(_learningStore, text, label, delta, ct); await MlOutboxQueue.PushAsync(_learningStore, text, label, delta, ct);
} }
/// <inheritdoc />
async Task<int> IMlTrainClient.TrainBatchAsync(IReadOnlyList<MlOutboxEntryDto> items, CancellationToken ct) async Task<int> IMlTrainClient.TrainBatchAsync(IReadOnlyList<MlOutboxEntryDto> items, CancellationToken ct)
{ {
TenantId tenantId = RequireTenant(); TenantId tenantId = RequireTenant();
@@ -9,9 +9,6 @@ using Microsoft.Extensions.Logging;
namespace Deal.Infrastructure.Integrations.Services; namespace Deal.Infrastructure.Integrations.Services;
/// <summary>
/// gRPC-адаптер порта <see cref="ITelegramGateway"/> к автономному telegram-service.
/// </summary>
public sealed class GrpcTelegramClient : ITelegramGateway public sealed class GrpcTelegramClient : ITelegramGateway
{ {
/// <summary> /// <summary>
@@ -58,7 +55,6 @@ public sealed class GrpcTelegramClient : ITelegramGateway
_logger = logger; _logger = logger;
} }
/// <inheritdoc />
async Task<TelegramAccountStatusDto> ITelegramGateway.StatusAsync(CancellationToken ct) async Task<TelegramAccountStatusDto> ITelegramGateway.StatusAsync(CancellationToken ct)
{ {
TenantId tenantId = RequireTenant(); TenantId tenantId = RequireTenant();
@@ -77,11 +73,10 @@ public sealed class GrpcTelegramClient : ITelegramGateway
} }
catch (Exception exception) catch (Exception exception)
{ {
throw TranslateTransportFailure(exception, tenantId, "status"); throw TranslateTransportFailure(exception, tenantId, TelegramOperations.Status);
} }
} }
/// <inheritdoc />
async Task<TelegramAuthResultDto> ITelegramGateway.StartPhoneAsync( async Task<TelegramAuthResultDto> ITelegramGateway.StartPhoneAsync(
string phone, string phone,
int apiId, int apiId,
@@ -100,11 +95,10 @@ public sealed class GrpcTelegramClient : ITelegramGateway
} }
catch (Exception exception) catch (Exception exception)
{ {
throw TranslateTransportFailure(exception, tenantId, "start_phone"); throw TranslateTransportFailure(exception, tenantId, TelegramOperations.StartPhone);
} }
} }
/// <inheritdoc />
async Task<TelegramAuthResultDto> ITelegramGateway.StartQrAsync( async Task<TelegramAuthResultDto> ITelegramGateway.StartQrAsync(
int apiId, int apiId,
string apiHash, string apiHash,
@@ -124,11 +118,10 @@ public sealed class GrpcTelegramClient : ITelegramGateway
} }
catch (Exception exception) catch (Exception exception)
{ {
throw TranslateTransportFailure(exception, tenantId, "start_qr"); throw TranslateTransportFailure(exception, tenantId, TelegramOperations.StartQr);
} }
} }
/// <inheritdoc />
async Task<string> ITelegramGateway.SendCodeAsync(string code, CancellationToken ct) async Task<string> ITelegramGateway.SendCodeAsync(string code, CancellationToken ct)
{ {
TenantId tenantId = RequireTenant(); TenantId tenantId = RequireTenant();
@@ -142,11 +135,10 @@ public sealed class GrpcTelegramClient : ITelegramGateway
} }
catch (Exception exception) catch (Exception exception)
{ {
throw TranslateTransportFailure(exception, tenantId, "send_code"); throw TranslateTransportFailure(exception, tenantId, TelegramOperations.SendCode);
} }
} }
/// <inheritdoc />
async Task<string> ITelegramGateway.SendPasswordAsync(string password, CancellationToken ct) async Task<string> ITelegramGateway.SendPasswordAsync(string password, CancellationToken ct)
{ {
TenantId tenantId = RequireTenant(); TenantId tenantId = RequireTenant();
@@ -160,11 +152,10 @@ public sealed class GrpcTelegramClient : ITelegramGateway
} }
catch (Exception exception) catch (Exception exception)
{ {
throw TranslateTransportFailure(exception, tenantId, "send_password"); throw TranslateTransportFailure(exception, tenantId, TelegramOperations.SendPassword);
} }
} }
/// <inheritdoc />
async Task ITelegramGateway.LogoutAsync(CancellationToken ct) async Task ITelegramGateway.LogoutAsync(CancellationToken ct)
{ {
TenantId tenantId = RequireTenant(); TenantId tenantId = RequireTenant();
@@ -176,11 +167,10 @@ public sealed class GrpcTelegramClient : ITelegramGateway
} }
catch (Exception exception) catch (Exception exception)
{ {
throw TranslateTransportFailure(exception, tenantId, "logout"); throw TranslateTransportFailure(exception, tenantId, TelegramOperations.Logout);
} }
} }
/// <inheritdoc />
async Task<IReadOnlyList<TelegramDialogEntryDto>> ITelegramGateway.RefreshDialogsAsync(CancellationToken ct) async Task<IReadOnlyList<TelegramDialogEntryDto>> ITelegramGateway.RefreshDialogsAsync(CancellationToken ct)
{ {
TenantId tenantId = RequireTenant(); TenantId tenantId = RequireTenant();
@@ -193,11 +183,10 @@ public sealed class GrpcTelegramClient : ITelegramGateway
} }
catch (Exception exception) catch (Exception exception)
{ {
throw TranslateTransportFailure(exception, tenantId, "refresh_dialogs"); throw TranslateTransportFailure(exception, tenantId, TelegramOperations.RefreshDialogs);
} }
} }
/// <inheritdoc />
async Task ITelegramGateway.SetMonitorAsync( async Task ITelegramGateway.SetMonitorAsync(
string dialogId, string dialogId,
bool enabled, bool enabled,
@@ -213,11 +202,10 @@ public sealed class GrpcTelegramClient : ITelegramGateway
} }
catch (Exception exception) catch (Exception exception)
{ {
throw TranslateTransportFailure(exception, tenantId, "set_monitor"); throw TranslateTransportFailure(exception, tenantId, TelegramOperations.SetMonitor);
} }
} }
/// <inheritdoc />
async Task ITelegramGateway.SetMonitorAllAsync(bool enabled, CancellationToken ct) async Task ITelegramGateway.SetMonitorAllAsync(bool enabled, CancellationToken ct)
{ {
TenantId tenantId = RequireTenant(); TenantId tenantId = RequireTenant();
@@ -230,11 +218,10 @@ public sealed class GrpcTelegramClient : ITelegramGateway
} }
catch (Exception exception) catch (Exception exception)
{ {
throw TranslateTransportFailure(exception, tenantId, "set_monitor_all"); throw TranslateTransportFailure(exception, tenantId, TelegramOperations.SetMonitorAll);
} }
} }
/// <inheritdoc />
async Task<int> ITelegramGateway.BackfillAsync( async Task<int> ITelegramGateway.BackfillAsync(
string dialogId, string dialogId,
bool force, bool force,
@@ -251,11 +238,10 @@ public sealed class GrpcTelegramClient : ITelegramGateway
} }
catch (Exception exception) catch (Exception exception)
{ {
throw TranslateTransportFailure(exception, tenantId, "backfill"); throw TranslateTransportFailure(exception, tenantId, TelegramOperations.Backfill);
} }
} }
/// <inheritdoc />
async Task<IReadOnlyList<TelegramRecentMessageDto>> ITelegramGateway.ReadRecentAsync( async Task<IReadOnlyList<TelegramRecentMessageDto>> ITelegramGateway.ReadRecentAsync(
string dialogId, string dialogId,
int limit, int limit,
@@ -274,11 +260,10 @@ public sealed class GrpcTelegramClient : ITelegramGateway
} }
catch (Exception exception) catch (Exception exception)
{ {
throw TranslateTransportFailure(exception, tenantId, "read_recent"); throw TranslateTransportFailure(exception, tenantId, TelegramOperations.ReadRecent);
} }
} }
/// <inheritdoc />
async Task<TelegramSourceContentDto> ITelegramGateway.ReadSourceAsync( async Task<TelegramSourceContentDto> ITelegramGateway.ReadSourceAsync(
string dialogId, string dialogId,
long msgId, long msgId,
@@ -298,11 +283,10 @@ public sealed class GrpcTelegramClient : ITelegramGateway
} }
catch (Exception exception) catch (Exception exception)
{ {
throw TranslateTransportFailure(exception, tenantId, "read_source"); throw TranslateTransportFailure(exception, tenantId, TelegramOperations.ReadSource);
} }
} }
/// <inheritdoc />
async Task<IReadOnlyList<TelegramDialogEntryDto>> ITelegramGateway.SearchAsync( async Task<IReadOnlyList<TelegramDialogEntryDto>> ITelegramGateway.SearchAsync(
string query, string query,
int limit, int limit,
@@ -319,11 +303,10 @@ public sealed class GrpcTelegramClient : ITelegramGateway
} }
catch (Exception exception) catch (Exception exception)
{ {
throw TranslateTransportFailure(exception, tenantId, "search"); throw TranslateTransportFailure(exception, tenantId, TelegramOperations.Search);
} }
} }
/// <inheritdoc />
async Task<TelegramChannelInfoDto> ITelegramGateway.InfoAsync(string dialogId, CancellationToken ct) async Task<TelegramChannelInfoDto> ITelegramGateway.InfoAsync(string dialogId, CancellationToken ct)
{ {
TenantId tenantId = RequireTenant(); TenantId tenantId = RequireTenant();
@@ -345,11 +328,10 @@ public sealed class GrpcTelegramClient : ITelegramGateway
} }
catch (Exception exception) catch (Exception exception)
{ {
throw TranslateTransportFailure(exception, tenantId, "get_info"); throw TranslateTransportFailure(exception, tenantId, TelegramOperations.GetInfo);
} }
} }
/// <inheritdoc />
async Task<TelegramEvalReadDto> ITelegramGateway.ReadForEvalAsync( async Task<TelegramEvalReadDto> ITelegramGateway.ReadForEvalAsync(
string dialogId, string dialogId,
int limit, int limit,
@@ -376,11 +358,10 @@ public sealed class GrpcTelegramClient : ITelegramGateway
} }
catch (Exception exception) catch (Exception exception)
{ {
throw TranslateTransportFailure(exception, tenantId, "read_for_eval"); throw TranslateTransportFailure(exception, tenantId, TelegramOperations.ReadForEval);
} }
} }
/// <inheritdoc />
async Task ITelegramGateway.JoinAsync(string username, CancellationToken ct) async Task ITelegramGateway.JoinAsync(string username, CancellationToken ct)
{ {
TenantId tenantId = RequireTenant(); TenantId tenantId = RequireTenant();
@@ -393,11 +374,10 @@ public sealed class GrpcTelegramClient : ITelegramGateway
} }
catch (Exception exception) catch (Exception exception)
{ {
throw TranslateTransportFailure(exception, tenantId, "join"); throw TranslateTransportFailure(exception, tenantId, TelegramOperations.Join);
} }
} }
/// <inheritdoc />
async Task ITelegramGateway.LeaveAsync(string dialogId, CancellationToken ct) async Task ITelegramGateway.LeaveAsync(string dialogId, CancellationToken ct)
{ {
TenantId tenantId = RequireTenant(); TenantId tenantId = RequireTenant();
@@ -410,7 +390,7 @@ public sealed class GrpcTelegramClient : ITelegramGateway
} }
catch (Exception exception) catch (Exception exception)
{ {
throw TranslateTransportFailure(exception, tenantId, "leave"); throw TranslateTransportFailure(exception, tenantId, TelegramOperations.Leave);
} }
} }
@@ -3,20 +3,15 @@ using Deal.Contracts.Integrations.Models;
namespace Deal.Infrastructure.Integrations.Services; namespace Deal.Infrastructure.Integrations.Services;
/// <summary>
/// Локальная реализация <see cref="IAiTools"/> без внешнего ИИ-сервиса.
/// </summary>
public sealed class LocalAiTools : IAiTools public sealed class LocalAiTools : IAiTools
{ {
// Сообщение исключения методов (локальный режим = ai-service не подключён). // Сообщение исключения методов (локальный режим = ai-service не подключён).
private const string NotSupportedMessage = private const string NotSupportedMessage =
"ИИ-инструменты доступны только при подключённом ai-service (Services:Ai:UseLocal=false)."; "ИИ-инструменты доступны только при подключённом ai-service (Services:Ai:UseLocal=false).";
/// <inheritdoc />
Task<AiGenerateKeywordsResultDto> IAiTools.GenerateKeywordsAsync(string description, CancellationToken ct) Task<AiGenerateKeywordsResultDto> IAiTools.GenerateKeywordsAsync(string description, CancellationToken ct)
=> throw new NotSupportedException(NotSupportedMessage); => throw new NotSupportedException(NotSupportedMessage);
/// <inheritdoc />
Task<AiEvaluateFitResultDto> IAiTools.EvaluateFitAsync( Task<AiEvaluateFitResultDto> IAiTools.EvaluateFitAsync(
string text, string text,
string description, string description,
@@ -39,8 +39,6 @@ ContainersService containersService) : IColumnSuggester
private const string KeywordsEmptyReason = "ИИ не смог выделить ключи — попробуйте ещё раз"; private const string KeywordsEmptyReason = "ИИ не смог выделить ключи — попробуйте ещё раз";
private const string RulesModeAny = "any";
/// <inheritdoc /> /// <inheritdoc />
public async Task<SuggestColumnsResultDto> SuggestColumnsAsync(CancellationToken ct) public async Task<SuggestColumnsResultDto> SuggestColumnsAsync(CancellationToken ct)
{ {
@@ -121,7 +119,7 @@ ContainersService containersService) : IColumnSuggester
foreach (SuggestedColumnPlan plan in plans) foreach (SuggestedColumnPlan plan in plans)
{ {
var rules = new ContainerRulesDto( var rules = new ContainerRulesDto(
Mode: RulesModeAny, Mode: ColumnRuleModes.Any,
Direction: Array.Empty<string>(), Direction: Array.Empty<string>(),
Keywords: [plan.Word], Keywords: [plan.Word],
Stack: Array.Empty<string>(), Stack: Array.Empty<string>(),
@@ -3,21 +3,18 @@ using Deal.Contracts.Integrations.Models;
namespace Deal.Infrastructure.Integrations.Services; namespace Deal.Infrastructure.Integrations.Services;
/// <summary>
/// Локальная заглушка <see cref="ITelegramGateway"/> без telegram-service.
/// </summary>
public sealed class LocalTelegramGateway : ITelegramGateway public sealed class LocalTelegramGateway : ITelegramGateway
{ {
// Причина пустого чтения: сообщений в источнике нет.
private const string NoHistoryReason = "no_history";
// Фаза idle-формы (аккаунт не подключён — сервиса нет). // Фаза idle-формы (аккаунт не подключён — сервиса нет).
private const string IdlePhase = "idle"; private const string IdlePhase = "idle";
/// <inheritdoc />
Task<TelegramAccountStatusDto> ITelegramGateway.StatusAsync(CancellationToken ct) Task<TelegramAccountStatusDto> ITelegramGateway.StatusAsync(CancellationToken ct)
{ {
return Task.FromResult(new TelegramAccountStatusDto(IdlePhase, false, false, string.Empty, null, null)); return Task.FromResult(new TelegramAccountStatusDto(IdlePhase, false, false, string.Empty, null, null));
} }
/// <inheritdoc />
Task<TelegramAuthResultDto> ITelegramGateway.StartPhoneAsync( Task<TelegramAuthResultDto> ITelegramGateway.StartPhoneAsync(
string phone, string phone,
int apiId, int apiId,
@@ -25,76 +22,61 @@ public sealed class LocalTelegramGateway : ITelegramGateway
CancellationToken ct) CancellationToken ct)
=> Task.FromResult(new TelegramAuthResultDto(IdlePhase, null)); => Task.FromResult(new TelegramAuthResultDto(IdlePhase, null));
/// <inheritdoc />
Task<TelegramAuthResultDto> ITelegramGateway.StartQrAsync( Task<TelegramAuthResultDto> ITelegramGateway.StartQrAsync(
int apiId, int apiId,
string apiHash, string apiHash,
CancellationToken ct) CancellationToken ct)
=> Task.FromResult(new TelegramAuthResultDto(IdlePhase, null)); => Task.FromResult(new TelegramAuthResultDto(IdlePhase, null));
/// <inheritdoc /> Task<string> ITelegramGateway.SendCodeAsync(string code, CancellationToken ct) => Task.FromResult(IdlePhase);
public Task<string> SendCodeAsync(string code, CancellationToken ct) => Task.FromResult(IdlePhase);
/// <inheritdoc /> Task<string> ITelegramGateway.SendPasswordAsync(string password, CancellationToken ct) => Task.FromResult(IdlePhase);
public Task<string> SendPasswordAsync(string password, CancellationToken ct) => Task.FromResult(IdlePhase);
/// <inheritdoc />
Task ITelegramGateway.LogoutAsync(CancellationToken ct) => Task.CompletedTask; Task ITelegramGateway.LogoutAsync(CancellationToken ct) => Task.CompletedTask;
/// <inheritdoc />
Task<IReadOnlyList<TelegramDialogEntryDto>> ITelegramGateway.RefreshDialogsAsync(CancellationToken ct) Task<IReadOnlyList<TelegramDialogEntryDto>> ITelegramGateway.RefreshDialogsAsync(CancellationToken ct)
=> Task.FromResult<IReadOnlyList<TelegramDialogEntryDto>>([]); => Task.FromResult<IReadOnlyList<TelegramDialogEntryDto>>([]);
/// <inheritdoc />
Task ITelegramGateway.SetMonitorAsync( Task ITelegramGateway.SetMonitorAsync(
string dialogId, string dialogId,
bool enabled, bool enabled,
CancellationToken ct) => Task.CompletedTask; CancellationToken ct) => Task.CompletedTask;
/// <inheritdoc />
Task ITelegramGateway.SetMonitorAllAsync(bool enabled, CancellationToken ct) => Task.CompletedTask; Task ITelegramGateway.SetMonitorAllAsync(bool enabled, CancellationToken ct) => Task.CompletedTask;
/// <inheritdoc /> Task<int> ITelegramGateway.BackfillAsync(
public Task<int> BackfillAsync(
string dialogId, string dialogId,
bool force, bool force,
CancellationToken ct) => Task.FromResult(0); CancellationToken ct) => Task.FromResult(0);
/// <inheritdoc />
Task<IReadOnlyList<TelegramRecentMessageDto>> ITelegramGateway.ReadRecentAsync( Task<IReadOnlyList<TelegramRecentMessageDto>> ITelegramGateway.ReadRecentAsync(
string dialogId, string dialogId,
int limit, int limit,
CancellationToken ct) CancellationToken ct)
=> Task.FromResult<IReadOnlyList<TelegramRecentMessageDto>>([]); => Task.FromResult<IReadOnlyList<TelegramRecentMessageDto>>([]);
/// <inheritdoc />
Task<TelegramSourceContentDto> ITelegramGateway.ReadSourceAsync( Task<TelegramSourceContentDto> ITelegramGateway.ReadSourceAsync(
string dialogId, string dialogId,
long msgId, long msgId,
CancellationToken ct) CancellationToken ct)
=> Task.FromResult(new TelegramSourceContentDto(false, null, null)); => Task.FromResult(new TelegramSourceContentDto(false, null, null));
/// <inheritdoc />
Task<IReadOnlyList<TelegramDialogEntryDto>> ITelegramGateway.SearchAsync( Task<IReadOnlyList<TelegramDialogEntryDto>> ITelegramGateway.SearchAsync(
string query, string query,
int limit, int limit,
CancellationToken ct) CancellationToken ct)
=> Task.FromResult<IReadOnlyList<TelegramDialogEntryDto>>([]); => Task.FromResult<IReadOnlyList<TelegramDialogEntryDto>>([]);
/// <inheritdoc />
Task<TelegramChannelInfoDto> ITelegramGateway.InfoAsync(string dialogId, CancellationToken ct) Task<TelegramChannelInfoDto> ITelegramGateway.InfoAsync(string dialogId, CancellationToken ct)
=> Task.FromResult(new TelegramChannelInfoDto(dialogId, string.Empty, string.Empty, string.Empty, SourceDefaults.DefaultHue, null, false)); => Task.FromResult(new TelegramChannelInfoDto(dialogId, string.Empty, string.Empty, string.Empty, SourceDefaults.DefaultHue, null, false));
/// <inheritdoc />
Task<TelegramEvalReadDto> ITelegramGateway.ReadForEvalAsync( Task<TelegramEvalReadDto> ITelegramGateway.ReadForEvalAsync(
string dialogId, string dialogId,
int limit, int limit,
CancellationToken ct) CancellationToken ct)
=> Task.FromResult(new TelegramEvalReadDto(false, "no_history", [])); => Task.FromResult(new TelegramEvalReadDto(false, NoHistoryReason, []));
/// <inheritdoc />
Task ITelegramGateway.JoinAsync(string username, CancellationToken ct) => Task.CompletedTask; Task ITelegramGateway.JoinAsync(string username, CancellationToken ct) => Task.CompletedTask;
/// <inheritdoc />
Task ITelegramGateway.LeaveAsync(string dialogId, CancellationToken ct) => Task.CompletedTask; Task ITelegramGateway.LeaveAsync(string dialogId, CancellationToken ct) => Task.CompletedTask;
} }
@@ -0,0 +1,15 @@
using Deal.Contracts.Integrations.Abstractions;
namespace Deal.Infrastructure.Integrations.Storage.Abstractions;
/// <summary>
/// Фабрика файлового хранилища вложений (Local или MinIO по конфигурации)
/// </summary>
public interface IFileStorageFactory
{
/// <summary>
/// Создаёт файловое хранилище
/// </summary>
/// <returns>Готовый порт файлового хранилища.</returns>
public IFileStorage Create();
}
@@ -1,8 +1,13 @@
using Deal.SharedKernel.Utilities;
namespace Deal.Infrastructure.Integrations.Storage.Extensions; namespace Deal.Infrastructure.Integrations.Storage.Extensions;
// Расширения string для разбора конфигурационных значений. // Расширения string для разбора конфигурационных значений.
internal static class StringExtensions internal static class StringExtensions
{ {
// Строковое представление включённого флага числом.
private const string OneLiteral = "1";
/// <summary> /// <summary>
/// Разбирает строковое значение как булев флаг конфигурации /// Разбирает строковое значение как булев флаг конфигурации
/// </summary> /// </summary>
@@ -10,6 +15,6 @@ internal static class StringExtensions
/// <returns>True — значение распознано как включённое.</returns> /// <returns>True — значение распознано как включённое.</returns>
public static bool IsTrue(this string raw) public static bool IsTrue(this string raw)
{ {
return string.Equals(raw, "true", StringComparison.OrdinalIgnoreCase) || raw == "1"; return string.Equals(raw, BoolText.True, StringComparison.OrdinalIgnoreCase) || raw == OneLiteral;
} }
} }
@@ -0,0 +1,7 @@
namespace Deal.Infrastructure.Integrations.Storage.Options;
/// <summary>
/// Абсолютный путь корня локального файлового хранилища
/// </summary>
/// <param name="Path">Абсолютный путь каталога вложений.</param>
public sealed record LocalStorageRoot(string Path);
@@ -0,0 +1,32 @@
using Deal.Contracts.Integrations.Abstractions;
using Deal.Infrastructure.Integrations.Storage.Abstractions;
using Deal.Infrastructure.Integrations.Storage.Extensions;
using Deal.Infrastructure.Integrations.Storage.Options;
using Microsoft.Extensions.Logging;
namespace Deal.Infrastructure.Integrations.Storage.Services;
public sealed class FileStorageFactory : IFileStorageFactory
{
private readonly StorageOptions _options;
private readonly LocalStorageRoot _localRoot;
private readonly ILogger<MinioFileStorage> _minioLogger;
public FileStorageFactory(
StorageOptions options,
LocalStorageRoot localRoot,
ILogger<MinioFileStorage> minioLogger)
{
ArgumentNullException.ThrowIfNull(options);
ArgumentNullException.ThrowIfNull(localRoot);
ArgumentNullException.ThrowIfNull(minioLogger);
_options = options;
_localRoot = localRoot;
_minioLogger = minioLogger;
}
IFileStorage IFileStorageFactory.Create() =>
_options.Minio.IsConfigured()
? new MinioFileStorage(_options.Minio, _minioLogger)
: new LocalFileStorage(_localRoot.Path);
}
@@ -1,4 +1,5 @@
using Deal.Contracts.Integrations.Abstractions; using Deal.Contracts.Integrations.Abstractions;
using Deal.Infrastructure.Integrations.Storage.Abstractions;
using Deal.Infrastructure.Integrations.Storage.Extensions; using Deal.Infrastructure.Integrations.Storage.Extensions;
using Deal.Infrastructure.Integrations.Storage.Options; using Deal.Infrastructure.Integrations.Storage.Options;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
@@ -48,16 +49,12 @@ public static class FileStorageRegistrar
ArgumentNullException.ThrowIfNull(configuration); ArgumentNullException.ThrowIfNull(configuration);
StorageOptions options = ReadOptions(configuration); StorageOptions options = ReadOptions(configuration);
if (options.Minio.IsConfigured())
{
services.AddSingleton<IFileStorage>(serviceProvider =>
new MinioFileStorage(options.Minio, serviceProvider.GetRequiredService<ILogger<MinioFileStorage>>()));
return services;
}
string rootPath = ResolveLocalRoot(options.Local, contentRootPath); string rootPath = ResolveLocalRoot(options.Local, contentRootPath);
services.AddSingleton<IFileStorage>(new LocalFileStorage(rootPath)); services.AddSingleton(options);
services.AddSingleton(new LocalStorageRoot(rootPath));
services.AddSingleton<IFileStorageFactory, FileStorageFactory>();
services.AddSingleton<IFileStorage>(serviceProvider =>
serviceProvider.GetRequiredService<IFileStorageFactory>().Create());
return services; return services;
} }
@@ -3,9 +3,6 @@ using Deal.Contracts.Integrations.Models;
namespace Deal.Infrastructure.Integrations.Storage.Services; namespace Deal.Infrastructure.Integrations.Storage.Services;
/// <summary>
/// Локальное файловое хранилище вложений — каталог на диске.
/// </summary>
public sealed class LocalFileStorage : IFileStorage public sealed class LocalFileStorage : IFileStorage
{ {
// Размер буфера чтения при скачивании (async FileStream). // Размер буфера чтения при скачивании (async FileStream).
@@ -31,7 +28,6 @@ public sealed class LocalFileStorage : IFileStorage
/// <returns>Строка вида <c>LocalFileStorage (root: …)</c>.</returns> /// <returns>Строка вида <c>LocalFileStorage (root: …)</c>.</returns>
public override string ToString() => $"LocalFileStorage (root: {_rootPath})"; public override string ToString() => $"LocalFileStorage (root: {_rootPath})";
/// <inheritdoc />
async Task<string> IFileStorage.PutAsync( async Task<string> IFileStorage.PutAsync(
string objectKey, string objectKey,
Stream content, Stream content,
@@ -55,7 +51,6 @@ public sealed class LocalFileStorage : IFileStorage
return objectKey; return objectKey;
} }
/// <inheritdoc />
Task<Stream?> IFileStorage.GetAsync(string objectKey, CancellationToken ct) Task<Stream?> IFileStorage.GetAsync(string objectKey, CancellationToken ct)
{ {
string path = ResolvePath(objectKey); string path = ResolvePath(objectKey);
@@ -68,7 +63,6 @@ public sealed class LocalFileStorage : IFileStorage
return Task.FromResult<Stream?>(stream); return Task.FromResult<Stream?>(stream);
} }
/// <inheritdoc />
Task<FileMeta?> IFileStorage.StatAsync(string objectKey, CancellationToken ct) Task<FileMeta?> IFileStorage.StatAsync(string objectKey, CancellationToken ct)
{ {
string path = ResolvePath(objectKey); string path = ResolvePath(objectKey);
@@ -81,7 +75,6 @@ public sealed class LocalFileStorage : IFileStorage
return Task.FromResult<FileMeta?>(new FileMeta(objectKey, info.Length, string.Empty)); return Task.FromResult<FileMeta?>(new FileMeta(objectKey, info.Length, string.Empty));
} }
/// <inheritdoc />
Task IFileStorage.DeleteAsync(string objectKey, CancellationToken ct) Task IFileStorage.DeleteAsync(string objectKey, CancellationToken ct)
{ {
string path = ResolvePath(objectKey); string path = ResolvePath(objectKey);
@@ -9,9 +9,6 @@ using Minio.Exceptions;
namespace Deal.Infrastructure.Integrations.Storage.Services; namespace Deal.Infrastructure.Integrations.Storage.Services;
/// <summary>
/// Хранилище вложений на MinIO
/// </summary>
public sealed class MinioFileStorage : IFileStorage public sealed class MinioFileStorage : IFileStorage
{ {
private const string DefaultContentType = "application/octet-stream"; private const string DefaultContentType = "application/octet-stream";
@@ -66,7 +63,6 @@ public sealed class MinioFileStorage : IFileStorage
/// <returns>Строка вида <c>MinioFileStorage (endpoint: …; bucket: …)</c>.</returns> /// <returns>Строка вида <c>MinioFileStorage (endpoint: …; bucket: …)</c>.</returns>
public override string ToString() => $"MinioFileStorage (endpoint: {_endpoint}; bucket: {_bucket})"; public override string ToString() => $"MinioFileStorage (endpoint: {_endpoint}; bucket: {_bucket})";
/// <inheritdoc />
async Task<string> IFileStorage.PutAsync( async Task<string> IFileStorage.PutAsync(
string objectKey, string objectKey,
Stream content, Stream content,
@@ -98,7 +94,6 @@ public sealed class MinioFileStorage : IFileStorage
return objectKey; return objectKey;
} }
/// <inheritdoc />
async Task<Stream?> IFileStorage.GetAsync(string objectKey, CancellationToken ct) async Task<Stream?> IFileStorage.GetAsync(string objectKey, CancellationToken ct)
{ {
MemoryStream buffer = new(); MemoryStream buffer = new();
@@ -128,7 +123,6 @@ public sealed class MinioFileStorage : IFileStorage
return buffer; return buffer;
} }
/// <inheritdoc />
async Task<FileMeta?> IFileStorage.StatAsync(string objectKey, CancellationToken ct) async Task<FileMeta?> IFileStorage.StatAsync(string objectKey, CancellationToken ct)
{ {
try try
@@ -144,7 +138,6 @@ public sealed class MinioFileStorage : IFileStorage
} }
} }
/// <inheritdoc />
async Task IFileStorage.DeleteAsync(string objectKey, CancellationToken ct) async Task IFileStorage.DeleteAsync(string objectKey, CancellationToken ct)
{ {
try try
@@ -0,0 +1,15 @@
using Deal.Modules.Tenants.Application.Abstractions;
namespace Deal.Infrastructure.Persistence.Abstractions;
/// <summary>
/// Фабрика хранилища лимитов ИИ-бюджета
/// </summary>
public interface ITenantLimitStoreFactory
{
/// <summary>
/// Создаёт хранилище лимитов
/// </summary>
/// <returns>Готовый порт лимитов тенанта.</returns>
public ITenantLimitStore Create();
}
@@ -1,3 +1,4 @@
using Deal.Modules.Cards.Application.Models;
using NpgsqlTypes; using NpgsqlTypes;
namespace Deal.Infrastructure.Persistence.Entities; namespace Deal.Infrastructure.Persistence.Entities;
@@ -130,7 +131,7 @@ public sealed class CardEntity
/// <summary> /// <summary>
/// Предыдущая колонка /// Предыдущая колонка
/// </summary> /// </summary>
public string PrevCol { get; set; } = "inbox"; public string PrevCol { get; set; } = CardIds.Inbox;
/// <summary> /// <summary>
/// Время помещения в архив /// Время помещения в архив
@@ -1,3 +1,4 @@
using Deal.Modules.Kanban.Application.Models;
using NpgsqlTypes; using NpgsqlTypes;
namespace Deal.Infrastructure.Persistence.Entities; namespace Deal.Infrastructure.Persistence.Entities;
@@ -35,12 +36,12 @@ public sealed class ContainerEntity
/// <summary> /// <summary>
/// Вид контейнера: board /// Вид контейнера: board
/// </summary> /// </summary>
public string Kind { get; set; } = "board"; public string Kind { get; set; } = ContainerKinds.Board;
/// <summary> /// <summary>
/// Пространство: dashboard | selected /// Пространство: dashboard | selected
/// </summary> /// </summary>
public string Space { get; set; } = "dashboard"; public string Space { get; set; } = ContainerSpaces.Dashboard;
/// <summary> /// <summary>
/// Свёрнутость колонки на дашборде /// Свёрнутость колонки на дашборде
@@ -1,3 +1,5 @@
using Deal.Modules.Discovery.Application.Models;
namespace Deal.Infrastructure.Persistence.Entities; namespace Deal.Infrastructure.Persistence.Entities;
/// <summary> /// <summary>
@@ -28,7 +30,7 @@ public sealed class DiscCandidateEntity
/// <summary> /// <summary>
/// Тип источника: channel|group|forum. /// Тип источника: channel|group|forum.
/// </summary> /// </summary>
public string Kind { get; set; } = "channel"; public string Kind { get; set; } = DiscoveryCandidateKinds.Channel;
/// <summary> /// <summary>
/// Цвет источника из палитры DIALOG_HUES /// Цвет источника из палитры DIALOG_HUES
@@ -63,7 +65,7 @@ public sealed class DiscCandidateEntity
/// <summary> /// <summary>
/// Статус кандидата /// Статус кандидата
/// </summary> /// </summary>
public string Status { get; set; } = "new"; public string Status { get; set; } = DiscoveryCandidateStatuses.New;
/// <summary> /// <summary>
/// Вступили автоматически /// Вступили автоматически
@@ -1,3 +1,5 @@
using Deal.Modules.Discovery.Application.Models;
namespace Deal.Infrastructure.Persistence.Entities; namespace Deal.Infrastructure.Persistence.Entities;
/// <summary> /// <summary>
@@ -55,7 +57,7 @@ public sealed class DiscTaskEntity
/// <summary> /// <summary>
/// Статус задачи: draft|running|paused|done|failed. /// Статус задачи: draft|running|paused|done|failed.
/// </summary> /// </summary>
public string Status { get; set; } = "draft"; public string Status { get; set; } = DiscoveryTaskStatuses.Draft;
/// <summary> /// <summary>
/// Индекс текущего ключа поиска /// Индекс текущего ключа поиска
@@ -1,3 +1,5 @@
using Deal.Modules.Tenants.Application.Models;
namespace Deal.Infrastructure.Persistence.Entities; namespace Deal.Infrastructure.Persistence.Entities;
/// <summary> /// <summary>
@@ -20,7 +22,7 @@ public sealed class InviteEntity
/// </summary> /// </summary>
public Guid? TenantId { get; set; } public Guid? TenantId { get; set; }
public string Status { get; set; } = "pending"; public string Status { get; set; } = InviteStatuses.Pending;
public DateTimeOffset ExpiresAt { get; set; } public DateTimeOffset ExpiresAt { get; set; }
@@ -1,3 +1,5 @@
using Deal.Modules.Tenants.Application.Models;
namespace Deal.Infrastructure.Persistence.Entities; namespace Deal.Infrastructure.Persistence.Entities;
/// <summary> /// <summary>
@@ -14,7 +16,7 @@ public sealed class OperatorEntity
public string PasswordHash { get; set; } = string.Empty; public string PasswordHash { get; set; } = string.Empty;
public string Status { get; set; } = "active"; public string Status { get; set; } = TenantStatuses.Active;
public DateTimeOffset CreatedAt { get; set; } public DateTimeOffset CreatedAt { get; set; }
} }
@@ -1,3 +1,5 @@
using Deal.Modules.Pipeline.Application.Models;
namespace Deal.Infrastructure.Persistence.Entities; namespace Deal.Infrastructure.Persistence.Entities;
/// <summary> /// <summary>
@@ -38,7 +40,7 @@ public sealed class QueueItemEntity
/// <summary> /// <summary>
/// Статус строки: <c>new</c> — ждёт разбора воркером, <c>filtered</c> — прошла фильтры и ждёт ИИ/ML. /// Статус строки: <c>new</c> — ждёт разбора воркером, <c>filtered</c> — прошла фильтры и ждёт ИИ/ML.
/// </summary> /// </summary>
public string Status { get; set; } = "new"; public string Status { get; set; } = PipelineQueueStatuses.New;
/// <summary> /// <summary>
/// Признак возврата из отсева /// Признак возврата из отсева
@@ -1,5 +1,7 @@
using NpgsqlTypes; using NpgsqlTypes;
using Deal.Modules.Pipeline.Application.Models;
namespace Deal.Infrastructure.Persistence.Entities; namespace Deal.Infrastructure.Persistence.Entities;
/// <summary> /// <summary>
@@ -50,7 +52,7 @@ public sealed class RejectedItemEntity
/// <summary> /// <summary>
/// Кто вынес решение /// Кто вынес решение
/// </summary> /// </summary>
public string Source { get; set; } = "stop"; public string Source { get; set; } = PipelineRejectSources.Rules;
/// <summary> /// <summary>
/// Время получения исходного сообщения /// Время получения исходного сообщения
@@ -1,3 +1,5 @@
using Deal.Modules.Tenants.Application.Models;
namespace Deal.Infrastructure.Persistence.Entities; namespace Deal.Infrastructure.Persistence.Entities;
/// <summary> /// <summary>
@@ -9,7 +11,7 @@ public sealed class TenantEntity
public string Name { get; set; } = string.Empty; public string Name { get; set; } = string.Empty;
public string Status { get; set; } = "active"; public string Status { get; set; } = TenantStatuses.Active;
public DateTimeOffset CreatedAt { get; set; } public DateTimeOffset CreatedAt { get; set; }
} }
@@ -1,3 +1,5 @@
using Deal.Modules.Tenants.Application.Models;
namespace Deal.Infrastructure.Persistence.Entities; namespace Deal.Infrastructure.Persistence.Entities;
/// <summary> /// <summary>
@@ -18,7 +20,7 @@ public sealed class TenantLimitEntity
/// <summary> /// <summary>
/// Тип периода: month|day. /// Тип периода: month|day.
/// </summary> /// </summary>
public string Period { get; set; } = "month"; public string Period { get; set; } = TenantLimitPeriods.Month;
/// <summary> /// <summary>
/// Начало текущего периода /// Начало текущего периода
@@ -1,3 +1,5 @@
using Deal.Modules.Tenants.Application.Models;
namespace Deal.Infrastructure.Persistence.Entities; namespace Deal.Infrastructure.Persistence.Entities;
/// <summary> /// <summary>
@@ -16,7 +18,7 @@ public sealed class UserEntity
public string PasswordHash { get; set; } = string.Empty; public string PasswordHash { get; set; } = string.Empty;
public string Status { get; set; } = "active"; public string Status { get; set; } = TenantStatuses.Active;
public DateTimeOffset CreatedAt { get; set; } public DateTimeOffset CreatedAt { get; set; }
} }
@@ -67,6 +67,32 @@ public sealed class AuthStore(DealDbContext dbContext) : IAuthStore
return entities; return entities;
} }
public async Task<IReadOnlyDictionary<Guid, string>> FindOwnerLoginsByTenantIdsAsync(
IReadOnlyCollection<Guid> tenantIds,
CancellationToken ct)
{
if (tenantIds.Count == 0)
{
return new Dictionary<Guid, string>();
}
var users = await dbContext.Users
.AsNoTracking()
.Where(u => tenantIds.Contains(u.TenantId))
.OrderBy(u => u.CreatedAt)
.ThenBy(u => u.Login)
.Select(u => new { u.TenantId, u.Login })
.ToListAsync(ct);
var owners = new Dictionary<Guid, string>();
foreach (var user in users)
{
owners.TryAdd(user.TenantId, user.Login);
}
return owners;
}
/// <inheritdoc /> /// <inheritdoc />
public async Task CreateSessionAsync(SessionDto session, CancellationToken ct) public async Task CreateSessionAsync(SessionDto session, CancellationToken ct)
{ {
@@ -59,7 +59,7 @@ public sealed partial class DiscoveryStore
Username = row.Username, Username = row.Username,
Kind = row.Kind, Kind = row.Kind,
Hue = row.Hue, Hue = row.Hue,
Status = "new", Status = DiscoveryCandidateStatuses.New,
CreatedAt = now, CreatedAt = now,
UpdatedAt = now, UpdatedAt = now,
}); });
@@ -123,7 +123,7 @@ public sealed partial class DiscoveryStore
return false; return false;
} }
row.Status = "joined"; row.Status = DiscoveryCandidateStatuses.Joined;
row.AutoJoined = autoJoined; row.AutoJoined = autoJoined;
row.UpdatedAt = DateTimeOffset.UtcNow; row.UpdatedAt = DateTimeOffset.UtcNow;
await _dbContext.SaveChangesAsync(ct); await _dbContext.SaveChangesAsync(ct);
@@ -135,7 +135,7 @@ public sealed partial class DiscoveryStore
{ {
DiscCandidateEntity? row = await _dbContext.DiscCandidates DiscCandidateEntity? row = await _dbContext.DiscCandidates
.FirstOrDefaultAsync(candidate => candidate.DialogId == dialogId, ct); .FirstOrDefaultAsync(candidate => candidate.DialogId == dialogId, ct);
if (row is null || row.Status != "review") if (row is null || row.Status != DiscoveryCandidateStatuses.Review)
{ {
return null; return null;
} }
@@ -156,7 +156,7 @@ public sealed partial class DiscoveryStore
return false; return false;
} }
row.Status = "rejected"; row.Status = DiscoveryCandidateStatuses.Rejected;
row.UpdatedAt = DateTimeOffset.UtcNow; row.UpdatedAt = DateTimeOffset.UtcNow;
await _dbContext.SaveChangesAsync(ct); await _dbContext.SaveChangesAsync(ct);
return true; return true;

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