27 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
104 changed files with 2428 additions and 545 deletions
+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(
@@ -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);
}
}
@@ -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,7 +91,7 @@ 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 TestSettingsStore();
@@ -41,7 +41,7 @@ public sealed class MlOutboxFlushSchedulerTests
{
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)),
@@ -67,7 +67,7 @@ public sealed class MlOutboxFlushSchedulerTests
service.TrainUnavailable = true;
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)),
@@ -95,7 +95,7 @@ public sealed class MlOutboxFlushSchedulerTests
SeedRows(storeA, count: 12, prefix: "a");
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)),
@@ -121,7 +121,7 @@ public sealed class MlOutboxFlushSchedulerTests
{
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)),
@@ -148,7 +148,7 @@ public sealed class MlOutboxFlushSchedulerTests
private static ServiceProvider BuildProvider(
int port,
TestTenantRepository tenants,
TenantContext tenantContext,
ITenantContext tenantContext,
Dictionary<Guid, TestMlLearningStore> storesByTenant)
{
var services = new ServiceCollection();
@@ -1,9 +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.SharedKernel.Errors;
using Deal.Tests.Unit.Modules.Settings;
using Deal.Tests.Unit.Support;
namespace Deal.Tests.Unit.Contracts;
@@ -343,13 +344,13 @@ public sealed class CardsServiceTests
}
[Fact]
public async Task Trash_CardMissing_ReturnsNull()
public async Task Trash_CardMissing_ThrowsNotFound()
{
(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);
}
@@ -425,13 +426,12 @@ 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));
}
@@ -443,7 +443,8 @@ public sealed class DiscoveryWorkerServiceTests
var pacer = Substitute.For<IDiscoveryPacer>();
var gateway = new TestDiscoveryGateway();
var worker = new DiscoveryWorkerService(
store.Store, tasks, candidates, log, new DiscoveryEvaluator(settings.Store, ml.Client, 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);
}
@@ -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);
});
}
@@ -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);
});
}
@@ -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;
@@ -101,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);
});
}
@@ -1,8 +1,9 @@
using Deal.Modules.Discovery.Application.Exceptions;
using Deal.Modules.Discovery.Application.Models;
using Deal.Tests.Unit.Support;
using Deal.Modules.Discovery.Application.Services;
using Deal.SharedKernel.Errors;
using Deal.Tests.Unit.Modules.Settings;
using Deal.Tests.Unit.Support;
namespace Deal.Tests.Unit.Modules.Discovery;
@@ -131,14 +132,13 @@ public sealed class DiscoveryTasksServiceTests
}
[Fact]
public async Task Patch_MissingTask_ReturnsNull()
public async Task Patch_MissingTask_ThrowsNotFound()
{
(DiscoveryTasksService service, _, _) = Create();
DiscoveryTaskDto? patched = await service.PatchAsync(
"dt_missing", new DiscoveryTaskPatch { Name = "Новое" }, CancellationToken.None);
Assert.Null(patched);
await Assert.ThrowsAsync<NotFoundException>(
() => service.PatchAsync(
"dt_missing", new DiscoveryTaskPatch { Name = "Новое" }, CancellationToken.None));
}
[Fact]
@@ -201,13 +201,12 @@ public sealed class DiscoveryTasksServiceTests
}
[Fact]
public async Task Start_MissingTask_ReturnsNull()
public async Task Start_MissingTask_ThrowsNotFound()
{
(DiscoveryTasksService service, _, _) = Create();
DiscoveryTaskDto? task = await service.StartAsync("dt_missing", CancellationToken.None);
Assert.Null(task);
await Assert.ThrowsAsync<NotFoundException>(
() => service.StartAsync("dt_missing", CancellationToken.None));
}
[Fact]
@@ -281,9 +280,8 @@ public sealed class DiscoveryTasksServiceTests
store.SeedCandidate(Candidate("c_2", "dt_2"));
await store.Store.UpsertBlacklistAsync("c_1", "Источник", "причина", CancellationToken.None);
bool deleted = await service.DeleteAsync("dt_1", CancellationToken.None);
await service.DeleteAsync("dt_1", CancellationToken.None);
Assert.True(deleted);
Assert.Single(store.Tasks); // dt_2 осталась
Assert.Equal("dt_2", Assert.Single(store.Tasks).Id);
Assert.Single(store.Candidates); // кандидат dt_2 остался
@@ -292,13 +290,12 @@ public sealed class DiscoveryTasksServiceTests
}
[Fact]
public async Task Delete_MissingTask_ReturnsFalse()
public async Task Delete_MissingTask_ThrowsNotFound()
{
(DiscoveryTasksService service, _, _) = Create();
bool deleted = await service.DeleteAsync("dt_missing", CancellationToken.None);
Assert.False(deleted);
await Assert.ThrowsAsync<NotFoundException>(
() => service.DeleteAsync("dt_missing", CancellationToken.None));
}
[Fact]
@@ -1,6 +1,7 @@
using System.Text;
using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Kanban.Application.Services;
using Deal.SharedKernel.Errors;
using Deal.Tests.Unit.Contracts;
using Deal.Tests.Unit.Modules.Settings;
@@ -73,14 +74,13 @@ public sealed class CardsServiceFilesTests
}
[Fact]
public async Task Add_CardMissing_ReturnsNullAndDoesNotWriteObject()
public async Task Add_CardMissing_ThrowsAndDoesNotWriteObject()
{
(CardsService service, TestKanjStore store, TestFileStorage storage) = Create();
CardFileDto? entry = await service.AddFileAsync(
"c_missing", "photo.png", "image/png", new MemoryStream("x"u8.ToArray()), 1, CancellationToken.None);
await Assert.ThrowsAsync<NotFoundException>(() => service.AddFileAsync(
"c_missing", "photo.png", "image/png", new MemoryStream("x"u8.ToArray()), 1, CancellationToken.None));
Assert.Null(entry); // эндпоинт отвечает 404 «Карточка не найдена»
Assert.Empty(storage.StoredObjectKeys); // «add на несуществующей карточке не пишет объект»
Assert.Empty(store.CardDtos);
}
@@ -151,25 +151,23 @@ public sealed class CardsServiceFilesTests
}
[Fact]
public async Task GetEntry_CardMissing_ReturnsNull()
public async Task GetEntry_CardMissing_ThrowsNotFound()
{
(CardsService service, _, _) = Create();
CardFileDto? entry = await service.GetFileEntryAsync("c_missing", "pf_1", CancellationToken.None);
Assert.Null(entry); // 404 «Карточка не найдена» у эндпоинта
await Assert.ThrowsAsync<NotFoundException>(
() => service.GetFileEntryAsync("c_missing", "pf_1", CancellationToken.None));
}
[Fact]
public async Task GetEntry_FileNotInMetadata_ReturnsNull()
public async Task GetEntry_FileNotInMetadata_ThrowsNotFound()
{
(CardsService service, TestKanjStore store, _) = Create();
store.SeedCard(Card("c_1")
with { Files = new[] { new CardFileDto("pf_1", "tz.pdf", 100, "document", "Документ", "k") } });
CardFileDto? entry = await service.GetFileEntryAsync("c_1", "pf_ghost", CancellationToken.None);
Assert.Null(entry); // файла нет в метаданных карточки — 404-семантика
await Assert.ThrowsAsync<NotFoundException>(
() => service.GetFileEntryAsync("c_1", "pf_ghost", CancellationToken.None));
}
@@ -225,13 +223,13 @@ public sealed class CardsServiceFilesTests
}
[Fact]
public async Task Remove_CardMissing_ReturnsNullWithoutStorageDelete()
public async Task Remove_CardMissing_ThrowsWithoutStorageDelete()
{
(CardsService service, _, TestFileStorage storage) = Create();
CardDto? card = await service.RemoveFileAsync("c_missing", "pf_1", CancellationToken.None);
await Assert.ThrowsAsync<NotFoundException>(
() => service.RemoveFileAsync("c_missing", "pf_1", CancellationToken.None));
Assert.Null(card); // эндпоинт отвечает 404 «Карточка не найдена»
Assert.Empty(storage.DeletedKeys);
}
@@ -1,6 +1,7 @@
using System.Text.Json;
using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Kanban.Application.Services;
using Deal.SharedKernel.Errors;
using Deal.Tests.Unit.Contracts;
using Deal.Tests.Unit.Modules.Settings;
@@ -99,13 +100,13 @@ public sealed class CardsServiceSelectedTests
[Fact]
public async Task TakeCard_CardMissing_ReturnsNullAndCreatesNothing()
public async Task TakeCard_CardMissing_ThrowsAndCreatesNothing()
{
(CardsService service, TestKanjStore store, _, _) = Create();
CardDto? card = await service.TakeCardAsync("c_missing", CancellationToken.None);
await Assert.ThrowsAsync<NotFoundException>(
() => service.TakeCardAsync("c_missing", CancellationToken.None));
Assert.Null(card);
Assert.Empty(store.CardDtos);
}
@@ -122,8 +123,7 @@ public sealed class CardsServiceSelectedTests
stack: new[] { "Python", "aiogram" },
budget: new CardBudgetDto(From: 1600, To: 2200, Cur: "USD")));
CardDto card = await service.TakeCardAsync("c_1", CancellationToken.None)
?? throw new InvalidOperationException("take вернул null при существующей карточке");
CardDto card = await service.TakeCardAsync("c_1", CancellationToken.None);
Assert.Equal("c_1", card.Id);
Assert.Equal("planned", card.Col);
@@ -182,8 +182,7 @@ public sealed class CardsServiceSelectedTests
("tzText", "ТЗ"),
("stack", new[] { "C#", ".NET" }), // стек — полная замена массива
("budget", new { from = 500, cur = "EUR" })),
CancellationToken.None)
?? throw new InvalidOperationException("patch вернул null при существующей карточке");
CancellationToken.None);
Assert.Equal("Новый заголовок", card.Title);
Assert.Equal(string.Empty, card.Summary); // summary очищена пустой строкой
@@ -255,16 +254,14 @@ public sealed class CardsServiceSelectedTests
}
[Fact]
public async Task Patch_CardMissing_ReturnsNull()
public async Task Patch_CardMissing_ThrowsNotFound()
{
(CardsService service, _, _, _) = Create();
CardDto? card = await service.PatchCardAsync(
await Assert.ThrowsAsync<NotFoundException>(() => service.PatchCardAsync(
"c_missing",
PatchBody(("title", "Т")),
CancellationToken.None);
Assert.Null(card);
CancellationToken.None));
}
@@ -1,6 +1,7 @@
using System.Text.Json;
using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Kanban.Application.Services;
using Deal.SharedKernel.Errors;
using Deal.Tests.Unit.Modules.Settings;
namespace Deal.Tests.Unit.Support;
@@ -237,14 +238,12 @@ public sealed class ContainersServiceTests
}
[Fact]
public async Task Patch_UnknownContainer_ReturnsNull()
public async Task Patch_UnknownContainer_ThrowsNotFound()
{
(ContainersService service, _, _) = Create();
ContainerDto? result = await service.PatchAsync(
"b_missing", Patch(name: "X"), CancellationToken.None);
Assert.Null(result); // эндпоинт отвечает 404 «Контейнер не найден»
await Assert.ThrowsAsync<NotFoundException>(
() => service.PatchAsync("b_missing", Patch(name: "X"), CancellationToken.None));
}
[Fact]
@@ -0,0 +1,34 @@
using Deal.SharedKernel.Resources;
namespace Deal.Tests.Unit.Support;
/// <summary>
/// Тесты ресурсов текстов ошибок (ErrorMessages.resx).
/// </summary>
public sealed class ErrorResourcesTests
{
[Fact]
public void Format_KnownKey_ReturnsRussianText()
{
string text = ErrorResources.Format(ErrorResourceKeys.UnexpectedError);
Assert.Contains("Внутренняя ошибка", text);
}
[Fact]
public void Format_TemplateWithArgs_SubstitutesPlaceholders()
{
string text = ErrorResources.Format(ErrorResourceKeys.NotFoundEntityWithId, "Карточка", "c_1");
Assert.Contains("Карточка", text);
Assert.Contains("c_1", text);
}
[Fact]
public void Format_UnknownKey_ReturnsKey()
{
string text = ErrorResources.Format("NoSuchKey");
Assert.Equal("NoSuchKey", text);
}
}
@@ -0,0 +1,50 @@
using Deal.Contracts.Integrations.Abstractions;
using Deal.Infrastructure.Integrations.Storage.Abstractions;
using Deal.Infrastructure.Integrations.Storage.Options;
using Deal.Infrastructure.Integrations.Storage.Services;
using Microsoft.Extensions.Logging.Abstractions;
namespace Deal.Tests.Unit.Support;
/// <summary>
/// Тесты FileStorageFactory — выбор Local/MinIO по заполненности секции Storage:Minio.
/// </summary>
public sealed class FileStorageFactoryTests
{
// Локальный режим: относительно пустая секция Minio → LocalFileStorage.
[Fact]
public void Create_MinioNotConfigured_ReturnsLocalFileStorage()
{
IFileStorageFactory factory = new FileStorageFactory(
new StorageOptions(),
new LocalStorageRoot("/tmp/deal-attachments"),
NullLogger<MinioFileStorage>.Instance);
IFileStorage storage = factory.Create();
Assert.IsType<LocalFileStorage>(storage);
}
// MinIO-режим: заполнены Endpoint/AccessKey/SecretKey → MinioFileStorage.
[Fact]
public void Create_MinioConfigured_ReturnsMinioFileStorage()
{
var options = new StorageOptions
{
Minio = new MinioStorageOptions
{
Endpoint = "localhost:9000",
AccessKey = "deal",
SecretKey = "deal-secret",
},
};
IFileStorageFactory factory = new FileStorageFactory(
options,
new LocalStorageRoot("/tmp/deal-attachments"),
NullLogger<MinioFileStorage>.Instance);
IFileStorage storage = factory.Create();
Assert.IsType<MinioFileStorage>(storage);
}
}
@@ -0,0 +1,93 @@
using Deal.Infrastructure.Integrations.Resilience;
using Grpc.Core;
namespace Deal.Tests.Unit.Support;
/// <summary>
/// Тесты <see cref="GrpcRetry"/> — повтор транзиентных gRPC-сбоев.
/// </summary>
public sealed class GrpcRetryTests
{
[Theory]
[InlineData(StatusCode.Unavailable)]
[InlineData(StatusCode.DeadlineExceeded)]
public void IsTransient_TransportFailures_True(StatusCode statusCode)
{
Assert.True(GrpcRetry.IsTransient(new RpcException(new Status(statusCode, "сбой"))));
}
[Theory]
[InlineData(StatusCode.NotFound)]
[InlineData(StatusCode.InvalidArgument)]
[InlineData(StatusCode.Internal)]
public void IsTransient_ApplicationFailures_False(StatusCode statusCode)
{
Assert.False(GrpcRetry.IsTransient(new RpcException(new Status(statusCode, "сбой"))));
}
[Fact]
public void IsTransient_NonRpcException_False()
{
Assert.False(GrpcRetry.IsTransient(new InvalidOperationException("сбой")));
}
[Fact]
public async Task ExecuteAsync_TransientThenSuccess_Retries()
{
int calls = 0;
string result = await GrpcRetry.ExecuteAsync(
_ =>
{
calls++;
return calls < 2
? Task.FromException<string>(new RpcException(new Status(StatusCode.Unavailable, "down")))
: Task.FromResult("ok");
},
InstantDelayAsync,
CancellationToken.None);
Assert.Equal("ok", result);
Assert.Equal(2, calls);
}
[Fact]
public async Task ExecuteAsync_TransientExhausted_ThrowsRpcException()
{
int calls = 0;
RpcException thrown = await Assert.ThrowsAsync<RpcException>(
() => GrpcRetry.ExecuteAsync(
_ =>
{
calls++;
return Task.FromException<string>(new RpcException(new Status(StatusCode.Unavailable, "down")));
},
InstantDelayAsync,
CancellationToken.None));
Assert.Equal(StatusCode.Unavailable, thrown.StatusCode);
Assert.Equal(GrpcRetry.RetryCount + 1, calls);
}
[Fact]
public async Task ExecuteAsync_ApplicationFailure_NotRetried()
{
int calls = 0;
await Assert.ThrowsAsync<RpcException>(
() => GrpcRetry.ExecuteAsync(
_ =>
{
calls++;
return Task.FromException<string>(new RpcException(new Status(StatusCode.InvalidArgument, "bad")));
},
InstantDelayAsync,
CancellationToken.None));
Assert.Equal(1, calls);
}
private static Task InstantDelayAsync(TimeSpan delay, CancellationToken cancellationToken)
=> Task.CompletedTask;
}
@@ -47,7 +47,7 @@ public sealed class PipelineWorkerSchedulerTests
TestKanjStore KanjB,
SseSubscription SubscriptionB,
PipelinePumpGate PumpGate,
TenantContext TenantContext,
ITenantContext TenantContext,
ListLogger Logs);
// ─── Цикл: pump каждого тенанта в собственном scope + new_card ─────────
@@ -149,7 +149,7 @@ public sealed class PipelineWorkerSchedulerTests
private static Context CreateContext(bool withThrowingQueueReadA = false)
{
var tenants = new TestTenantRepository(Tenant(TenantA), Tenant(TenantB));
var tenantContext = new TenantContext();
ITenantContext tenantContext = new TenantContext();
var pipelineA = new TestPipelineStore(throwOnList: withThrowingQueueReadA);
var pipelineB = new TestPipelineStore();
var kanjA = new TestKanjStore();
@@ -0,0 +1,158 @@
using Deal.SharedKernel.Resilience;
namespace Deal.Tests.Unit.Support;
/// <summary>
/// Тесты <see cref="RetryExecutor"/> — повтор транзиентных сбоев.
/// </summary>
public sealed class RetryExecutorTests
{
private const int RetryCount = 2;
private static readonly TimeSpan BaseDelay = TimeSpan.FromMilliseconds(10);
[Fact]
public async Task FirstAttemptSucceeds_NoRetry()
{
var delays = new List<TimeSpan>();
int calls = 0;
string result = await ExecuteAsync(
() =>
{
calls++;
return Task.FromResult("ok");
},
shouldRetry: _ => true,
delays);
Assert.Equal("ok", result);
Assert.Equal(1, calls);
Assert.Empty(delays);
}
[Fact]
public async Task TransientFailureThenSuccess_RetriesUntilSuccess()
{
var delays = new List<TimeSpan>();
int calls = 0;
string result = await ExecuteAsync(
() =>
{
calls++;
return calls < 3
? throw new InvalidOperationException("транзиент")
: Task.FromResult("ok");
},
shouldRetry: _ => true,
delays);
Assert.Equal("ok", result);
Assert.Equal(3, calls);
Assert.Equal(2, delays.Count);
}
[Fact]
public async Task RetriesExhausted_ThrowsLastFailure()
{
var delays = new List<TimeSpan>();
int calls = 0;
InvalidOperationException thrown = await Assert.ThrowsAsync<InvalidOperationException>(
() => ExecuteAsync<string>(
async () =>
{
calls++;
await Task.Yield();
throw new InvalidOperationException($"сбой {calls}");
},
shouldRetry: _ => true,
delays));
Assert.Equal(3, calls); // первая попытка + 2 повтора
Assert.Equal("сбой 3", thrown.Message);
}
[Fact]
public async Task NonTransientFailure_NotRetried()
{
var delays = new List<TimeSpan>();
int calls = 0;
await Assert.ThrowsAsync<InvalidOperationException>(
() => ExecuteAsync<string>(
async () =>
{
calls++;
await Task.Yield();
throw new InvalidOperationException("не транзиент");
},
shouldRetry: _ => false,
delays));
Assert.Equal(1, calls);
Assert.Empty(delays);
}
[Fact]
public async Task Cancellation_DoesNotRetry()
{
var delays = new List<TimeSpan>();
int calls = 0;
using var cts = new CancellationTokenSource();
cts.Cancel();
await Assert.ThrowsAsync<InvalidOperationException>(
() => ExecuteAsync<string>(
async () =>
{
calls++;
await Task.Yield();
throw new InvalidOperationException("сбой");
},
shouldRetry: _ => true,
delays,
cts.Token));
Assert.Equal(1, calls);
}
[Fact]
public async Task Backoff_GrowsExponentially()
{
var delays = new List<TimeSpan>();
int calls = 0;
await ExecuteAsync(
() =>
{
calls++;
return calls < 3
? throw new InvalidOperationException("транзиент")
: Task.FromResult(1);
},
shouldRetry: _ => true,
delays);
Assert.Equal(2, delays.Count);
Assert.Equal(BaseDelay, delays[0]);
Assert.Equal(BaseDelay * 2, delays[1]);
}
private static Task<TResult> ExecuteAsync<TResult>(
Func<Task<TResult>> operation,
Func<Exception, bool> shouldRetry,
List<TimeSpan> delays,
CancellationToken cancellationToken = default)
=> RetryExecutor.ExecuteAsync(
_ => operation(),
RetryCount,
BaseDelay,
shouldRetry,
(delay, _) =>
{
delays.Add(delay);
return Task.CompletedTask;
},
cancellationToken);
}
@@ -46,7 +46,7 @@ public sealed class StorageTickSchedulerTests
TestKanjStore storeB = StoreWithExpiredInboxCard("l_b_old");
var settings = new TestSettingsStore();
var tenants = new TestTenantRepository(Tenant(TenantA), Tenant(TenantB));
var tenantContext = new TenantContext();
ITenantContext tenantContext = new TenantContext();
await using ServiceProvider provider = BuildProvider(tenants, tenantContext, storeA, storeB, settings);
SseBroker broker = provider.GetRequiredService<SseBroker>();
@@ -74,7 +74,7 @@ public sealed class StorageTickSchedulerTests
var storeB = new TestKanjStore();
storeB.SeedCard(Card("l_b_fresh", KanbanColumns.Inbox, ReceivedAtMsAgo(TimeSpan.FromHours(1))));
var settings = new TestSettingsStore();
var tenantContext = new TenantContext();
ITenantContext tenantContext = new TenantContext();
await using ServiceProvider provider = BuildProvider(
new TestTenantRepository(Tenant(TenantA), Tenant(TenantB)),
tenantContext,
@@ -100,7 +100,7 @@ public sealed class StorageTickSchedulerTests
{
// У тенанта A настройки падают (имитация сбоя схемы/БД) — тик A логирует ошибку, B обрабатывается.
TestKanjStore storeB = StoreWithExpiredInboxCard("l_b_old");
var tenantContext = new TenantContext();
ITenantContext tenantContext = new TenantContext();
var settingsByTenant = new Dictionary<Guid, ISettingsStore>
{
[TenantA] = new ThrowingSettingsStore(),
@@ -133,7 +133,7 @@ public sealed class StorageTickSchedulerTests
[Fact]
public async Task RunCycle_TenantListFailure_DoesNotThrow()
{
var tenantContext = new TenantContext();
ITenantContext tenantContext = new TenantContext();
await using ServiceProvider provider = BuildProvider(
new ThrowingTenantRepository(),
tenantContext,
@@ -152,7 +152,7 @@ public sealed class StorageTickSchedulerTests
{
var kanjStore = new TestKanjStore();
var settings = new TestSettingsStore();
var tenantContext = new TenantContext();
ITenantContext tenantContext = new TenantContext();
var pipelineStoreA = new TestPipelineStore();
long nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
pipelineStoreA.SeedRejected(Rejected("r_old_a", (long)(nowMs - TimeSpan.FromDays(4).TotalMilliseconds)));
@@ -186,7 +186,7 @@ public sealed class StorageTickSchedulerTests
cardStoreA.SeedCard(HoldCard("c_a_future", title: "Будущий", reminderAtMs: NowMs() + 60_000));
cardStoreA.SeedCard(HoldCard("c_a_work", stage: "work", title: "В работе", reminderAtMs: NowMs() - 60_000));
var cardStoreB = new TestKanjStore();
var tenantContext = new TenantContext();
ITenantContext tenantContext = new TenantContext();
await using ServiceProvider provider = BuildProvider(
new TestTenantRepository(Tenant(TenantA), Tenant(TenantB)).Repository,
tenantContext,
@@ -221,7 +221,7 @@ public sealed class StorageTickSchedulerTests
cardStoreA.SeedCard(HoldCard("c_past", title: "Старое", reminderAtMs: NowMs() - 60_000));
var settingsA = new TestSettingsStore();
settingsA.Preload(SettingsKeys.RemindersEnabled, "false");
var tenantContext = new TenantContext();
ITenantContext tenantContext = new TenantContext();
await using ServiceProvider provider = BuildProvider(
new TestTenantRepository(Tenant(TenantA)).Repository,
tenantContext,
@@ -247,7 +247,7 @@ public sealed class StorageTickSchedulerTests
// и тик A завершается, тенант B обрабатывается (автоархив + тост) — проход жив.
var cardStoreA = new TestKanjStore(throwOnDueReminders: true);
TestKanjStore storeB = StoreWithExpiredInboxCard("l_b_old");
var tenantContext = new TenantContext();
ITenantContext tenantContext = new TenantContext();
await using ServiceProvider provider = BuildProvider(
new TestTenantRepository(Tenant(TenantA), Tenant(TenantB)).Repository,
tenantContext,
@@ -280,7 +280,7 @@ public sealed class StorageTickSchedulerTests
// Возвращает: Провайдер с зарегистрированными сервисами теста.
private static ServiceProvider BuildProvider(
TestTenantRepository tenants,
TenantContext tenantContext,
ITenantContext tenantContext,
TestKanjStore storeA,
TestKanjStore storeB,
TestSettingsStore settings)
@@ -301,7 +301,7 @@ public sealed class StorageTickSchedulerTests
// Возвращает: Провайдер с зарегистрированными сервисами теста.
private static ServiceProvider BuildProvider(
ITenantRepository tenants,
TenantContext tenantContext,
ITenantContext tenantContext,
Dictionary<Guid, TestKanjStore> storesByTenant,
Dictionary<Guid, ISettingsStore> settingsByTenant,
Dictionary<Guid, TestPipelineStore>? pipelineStoresByTenant = null)
@@ -18,6 +18,7 @@ using Grpc.Net.Client;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Server.Kestrel.Core;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Diagnostics.HealthChecks;
@@ -57,15 +58,17 @@ internal static class TelegramIngressTestHost
Func<GrpcChannel, Task> scenario,
RateLimitOptions? rateLimitOptions = null)
{
string? originalToken = Environment.GetEnvironmentVariable(ServiceTokenEnvKey);
Environment.SetEnvironmentVariable(ServiceTokenEnvKey, serviceToken);
WebApplication? app = null;
GrpcChannel? channel = null;
try
{
int port = TestPort.Allocate();
WebApplicationBuilder builder = WebApplication.CreateBuilder();
// Токен ингресса задаётся конфигурацией хоста (в приоритете над env) — без мутации процесса.
builder.Configuration.AddInMemoryCollection(new Dictionary<string, string?>
{
[ServiceTokenEnvKey] = serviceToken,
});
builder.WebHost.ConfigureKestrel(kestrel =>
kestrel.Listen(IPAddress.Loopback, port, listen => listen.Protocols = HttpProtocols.Http2));
@@ -131,8 +134,6 @@ internal static class TelegramIngressTestHost
await app.StopAsync();
await app.DisposeAsync();
}
Environment.SetEnvironmentVariable(ServiceTokenEnvKey, originalToken);
}
}
@@ -30,6 +30,11 @@
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
<ItemGroup>
<!-- Общие доменные исключения и тексты ошибок (Deal.SharedKernel.Errors/Resources). -->
<ProjectReference Include="..\..\core\Deal.SharedKernel\Deal.SharedKernel.csproj" />
</ItemGroup>
<ItemGroup>
<!-- gRPC-сервер ASP.NET Core (Interceptor/AddGrpc) и стандартный gRPC-health (Ruling 12). -->
<PackageReference Include="Grpc.AspNetCore" Version="2.83.0" />
@@ -45,6 +50,7 @@
ней нет; держим для будущего трейсинга). Версии — 1.17.x (Prometheus-экспортёр и gRPC-клиент
выпускаются только pre-release-линией; остальные пакеты 1.17.0 stable). -->
<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" />
@@ -2,6 +2,8 @@ using System.Diagnostics;
using Deal.Grpc.Hosting.Models;
using Deal.Grpc.Hosting.Options;
using Deal.Grpc.Hosting.Services;
using Deal.SharedKernel.Errors;
using Deal.SharedKernel.Resources;
using Grpc.Core;
using Grpc.Core.Interceptors;
using Microsoft.Extensions.Logging;
@@ -16,9 +18,6 @@ public sealed class RpcCallLoggingInterceptor : Interceptor
// Префикс методов стандартного gRPC-health — не логируется (инфраструктурный liveness).
private const string HealthMethodPrefix = "/grpc.health.v1.Health/";
// Деталь RpcException для сбоя реализации (фиксированный текст; детали ошибки не наружу).
private const string UnknownFailureDetail = "Внутренняя ошибка сервиса";
private readonly ILogger<RpcCallLoggingInterceptor> _logger;
/// <summary>
@@ -101,14 +100,46 @@ public sealed class RpcCallLoggingInterceptor : Interceptor
}
catch (Exception exception)
{
// «Прочие» сбои реализации gRPC показал бы клиенту как UNKNOWN мимо access-лога: логируем
// строку со статусом Unknown, пишем детали сбоя и переводим в RpcException (текст фиксирован).
_logger.LogError(exception, "gRPC {RpcMethod}: необработанный сбой реализации", context.Method);
LogCall(context, startedAt, StatusCode.Unknown);
throw new RpcException(new Status(StatusCode.Unknown, UnknownFailureDetail));
RpcException mapped = MapFailure(exception, context.Method);
LogCall(context, startedAt, mapped.Status.StatusCode);
throw mapped;
}
}
// Переводит сбой реализации в RpcException: доменные ошибки — по коду, прочие — Unknown
// с фиксированным текстом (детали и стектрейс остаются только в логе).
// exception: Сбой обработчика.
// rpcMethod: Полное имя RPC-метода (для лога).
// Возвращает: RpcException для клиента.
private RpcException MapFailure(Exception exception, string rpcMethod)
{
if (exception is DealException dealException)
{
_logger.LogWarning(
"gRPC {RpcMethod}: доменная ошибка {ErrorCode}: {Message}",
rpcMethod,
dealException.ErrorCode,
dealException.Message);
return new RpcException(new Status(MapErrorCode(dealException.ErrorCode), dealException.Message));
}
_logger.LogError(exception, "gRPC {RpcMethod}: необработанный сбой реализации", rpcMethod);
return new RpcException(
new Status(StatusCode.Unknown, ErrorResources.Format(ErrorResourceKeys.UnexpectedError)));
}
// Код ошибки Deal → статус gRPC.
// errorCode: Код из DealException.ErrorCode.
// Возвращает: Статус gRPC для клиента.
private static StatusCode MapErrorCode(string errorCode) => errorCode switch
{
DealErrorCodes.NotFound => StatusCode.NotFound,
DealErrorCodes.Validation => StatusCode.InvalidArgument,
DealErrorCodes.Conflict => StatusCode.FailedPrecondition,
DealErrorCodes.Unavailable => StatusCode.Unavailable,
_ => StatusCode.Internal,
};
// Обёртка для handler-ов, возвращающих Task (server-streaming/дуплексный).
// context: Контекст вызова (метод — context.Method).
// invoke: Вызов нижестоящего обработчика.
@@ -67,7 +67,8 @@ public static class DealLogging
loggerConfiguration
.MinimumLevel.Is(ParseMinimumLevel(configuration[MinimumLevelEnvKey]))
.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,54 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
using OpenTelemetry.Resources;
using OpenTelemetry.Trace;
namespace Deal.Grpc.Hosting.Services;
/// <summary>
/// Общая настройка трейсинга Deal-сервисов
/// </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)));
}
}
@@ -0,0 +1,20 @@
using System.Diagnostics;
using Serilog.Core;
using Serilog.Events;
namespace Deal.Grpc.Hosting.Services;
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()));
}
}
+1
View File
@@ -18,6 +18,7 @@ WebApplication app = MlServiceHost.Create(
{
DealLogging.Configure(builder, mlProcessName);
DealMetricsHosting.AddDealMetrics(builder, metricsPort);
DealTracingHosting.AddDealTracing(builder, mlProcessName);
});
DealMetricsHosting.MapDealMetrics(app);
@@ -16,6 +16,7 @@ WebApplication app = StorageServiceHost.Create(
{
DealLogging.Configure(builder, storageProcessName);
DealMetricsHosting.AddDealMetrics(builder, metricsPort);
DealTracingHosting.AddDealTracing(builder, storageProcessName);
});
DealMetricsHosting.MapDealMetrics(app);
@@ -11,9 +11,6 @@ using Deal.Telegram.Core;
namespace Deal.Telegram.Core;
/// <summary>
/// Исходящий gRPC-канал в ядро
/// </summary>
public sealed class CoreIngressClient : ICoreIngressClient
{
public const string TenantIdMetadataKey = "tenant-id";
@@ -44,7 +41,6 @@ public sealed class CoreIngressClient : ICoreIngressClient
_mtlsCertificates = mtlsCertificates;
}
/// <inheritdoc />
async Task<PushSourceReply> ICoreIngressClient.PushSourceAsync(
string tenantId,
PushSourceRequest request,
@@ -61,7 +57,6 @@ public sealed class CoreIngressClient : ICoreIngressClient
}
}
/// <inheritdoc />
async Task<IReadOnlyList<string>> ICoreIngressClient.SyncDialogsAsync(
string tenantId,
IReadOnlyList<DialogEntry> entries,

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