40 Commits
Author SHA1 Message Date
rust 63242775ee Merge branch 'main' into t2_grpc_retry
ci / build-test (pull_request) Successful in 3m0s
2026-09-13 14:54:58 +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 e81f1ebf30 Повторять транзиентные сбои gRPC ai/ml-клиентов
ci / build-test (pull_request) Successful in 2m56s
Filter/Classify, generate-keywords/evaluate-fit и Predict/Status оборачиваются в GrpcRetry; мутирующие вызовы (train/reset/telegram) не повторяются. Тесты учли повторы.
2026-09-13 14:42:56 +03:00
stepan babfbf8006 Добавить механизм повторов транзиентных сбоев
RetryExecutor (SharedKernel) с экспоненциальной задержкой и GrpcRetry (Infrastructure) для gRPC Unavailable/DeadlineExceeded; джиттер сглаживает синхронные ретраи воркеров. Покрыто тестами.
2026-09-13 14:42:53 +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
183 changed files with 4421 additions and 3516 deletions
+17
View File
@@ -1,10 +1,27 @@
name: ci
# Прогоняем CI в двух случаях:
# 1) МР: создан, переоткрыт или в ветку МР запушены новые коммиты (synchronize).
# 2) Влитие в main (пуш в main — в т.ч. мерж МР).
# Обычные коммиты в ветки без открытого МР CI не запускают.
on:
push:
branches:
- main
pull_request:
types:
- opened
- synchronize
- reopened
workflow_dispatch:
# Новый прогон того же рефа (та же ветка МР / тот же main) отменяет незавершённый предыдущий:
# коммит-А стартовал, через пару минут коммит-Б — прогон А отменяется, если ещё не закончил.
# Завершённые (успех/фейл) прогоны не трогаются.
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
build-test:
runs-on: ubuntu-latest
+72
View File
@@ -108,6 +108,9 @@ services:
Storage__Minio__SecretKey: deal_minio_secret
Storage__Minio__Bucket: deal-files
Storage__Minio__Secure: "false"
# Трейсинг OTel → коллектор профиля observability (без коллектора трейсы не экспортируются).
OTEL_EXPORTER_OTLP_ENDPOINT: ${DEAL_OTEL_ENDPOINT:-}
OTEL_SERVICE_NAME: core
ports:
- "5080:5080"
- "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_DIR: /data/sessions
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}
ports:
- "5101:5101"
@@ -176,6 +181,8 @@ services:
GRPC_PORT: "5102"
DEAL_SERVICE_TOKEN: ${DEAL_SERVICE_TOKEN:-deal_dev_service_token}
DEAL_LOGS_DIR: /tmp/logs
OTEL_EXPORTER_OTLP_ENDPOINT: ${DEAL_OTEL_ENDPOINT:-}
OTEL_SERVICE_NAME: ai-service
ports:
- "5102:5102"
healthcheck:
@@ -203,6 +210,8 @@ services:
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_LOGS_DIR: /tmp/logs
OTEL_EXPORTER_OTLP_ENDPOINT: ${DEAL_OTEL_ENDPOINT:-}
OTEL_SERVICE_NAME: ml-service
ports:
- "5103:5103"
volumes:
@@ -233,6 +242,8 @@ services:
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
ports:
- "5104:5104"
depends_on:
@@ -261,6 +272,66 @@ services:
- ./observability/prometheus-rules.yml:/etc/prometheus/prometheus-rules.yml:ro
- 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
volumes:
deal_pgdata:
deal_minio_data:
@@ -268,3 +339,4 @@ volumes:
deal_ml_data:
deal_api_data:
deal_prometheus_data:
deal_tempo_data:
+111 -5
View File
@@ -6,12 +6,12 @@
# Состав (всё в одной внутренней сети compose, наружу — ТОЛЬКО caddy :80/:443):
# postgres, minio — хранилища БЕЗ host-портов (volume'ы);
# 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.
# loki/promtail/grafana/prometheus — observability (Ruling 7; метрики — этап 12, пакет A): ПРОФИЛЬ
# `observability` — поднимается только: docker compose --profile observability up -d
# (или ... up -d --profile observability). Prometheus scrape'ит /metrics
# (порт 9464) всех 4 процессов; Grafana — логи (Loki) и метрики (Prometheus).
# observability (ПРОФИЛЬ `observability`) — современный стек: otel-collector (приём трейсов OTLP),
# tempo (хранилище трейсов), loki/promtail (логи), prometheus (метрики),
# cadvisor/node-exporter (потребление ресурсов контейнеров/хоста), grafana (UI).
# Подъём: docker compose --profile observability up -d.
#
# Секреты — ТОЛЬКО из env: шаблон deploy/.env.prod.example → скопируйте в deploy/.env.prod,
# заполните значения и запускайте с --env-file:
@@ -125,6 +125,7 @@ services:
Services__Ai__Endpoint: ${DEAL_AI_ENDPOINT:-http://ai-service:5102}
Services__Telegram__UseLocal: "false"
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
# + endpoint https и сертификаты).
Storage__Minio__Endpoint: minio:9000
@@ -132,6 +133,9 @@ services:
Storage__Minio__SecretKey: ${MINIO_ROOT_PASSWORD:?MINIO_ROOT_PASSWORD не задан}
Storage__Minio__Bucket: deal-files
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 ниже).
DEAL_MTLS_ENABLED: ${DEAL_MTLS_ENABLED:-0}
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_DIR: /data/sessions
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}
DEAL_MTLS_ENABLED: ${DEAL_MTLS_ENABLED:-0}
DEAL_MTLS_CA_PEM: /etc/deal/certs/ca.pem
@@ -201,6 +207,8 @@ services:
GRPC_PORT: "5102"
DEAL_SERVICE_TOKEN: ${DEAL_SERVICE_TOKEN:?DEAL_SERVICE_TOKEN не задан}
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_CA_PEM: /etc/deal/certs/ca.pem
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_ML_DATA_DIR: /data/ml
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_CA_PEM: /etc/deal/certs/ca.pem
DEAL_MTLS_SERVER_CERT_PFX: /etc/deal/certs/ml-service-server.pfx
@@ -245,6 +255,44 @@ services:
retries: 10
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: домен,
# реальный сертификат/Cloudflare, CSP/HSTS). Статика — ../src/frontend/dist (СОБРАТЬ ДО up).
caddy:
@@ -334,6 +382,63 @@ services:
condition: service_started
prometheus:
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
volumes:
@@ -343,6 +448,7 @@ volumes:
deal_ml_data:
deal_api_data:
deal_caddy_data:
deal_tempo_data:
deal_caddy_config:
deal_loki_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
jsonData:
maxLines: 1000
# Клик по TraceId в логе открывает трейс в Tempo (обогащение логов TraceContextEnricher).
derivedFields:
- name: TraceID
matcherRegex: '"TraceId":"([0-9a-f]+)"'
datasourceUid: tempo
url: "$${__value.raw}"
- name: Prometheus
# UID фиксирован: на него ссылаются панели дашборда Deal-Metrics-Overview (datasource uid: prometheus).
@@ -26,3 +32,24 @@ datasources:
# Prometheus хранит OTel-гистограммы в нативных bucket'ах — используем нативные histogram_quantile.
httpMethod: POST
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:
summary: "Растёт очередь обучения ML (outbox)"
description: "Суммарная глубина MlOutbox держится выше 100 более 15 минут ({{ $value }})."
+15
View File
@@ -46,3 +46,18 @@ scrape_configs:
- targets: ["ml-service:9464"]
labels:
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
+44 -7
View File
@@ -103,6 +103,25 @@
- Асинхронность: суффикс `Async`, `CancellationToken` пробрасывать до конца; `.Result` / `.Wait()`
запрещены — только `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. Комментирование кода
Все комментарии — на русском языке.
@@ -123,6 +142,8 @@
- **`<param>`/`<returns>`** — только если смысл не очевиден из имени/типа; не переписывать сигнатуру.
- **`<summary>` — только блочный.** Открывающий `<summary>` и закрывающий `</summary>` — **каждый на
своей строке**; запись в одну строку (`/// <summary>текст</summary>`) **не допускается**. **[изм.]**
- **Конструкторы не документируем** — `<summary>`/`<param>` на них не нужны: назначение очевидно из
типа и сигнатуры. В частности, не документируем конструкторы классов, реализующих интерфейс. **[изм.]**
Правильно:
```csharp
@@ -221,21 +242,37 @@
- `try-catch` — только для непредвиденных ошибок, не для управления ходом программы.
- При пробрасывании выше — `throw;`, а **не** `throw ex;`.
- Свои исключения наследовать от `Exception`.
- **Свои доменные исключения наследовать от `DealException`** (`Deal.SharedKernel.Errors`) — базовый тип
хранит код ошибки (`ErrorCode`) и умеет брать текст из ресурсов. Состав: `NotFoundException`,
`ValidationException`, `ConflictException`, `ServiceUnavailableException`; новые — по тому же образцу.
- **Не возвращать `null` как штатный результат «не найдено»/ошибки.** Доменный сервис, у которого объект
не найден, бросает `NotFoundException` (эндпоинт отдаёт 404 через общий обработчик, а не проверкой
`is null` в каждом хендлере). `null` допустим только для **опциональных значений** — парсеры/извлечение
полей, выборки-запросы («нет строки» — нормальный результат), `Try*`-паттерн; такие методы должны быть
nullable-аннотированы и явно описаны в XML-doc.
- Исключение создавать всегда, когда функция не может быть выполнена (неверные параметры, нет доступа к
БД, неизвестные идентификаторы и т.п.).
- Все исключения должны быть залогированы или показаны пользователю; пустые `catch` запрещены.
- В лог об ошибке, как правило, писать `StackTrace`.
- Все исключения должны быть залогированы или показаны пользователю; **пустые `catch` запрещены**.
- **Единый формат лога ошибки:** понятный русский текст + структурированный контекст (операция, `tenantId`,
id сущности, `traceId`). Стектрейс пишется **только в лог**; в ответ/сообщение клиенту он не попадает —
наружу отдаётся обобщённый текст и код (обработчики на границах: `DealExceptionHandler`, gRPC-интерцептор).
- **Тексты исключений/ошибок не хардкодить** — держать в ресурсах (`ErrorMessages.resx`, доступ через
`ErrorResources.Format(ErrorResourceKeys.*)` и шаблоны `DealException`), чтобы переводы добавлялись
отдельной культурой (`.resx`-спутник) без правок кода.
## 11. Интерфейсы
- **Не дублировать `<summary>` интерфейса в реализации.** Если член объявлен в интерфейсе с XML-doc,
в классе-реализации достаточно `/// <inheritdoc/>` (или вообще ничего, если doc наследуется настройкой).
Текст описания пишется **один раз** — у интерфейса.
- **В реализациях интерфейсов XML-doc не пишем вообще.** Если тип или член объявлен в интерфейсе,
класс-реализация не документируется: ни `<summary>`, ни `<inheritdoc/>` (и ни `<param>` на
конструкторе). Описание живёт **один раз** — в интерфейсе; реализации вызываются только через порт.
Под этот запрет попадает и сам класс-реализация (его `<summary>` тоже лишний — есть у интерфейса).
- **XML-doc уместен только там, где нет интерфейса:** public-типы/члены без порта (статика, константы,
extension-классы), `protected`-члены и DTO/модели. **[изм. 2026-09-13, решение владельца]**
- **Явная реализация интерфейсов — по умолчанию** (`Task ICardStore.GetAsync(...)`). **[изм. 2026-09-11,
решение владельца]** Классы напрямую не вызываются — только через интерфейсы; исключения: DTO/модели
(напр. `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]**
@@ -22,7 +22,7 @@
| Файлы | MinIO (S3-совместимое хранилище) |
| Фронтенд | Vue 3 + Vite + Tailwind |
| Межсервисно | 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) |
| Контейнеры | Docker / docker compose (VPS); k8s — позже |
| Бэкапы | Ежедневные: 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). Остановка —
`docker compose -f deploy/compose.prod.yml --profile observability down`.
- **Провижининг 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`. Дашборды — файлы
`deploy/observability/grafana/dashboards/*.json`: правки только в репозитории, UI-изменения не
сохраняются (`allowUiUpdates: false`).
@@ -251,7 +253,10 @@ settings(Key varchar(200) PK, ValueJson text, UpdatedAt timestamptz) --
access-лога core) и активация инвайтов (`/api/join`);
- `Deal-Errors` — HTTP 5xx, необработанные исключения (`@x`), Error/Fatal, ошибки gRPC и общая лента;
- `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 пользователя/оператора) и
`tenant`; полная лента действий с деталями — `public.audit_log` (append-only) через
`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)`). Замечание: правила
бюджета токенов нет — метрика бюджета в Prometheus отсутствует (см. §6/§10), поэтому алерт не вводится.
- Как поднять/проверить: `docker compose --env-file deploy/.env.prod -f deploy/compose.prod.yml
--profile observability up -d` → Prometheus `/targets` (все 4 UP) → Grafana → папка «Дейл» →
`Deal-Metrics-Overview`. Быстрая проверка экспортёра без Grafana: `curl http://<процесс>:9464/metrics`
изнутри сети.
--profile observability up -d` → Prometheus `/targets` (все UP) → Grafana → папка «Дейл» →
`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` —
`tls internal` для dev/интранет, для реального домена заменить на Cloudflare-origin/сертификаты),
статика `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-проба с
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`
(шаблон `deploy/.env.prod.example`, без дефолтных паролей; отсутствие → fail-fast `:?`).
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`
(HTTP/1.1 :9464) + прикладные `deal.*` (токены/вызовы AI/ML, аудит, глубины очередей, сессии) — см. §7.
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
(mTLS env; healthcheck — `grpc_health_probe`, при mTLS — TLS-проба с PEM), `caddy` (80/443: статика
`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 `:?`). Запуск:
`docker compose --env-file deploy/.env.prod -f deploy/compose.prod.yml up -d --build` (+ `--profile observability`);
авто-проверка — `... 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`,
`telegram_linked` (таблица — в контракте; `channel_created` зарезервирован, но не эмитится).
- **Наблюдаемость** (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`
с Postgres `:5433`, AI в Local-режиме) → фронт `cd src/frontend && npm run dev` (`:5173`, прокси `/api`)
→ **оператор:** `http://localhost:5173/#/operator`, вход `operator`/`operator` (dev-дефолт; в Production —
@@ -4,9 +4,6 @@ using System.Text.Json.Nodes;
namespace Deal.Ai.Llm;
/// <summary>
/// HTTP-реализация <see cref="IProviderClient"/>
/// </summary>
public sealed class LlmHttpClient : IProviderClient
{
// Относительный путь OpenAI-совместимого эндпоинта (база уже без хвостового «/»).
@@ -58,13 +55,6 @@ public sealed class LlmHttpClient : IProviderClient
_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(
LlmConfig config,
string systemPrompt,
+1
View File
@@ -18,6 +18,7 @@ WebApplication app = AiServiceHost.Create(
{
DealLogging.Configure(builder, aiProcessName);
DealMetricsHosting.AddDealMetrics(builder, metricsPort);
DealTracingHosting.AddDealTracing(builder, aiProcessName);
});
DealMetricsHosting.MapDealMetrics(app);
+1
View File
@@ -42,6 +42,7 @@
Версии — 1.17.x (Prometheus-экспортёр и gRPC-клиент только pre-release-линией; остальные —
1.17.0 stable). Настройка — Deal.Api/Observability/DealMetricsHosting.cs. -->
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.17.0" />
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.17.0" />
<PackageReference Include="OpenTelemetry.Exporter.Prometheus.AspNetCore" Version="1.17.0-beta.1" />
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.17.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.17.0" />
@@ -136,10 +136,8 @@ public static class CardDetailsEndpoints
}
CardsService service = context.RequestServices.GetRequiredService<CardsService>();
CardDto? card = await service.TakeCardAsync(body.CardId ?? body.LeadId ?? string.Empty, ct);
return card is null
? EndpointResults.NotFound(CardNotFoundDetail)
: await ReadCardAsync(context, card.Id, ct);
CardDto card = await service.TakeCardAsync(body.CardId ?? body.LeadId ?? string.Empty, ct);
return await ReadCardAsync(context, card.Id, ct);
}
// POST /api/cards/clear-rejected: полная очистка терминальной стадии «Отклонено».
@@ -261,11 +259,7 @@ public static class CardDetailsEndpoints
foreach (IFormFile file in form.Files)
{
await using Stream content = file.OpenReadStream();
CardFileDto? entry = await cardsService.AddFileAsync(cardId, file.FileName, file.ContentType, content, file.Length, ct);
if (entry is null)
{
return EndpointResults.NotFound(CardNotFoundDetail);
}
await cardsService.AddFileAsync(cardId, file.FileName, file.ContentType, content, file.Length, ct);
}
return await ReadCardAsync(context, cardId, ct);
@@ -286,11 +280,7 @@ public static class CardDetailsEndpoints
}
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
CardFileDto? entry = await cardsService.GetFileEntryAsync(cardId, fileId, ct);
if (entry is null)
{
return EndpointResults.NotFound(CardNotFoundDetail);
}
CardFileDto entry = await cardsService.GetFileEntryAsync(cardId, fileId, ct);
if (string.IsNullOrWhiteSpace(entry.ObjectKey))
{
@@ -339,10 +329,8 @@ public static class CardDetailsEndpoints
}
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
CardDto? card = await cardsService.RemoveFileAsync(cardId, fileId, ct);
return card is null
? EndpointResults.NotFound(CardNotFoundDetail)
: await ReadCardAsync(context, cardId, ct);
await cardsService.RemoveFileAsync(cardId, fileId, ct);
return await ReadCardAsync(context, cardId, ct);
}
// POST /api/cards/{cardId}/reminder {at: epoch-ms}: установить напоминание. Ответ — карточка.
+2 -10
View File
@@ -209,11 +209,7 @@ public static class CardsEndpoints
}
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
CardDto? card = await cardsService.TrashCardAsync(cardId, ct);
if (card is null)
{
return EndpointResults.NotFound(CardNotFoundDetail);
}
await cardsService.TrashCardAsync(cardId, ct);
await AuditAppender.AppendTenantAsync(context, AuditEvents.CardTrashed, new { cardId }, ct);
return Results.Ok(new { ok = true });
@@ -230,11 +226,7 @@ public static class CardsEndpoints
}
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
string? col = await cardsService.RestoreCardAsync(cardId, ct);
if (col is null)
{
return EndpointResults.NotFound(CardNotFoundDetail);
}
string col = await cardsService.RestoreCardAsync(cardId, ct);
await AuditAppender.AppendTenantAsync(context, AuditEvents.CardRestored, new { cardId, col }, ct);
return Results.Ok(new { ok = true, col });
@@ -19,9 +19,6 @@ public static class ContainersEndpoints
// OpenAPI-тег группы.
private const string OpenApiTag = "containers";
// 404 PATCH/accept: контейнер не найден.
private const string ContainerNotFoundDetail = "Контейнер не найден";
// 400: отсутствующий/явный null name контейнера.
private const string ContainerNameRequiredDetail = "Укажите название колонки";
@@ -143,7 +140,7 @@ public static class ContainersEndpoints
}
ContainersService containers = context.RequestServices.GetRequiredService<ContainersService>();
ContainerDto? updated = await containers.PatchAsync(
ContainerDto updated = await containers.PatchAsync(
containerId,
new ContainerPatchDto(
patchBody.Name,
@@ -155,10 +152,6 @@ public static class ContainersEndpoints
NormalizeWireRules(patchBody.Rules),
patchBody.Policy),
ct);
if (updated is null)
{
return EndpointResults.NotFound(ContainerNotFoundDetail);
}
await AuditAppender.AppendTenantAsync(context, AuditEvents.ContainerUpdated, new { id = updated.Id }, ct);
return Results.Ok(new { id = updated.Id });
@@ -176,11 +169,7 @@ public static class ContainersEndpoints
}
ContainersService containers = context.RequestServices.GetRequiredService<ContainersService>();
ContainerDto? accepted = await containers.AcceptSuggestedAsync(containerId, ct);
if (accepted is null)
{
return EndpointResults.NotFound(ContainerNotFoundDetail);
}
ContainerDto accepted = await containers.AcceptSuggestedAsync(containerId, ct);
await AuditAppender.AppendTenantAsync(context, AuditEvents.ContainerUpdated, new { id = accepted.Id }, ct);
return Results.Ok(accepted);
@@ -55,8 +55,7 @@ public static class DiscoveryEndpoints
// Путь лога задачи (GET).
private const string TaskLogPath = "/tasks/{task_id}/log";
private const string TaskNotFoundDetail = "Задача не найдена";
// 404: кандидат не найден.
private const string CandidateNotFoundDetail = "Кандидат не найден";
private const string AlreadyJoinedDetail = "Уже вступили в этот источник";
@@ -149,8 +148,8 @@ public static class DiscoveryEndpoints
try
{
DiscoveryTasksService tasks = context.RequestServices.GetRequiredService<DiscoveryTasksService>();
DiscoveryTaskDto? task = await tasks.PatchAsync(task_id, ToPatch(body), ct);
return task is null ? EndpointResults.NotFound(TaskNotFoundDetail) : Results.Ok(task);
DiscoveryTaskDto task = await tasks.PatchAsync(task_id, ToPatch(body), ct);
return Results.Ok(task);
}
catch (DiscoveryValidationException exception)
{
@@ -169,8 +168,8 @@ public static class DiscoveryEndpoints
}
DiscoveryTasksService tasks = context.RequestServices.GetRequiredService<DiscoveryTasksService>();
bool deleted = await tasks.DeleteAsync(task_id, ct);
return deleted ? Results.Ok(new { ok = true }) : EndpointResults.NotFound(TaskNotFoundDetail);
await tasks.DeleteAsync(task_id, ct);
return Results.Ok(new { ok = true });
}
private static async Task<IResult> StartTaskAsync(
@@ -186,8 +185,8 @@ public static class DiscoveryEndpoints
try
{
DiscoveryTasksService tasks = context.RequestServices.GetRequiredService<DiscoveryTasksService>();
DiscoveryTaskDto? task = await tasks.StartAsync(task_id, ct);
return task is null ? EndpointResults.NotFound(TaskNotFoundDetail) : Results.Ok(task);
DiscoveryTaskDto task = await tasks.StartAsync(task_id, ct);
return Results.Ok(task);
}
catch (DiscoveryValidationException exception)
{
@@ -206,8 +205,8 @@ public static class DiscoveryEndpoints
}
DiscoveryTasksService tasks = context.RequestServices.GetRequiredService<DiscoveryTasksService>();
DiscoveryTaskDto? task = await tasks.PauseAsync(task_id, ct);
return task is null ? EndpointResults.NotFound(TaskNotFoundDetail) : Results.Ok(task);
DiscoveryTaskDto task = await tasks.PauseAsync(task_id, ct);
return Results.Ok(task);
}
private static async Task<IResult> GenerateKeywordsAsync(
@@ -221,11 +220,7 @@ public static class DiscoveryEndpoints
}
DiscoveryTasksService tasks = context.RequestServices.GetRequiredService<DiscoveryTasksService>();
DiscoveryTaskDto? task = await tasks.GetAsync(task_id, ct);
if (task is null)
{
return EndpointResults.NotFound(TaskNotFoundDetail);
}
DiscoveryTaskDto task = await tasks.GetAsync(task_id, ct);
ISettingsStore settings = context.RequestServices.GetRequiredService<ISettingsStore>();
if (!await ReadAiEnabledAsync(settings, ct))
@@ -268,11 +263,7 @@ public static class DiscoveryEndpoints
}
DiscoveryTasksService tasks = context.RequestServices.GetRequiredService<DiscoveryTasksService>();
DiscoveryTaskDto? task = await tasks.GetAsync(task_id, ct);
if (task is null)
{
return EndpointResults.NotFound(TaskNotFoundDetail);
}
DiscoveryTaskDto task = await tasks.GetAsync(task_id, ct);
DiscoveryCandidatesService candidates = context.RequestServices.GetRequiredService<DiscoveryCandidatesService>();
IReadOnlyList<DiscoveryCandidateDto> items = await candidates.ListAsync(task_id, status, ct);
@@ -403,11 +394,7 @@ public static class DiscoveryEndpoints
}
DiscoveryTasksService tasks = context.RequestServices.GetRequiredService<DiscoveryTasksService>();
DiscoveryTaskDto? task = await tasks.GetAsync(task_id, ct);
if (task is null)
{
return EndpointResults.NotFound(TaskNotFoundDetail);
}
DiscoveryTaskDto task = await tasks.GetAsync(task_id, ct);
DiscoveryLogService log = context.RequestServices.GetRequiredService<DiscoveryLogService>();
IReadOnlyList<DiscoveryLogDto> items = await log.TaskLogAsync(task_id, ct);
+2 -1
View File
@@ -62,7 +62,8 @@ internal static class DealLogging
.MinimumLevel.Is(ParseMinimumLevel(configuration[MinimumLevelEnvKey]))
.MinimumLevel.Override(EntityFrameworkCoreCategory, LogEventLevel.Warning)
.MinimumLevel.Override(GrpcCategory, LogEventLevel.Information)
.Enrich.FromLogContext();
.Enrich.FromLogContext()
.Enrich.With<TraceContextEnricher>();
string logsDirectory = ResolveLogsDirectory(environment.ContentRootPath, configuration[LogsDirectoryEnvKey]);
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)));
}
}
+4
View File
@@ -66,6 +66,7 @@ DealLogging.Configure(builder, coreProcessName);
int metricsPort = DealMetricsHosting.ResolveMetricsPort(DealMetricsHosting.DefaultMetricsPort);
DealMetricsHosting.AddDealMetrics(builder, metricsPort);
DealTracingHosting.AddDealTracing(builder, coreProcessName);
// Строка подключения Postgres — ТОЛЬКО из конфигурации (env/appsettings): dev-пароль в коде отсутствует
// (Security review). Отсутствие строки = fail-fast на старте, а не тихий уход на несуществующую dev-БД.
@@ -132,6 +133,8 @@ TokenLimitDefaults tenantLimitDefaults = new(
builder.Services.AddDealPersistence(tenantLimitDefaults);
builder.Services.AddDealSecurity(builder.Environment.ContentRootPath);
builder.Services.AddExceptionHandler<DealExceptionHandler>();
builder.Services.AddProblemDetails();
MlServiceOptions mlOptions = builder.Configuration.GetSection(servicesSectionName).Get<MlServiceOptions>() ?? new MlServiceOptions();
builder.Services.AddSingleton(mlOptions);
@@ -325,6 +328,7 @@ if (forwardedHeadersConfig.Enabled)
app.UseForwardedHeaders(BuildForwardedHeadersOptions(forwardedHeadersConfig));
}
app.UseExceptionHandler();
app.UseMiddleware<HttpAccessLogMiddleware>();
app.UseCors(corsPolicyName);
@@ -3,22 +3,17 @@ using Deal.SharedKernel.Tenants.Models;
namespace Deal.Infrastructure.Data;
/// <summary>
/// Контекст тенанта на AsyncLocal
/// </summary>
public sealed class TenantContext : ITenantContext
{
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;
/// <inheritdoc />
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();
}
@@ -0,0 +1,66 @@
using Deal.SharedKernel.Resilience;
using Grpc.Core;
namespace Deal.Infrastructure.Integrations.Resilience;
/// <summary>
/// Повтор транзиентных gRPC-сбоев клиентов автономных сервисов.
/// </summary>
public static class GrpcRetry
{
/// <summary>
/// Число повторов после первой попытки.
/// </summary>
public const int RetryCount = 2;
/// <summary>
/// Базовая задержка повтора (далее — экспоненциально с джиттером).
/// </summary>
public static readonly TimeSpan BaseDelay = TimeSpan.FromMilliseconds(200);
/// <summary>
/// Выполняет gRPC-вызов с повтором транзиентных сбоев.
/// </summary>
/// <param name="operation">Вызов (принимает токен отмены).</param>
/// <param name="cancellationToken">Токен отмены.</param>
/// <returns>Ответ вызова.</returns>
public static Task<TResult> ExecuteAsync<TResult>(
Func<CancellationToken, Task<TResult>> operation,
CancellationToken cancellationToken)
=> ExecuteAsync(operation, DefaultDelayAsync, cancellationToken);
/// <summary>
/// Выполняет gRPC-вызов с повтором и заданной паузой между попытками.
/// </summary>
/// <param name="operation">Вызов (принимает токен отмены).</param>
/// <param name="delayAsync">Пауза между попытками (в тестах — мгновенная).</param>
/// <param name="cancellationToken">Токен отмены.</param>
/// <returns>Ответ вызова.</returns>
public static Task<TResult> ExecuteAsync<TResult>(
Func<CancellationToken, Task<TResult>> operation,
Func<TimeSpan, CancellationToken, Task> delayAsync,
CancellationToken cancellationToken)
=> RetryExecutor.ExecuteAsync(
operation,
RetryCount,
BaseDelay,
IsTransient,
delayAsync,
cancellationToken);
/// <summary>
/// Признак транзиентного сбоя транспорта (недоступность/дедлайн).
/// </summary>
/// <param name="exception">Исключение вызова.</param>
/// <returns>True — сбой имеет смысл повторить.</returns>
public static bool IsTransient(Exception exception)
=> exception is RpcException rpc
&& rpc.StatusCode is StatusCode.Unavailable or StatusCode.DeadlineExceeded;
// Экспоненциальная задержка с джиттером 0.5–1.5× (сглаживает синхронные ретраи воркеров).
private static Task DefaultDelayAsync(TimeSpan delay, CancellationToken cancellationToken)
{
double factor = 0.5 + Random.Shared.NextDouble();
return Task.Delay(TimeSpan.FromMilliseconds(delay.TotalMilliseconds * factor), cancellationToken);
}
}
@@ -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;
/// <summary>
/// HTTP-реализация проверки подключения к AI-провайдеру.
/// </summary>
public sealed class AiConnectionChecker : IAiConnectionChecker
{
/// <summary>
@@ -78,7 +75,6 @@ public sealed class AiConnectionChecker : IAiConnectionChecker
_httpClient = httpClient;
}
/// <inheritdoc />
async Task<AiCheckResultDto> IAiConnectionChecker.CheckAsync(AiCheckRequest request, CancellationToken ct)
{
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;
/// <summary>
/// Декоратор бюджетного гейта порта <see cref="IAiClassifier"/>
/// </summary>
public sealed class BudgetedAiClassifier : IAiClassifier
{
// Текст ошибки вызова вне tenant-контекста (гейт читает лимиты по тенанту).
@@ -54,7 +51,6 @@ public sealed class BudgetedAiClassifier : IAiClassifier
_logger = logger;
}
/// <inheritdoc />
async Task<AiFilterResultDto> IAiClassifier.FilterAsync(string text, CancellationToken ct)
{
if (await IsPaidAllowedAsync(ct))
@@ -67,7 +63,6 @@ public sealed class BudgetedAiClassifier : IAiClassifier
return await _localClassifier.FilterAsync(text, ct);
}
/// <inheritdoc />
async Task<AiParsedCardDto> IAiClassifier.ClassifyAsync(string text, CancellationToken ct)
{
if (await IsPaidAllowedAsync(ct))
@@ -9,9 +9,6 @@ using Microsoft.Extensions.Logging;
namespace Deal.Infrastructure.Integrations.Services;
/// <summary>
/// Декоратор бюджетного гейта порта <see cref="IAiTools"/>
/// </summary>
public sealed class BudgetedAiTools : IAiTools
{
private const string ExhaustedKeywordsError = "ИИ-бюджет исчерпан — генерация ключевых слов недоступна";
@@ -50,7 +47,6 @@ public sealed class BudgetedAiTools : IAiTools
_logger = logger;
}
/// <inheritdoc />
async Task<AiGenerateKeywordsResultDto> IAiTools.GenerateKeywordsAsync(string description, CancellationToken ct)
{
BudgetStateDto state = await GateStateAsync(ct);
@@ -69,7 +65,6 @@ public sealed class BudgetedAiTools : IAiTools
Error: state.Status == TenantStatuses.Suspended ? SuspendedKeywordsError : ExhaustedKeywordsError);
}
/// <inheritdoc />
async Task<AiEvaluateFitResultDto> IAiTools.EvaluateFitAsync(
string text,
string description,
@@ -5,9 +5,6 @@ using Microsoft.Extensions.Logging;
namespace Deal.Infrastructure.Integrations.Services;
/// <summary>
/// HTTP-источник курсов ЦБ РФ
/// </summary>
public sealed class CbrRateSource : IRatesSource
{
/// <summary>
@@ -47,7 +44,6 @@ public sealed class CbrRateSource : IRatesSource
_logger = logger;
}
/// <inheritdoc />
async Task<Dictionary<string, double>?> IRatesSource.FetchAsync(CancellationToken ct)
{
try
@@ -3,6 +3,7 @@ using Deal.Contracts.Integrations.Models;
using Deal.Grpc.Ai;
using Deal.Infrastructure.Integrations.Exceptions;
using Deal.Infrastructure.Integrations.Models;
using Deal.Infrastructure.Integrations.Resilience;
using Deal.Modules.Pipeline.Application.Services;
using Deal.SharedKernel.Tenants.Abstractions;
using Deal.SharedKernel.Tenants.Models;
@@ -11,9 +12,6 @@ using Microsoft.Extensions.Logging;
namespace Deal.Infrastructure.Integrations.Services;
/// <summary>
/// gRPC-адаптер порта <see cref="IAiClassifier"/> к автономному ai-service.
/// </summary>
public sealed class GrpcAiClassifier : IAiClassifier
{
/// <summary>
@@ -70,7 +68,6 @@ public sealed class GrpcAiClassifier : IAiClassifier
_logger = logger;
}
/// <inheritdoc />
async Task<AiFilterResultDto> IAiClassifier.FilterAsync(string text, CancellationToken ct)
{
TenantId tenantId = RequireTenant();
@@ -79,14 +76,16 @@ public sealed class GrpcAiClassifier : IAiClassifier
string prompt = await _contextBuilder.BuildFilterPromptAsync(ct);
ProviderConfig providerConfig = await _providerConfigBuilder.BuildAsync(ct);
AiService.AiServiceClient client = _connection.CreateClient();
FilterReply reply = await client.FilterAsync(
new FilterRequest
{
Prompt = prompt,
Text = SliceCodePoints(text, MaxFilterTextCodePoints), // python L193: text[:4000]
ProviderConfig = providerConfig,
},
CallOptions(tenantId.Value, ct));
FilterReply reply = await GrpcRetry.ExecuteAsync(
token => client.FilterAsync(
new FilterRequest
{
Prompt = prompt,
Text = SliceCodePoints(text, MaxFilterTextCodePoints), // python L193: text[:4000]
ProviderConfig = providerConfig,
},
CallOptions(tenantId.Value, token)).ResponseAsync,
ct);
await _usageRecorder.AddAsync(reply.Usage, providerConfig.ProviderId, providerConfig.Model, ct);
return new AiFilterResultDto(
@@ -106,7 +105,6 @@ public sealed class GrpcAiClassifier : IAiClassifier
}
}
/// <inheritdoc />
async Task<AiParsedCardDto> IAiClassifier.ClassifyAsync(string text, CancellationToken ct)
{
TenantId tenantId = RequireTenant();
@@ -118,14 +116,16 @@ public sealed class GrpcAiClassifier : IAiClassifier
ClassifyReply reply;
try
{
reply = await client.ClassifyAsync(
new ClassifyRequest
{
SystemPrompt = systemPrompt,
UserContext = userContext,
ProviderConfig = providerConfig,
},
CallOptions(tenantId.Value, ct));
reply = await GrpcRetry.ExecuteAsync(
token => client.ClassifyAsync(
new ClassifyRequest
{
SystemPrompt = systemPrompt,
UserContext = userContext,
ProviderConfig = providerConfig,
},
CallOptions(tenantId.Value, token)).ResponseAsync,
ct);
}
catch (RpcException exception)
{
@@ -3,6 +3,7 @@ using Deal.Contracts.Integrations.Models;
using Deal.Grpc.Ai;
using Deal.Infrastructure.Integrations.Exceptions;
using Deal.Infrastructure.Integrations.Models;
using Deal.Infrastructure.Integrations.Resilience;
using Deal.SharedKernel.Tenants.Abstractions;
using Deal.SharedKernel.Tenants.Models;
using Grpc.Core;
@@ -10,9 +11,6 @@ using Microsoft.Extensions.Logging;
namespace Deal.Infrastructure.Integrations.Services;
/// <summary>
/// gRPC-адаптер порта <see cref="IAiTools"/> к автономному ai-service.
/// </summary>
public sealed class GrpcAiTools : IAiTools
{
/// <summary>
@@ -70,7 +68,6 @@ public sealed class GrpcAiTools : IAiTools
_logger = logger;
}
/// <inheritdoc />
async Task<AiGenerateKeywordsResultDto> IAiTools.GenerateKeywordsAsync(string description, CancellationToken ct)
{
TenantId tenantId = RequireTenant();
@@ -78,13 +75,15 @@ public sealed class GrpcAiTools : IAiTools
{
AiService.AiServiceClient client = _connection.CreateClient();
ProviderConfig providerConfig = await _providerConfigBuilder.BuildAsync(ct);
GenerateKeywordsReply reply = await client.GenerateKeywordsAsync(
new GenerateKeywordsRequest
{
Description = SliceCodePoints(description ?? string.Empty, MaxDescriptionCodePoints),
ProviderConfig = providerConfig,
},
CallOptions(tenantId.Value, ct));
GenerateKeywordsReply reply = await GrpcRetry.ExecuteAsync(
token => client.GenerateKeywordsAsync(
new GenerateKeywordsRequest
{
Description = SliceCodePoints(description ?? string.Empty, MaxDescriptionCodePoints),
ProviderConfig = providerConfig,
},
CallOptions(tenantId.Value, token)).ResponseAsync,
ct);
await _usageRecorder.AddAsync(reply.Usage, providerConfig.ProviderId, providerConfig.Model, ct);
return new AiGenerateKeywordsResultDto(
Ok: true,
@@ -104,7 +103,6 @@ public sealed class GrpcAiTools : IAiTools
}
}
/// <inheritdoc />
async Task<AiEvaluateFitResultDto> IAiTools.EvaluateFitAsync(
string text,
string description,
@@ -130,7 +128,9 @@ public sealed class GrpcAiTools : IAiTools
}
}
EvaluateFitReply reply = await client.EvaluateFitAsync(request, CallOptions(tenantId.Value, ct));
EvaluateFitReply reply = await GrpcRetry.ExecuteAsync(
token => client.EvaluateFitAsync(request, CallOptions(tenantId.Value, token)).ResponseAsync,
ct);
await _usageRecorder.AddAsync(reply.Usage, providerConfig.ProviderId, providerConfig.Model, ct);
return new AiEvaluateFitResultDto(
Fit: reply.Fit,
@@ -4,6 +4,7 @@ using Deal.Contracts.Integrations.Models;
using Deal.Grpc.Ml;
using Deal.Infrastructure.Integrations.Abstractions;
using Deal.Infrastructure.Integrations.Models;
using Deal.Infrastructure.Integrations.Resilience;
using Deal.Modules.Kanban.Application.Abstractions;
using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Settings.Application.Abstractions;
@@ -16,9 +17,6 @@ using Microsoft.Extensions.Logging;
namespace Deal.Infrastructure.Integrations.Services;
/// <summary>
/// gRPC-адаптер порта IMlClient к автономному ml-service.
/// </summary>
public sealed class GrpcMlClient : IMlClient, IMlTrainClient
{
/// <summary>
@@ -97,7 +95,6 @@ public sealed class GrpcMlClient : IMlClient, IMlTrainClient
_logger = logger;
}
/// <inheritdoc />
async Task<MlStatusResponseDto> IMlClient.StatusAsync(CancellationToken ct)
{
TenantId tenantId = RequireTenant();
@@ -122,16 +119,17 @@ public sealed class GrpcMlClient : IMlClient, IMlTrainClient
return new MlStatusResponseDto(Enabled: enabled, Service: snapshot.Service, Reachable: snapshot.Reachable, Stats: stats);
}
/// <inheritdoc />
async Task<MlPredictResultDto> IMlClient.PredictAsync(string text, CancellationToken ct)
{
TenantId tenantId = RequireTenant();
try
{
MlService.MlServiceClient client = _connection.CreateClient();
PredictReply reply = await client.PredictAsync(
new PredictRequest { Text = text ?? string.Empty },
CallOptions(tenantId.Value, TimeSpan.FromSeconds(PredictDeadlineSeconds), ct));
PredictReply reply = await GrpcRetry.ExecuteAsync(
token => client.PredictAsync(
new PredictRequest { Text = text ?? string.Empty },
CallOptions(tenantId.Value, TimeSpan.FromSeconds(PredictDeadlineSeconds), token)).ResponseAsync,
ct);
await _usageRecorder.AddEstimatedAsync(text, TokenUsageSources.Local, TokenUsageSources.Ml, ct);
return MapPredict(reply);
@@ -143,7 +141,6 @@ public sealed class GrpcMlClient : IMlClient, IMlTrainClient
}
}
/// <inheritdoc />
async Task<MlResetResultDto> IMlClient.ResetAsync(CancellationToken ct)
{
TenantId tenantId = RequireTenant();
@@ -171,7 +168,6 @@ public sealed class GrpcMlClient : IMlClient, IMlTrainClient
return new MlResetResultDto(Ok: true, Error: null);
}
/// <inheritdoc />
async Task IMlClient.PushAsync(
string text,
string label,
@@ -181,7 +177,6 @@ public sealed class GrpcMlClient : IMlClient, IMlTrainClient
await MlOutboxQueue.PushAsync(_learningStore, text, label, delta, ct);
}
/// <inheritdoc />
async Task<int> IMlTrainClient.TrainBatchAsync(IReadOnlyList<MlOutboxEntryDto> items, CancellationToken ct)
{
TenantId tenantId = RequireTenant();
@@ -228,9 +223,11 @@ public sealed class GrpcMlClient : IMlClient, IMlTrainClient
try
{
MlService.MlServiceClient client = _connection.CreateClient();
StatusReply reply = await client.StatusAsync(
new StatusRequest(),
CallOptions(tenantId.Value, TimeSpan.FromSeconds(StatusDeadlineSeconds), ct));
StatusReply reply = await GrpcRetry.ExecuteAsync(
token => client.StatusAsync(
new StatusRequest(),
CallOptions(tenantId.Value, TimeSpan.FromSeconds(StatusDeadlineSeconds), token)).ResponseAsync,
ct);
MlServiceStatusDto service = MapStatus(reply);
_statusCache.Set(tenantId.Value, service, reachable: true);
return _statusCache.TryGet(tenantId.Value, out MlStatusCache.Snapshot updated)
@@ -9,9 +9,6 @@ using Microsoft.Extensions.Logging;
namespace Deal.Infrastructure.Integrations.Services;
/// <summary>
/// gRPC-адаптер порта <see cref="ITelegramGateway"/> к автономному telegram-service.
/// </summary>
public sealed class GrpcTelegramClient : ITelegramGateway
{
/// <summary>
@@ -58,7 +55,6 @@ public sealed class GrpcTelegramClient : ITelegramGateway
_logger = logger;
}
/// <inheritdoc />
async Task<TelegramAccountStatusDto> ITelegramGateway.StatusAsync(CancellationToken ct)
{
TenantId tenantId = RequireTenant();
@@ -81,7 +77,6 @@ public sealed class GrpcTelegramClient : ITelegramGateway
}
}
/// <inheritdoc />
async Task<TelegramAuthResultDto> ITelegramGateway.StartPhoneAsync(
string phone,
int apiId,
@@ -104,7 +99,6 @@ public sealed class GrpcTelegramClient : ITelegramGateway
}
}
/// <inheritdoc />
async Task<TelegramAuthResultDto> ITelegramGateway.StartQrAsync(
int apiId,
string apiHash,
@@ -128,7 +122,6 @@ public sealed class GrpcTelegramClient : ITelegramGateway
}
}
/// <inheritdoc />
async Task<string> ITelegramGateway.SendCodeAsync(string code, CancellationToken ct)
{
TenantId tenantId = RequireTenant();
@@ -146,7 +139,6 @@ public sealed class GrpcTelegramClient : ITelegramGateway
}
}
/// <inheritdoc />
async Task<string> ITelegramGateway.SendPasswordAsync(string password, CancellationToken ct)
{
TenantId tenantId = RequireTenant();
@@ -164,7 +156,6 @@ public sealed class GrpcTelegramClient : ITelegramGateway
}
}
/// <inheritdoc />
async Task ITelegramGateway.LogoutAsync(CancellationToken ct)
{
TenantId tenantId = RequireTenant();
@@ -180,7 +171,6 @@ public sealed class GrpcTelegramClient : ITelegramGateway
}
}
/// <inheritdoc />
async Task<IReadOnlyList<TelegramDialogEntryDto>> ITelegramGateway.RefreshDialogsAsync(CancellationToken ct)
{
TenantId tenantId = RequireTenant();
@@ -197,7 +187,6 @@ public sealed class GrpcTelegramClient : ITelegramGateway
}
}
/// <inheritdoc />
async Task ITelegramGateway.SetMonitorAsync(
string dialogId,
bool enabled,
@@ -217,7 +206,6 @@ public sealed class GrpcTelegramClient : ITelegramGateway
}
}
/// <inheritdoc />
async Task ITelegramGateway.SetMonitorAllAsync(bool enabled, CancellationToken ct)
{
TenantId tenantId = RequireTenant();
@@ -234,7 +222,6 @@ public sealed class GrpcTelegramClient : ITelegramGateway
}
}
/// <inheritdoc />
async Task<int> ITelegramGateway.BackfillAsync(
string dialogId,
bool force,
@@ -255,7 +242,6 @@ public sealed class GrpcTelegramClient : ITelegramGateway
}
}
/// <inheritdoc />
async Task<IReadOnlyList<TelegramRecentMessageDto>> ITelegramGateway.ReadRecentAsync(
string dialogId,
int limit,
@@ -278,7 +264,6 @@ public sealed class GrpcTelegramClient : ITelegramGateway
}
}
/// <inheritdoc />
async Task<TelegramSourceContentDto> ITelegramGateway.ReadSourceAsync(
string dialogId,
long msgId,
@@ -302,7 +287,6 @@ public sealed class GrpcTelegramClient : ITelegramGateway
}
}
/// <inheritdoc />
async Task<IReadOnlyList<TelegramDialogEntryDto>> ITelegramGateway.SearchAsync(
string query,
int limit,
@@ -323,7 +307,6 @@ public sealed class GrpcTelegramClient : ITelegramGateway
}
}
/// <inheritdoc />
async Task<TelegramChannelInfoDto> ITelegramGateway.InfoAsync(string dialogId, CancellationToken ct)
{
TenantId tenantId = RequireTenant();
@@ -349,7 +332,6 @@ public sealed class GrpcTelegramClient : ITelegramGateway
}
}
/// <inheritdoc />
async Task<TelegramEvalReadDto> ITelegramGateway.ReadForEvalAsync(
string dialogId,
int limit,
@@ -380,7 +362,6 @@ public sealed class GrpcTelegramClient : ITelegramGateway
}
}
/// <inheritdoc />
async Task ITelegramGateway.JoinAsync(string username, CancellationToken ct)
{
TenantId tenantId = RequireTenant();
@@ -397,7 +378,6 @@ public sealed class GrpcTelegramClient : ITelegramGateway
}
}
/// <inheritdoc />
async Task ITelegramGateway.LeaveAsync(string dialogId, CancellationToken ct)
{
TenantId tenantId = RequireTenant();
@@ -3,20 +3,15 @@ using Deal.Contracts.Integrations.Models;
namespace Deal.Infrastructure.Integrations.Services;
/// <summary>
/// Локальная реализация <see cref="IAiTools"/> без внешнего ИИ-сервиса.
/// </summary>
public sealed class LocalAiTools : IAiTools
{
// Сообщение исключения методов (локальный режим = ai-service не подключён).
private const string NotSupportedMessage =
"ИИ-инструменты доступны только при подключённом ai-service (Services:Ai:UseLocal=false).";
/// <inheritdoc />
Task<AiGenerateKeywordsResultDto> IAiTools.GenerateKeywordsAsync(string description, CancellationToken ct)
=> throw new NotSupportedException(NotSupportedMessage);
/// <inheritdoc />
Task<AiEvaluateFitResultDto> IAiTools.EvaluateFitAsync(
string text,
string description,
@@ -3,21 +3,16 @@ using Deal.Contracts.Integrations.Models;
namespace Deal.Infrastructure.Integrations.Services;
/// <summary>
/// Локальная заглушка <see cref="ITelegramGateway"/> без telegram-service.
/// </summary>
public sealed class LocalTelegramGateway : ITelegramGateway
{
// Фаза idle-формы (аккаунт не подключён — сервиса нет).
private const string IdlePhase = "idle";
/// <inheritdoc />
Task<TelegramAccountStatusDto> ITelegramGateway.StatusAsync(CancellationToken ct)
{
return Task.FromResult(new TelegramAccountStatusDto(IdlePhase, false, false, string.Empty, null, null));
}
/// <inheritdoc />
Task<TelegramAuthResultDto> ITelegramGateway.StartPhoneAsync(
string phone,
int apiId,
@@ -25,76 +20,61 @@ public sealed class LocalTelegramGateway : ITelegramGateway
CancellationToken ct)
=> Task.FromResult(new TelegramAuthResultDto(IdlePhase, null));
/// <inheritdoc />
Task<TelegramAuthResultDto> ITelegramGateway.StartQrAsync(
int apiId,
string apiHash,
CancellationToken ct)
=> Task.FromResult(new TelegramAuthResultDto(IdlePhase, null));
/// <inheritdoc />
public Task<string> SendCodeAsync(string code, CancellationToken ct) => Task.FromResult(IdlePhase);
Task<string> ITelegramGateway.SendCodeAsync(string code, CancellationToken ct) => Task.FromResult(IdlePhase);
/// <inheritdoc />
public Task<string> SendPasswordAsync(string password, CancellationToken ct) => Task.FromResult(IdlePhase);
Task<string> ITelegramGateway.SendPasswordAsync(string password, CancellationToken ct) => Task.FromResult(IdlePhase);
/// <inheritdoc />
Task ITelegramGateway.LogoutAsync(CancellationToken ct) => Task.CompletedTask;
/// <inheritdoc />
Task<IReadOnlyList<TelegramDialogEntryDto>> ITelegramGateway.RefreshDialogsAsync(CancellationToken ct)
=> Task.FromResult<IReadOnlyList<TelegramDialogEntryDto>>([]);
/// <inheritdoc />
Task ITelegramGateway.SetMonitorAsync(
string dialogId,
bool enabled,
CancellationToken ct) => Task.CompletedTask;
/// <inheritdoc />
Task ITelegramGateway.SetMonitorAllAsync(bool enabled, CancellationToken ct) => Task.CompletedTask;
/// <inheritdoc />
public Task<int> BackfillAsync(
Task<int> ITelegramGateway.BackfillAsync(
string dialogId,
bool force,
CancellationToken ct) => Task.FromResult(0);
/// <inheritdoc />
Task<IReadOnlyList<TelegramRecentMessageDto>> ITelegramGateway.ReadRecentAsync(
string dialogId,
int limit,
CancellationToken ct)
=> Task.FromResult<IReadOnlyList<TelegramRecentMessageDto>>([]);
/// <inheritdoc />
Task<TelegramSourceContentDto> ITelegramGateway.ReadSourceAsync(
string dialogId,
long msgId,
CancellationToken ct)
=> Task.FromResult(new TelegramSourceContentDto(false, null, null));
/// <inheritdoc />
Task<IReadOnlyList<TelegramDialogEntryDto>> ITelegramGateway.SearchAsync(
string query,
int limit,
CancellationToken ct)
=> Task.FromResult<IReadOnlyList<TelegramDialogEntryDto>>([]);
/// <inheritdoc />
Task<TelegramChannelInfoDto> ITelegramGateway.InfoAsync(string dialogId, CancellationToken ct)
=> Task.FromResult(new TelegramChannelInfoDto(dialogId, string.Empty, string.Empty, string.Empty, SourceDefaults.DefaultHue, null, false));
/// <inheritdoc />
Task<TelegramEvalReadDto> ITelegramGateway.ReadForEvalAsync(
string dialogId,
int limit,
CancellationToken ct)
=> Task.FromResult(new TelegramEvalReadDto(false, "no_history", []));
/// <inheritdoc />
Task ITelegramGateway.JoinAsync(string username, CancellationToken ct) => Task.CompletedTask;
/// <inheritdoc />
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();
}
@@ -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.Infrastructure.Integrations.Storage.Abstractions;
using Deal.Infrastructure.Integrations.Storage.Extensions;
using Deal.Infrastructure.Integrations.Storage.Options;
using Microsoft.Extensions.Configuration;
@@ -48,16 +49,12 @@ public static class FileStorageRegistrar
ArgumentNullException.ThrowIfNull(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);
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;
}
@@ -3,9 +3,6 @@ using Deal.Contracts.Integrations.Models;
namespace Deal.Infrastructure.Integrations.Storage.Services;
/// <summary>
/// Локальное файловое хранилище вложений — каталог на диске.
/// </summary>
public sealed class LocalFileStorage : IFileStorage
{
// Размер буфера чтения при скачивании (async FileStream).
@@ -31,7 +28,6 @@ public sealed class LocalFileStorage : IFileStorage
/// <returns>Строка вида <c>LocalFileStorage (root: …)</c>.</returns>
public override string ToString() => $"LocalFileStorage (root: {_rootPath})";
/// <inheritdoc />
async Task<string> IFileStorage.PutAsync(
string objectKey,
Stream content,
@@ -55,7 +51,6 @@ public sealed class LocalFileStorage : IFileStorage
return objectKey;
}
/// <inheritdoc />
Task<Stream?> IFileStorage.GetAsync(string objectKey, CancellationToken ct)
{
string path = ResolvePath(objectKey);
@@ -68,7 +63,6 @@ public sealed class LocalFileStorage : IFileStorage
return Task.FromResult<Stream?>(stream);
}
/// <inheritdoc />
Task<FileMeta?> IFileStorage.StatAsync(string objectKey, CancellationToken ct)
{
string path = ResolvePath(objectKey);
@@ -81,7 +75,6 @@ public sealed class LocalFileStorage : IFileStorage
return Task.FromResult<FileMeta?>(new FileMeta(objectKey, info.Length, string.Empty));
}
/// <inheritdoc />
Task IFileStorage.DeleteAsync(string objectKey, CancellationToken ct)
{
string path = ResolvePath(objectKey);
@@ -9,9 +9,6 @@ using Minio.Exceptions;
namespace Deal.Infrastructure.Integrations.Storage.Services;
/// <summary>
/// Хранилище вложений на MinIO
/// </summary>
public sealed class MinioFileStorage : IFileStorage
{
private const string DefaultContentType = "application/octet-stream";
@@ -66,7 +63,6 @@ public sealed class MinioFileStorage : IFileStorage
/// <returns>Строка вида <c>MinioFileStorage (endpoint: …; bucket: …)</c>.</returns>
public override string ToString() => $"MinioFileStorage (endpoint: {_endpoint}; bucket: {_bucket})";
/// <inheritdoc />
async Task<string> IFileStorage.PutAsync(
string objectKey,
Stream content,
@@ -98,7 +94,6 @@ public sealed class MinioFileStorage : IFileStorage
return objectKey;
}
/// <inheritdoc />
async Task<Stream?> IFileStorage.GetAsync(string objectKey, CancellationToken ct)
{
MemoryStream buffer = new();
@@ -128,7 +123,6 @@ public sealed class MinioFileStorage : IFileStorage
return buffer;
}
/// <inheritdoc />
async Task<FileMeta?> IFileStorage.StatAsync(string objectKey, CancellationToken ct)
{
try
@@ -144,7 +138,6 @@ public sealed class MinioFileStorage : IFileStorage
}
}
/// <inheritdoc />
async Task IFileStorage.DeleteAsync(string objectKey, CancellationToken ct)
{
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();
}
@@ -5,9 +5,6 @@ using Deal.Modules.Discovery.Application.Models;
namespace Deal.Infrastructure.Persistence.Repositories;
/// <summary>
/// EF-адаптер хранилища Discovery
/// </summary>
public sealed partial class DiscoveryStore : IDiscoveryStore
{
private readonly TenantDbContext _dbContext;
@@ -9,9 +9,6 @@ using Microsoft.EntityFrameworkCore;
namespace Deal.Infrastructure.Persistence.Repositories;
/// <summary>
/// EF-адаптер хранилища карточек и контейнеров
/// </summary>
public sealed partial class KanbanStore : ICardStore
{
private readonly TenantDbContext _dbContext;
@@ -6,9 +6,6 @@ using Microsoft.EntityFrameworkCore;
namespace Deal.Infrastructure.Persistence.Repositories;
/// <summary>
/// EF-адаптер хранилища лимитов ИИ-бюджета
/// </summary>
public sealed class TenantLimitStore : ITenantLimitStore
{
private readonly DealDbContext _dbContext;
@@ -58,7 +55,6 @@ public sealed class TenantLimitStore : ITenantLimitStore
_utcNow = utcNow;
}
/// <inheritdoc />
async Task<TenantLimitDto> ITenantLimitStore.GetOrCreateAsync(
Guid tenantId,
CancellationToken ct,
@@ -68,7 +64,6 @@ public sealed class TenantLimitStore : ITenantLimitStore
return ToLimitDto(entity);
}
/// <inheritdoc />
async Task<BudgetStateDto> ITenantLimitStore.GetStateAsync(Guid tenantId, CancellationToken ct)
{
TenantLimitEntity entity = await LoadOrCreateAsync(tenantId, _defaults, ct);
@@ -76,7 +71,6 @@ public sealed class TenantLimitStore : ITenantLimitStore
return await ToStateDtoAsync(entity, ct);
}
/// <inheritdoc />
async Task<BudgetStateDto> ITenantLimitStore.AddUsageAsync(
Guid tenantId,
long tokens,
@@ -109,7 +103,6 @@ public sealed class TenantLimitStore : ITenantLimitStore
return await ToStateDtoAsync(entity, ct);
}
/// <inheritdoc />
async Task<BudgetStateDto> ITenantLimitStore.UpdateBudgetAsync(
Guid tenantId,
long budgetTokens,
@@ -132,7 +125,6 @@ public sealed class TenantLimitStore : ITenantLimitStore
return await ToStateDtoAsync(entity, ct);
}
/// <inheritdoc />
async Task<bool> ITenantLimitStore.TryMarkWarnedAsync(Guid tenantId, CancellationToken ct)
{
TenantLimitEntity entity = await LoadOrCreateAsync(tenantId, _defaults, ct);
@@ -148,7 +140,6 @@ public sealed class TenantLimitStore : ITenantLimitStore
return true;
}
/// <inheritdoc />
async Task<bool> ITenantLimitStore.TryMarkNotifiedExhaustedAsync(Guid tenantId, CancellationToken ct)
{
TenantLimitEntity entity = await LoadOrCreateAsync(tenantId, _defaults, ct);
@@ -219,7 +210,6 @@ public sealed class TenantLimitStore : ITenantLimitStore
return true;
}
/// <inheritdoc />
async Task<int> ITenantLimitStore.ResetExpiredPeriodsAsync(DateTimeOffset now, CancellationToken ct)
{
// Трогаем только строки с накоплениями (расход/флаги): строки без накоплений чистить нечего.
@@ -0,0 +1,22 @@
using Deal.Infrastructure.Persistence.Abstractions;
using Deal.Infrastructure.Persistence.Repositories;
using Deal.Modules.Tenants.Application.Abstractions;
using Deal.Modules.Tenants.Application.Models;
namespace Deal.Infrastructure.Persistence.Services;
public sealed class TenantLimitStoreFactory : ITenantLimitStoreFactory
{
private readonly DealDbContext _dbContext;
private readonly TokenLimitDefaults _defaults;
public TenantLimitStoreFactory(DealDbContext dbContext, TokenLimitDefaults defaults)
{
ArgumentNullException.ThrowIfNull(dbContext);
ArgumentNullException.ThrowIfNull(defaults);
_dbContext = dbContext;
_defaults = defaults;
}
ITenantLimitStore ITenantLimitStoreFactory.Create() => new TenantLimitStore(_dbContext, _defaults);
}
@@ -0,0 +1,15 @@
using Deal.Modules.Settings.Application.Abstractions;
namespace Deal.Infrastructure.Security.Abstractions;
/// <summary>
/// Фабрика шифра секретов тенанта
/// </summary>
public interface ISecretCipherFactory
{
/// <summary>
/// Создаёт шифр секретов
/// </summary>
/// <returns>Готовый порт симметричного шифрования.</returns>
public ISecretCipher Create();
}
@@ -4,9 +4,6 @@ using Deal.Modules.Settings.Application.Abstractions;
namespace Deal.Infrastructure.Security;
/// <summary>
/// AES-256-GCM-шифр секретов
/// </summary>
public sealed class AesGcmSecretCipher : ISecretCipher
{
// Префикс зашифрованного значения (маркер формата в хранилище).
@@ -39,7 +36,6 @@ public sealed class AesGcmSecretCipher : ISecretCipher
_key = key;
}
/// <inheritdoc />
string ISecretCipher.Encrypt(string plainText)
{
if (string.IsNullOrEmpty(plainText))
@@ -65,7 +61,6 @@ public sealed class AesGcmSecretCipher : ISecretCipher
return EncryptedPrefix + Convert.ToBase64String(payload);
}
/// <inheritdoc />
string ISecretCipher.Decrypt(string cipherText)
{
if (string.IsNullOrEmpty(cipherText) || !cipherText.StartsWith(EncryptedPrefix, StringComparison.Ordinal))
@@ -0,0 +1,17 @@
using Deal.Infrastructure.Security.Abstractions;
using Deal.Modules.Settings.Application.Abstractions;
namespace Deal.Infrastructure.Security.Services;
public sealed class SecretCipherFactory : ISecretCipherFactory
{
private readonly EncryptionKeyProvider _keyProvider;
public SecretCipherFactory(EncryptionKeyProvider keyProvider)
{
ArgumentNullException.ThrowIfNull(keyProvider);
_keyProvider = keyProvider;
}
ISecretCipher ISecretCipherFactory.Create() => new AesGcmSecretCipher(_keyProvider.GetKey());
}
@@ -5,8 +5,12 @@ using Deal.Infrastructure.Integrations.Options;
using Deal.Infrastructure.Integrations.Services;
using Deal.Infrastructure.Integrations.Sources;
using Deal.Infrastructure.Persistence;
using Deal.Infrastructure.Persistence.Abstractions;
using Deal.Infrastructure.Persistence.Repositories;
using Deal.Infrastructure.Persistence.Services;
using Deal.Infrastructure.Security;
using Deal.Infrastructure.Security.Abstractions;
using Deal.Infrastructure.Security.Services;
using Deal.Infrastructure.Services;
using Deal.Infrastructure.Tenancy;
using Deal.Modules.Cards.Application.Abstractions;
@@ -20,7 +24,6 @@ using Deal.Modules.Tenants.Application.Abstractions;
using Deal.Modules.Tenants.Application.Models;
using Deal.SharedKernel.Tenants.Abstractions;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace Deal.Infrastructure;
@@ -46,9 +49,9 @@ public static class ServiceCollectionExtensions
services.AddScoped<IInviteStore, InviteStore>();
services.AddScoped<ITenantLimitStore>(provider => new TenantLimitStore(
provider.GetRequiredService<DealDbContext>(),
tenantLimitDefaults ?? TokenBudgetDefaults.Default));
services.AddSingleton(tenantLimitDefaults ?? TokenBudgetDefaults.Default);
services.AddScoped<ITenantLimitStoreFactory, TenantLimitStoreFactory>();
services.AddScoped<ITenantLimitStore>(provider => provider.GetRequiredService<ITenantLimitStoreFactory>().Create());
services.AddScoped<IRateLimitCounterStore, RateLimitCounterStore>();
@@ -117,18 +120,11 @@ public static class ServiceCollectionExtensions
services.AddScoped<AiProviderConfigBuilder>();
services.AddScoped<GrpcAiClassifier>();
services.AddScoped<LocalAiClassifier>();
services.AddScoped<IAiClassifier>(provider => new BudgetedAiClassifier(
provider.GetRequiredService<GrpcAiClassifier>(),
provider.GetRequiredService<LocalAiClassifier>(),
provider.GetRequiredService<ITenantLimitStore>(),
provider.GetRequiredService<ITenantContext>(),
provider.GetRequiredService<ILogger<BudgetedAiClassifier>>()));
services.AddScoped<IAiClassifierFactory, AiClassifierFactory>();
services.AddScoped<IAiClassifier>(provider => provider.GetRequiredService<IAiClassifierFactory>().Create());
services.AddScoped<GrpcAiTools>();
services.AddScoped<IAiTools>(provider => new BudgetedAiTools(
provider.GetRequiredService<GrpcAiTools>(),
provider.GetRequiredService<ITenantLimitStore>(),
provider.GetRequiredService<ITenantContext>(),
provider.GetRequiredService<ILogger<BudgetedAiTools>>()));
services.AddScoped<IAiToolsFactory, AiToolsFactory>();
services.AddScoped<IAiTools>(provider => provider.GetRequiredService<IAiToolsFactory>().Create());
}
if (telegramOptions.UseLocal)
@@ -152,8 +148,9 @@ public static class ServiceCollectionExtensions
public static IServiceCollection AddDealSecurity(this IServiceCollection services, string contentRootPath)
{
EncryptionKeyProvider keyProvider = new(contentRootPath);
byte[] key = keyProvider.GetKey();
services.AddSingleton<ISecretCipher>(new AesGcmSecretCipher(key));
services.AddSingleton(keyProvider);
services.AddSingleton<ISecretCipherFactory, SecretCipherFactory>();
services.AddSingleton<ISecretCipher>(provider => provider.GetRequiredService<ISecretCipherFactory>().Create());
return services;
}
}
@@ -21,8 +21,7 @@ public static class DiscoveryModuleRegistrar
services.AddScoped<DiscoveryBlacklistService>();
services.AddScoped<DiscoveryLogService>();
services.AddSingleton<DiscoverySearchErrorCounter>();
services.AddSingleton<IDiscoverySearchErrorCounter>(sp => sp.GetRequiredService<DiscoverySearchErrorCounter>());
services.AddSingleton<IDiscoverySearchErrorCounter, DiscoverySearchErrorCounter>();
services.AddScoped<DiscoveryEvaluator>();
services.AddScoped<IDiscoveryPacer, DiscoveryPacer>();
services.AddScoped(sp => new DiscoveryBanGuard(
@@ -20,7 +20,7 @@ public sealed class DiscoveryBanGuard
/// </summary>
/// <param name="store">Хранилище Discovery (счётчик DiscLog за сутки).</param>
/// <param name="settings">KV-настройки тенанта (discJoinLimit/discFloodDay/discPaused).</param>
/// <param name="utcNow">Источник текущего времени (UTC; тесты передают фиксированные «часы», эталон FakeDiscoveryStore). По умолчанию — <see cref="DateTimeOffset.UtcNow"/>.</param>
/// <param name="utcNow">Источник текущего времени (UTC). По умолчанию — <see cref="DateTimeOffset.UtcNow"/>.</param>
public DiscoveryBanGuard(
IDiscoveryStore store,
ISettingsStore settings,
@@ -3,9 +3,6 @@ using Deal.Modules.Discovery.Application.Abstractions;
namespace Deal.Modules.Discovery.Application.Services;
/// <summary>
/// Потокобезопасная реализация <see cref="IDiscoverySearchErrorCounter"/>
/// </summary>
public sealed class DiscoverySearchErrorCounter : IDiscoverySearchErrorCounter
{
/// <summary>
@@ -43,7 +40,6 @@ public sealed class DiscoverySearchErrorCounter : IDiscoverySearchErrorCounter
_utcNow = utcNow;
}
/// <inheritdoc />
int IDiscoverySearchErrorCounter.Next(string taskId)
{
EvictExpired();
@@ -55,7 +51,6 @@ public sealed class DiscoverySearchErrorCounter : IDiscoverySearchErrorCounter
return fresh.Count;
}
/// <inheritdoc />
void IDiscoverySearchErrorCounter.Reset(string taskId)
{
EvictExpired();
@@ -4,6 +4,7 @@ using Deal.Modules.Discovery.Application.Extensions;
using Deal.Modules.Discovery.Application.Models;
using Deal.Modules.Settings.Application.Abstractions;
using Deal.Modules.Settings.Application.Models;
using Deal.SharedKernel.Errors;
namespace Deal.Modules.Discovery.Application.Services;
@@ -12,6 +13,9 @@ namespace Deal.Modules.Discovery.Application.Services;
/// </summary>
public sealed class DiscoveryTasksService(IDiscoveryStore store, DiscoveryPlanGuard planGuard, ISettingsStore settings)
{
// Имя сущности для текста ошибки «не найдено».
private const string TaskEntityName = "Задача поиска";
/// <summary>
/// 400 create: пустое название после Trim.
/// </summary>
@@ -35,10 +39,12 @@ public sealed class DiscoveryTasksService(IDiscoveryStore store, DiscoveryPlanGu
/// Одна задача по id.
/// </summary>
/// <param name="taskId">Id задачи (<c>dt_...</c>).</param>
/// <returns>Задача или null (404 «Задача не найдена» у эндпоинта).</returns>
public Task<DiscoveryTaskDto?> GetAsync(string taskId, CancellationToken ct)
/// <returns>Задача.</returns>
/// <exception cref="NotFoundException">Задача не найдена.</exception>
public async Task<DiscoveryTaskDto> GetAsync(string taskId, CancellationToken ct)
{
return store.GetTaskAsync(taskId, ct);
return await store.GetTaskAsync(taskId, ct).ConfigureAwait(false)
?? throw new NotFoundException(TaskEntityName, taskId);
}
/// <summary>
@@ -102,18 +108,16 @@ public sealed class DiscoveryTasksService(IDiscoveryStore store, DiscoveryPlanGu
/// </summary>
/// <param name="taskId">Id задачи (<c>dt_...</c>).</param>
/// <param name="patch">Изменяемые поля (null — не меняется).</param>
/// <returns>Обновлённая задача либо null (404 «Задача не найдена»).</returns>
/// <returns>Обновлённая задача.</returns>
/// <exception cref="NotFoundException">Задача не найдена.</exception>
/// <exception cref="DiscoveryValidationException">Новый план вне границ / бюджет исчерпан.</exception>
public async Task<DiscoveryTaskDto?> PatchAsync(
public async Task<DiscoveryTaskDto> PatchAsync(
string taskId,
DiscoveryTaskPatch patch,
CancellationToken ct)
{
DiscoveryTaskDto? current = await store.GetTaskAsync(taskId, ct).ConfigureAwait(false);
if (current is null)
{
return null;
}
DiscoveryTaskDto current = await store.GetTaskAsync(taskId, ct).ConfigureAwait(false)
?? throw new NotFoundException(TaskEntityName, taskId);
DiscoveryTaskPatch normalized = NormalizeTaskPatch(patch);
if (normalized.PlanJoins is int newPlan)
@@ -131,38 +135,34 @@ public sealed class DiscoveryTasksService(IDiscoveryStore store, DiscoveryPlanGu
}
await store.PatchTaskAsync(taskId, normalized, ct).ConfigureAwait(false);
return await store.GetTaskAsync(taskId, ct).ConfigureAwait(false);
return await store.GetTaskAsync(taskId, ct).ConfigureAwait(false)
?? throw new NotFoundException(TaskEntityName, taskId);
}
/// <summary>
/// Удаляет задачу вместе с кандидатами и логом.
/// </summary>
/// <param name="taskId">Id задачи (<c>dt_...</c>).</param>
/// <returns>True — задача удалена; false — строки нет (404 у эндпоинта).</returns>
public async Task<bool> DeleteAsync(string taskId, CancellationToken ct)
/// <exception cref="NotFoundException">Задача не найдена.</exception>
public async Task DeleteAsync(string taskId, CancellationToken ct)
{
DiscoveryTaskDto? current = await store.GetTaskAsync(taskId, ct).ConfigureAwait(false);
if (current is null)
{
return false;
}
DiscoveryTaskDto current = await store.GetTaskAsync(taskId, ct).ConfigureAwait(false)
?? throw new NotFoundException(TaskEntityName, taskId);
return await store.DeleteTaskAsync(taskId, ct).ConfigureAwait(false);
await store.DeleteTaskAsync(current.Id, ct).ConfigureAwait(false);
}
/// <summary>
/// Запускает поиск
/// </summary>
/// <param name="taskId">Id задачи (<c>dt_...</c>).</param>
/// <returns>Задача в running либо null (404).</returns>
/// <returns>Задача в running.</returns>
/// <exception cref="NotFoundException">Задача не найдена.</exception>
/// <exception cref="DiscoveryValidationException">Ключевых слов нет.</exception>
public async Task<DiscoveryTaskDto?> StartAsync(string taskId, CancellationToken ct)
public async Task<DiscoveryTaskDto> StartAsync(string taskId, CancellationToken ct)
{
DiscoveryTaskDto? current = await store.GetTaskAsync(taskId, ct).ConfigureAwait(false);
if (current is null)
{
return null;
}
DiscoveryTaskDto current = await store.GetTaskAsync(taskId, ct).ConfigureAwait(false)
?? throw new NotFoundException(TaskEntityName, taskId);
if (current.Keywords.Count == 0)
{
@@ -171,24 +171,24 @@ public sealed class DiscoveryTasksService(IDiscoveryStore store, DiscoveryPlanGu
bool resetProgress = DiscoveryTaskStatuses.IsFinished(current.Status);
await store.SetTaskRunningAsync(taskId, resetProgress, ct).ConfigureAwait(false);
return await store.GetTaskAsync(taskId, ct).ConfigureAwait(false);
return await store.GetTaskAsync(taskId, ct).ConfigureAwait(false)
?? throw new NotFoundException(TaskEntityName, taskId);
}
/// <summary>
/// Ставит задачу на паузу.
/// </summary>
/// <param name="taskId">Id задачи (<c>dt_...</c>).</param>
/// <returns>Задача в paused либо null (404).</returns>
public async Task<DiscoveryTaskDto?> PauseAsync(string taskId, CancellationToken ct)
/// <returns>Задача в paused.</returns>
/// <exception cref="NotFoundException">Задача не найдена.</exception>
public async Task<DiscoveryTaskDto> PauseAsync(string taskId, CancellationToken ct)
{
DiscoveryTaskDto? current = await store.GetTaskAsync(taskId, ct).ConfigureAwait(false);
if (current is null)
{
return null;
}
DiscoveryTaskDto current = await store.GetTaskAsync(taskId, ct).ConfigureAwait(false)
?? throw new NotFoundException(TaskEntityName, taskId);
await store.SetTaskPausedAsync(taskId, ct).ConfigureAwait(false);
return await store.GetTaskAsync(taskId, ct).ConfigureAwait(false);
await store.SetTaskPausedAsync(current.Id, ct).ConfigureAwait(false);
return await store.GetTaskAsync(taskId, ct).ConfigureAwait(false)
?? throw new NotFoundException(TaskEntityName, taskId);
}
/// <summary>
@@ -31,7 +31,7 @@ public sealed partial class DiscoveryWorkerService
/// <param name="banGuard">Квоты дня/flood/стоп-кран (DiscoveryBanGuard).</param>
/// <param name="pacer">Паузы между авто-вступлениями (интерфейс — фейк в тестах).</param>
/// <param name="gateway">Гейт telegram-service (Search/Info/ReadForEval/Join/SetMonitor/Backfill).</param>
/// <param name="searchErrors">Singleton-счётчик ошибок ключей поиска; null — локальный (на инстанс воркера).</param>
/// <param name="searchErrors">Singleton-счётчик ошибок ключей поиска.</param>
public DiscoveryWorkerService(
IDiscoveryStore store,
DiscoveryTasksService tasks,
@@ -41,7 +41,7 @@ public sealed partial class DiscoveryWorkerService
DiscoveryBanGuard banGuard,
IDiscoveryPacer pacer,
ITelegramGateway gateway,
IDiscoverySearchErrorCounter? searchErrors = null)
IDiscoverySearchErrorCounter searchErrors)
{
ArgumentNullException.ThrowIfNull(store);
ArgumentNullException.ThrowIfNull(tasks);
@@ -51,6 +51,7 @@ public sealed partial class DiscoveryWorkerService
ArgumentNullException.ThrowIfNull(banGuard);
ArgumentNullException.ThrowIfNull(pacer);
ArgumentNullException.ThrowIfNull(gateway);
ArgumentNullException.ThrowIfNull(searchErrors);
_store = store;
_tasks = tasks;
_candidates = candidates;
@@ -59,7 +60,7 @@ public sealed partial class DiscoveryWorkerService
_banGuard = banGuard;
_pacer = pacer;
_gateway = gateway;
_searchErrors = searchErrors ?? new DiscoverySearchErrorCounter();
_searchErrors = searchErrors;
}
/// <summary>
@@ -1,5 +1,6 @@
using Deal.Contracts.Integrations.Abstractions;
using Deal.Modules.Kanban.Application.Models;
using Deal.SharedKernel.Errors;
namespace Deal.Modules.Kanban.Application.Services;
@@ -18,6 +19,10 @@ public sealed partial class CardsService
private const string DefaultAttachmentName = "file";
// Имена сущностей для текстов ошибок «не найдено».
private const string CardEntityName = "Карточка";
private const string CardFileEntityName = "Файл карточки";
/// <summary>
/// Добавляет файл карточке
/// </summary>
@@ -26,8 +31,9 @@ public sealed partial class CardsService
/// <param name="contentType">MIME-тип загрузки (может быть null/пустым — детект по расширению).</param>
/// <param name="content">Поток содержимого файла (читается хранилищем с позиции 0).</param>
/// <param name="size">Длина содержимого в байтах (пишется в метаданные записи).</param>
/// <returns>Метаданные добавленного файла или null — карточки нет (404).</returns>
public async Task<CardFileDto?> AddFileAsync(
/// <returns>Метаданные добавленного файла.</returns>
/// <exception cref="NotFoundException">Карточка не найдена.</exception>
public async Task<CardFileDto> AddFileAsync(
string cardId,
string fileName,
string? contentType,
@@ -37,11 +43,8 @@ public sealed partial class CardsService
{
ArgumentNullException.ThrowIfNull(content);
CardDto? card = await _store.GetCardAsync(cardId, ct);
if (card is null)
{
return null;
}
CardDto card = await _store.GetCardAsync(cardId, ct)
?? throw new NotFoundException(CardEntityName, cardId);
string name = string.IsNullOrWhiteSpace(fileName) ? DefaultAttachmentName : fileName;
CardFileKind kind = FileKindDetector.Detect(name, contentType);
@@ -62,7 +65,7 @@ public sealed partial class CardsService
// Карточка исчезла между чтением и записью (гонка): объект-сирота в хранилище не нужен —
// удаляем и отвечаем 404-семантикой (DeleteAsync сбои не бросает).
await _storage.DeleteAsync(objectKey, ct);
return null;
throw new NotFoundException(CardEntityName, cardId);
}
return entry;
@@ -73,19 +76,18 @@ public sealed partial class CardsService
/// </summary>
/// <param name="cardId">Id карточки (<c>c_...</c>).</param>
/// <param name="fileId">Id записи файла (<c>pf_...</c>).</param>
/// <returns>Метаданные записи файла либо null (карточка/запись не найдены).</returns>
public async Task<CardFileDto?> GetFileEntryAsync(
/// <returns>Метаданные записи файла.</returns>
/// <exception cref="NotFoundException">Карточка или запись файла не найдены.</exception>
public async Task<CardFileDto> GetFileEntryAsync(
string cardId,
string fileId,
CancellationToken ct)
{
CardDto? card = await _store.GetCardAsync(cardId, ct);
if (card is null)
{
return null;
}
CardDto card = await _store.GetCardAsync(cardId, ct)
?? throw new NotFoundException(CardEntityName, cardId);
return card.Files.FirstOrDefault(file => file.Id == fileId);
return card.Files.FirstOrDefault(file => file.Id == fileId)
?? throw new NotFoundException(CardFileEntityName, fileId);
}
/// <summary>
@@ -93,17 +95,15 @@ public sealed partial class CardsService
/// </summary>
/// <param name="cardId">Id карточки (<c>c_...</c>).</param>
/// <param name="fileId">Id удаляемой записи файла (<c>pf_...</c>).</param>
/// <returns>Карточка после удаления (без записи) либо null — карточки нет (404-семантика).</returns>
public async Task<CardDto?> RemoveFileAsync(
/// <returns>Карточка после удаления (без записи).</returns>
/// <exception cref="NotFoundException">Карточка или запись файла не найдены.</exception>
public async Task<CardDto> RemoveFileAsync(
string cardId,
string fileId,
CancellationToken ct)
{
CardDto? card = await _store.GetCardAsync(cardId, ct);
if (card is null)
{
return null;
}
CardDto card = await _store.GetCardAsync(cardId, ct)
?? throw new NotFoundException(CardEntityName, cardId);
CardFileDto? entry = card.Files.FirstOrDefault(file => file.Id == fileId);
if (entry is not null && !string.IsNullOrWhiteSpace(entry.ObjectKey))
@@ -113,11 +113,11 @@ public sealed partial class CardsService
if (!await _store.RemoveFileAsync(cardId, fileId, ct))
{
return null;
throw new NotFoundException(CardFileEntityName, fileId);
}
return await _store.GetCardAsync(cardId, ct)
?? throw new InvalidOperationException("Карточка не прочиталась после удаления файла: " + cardId);
?? throw new NotFoundException(CardEntityName, cardId);
}
private static string BuildObjectKey(
@@ -2,6 +2,7 @@ using Deal.Contracts.Integrations.Models;
using Deal.Modules.Cards.Application.Models;
using Deal.Modules.Kanban.Application.Abstractions;
using Deal.Modules.Kanban.Application.Models;
using Deal.SharedKernel.Errors;
namespace Deal.Modules.Kanban.Application.Services;
@@ -87,8 +88,9 @@ public sealed partial class CardsService
/// Перенос карточки в корзину
/// </summary>
/// <param name="cardId">Id карточки (<c>c_...</c>).</param>
/// <returns>Карточка после переноса (при no-op — как была) либо null — карточки нет (404).</returns>
public Task<CardDto?> TrashCardAsync(string cardId, CancellationToken ct)
/// <returns>Карточка после переноса (при no-op — как была).</returns>
/// <exception cref="NotFoundException">Карточка не найдена.</exception>
public Task<CardDto> TrashCardAsync(string cardId, CancellationToken ct)
{
return TrashCardAsync(cardId, teach: true, ct);
}
@@ -98,17 +100,15 @@ public sealed partial class CardsService
/// </summary>
/// <param name="cardId">Id карточки (<c>c_...</c>).</param>
/// <param name="teach">True — писать сигнал «спам» (действие пользователя); false — не писать.</param>
/// <returns>Карточка после переноса (при no-op — как была) либо null — карточки нет (404).</returns>
public async Task<CardDto?> TrashCardAsync(
/// <returns>Карточка после переноса (при no-op — как была).</returns>
/// <exception cref="NotFoundException">Карточка не найдена.</exception>
public async Task<CardDto> TrashCardAsync(
string cardId,
bool teach,
CancellationToken ct)
{
CardDto? card = await _store.GetCardAsync(cardId, ct);
if (card is null)
{
return null;
}
CardDto card = await _store.GetCardAsync(cardId, ct)
?? throw new NotFoundException(CardEntityName, cardId);
if (card.Col == CardIds.Trash)
{
@@ -122,21 +122,20 @@ public sealed partial class CardsService
await _mlClient.PushAsync(text, MlLearningLabels.Spam, PushWeightUser, ct);
}
return await _store.GetCardAsync(cardId, ct);
return await _store.GetCardAsync(cardId, ct)
?? throw new NotFoundException(CardEntityName, cardId);
}
/// <summary>
/// Возврат карточки из архива/корзины на канбан.
/// </summary>
/// <param name="cardId">Id карточки (<c>c_...</c>).</param>
/// <returns>Колонка возврата (inbox/доска) либо null — карточки нет (404).</returns>
public async Task<string?> RestoreCardAsync(string cardId, CancellationToken ct)
/// <returns>Колонка возврата (inbox/доска).</returns>
/// <exception cref="NotFoundException">Карточка не найдена.</exception>
public async Task<string> RestoreCardAsync(string cardId, CancellationToken ct)
{
CardDto? card = await _store.GetCardAsync(cardId, ct);
if (card is null)
{
return null;
}
CardDto card = await _store.GetCardAsync(cardId, ct)
?? throw new NotFoundException(CardEntityName, cardId);
string back = await ResolveReturnColAsync(card.PrevCol, ct);
string text = LearningText(card);
@@ -3,6 +3,7 @@ using Deal.Modules.Cards.Application.Models;
using Deal.Modules.Cards.Application.Sources;
using Deal.Modules.Kanban.Application.Abstractions;
using Deal.Modules.Kanban.Application.Models;
using Deal.SharedKernel.Errors;
namespace Deal.Modules.Kanban.Application.Services;
@@ -101,14 +102,12 @@ public sealed partial class CardsService
/// «Взять в работу»
/// </summary>
/// <param name="cardId">Id карточки (<c>c_...</c>).</param>
/// <returns>Карточка в стадии planned; null — карточки нет (404).</returns>
public async Task<CardDto?> TakeCardAsync(string cardId, CancellationToken ct)
/// <returns>Карточка в стадии planned.</returns>
/// <exception cref="NotFoundException">Карточка не найдена.</exception>
public async Task<CardDto> TakeCardAsync(string cardId, CancellationToken ct)
{
CardDto? card = await _store.GetCardAsync(cardId, ct);
if (card is null)
{
return null;
}
CardDto card = await _store.GetCardAsync(cardId, ct)
?? throw new NotFoundException(CardEntityName, cardId);
if (CardsDefaultContainers.Contains(card.Col))
{
@@ -120,14 +119,14 @@ public sealed partial class CardsService
if (!await _store.MoveCardStageAsync(cardId, PlannedStage, entry, nowMs, ct))
{
// Карточка исчезла между чтением и переносом (гонка с удалением).
return null;
throw new NotFoundException(CardEntityName, cardId);
}
await _store.AddCommentAsync(
PrefixId.New(KanbanIdPrefixes.Comment), cardId, CommentAuthor, TakenCommentText, ct);
return await _store.GetCardAsync(cardId, ct)
?? throw new InvalidOperationException("Карточка не прочиталась после take: " + cardId);
?? throw new NotFoundException(CardEntityName, cardId);
}
/// <summary>
@@ -135,8 +134,9 @@ public sealed partial class CardsService
/// </summary>
/// <param name="cardId">Id карточки (<c>c_...</c>).</param>
/// <param name="body">Тело PATCH: ключ → JSON-значение (наличие ключа = поле меняется).</param>
/// <returns>Обновлённая карточка или null — карточки нет (404).</returns>
public async Task<CardDto?> PatchCardAsync(
/// <returns>Обновлённая карточка.</returns>
/// <exception cref="NotFoundException">Карточка не найдена.</exception>
public async Task<CardDto> PatchCardAsync(
string cardId,
IReadOnlyDictionary<string, JsonElement> body,
CancellationToken ct)
@@ -144,7 +144,13 @@ public sealed partial class CardsService
ArgumentNullException.ThrowIfNull(body);
bool updated = await _store.PatchCardAsync(cardId, ResolvePatch(body), ct);
return updated ? await _store.GetCardAsync(cardId, ct) : null;
if (!updated)
{
throw new NotFoundException(CardEntityName, cardId);
}
return await _store.GetCardAsync(cardId, ct)
?? throw new NotFoundException(CardEntityName, cardId);
}
/// <summary>
@@ -4,6 +4,7 @@ using Deal.Modules.Kanban.Application.Abstractions;
using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Settings.Application.Abstractions;
using Deal.Modules.Settings.Application.Models;
using Deal.SharedKernel.Errors;
namespace Deal.Modules.Kanban.Application.Services;
@@ -19,6 +20,9 @@ public sealed class ContainersService(ICardStore store, ISettingsStore settings)
/// </summary>
public const string DefaultContainerName = "Новая колонка";
// Имя сущности для текста ошибки «не найдено».
private const string ContainerEntityName = "Контейнер";
// Палитра колонок по умолчанию: цвет = Palette[order % 8], если цвет не задан.
private static readonly string[] Palette =
["#818cf8", "#fbbf24", "#22d3ee", "#e879f9", "#34d399", "#fb7185", "#a78bfa", "#f97316"];
@@ -53,17 +57,15 @@ public sealed class ContainersService(ICardStore store, ISettingsStore settings)
}
/// <summary>
/// Один контейнер со счётчиками; null — контейнера нет.
/// Один контейнер со счётчиками
/// </summary>
/// <param name="containerId">Id контейнера.</param>
/// <returns>Контейнер со счётчиками либо null.</returns>
public async Task<ContainerDto?> GetAsync(string containerId, CancellationToken ct)
/// <returns>Контейнер со счётчиками.</returns>
/// <exception cref="NotFoundException">Контейнер не найден.</exception>
public async Task<ContainerDto> GetAsync(string containerId, CancellationToken ct)
{
ContainerDto? container = await store.GetContainerAsync(containerId, ct);
if (container is null)
{
return null;
}
ContainerDto container = await store.GetContainerAsync(containerId, ct)
?? throw new NotFoundException(ContainerEntityName, containerId);
IReadOnlyDictionary<string, CardColumnCountDto> counts = await store.CountCardsByColAsync(ct);
ContainerCountsDto containerCounts = counts.TryGetValue(container.Id, out CardColumnCountDto? count)
@@ -107,17 +109,15 @@ public sealed class ContainersService(ICardStore store, ISettingsStore settings)
/// </summary>
/// <param name="containerId">Id контейнера.</param>
/// <param name="patch">Изменения; null-поле означает «не менять».</param>
/// <returns>Контейнер после патча; null — контейнера нет (404 «Контейнер не найден»).</returns>
public async Task<ContainerDto?> PatchAsync(
/// <returns>Контейнер после патча.</returns>
/// <exception cref="NotFoundException">Контейнер не найден.</exception>
public async Task<ContainerDto> PatchAsync(
string containerId,
ContainerPatchDto patch,
CancellationToken ct)
{
ContainerDto? current = await store.GetContainerAsync(containerId, ct);
if (current is null)
{
return null;
}
ContainerDto current = await store.GetContainerAsync(containerId, ct)
?? throw new NotFoundException(ContainerEntityName, containerId);
ContainerDto updated = ApplyPatch(current, patch);
await store.UpdateContainerAsync(updated, ct);
@@ -128,8 +128,9 @@ public sealed class ContainersService(ICardStore store, ISettingsStore settings)
/// Принимает ИИ-предложение
/// </summary>
/// <param name="containerId">Id контейнера-предложения.</param>
/// <returns>Контейнер после принятия; null — контейнера нет (404).</returns>
public Task<ContainerDto?> AcceptSuggestedAsync(string containerId, CancellationToken ct)
/// <returns>Контейнер после принятия.</returns>
/// <exception cref="NotFoundException">Контейнер не найден.</exception>
public Task<ContainerDto> AcceptSuggestedAsync(string containerId, CancellationToken ct)
{
return PatchAsync(containerId, new ContainerPatchDto(
Name: null,
@@ -3,14 +3,9 @@ using Isopoh.Cryptography.Argon2;
namespace Deal.Modules.Tenants.Application.Services;
/// <summary>
/// Реализация <see cref="IPasswordHasher"/> на Argon2id.
/// </summary>
public sealed class DefaultPasswordHasher : IPasswordHasher
{
/// <inheritdoc />
public string Hash(string password) => Argon2.Hash(password);
string IPasswordHasher.Hash(string password) => Argon2.Hash(password);
/// <inheritdoc />
public bool Verify(string password, string encodedHash) => Argon2.Verify(encodedHash, password);
bool IPasswordHasher.Verify(string password, string encodedHash) => Argon2.Verify(encodedHash, password);
}
@@ -0,0 +1,32 @@
namespace Deal.SharedKernel.Errors;
/// <summary>
/// Коды ошибок Deal для логов и ответов клиенту.
/// </summary>
public static class DealErrorCodes
{
/// <summary>
/// Запрошенный объект не найден.
/// </summary>
public const string NotFound = "not_found";
/// <summary>
/// Некорректные данные запроса.
/// </summary>
public const string Validation = "validation_error";
/// <summary>
/// Конфликт состояния.
/// </summary>
public const string Conflict = "conflict";
/// <summary>
/// Внешний сервис недоступен.
/// </summary>
public const string Unavailable = "unavailable";
/// <summary>
/// Непредвиденная внутренняя ошибка.
/// </summary>
public const string Internal = "internal_error";
}
@@ -0,0 +1,35 @@
using Deal.SharedKernel.Resources;
namespace Deal.SharedKernel.Errors;
/// <summary>
/// База доменных исключений Deal: код ошибки и текст из ресурсов.
/// </summary>
public abstract class DealException : Exception
{
protected DealException(
string errorCode,
string messageKey,
params object?[] messageArgs)
: base(ErrorResources.Format(messageKey, messageArgs))
{
ArgumentException.ThrowIfNullOrWhiteSpace(errorCode);
ErrorCode = errorCode;
}
protected DealException(
string errorCode,
Exception innerException,
string messageKey,
params object?[] messageArgs)
: base(ErrorResources.Format(messageKey, messageArgs), innerException)
{
ArgumentException.ThrowIfNullOrWhiteSpace(errorCode);
ErrorCode = errorCode;
}
/// <summary>
/// Код ошибки для логов и ответов клиенту.
/// </summary>
public string ErrorCode { get; }
}
@@ -0,0 +1,19 @@
using Deal.SharedKernel.Resources;
namespace Deal.SharedKernel.Errors;
/// <summary>
/// Запрошенный объект не найден.
/// </summary>
public sealed class NotFoundException : DealException
{
public NotFoundException(string entityName)
: base(DealErrorCodes.NotFound, ErrorResourceKeys.NotFoundEntity, entityName)
{
}
public NotFoundException(string entityName, string entityId)
: base(DealErrorCodes.NotFound, ErrorResourceKeys.NotFoundEntityWithId, entityName, entityId)
{
}
}
@@ -0,0 +1,19 @@
using Deal.SharedKernel.Resources;
namespace Deal.SharedKernel.Errors;
/// <summary>
/// Внешний сервис недоступен.
/// </summary>
public sealed class ServiceUnavailableException : DealException
{
public ServiceUnavailableException(string serviceName)
: base(DealErrorCodes.Unavailable, ErrorResourceKeys.ServiceUnavailable, serviceName)
{
}
public ServiceUnavailableException(string serviceName, Exception innerException)
: base(DealErrorCodes.Unavailable, innerException, ErrorResourceKeys.ServiceUnavailable, serviceName)
{
}
}
@@ -0,0 +1,14 @@
using Deal.SharedKernel.Resources;
namespace Deal.SharedKernel.Errors;
/// <summary>
/// Некорректные данные запроса.
/// </summary>
public sealed class ValidationException : DealException
{
public ValidationException(string messageKey, params object?[] messageArgs)
: base(DealErrorCodes.Validation, messageKey, messageArgs)
{
}
}
@@ -0,0 +1,49 @@
namespace Deal.SharedKernel.Resilience;
/// <summary>
/// Повтор операции при транзиентном сбое.
/// </summary>
public static class RetryExecutor
{
/// <summary>
/// Выполняет операцию, повторяя её при транзиентном сбое с задержкой.
/// </summary>
/// <param name="operation">Операция (принимает токен отмены).</param>
/// <param name="retryCount">Число повторов после первой попытки.</param>
/// <param name="baseDelay">Базовая задержка; для повтора N — baseDelay * 2^N.</param>
/// <param name="shouldRetry">Предикат транзиентности сбоя.</param>
/// <param name="delayAsync">Пауза между попытками (в тестах — мгновенная).</param>
/// <param name="cancellationToken">Токен отмены.</param>
/// <returns>Результат первой успешной попытки.</returns>
public static async Task<TResult> ExecuteAsync<TResult>(
Func<CancellationToken, Task<TResult>> operation,
int retryCount,
TimeSpan baseDelay,
Func<Exception, bool> shouldRetry,
Func<TimeSpan, CancellationToken, Task> delayAsync,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(operation);
ArgumentOutOfRangeException.ThrowIfNegative(retryCount);
ArgumentNullException.ThrowIfNull(shouldRetry);
ArgumentNullException.ThrowIfNull(delayAsync);
for (int attempt = 0; ; attempt++)
{
try
{
return await operation(cancellationToken).ConfigureAwait(false);
}
catch (Exception exception) when (attempt < retryCount
&& shouldRetry(exception)
&& !cancellationToken.IsCancellationRequested)
{
await delayAsync(BackoffDelay(baseDelay, attempt), cancellationToken).ConfigureAwait(false);
}
}
}
// Задержка повтора с экспоненциальным ростом от базовой.
private static TimeSpan BackoffDelay(TimeSpan baseDelay, int attempt)
=> TimeSpan.FromMilliseconds(baseDelay.TotalMilliseconds * Math.Pow(2, attempt));
}
@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>2.0</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<data name="UnexpectedError" xml:space="preserve">
<value>Внутренняя ошибка сервиса. Обратитесь в поддержку.</value>
</data>
<data name="NotFoundEntity" xml:space="preserve">
<value>Объект не найден: {0}.</value>
</data>
<data name="NotFoundEntityWithId" xml:space="preserve">
<value>Объект не найден: {0} (id: {1}).</value>
</data>
<data name="ValidationFailed" xml:space="preserve">
<value>Некорректные данные запроса: {0}.</value>
</data>
<data name="ConflictState" xml:space="preserve">
<value>Конфликт состояния: {0}.</value>
</data>
<data name="ServiceUnavailable" xml:space="preserve">
<value>Сервис «{0}» временно недоступен.</value>
</data>
</root>
@@ -0,0 +1,37 @@
namespace Deal.SharedKernel.Resources;
/// <summary>
/// Ключи текстов ошибок Deal в ресурсах ErrorMessages.resx.
/// </summary>
public static class ErrorResourceKeys
{
/// <summary>
/// Общая непредвиденная внутренняя ошибка.
/// </summary>
public const string UnexpectedError = "UnexpectedError";
/// <summary>
/// Объект не найден (без идентификатора).
/// </summary>
public const string NotFoundEntity = "NotFoundEntity";
/// <summary>
/// Объект не найден (с идентификатором).
/// </summary>
public const string NotFoundEntityWithId = "NotFoundEntityWithId";
/// <summary>
/// Некорректные данные запроса.
/// </summary>
public const string ValidationFailed = "ValidationFailed";
/// <summary>
/// Конфликт состояния.
/// </summary>
public const string ConflictState = "ConflictState";
/// <summary>
/// Внешний сервис недоступен.
/// </summary>
public const string ServiceUnavailable = "ServiceUnavailable";
}
@@ -0,0 +1,34 @@
using System.Globalization;
using System.Resources;
namespace Deal.SharedKernel.Resources;
/// <summary>
/// Тексты ошибок Deal из ресурсов ErrorMessages.resx.
/// </summary>
public static class ErrorResources
{
private const string ResourceBaseName = "Deal.SharedKernel.Resources.ErrorMessages";
private static readonly ResourceManager Manager = new(ResourceBaseName, typeof(ErrorResources).Assembly);
/// <summary>
/// Форматированный текст по ключу ресурса с подстановкой аргументов.
/// </summary>
/// <param name="key">Ключ ресурса (см. <see cref="ErrorResourceKeys"/>).</param>
/// <param name="args">Аргументы шаблона.</param>
/// <returns>Текст ресурса; неизвестный ключ возвращается как есть.</returns>
public static string Format(string key, params object?[] args)
{
ArgumentException.ThrowIfNullOrWhiteSpace(key);
string? template = Manager.GetString(key, CultureInfo.CurrentUICulture);
if (string.IsNullOrEmpty(template))
{
return key;
}
return args.Length == 0
? template
: string.Format(CultureInfo.CurrentUICulture, template, args);
}
}
@@ -24,11 +24,11 @@ public sealed class DataRetentionSchedulerTests
public async Task RunCycle_PurgesAgedAuditResetsExpiredLimitsAndDeletesExpiredCounters()
{
DateTimeOffset now = DateTimeOffset.UtcNow;
var audit = new FakeAuditLogStore();
await audit.AppendAsync(AuditRecord(AuditEvents.OperatorLoginOk, now.AddDays(-(RetentionDays + 20))), CancellationToken.None);
await audit.AppendAsync(AuditRecord(AuditEvents.TenantLoginOk, now.AddDays(-1)), CancellationToken.None);
var audit = new TestAuditLogStore();
await audit.Store.AppendAsync(AuditRecord(AuditEvents.OperatorLoginOk, now.AddDays(-(RetentionDays + 20))), CancellationToken.None);
await audit.Store.AppendAsync(AuditRecord(AuditEvents.TenantLoginOk, now.AddDays(-1)), CancellationToken.None);
var limits = new FakeTenantLimitStore();
var limits = new TestTenantLimitStore();
// Период месяца начат два месяца назад → истёк; строка с накоплениями должна обнулиться.
limits.Preload(TenantId, budgetTokens: 1000, TenantLimitPeriods.Month, now.AddMonths(-2), usedTokens: 700, warned80: true);
@@ -63,9 +63,9 @@ public sealed class DataRetentionSchedulerTests
public async Task RunCycle_IsIdempotent()
{
DateTimeOffset now = DateTimeOffset.UtcNow;
var audit = new FakeAuditLogStore();
await audit.AppendAsync(AuditRecord(AuditEvents.OperatorLoginOk, now.AddDays(-(RetentionDays + 1))), CancellationToken.None);
var limits = new FakeTenantLimitStore();
var audit = new TestAuditLogStore();
await audit.Store.AppendAsync(AuditRecord(AuditEvents.OperatorLoginOk, now.AddDays(-(RetentionDays + 1))), CancellationToken.None);
var limits = new TestTenantLimitStore();
limits.Preload(TenantId, budgetTokens: 1000, TenantLimitPeriods.Month, now.AddMonths(-2), usedTokens: 500);
await using ServiceProvider provider = BuildProvider(audit, limits, new TestRateLimitCounterStore());
DataRetentionScheduler scheduler = new(
@@ -86,13 +86,13 @@ public sealed class DataRetentionSchedulerTests
// counters: Хелпер счётчиков (окна посеяны сценарием).
// Возвращает: Провайдер с сервисами цикла.
private static ServiceProvider BuildProvider(
FakeAuditLogStore audit,
FakeTenantLimitStore limits,
TestAuditLogStore audit,
TestTenantLimitStore limits,
TestRateLimitCounterStore counters)
{
var services = new ServiceCollection();
services.AddScoped<IAuditLogStore>(_ => audit);
services.AddScoped<ITenantLimitStore>(_ => limits);
services.AddScoped<IAuditLogStore>(_ => audit.Store);
services.AddScoped<ITenantLimitStore>(_ => limits.Store);
services.AddScoped<IRateLimitCounterStore>(_ => counters.Store);
return services.BuildServiceProvider();
}
@@ -0,0 +1,83 @@
using System.Text.Json;
using Deal.Api.Middleware;
using Deal.SharedKernel.Errors;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using NSubstitute;
namespace Deal.Tests.Unit.Api;
/// <summary>
/// Тесты обработчика необработанных исключений HTTP.
/// </summary>
public sealed class DealExceptionHandlerTests
{
[Fact]
public async Task NotFound_MapsTo404WithCodeAndRussianDetail()
{
DefaultHttpContext context = CreateContext();
DealExceptionHandler handler = new(Substitute.For<ILogger<DealExceptionHandler>>());
bool handled = await handler.TryHandleAsync(
context,
new NotFoundException("Карточка", "c_1"),
CancellationToken.None);
Assert.True(handled);
Assert.Equal(StatusCodes.Status404NotFound, context.Response.StatusCode);
(string detail, string code) = await ReadBodyAsync(context);
Assert.Equal(DealErrorCodes.NotFound, code);
Assert.Contains("Карточка", detail);
}
[Fact]
public async Task Unavailable_MapsTo503()
{
DefaultHttpContext context = CreateContext();
DealExceptionHandler handler = new(Substitute.For<ILogger<DealExceptionHandler>>());
await handler.TryHandleAsync(
context,
new ServiceUnavailableException("ИИ"),
CancellationToken.None);
Assert.Equal(StatusCodes.Status503ServiceUnavailable, context.Response.StatusCode);
}
[Fact]
public async Task UnexpectedException_MapsToGeneric500WithoutStackOrDetails()
{
DefaultHttpContext context = CreateContext();
DealExceptionHandler handler = new(Substitute.For<ILogger<DealExceptionHandler>>());
bool handled = await handler.TryHandleAsync(
context,
new InvalidOperationException("секретная внутренняя деталь"),
CancellationToken.None);
Assert.True(handled);
Assert.Equal(StatusCodes.Status500InternalServerError, context.Response.StatusCode);
(string detail, string code) = await ReadBodyAsync(context);
Assert.Equal(DealErrorCodes.Internal, code);
Assert.DoesNotContain("секретная внутренняя деталь", detail);
Assert.DoesNotContain("at ", detail);
}
private static DefaultHttpContext CreateContext()
{
var context = new DefaultHttpContext();
context.Response.Body = new MemoryStream();
return context;
}
private static async Task<(string Detail, string Code)> ReadBodyAsync(HttpContext context)
{
context.Response.Body.Seek(0, SeekOrigin.Begin);
using var reader = new StreamReader(context.Response.Body);
string json = await reader.ReadToEndAsync();
using JsonDocument document = JsonDocument.Parse(json);
return (
document.RootElement.GetProperty("detail").GetString() ?? string.Empty,
document.RootElement.GetProperty("code").GetString() ?? string.Empty);
}
}
@@ -39,7 +39,7 @@ public sealed class DiscoveryWorkerSchedulerTests
TestDiscoveryStore StoreB,
TestDiscoveryGateway GatewayA,
TestDiscoveryGateway GatewayB,
TenantContext TenantContext,
ITenantContext TenantContext,
ListLogger Logs);
[Fact]
@@ -91,11 +91,11 @@ public sealed class DiscoveryWorkerSchedulerTests
private static Context CreateContext()
{
var tenants = new TestTenantRepository(Tenant(TenantA), Tenant(TenantB));
var tenantContext = new TenantContext();
ITenantContext tenantContext = new TenantContext();
var storeA = new TestDiscoveryStore();
var storeB = new TestDiscoveryStore();
var settingsA = new FakeSettingsStore();
var settingsB = new FakeSettingsStore();
var settingsA = new TestSettingsStore();
var settingsB = new TestSettingsStore();
var gatewayA = new TestDiscoveryGateway();
var gatewayB = new TestDiscoveryGateway();
PreloadDiscoveryEvalOff(settingsA);
@@ -104,12 +104,12 @@ public sealed class DiscoveryWorkerSchedulerTests
var services = new ServiceCollection();
services.AddSingleton<ITenantContext>(tenantContext);
services.AddSingleton<ITenantRepository>(tenants.Repository);
services.AddSingleton<IMlClient>(new FakeMlClient());
services.AddSingleton<IMlClient>(new TestMlClient().Client);
services.AddSingleton<IAiTools>(_ => TestAiTools.New());
// Тенант-scoped адаптеры: фейк выбирает хранилище по ITenantContext, который цикл заполняет SetTenant
// (эталон PipelineWorkerSchedulerTests/ConnectionStringProvider.ForTenant).
services.AddScoped<IDiscoveryStore>(provider => TenantOf(provider) == TenantA ? storeA.Store : storeB.Store);
services.AddScoped<ISettingsStore>(provider => TenantOf(provider) == TenantA ? settingsA : settingsB);
services.AddScoped<ISettingsStore>(provider => TenantOf(provider) == TenantA ? settingsA.Store : settingsB.Store);
services.AddScoped<ITelegramGateway>(provider => TenantOf(provider) == TenantA ? gatewayA.Gateway : gatewayB.Gateway);
// Реальные сервисы модуля Discovery — как AddDiscoveryModule в Program.cs: цикл резолвит их в tenant-scope.
services.AddDiscoveryModule();
@@ -124,7 +124,7 @@ public sealed class DiscoveryWorkerSchedulerTests
}
// ML/ИИ выключены в настройках тенанта (оценка содержания — эвристика по ключам).
private static void PreloadDiscoveryEvalOff(FakeSettingsStore settings)
private static void PreloadDiscoveryEvalOff(TestSettingsStore settings)
{
settings.Preload(SettingsKeys.MlEnabled, "false");
settings.Preload(SettingsKeys.AiEnabled, "false");
@@ -49,7 +49,7 @@ public sealed class IngressRateLimitInterceptorTests
await RunAsync(
registry,
services => services.AddScoped<IPipelineStore>(_ => new FakePipelineStore()),
services => services.AddScoped<IPipelineStore>(_ => new TestPipelineStore().Store),
async channel =>
{
PushSourceReply first = await PushAsync(channel, TenantA);
@@ -77,7 +77,7 @@ public sealed class IngressRateLimitInterceptorTests
await RunAsync(
registry,
services => services.AddScoped<IPipelineStore>(_ => new FakePipelineStore()),
services => services.AddScoped<IPipelineStore>(_ => new TestPipelineStore().Store),
async channel =>
{
// Окно ингресса — 1/мин: единственный разрешённый вызов исчерпывает лимит.
@@ -39,14 +39,14 @@ public sealed class MlOutboxFlushSchedulerTests
{
await MlGrpcTestHost.RunAsync(MlGrpcTestHost.DefaultToken, new RecordingMlService(), async (port, service) =>
{
var store = new FakeMlLearningStore();
var store = new TestMlLearningStore();
SeedRows(store, count: 25, prefix: "a");
var tenantContext = new TenantContext();
ITenantContext tenantContext = new TenantContext();
await using ServiceProvider provider = BuildProvider(
port,
new TestTenantRepository(Tenant(TenantA)),
tenantContext,
new Dictionary<Guid, FakeMlLearningStore> { [TenantA] = store });
new Dictionary<Guid, TestMlLearningStore> { [TenantA] = store });
MlOutboxFlushScheduler scheduler = CreateScheduler(provider);
await scheduler.RunCycleAsync(CancellationToken.None);
@@ -54,7 +54,7 @@ public sealed class MlOutboxFlushSchedulerTests
// 25 строк → 3 батча по 10/10/5 (ml_client.flush_outbox: chunk=10), строки удалены после успеха.
Assert.Equal(3, service.TrainCalls);
Assert.Equal(new[] { 10, 10, 5 }, service.TrainBatches.Select(batch => batch.Items.Count).ToArray());
Assert.Equal(0, await store.CountOutboxAsync(CancellationToken.None));
Assert.Equal(0, await store.Store.CountOutboxAsync(CancellationToken.None));
Assert.False(tenantContext.HasTenant); // контекст AsyncLocal не переживает проход
});
}
@@ -65,23 +65,23 @@ public sealed class MlOutboxFlushSchedulerTests
await MlGrpcTestHost.RunAsync(MlGrpcTestHost.DefaultToken, new RecordingMlService(), async (port, service) =>
{
service.TrainUnavailable = true;
var store = new FakeMlLearningStore();
var store = new TestMlLearningStore();
SeedRows(store, count: 5, prefix: "a");
var tenantContext = new TenantContext();
ITenantContext tenantContext = new TenantContext();
await using ServiceProvider provider = BuildProvider(
port,
new TestTenantRepository(Tenant(TenantA)),
tenantContext,
new Dictionary<Guid, FakeMlLearningStore> { [TenantA] = store });
new Dictionary<Guid, TestMlLearningStore> { [TenantA] = store });
MlOutboxFlushScheduler scheduler = CreateScheduler(provider);
await scheduler.RunCycleAsync(CancellationToken.None);
Assert.Equal(5, await store.CountOutboxAsync(CancellationToken.None)); // строки остались
Assert.Equal(5, await store.Store.CountOutboxAsync(CancellationToken.None)); // строки остались
// Следующий цикл — повторная попытка (ретрай на каждом тике; строки снова не удалены).
await scheduler.RunCycleAsync(CancellationToken.None);
Assert.Equal(2, service.TrainCalls);
Assert.Equal(5, await store.CountOutboxAsync(CancellationToken.None));
Assert.Equal(5, await store.Store.CountOutboxAsync(CancellationToken.None));
Assert.False(tenantContext.HasTenant);
});
}
@@ -91,16 +91,16 @@ public sealed class MlOutboxFlushSchedulerTests
{
await MlGrpcTestHost.RunAsync(MlGrpcTestHost.DefaultToken, new RecordingMlService(), async (port, service) =>
{
var storeA = new FakeMlLearningStore();
var storeA = new TestMlLearningStore();
SeedRows(storeA, count: 12, prefix: "a");
var storeB = new FakeMlLearningStore();
var storeB = new TestMlLearningStore();
SeedRows(storeB, count: 3, prefix: "b");
var tenantContext = new TenantContext();
ITenantContext tenantContext = new TenantContext();
await using ServiceProvider provider = BuildProvider(
port,
new TestTenantRepository(Tenant(TenantA), Tenant(TenantB)),
tenantContext,
new Dictionary<Guid, FakeMlLearningStore> { [TenantA] = storeA, [TenantB] = storeB });
new Dictionary<Guid, TestMlLearningStore> { [TenantA] = storeA, [TenantB] = storeB });
MlOutboxFlushScheduler scheduler = CreateScheduler(provider);
await scheduler.RunCycleAsync(CancellationToken.None);
@@ -108,8 +108,8 @@ public sealed class MlOutboxFlushSchedulerTests
// Каждый тенант выгрузил свою очередь в собственном scope (A: 10+2, B: 3) — строки удалены.
Assert.Equal(3, service.TrainCalls);
Assert.Equal(new[] { 10, 2, 3 }, service.TrainBatches.Select(batch => batch.Items.Count).ToArray());
Assert.Equal(0, await storeA.CountOutboxAsync(CancellationToken.None));
Assert.Equal(0, await storeB.CountOutboxAsync(CancellationToken.None));
Assert.Equal(0, await storeA.Store.CountOutboxAsync(CancellationToken.None));
Assert.Equal(0, await storeB.Store.CountOutboxAsync(CancellationToken.None));
Assert.False(tenantContext.HasTenant);
});
}
@@ -119,20 +119,20 @@ public sealed class MlOutboxFlushSchedulerTests
{
await MlGrpcTestHost.RunAsync(MlGrpcTestHost.DefaultToken, new RecordingMlService(), async (port, service) =>
{
var store = new FakeMlLearningStore();
var store = new TestMlLearningStore();
SeedRows(store, count: 105, prefix: "a");
var tenantContext = new TenantContext();
ITenantContext tenantContext = new TenantContext();
await using ServiceProvider provider = BuildProvider(
port,
new TestTenantRepository(Tenant(TenantA)),
tenantContext,
new Dictionary<Guid, FakeMlLearningStore> { [TenantA] = store });
new Dictionary<Guid, TestMlLearningStore> { [TenantA] = store });
MlOutboxFlushScheduler scheduler = CreateScheduler(provider);
await scheduler.RunCycleAsync(CancellationToken.None);
Assert.Equal(10, service.TrainCalls);
Assert.Equal(5, await store.CountOutboxAsync(CancellationToken.None));
Assert.Equal(5, await store.Store.CountOutboxAsync(CancellationToken.None));
});
}
@@ -148,8 +148,8 @@ public sealed class MlOutboxFlushSchedulerTests
private static ServiceProvider BuildProvider(
int port,
TestTenantRepository tenants,
TenantContext tenantContext,
Dictionary<Guid, FakeMlLearningStore> storesByTenant)
ITenantContext tenantContext,
Dictionary<Guid, TestMlLearningStore> storesByTenant)
{
var services = new ServiceCollection();
services.AddSingleton<ITenantContext>(tenantContext);
@@ -162,9 +162,9 @@ public sealed class MlOutboxFlushSchedulerTests
services.AddScoped<IMlClient>(provider => provider.GetRequiredService<GrpcMlClient>());
services.AddScoped<IMlTrainClient>(provider => provider.GetRequiredService<GrpcMlClient>());
// Tenant-scoped адаптеры: выбирают фейк по тому же ITenantContext, который планировщик заполняет SetTenant.
services.AddScoped<IMlLearningStore>(provider => storesByTenant[TenantOf(provider)]);
services.AddScoped<ISettingsStore>(_ => new FakeSettingsStore());
services.AddScoped<ITenantLimitStore>(_ => new FakeTenantLimitStore());
services.AddScoped<IMlLearningStore>(provider => storesByTenant[TenantOf(provider)].Store);
services.AddScoped<ISettingsStore>(_ => new TestSettingsStore().Store);
services.AddScoped<ITenantLimitStore>(_ => new TestTenantLimitStore().Store);
services.AddScoped<TokenUsageEventService>(_ => new TokenUsageEventService(new TestTokenUsageEventStore().Store));
services.AddScoped<TokenUsageRecorder>();
services.AddLogging();
@@ -196,7 +196,7 @@ public sealed class MlOutboxFlushSchedulerTests
// count: Число строк.
// prefix: Префикс id (различает тенантов теста).
private static void SeedRows(
FakeMlLearningStore store,
TestMlLearningStore store,
int count,
string prefix)
{
@@ -128,13 +128,13 @@ public sealed class OperatorBootstrapHostedServiceTests
IConfiguration configuration = new ConfigurationBuilder()
.AddInMemoryCollection(values)
.Build();
var store = new FakeOperatorAuthStore();
var store = new TestOperatorAuthStore();
var passwordHasher = TestHashers.New();
var logger = new ListLogger();
// Hosted-шаг резолвит scoped OperatorBootstrapService из scope (как TenantBootstrapService):
// провайдер собирается с реальной регистрацией модуля поверх фейков.
var services = new ServiceCollection();
services.AddScoped<IOperatorAuthStore>(_ => store);
services.AddScoped<IOperatorAuthStore>(_ => store.Store);
services.AddScoped<IPasswordHasher>(_ => passwordHasher);
services.AddScoped<OperatorBootstrapService>();
// Провайдер живёт всё время теста (без using): hosted резолвит scope в StartAsync после создания.
@@ -150,7 +150,7 @@ public sealed class OperatorBootstrapHostedServiceTests
// Контекст теста bootstrap-шага.
private sealed record Context(
OperatorBootstrapHostedService Hosted,
FakeOperatorAuthStore Store,
TestOperatorAuthStore Store,
IPasswordHasher PasswordHasher,
ListLogger Logs);
@@ -29,19 +29,19 @@ public sealed class RuntimeDepthsCollectorTests
[Fact]
public async Task CollectAsync_SumsQueueAndOutboxAcrossTenants()
{
var pipelineByTenant = new Dictionary<string, FakePipelineStore>
var pipelineByTenant = new Dictionary<string, TestPipelineStore>
{
[TenantA.ToString("N")] = new FakePipelineStore(),
[TenantB.ToString("N")] = new FakePipelineStore(),
[TenantA.ToString("N")] = new TestPipelineStore(),
[TenantB.ToString("N")] = new TestPipelineStore(),
};
pipelineByTenant[TenantA.ToString("N")].SeedQueue(QueueItem("p_a1"));
pipelineByTenant[TenantA.ToString("N")].SeedQueue(QueueItem("p_a2"));
pipelineByTenant[TenantB.ToString("N")].SeedQueue(QueueItem("p_b1"));
var outboxByTenant = new Dictionary<string, FakeMlLearningStore>
var outboxByTenant = new Dictionary<string, TestMlLearningStore>
{
[TenantA.ToString("N")] = new FakeMlLearningStore(),
[TenantB.ToString("N")] = new FakeMlLearningStore(),
[TenantA.ToString("N")] = new TestMlLearningStore(),
[TenantB.ToString("N")] = new TestMlLearningStore(),
};
outboxByTenant[TenantA.ToString("N")].SeedOutbox("mle_1", "текст", "spam");
@@ -59,7 +59,7 @@ public sealed class RuntimeDepthsCollectorTests
[Fact]
public async Task CollectAsync_CollectsBudgetRatiosPerTenant()
{
var limits = new FakeTenantLimitStore();
var limits = new TestTenantLimitStore();
DateTimeOffset periodStart = DateTimeOffset.UtcNow;
limits.Preload(TenantA, 100, TenantLimitPeriods.Month, periodStart, 80);
limits.Preload(TenantB, 0, TenantLimitPeriods.Month, periodStart, 55);
@@ -69,7 +69,7 @@ public sealed class RuntimeDepthsCollectorTests
PipelineStores(TenantA, TenantB, TenantC),
OutboxStores(TenantA, TenantB, TenantC),
TenantRecords(TenantA, TenantB, TenantC),
limits);
limits.Store);
RuntimeDepthsDto depths = await collector.CollectAsync(CancellationToken.None);
@@ -83,7 +83,7 @@ public sealed class RuntimeDepthsCollectorTests
[Fact]
public async Task CollectAsync_BudgetReadFailure_SkipsFailedTenant()
{
var limits = new FakeTenantLimitStore();
var limits = new TestTenantLimitStore();
limits.FailStateReads.Add(TenantA);
limits.Preload(TenantB, 100, TenantLimitPeriods.Month, DateTimeOffset.UtcNow, 80);
@@ -91,7 +91,7 @@ public sealed class RuntimeDepthsCollectorTests
PipelineStores(TenantA, TenantB),
OutboxStores(TenantA, TenantB),
TenantRecords(TenantA, TenantB),
limits);
limits.Store);
RuntimeDepthsDto depths = await collector.CollectAsync(CancellationToken.None);
@@ -105,8 +105,8 @@ public sealed class RuntimeDepthsCollectorTests
public async Task CollectAsync_NoTenants_ReturnsZeros()
{
RuntimeDepthsCollector collector = Build(
new Dictionary<string, FakePipelineStore>(),
new Dictionary<string, FakeMlLearningStore>(),
new Dictionary<string, TestPipelineStore>(),
new Dictionary<string, TestMlLearningStore>(),
tenants: Array.Empty<TenantRecordDto>());
RuntimeDepthsDto depths = await collector.CollectAsync(CancellationToken.None);
@@ -123,11 +123,11 @@ public sealed class RuntimeDepthsCollectorTests
Status = PipelineQueueStatuses.New,
};
private static Dictionary<string, FakePipelineStore> PipelineStores(params Guid[] tenants)
=> tenants.ToDictionary(tenant => tenant.ToString("N"), _ => new FakePipelineStore());
private static Dictionary<string, TestPipelineStore> PipelineStores(params Guid[] tenants)
=> tenants.ToDictionary(tenant => tenant.ToString("N"), _ => new TestPipelineStore());
private static Dictionary<string, FakeMlLearningStore> OutboxStores(params Guid[] tenants)
=> tenants.ToDictionary(tenant => tenant.ToString("N"), _ => new FakeMlLearningStore());
private static Dictionary<string, TestMlLearningStore> OutboxStores(params Guid[] tenants)
=> tenants.ToDictionary(tenant => tenant.ToString("N"), _ => new TestMlLearningStore());
private static TenantRecordDto[] TenantRecords(params Guid[] tenants)
=> tenants
@@ -139,8 +139,8 @@ public sealed class RuntimeDepthsCollectorTests
// Собирает коллектор поверх tenant-scoped фейков (как реальные адаптеры по ITenantContext).
private static RuntimeDepthsCollector Build(
IReadOnlyDictionary<string, FakePipelineStore> pipelineByTenant,
IReadOnlyDictionary<string, FakeMlLearningStore> outboxByTenant,
IReadOnlyDictionary<string, TestPipelineStore> pipelineByTenant,
IReadOnlyDictionary<string, TestMlLearningStore> outboxByTenant,
IReadOnlyList<TenantRecordDto>? tenants = null,
ITenantLimitStore? limitStore = null)
{
@@ -153,12 +153,12 @@ public sealed class RuntimeDepthsCollectorTests
new TenantRecordDto(TenantB, "B", "active", DateTimeOffset.UtcNow),
}).ToArray()).Repository);
services.AddScoped<IPipelineStore>(provider => pipelineByTenant[CurrentTenant(provider)]);
services.AddScoped<IMlClient>(_ => new FakeMlClient());
services.AddScoped<IPipelineStore>(provider => pipelineByTenant[CurrentTenant(provider)].Store);
services.AddScoped<IMlClient>(_ => new TestMlClient().Client);
services.AddScoped<PipelineIngestService>();
services.AddScoped<PipelineProcessingService>();
services.AddScoped<IMlLearningStore>(provider => outboxByTenant[CurrentTenant(provider)]);
services.AddSingleton<ITenantLimitStore>(limitStore ?? new FakeTenantLimitStore());
services.AddScoped<IMlLearningStore>(provider => outboxByTenant[CurrentTenant(provider)].Store);
services.AddSingleton<ITenantLimitStore>(limitStore ?? new TestTenantLimitStore().Store);
ServiceProvider provider = services.BuildServiceProvider();
return new RuntimeDepthsCollector(
@@ -43,7 +43,7 @@ public sealed class TgStatusServiceTests
[Fact]
public async Task GetAsync_GatewayUnavailable_ReturnsIdleFormWithKvAccountCountAndKeys()
{
(TgStatusService service, TestTelegramStore store, FakeSettingsStore settings, TestTelegramGateway gateway, _, TestGlobalSettingsStore globalSettings) = Create();
(TgStatusService service, TestTelegramStore store, TestSettingsStore settings, TestTelegramGateway gateway, _, TestGlobalSettingsStore globalSettings) = Create();
store.Seed(Dialog("d_1", "Канал", "channel", Monitor: true));
store.Seed(Dialog("d_2", "Группа", "group", Monitor: true));
store.Seed(Dialog("d_off", "Выключен", "channel", Monitor: false));
@@ -69,7 +69,7 @@ public sealed class TgStatusServiceTests
[Fact]
public async Task GetAsync_ReadyGateway_ComposesLiveFieldsWithKvAccountMonitoredAndKeys()
{
(TgStatusService service, TestTelegramStore store, FakeSettingsStore settings, TestTelegramGateway gateway, _, TestGlobalSettingsStore globalSettings) = Create();
(TgStatusService service, TestTelegramStore store, TestSettingsStore settings, TestTelegramGateway gateway, _, TestGlobalSettingsStore globalSettings) = Create();
store.Seed(Dialog("d_1", "Канал", "channel", Monitor: true));
settings.Preload(SettingsKeys.TgAccount, "\"@realuser\"");
PreloadKeys(globalSettings, "123456", "abcdefghijklmnop");
@@ -106,16 +106,16 @@ public sealed class TgStatusServiceTests
// ─── Хелперы ────────────────────────────────────────────────────────────
// Собирает сервис на фейках (FakeTelegramGateway/Store/SettingsStore/GlobalSettingsStore + ISecretCipher).
private static (TgStatusService Service, TestTelegramStore Store, FakeSettingsStore Settings, TestTelegramGateway Gateway, ISecretCipher Cipher, TestGlobalSettingsStore GlobalSettings) Create()
private static (TgStatusService Service, TestTelegramStore Store, TestSettingsStore Settings, TestTelegramGateway Gateway, ISecretCipher Cipher, TestGlobalSettingsStore GlobalSettings) Create()
{
var store = new TestTelegramStore();
var settings = new FakeSettingsStore();
var settings = new TestSettingsStore();
var gateway = new TestTelegramGateway();
var cipher = TestCiphers.New();
var globalSettings = new TestGlobalSettingsStore();
var keys = new TelegramKeysService(globalSettings.Store, cipher);
var dialogs = new DialogsService(store.Store, settings, gateway.Gateway, NullLogger<DialogsService>.Instance);
var service = new TgStatusService(gateway.Gateway, dialogs, settings, keys);
var dialogs = new DialogsService(store.Store, settings.Store, gateway.Gateway, NullLogger<DialogsService>.Instance);
var service = new TgStatusService(gateway.Gateway, dialogs, settings.Store, keys);
return (service, store, settings, gateway, cipher, globalSettings);
}
@@ -18,7 +18,7 @@ namespace Deal.Tests.Unit.Contracts;
/// </summary>
public sealed class BudgetedAiClassifierTests
{
// Id тенанта сценариев строкой (формат N) — Guid ключа строк лимита FakeTenantLimitStore.
// Id тенанта сценариев строкой (формат N) — Guid ключа строк лимита TestTenantLimitStore.
private const string TenantIdValue = "0123456789abcdef0123456789abcdef";
// Guid того же тенанта — ключ строки лимита.
@@ -128,7 +128,7 @@ public sealed class BudgetedAiClassifierTests
Context ctx = Create(
limits => limits.Preload(
TenantGuid, budgetTokens: 0, TenantLimitPeriods.Month, PeriodStart, usedTokens: 0));
var standaloneLocal = new LocalAiClassifier(new LocalFieldsParser(ctx.Settings));
var standaloneLocal = new LocalAiClassifier(new LocalFieldsParser(ctx.Settings.Store));
const string text = "Ищем Python-разработчика на проект в команду\nБюджет: до 2к$";
AiParsedCardDto result = await ctx.Decorator.ClassifyAsync(text, CancellationToken.None);
@@ -152,24 +152,24 @@ public sealed class BudgetedAiClassifierTests
private sealed record Context(
IAiClassifier Decorator,
TestAiClassifier Paid,
FakeSettingsStore Settings);
TestSettingsStore Settings);
// Собирает контекст: платный фейк + реальный Local-классификатор + фейк лимитов и tenant-контекст
// (зеркало регистрации AddDealIntegrations при UseLocal=false).
// configure: Настройка строки лимита сценария (null — строки нет, ленивый дефолт-бюджет).
// Возвращает: Контекст теста.
private static Context Create(Action<FakeTenantLimitStore>? configure = null)
private static Context Create(Action<TestTenantLimitStore>? configure = null)
{
var settings = new FakeSettingsStore();
var limits = new FakeTenantLimitStore();
var settings = new TestSettingsStore();
var limits = new TestTenantLimitStore();
configure?.Invoke(limits);
ITenantContext tenantContext = new TenantContext();
tenantContext.SetTenant(new TenantId(TenantIdValue));
var paid = new TestAiClassifier();
IAiClassifier decorator = new BudgetedAiClassifier(
paid.Classifier,
new LocalAiClassifier(new LocalFieldsParser(settings)),
limits,
new LocalAiClassifier(new LocalFieldsParser(settings.Store)),
limits.Store,
tenantContext,
NullLogger<BudgetedAiClassifier>.Instance);
return new Context(decorator, paid, settings);
@@ -18,7 +18,7 @@ namespace Deal.Tests.Unit.Contracts;
/// </summary>
public sealed class BudgetedAiToolsTests
{
// Id тенанта сценариев строкой (формат N) — Guid ключа строк лимита FakeTenantLimitStore.
// Id тенанта сценариев строкой (формат N) — Guid ключа строк лимита TestTenantLimitStore.
private const string TenantIdValue = "0123456789abcdef0123456789abcdef";
// Guid того же тенанта — ключ строки лимита.
@@ -139,16 +139,16 @@ public sealed class BudgetedAiToolsTests
// AddDealIntegrations при UseLocal=false).
// configure: Настройка строки лимита сценария (null — строки нет, ленивый дефолт-бюджет).
// Возвращает: Контекст теста.
private static Context Create(Action<FakeTenantLimitStore>? configure = null)
private static Context Create(Action<TestTenantLimitStore>? configure = null)
{
var limits = new FakeTenantLimitStore();
var limits = new TestTenantLimitStore();
configure?.Invoke(limits);
ITenantContext tenantContext = new TenantContext();
tenantContext.SetTenant(new TenantId(TenantIdValue));
var paid = TestAiTools.New();
IAiTools decorator = new BudgetedAiTools(
paid,
limits,
limits.Store,
tenantContext,
NullLogger<BudgetedAiTools>.Instance);
return new Context(decorator, paid);
@@ -6,7 +6,6 @@ using Deal.Modules.Pipeline.Application.Models;
using Deal.Modules.Pipeline.Application.Services;
using Deal.Modules.Settings.Application.Models;
using Deal.Modules.Settings.Application.Services;
using Deal.Tests.Unit.Modules.Cards;
using Deal.Tests.Unit.Modules.Settings;
using Deal.Tests.Unit.Support;
@@ -109,7 +108,7 @@ public sealed class CardComposerTests
[Fact]
public async Task BuildAsync_BoardWithoutRules_PlacesCardIntoBoardWithEmptyMatchHits()
{
(CardComposer composer, FakeKanjStore store, _) = Create();
(CardComposer composer, TestKanjStore store, _) = Create();
store.SeedBoard(new ContainerDto { Id = "b_py" });
CardSnapshot snapshot = await composer.BuildAsync(
@@ -125,7 +124,7 @@ public sealed class CardComposerTests
[Fact]
public async Task BuildAsync_BoardWithNonMatchingRules_FallsBackToInbox()
{
(CardComposer composer, FakeKanjStore store, _) = Create();
(CardComposer composer, TestKanjStore store, _) = Create();
store.SeedBoard(BoardWithRules("b_wpf", new[] { "wpf" }));
CardSnapshot snapshot = await composer.BuildAsync(
@@ -141,7 +140,7 @@ public sealed class CardComposerTests
[Fact]
public async Task BuildAsync_BoardWithMatchingRules_PlacesCardAndComputesMatchHits()
{
(CardComposer composer, FakeKanjStore store, _) = Create();
(CardComposer composer, TestKanjStore store, _) = Create();
store.SeedBoard(BoardWithRules("b_py", new[] { "python" }));
CardSnapshot snapshot = await composer.BuildAsync(
@@ -193,7 +192,7 @@ public sealed class CardComposerTests
[Fact]
public async Task BuildAsync_ConversionDisabled_LeavesConvFieldsEmpty()
{
(CardComposer composer, _, FakeSettingsStore settings) = Create();
(CardComposer composer, _, TestSettingsStore settings) = Create();
settings.Preload(SettingsKeys.ConversionOn, "false"); // budget_to_target L348349
CardSnapshot snapshot = await composer.BuildAsync(
@@ -232,11 +231,11 @@ public sealed class CardComposerTests
// Создаёт контекст теста: пустые хранилища (дефолты: конверсия включена, курсы — мок).
// Возвращает: Кортеж (композитор, канбан-хранилище, KV-настройки).
private static (CardComposer Composer, FakeKanjStore Store, FakeSettingsStore Settings) Create()
private static (CardComposer Composer, TestKanjStore Store, TestSettingsStore Settings) Create()
{
var store = new FakeKanjStore();
var settings = new FakeSettingsStore();
return (new CardComposer(store, settings), store, settings);
var store = new TestKanjStore();
var settings = new TestSettingsStore();
return (new CardComposer(store.Store, settings.Store), store, settings);
}
// Разбор карточки со значениями по умолчанию (сценарий теста перекрывает нужные поля).
@@ -1,10 +1,10 @@
using Deal.Contracts.Integrations.Models;
using Deal.Tests.Unit.Support;
using Deal.Modules.Cards.Application.Sources;
using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Kanban.Application.Services;
using Deal.Tests.Unit.Modules.Cards;
using Deal.SharedKernel.Errors;
using Deal.Tests.Unit.Modules.Settings;
using Deal.Tests.Unit.Support;
namespace Deal.Tests.Unit.Contracts;
@@ -17,7 +17,7 @@ public sealed class CardsServiceTests
[Fact]
public async Task ListCards_NoCol_ReturnsAllOrderedByReceivedAtDesc()
{
(CardsService service, FakeKanjStore store, _, _) = Create();
(CardsService service, TestKanjStore store, _, _) = Create();
store.SeedCard(Card("l_1", "inbox", receivedAtMs: 100));
store.SeedCard(Card("l_2", "b_x", receivedAtMs: 300));
@@ -30,7 +30,7 @@ public sealed class CardsServiceTests
[Fact]
public async Task ListCards_ByColumn_ReturnsOnlyColumnCards()
{
(CardsService service, FakeKanjStore store, _, _) = Create();
(CardsService service, TestKanjStore store, _, _) = Create();
store.SeedCard(Card("l_1", "inbox"));
store.SeedCard(Card("l_2", KanbanColumns.Trash));
@@ -43,7 +43,7 @@ public sealed class CardsServiceTests
[Fact]
public async Task GetCard_Existing_ReturnsCard()
{
(CardsService service, FakeKanjStore store, _, _) = Create();
(CardsService service, TestKanjStore store, _, _) = Create();
store.SeedCard(Card("l_1", "inbox", title: "Middle Python"));
CardDto? card = await service.GetCardAsync("l_1", CancellationToken.None);
@@ -66,7 +66,7 @@ public sealed class CardsServiceTests
[Fact]
public async Task Move_ToBoardWithRules_UpdatesColumnAndComputesMatchHits()
{
(CardsService service, FakeKanjStore store, _, _) = Create();
(CardsService service, TestKanjStore store, _, _) = Create();
store.SeedBoard(Board("b_py", Rules(stack: new[] { "python" })));
store.SeedCard(Card("l_1", "inbox", sourceMsg: "Нужен Middle Python-разработчик на бота, 1600$."));
@@ -84,7 +84,7 @@ public sealed class CardsServiceTests
[Fact]
public async Task Move_ToBoard_WritesJournalAndPush()
{
(CardsService service, FakeKanjStore store, _, FakeMlClient ml) = Create();
(CardsService service, TestKanjStore store, _, TestMlClient ml) = Create();
store.SeedBoard(Board("b_py", Rules(stack: new[] { "python" })));
store.SeedCard(Card("l_1", "inbox", sourceMsg: "Нужен Python-разработчик"));
@@ -105,7 +105,7 @@ public sealed class CardsServiceTests
[Fact]
public async Task Move_ToInbox_WritesJournalWithoutPush()
{
(CardsService service, FakeKanjStore store, _, FakeMlClient ml) = Create();
(CardsService service, TestKanjStore store, _, TestMlClient ml) = Create();
store.SeedBoard(Board("b_py", Rules(stack: new[] { "python" })));
store.SeedCard(Card("l_1", "b_py", sourceMsg: "Нужен Python-разработчик", prevCol: "inbox"));
@@ -124,7 +124,7 @@ public sealed class CardsServiceTests
[Fact]
public async Task Move_ToUnknownBoard_Returns400Text()
{
(CardsService service, FakeKanjStore store, _, FakeMlClient ml) = Create();
(CardsService service, TestKanjStore store, _, TestMlClient ml) = Create();
store.SeedCard(Card("l_1", "inbox", sourceMsg: "Нужен Python-разработчик"));
CardResultDto result = await service.MoveDashboardCardAsync("l_1", "b_ghost", CancellationToken.None);
@@ -148,7 +148,7 @@ public sealed class CardsServiceTests
[Fact]
public async Task Move_CardMissing_ReturnsNullLead()
{
(CardsService service, FakeKanjStore store, _, FakeMlClient ml) = Create();
(CardsService service, TestKanjStore store, _, TestMlClient ml) = Create();
store.SeedBoard(Board("b_py"));
CardResultDto result = await service.MoveDashboardCardAsync("l_ghost", "b_py", CancellationToken.None);
@@ -162,7 +162,7 @@ public sealed class CardsServiceTests
[Fact]
public async Task Move_SameColumn_NoOpWithoutJournalAndPush()
{
(CardsService service, FakeKanjStore store, _, FakeMlClient ml) = Create();
(CardsService service, TestKanjStore store, _, TestMlClient ml) = Create();
store.SeedBoard(Board("b_py", Rules(stack: new[] { "python" })));
store.SeedCard(Card("l_1", "b_py", sourceMsg: "Нужен Python-разработчик", isNew: true));
@@ -179,7 +179,7 @@ public sealed class CardsServiceTests
[Fact]
public async Task Move_ToBoardWithoutRules_MatchHitsEmptyButPushSent()
{
(CardsService service, FakeKanjStore store, _, FakeMlClient ml) = Create();
(CardsService service, TestKanjStore store, _, TestMlClient ml) = Create();
store.SeedBoard(Board("b_free")); // правил нет
store.SeedCard(Card("l_1", "inbox", sourceMsg: "Любой текст"));
@@ -195,7 +195,7 @@ public sealed class CardsServiceTests
[Fact]
public async Task Move_FromBoardToBoard_PrevColIsOldBoardAndPushNewLabel()
{
(CardsService service, FakeKanjStore store, _, FakeMlClient ml) = Create();
(CardsService service, TestKanjStore store, _, TestMlClient ml) = Create();
store.SeedBoard(Board("b_java", Rules(stack: new[] { "java" })));
store.SeedBoard(Board("b_py", Rules(stack: new[] { "python" })));
store.SeedCard(Card("l_1", "b_java", sourceMsg: "Нужен Java-разработчик", prevCol: "inbox"));
@@ -214,7 +214,7 @@ public sealed class CardsServiceTests
[Fact]
public async Task Move_EmptySourceText_UsesTitleForMatchHitsAndPush()
{
(CardsService service, FakeKanjStore store, _, FakeMlClient ml) = Create();
(CardsService service, TestKanjStore store, _, TestMlClient ml) = Create();
store.SeedBoard(Board("b_py", Rules(stack: new[] { "python" })));
store.SeedCard(Card("l_1", "inbox", title: "Middle Python-разработчик", sourceMsg: " "));
@@ -228,7 +228,7 @@ public sealed class CardsServiceTests
[Fact]
public async Task Move_EmptySourceAndTitle_JournalWithoutPush()
{
(CardsService service, FakeKanjStore store, _, FakeMlClient ml) = Create();
(CardsService service, TestKanjStore store, _, TestMlClient ml) = Create();
store.SeedBoard(Board("b_py"));
store.SeedCard(Card("l_1", "inbox"));
@@ -241,7 +241,7 @@ public sealed class CardsServiceTests
[Fact]
public async Task Move_ToBoardWithGradeAndBudget_HitsIncludeGradeWordAndBudget()
{
(CardsService service, FakeKanjStore store, _, _) = Create();
(CardsService service, TestKanjStore store, _, _) = Create();
store.SeedBoard(Board("b_mid", Rules(
grade: new[] { "middle" },
budget: new BudgetRangeDto(1000, 3000, "USD"))));
@@ -263,7 +263,7 @@ public sealed class CardsServiceTests
[Fact]
public async Task Move_FromArchive_Returns400AndWritesNothing()
{
(CardsService service, FakeKanjStore store, _, FakeMlClient ml) = Create();
(CardsService service, TestKanjStore store, _, TestMlClient ml) = Create();
store.SeedBoard(Board("b_py"));
store.SeedCard(Card("l_1", KanbanColumns.Archive, sourceMsg: "Старая вакансия", prevCol: "b_py"));
@@ -279,7 +279,7 @@ public sealed class CardsServiceTests
[Fact]
public async Task Move_FromTrash_Returns400AndWritesNothing()
{
(CardsService service, FakeKanjStore store, _, FakeMlClient ml) = Create();
(CardsService service, TestKanjStore store, _, TestMlClient ml) = Create();
store.SeedBoard(Board("b_py"));
store.SeedCard(Card("l_1", KanbanColumns.Trash, sourceMsg: "Спам-текст", prevCol: "b_py"));
@@ -296,7 +296,7 @@ public sealed class CardsServiceTests
[Fact]
public async Task Trash_FromInbox_MovesToTrashWithJournalAndSpamPush()
{
(CardsService service, FakeKanjStore store, _, FakeMlClient ml) = Create();
(CardsService service, TestKanjStore store, _, TestMlClient ml) = Create();
store.SeedCard(Card("l_1", "inbox", sourceMsg: "Предлагаю услуги продвижения", isNew: true));
CardDto? result = await service.TrashCardAsync("l_1", CancellationToken.None);
@@ -318,7 +318,7 @@ public sealed class CardsServiceTests
[Fact]
public async Task Trash_AlreadyInTrash_NoOpWithoutJournalAndPush()
{
(CardsService service, FakeKanjStore store, _, FakeMlClient ml) = Create();
(CardsService service, TestKanjStore store, _, TestMlClient ml) = Create();
store.SeedCard(Card("l_1", KanbanColumns.Trash, sourceMsg: "Текст"));
CardDto? result = await service.TrashCardAsync("l_1", CancellationToken.None);
@@ -331,7 +331,7 @@ public sealed class CardsServiceTests
[Fact]
public async Task Trash_FromArchive_WritesJournalWithoutPush()
{
(CardsService service, FakeKanjStore store, _, FakeMlClient ml) = Create();
(CardsService service, TestKanjStore store, _, TestMlClient ml) = Create();
store.SeedCard(Card("l_1", KanbanColumns.Archive, sourceMsg: "Старая карточка", prevCol: "b_py"));
CardDto? result = await service.TrashCardAsync("l_1", CancellationToken.None);
@@ -344,13 +344,13 @@ public sealed class CardsServiceTests
}
[Fact]
public async Task Trash_CardMissing_ReturnsNull()
public async Task Trash_CardMissing_ThrowsNotFound()
{
(CardsService service, _, _, FakeMlClient ml) = Create();
(CardsService service, _, _, TestMlClient ml) = Create();
CardDto? result = await service.TrashCardAsync("l_ghost", CancellationToken.None);
await Assert.ThrowsAsync<NotFoundException>(
() => service.TrashCardAsync("l_ghost", CancellationToken.None));
Assert.Null(result); // эндпоинт отвечает 404 «Карточка не найдена»
Assert.Empty(ml.Pushed);
}
@@ -358,7 +358,7 @@ public sealed class CardsServiceTests
[Fact]
public async Task Restore_FromTrash_ToPrevColBoard_UnlearnsSpam()
{
(CardsService service, FakeKanjStore store, _, FakeMlClient ml) = Create();
(CardsService service, TestKanjStore store, _, TestMlClient ml) = Create();
store.SeedBoard(Board("b_py", Rules(stack: new[] { "python" })));
store.SeedCard(Card("l_1", KanbanColumns.Trash, sourceMsg: "Нужен Python-разработчик", prevCol: "b_py"));
@@ -383,7 +383,7 @@ public sealed class CardsServiceTests
[Fact]
public async Task Restore_FromArchive_NoPush()
{
(CardsService service, FakeKanjStore store, _, FakeMlClient ml) = Create();
(CardsService service, TestKanjStore store, _, TestMlClient ml) = Create();
store.SeedBoard(Board("b_py"));
store.SeedCard(Card("l_1", KanbanColumns.Archive, sourceMsg: "Старая карточка", prevCol: "b_py"));
@@ -399,7 +399,7 @@ public sealed class CardsServiceTests
[Fact]
public async Task Restore_FromTrash_PrevColDeletedBoard_FallsBackToInbox()
{
(CardsService service, FakeKanjStore store, _, FakeMlClient ml) = Create();
(CardsService service, TestKanjStore store, _, TestMlClient ml) = Create();
store.SeedCard(Card("l_1", KanbanColumns.Trash, sourceMsg: "Текст", prevCol: "b_gone"));
string? back = await service.RestoreCardAsync("l_1", CancellationToken.None);
@@ -414,7 +414,7 @@ public sealed class CardsServiceTests
[Fact]
public async Task Restore_FromTrash_PrevColInbox_ReturnsInbox()
{
(CardsService service, FakeKanjStore store, _, _) = Create();
(CardsService service, TestKanjStore store, _, _) = Create();
store.SeedCard(Card("l_1", KanbanColumns.Trash, sourceMsg: "Текст", prevCol: "inbox"));
string? back = await service.RestoreCardAsync("l_1", CancellationToken.None);
@@ -426,20 +426,19 @@ public sealed class CardsServiceTests
}
[Fact]
public async Task Restore_CardMissing_ReturnsNull()
public async Task Restore_CardMissing_ThrowsNotFound()
{
(CardsService service, _, _, _) = Create();
string? back = await service.RestoreCardAsync("l_ghost", CancellationToken.None);
Assert.Null(back); // эндпоинт отвечает 404 «Карточка не найдена»
await Assert.ThrowsAsync<NotFoundException>(
() => service.RestoreCardAsync("l_ghost", CancellationToken.None));
}
[Fact]
public async Task DeleteForever_RemovesCardAndComments_KeepsJournal()
{
(CardsService service, FakeKanjStore store, _, _) = Create();
(CardsService service, TestKanjStore store, _, _) = Create();
store.SeedCard(Card("l_1", KanbanColumns.Trash,
sourceMsg: "Нужен Python-разработчик",
comments: [new CardCommentDto("cm_1", "Вы", "Перезвонить", "5 мин")]));
@@ -454,7 +453,7 @@ public sealed class CardsServiceTests
[Fact]
public async Task DeleteForever_JournalRowsSurviveCardDeletion()
{
(CardsService service, FakeKanjStore store, _, _) = Create();
(CardsService service, TestKanjStore store, _, _) = Create();
store.SeedBoard(Board("b_py", Rules(stack: new[] { "python" })));
store.SeedCard(Card("l_1", "inbox", sourceMsg: "Нужен Python-разработчик"));
@@ -480,7 +479,7 @@ public sealed class CardsServiceTests
[Fact]
public async Task ClearCol_Trash_ReturnsClearedCount()
{
(CardsService service, FakeKanjStore store, _, _) = Create();
(CardsService service, TestKanjStore store, _, _) = Create();
store.SeedCard(Card("l_1", KanbanColumns.Trash));
store.SeedCard(Card("l_2", KanbanColumns.Trash));
store.SeedCard(Card("l_3", KanbanColumns.Archive));
@@ -507,7 +506,7 @@ public sealed class CardsServiceTests
[Fact]
public async Task ClearCol_Board_Returns400Text()
{
(CardsService service, FakeKanjStore store, _, _) = Create();
(CardsService service, TestKanjStore store, _, _) = Create();
store.SeedBoard(Board("b_py"));
store.SeedCard(Card("l_1", "b_py"));
@@ -531,7 +530,7 @@ public sealed class CardsServiceTests
[Fact]
public async Task MarkSeen_ById_OnlyThatCard()
{
(CardsService service, FakeKanjStore store, _, _) = Create();
(CardsService service, TestKanjStore store, _, _) = Create();
store.SeedCard(Card("l_1", "inbox", isNew: true));
store.SeedCard(Card("l_2", "inbox", isNew: true));
@@ -544,7 +543,7 @@ public sealed class CardsServiceTests
[Fact]
public async Task MarkSeen_ByCol_OnlyColumnCards()
{
(CardsService service, FakeKanjStore store, _, _) = Create();
(CardsService service, TestKanjStore store, _, _) = Create();
store.SeedCard(Card("l_1", "inbox", isNew: true));
store.SeedCard(Card("l_2", "inbox", isNew: true));
store.SeedCard(Card("l_3", "b_py", isNew: true));
@@ -558,7 +557,7 @@ public sealed class CardsServiceTests
[Fact]
public async Task MarkSeen_All_WithoutParameters()
{
(CardsService service, FakeKanjStore store, _, _) = Create();
(CardsService service, TestKanjStore store, _, _) = Create();
store.SeedCard(Card("l_1", "inbox", isNew: true));
store.SeedCard(Card("l_2", KanbanColumns.Trash, isNew: true));
@@ -571,7 +570,7 @@ public sealed class CardsServiceTests
[Fact]
public async Task AddComment_EmptyOrWhitespaceText_Returns400Text()
{
(CardsService service, FakeKanjStore store, _, _) = Create();
(CardsService service, TestKanjStore store, _, _) = Create();
store.SeedCard(Card("l_1", "inbox"));
AddCommentResultDto empty = await service.AddCommentAsync("l_1", " ", CancellationToken.None);
@@ -585,7 +584,7 @@ public sealed class CardsServiceTests
[Fact]
public async Task AddComment_Valid_AppendsTrimmedCommentWithJournal()
{
(CardsService service, FakeKanjStore store, _, _) = Create();
(CardsService service, TestKanjStore store, _, _) = Create();
store.SeedCard(Card("l_1", "inbox"));
AddCommentResultDto result = await service.AddCommentAsync("l_1", " Перезвонить завтра ", CancellationToken.None);
@@ -618,7 +617,7 @@ public sealed class CardsServiceTests
[Fact]
public async Task Counts_ColumnsNewAndMlStats()
{
(CardsService service, FakeKanjStore store, _, FakeMlClient ml) = Create();
(CardsService service, TestKanjStore store, _, TestMlClient ml) = Create();
store.SeedCard(Card("l_1", "inbox", isNew: true));
store.SeedCard(Card("l_2", "inbox"));
store.SeedCard(Card("l_3", "b_py"));
@@ -652,7 +651,7 @@ public sealed class CardsServiceTests
[Fact]
public async Task Search_QueryShorterThanTwoChars_ReturnsEmptyAndDoesNotCallStore()
{
(CardsService service, FakeKanjStore store, _, _) = Create();
(CardsService service, TestKanjStore store, _, _) = Create();
store.SeedCard(Card("l_1", "inbox", title: "Python-разработчик"));
IReadOnlyList<CardDto> one = await service.SearchCardsAsync("p", CancellationToken.None);
@@ -666,7 +665,7 @@ public sealed class CardsServiceTests
[Fact]
public async Task Search_DelegatesToStoreWithQueryAndLimit()
{
(CardsService service, FakeKanjStore store, _, _) = Create();
(CardsService service, TestKanjStore store, _, _) = Create();
store.SeedCard(Card("l_1", "inbox", title: "Middle Python-разработчик", receivedAtMs: 300));
IReadOnlyList<CardDto> result = await service.SearchCardsAsync(" pYtHoN ", CancellationToken.None);
@@ -681,7 +680,7 @@ public sealed class CardsServiceTests
[Fact]
public async Task Search_FindsByTitleSummaryContactSourceText_OrderedByReceivedAtDesc()
{
(CardsService service, FakeKanjStore store, _, _) = Create();
(CardsService service, TestKanjStore store, _, _) = Create();
store.SeedCard(Card("l_title", "inbox", title: "Middle Python-разработчик", receivedAtMs: 300));
store.SeedCard(Card("l_summary", "b_py", summary: "проект на python", receivedAtMs: 200));
store.SeedCard(Card("l_contact", "inbox", contact: "@python_dev", receivedAtMs: 100));
@@ -696,7 +695,7 @@ public sealed class CardsServiceTests
[Fact]
public async Task Search_LimitIsTwelve()
{
(CardsService service, FakeKanjStore store, _, _) = Create();
(CardsService service, TestKanjStore store, _, _) = Create();
for (int i = 0; i < 15; i++)
{
store.SeedCard(Card($"l_{i:00}", "inbox", title: $"python {i}", receivedAtMs: i));
@@ -711,12 +710,12 @@ public sealed class CardsServiceTests
// ─── Хелперы ──────────────────────────────────────────────────────────
private static (CardsService Service, FakeKanjStore Store, FakeSettingsStore Settings, FakeMlClient Ml) Create()
private static (CardsService Service, TestKanjStore Store, TestSettingsStore Settings, TestMlClient Ml) Create()
{
var store = new FakeKanjStore();
var settings = new FakeSettingsStore();
var ml = new FakeMlClient();
return (new CardsService(store, settings, ml, new TestFileStorage().Storage), store, settings, ml);
var store = new TestKanjStore();
var settings = new TestSettingsStore();
var ml = new TestMlClient();
return (new CardsService(store.Store, settings.Store, ml.Client, new TestFileStorage().Storage), store, settings, ml);
}
// Доска с правилами (ContainerRulesDto) либо без них.
@@ -38,7 +38,7 @@ public sealed class DialogsServiceTests
[Fact]
public async Task Sync_AutoMonitorNewFalse_AddsNewDialogsUnmonitored()
{
(DialogsService service, TestTelegramStore store, FakeSettingsStore settings, _) = Create();
(DialogsService service, TestTelegramStore store, TestSettingsStore settings, _) = Create();
settings.Preload(SettingsKeys.AutoMonitorNew, "false");
int count = await service.SyncFromTelegramAsync(Catalog(Entry("d_1", "Канал", "channel")), CancellationToken.None);
@@ -497,12 +497,12 @@ public sealed class DialogsServiceTests
// ─── Хелперы ────────────────────────────────────────────────────────────
// Собирает сервис на фейках (TestTelegramStore — семантика адаптера; настройки/гейт чистые).
private static (DialogsService Service, TestTelegramStore Store, FakeSettingsStore Settings, TestTelegramGateway Gateway) Create()
private static (DialogsService Service, TestTelegramStore Store, TestSettingsStore Settings, TestTelegramGateway Gateway) Create()
{
var store = new TestTelegramStore();
var settings = new FakeSettingsStore();
var settings = new TestSettingsStore();
var gateway = new TestTelegramGateway();
return (new DialogsService(store.Store, settings, gateway.Gateway, NullLogger<DialogsService>.Instance), store, settings, gateway);
return (new DialogsService(store.Store, settings.Store, gateway.Gateway, NullLogger<DialogsService>.Instance), store, settings, gateway);
}
// Строка каталога минимально (id/имя/kind + флаги/хвост каталога).
@@ -30,7 +30,7 @@ public sealed class DiscoveryEvaluatorTests
[Fact]
public async Task EvaluateMessage_MlSpam_NotFit()
{
(DiscoveryEvaluator evaluator, _, FakeMlClient ml, _) = Create(aiEnabled: false, mlEnabled: true);
(DiscoveryEvaluator evaluator, _, TestMlClient ml, _) = Create(aiEnabled: false, mlEnabled: true);
ml.Predict = new MlPredictResultDto(
Take: true, Label: "spam", Scores: new Dictionary<string, double>(), Hits: 3, Ready: true, Margin: 0.9,
Terms: Array.Empty<string>(), Type: null);
@@ -46,7 +46,7 @@ public sealed class DiscoveryEvaluatorTests
[Fact]
public async Task EvaluateMessage_AiFits_ReturnsAiVerdict()
{
(DiscoveryEvaluator evaluator, _, FakeMlClient ml, IAiTools ai) = Create(aiEnabled: true, mlEnabled: false);
(DiscoveryEvaluator evaluator, _, TestMlClient ml, IAiTools ai) = Create(aiEnabled: true, mlEnabled: false);
ai.ReturnsFit(new AiEvaluateFitResultDto(Fit: true, "тема совпадает"));
DiscoveryMessageFit fit = await evaluator.EvaluateMessageAsync(
@@ -168,14 +168,14 @@ public sealed class DiscoveryEvaluatorTests
=> new(id, text, DateMs: 1_700_000_000_000 + id, topic, TopicTitle: null);
// Создаёт оценщик на фейках: флаги веток выставляются сценарием.
private static (DiscoveryEvaluator Evaluator, FakeSettingsStore Settings, FakeMlClient Ml, IAiTools Ai) Create(bool aiEnabled, bool mlEnabled)
private static (DiscoveryEvaluator Evaluator, TestSettingsStore Settings, TestMlClient Ml, IAiTools Ai) Create(bool aiEnabled, bool mlEnabled)
{
var settings = new FakeSettingsStore();
var settings = new TestSettingsStore();
settings.Preload(SettingsKeys.AiEnabled, aiEnabled ? "true" : "false");
settings.Preload(SettingsKeys.MlEnabled, mlEnabled ? "true" : "false");
var ml = new FakeMlClient();
var ml = new TestMlClient();
var ai = TestAiTools.New();
return (new DiscoveryEvaluator(settings, ml, ai), settings, ml, ai);
return (new DiscoveryEvaluator(settings.Store, ml.Client, ai), settings, ml, ai);
}
// Задача как строка DiscTasks (адаптер-форма; нужны только keywords/description/threshold).
@@ -419,7 +419,7 @@ public sealed class DiscoveryWorkerServiceTests
// Контекст теста: воркер на фейках + ссылки для проверок состояния.
private sealed record Fixture(
TestDiscoveryStore Store,
FakeSettingsStore Settings,
TestSettingsStore Settings,
TestDiscoveryGateway Gateway,
IDiscoveryPacer Pacer,
DiscoveryWorkerService Worker,
@@ -429,21 +429,22 @@ public sealed class DiscoveryWorkerServiceTests
private static Fixture Create()
{
var store = new TestDiscoveryStore();
var settings = new FakeSettingsStore();
var settings = new TestSettingsStore();
settings.Preload(SettingsKeys.MlEnabled, "false");
settings.Preload(SettingsKeys.AiEnabled, "false");
var ml = new FakeMlClient();
var ml = new TestMlClient();
var ai = TestAiTools.New();
var log = new DiscoveryLogService(store.Store);
var blacklist = new DiscoveryBlacklistService(store.Store);
var candidates = new DiscoveryCandidatesService(store.Store, log, blacklist);
var planGuard = new DiscoveryPlanGuard(store.Store, settings);
var tasks = new DiscoveryTasksService(store.Store, planGuard, settings);
var banGuard = new DiscoveryBanGuard(store.Store, settings);
var planGuard = new DiscoveryPlanGuard(store.Store, settings.Store);
var tasks = new DiscoveryTasksService(store.Store, planGuard, settings.Store);
var banGuard = new DiscoveryBanGuard(store.Store, settings.Store);
var pacer = Substitute.For<IDiscoveryPacer>();
var gateway = new TestDiscoveryGateway();
var worker = new DiscoveryWorkerService(
store.Store, tasks, candidates, log, new DiscoveryEvaluator(settings, ml, ai), banGuard, pacer, gateway.Gateway);
store.Store, tasks, candidates, log, new DiscoveryEvaluator(settings.Store, ml.Client, ai), banGuard, pacer, gateway.Gateway,
new DiscoverySearchErrorCounter());
return new Fixture(store, settings, gateway, pacer, worker, tasks);
}
@@ -1,85 +0,0 @@
using Deal.Contracts.Integrations.Abstractions;
using Deal.Contracts.Integrations.Models;
namespace Deal.Tests.Unit.Contracts;
/// <summary>
/// In-memory реализация <see cref="IFileStorage"/> для unit-тестов файлов карточки
/// </summary>
public sealed class FakeFileStorage : IFileStorage
{
private readonly Dictionary<string, byte[]> _objects = new(StringComparer.Ordinal);
private readonly Dictionary<string, string> _contentTypes = new(StringComparer.Ordinal);
private readonly List<string> _deletedKeys = [];
/// <summary>
/// objectKey всех объектов, сохранённых на данный момент
/// </summary>
public IReadOnlyList<string> StoredObjectKeys => _objects.Keys.ToList();
/// <summary>
/// objectKey всех удалений в порядке вызовов DeleteAsync
/// </summary>
public IReadOnlyList<string> DeletedKeys => _deletedKeys.ToList();
/// <summary>
/// Содержимое сохранённого объекта по ключу либо null — объекта нет
/// </summary>
/// <param name="objectKey">Ключ объекта (opaque).</param>
public byte[]? ContentOf(string objectKey)
{
return _objects.TryGetValue(objectKey, out byte[]? content) ? content : null;
}
/// <inheritdoc />
public Task<string> PutAsync(
string objectKey,
Stream content,
string contentType,
CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(content);
using MemoryStream buffer = new();
if (content.CanSeek && content.Position != 0)
{
content.Position = 0; // контракт порта: Put читает всё содержимое с позиции 0 (Ruling T6)
}
content.CopyTo(buffer);
_objects[objectKey] = buffer.ToArray();
_contentTypes[objectKey] = contentType;
return Task.FromResult(objectKey);
}
/// <inheritdoc />
public Task<Stream?> GetAsync(string objectKey, CancellationToken ct)
{
if (!_objects.TryGetValue(objectKey, out byte[]? content))
{
return Task.FromResult<Stream?>(null);
}
return Task.FromResult<Stream?>(new MemoryStream(content));
}
/// <inheritdoc />
public Task<FileMeta?> StatAsync(string objectKey, CancellationToken ct)
{
if (!_objects.TryGetValue(objectKey, out byte[]? content))
{
return Task.FromResult<FileMeta?>(null);
}
string contentType = _contentTypes.TryGetValue(objectKey, out string? stored) ? stored : string.Empty;
return Task.FromResult<FileMeta?>(new FileMeta(objectKey, content.Length, contentType));
}
/// <inheritdoc />
public Task DeleteAsync(string objectKey, CancellationToken ct)
{
_deletedKeys.Add(objectKey);
_objects.Remove(objectKey);
return Task.CompletedTask;
}
}
@@ -39,9 +39,9 @@ public sealed class GrpcAiToolsTests
reply.Keywords.Add("python разработка");
reply.Keywords.Add("telegram каналы");
service.GenerateKeywordsReply = reply;
FakeSettingsStore settings = new();
TestSettingsStore settings = new();
ISecretCipher cipher = TestCiphers.New();
FakeTenantLimitStore limits = new();
TestTenantLimitStore limits = new();
IAiTools tools = CreateTools(port, settings, cipher, limits);
AiGenerateKeywordsResultDto result = await tools.GenerateKeywordsAsync("Бэкенд-разработка на Python", CancellationToken.None);
@@ -66,7 +66,7 @@ public sealed class GrpcAiToolsTests
await AiGrpcTestHost.RunAsync(AiGrpcTestHost.DefaultToken, new RecordingAiService(), async (port, service) =>
{
service.KeywordsUnavailable = true;
IAiTools tools = CreateTools(port, new FakeSettingsStore(), TestCiphers.New());
IAiTools tools = CreateTools(port, new TestSettingsStore(), TestCiphers.New());
AiGenerateKeywordsResultDto result = await tools.GenerateKeywordsAsync("описание", CancellationToken.None);
@@ -88,8 +88,8 @@ public sealed class GrpcAiToolsTests
Reason = "другая сфера",
Usage = new Usage { Prompt = 200, Completion = 10, Total = 210 },
};
FakeTenantLimitStore limits = new();
IAiTools tools = CreateTools(port, new FakeSettingsStore(), TestCiphers.New(), limits);
TestTenantLimitStore limits = new();
IAiTools tools = CreateTools(port, new TestSettingsStore(), TestCiphers.New(), limits);
AiEvaluateFitResultDto result = await tools.EvaluateFitAsync(
"Ищу дизайнера для лендинга", "Разработка сайтов на Python", new[] { "python", "бэкенд" }, CancellationToken.None);
@@ -113,7 +113,7 @@ public sealed class GrpcAiToolsTests
await AiGrpcTestHost.RunAsync(AiGrpcTestHost.DefaultToken, new RecordingAiService(), async (port, service) =>
{
service.FitUnavailable = true;
IAiTools tools = CreateTools(port, new FakeSettingsStore(), TestCiphers.New());
IAiTools tools = CreateTools(port, new TestSettingsStore(), TestCiphers.New());
await Assert.ThrowsAsync<AiUnavailableException>(
() => tools.EvaluateFitAsync("текст", "описание", new[] { "ключ" }, CancellationToken.None));
@@ -128,18 +128,19 @@ public sealed class GrpcAiToolsTests
// Возвращает: Экземпляр GrpcAiTools.
private static GrpcAiTools CreateTools(
int port,
FakeSettingsStore settings,
TestSettingsStore settings,
ISecretCipher cipher,
FakeTenantLimitStore? limits = null)
TestTenantLimitStore? limits = null)
{
limits ??= new TestTenantLimitStore();
ITenantContext tenantContext = new TenantContext();
tenantContext.SetTenant(new TenantId(TenantIdValue));
var connection = new AiGrpcConnection(new AiServiceOptions { UseLocal = false, Endpoint = $"http://127.0.0.1:{port}" });
return new GrpcAiTools(
tenantContext,
connection,
new AiProviderConfigBuilder(settings, cipher),
new TokenUsageRecorder(settings, limits ?? new FakeTenantLimitStore(), tenantContext, new TokenUsageEventService(new TestTokenUsageEventStore().Store)),
new AiProviderConfigBuilder(settings.Store, cipher),
new TokenUsageRecorder(settings.Store, limits.Store, tenantContext, new TokenUsageEventService(new TestTokenUsageEventStore().Store)),
NullLogger<GrpcAiTools>.Instance);
}
}
@@ -4,6 +4,7 @@ using Deal.Infrastructure.Data;
using Deal.Infrastructure.Integrations.Abstractions;
using Deal.Infrastructure.Integrations.Models;
using Deal.Infrastructure.Integrations.Options;
using Deal.Infrastructure.Integrations.Resilience;
using Deal.Infrastructure.Integrations.Services;
using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Settings.Application.Models;
@@ -91,7 +92,8 @@ public sealed class GrpcMlClientTests
Assert.Null(result.Margin);
Assert.Empty(result.Terms);
Assert.Null(result.Type);
Assert.Single(service.RequestTenantIds);
// Недоступность транспорта повторяется — на сервер приходит первая попытка и повторы.
Assert.Equal(GrpcRetry.RetryCount + 1, service.RequestTenantIds.Count);
});
}
@@ -110,10 +112,10 @@ public sealed class GrpcMlClientTests
Eval = new ModelEval { Count = 5, Correct = 4, Accuracy = 0.8 },
};
var settings = new FakeSettingsStore();
var settings = new TestSettingsStore();
settings.Preload(SettingsKeys.MlDecisions, "7");
settings.Preload(SettingsKeys.AiDecisions, "3");
var learning = new FakeMlLearningStore { LearningCount = 9 };
var learning = new TestMlLearningStore { LearningCount = 9 };
IMlClient client = CreateClient(port, settings, learning);
MlStatusResponseDto status = await client.StatusAsync(CancellationToken.None);
@@ -148,7 +150,8 @@ public sealed class GrpcMlClientTests
Assert.False(down.Reachable);
Assert.False(down.Service.Ready);
Assert.False(down.Stats.Reachable);
Assert.Equal(1, service.StatusCalls);
// При недоступности транспорта идёт повтор — считаем все попытки.
Assert.Equal(GrpcRetry.RetryCount + 1, service.StatusCalls);
// «Поднялся»: после TTL 15 с следующий StatusAsync обновляет кэш (ready=true, reachable=true).
service.StatusUnavailable = false;
@@ -165,7 +168,7 @@ public sealed class GrpcMlClientTests
Assert.True(up.Reachable);
Assert.True(up.Service.Ready);
Assert.Equal(3, up.Service.Learned);
Assert.Equal(2, service.StatusCalls);
Assert.Equal(GrpcRetry.RetryCount + 2, service.StatusCalls);
});
}
@@ -195,7 +198,7 @@ public sealed class GrpcMlClientTests
{
await MlGrpcTestHost.RunAsync(MlGrpcTestHost.DefaultToken, new RecordingMlService(), async (port, service) =>
{
var learning = new FakeMlLearningStore();
var learning = new TestMlLearningStore();
learning.SeedOutbox("mle_1", "текст 1", "b_a", 1.0);
learning.SeedOutbox("mle_2", "текст 2", "spam", 1.0);
IMlClient client = CreateClient(port, learning: learning);
@@ -206,7 +209,7 @@ public sealed class GrpcMlClientTests
Assert.True(reset.Ok);
Assert.Null(reset.Error);
Assert.Equal(0, await learning.CountOutboxAsync(CancellationToken.None)); // очередь очищена
Assert.Equal(0, await learning.Store.CountOutboxAsync(CancellationToken.None)); // очередь очищена
Assert.Equal(1, service.ResetCalls);
_ = await client.StatusAsync(CancellationToken.None);
@@ -220,7 +223,7 @@ public sealed class GrpcMlClientTests
await MlGrpcTestHost.RunAsync(MlGrpcTestHost.DefaultToken, new RecordingMlService(), async (port, service) =>
{
service.ResetReply = new ResetReply { Ok = false, Error = "не удалось пересоздать файл модели" };
var learning = new FakeMlLearningStore();
var learning = new TestMlLearningStore();
learning.SeedOutbox("mle_1", "текст", "b_a", 1.0);
IMlClient client = CreateClient(port, learning: learning);
@@ -228,7 +231,7 @@ public sealed class GrpcMlClientTests
Assert.False(reset.Ok);
Assert.Equal("не удалось пересоздать файл модели", reset.Error);
Assert.Equal(1, await learning.CountOutboxAsync(CancellationToken.None));
Assert.Equal(1, await learning.Store.CountOutboxAsync(CancellationToken.None));
});
}
@@ -238,7 +241,7 @@ public sealed class GrpcMlClientTests
await MlGrpcTestHost.RunAsync(MlGrpcTestHost.DefaultToken, new RecordingMlService(), async (port, service) =>
{
service.ResetUnavailable = true;
var learning = new FakeMlLearningStore();
var learning = new TestMlLearningStore();
learning.SeedOutbox("mle_1", "текст", "spam", 1.0);
IMlClient client = CreateClient(port, learning: learning);
@@ -246,7 +249,7 @@ public sealed class GrpcMlClientTests
Assert.False(reset.Ok);
Assert.Equal("ML-сервис недоступен", reset.Error);
Assert.Equal(1, await learning.CountOutboxAsync(CancellationToken.None));
Assert.Equal(1, await learning.Store.CountOutboxAsync(CancellationToken.None));
});
}
@@ -256,7 +259,7 @@ public sealed class GrpcMlClientTests
{
await MlGrpcTestHost.RunAsync(MlGrpcTestHost.DefaultToken, new RecordingMlService(), async (port, service) =>
{
var learning = new FakeMlLearningStore();
var learning = new TestMlLearningStore();
IMlClient client = CreateClient(port, learning: learning);
await client.PushAsync(" нужен python ", "b_junior", 1.0, CancellationToken.None);
@@ -309,22 +312,24 @@ public sealed class GrpcMlClientTests
// Возвращает: Экземпляр GrpcMlClient в tenant-контексте теста.
private static GrpcMlClient CreateClient(
int port,
FakeSettingsStore? settings = null,
FakeMlLearningStore? learning = null,
TestSettingsStore? settings = null,
TestMlLearningStore? learning = null,
MlStatusCache? cache = null)
{
settings ??= new TestSettingsStore();
learning ??= new TestMlLearningStore();
ITenantContext tenantContext = new TenantContext();
tenantContext.SetTenant(new TenantId(TenantIdValue));
var options = new MlServiceOptions { UseLocal = false, Endpoint = $"http://127.0.0.1:{port}" };
return new GrpcMlClient(
tenantContext,
settings ?? new FakeSettingsStore(),
learning ?? new FakeMlLearningStore(),
settings.Store,
learning.Store,
new MlGrpcConnection(options),
cache ?? new MlStatusCache(),
new TokenUsageRecorder(
settings ?? new FakeSettingsStore(),
new FakeTenantLimitStore(),
settings.Store,
new TestTenantLimitStore().Store,
tenantContext,
new TokenUsageEventService(new TestTokenUsageEventStore().Store)),
NullLogger<GrpcMlClient>.Instance);
@@ -13,7 +13,6 @@ using Deal.Modules.Tenants.Application.Abstractions;
using Deal.Modules.Tenants.Application.Services;
using Deal.SharedKernel.Tenants.Abstractions;
using Deal.SharedKernel.Tenants.Models;
using Deal.Tests.Unit.Modules.Cards;
using Deal.Tests.Unit.Modules.Kanban;
using Deal.Tests.Unit.Modules.Settings;
using Deal.Tests.Unit.Modules.Tenants;
@@ -144,11 +143,11 @@ public sealed class IntegrationsDiTests
ITenantContext tenantContext = new TenantContext();
tenantContext.SetTenant(new TenantId(Guid.NewGuid().ToString("N")));
services.AddSingleton<ITenantContext>(tenantContext);
services.AddScoped<ISettingsStore>(_ => new FakeSettingsStore());
services.AddScoped<ISettingsStore>(_ => new TestSettingsStore().Store);
services.AddScoped<ISecretCipher>(_ => TestCiphers.New());
services.AddScoped<ICardStore>(_ => new FakeKanjStore());
services.AddScoped<IMlLearningStore>(_ => new FakeMlLearningStore());
services.AddScoped<ITenantLimitStore>(_ => new FakeTenantLimitStore());
services.AddScoped<ICardStore>(_ => new TestKanjStore().Store);
services.AddScoped<IMlLearningStore>(_ => new TestMlLearningStore().Store);
services.AddScoped<ITenantLimitStore>(_ => new TestTenantLimitStore().Store);
services.AddScoped<TokenUsageEventService>(_ => new TokenUsageEventService(new TestTokenUsageEventStore().Store));
services.AddScoped<LocalFieldsParser>();
services.AddScoped<AiClassifyContextBuilder>();
@@ -2,6 +2,7 @@ using Deal.Contracts.Integrations.Models;
using Deal.Infrastructure.Integrations.Services;
using Deal.Modules.Pipeline.Application.Parse;
using Deal.Modules.Settings.Application.Models;
using Deal.Tests.Unit.Support;
using Deal.Tests.Unit.Modules.Settings;
namespace Deal.Tests.Unit.Contracts;
@@ -13,10 +14,10 @@ public sealed class LocalAiClassifierTests
{
// Создаёт контекст теста: пустое KV-хранилище (дефолты маркеров) + адаптер.
// Возвращает: Кортеж (KV-настройки, классификатор).
private static (FakeSettingsStore Settings, LocalAiClassifier Classifier) CreateContext()
private static (TestSettingsStore Settings, LocalAiClassifier Classifier) CreateContext()
{
var settings = new FakeSettingsStore();
var classifier = new LocalAiClassifier(new LocalFieldsParser(settings));
var settings = new TestSettingsStore();
var classifier = new LocalAiClassifier(new LocalFieldsParser(settings.Store));
return (settings, classifier);
}
@@ -140,7 +141,7 @@ public sealed class LocalAiClassifierTests
[Fact]
public async Task Classify_CustomHireMarkersOverride_AreUsed()
{
(FakeSettingsStore settings, LocalAiClassifier classifier) = CreateContext();
(TestSettingsStore settings, LocalAiClassifier classifier) = CreateContext();
// Дефолтные маркеры заменены: «вакансия» больше не маркер найма, свой — маркер.
settings.Preload(SettingsKeys.HireMarkers, "[\"нанимаем джунов\"]");
@@ -2,7 +2,6 @@ using Deal.Contracts.Integrations.Models;
using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Pipeline.Application.Models;
using Deal.Modules.Pipeline.Application.Services;
using Deal.Tests.Unit.Modules.Cards;
using Deal.Tests.Unit.Modules.Kanban;
using Deal.Tests.Unit.Modules.Settings;
using Deal.Tests.Unit.Support;
@@ -18,9 +17,9 @@ public sealed class PipelineCardWriterTests
[Fact]
public async Task CreateCard_CreatesInboxCardAndLinksDedupClaim()
{
(PipelineCardWriter writer, FakeKanjStore store, FakePipelineStore pipelineStore) = Create();
(PipelineCardWriter writer, TestKanjStore store, TestPipelineStore pipelineStore) = Create();
const string hash = "abcdef0123456789abcdef0123456789abcdef01";
await pipelineStore.ClaimAsync(hash, CancellationToken.None); // заявка воркера (LeadId=null, Ruling 8)
await pipelineStore.Store.ClaimAsync(hash, CancellationToken.None); // заявка воркера (LeadId=null, Ruling 8)
AiParsedCardDto parsed = Parsed(
title: "Python-разработчик на бота",
budget: new AiBudgetDto(2000, 2000, "USD"),
@@ -38,16 +37,16 @@ public sealed class PipelineCardWriterTests
Assert.Equal(new CardBudgetDto(2000, 2000, "USD"), card.Budget);
Assert.Equal(card.Id, pipelineStore.DedupLeadId(hash));
Assert.True(await pipelineStore.ExistsAsync(hash, CancellationToken.None));
Assert.True(await pipelineStore.Store.ExistsAsync(hash, CancellationToken.None));
}
[Fact]
public async Task CreateCard_AssignedBoardAccepted_WritesCardIntoBoardColumn()
{
(PipelineCardWriter writer, FakeKanjStore store, FakePipelineStore pipelineStore) = Create();
(PipelineCardWriter writer, TestKanjStore store, TestPipelineStore pipelineStore) = Create();
store.SeedBoard(new ContainerDto { Id = "b_py" });
const string hash = "abcdef0123456789abcdef0123456789abcdef01";
await pipelineStore.ClaimAsync(hash, CancellationToken.None);
await pipelineStore.Store.ClaimAsync(hash, CancellationToken.None);
CardDto card = await writer.CreateCardAsync(
Parsed(title: "Python-разработчик", board: "b_py"),
@@ -66,10 +65,10 @@ public sealed class PipelineCardWriterTests
[Fact]
public async Task CreateCard_AddCardFails_ThrowsAndDoesNotLinkDedup()
{
(PipelineCardWriter writer, FakeKanjStore store, FakePipelineStore pipelineStore) = Create();
(PipelineCardWriter writer, TestKanjStore store, TestPipelineStore pipelineStore) = Create();
store.FailAddCard = true; // сбой адаптера записи (например, недоступна БД тенанта)
const string hash = "abcdef0123456789abcdef0123456789abcdef01";
await pipelineStore.ClaimAsync(hash, CancellationToken.None);
await pipelineStore.Store.ClaimAsync(hash, CancellationToken.None);
await Assert.ThrowsAsync<InvalidOperationException>(
() => writer.CreateCardAsync(Parsed(title: "Python-разработчик"), Message("Текст"), hash, CancellationToken.None));
@@ -77,19 +76,19 @@ public sealed class PipelineCardWriterTests
// Карточка не создана → связывать нечего: заявка дедупа осталась несвязанной (LeadId=null).
Assert.Empty(store.CardDtos);
Assert.Null(pipelineStore.DedupLeadId(hash));
Assert.True(await pipelineStore.ExistsAsync(hash, CancellationToken.None)); // заявка на месте
Assert.True(await pipelineStore.Store.ExistsAsync(hash, CancellationToken.None)); // заявка на месте
}
// ─── Хелперы ──────────────────────────────────────────────────────────────────────────
// Создаёт контекст теста: фейки канбана, пайплайна и композитор поверх них.
// Создаёт контекст теста: подставки канбана, пайплайна и композитор поверх них.
// Возвращает: Кортеж (обёртка, канбан-хранилище, хранилище пайплайна).
private static (PipelineCardWriter Writer, FakeKanjStore Store, FakePipelineStore PipelineStore) Create()
private static (PipelineCardWriter Writer, TestKanjStore Store, TestPipelineStore PipelineStore) Create()
{
var store = new FakeKanjStore();
var pipelineStore = new FakePipelineStore();
var composer = new CardComposer(store, new FakeSettingsStore());
return (new PipelineCardWriter(store, pipelineStore, composer), store, pipelineStore);
var store = new TestKanjStore();
var pipelineStore = new TestPipelineStore();
var composer = new CardComposer(store.Store, new TestSettingsStore().Store);
return (new PipelineCardWriter(store.Store, pipelineStore.Store, composer), store, pipelineStore);
}
// Разбор карточки со значениями по умолчанию (сценарий теста перекрывает нужные поля).
@@ -4,6 +4,7 @@ using Deal.Grpc.Ai;
using Deal.Infrastructure.Data;
using Deal.Infrastructure.Integrations.Models;
using Deal.Infrastructure.Integrations.Options;
using Deal.Infrastructure.Integrations.Resilience;
using Deal.Infrastructure.Integrations.Services;
using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Pipeline.Application.Models;
@@ -15,7 +16,6 @@ using Deal.Modules.Tenants.Application.Models;
using Deal.Modules.Tenants.Application.Services;
using Deal.SharedKernel.Tenants.Models;
using Deal.Tests.Unit.Grpc;
using Deal.Tests.Unit.Modules.Cards;
using Deal.Tests.Unit.Modules.Kanban;
using Deal.Tests.Unit.Modules.Settings;
using Deal.Tests.Unit.Modules.Tenants;
@@ -34,7 +34,7 @@ public sealed class PipelineWorkerGrpcAiTests
// Id тенанта сценария строкой (формат N) — metadata tenant-id вызовов ai-service.
private const string TenantIdValue = "abcdefabcdefabcdefabcdefabcdefab";
// Guid того же тенанта — ключ строки лимита в FakeTenantLimitStore (списание usage).
// Guid того же тенанта — ключ строки лимита в TestTenantLimitStore (списание usage).
private static readonly Guid TenantGuid = Guid.Parse(TenantIdValue);
[Fact]
@@ -102,8 +102,9 @@ public sealed class PipelineWorkerGrpcAiTests
Assert.Equal(1, result.AiFail);
Assert.Equal(1, result.AiStored);
Assert.Single(result.CreatedCards);
Assert.Equal(1, service.FilterCalls);
Assert.Equal(1, service.ClassifyCalls);
// Недоступность транспорта повторяется — на сервер приходит первая попытка и повторы.
Assert.Equal(GrpcRetry.RetryCount + 1, service.FilterCalls);
Assert.Equal(GrpcRetry.RetryCount + 1, service.ClassifyCalls);
});
}
@@ -127,7 +128,7 @@ public sealed class PipelineWorkerGrpcAiTests
Usage = new Usage { Prompt = 2000, Completion = 400, Total = 2400 },
};
var limits = new FakeTenantLimitStore();
var limits = new TestTenantLimitStore();
limits.Preload(TenantGuid, budgetTokens: 1000, TenantLimitPeriods.Month, DateTimeOffset.UtcNow, usedTokens: 1000);
Context ctx = CreateContext(port, limits, budgeted: true);
ctx.PipelineStore.SeedQueue(QueueRowFiltered("p_1", "Нужен middle Python-разработчик в команду, оплата 2000$ в месяц"));
@@ -154,49 +155,49 @@ public sealed class PipelineWorkerGrpcAiTests
private sealed record Context(
PipelineWorkerService Worker,
FakePipelineStore PipelineStore,
FakeKanjStore KanjStore,
FakeSettingsStore Settings,
FakeTenantLimitStore Limits);
TestPipelineStore PipelineStore,
TestKanjStore KanjStore,
TestSettingsStore Settings,
TestTenantLimitStore Limits);
private static Context CreateContext(
int port,
FakeTenantLimitStore? limits = null,
TestTenantLimitStore? limits = null,
bool budgeted = false)
{
var settings = new FakeSettingsStore();
var pipelineStore = new FakePipelineStore();
var kanjStore = new FakeKanjStore();
var mlClient = new FakeMlClient { Predict = NotReadyPrediction() };
var rules = new IncomingRules(settings);
var fieldsParser = new LocalFieldsParser(settings);
var settings = new TestSettingsStore();
var pipelineStore = new TestPipelineStore();
var kanjStore = new TestKanjStore();
var mlClient = new TestMlClient { Predict = NotReadyPrediction() };
var rules = new IncomingRules(settings.Store);
var fieldsParser = new LocalFieldsParser(settings.Store);
ITenantContext tenantContext = new TenantContext();
tenantContext.SetTenant(new TenantId(TenantIdValue));
var connection = new AiGrpcConnection(
new AiServiceOptions { UseLocal = false, Endpoint = $"http://127.0.0.1:{port}" });
limits ??= new FakeTenantLimitStore();
limits ??= new TestTenantLimitStore();
var grpcClassifier = new GrpcAiClassifier(
tenantContext,
connection,
new AiProviderConfigBuilder(settings, TestCiphers.New()),
new AiClassifyContextBuilder(settings, kanjStore),
new TokenUsageRecorder(settings, limits, tenantContext, new TokenUsageEventService(new TestTokenUsageEventStore().Store)),
new AiProviderConfigBuilder(settings.Store, TestCiphers.New()),
new AiClassifyContextBuilder(settings.Store, kanjStore.Store),
new TokenUsageRecorder(settings.Store, limits.Store, tenantContext, new TokenUsageEventService(new TestTokenUsageEventStore().Store)),
NullLogger<GrpcAiClassifier>.Instance);
IAiClassifier aiClassifier = budgeted
? new BudgetedAiClassifier(
grpcClassifier,
new LocalAiClassifier(fieldsParser),
limits,
limits.Store,
tenantContext,
NullLogger<BudgetedAiClassifier>.Instance)
: grpcClassifier;
var processing = new PipelineProcessingService(pipelineStore, mlClient, new PipelineIngestService(pipelineStore));
var composer = new CardComposer(kanjStore, settings);
var writer = new PipelineCardWriter(kanjStore, pipelineStore, composer);
var processing = new PipelineProcessingService(pipelineStore.Store, mlClient.Client, new PipelineIngestService(pipelineStore.Store));
var composer = new CardComposer(kanjStore.Store, settings.Store);
var writer = new PipelineCardWriter(kanjStore.Store, pipelineStore.Store, composer);
var worker = new PipelineWorkerService(
pipelineStore, settings, rules, kanjStore, mlClient, aiClassifier, processing, writer, fieldsParser);
pipelineStore.Store, settings.Store, rules, kanjStore.Store, mlClient.Client, aiClassifier, processing, writer, fieldsParser);
return new Context(worker, pipelineStore, kanjStore, settings, limits);
}
@@ -6,7 +6,6 @@ using Deal.Modules.Kanban.Application.Models;
using Deal.Tests.Unit.Support;
using Deal.Modules.Kanban.Application.Services;
using Deal.Tests.Unit.Contracts;
using Deal.Tests.Unit.Modules.Cards;
using Deal.Tests.Unit.Modules.Settings;
namespace Deal.Tests.Unit.Infrastructure;
@@ -21,7 +20,7 @@ public sealed class CardMoverTests
[Fact]
public async Task Move_ToStage_RoutesToStageMoveAndResetsReminder()
{
(CardMover mover, FakeKanjStore store) = Create();
(CardMover mover, TestKanjStore store) = Create();
store.SeedCard(new CardDto
{
Id = "c_1",
@@ -44,7 +43,7 @@ public sealed class CardMoverTests
[Fact]
public async Task Move_ToBoard_RoutesToDashboard()
{
(CardMover mover, FakeKanjStore store) = Create();
(CardMover mover, TestKanjStore store) = Create();
store.SeedBoard(new ContainerDto { Id = "b_py", Name = "Python" });
store.SeedCard(new CardDto { Id = "c_1", Col = KanbanColumns.Inbox, Content = new SourceContent { Text = "Нужен Python" } });
@@ -59,7 +58,7 @@ public sealed class CardMoverTests
[Fact]
public async Task Move_UnknownTarget_ReturnsDashboardInvalidTargetError()
{
(CardMover mover, FakeKanjStore store) = Create();
(CardMover mover, TestKanjStore store) = Create();
store.SeedCard(new CardDto { Id = "c_1", Col = KanbanColumns.Inbox, Content = new SourceContent { Text = "Текст" } });
CardMoveResultDto result = await mover.MoveAsync("c_1", "b_ghost", UserMove, CancellationToken.None);
@@ -79,10 +78,10 @@ public sealed class CardMoverTests
Assert.False(result.Exists);
}
private static (CardMover Mover, FakeKanjStore Store) Create()
private static (CardMover Mover, TestKanjStore Store) Create()
{
var store = new FakeKanjStore();
var cardsService = new CardsService(store, new FakeSettingsStore(), new FakeMlClient(), new TestFileStorage().Storage);
var store = new TestKanjStore();
var cardsService = new CardsService(store.Store, new TestSettingsStore().Store, new TestMlClient().Client, new TestFileStorage().Storage);
return (new CardMover(cardsService), store);
}
}
@@ -40,8 +40,8 @@ public sealed class DiscoveryBanGuardTests
// Вчерашние вступления в суточную квоту не входят: 49 сегодня → ещё можно.
TestDiscoveryStore yesterdayStore = new(() => Now);
var settings = new FakeSettingsStore();
var secondGuard = new DiscoveryBanGuard(yesterdayStore.Store, settings, () => Now);
var settings = new TestSettingsStore();
var secondGuard = new DiscoveryBanGuard(yesterdayStore.Store, settings.Store, () => Now);
for (int i = 0; i < 49; i++)
{
yesterdayStore.SeedLog(DiscoveryLogEvents.JoinAuto, TodayStart.AddMinutes(i));
@@ -67,7 +67,7 @@ public sealed class DiscoveryBanGuardTests
[Fact]
public async Task NoteFlood_BlocksAutoJoinsUntilEndOfDay()
{
(DiscoveryBanGuard guard, _, FakeSettingsStore settings) = Create();
(DiscoveryBanGuard guard, _, TestSettingsStore settings) = Create();
await guard.NoteFloodAsync(CancellationToken.None);
@@ -79,7 +79,7 @@ public sealed class DiscoveryBanGuardTests
[Fact]
public async Task FloodYesterday_DoesNotBlockToday()
{
(DiscoveryBanGuard guard, _, FakeSettingsStore settings) = Create();
(DiscoveryBanGuard guard, _, TestSettingsStore settings) = Create();
settings.Preload(SettingsKeys.DiscFloodDay, TodayStart.AddDays(-1).ToUnixTimeMilliseconds().ToString());
Assert.False(await guard.FloodTodayAsync(CancellationToken.None));
@@ -89,7 +89,7 @@ public sealed class DiscoveryBanGuardTests
[Fact]
public async Task GlobalPaused_BlocksAutoJoins()
{
(DiscoveryBanGuard guard, _, FakeSettingsStore settings) = Create();
(DiscoveryBanGuard guard, _, TestSettingsStore settings) = Create();
settings.Preload(SettingsKeys.DiscPaused, "true");
Assert.True(await guard.GlobalPausedAsync(CancellationToken.None));
@@ -99,7 +99,7 @@ public sealed class DiscoveryBanGuardTests
[Fact]
public async Task CustomJoinLimit_AppliedWhenSettingStored()
{
(DiscoveryBanGuard guard, TestDiscoveryStore store, FakeSettingsStore settings) = Create();
(DiscoveryBanGuard guard, TestDiscoveryStore store, TestSettingsStore settings) = Create();
settings.Preload(SettingsKeys.DiscJoinLimit, "2");
Assert.True(await guard.CanAutoJoinAsync(CancellationToken.None));
@@ -109,11 +109,11 @@ public sealed class DiscoveryBanGuardTests
}
// Создаёт связку гард + фейки на фиксированных «часах» (store и гард смотрят одно время).
private static (DiscoveryBanGuard Guard, TestDiscoveryStore Store, FakeSettingsStore Settings) Create()
private static (DiscoveryBanGuard Guard, TestDiscoveryStore Store, TestSettingsStore Settings) Create()
{
var store = new TestDiscoveryStore(() => Now);
var settings = new FakeSettingsStore();
var guard = new DiscoveryBanGuard(store.Store, settings, () => Now);
var settings = new TestSettingsStore();
var guard = new DiscoveryBanGuard(store.Store, settings.Store, () => Now);
return (guard, store, settings);
}
}

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