18 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
86 changed files with 2094 additions and 505 deletions
+72
View File
@@ -108,6 +108,9 @@ services:
Storage__Minio__SecretKey: deal_minio_secret Storage__Minio__SecretKey: deal_minio_secret
Storage__Minio__Bucket: deal-files Storage__Minio__Bucket: deal-files
Storage__Minio__Secure: "false" Storage__Minio__Secure: "false"
# Трейсинг OTel → коллектор профиля observability (без коллектора трейсы не экспортируются).
OTEL_EXPORTER_OTLP_ENDPOINT: ${DEAL_OTEL_ENDPOINT:-}
OTEL_SERVICE_NAME: core
ports: ports:
- "5080:5080" - "5080:5080"
- "5082:5082" # gRPC-ингресс telegram-service (сервисы ходят на http://core:5082 внутри сети) - "5082:5082" # gRPC-ингресс telegram-service (сервисы ходят на http://core:5082 внутри сети)
@@ -146,6 +149,8 @@ services:
DEAL_TELEGRAM_SESSION_KEY: ${DEAL_TELEGRAM_SESSION_KEY:-ZmVkY2JhOTg3NjU0MzIxMGZlZGNiYTk4NzY1NDMyMTA=} DEAL_TELEGRAM_SESSION_KEY: ${DEAL_TELEGRAM_SESSION_KEY:-ZmVkY2JhOTg3NjU0MzIxMGZlZGNiYTk4NzY1NDMyMTA=}
DEAL_TELEGRAM_SESSION_DIR: /data/sessions DEAL_TELEGRAM_SESSION_DIR: /data/sessions
DEAL_LOGS_DIR: /tmp/logs DEAL_LOGS_DIR: /tmp/logs
OTEL_EXPORTER_OTLP_ENDPOINT: ${DEAL_OTEL_ENDPOINT:-}
OTEL_SERVICE_NAME: telegram-service
SERVICES__CORE__INGRESS: ${DEAL_CORE_INGRESS:-http://core:5082} SERVICES__CORE__INGRESS: ${DEAL_CORE_INGRESS:-http://core:5082}
ports: ports:
- "5101:5101" - "5101:5101"
@@ -176,6 +181,8 @@ services:
GRPC_PORT: "5102" GRPC_PORT: "5102"
DEAL_SERVICE_TOKEN: ${DEAL_SERVICE_TOKEN:-deal_dev_service_token} DEAL_SERVICE_TOKEN: ${DEAL_SERVICE_TOKEN:-deal_dev_service_token}
DEAL_LOGS_DIR: /tmp/logs DEAL_LOGS_DIR: /tmp/logs
OTEL_EXPORTER_OTLP_ENDPOINT: ${DEAL_OTEL_ENDPOINT:-}
OTEL_SERVICE_NAME: ai-service
ports: ports:
- "5102:5102" - "5102:5102"
healthcheck: healthcheck:
@@ -203,6 +210,8 @@ services:
DEAL_SERVICE_TOKEN: ${DEAL_SERVICE_TOKEN:-deal_dev_service_token} DEAL_SERVICE_TOKEN: ${DEAL_SERVICE_TOKEN:-deal_dev_service_token}
DEAL_ML_DATA_DIR: /data/ml # файлы моделей data/ml/<tenantId>.sqlite на volume deal_ml_data (Ruling 4/12) DEAL_ML_DATA_DIR: /data/ml # файлы моделей data/ml/<tenantId>.sqlite на volume deal_ml_data (Ruling 4/12)
DEAL_LOGS_DIR: /tmp/logs DEAL_LOGS_DIR: /tmp/logs
OTEL_EXPORTER_OTLP_ENDPOINT: ${DEAL_OTEL_ENDPOINT:-}
OTEL_SERVICE_NAME: ml-service
ports: ports:
- "5103:5103" - "5103:5103"
volumes: volumes:
@@ -233,6 +242,8 @@ services:
DEAL_STORAGE_BUCKET: deal-attachments DEAL_STORAGE_BUCKET: deal-attachments
DEAL_STORAGE_SECURE: "false" DEAL_STORAGE_SECURE: "false"
DEAL_LOGS_DIR: /tmp/logs DEAL_LOGS_DIR: /tmp/logs
OTEL_EXPORTER_OTLP_ENDPOINT: ${DEAL_OTEL_ENDPOINT:-}
OTEL_SERVICE_NAME: storage-service
ports: ports:
- "5104:5104" - "5104:5104"
depends_on: depends_on:
@@ -261,6 +272,66 @@ services:
- ./observability/prometheus-rules.yml:/etc/prometheus/prometheus-rules.yml:ro - ./observability/prometheus-rules.yml:/etc/prometheus/prometheus-rules.yml:ro
- deal_prometheus_data:/prometheus - deal_prometheus_data:/prometheus
# OpenTelemetry Collector — приём трейсов Deal-процессов (OTLP) → Tempo (профиль observability).
otel-collector:
image: otel/opentelemetry-collector-contrib:0.160.0
container_name: deal-otel-collector
profiles: ["observability"]
command: ["--config=/etc/otelcol-contrib/config.yaml"]
ports:
- "4317:4317"
- "4318:4318"
volumes:
- ./observability/otel-collector.yml:/etc/otelcol-contrib/config.yaml:ro
depends_on:
tempo:
condition: service_started
# Tempo — хранилище трейсов (OTLP от коллектора), UI/API — :3200 (профиль observability).
tempo:
image: grafana/tempo:2.8.1
container_name: deal-tempo
profiles: ["observability"]
command: ["-config.file=/etc/tempo.yml"]
ports:
- "3200:3200"
volumes:
- ./observability/tempo.yml:/etc/tempo.yml:ro
- deal_tempo_data:/var/tempo
# cAdvisor — ресурсы контейнеров (CPU/RAM/сеть/диск); scrape — job cadvisor.
cadvisor:
image: gcr.io/cadvisor/cadvisor:v0.52.1
container_name: deal-cadvisor
profiles: ["observability"]
privileged: true
devices:
- /dev/kmsg:/dev/kmsg
volumes:
- /:/rootfs:ro
- /var/run:/var/run:ro
- /sys:/sys:ro
- /var/lib/docker/:/var/lib/docker:ro
- /dev/disk/:/dev/disk:ro
# node-exporter — ресурсы хоста (CPU/RAM/диск/сеть); scrape — job node-exporter.
node-exporter:
image: prom/node-exporter:v1.9.1
container_name: deal-node-exporter
profiles: ["observability"]
command:
- --path.procfs=/host/proc
- --path.sysfs=/host/sys
- --path.rootfs=/host/root
- --collector.filesystem.mount-points-exclude=^/(sys|proc|dev|host|etc)($|/)
ports:
- "9100:9100"
volumes:
- /proc:/host/proc:ro
- /sys:/host/sys:ro
- /:/host/root:ro
pid: host
volumes: volumes:
deal_pgdata: deal_pgdata:
deal_minio_data: deal_minio_data:
@@ -268,3 +339,4 @@ volumes:
deal_ml_data: deal_ml_data:
deal_api_data: deal_api_data:
deal_prometheus_data: deal_prometheus_data:
deal_tempo_data:
+111 -5
View File
@@ -6,12 +6,12 @@
# Состав (всё в одной внутренней сети compose, наружу — ТОЛЬКО caddy :80/:443): # Состав (всё в одной внутренней сети compose, наружу — ТОЛЬКО caddy :80/:443):
# postgres, minio — хранилища БЕЗ host-портов (volume'ы); # postgres, minio — хранилища БЕЗ host-портов (volume'ы);
# core (:5080 HTTP + :5082 gRPC-ингресс), telegram-service (:5101), ai-service (:5102), # core (:5080 HTTP + :5082 gRPC-ингресс), telegram-service (:5101), ai-service (:5102),
# ml-service (:5103) — процессы «Дейла»; mTLS-транспорт — по env Ruling 6 (см. ниже); # ml-service (:5103), storage-service (:5104) — процессы «Дейла»; mTLS-транспорт — по env Ruling 6 (см. ниже);
# caddy — edge: TLS-терминация, статика фронта, reverse_proxy /api → core. # caddy — edge: TLS-терминация, статика фронта, reverse_proxy /api → core.
# loki/promtail/grafana/prometheus — observability (Ruling 7; метрики — этап 12, пакет A): ПРОФИЛЬ # observability (ПРОФИЛЬ `observability`) — современный стек: otel-collector (приём трейсов OTLP),
# `observability` — поднимается только: docker compose --profile observability up -d # tempo (хранилище трейсов), loki/promtail (логи), prometheus (метрики),
# (или ... up -d --profile observability). Prometheus scrape'ит /metrics # cadvisor/node-exporter (потребление ресурсов контейнеров/хоста), grafana (UI).
# (порт 9464) всех 4 процессов; Grafana — логи (Loki) и метрики (Prometheus). # Подъём: docker compose --profile observability up -d.
# #
# Секреты — ТОЛЬКО из env: шаблон deploy/.env.prod.example → скопируйте в deploy/.env.prod, # Секреты — ТОЛЬКО из env: шаблон deploy/.env.prod.example → скопируйте в deploy/.env.prod,
# заполните значения и запускайте с --env-file: # заполните значения и запускайте с --env-file:
@@ -125,6 +125,7 @@ services:
Services__Ai__Endpoint: ${DEAL_AI_ENDPOINT:-http://ai-service:5102} Services__Ai__Endpoint: ${DEAL_AI_ENDPOINT:-http://ai-service:5102}
Services__Telegram__UseLocal: "false" Services__Telegram__UseLocal: "false"
Services__Telegram__Endpoint: ${DEAL_TELEGRAM_ENDPOINT:-http://telegram-service:5101} Services__Telegram__Endpoint: ${DEAL_TELEGRAM_ENDPOINT:-http://telegram-service:5101}
Services__Storage__Endpoint: ${DEAL_STORAGE_SERVICE_ENDPOINT:-http://storage-service:5104}
# Файлы — MinIO (внутренний http; TLS minio — вне этапа, при желании Storage__Minio__Secure=true # Файлы — MinIO (внутренний http; TLS minio — вне этапа, при желании Storage__Minio__Secure=true
# + endpoint https и сертификаты). # + endpoint https и сертификаты).
Storage__Minio__Endpoint: minio:9000 Storage__Minio__Endpoint: minio:9000
@@ -132,6 +133,9 @@ services:
Storage__Minio__SecretKey: ${MINIO_ROOT_PASSWORD:?MINIO_ROOT_PASSWORD не задан} Storage__Minio__SecretKey: ${MINIO_ROOT_PASSWORD:?MINIO_ROOT_PASSWORD не задан}
Storage__Minio__Bucket: deal-files Storage__Minio__Bucket: deal-files
Storage__Minio__Secure: "false" Storage__Minio__Secure: "false"
# Трейсинг OTel → коллектор профиля observability; без коллектора трейсы не экспортируются.
OTEL_EXPORTER_OTLP_ENDPOINT: ${DEAL_OTEL_ENDPOINT:-}
OTEL_SERVICE_NAME: core
# mTLS внутреннего gRPC (Ruling 6; пути — /etc/deal/certs, см. volume ниже). # mTLS внутреннего gRPC (Ruling 6; пути — /etc/deal/certs, см. volume ниже).
DEAL_MTLS_ENABLED: ${DEAL_MTLS_ENABLED:-0} DEAL_MTLS_ENABLED: ${DEAL_MTLS_ENABLED:-0}
DEAL_MTLS_CA_PEM: /etc/deal/certs/ca.pem DEAL_MTLS_CA_PEM: /etc/deal/certs/ca.pem
@@ -172,6 +176,8 @@ services:
DEAL_TELEGRAM_SESSION_KEY: ${DEAL_TELEGRAM_SESSION_KEY:?DEAL_TELEGRAM_SESSION_KEY не задан (ключ AES-GCM сессий)} DEAL_TELEGRAM_SESSION_KEY: ${DEAL_TELEGRAM_SESSION_KEY:?DEAL_TELEGRAM_SESSION_KEY не задан (ключ AES-GCM сессий)}
DEAL_TELEGRAM_SESSION_DIR: /data/sessions DEAL_TELEGRAM_SESSION_DIR: /data/sessions
DEAL_LOGS_DIR: /tmp/logs DEAL_LOGS_DIR: /tmp/logs
OTEL_EXPORTER_OTLP_ENDPOINT: ${DEAL_OTEL_ENDPOINT:-}
OTEL_SERVICE_NAME: telegram-service
SERVICES__CORE__INGRESS: ${DEAL_CORE_INGRESS:-http://core:5082} SERVICES__CORE__INGRESS: ${DEAL_CORE_INGRESS:-http://core:5082}
DEAL_MTLS_ENABLED: ${DEAL_MTLS_ENABLED:-0} DEAL_MTLS_ENABLED: ${DEAL_MTLS_ENABLED:-0}
DEAL_MTLS_CA_PEM: /etc/deal/certs/ca.pem DEAL_MTLS_CA_PEM: /etc/deal/certs/ca.pem
@@ -201,6 +207,8 @@ services:
GRPC_PORT: "5102" GRPC_PORT: "5102"
DEAL_SERVICE_TOKEN: ${DEAL_SERVICE_TOKEN:?DEAL_SERVICE_TOKEN не задан} DEAL_SERVICE_TOKEN: ${DEAL_SERVICE_TOKEN:?DEAL_SERVICE_TOKEN не задан}
DEAL_LOGS_DIR: /tmp/logs DEAL_LOGS_DIR: /tmp/logs
OTEL_EXPORTER_OTLP_ENDPOINT: ${DEAL_OTEL_ENDPOINT:-}
OTEL_SERVICE_NAME: ai-service
DEAL_MTLS_ENABLED: ${DEAL_MTLS_ENABLED:-0} DEAL_MTLS_ENABLED: ${DEAL_MTLS_ENABLED:-0}
DEAL_MTLS_CA_PEM: /etc/deal/certs/ca.pem DEAL_MTLS_CA_PEM: /etc/deal/certs/ca.pem
DEAL_MTLS_SERVER_CERT_PFX: /etc/deal/certs/ai-service-server.pfx DEAL_MTLS_SERVER_CERT_PFX: /etc/deal/certs/ai-service-server.pfx
@@ -229,6 +237,8 @@ services:
DEAL_SERVICE_TOKEN: ${DEAL_SERVICE_TOKEN:?DEAL_SERVICE_TOKEN не задан} DEAL_SERVICE_TOKEN: ${DEAL_SERVICE_TOKEN:?DEAL_SERVICE_TOKEN не задан}
DEAL_ML_DATA_DIR: /data/ml DEAL_ML_DATA_DIR: /data/ml
DEAL_LOGS_DIR: /tmp/logs DEAL_LOGS_DIR: /tmp/logs
OTEL_EXPORTER_OTLP_ENDPOINT: ${DEAL_OTEL_ENDPOINT:-}
OTEL_SERVICE_NAME: ml-service
DEAL_MTLS_ENABLED: ${DEAL_MTLS_ENABLED:-0} DEAL_MTLS_ENABLED: ${DEAL_MTLS_ENABLED:-0}
DEAL_MTLS_CA_PEM: /etc/deal/certs/ca.pem DEAL_MTLS_CA_PEM: /etc/deal/certs/ca.pem
DEAL_MTLS_SERVER_CERT_PFX: /etc/deal/certs/ml-service-server.pfx DEAL_MTLS_SERVER_CERT_PFX: /etc/deal/certs/ml-service-server.pfx
@@ -245,6 +255,44 @@ services:
retries: 10 retries: 10
restart: unless-stopped restart: unless-stopped
# storage-service — общий gRPC-сервис данных (вложения источников), :5104. Бэкенд — MinIO.
# Без host-портов; защита — общий service-token (+ mTLS при DEAL_MTLS_ENABLED=1).
storage-service:
build:
context: ..
dockerfile: src/storage-service/Deal.Storage/Dockerfile
<<: *service-hardening
mem_limit: 512m
cpus: 1.0
environment:
GRPC_PORT: "5104"
DEAL_SERVICE_TOKEN: ${DEAL_SERVICE_TOKEN:?DEAL_SERVICE_TOKEN не задан}
DEAL_STORAGE_ENDPOINT: minio:9000
DEAL_STORAGE_ACCESS_KEY: ${MINIO_ROOT_USER:?MINIO_ROOT_USER не задан}
DEAL_STORAGE_SECRET_KEY: ${MINIO_ROOT_PASSWORD:?MINIO_ROOT_PASSWORD не задан}
DEAL_STORAGE_BUCKET: deal-attachments
DEAL_STORAGE_SECURE: "false"
DEAL_LOGS_DIR: /tmp/logs
OTEL_EXPORTER_OTLP_ENDPOINT: ${DEAL_OTEL_ENDPOINT:-}
OTEL_SERVICE_NAME: storage-service
DEAL_MTLS_ENABLED: ${DEAL_MTLS_ENABLED:-0}
DEAL_MTLS_CA_PEM: /etc/deal/certs/ca.pem
DEAL_MTLS_SERVER_CERT_PFX: /etc/deal/certs/storage-service-server.pfx
DEAL_MTLS_SERVER_CERT_PASSWORD: ${DEAL_MTLS_CERT_PASSWORD:-}
DEAL_MTLS_CLIENT_CERT_PFX: /etc/deal/certs/deal-client.pfx
DEAL_MTLS_CLIENT_CERT_PASSWORD: ${DEAL_MTLS_CERT_PASSWORD:-}
volumes:
- ${DEAL_CERTS_DIR:-./certs}:/etc/deal/certs:ro
depends_on:
minio:
condition: service_started
healthcheck:
test: ["CMD-SHELL", "if [ \"$$DEAL_MTLS_ENABLED\" = \"1\" ]; then /bin/grpc_health_probe -addr=localhost:5104 -tls -tls-ca-cert=/etc/deal/certs/ca.pem -tls-client-cert=/etc/deal/certs/deal-client.crt -tls-client-key=/etc/deal/certs/deal-client.key -tls-server-name=localhost; else /bin/grpc_health_probe -addr=localhost:5104; fi"]
interval: 5s
timeout: 3s
retries: 10
restart: unless-stopped
# caddy — edge: наружу только :80/:443. TLS — плейсхолдер tls internal (см. Caddyfile: домен, # caddy — edge: наружу только :80/:443. TLS — плейсхолдер tls internal (см. Caddyfile: домен,
# реальный сертификат/Cloudflare, CSP/HSTS). Статика — ../src/frontend/dist (СОБРАТЬ ДО up). # реальный сертификат/Cloudflare, CSP/HSTS). Статика — ../src/frontend/dist (СОБРАТЬ ДО up).
caddy: caddy:
@@ -334,6 +382,63 @@ services:
condition: service_started condition: service_started
prometheus: prometheus:
condition: service_started condition: service_started
tempo:
condition: service_started
restart: unless-stopped
# ── Трейсы и ресурсы (observability-стек) ─────────────────────────────────
# OpenTelemetry Collector — приёмник трейсов Deal-процессов (OTLP gRPC :4317 / HTTP :4318),
# батчит и перекладывает в Tempo. Наружу порты не публикуются (внутри compose-сети).
otel-collector:
image: otel/opentelemetry-collector-contrib:0.160.0
profiles: ["observability"]
command: ["--config=/etc/otelcol-contrib/config.yaml"]
volumes:
- ./observability/otel-collector.yml:/etc/otelcol-contrib/config.yaml:ro
depends_on:
tempo:
condition: service_started
restart: unless-stopped
# Tempo — хранилище трейсов (OTLP от коллектора). Retention блоков — 7 суток (см. tempo.yml).
tempo:
image: grafana/tempo:2.8.1
profiles: ["observability"]
command: ["-config.file=/etc/tempo.yml"]
volumes:
- ./observability/tempo.yml:/etc/tempo.yml:ro
- deal_tempo_data:/var/tempo
restart: unless-stopped
# cAdvisor — ресурсы контейнеров (CPU/RAM/сеть/диск); scrape — job cadvisor в prometheus.yml.
cadvisor:
image: gcr.io/cadvisor/cadvisor:v0.52.1
profiles: ["observability"]
privileged: true
devices:
- /dev/kmsg:/dev/kmsg
volumes:
- /:/rootfs:ro
- /var/run:/var/run:ro
- /sys:/sys:ro
- /var/lib/docker/:/var/lib/docker:ro
- /dev/disk/:/dev/disk:ro
restart: unless-stopped
# node-exporter — ресурсы хоста (CPU/RAM/диск/сеть); scrape — job node-exporter в prometheus.yml.
node-exporter:
image: prom/node-exporter:v1.9.1
profiles: ["observability"]
command:
- --path.procfs=/host/proc
- --path.sysfs=/host/sys
- --path.rootfs=/host/root
- --collector.filesystem.mount-points-exclude=^/(sys|proc|dev|host|etc)($|/)
volumes:
- /proc:/host/proc:ro
- /sys:/host/sys:ro
- /:/host/root:ro
pid: host
restart: unless-stopped restart: unless-stopped
volumes: volumes:
@@ -343,6 +448,7 @@ volumes:
deal_ml_data: deal_ml_data:
deal_api_data: deal_api_data:
deal_caddy_data: deal_caddy_data:
deal_tempo_data:
deal_caddy_config: deal_caddy_config:
deal_loki_data: deal_loki_data:
deal_promtail_data: deal_promtail_data:
+53
View File
@@ -0,0 +1,53 @@
# Observability-стек «Дейла»
Современный (vendor-neutral) стек мониторинга. Поднимается **профилем `observability`** (в prod —
`deploy/compose.prod.yml`, в dev — `deploy/compose.dev.yml`); наружу порты не публикуются (prod —
доступ оператору по SSH-туннелю).
## Состав и поток данных
| Слой | Сервис | Конфиг | Поток |
|---|---|---|---|
| Трейсы (приём) | `otel-collector` | `otel-collector.yml` | OTLP от сервисов (`:4317`) → Tempo |
| Трейсы (хранение) | `tempo` | `tempo.yml` | OTLP от коллектора, retention 7 сут. |
| Логи | `loki` + `promtail` | `loki.yml`, `promtail.yml` | docker-логи → Loki |
| Метрики | `prometheus` | `prometheus.yml`, `prometheus-rules.yml` | scrape `/metrics` процессов и `cadvisor`/`node-exporter` |
| Ресурсы контейнеров | `cadvisor` | — | Prometheus |
| Ресурсы хоста | `node-exporter` | — | Prometheus |
| Визуализация | `grafana` | `grafana/provisioning/**` | Loki + Prometheus + Tempo |
## Подъём
```bash
# prod (нужен deploy/.env.prod с DEAL_GRAFANA_ADMIN_PASSWORD)
docker compose --env-file deploy/.env.prod -f deploy/compose.prod.yml --profile observability up -d
# dev
docker compose -f deploy/compose.dev.yml --profile observability up -d
```
Проверка: Prometheus `/targets` (job `deal` — 4 процесса UP, `cadvisor`, `node-exporter`, `tempo`) →
Grafana → папка «Дейл» → `Deal-Metrics-Overview` / `Deal-Logs` / `Deal-Traces` / `Deal-Resources`.
## Подключение сервисов (код)
Общая настройка — `Deal.Grpc.Hosting` (сервисы) и `Deal.Api/Observability` (ядро):
- **метрики**: `DealMetricsHosting` — OTel → Prometheus, отдельный HTTP/1.1-эндпоинт `/metrics:9464`
(env `METRICS_PORT`);
- **трейсы**: `DealTracingHosting` — OTel → OTLP, **опт-ин** через env `OTEL_EXPORTER_OTLP_ENDPOINT`
(адрес коллектора; без него трейсинг выключен), имя сервиса — `OTEL_SERVICE_NAME`;
- **логи**: Serilog JSON обогащается `TraceId`/`SpanId` (`TraceContextEnricher`) для связи с трейсами.
## Дашборды и алерты
Дашборды — как код: `grafana/dashboards/*.json` (правки только в репозитории, UI не сохраняет).
Алерты — `prometheus-rules.yml` (доступность, ошибки/5xx, очереди). Алерты по **ресурсам** вынесены в
`prometheus-resource-rules.yml` и **отключены по умолчанию**; включаются добавлением файла в `rule_files`,
пороги — через env `DEAL_ALERT_*` (см. шапку файла).
## Обновление версий образов
Версии зафиксированы в compose-файлах. При обновлении — свежие стабильные теги:
`otel/opentelemetry-collector-contrib`, `grafana/tempo`, `prom/node-exporter`,
`gcr.io/cadvisor/cadvisor`, `grafana/loki`, `grafana/promtail`, `prom/prometheus`, `grafana/grafana`.
@@ -0,0 +1,289 @@
{
"annotations": {
"list": []
},
"editable": true,
"graphTooltip": 0,
"id": null,
"links": [],
"panels": [
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"description": "Потребление CPU контейнерами Deal (cAdvisor). Значение — ядра, занятые контейнером за 5 минут.",
"fieldConfig": {
"defaults": {
"custom": {
"drawStyle": "line",
"fillOpacity": 15,
"lineWidth": 1,
"showPoints": "never"
},
"unit": "short"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 0
},
"id": 1,
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "desc"
}
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"expr": "sum by (name) (rate(container_cpu_usage_seconds_total{name=~\".*deal.*|.*core.*|.*service.*\"}[5m]))",
"legendFormat": "{{name}}",
"refId": "A"
}
],
"title": "CPU контейнеров (cAdvisor)",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"description": "Рабочий набор памяти контейнеров Deal (cAdvisor). Лимиты заданы mem_limit в compose.prod.yml.",
"fieldConfig": {
"defaults": {
"custom": {
"drawStyle": "line",
"fillOpacity": 15,
"lineWidth": 1,
"showPoints": "never"
},
"unit": "bytes"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 0
},
"id": 2,
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "desc"
}
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"expr": "sum by (name) (container_memory_working_set_bytes{name=~\".*deal.*|.*core.*|.*service.*\"})",
"legendFormat": "{{name}}",
"refId": "A"
}
],
"title": "Память контейнеров (cAdvisor)",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"description": "Загрузка CPU хоста (node-exporter), 0100%.",
"fieldConfig": {
"defaults": {
"custom": {
"drawStyle": "line",
"fillOpacity": 15,
"lineWidth": 1,
"showPoints": "never"
},
"max": 100,
"min": 0,
"unit": "percent"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 8
},
"id": 3,
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "desc"
}
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"expr": "100 - (avg(rate(node_cpu_seconds_total{mode=\"idle\"}[5m])) * 100)",
"legendFormat": "CPU хоста",
"refId": "A"
}
],
"title": "CPU хоста (node-exporter)",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"description": "Занятая память хоста (node-exporter), проценты от общего объёма.",
"fieldConfig": {
"defaults": {
"custom": {
"drawStyle": "line",
"fillOpacity": 15,
"lineWidth": 1,
"showPoints": "never"
},
"max": 100,
"min": 0,
"unit": "percent"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 8
},
"id": 4,
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "desc"
}
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"expr": "(1 - node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes) * 100",
"legendFormat": "Память хоста",
"refId": "A"
}
],
"title": "Память хоста (node-exporter)",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"description": "Свободное место на разделах хоста (node-exporter), проценты.",
"fieldConfig": {
"defaults": {
"custom": {
"drawStyle": "line",
"fillOpacity": 15,
"lineWidth": 1,
"showPoints": "never"
},
"max": 100,
"min": 0,
"unit": "percent"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 24,
"x": 0,
"y": 16
},
"id": 5,
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "desc"
}
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"expr": "min by (mountpoint) (node_filesystem_avail_bytes{fstype!~\"tmpfs|overlay\"} / node_filesystem_size_bytes{fstype!~\"tmpfs|overlay\"}) * 100",
"legendFormat": "{{mountpoint}}",
"refId": "A"
}
],
"title": "Свободное место на диске (node-exporter)",
"type": "timeseries"
}
],
"refresh": "30s",
"schemaVersion": 39,
"tags": [
"deal",
"resources"
],
"templating": {
"list": []
},
"time": {
"from": "now-6h",
"to": "now"
},
"timezone": "browser",
"title": "Deal — Ресурсы",
"uid": "deal-resources",
"version": 1
}
@@ -0,0 +1,56 @@
{
"annotations": {
"list": []
},
"editable": true,
"graphTooltip": 0,
"id": null,
"links": [],
"panels": [
{
"datasource": {
"type": "tempo",
"uid": "tempo"
},
"description": "Поиск трейсов Deal (Tempo, TraceQL). Источник — OTLP от сервисов через otel-collector. Клик по трейсу — спаны по сервисам; из спана можно перейти к логам (Loki) по traceId.",
"gridPos": {
"h": 22,
"w": 24,
"x": 0,
"y": 0
},
"id": 1,
"options": {},
"targets": [
{
"datasource": {
"type": "tempo",
"uid": "tempo"
},
"query": "{}",
"queryType": "traceql",
"refId": "A"
}
],
"title": "Трейсы (Tempo)",
"type": "traces"
}
],
"refresh": "30s",
"schemaVersion": 39,
"tags": [
"deal",
"traces"
],
"templating": {
"list": []
},
"time": {
"from": "now-1h",
"to": "now"
},
"timezone": "browser",
"title": "Deal — Трейсы",
"uid": "deal-traces",
"version": 1
}
@@ -13,6 +13,12 @@ datasources:
isDefault: true isDefault: true
jsonData: jsonData:
maxLines: 1000 maxLines: 1000
# Клик по TraceId в логе открывает трейс в Tempo (обогащение логов TraceContextEnricher).
derivedFields:
- name: TraceID
matcherRegex: '"TraceId":"([0-9a-f]+)"'
datasourceUid: tempo
url: "$${__value.raw}"
- name: Prometheus - name: Prometheus
# UID фиксирован: на него ссылаются панели дашборда Deal-Metrics-Overview (datasource uid: prometheus). # UID фиксирован: на него ссылаются панели дашборда Deal-Metrics-Overview (datasource uid: prometheus).
@@ -26,3 +32,24 @@ datasources:
# Prometheus хранит OTel-гистограммы в нативных bucket'ах — используем нативные histogram_quantile. # Prometheus хранит OTel-гистограммы в нативных bucket'ах — используем нативные histogram_quantile.
httpMethod: POST httpMethod: POST
timeInterval: 15s timeInterval: 15s
- name: Tempo
# UID фиксирован: на него ссылаются панели Deal-Traces и derivedFields логов (datasource uid: tempo).
uid: tempo
type: tempo
access: proxy
url: http://tempo:3200
isDefault: false
jsonData:
# Из спана трейса — к логам того же сервиса и traceId (обратная корреляция Loki ↔ Tempo).
tracesToLogsV2:
datasourceUid: loki
filterByTraceID: true
filterBySpanID: false
spanStartTimeShift: -1m
spanEndTimeShift: 1m
# Карта сервисов и граф узлов — из метрик Prometheus.
serviceMap:
datasourceUid: prometheus
nodeGraph:
enabled: true
+40
View File
@@ -0,0 +1,40 @@
# OpenTelemetry Collector — приёмник трейсов Deal-процессов (профиль observability).
#
# Процессы шлют OTLP (gRPC :4317 / HTTP :4318) на этот сервис; коллектор батчит и перекладывает
# трейсы в Tempo (OTLP). Запускается только профилем observability (deploy/compose.prod.yml);
# наружу порты не публикуются (внутри compose-сети).
#
# Конфиг монтируется в /etc/otelcol-contrib/config.yaml. Health-эндпоинт :13133 — для healthcheck.
receivers:
otlp:
protocols:
grpc:
endpoint: 0.0.0.0:4317
http:
endpoint: 0.0.0.0:4318
processors:
# Батчинг снижает число сетевых вызовов к Tempo (стандартный приём OTel).
batch: {}
exporters:
otlp/tempo:
endpoint: tempo:4317
tls:
insecure: true
extensions:
health_check:
endpoint: 0.0.0.0:13133
service:
extensions: [health_check]
telemetry:
logs:
level: warn
pipelines:
traces:
receivers: [otlp]
processors: [batch]
exporters: [otlp/tempo]
@@ -0,0 +1,73 @@
# Правила алертов Prometheus по потреблению ресурсов — ОТКЛЮЧЕНЫ ПО УМОЛЧАНИЮ.
#
# Заготовка: включить, добавив эту строку в `rule_files` файла prometheus.yml:
# rule_files:
# - prometheus-rules.yml
# - prometheus-resource-rules.yml
#
# Пороги выносятся в env (DEAL_ALERT_*); Prometheus не раскрывает значения env в конфиге, поэтому при
# включении файл рендерится из шаблона (подстановка порогов) или пороги проставляются вручную:
# DEAL_ALERT_HOST_CPU_PERCENT — загрузка CPU хоста, % (дефолт 90)
# DEAL_ALERT_HOST_MEMORY_PERCENT — занятая память хоста, % (дефолт 90)
# DEAL_ALERT_HOST_DISK_PERCENT — минимум свободного места, % (дефолт 15)
# DEAL_ALERT_CONTAINER_CPU — CPU контейнера (ядра) (дефолт 1.5)
# DEAL_ALERT_CONTAINER_MEMORY_PERCENT — память контейнера к лимиту, %(дефолт 90)
#
# Источники: node-exporter (хост) и cAdvisor (контейнеры), scrape — jobs node-exporter/cadvisor.
groups:
- name: deal-resources
rules:
# Хост: загрузка CPU. Дефолт порога — 90%.
- alert: DealHostHighCpu
expr: 100 - (avg(rate(node_cpu_seconds_total{mode="idle"}[5m])) * 100) > 90
for: 10m
labels:
severity: warning
annotations:
summary: "Высокая загрузка CPU хоста"
description: "Средняя загрузка CPU хоста выше 90% за 5 минут более 10 минут."
# Хост: занятая память. Дефолт порога — 90%.
- alert: DealHostHighMemory
expr: (1 - node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes) > 0.9
for: 10m
labels:
severity: warning
annotations:
summary: "Высокое потребление памяти хостом"
description: "Свободной памяти меньше 10% более 10 минут."
# Хост: свободное место на разделе. Дефолт порога — 15%.
- alert: DealHostDiskLow
expr: |
min by (mountpoint) (node_filesystem_avail_bytes{fstype!~"tmpfs|overlay"} / node_filesystem_size_bytes{fstype!~"tmpfs|overlay"}) < 0.15
for: 15m
labels:
severity: warning
annotations:
summary: "Мало места на диске ({{ $labels.mountpoint }})"
description: "Свободно менее 15% на разделе {{ $labels.mountpoint }} более 15 минут."
# Контейнеры Deal: CPU (ядра). Дефолт порога — 1.5.
- alert: DealContainerHighCpu
expr: |
sum by (name) (rate(container_cpu_usage_seconds_total{name=~".*deal.*|.*core.*|.*service.*"}[5m])) > 1.5
for: 10m
labels:
severity: warning
annotations:
summary: "Высокий CPU контейнера {{ $labels.name }}"
description: "Контейнер {{ $labels.name }} держит > 1.5 CPU за 5 минут более 10 минут."
# Контейнеры Deal: память к лимиту. Дефолт порога — 90%.
- alert: DealContainerHighMemory
expr: |
(container_memory_working_set_bytes{name=~".*deal.*|.*core.*|.*service.*"}
/ clamp_min(container_spec_memory_limit_bytes{name=~".*deal.*|.*core.*|.*service.*"}, 1)) > 0.9
for: 10m
labels:
severity: warning
annotations:
summary: "Контейнер {{ $labels.name }} близок к лимиту памяти"
description: "Контейнер {{ $labels.name }} использует более 90% лимита памяти более 10 минут."
@@ -79,3 +79,4 @@ groups:
annotations: annotations:
summary: "Растёт очередь обучения ML (outbox)" summary: "Растёт очередь обучения ML (outbox)"
description: "Суммарная глубина MlOutbox держится выше 100 более 15 минут ({{ $value }})." description: "Суммарная глубина MlOutbox держится выше 100 более 15 минут ({{ $value }})."
+15
View File
@@ -46,3 +46,18 @@ scrape_configs:
- targets: ["ml-service:9464"] - targets: ["ml-service:9464"]
labels: labels:
service: ml-service service: ml-service
# Ресурсы контейнеров (cAdvisor) и хоста (node-exporter) — этап «observability-стек».
# cAdvisor отдаёт метрики контейнеров (CPU/RAM/сеть/диск), node-exporter — хоста.
- job_name: cadvisor
static_configs:
- targets: ["cadvisor:8080"]
- job_name: node-exporter
static_configs:
- targets: ["node-exporter:9100"]
# Темпо (хранилище трейсов) — само-мониторинг: метрики приёма/отдачи трейсов.
- job_name: tempo
static_configs:
- targets: ["tempo:3200"]
+28
View File
@@ -0,0 +1,28 @@
# Grafana Tempo — хранилище трейсов Deal (профиль observability).
#
# Принимает OTLP-трейсы от OpenTelemetry Collector (grpc :4317), хранит локально (volume
# deal_tempo_data), отдаёт запросы Grafana на :3200 (внутри compose-сети). Block retention — 7 суток
# (трейсы объёмны; логи/метрики живут дольше).
server:
http_listen_port: 3200
log_level: warn
distributor:
receivers:
otlp:
protocols:
grpc:
endpoint: 0.0.0.0:4317
storage:
trace:
backend: local
local:
path: /var/tempo/traces
wal:
path: /var/tempo/wal
compactor:
compaction:
block_retention: 168h
+17 -4
View File
@@ -242,11 +242,23 @@
- `try-catch` — только для непредвиденных ошибок, не для управления ходом программы. - `try-catch` — только для непредвиденных ошибок, не для управления ходом программы.
- При пробрасывании выше — `throw;`, а **не** `throw ex;`. - При пробрасывании выше — `throw;`, а **не** `throw ex;`.
- Свои исключения наследовать от `Exception`. - **Свои доменные исключения наследовать от `DealException`** (`Deal.SharedKernel.Errors`) — базовый тип
хранит код ошибки (`ErrorCode`) и умеет брать текст из ресурсов. Состав: `NotFoundException`,
`ValidationException`, `ConflictException`, `ServiceUnavailableException`; новые — по тому же образцу.
- **Не возвращать `null` как штатный результат «не найдено»/ошибки.** Доменный сервис, у которого объект
не найден, бросает `NotFoundException` (эндпоинт отдаёт 404 через общий обработчик, а не проверкой
`is null` в каждом хендлере). `null` допустим только для **опциональных значений** — парсеры/извлечение
полей, выборки-запросы («нет строки» — нормальный результат), `Try*`-паттерн; такие методы должны быть
nullable-аннотированы и явно описаны в XML-doc.
- Исключение создавать всегда, когда функция не может быть выполнена (неверные параметры, нет доступа к - Исключение создавать всегда, когда функция не может быть выполнена (неверные параметры, нет доступа к
БД, неизвестные идентификаторы и т.п.). БД, неизвестные идентификаторы и т.п.).
- Все исключения должны быть залогированы или показаны пользователю; пустые `catch` запрещены. - Все исключения должны быть залогированы или показаны пользователю; **пустые `catch` запрещены**.
- В лог об ошибке, как правило, писать `StackTrace`. - **Единый формат лога ошибки:** понятный русский текст + структурированный контекст (операция, `tenantId`,
id сущности, `traceId`). Стектрейс пишется **только в лог**; в ответ/сообщение клиенту он не попадает —
наружу отдаётся обобщённый текст и код (обработчики на границах: `DealExceptionHandler`, gRPC-интерцептор).
- **Тексты исключений/ошибок не хардкодить** — держать в ресурсах (`ErrorMessages.resx`, доступ через
`ErrorResources.Format(ErrorResourceKeys.*)` и шаблоны `DealException`), чтобы переводы добавлялись
отдельной культурой (`.resx`-спутник) без правок кода.
## 11. Интерфейсы ## 11. Интерфейсы
@@ -259,7 +271,8 @@
- **Явная реализация интерфейсов — по умолчанию** (`Task ICardStore.GetAsync(...)`). **[изм. 2026-09-11, - **Явная реализация интерфейсов — по умолчанию** (`Task ICardStore.GetAsync(...)`). **[изм. 2026-09-11,
решение владельца]** Классы напрямую не вызываются — только через интерфейсы; исключения: DTO/модели решение владельца]** Классы напрямую не вызываются — только через интерфейсы; исключения: DTO/модели
(напр. `Card` и семейство `I*Card`), хелперы, extension-классы. Весь прод-код уже переведён на явные (напр. `Card` и семейство `I*Card`), хелперы, extension-классы. Весь прод-код уже переведён на явные
реализации (codemod `scripts/make_explicit.py`, идемпотентный). реализации (codemod `scripts/make_explicit.py` и `scripts/strip_implementation_docs.py` — идемпотентны,
`--apply` применяет правки, без флага — dry-run-отчёт).
- Один публичный тип интерфейса = один файл (как и для классов); имя файла = имя типа. - Один публичный тип интерфейса = один файл (как и для классов); имя файла = имя типа.
- **Маркерные классы не используются** — если нужен маркер, это маркерный интерфейс - **Маркерные классы не используются** — если нужен маркер, это маркерный интерфейс
(`IKanbanModule`, `ISharedKernel` и т.п.). **[изм. 2026-09-11]** (`IKanbanModule`, `ISharedKernel` и т.п.). **[изм. 2026-09-11]**
@@ -22,7 +22,7 @@
| Файлы | MinIO (S3-совместимое хранилище) | | Файлы | MinIO (S3-совместимое хранилище) |
| Фронтенд | Vue 3 + Vite + Tailwind | | Фронтенд | Vue 3 + Vite + Tailwind |
| Межсервисно | gRPC + Protobuf (mTLS — за флагом `DEAL_MTLS_*`, §10/§13.8) | | Межсервисно | gRPC + Protobuf (mTLS — за флагом `DEAL_MTLS_*`, §10/§13.8) |
| Наблюдаемость | Serilog (JSON: консоль + rolling-файл) → Promtail → Loki → Grafana; метрики OTel → Prometheus Grafana | | Наблюдаемость | Serilog (JSON: консоль + rolling-файл) → Promtail → Loki → Grafana; метрики OTel → Prometheus; трейсы OTel → Collector → Tempo; ресурсы cAdvisor/node-exporter → Prometheus; единый UI — Grafana |
| Прокси/edge | Caddy (TLS, security-заголовки); Cloudflare/k8s — вне этапа (§10/§11) | | Прокси/edge | Caddy (TLS, security-заголовки); Cloudflare/k8s — вне этапа (§10/§11) |
| Контейнеры | Docker / docker compose (VPS); k8s — позже | | Контейнеры | Docker / docker compose (VPS); k8s — позже |
| Бэкапы | Ежедневные: pg_dump + MinIO + сессии | | Бэкапы | Ежедневные: pg_dump + MinIO + сессии |
@@ -236,7 +236,9 @@ settings(Key varchar(200) PK, ValueJson text, UpdatedAt timestamptz) --
(нужен `DEAL_GRAFANA_ADMIN_PASSWORD` в `.env.prod`; порты Grafana/Prometheus — только loopback). Остановка — (нужен `DEAL_GRAFANA_ADMIN_PASSWORD` в `.env.prod`; порты Grafana/Prometheus — только loopback). Остановка —
`docker compose -f deploy/compose.prod.yml --profile observability down`. `docker compose -f deploy/compose.prod.yml --profile observability down`.
- **Провижининг Grafana — как код** (`deploy/observability/grafana/provisioning`, монтируется в - **Провижининг Grafana — как код** (`deploy/observability/grafana/provisioning`, монтируется в
контейнер): `datasources/datasources.yml` — датасорс Loki (uid `loki`, URL `http://loki:3100`); контейнер): `datasources/datasources.yml` — датасорсы Loki (uid `loki`, default), Prometheus
(uid `prometheus`) и Tempo (uid `tempo`); у Loki — `derivedFields` TraceID → Tempo (клик по traceId
в логе открывает трейс), у Tempo — `tracesToLogsV2` → Loki и `serviceMap`/`nodeGraph` по метрикам;
`dashboards/dashboards.yml` — папка `Дейл` из `/var/lib/grafana/dashboards`. Дашборды — файлы `dashboards/dashboards.yml` — папка `Дейл` из `/var/lib/grafana/dashboards`. Дашборды — файлы
`deploy/observability/grafana/dashboards/*.json`: правки только в репозитории, UI-изменения не `deploy/observability/grafana/dashboards/*.json`: правки только в репозитории, UI-изменения не
сохраняются (`allowUiUpdates: false`). сохраняются (`allowUiUpdates: false`).
@@ -251,7 +253,10 @@ settings(Key varchar(200) PK, ValueJson text, UpdatedAt timestamptz) --
access-лога core) и активация инвайтов (`/api/join`); access-лога core) и активация инвайтов (`/api/join`);
- `Deal-Errors` — HTTP 5xx, необработанные исключения (`@x`), Error/Fatal, ошибки gRPC и общая лента; - `Deal-Errors` — HTTP 5xx, необработанные исключения (`@x`), Error/Fatal, ошибки gRPC и общая лента;
- `Deal-Rps` — нагрузка HTTP+gRPC (RPS), top-путей/методов и p50/p95 длительности запроса; - `Deal-Rps` — нагрузка HTTP+gRPC (RPS), top-путей/методов и p50/p95 длительности запроса;
- `Deal-Logs` — обзор логов с фильтрами по сервису и уровню, активность по тенантам (AI/ML/Telegram). - `Deal-Logs` — обзор логов с фильтрами по сервису и уровню, активность по тенантам (AI/ML/Telegram);
- `Deal-Traces` — поиск трейсов (Tempo, TraceQL), спаны по сервисам, переход к логам по traceId;
- `Deal-Resources` — потребление ресурсов контейнерами (cAdvisor) и хостом (node-exporter): CPU/RAM,
свободное место на дисках.
**Актор в логах:** с BL-LOG-ACTOR access-лог включает `actor` (login пользователя/оператора) и **Актор в логах:** с BL-LOG-ACTOR access-лог включает `actor` (login пользователя/оператора) и
`tenant`; полная лента действий с деталями — `public.audit_log` (append-only) через `tenant`; полная лента действий с деталями — `public.audit_log` (append-only) через
`GET /api/operator/audit` / экран «Аудит» оператор-консоли. `GET /api/operator/audit` / экран «Аудит» оператор-консоли.
@@ -295,9 +300,37 @@ settings(Key varchar(200) PK, ValueJson text, UpdatedAt timestamptz) --
`deal_ml_outbox_depth`), пропажа метрик ядра (`absent(deal_sessions_active)`). Замечание: правила `deal_ml_outbox_depth`), пропажа метрик ядра (`absent(deal_sessions_active)`). Замечание: правила
бюджета токенов нет — метрика бюджета в Prometheus отсутствует (см. §6/§10), поэтому алерт не вводится. бюджета токенов нет — метрика бюджета в Prometheus отсутствует (см. §6/§10), поэтому алерт не вводится.
- Как поднять/проверить: `docker compose --env-file deploy/.env.prod -f deploy/compose.prod.yml - Как поднять/проверить: `docker compose --env-file deploy/.env.prod -f deploy/compose.prod.yml
--profile observability up -d` → Prometheus `/targets` (все 4 UP) → Grafana → папка «Дейл» → --profile observability up -d` → Prometheus `/targets` (все UP) → Grafana → папка «Дейл» →
`Deal-Metrics-Overview`. Быстрая проверка экспортёра без Grafana: `curl http://<процесс>:9464/metrics` `Deal-Metrics-Overview`/`Deal-Resources`/`Deal-Traces`. Быстрая проверка экспортёра без Grafana:
изнутри сети. `curl http://<процесс>:9464/metrics` изнутри сети.
### Трейсы (OpenTelemetry Collector + Tempo)
- **Экспорт из 5 процессов**: OpenTelemetry SDK → OTLP → **OpenTelemetry Collector** (`otel-collector:
4317`) → **Tempo** (`tempo:4317`, хранилище трейсов, retention 7 суток). Настройка — общая в
`Deal.Grpc.Hosting` (`DealTracingHosting`) для telegram/ai/ml/storage и `Deal.Api/Observability/
DealTracingHosting.cs` для ядра. Инструментируется входящий HTTP/gRPC (AspNetCore), исходящие
HTTP-клиенты и gRPC-клиенты (GrpcNetClient) — трейсы сквозные от входа до БД/внешних сервисов.
- **Включение — опт-ин через env** `OTEL_EXPORTER_OTLP_ENDPOINT` (адрес коллектора, напр.
`http://otel-collector:4317`); без него трейсинг выключен. В compose env задан пустым
(`${DEAL_OTEL_ENDPOINT:-}`) — чтобы включить, задайте `DEAL_OTEL_ENDPOINT` в `.env`. Имя сервиса в
трейсах — `OTEL_SERVICE_NAME` (дефолт по процессу: `core`, `telegram-service`, `ai-service`,
`ml-service`, `storage-service`).
- **Корреляция с логами**: Serilog обогащается `TraceId`/`SpanId` из `Activity.Current`
(`TraceContextEnricher`) — в Loki-логе есть `TraceId`, а датасорс Loki `derivedFields` даёт переход
из лога в трейс Tempo (и обратно — `tracesToLogsV2`).
- Сервисы профиля: `otel-collector` (`otel/opentelemetry-collector-contrib:0.160.0`, конфиг
`deploy/observability/otel-collector.yml`) и `tempo` (`grafana/tempo:2.8.1`, конфиг
`deploy/observability/tempo.yml`, volume `deal_tempo_data`). Наружу порты не публикуются (dev — для отладки).
### Ресурсы (cAdvisor + node-exporter)
- **cAdvisor** (`gcr.io/cadvisor/cadvisor:v0.52.1`) — потребление ресурсов **контейнерами**
(CPU/RAM/сеть/диск); **node-exporter** (`prom/node-exporter:v1.9.1`) — ресурсы **хоста** (CPU/RAM/
диски/сеть). Оба scrape'ит Prometheus (jobs `cadvisor`, `node-exporter` в `prometheus.yml`).
- Дашборд `Deal-Resources` (uid `deal-resources`): CPU/RAM контейнеров, CPU/RAM хоста, свободное место
на дисках. Правила алертов по ресурсам — в отдельном файле `prometheus-resource-rules.yml`,
**отключены по умолчанию** (не входят в `rule_files`); пороги — через env `DEAL_ALERT_*` при включении.
--- ---
@@ -330,10 +363,11 @@ settings(Key varchar(200) PK, ValueJson text, UpdatedAt timestamptz) --
- Одна внутренняя сеть; наружу — только **caddy** (80/443): TLS (шапка `deploy/caddy/Caddyfile` — - Одна внутренняя сеть; наружу — только **caddy** (80/443): TLS (шапка `deploy/caddy/Caddyfile` —
`tls internal` для dev/интранет, для реального домена заменить на Cloudflare-origin/сертификаты), `tls internal` для dev/интранет, для реального домена заменить на Cloudflare-origin/сертификаты),
статика `src/frontend/dist`, `reverse_proxy /api → core:5080`, security-заголовки (CSP/HSTS — здесь). статика `src/frontend/dist`, `reverse_proxy /api → core:5080`, security-заголовки (CSP/HSTS — здесь).
- `core` (:5080 http + :5082 gRPC-ингресс), `telegram/ai/ml-service` (mTLS-env, Ruling 6), - `core` (:5080 http + :5082 gRPC-ингресс), `telegram/ai/ml/storage-service` (mTLS-env, Ruling 6),
`postgres`/`minio` **без host-портов**; healthcheck'и — `grpc_health_probe` (при mTLS — TLS-проба с `postgres`/`minio` **без host-портов**; healthcheck'и — `grpc_health_probe` (при mTLS — TLS-проба с
PEM `deal-client.crt/.key`)/`pg_isready`. PEM `deal-client.crt/.key`)/`pg_isready`.
- Профиль `observability`: `loki`/`promtail`/`grafana` + `prometheus` (метрики — этап 12, пакет A; см. §7). - Профиль `observability`: `otel-collector`/`tempo` (трейсы), `loki`/`promtail` (логи),
`prometheus`/`cadvisor`/`node-exporter` (метрики и ресурсы), `grafana` (UI); см. §7.
Секреты — только из `.env.prod` Секреты — только из `.env.prod`
(шаблон `deploy/.env.prod.example`, без дефолтных паролей; отсутствие → fail-fast `:?`). (шаблон `deploy/.env.prod.example`, без дефолтных паролей; отсутствие → fail-fast `:?`).
Rate limiting включён (`RateLimit__Enabled: true`), CORS — явный `Security__AllowedOrigins` Rate limiting включён (`RateLimit__Enabled: true`), CORS — явный `Security__AllowedOrigins`
@@ -1152,11 +1186,12 @@ docker compose -f deploy/compose.dev.yml down # погасить ст
(RpcCallLoggingInterceptor; gRPC-health не логируется). **Метрики** — OTel → Prometheus: `/metrics` (RpcCallLoggingInterceptor; gRPC-health не логируется). **Метрики** — OTel → Prometheus: `/metrics`
(HTTP/1.1 :9464) + прикладные `deal.*` (токены/вызовы AI/ML, аудит, глубины очередей, сессии) — см. §7. (HTTP/1.1 :9464) + прикладные `deal.*` (токены/вызовы AI/ML, аудит, глубины очередей, сессии) — см. §7.
PROD-стек: docker-логи → Promtail → Loki (retention 7 сут.) → Grafana (`127.0.0.1:3001`, SSH-туннель), PROD-стек: docker-логи → Promtail → Loki (retention 7 сут.) → Grafana (`127.0.0.1:3001`, SSH-туннель),
метрики → Prometheus (`127.0.0.1:9090`) → Grafana; профиль `observability` compose.prod. метрики → Prometheus (`127.0.0.1:9090`) → Grafana, трейсы OTel → otel-collector → Tempo, ресурсы
cAdvisor/node-exporter → Prometheus; профиль `observability` compose.prod.
- **compose.prod** (Ruling 9): `deploy/compose.prod.yml` — postgres/minio (без host-портов), core + telegram/ai/ml - **compose.prod** (Ruling 9): `deploy/compose.prod.yml` — postgres/minio (без host-портов), core + telegram/ai/ml
(mTLS env; healthcheck — `grpc_health_probe`, при mTLS — TLS-проба с PEM), `caddy` (80/443: статика (mTLS env; healthcheck — `grpc_health_probe`, при mTLS — TLS-проба с PEM), `caddy` (80/443: статика
`src/frontend/dist` + `reverse_proxy /api → core:5080`, security-заголовки; домен/TLS/Cloudflare — шапка `src/frontend/dist` + `reverse_proxy /api → core:5080`, security-заголовки; домен/TLS/Cloudflare — шапка
`deploy/caddy/Caddyfile`), профиль `observability` (loki/promtail/grafana/prometheus). Секреты — только из `.env.prod` `deploy/caddy/Caddyfile`), профиль `observability` (otel-collector/tempo/loki/promtail/prometheus/cadvisor/node-exporter/grafana). Секреты — только из `.env.prod`
(шаблон `deploy/.env.prod.example`, без дефолтных паролей, fail-fast `:?`). Запуск: (шаблон `deploy/.env.prod.example`, без дефолтных паролей, fail-fast `:?`). Запуск:
`docker compose --env-file deploy/.env.prod -f deploy/compose.prod.yml up -d --build` (+ `--profile observability`); `docker compose --env-file deploy/.env.prod -f deploy/compose.prod.yml up -d --build` (+ `--profile observability`);
авто-проверка — `... config` rc=0. авто-проверка — `... config` rc=0.
@@ -1305,7 +1340,8 @@ docker compose -f deploy/compose.dev.yml start core telegram-service ml-service
(`container_created`, `container_updated`, `container_deleted`), `settings_updated`, `channel_enabled`, (`container_created`, `container_updated`, `container_deleted`), `settings_updated`, `channel_enabled`,
`telegram_linked` (таблица — в контракте; `channel_created` зарезервирован, но не эмитится). `telegram_linked` (таблица — в контракте; `channel_created` зарезервирован, но не эмитится).
- **Наблюдаемость** (Grafana provisioning + promtail-лейблы, дашборды `Deal-Auth/Errors/Rps/Logs`; с этапа 12 — - **Наблюдаемость** (Grafana provisioning + promtail-лейблы, дашборды `Deal-Auth/Errors/Rps/Logs`; с этапа 12 —
метрики OTel → Prometheus и дашборд `Deal-Metrics-Overview`, см. §7). метрики OTel → Prometheus и дашборд `Deal-Metrics-Overview`; трейсы OTel → Collector → Tempo (`Deal-Traces`)
и ресурсы cAdvisor/node-exporter (`Deal-Resources`), см. §7).
- **Как открыть (dev):** `docker compose -f deploy/compose.dev.yml up -d --build` (или core на `:5080` - **Как открыть (dev):** `docker compose -f deploy/compose.dev.yml up -d --build` (или core на `:5080`
с Postgres `:5433`, AI в Local-режиме) → фронт `cd src/frontend && npm run dev` (`:5173`, прокси `/api`) с Postgres `:5433`, AI в Local-режиме) → фронт `cd src/frontend && npm run dev` (`:5173`, прокси `/api`)
→ **оператор:** `http://localhost:5173/#/operator`, вход `operator`/`operator` (dev-дефолт; в Production — → **оператор:** `http://localhost:5173/#/operator`, вход `operator`/`operator` (dev-дефолт; в Production —
@@ -4,9 +4,6 @@ using System.Text.Json.Nodes;
namespace Deal.Ai.Llm; namespace Deal.Ai.Llm;
/// <summary>
/// HTTP-реализация <see cref="IProviderClient"/>
/// </summary>
public sealed class LlmHttpClient : IProviderClient public sealed class LlmHttpClient : IProviderClient
{ {
// Относительный путь OpenAI-совместимого эндпоинта (база уже без хвостового «/»). // Относительный путь OpenAI-совместимого эндпоинта (база уже без хвостового «/»).
@@ -58,13 +55,6 @@ public sealed class LlmHttpClient : IProviderClient
_anthropicCallTimeout = anthropicCallTimeout; _anthropicCallTimeout = anthropicCallTimeout;
} }
/// <summary>
/// Выполняет один вызов модели по выбранной схеме API.
/// </summary>
/// <param name="config">Конфиг провайдера (стиль — <c>ApiStyle</c>).</param>
/// <param name="systemPrompt">Системный промпт.</param>
/// <param name="userText">Пользовательское сообщение/контекст.</param>
/// <returns>Текст ответа и usage API-ответа (null при его отсутствии).</returns>
async Task<ProviderChatResult> IProviderClient.ChatAsync( async Task<ProviderChatResult> IProviderClient.ChatAsync(
LlmConfig config, LlmConfig config,
string systemPrompt, string systemPrompt,
+1
View File
@@ -18,6 +18,7 @@ WebApplication app = AiServiceHost.Create(
{ {
DealLogging.Configure(builder, aiProcessName); DealLogging.Configure(builder, aiProcessName);
DealMetricsHosting.AddDealMetrics(builder, metricsPort); DealMetricsHosting.AddDealMetrics(builder, metricsPort);
DealTracingHosting.AddDealTracing(builder, aiProcessName);
}); });
DealMetricsHosting.MapDealMetrics(app); DealMetricsHosting.MapDealMetrics(app);
+1
View File
@@ -42,6 +42,7 @@
Версии — 1.17.x (Prometheus-экспортёр и gRPC-клиент только pre-release-линией; остальные — Версии — 1.17.x (Prometheus-экспортёр и gRPC-клиент только pre-release-линией; остальные —
1.17.0 stable). Настройка — Deal.Api/Observability/DealMetricsHosting.cs. --> 1.17.0 stable). Настройка — Deal.Api/Observability/DealMetricsHosting.cs. -->
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.17.0" /> <PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.17.0" />
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.17.0" />
<PackageReference Include="OpenTelemetry.Exporter.Prometheus.AspNetCore" Version="1.17.0-beta.1" /> <PackageReference Include="OpenTelemetry.Exporter.Prometheus.AspNetCore" Version="1.17.0-beta.1" />
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.17.0" /> <PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.17.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.17.0" /> <PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.17.0" />
@@ -136,10 +136,8 @@ public static class CardDetailsEndpoints
} }
CardsService service = context.RequestServices.GetRequiredService<CardsService>(); CardsService service = context.RequestServices.GetRequiredService<CardsService>();
CardDto? card = await service.TakeCardAsync(body.CardId ?? body.LeadId ?? string.Empty, ct); CardDto card = await service.TakeCardAsync(body.CardId ?? body.LeadId ?? string.Empty, ct);
return card is null return await ReadCardAsync(context, card.Id, ct);
? EndpointResults.NotFound(CardNotFoundDetail)
: await ReadCardAsync(context, card.Id, ct);
} }
// POST /api/cards/clear-rejected: полная очистка терминальной стадии «Отклонено». // POST /api/cards/clear-rejected: полная очистка терминальной стадии «Отклонено».
@@ -261,11 +259,7 @@ public static class CardDetailsEndpoints
foreach (IFormFile file in form.Files) foreach (IFormFile file in form.Files)
{ {
await using Stream content = file.OpenReadStream(); await using Stream content = file.OpenReadStream();
CardFileDto? entry = await cardsService.AddFileAsync(cardId, file.FileName, file.ContentType, content, file.Length, ct); await cardsService.AddFileAsync(cardId, file.FileName, file.ContentType, content, file.Length, ct);
if (entry is null)
{
return EndpointResults.NotFound(CardNotFoundDetail);
}
} }
return await ReadCardAsync(context, cardId, ct); return await ReadCardAsync(context, cardId, ct);
@@ -286,11 +280,7 @@ public static class CardDetailsEndpoints
} }
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>(); CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
CardFileDto? entry = await cardsService.GetFileEntryAsync(cardId, fileId, ct); CardFileDto entry = await cardsService.GetFileEntryAsync(cardId, fileId, ct);
if (entry is null)
{
return EndpointResults.NotFound(CardNotFoundDetail);
}
if (string.IsNullOrWhiteSpace(entry.ObjectKey)) if (string.IsNullOrWhiteSpace(entry.ObjectKey))
{ {
@@ -339,10 +329,8 @@ public static class CardDetailsEndpoints
} }
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>(); CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
CardDto? card = await cardsService.RemoveFileAsync(cardId, fileId, ct); await cardsService.RemoveFileAsync(cardId, fileId, ct);
return card is null return await ReadCardAsync(context, cardId, ct);
? EndpointResults.NotFound(CardNotFoundDetail)
: await ReadCardAsync(context, cardId, ct);
} }
// POST /api/cards/{cardId}/reminder {at: epoch-ms}: установить напоминание. Ответ — карточка. // POST /api/cards/{cardId}/reminder {at: epoch-ms}: установить напоминание. Ответ — карточка.
+2 -10
View File
@@ -209,11 +209,7 @@ public static class CardsEndpoints
} }
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>(); CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
CardDto? card = await cardsService.TrashCardAsync(cardId, ct); await cardsService.TrashCardAsync(cardId, ct);
if (card is null)
{
return EndpointResults.NotFound(CardNotFoundDetail);
}
await AuditAppender.AppendTenantAsync(context, AuditEvents.CardTrashed, new { cardId }, ct); await AuditAppender.AppendTenantAsync(context, AuditEvents.CardTrashed, new { cardId }, ct);
return Results.Ok(new { ok = true }); return Results.Ok(new { ok = true });
@@ -230,11 +226,7 @@ public static class CardsEndpoints
} }
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>(); CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
string? col = await cardsService.RestoreCardAsync(cardId, ct); string col = await cardsService.RestoreCardAsync(cardId, ct);
if (col is null)
{
return EndpointResults.NotFound(CardNotFoundDetail);
}
await AuditAppender.AppendTenantAsync(context, AuditEvents.CardRestored, new { cardId, col }, ct); await AuditAppender.AppendTenantAsync(context, AuditEvents.CardRestored, new { cardId, col }, ct);
return Results.Ok(new { ok = true, col }); return Results.Ok(new { ok = true, col });
@@ -19,9 +19,6 @@ public static class ContainersEndpoints
// OpenAPI-тег группы. // OpenAPI-тег группы.
private const string OpenApiTag = "containers"; private const string OpenApiTag = "containers";
// 404 PATCH/accept: контейнер не найден.
private const string ContainerNotFoundDetail = "Контейнер не найден";
// 400: отсутствующий/явный null name контейнера. // 400: отсутствующий/явный null name контейнера.
private const string ContainerNameRequiredDetail = "Укажите название колонки"; private const string ContainerNameRequiredDetail = "Укажите название колонки";
@@ -143,7 +140,7 @@ public static class ContainersEndpoints
} }
ContainersService containers = context.RequestServices.GetRequiredService<ContainersService>(); ContainersService containers = context.RequestServices.GetRequiredService<ContainersService>();
ContainerDto? updated = await containers.PatchAsync( ContainerDto updated = await containers.PatchAsync(
containerId, containerId,
new ContainerPatchDto( new ContainerPatchDto(
patchBody.Name, patchBody.Name,
@@ -155,10 +152,6 @@ public static class ContainersEndpoints
NormalizeWireRules(patchBody.Rules), NormalizeWireRules(patchBody.Rules),
patchBody.Policy), patchBody.Policy),
ct); ct);
if (updated is null)
{
return EndpointResults.NotFound(ContainerNotFoundDetail);
}
await AuditAppender.AppendTenantAsync(context, AuditEvents.ContainerUpdated, new { id = updated.Id }, ct); await AuditAppender.AppendTenantAsync(context, AuditEvents.ContainerUpdated, new { id = updated.Id }, ct);
return Results.Ok(new { id = updated.Id }); return Results.Ok(new { id = updated.Id });
@@ -176,11 +169,7 @@ public static class ContainersEndpoints
} }
ContainersService containers = context.RequestServices.GetRequiredService<ContainersService>(); ContainersService containers = context.RequestServices.GetRequiredService<ContainersService>();
ContainerDto? accepted = await containers.AcceptSuggestedAsync(containerId, ct); ContainerDto accepted = await containers.AcceptSuggestedAsync(containerId, ct);
if (accepted is null)
{
return EndpointResults.NotFound(ContainerNotFoundDetail);
}
await AuditAppender.AppendTenantAsync(context, AuditEvents.ContainerUpdated, new { id = accepted.Id }, ct); await AuditAppender.AppendTenantAsync(context, AuditEvents.ContainerUpdated, new { id = accepted.Id }, ct);
return Results.Ok(accepted); return Results.Ok(accepted);
@@ -55,8 +55,7 @@ public static class DiscoveryEndpoints
// Путь лога задачи (GET). // Путь лога задачи (GET).
private const string TaskLogPath = "/tasks/{task_id}/log"; private const string TaskLogPath = "/tasks/{task_id}/log";
private const string TaskNotFoundDetail = "Задача не найдена"; // 404: кандидат не найден.
private const string CandidateNotFoundDetail = "Кандидат не найден"; private const string CandidateNotFoundDetail = "Кандидат не найден";
private const string AlreadyJoinedDetail = "Уже вступили в этот источник"; private const string AlreadyJoinedDetail = "Уже вступили в этот источник";
@@ -149,8 +148,8 @@ public static class DiscoveryEndpoints
try try
{ {
DiscoveryTasksService tasks = context.RequestServices.GetRequiredService<DiscoveryTasksService>(); DiscoveryTasksService tasks = context.RequestServices.GetRequiredService<DiscoveryTasksService>();
DiscoveryTaskDto? task = await tasks.PatchAsync(task_id, ToPatch(body), ct); DiscoveryTaskDto task = await tasks.PatchAsync(task_id, ToPatch(body), ct);
return task is null ? EndpointResults.NotFound(TaskNotFoundDetail) : Results.Ok(task); return Results.Ok(task);
} }
catch (DiscoveryValidationException exception) catch (DiscoveryValidationException exception)
{ {
@@ -169,8 +168,8 @@ public static class DiscoveryEndpoints
} }
DiscoveryTasksService tasks = context.RequestServices.GetRequiredService<DiscoveryTasksService>(); DiscoveryTasksService tasks = context.RequestServices.GetRequiredService<DiscoveryTasksService>();
bool deleted = await tasks.DeleteAsync(task_id, ct); await tasks.DeleteAsync(task_id, ct);
return deleted ? Results.Ok(new { ok = true }) : EndpointResults.NotFound(TaskNotFoundDetail); return Results.Ok(new { ok = true });
} }
private static async Task<IResult> StartTaskAsync( private static async Task<IResult> StartTaskAsync(
@@ -186,8 +185,8 @@ public static class DiscoveryEndpoints
try try
{ {
DiscoveryTasksService tasks = context.RequestServices.GetRequiredService<DiscoveryTasksService>(); DiscoveryTasksService tasks = context.RequestServices.GetRequiredService<DiscoveryTasksService>();
DiscoveryTaskDto? task = await tasks.StartAsync(task_id, ct); DiscoveryTaskDto task = await tasks.StartAsync(task_id, ct);
return task is null ? EndpointResults.NotFound(TaskNotFoundDetail) : Results.Ok(task); return Results.Ok(task);
} }
catch (DiscoveryValidationException exception) catch (DiscoveryValidationException exception)
{ {
@@ -206,8 +205,8 @@ public static class DiscoveryEndpoints
} }
DiscoveryTasksService tasks = context.RequestServices.GetRequiredService<DiscoveryTasksService>(); DiscoveryTasksService tasks = context.RequestServices.GetRequiredService<DiscoveryTasksService>();
DiscoveryTaskDto? task = await tasks.PauseAsync(task_id, ct); DiscoveryTaskDto task = await tasks.PauseAsync(task_id, ct);
return task is null ? EndpointResults.NotFound(TaskNotFoundDetail) : Results.Ok(task); return Results.Ok(task);
} }
private static async Task<IResult> GenerateKeywordsAsync( private static async Task<IResult> GenerateKeywordsAsync(
@@ -221,11 +220,7 @@ public static class DiscoveryEndpoints
} }
DiscoveryTasksService tasks = context.RequestServices.GetRequiredService<DiscoveryTasksService>(); DiscoveryTasksService tasks = context.RequestServices.GetRequiredService<DiscoveryTasksService>();
DiscoveryTaskDto? task = await tasks.GetAsync(task_id, ct); DiscoveryTaskDto task = await tasks.GetAsync(task_id, ct);
if (task is null)
{
return EndpointResults.NotFound(TaskNotFoundDetail);
}
ISettingsStore settings = context.RequestServices.GetRequiredService<ISettingsStore>(); ISettingsStore settings = context.RequestServices.GetRequiredService<ISettingsStore>();
if (!await ReadAiEnabledAsync(settings, ct)) if (!await ReadAiEnabledAsync(settings, ct))
@@ -268,11 +263,7 @@ public static class DiscoveryEndpoints
} }
DiscoveryTasksService tasks = context.RequestServices.GetRequiredService<DiscoveryTasksService>(); DiscoveryTasksService tasks = context.RequestServices.GetRequiredService<DiscoveryTasksService>();
DiscoveryTaskDto? task = await tasks.GetAsync(task_id, ct); DiscoveryTaskDto task = await tasks.GetAsync(task_id, ct);
if (task is null)
{
return EndpointResults.NotFound(TaskNotFoundDetail);
}
DiscoveryCandidatesService candidates = context.RequestServices.GetRequiredService<DiscoveryCandidatesService>(); DiscoveryCandidatesService candidates = context.RequestServices.GetRequiredService<DiscoveryCandidatesService>();
IReadOnlyList<DiscoveryCandidateDto> items = await candidates.ListAsync(task_id, status, ct); IReadOnlyList<DiscoveryCandidateDto> items = await candidates.ListAsync(task_id, status, ct);
@@ -403,11 +394,7 @@ public static class DiscoveryEndpoints
} }
DiscoveryTasksService tasks = context.RequestServices.GetRequiredService<DiscoveryTasksService>(); DiscoveryTasksService tasks = context.RequestServices.GetRequiredService<DiscoveryTasksService>();
DiscoveryTaskDto? task = await tasks.GetAsync(task_id, ct); DiscoveryTaskDto task = await tasks.GetAsync(task_id, ct);
if (task is null)
{
return EndpointResults.NotFound(TaskNotFoundDetail);
}
DiscoveryLogService log = context.RequestServices.GetRequiredService<DiscoveryLogService>(); DiscoveryLogService log = context.RequestServices.GetRequiredService<DiscoveryLogService>();
IReadOnlyList<DiscoveryLogDto> items = await log.TaskLogAsync(task_id, ct); IReadOnlyList<DiscoveryLogDto> items = await log.TaskLogAsync(task_id, ct);
+2 -1
View File
@@ -62,7 +62,8 @@ internal static class DealLogging
.MinimumLevel.Is(ParseMinimumLevel(configuration[MinimumLevelEnvKey])) .MinimumLevel.Is(ParseMinimumLevel(configuration[MinimumLevelEnvKey]))
.MinimumLevel.Override(EntityFrameworkCoreCategory, LogEventLevel.Warning) .MinimumLevel.Override(EntityFrameworkCoreCategory, LogEventLevel.Warning)
.MinimumLevel.Override(GrpcCategory, LogEventLevel.Information) .MinimumLevel.Override(GrpcCategory, LogEventLevel.Information)
.Enrich.FromLogContext(); .Enrich.FromLogContext()
.Enrich.With<TraceContextEnricher>();
string logsDirectory = ResolveLogsDirectory(environment.ContentRootPath, configuration[LogsDirectoryEnvKey]); string logsDirectory = ResolveLogsDirectory(environment.ContentRootPath, configuration[LogsDirectoryEnvKey]);
Directory.CreateDirectory(logsDirectory); Directory.CreateDirectory(logsDirectory);
@@ -0,0 +1,20 @@
using System.Diagnostics;
using Serilog.Core;
using Serilog.Events;
namespace Deal.Api.Logging;
internal sealed class TraceContextEnricher : ILogEventEnricher
{
void ILogEventEnricher.Enrich(LogEvent logEvent, ILogEventPropertyFactory propertyFactory)
{
Activity? activity = Activity.Current;
if (activity is null)
{
return;
}
logEvent.AddPropertyIfAbsent(propertyFactory.CreateProperty("TraceId", activity.TraceId.ToString()));
logEvent.AddPropertyIfAbsent(propertyFactory.CreateProperty("SpanId", activity.SpanId.ToString()));
}
}
@@ -0,0 +1,83 @@
using Deal.SharedKernel.Errors;
using Deal.SharedKernel.Resources;
using Deal.SharedKernel.Tenants.Abstractions;
using Microsoft.AspNetCore.Diagnostics;
namespace Deal.Api.Middleware;
public sealed class DealExceptionHandler(ILogger<DealExceptionHandler> logger) : IExceptionHandler
{
public async ValueTask<bool> TryHandleAsync(
HttpContext httpContext,
Exception exception,
CancellationToken cancellationToken)
{
(int statusCode, string errorCode, string detail) = Resolve(exception);
LogFailure(httpContext, exception, statusCode, errorCode);
httpContext.Response.StatusCode = statusCode;
await httpContext.Response.WriteAsJsonAsync(
new { detail, code = errorCode },
cancellationToken);
return true;
}
// Доменные ошибки отдаются по коду; прочие — обобщённый 500 без деталей и стектрейса.
private static (int StatusCode, string ErrorCode, string Detail) Resolve(Exception exception)
=> exception is DealException dealException
? (MapStatusCode(dealException.ErrorCode), dealException.ErrorCode, dealException.Message)
: (StatusCodes.Status500InternalServerError,
DealErrorCodes.Internal,
ErrorResources.Format(ErrorResourceKeys.UnexpectedError));
// Код ошибки Deal → статус HTTP.
private static int MapStatusCode(string errorCode) => errorCode switch
{
DealErrorCodes.NotFound => StatusCodes.Status404NotFound,
DealErrorCodes.Validation => StatusCodes.Status400BadRequest,
DealErrorCodes.Conflict => StatusCodes.Status409Conflict,
DealErrorCodes.Unavailable => StatusCodes.Status503ServiceUnavailable,
_ => StatusCodes.Status500InternalServerError,
};
// Доменные ошибки — Warning без стектрейса; непредвиденные — Error со стектрейсом (только в лог).
private void LogFailure(
HttpContext context,
Exception exception,
int statusCode,
string errorCode)
{
string method = context.Request.Method;
string path = context.Request.Path.Value ?? "/";
string tenantId = ResolveTenantId(context);
if (exception is DealException dealException)
{
logger.LogWarning(
"HTTP {Method} {Path} -> {StatusCode} {ErrorCode}; tenant={TenantId} trace={TraceId}: {Message}",
method,
path,
statusCode,
errorCode,
tenantId,
context.TraceIdentifier,
dealException.Message);
return;
}
logger.LogError(
exception,
"HTTP {Method} {Path} -> {StatusCode} {ErrorCode}; tenant={TenantId} trace={TraceId}",
method,
path,
statusCode,
errorCode,
tenantId,
context.TraceIdentifier);
}
// Идентификатор тенанта запроса; вне tenant-запроса — "-".
private static string ResolveTenantId(HttpContext context)
{
ITenantContext? tenantContext = context.RequestServices?.GetService<ITenantContext>();
return tenantContext?.TenantId?.Value ?? "-";
}
}
@@ -0,0 +1,54 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
using OpenTelemetry.Resources;
using OpenTelemetry.Trace;
namespace Deal.Api.Observability;
/// <summary>
/// Настройка трейсинга ядра Deal.Api
/// </summary>
public static class DealTracingHosting
{
/// <summary>
/// Env-ключ OTLP-endpoint коллектора
/// </summary>
public const string OtlpEndpointEnvKey = "OTEL_EXPORTER_OTLP_ENDPOINT";
/// <summary>
/// Env-ключ имени сервиса в трейсах
/// </summary>
public const string ServiceNameEnvKey = "OTEL_SERVICE_NAME";
/// <summary>
/// Регистрирует трейсинг OpenTelemetry с экспортом OTLP
/// </summary>
/// <param name="builder">Билдер ядра.</param>
/// <param name="defaultServiceName">Имя сервиса, если env OTEL_SERVICE_NAME не задан.</param>
public static void AddDealTracing(WebApplicationBuilder builder, string defaultServiceName)
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentException.ThrowIfNullOrWhiteSpace(defaultServiceName);
// Трейсинг выключен без OTLP-endpoint (dev без профиля observability): иначе экспортёр
// вхолостую спамит ошибками соединения.
string? endpoint = Environment.GetEnvironmentVariable(OtlpEndpointEnvKey);
if (string.IsNullOrWhiteSpace(endpoint))
{
return;
}
string serviceName = Environment.GetEnvironmentVariable(ServiceNameEnvKey) is { Length: > 0 } configured
? configured
: defaultServiceName;
builder.Services
.AddOpenTelemetry()
.WithTracing(tracing => tracing
.SetResourceBuilder(ResourceBuilder.CreateDefault().AddService(serviceName))
.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddGrpcClientInstrumentation()
.AddOtlpExporter(options => options.Endpoint = new Uri(endpoint)));
}
}
+4
View File
@@ -66,6 +66,7 @@ DealLogging.Configure(builder, coreProcessName);
int metricsPort = DealMetricsHosting.ResolveMetricsPort(DealMetricsHosting.DefaultMetricsPort); int metricsPort = DealMetricsHosting.ResolveMetricsPort(DealMetricsHosting.DefaultMetricsPort);
DealMetricsHosting.AddDealMetrics(builder, metricsPort); DealMetricsHosting.AddDealMetrics(builder, metricsPort);
DealTracingHosting.AddDealTracing(builder, coreProcessName);
// Строка подключения Postgres — ТОЛЬКО из конфигурации (env/appsettings): dev-пароль в коде отсутствует // Строка подключения Postgres — ТОЛЬКО из конфигурации (env/appsettings): dev-пароль в коде отсутствует
// (Security review). Отсутствие строки = fail-fast на старте, а не тихий уход на несуществующую dev-БД. // (Security review). Отсутствие строки = fail-fast на старте, а не тихий уход на несуществующую dev-БД.
@@ -132,6 +133,8 @@ TokenLimitDefaults tenantLimitDefaults = new(
builder.Services.AddDealPersistence(tenantLimitDefaults); builder.Services.AddDealPersistence(tenantLimitDefaults);
builder.Services.AddDealSecurity(builder.Environment.ContentRootPath); builder.Services.AddDealSecurity(builder.Environment.ContentRootPath);
builder.Services.AddExceptionHandler<DealExceptionHandler>();
builder.Services.AddProblemDetails();
MlServiceOptions mlOptions = builder.Configuration.GetSection(servicesSectionName).Get<MlServiceOptions>() ?? new MlServiceOptions(); MlServiceOptions mlOptions = builder.Configuration.GetSection(servicesSectionName).Get<MlServiceOptions>() ?? new MlServiceOptions();
builder.Services.AddSingleton(mlOptions); builder.Services.AddSingleton(mlOptions);
@@ -325,6 +328,7 @@ if (forwardedHeadersConfig.Enabled)
app.UseForwardedHeaders(BuildForwardedHeadersOptions(forwardedHeadersConfig)); app.UseForwardedHeaders(BuildForwardedHeadersOptions(forwardedHeadersConfig));
} }
app.UseExceptionHandler();
app.UseMiddleware<HttpAccessLogMiddleware>(); app.UseMiddleware<HttpAccessLogMiddleware>();
app.UseCors(corsPolicyName); app.UseCors(corsPolicyName);
@@ -3,22 +3,17 @@ using Deal.SharedKernel.Tenants.Models;
namespace Deal.Infrastructure.Data; namespace Deal.Infrastructure.Data;
/// <summary>
/// Контекст тенанта на AsyncLocal
/// </summary>
public sealed class TenantContext : ITenantContext public sealed class TenantContext : ITenantContext
{ {
private static readonly AsyncLocal<TenantId?> Current = new(); private static readonly AsyncLocal<TenantId?> Current = new();
public TenantId? TenantId => Current.Value; TenantId? ITenantContext.TenantId => Current.Value;
public bool HasTenant => Current.Value is not null; bool ITenantContext.HasTenant => Current.Value is not null;
public string? SchemaName => Current.Value?.SchemaName; string? ITenantContext.SchemaName => Current.Value?.SchemaName;
/// <inheritdoc />
void ITenantContext.SetTenant(TenantId tenantId) => Current.Value = tenantId; void ITenantContext.SetTenant(TenantId tenantId) => Current.Value = tenantId;
/// <inheritdoc />
void ITenantContext.Reset() => Current.Value = null; void ITenantContext.Reset() => Current.Value = null;
} }
@@ -0,0 +1,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);
}
}
@@ -6,9 +6,6 @@ using Deal.Modules.Settings.Application.Models;
namespace Deal.Infrastructure.Integrations.Services; namespace Deal.Infrastructure.Integrations.Services;
/// <summary>
/// HTTP-реализация проверки подключения к AI-провайдеру.
/// </summary>
public sealed class AiConnectionChecker : IAiConnectionChecker public sealed class AiConnectionChecker : IAiConnectionChecker
{ {
/// <summary> /// <summary>
@@ -78,7 +75,6 @@ public sealed class AiConnectionChecker : IAiConnectionChecker
_httpClient = httpClient; _httpClient = httpClient;
} }
/// <inheritdoc />
async Task<AiCheckResultDto> IAiConnectionChecker.CheckAsync(AiCheckRequest request, CancellationToken ct) async Task<AiCheckResultDto> IAiConnectionChecker.CheckAsync(AiCheckRequest request, CancellationToken ct)
{ {
ArgumentNullException.ThrowIfNull(request); ArgumentNullException.ThrowIfNull(request);
@@ -9,9 +9,6 @@ using Microsoft.Extensions.Logging;
namespace Deal.Infrastructure.Integrations.Services; namespace Deal.Infrastructure.Integrations.Services;
/// <summary>
/// Декоратор бюджетного гейта порта <see cref="IAiClassifier"/>
/// </summary>
public sealed class BudgetedAiClassifier : IAiClassifier public sealed class BudgetedAiClassifier : IAiClassifier
{ {
// Текст ошибки вызова вне tenant-контекста (гейт читает лимиты по тенанту). // Текст ошибки вызова вне tenant-контекста (гейт читает лимиты по тенанту).
@@ -54,7 +51,6 @@ public sealed class BudgetedAiClassifier : IAiClassifier
_logger = logger; _logger = logger;
} }
/// <inheritdoc />
async Task<AiFilterResultDto> IAiClassifier.FilterAsync(string text, CancellationToken ct) async Task<AiFilterResultDto> IAiClassifier.FilterAsync(string text, CancellationToken ct)
{ {
if (await IsPaidAllowedAsync(ct)) if (await IsPaidAllowedAsync(ct))
@@ -67,7 +63,6 @@ public sealed class BudgetedAiClassifier : IAiClassifier
return await _localClassifier.FilterAsync(text, ct); return await _localClassifier.FilterAsync(text, ct);
} }
/// <inheritdoc />
async Task<AiParsedCardDto> IAiClassifier.ClassifyAsync(string text, CancellationToken ct) async Task<AiParsedCardDto> IAiClassifier.ClassifyAsync(string text, CancellationToken ct)
{ {
if (await IsPaidAllowedAsync(ct)) if (await IsPaidAllowedAsync(ct))
@@ -9,9 +9,6 @@ using Microsoft.Extensions.Logging;
namespace Deal.Infrastructure.Integrations.Services; namespace Deal.Infrastructure.Integrations.Services;
/// <summary>
/// Декоратор бюджетного гейта порта <see cref="IAiTools"/>
/// </summary>
public sealed class BudgetedAiTools : IAiTools public sealed class BudgetedAiTools : IAiTools
{ {
private const string ExhaustedKeywordsError = "ИИ-бюджет исчерпан — генерация ключевых слов недоступна"; private const string ExhaustedKeywordsError = "ИИ-бюджет исчерпан — генерация ключевых слов недоступна";
@@ -50,7 +47,6 @@ public sealed class BudgetedAiTools : IAiTools
_logger = logger; _logger = logger;
} }
/// <inheritdoc />
async Task<AiGenerateKeywordsResultDto> IAiTools.GenerateKeywordsAsync(string description, CancellationToken ct) async Task<AiGenerateKeywordsResultDto> IAiTools.GenerateKeywordsAsync(string description, CancellationToken ct)
{ {
BudgetStateDto state = await GateStateAsync(ct); BudgetStateDto state = await GateStateAsync(ct);
@@ -69,7 +65,6 @@ public sealed class BudgetedAiTools : IAiTools
Error: state.Status == TenantStatuses.Suspended ? SuspendedKeywordsError : ExhaustedKeywordsError); Error: state.Status == TenantStatuses.Suspended ? SuspendedKeywordsError : ExhaustedKeywordsError);
} }
/// <inheritdoc />
async Task<AiEvaluateFitResultDto> IAiTools.EvaluateFitAsync( async Task<AiEvaluateFitResultDto> IAiTools.EvaluateFitAsync(
string text, string text,
string description, string description,
@@ -5,9 +5,6 @@ using Microsoft.Extensions.Logging;
namespace Deal.Infrastructure.Integrations.Services; namespace Deal.Infrastructure.Integrations.Services;
/// <summary>
/// HTTP-источник курсов ЦБ РФ
/// </summary>
public sealed class CbrRateSource : IRatesSource public sealed class CbrRateSource : IRatesSource
{ {
/// <summary> /// <summary>
@@ -47,7 +44,6 @@ public sealed class CbrRateSource : IRatesSource
_logger = logger; _logger = logger;
} }
/// <inheritdoc />
async Task<Dictionary<string, double>?> IRatesSource.FetchAsync(CancellationToken ct) async Task<Dictionary<string, double>?> IRatesSource.FetchAsync(CancellationToken ct)
{ {
try try
@@ -3,6 +3,7 @@ using Deal.Contracts.Integrations.Models;
using Deal.Grpc.Ai; using Deal.Grpc.Ai;
using Deal.Infrastructure.Integrations.Exceptions; using Deal.Infrastructure.Integrations.Exceptions;
using Deal.Infrastructure.Integrations.Models; using Deal.Infrastructure.Integrations.Models;
using Deal.Infrastructure.Integrations.Resilience;
using Deal.Modules.Pipeline.Application.Services; using Deal.Modules.Pipeline.Application.Services;
using Deal.SharedKernel.Tenants.Abstractions; using Deal.SharedKernel.Tenants.Abstractions;
using Deal.SharedKernel.Tenants.Models; using Deal.SharedKernel.Tenants.Models;
@@ -11,9 +12,6 @@ using Microsoft.Extensions.Logging;
namespace Deal.Infrastructure.Integrations.Services; namespace Deal.Infrastructure.Integrations.Services;
/// <summary>
/// gRPC-адаптер порта <see cref="IAiClassifier"/> к автономному ai-service.
/// </summary>
public sealed class GrpcAiClassifier : IAiClassifier public sealed class GrpcAiClassifier : IAiClassifier
{ {
/// <summary> /// <summary>
@@ -70,7 +68,6 @@ public sealed class GrpcAiClassifier : IAiClassifier
_logger = logger; _logger = logger;
} }
/// <inheritdoc />
async Task<AiFilterResultDto> IAiClassifier.FilterAsync(string text, CancellationToken ct) async Task<AiFilterResultDto> IAiClassifier.FilterAsync(string text, CancellationToken ct)
{ {
TenantId tenantId = RequireTenant(); TenantId tenantId = RequireTenant();
@@ -79,14 +76,16 @@ public sealed class GrpcAiClassifier : IAiClassifier
string prompt = await _contextBuilder.BuildFilterPromptAsync(ct); string prompt = await _contextBuilder.BuildFilterPromptAsync(ct);
ProviderConfig providerConfig = await _providerConfigBuilder.BuildAsync(ct); ProviderConfig providerConfig = await _providerConfigBuilder.BuildAsync(ct);
AiService.AiServiceClient client = _connection.CreateClient(); AiService.AiServiceClient client = _connection.CreateClient();
FilterReply reply = await client.FilterAsync( FilterReply reply = await GrpcRetry.ExecuteAsync(
token => client.FilterAsync(
new FilterRequest new FilterRequest
{ {
Prompt = prompt, Prompt = prompt,
Text = SliceCodePoints(text, MaxFilterTextCodePoints), // python L193: text[:4000] Text = SliceCodePoints(text, MaxFilterTextCodePoints), // python L193: text[:4000]
ProviderConfig = providerConfig, ProviderConfig = providerConfig,
}, },
CallOptions(tenantId.Value, ct)); CallOptions(tenantId.Value, token)).ResponseAsync,
ct);
await _usageRecorder.AddAsync(reply.Usage, providerConfig.ProviderId, providerConfig.Model, ct); await _usageRecorder.AddAsync(reply.Usage, providerConfig.ProviderId, providerConfig.Model, ct);
return new AiFilterResultDto( return new AiFilterResultDto(
@@ -106,7 +105,6 @@ public sealed class GrpcAiClassifier : IAiClassifier
} }
} }
/// <inheritdoc />
async Task<AiParsedCardDto> IAiClassifier.ClassifyAsync(string text, CancellationToken ct) async Task<AiParsedCardDto> IAiClassifier.ClassifyAsync(string text, CancellationToken ct)
{ {
TenantId tenantId = RequireTenant(); TenantId tenantId = RequireTenant();
@@ -118,14 +116,16 @@ public sealed class GrpcAiClassifier : IAiClassifier
ClassifyReply reply; ClassifyReply reply;
try try
{ {
reply = await client.ClassifyAsync( reply = await GrpcRetry.ExecuteAsync(
token => client.ClassifyAsync(
new ClassifyRequest new ClassifyRequest
{ {
SystemPrompt = systemPrompt, SystemPrompt = systemPrompt,
UserContext = userContext, UserContext = userContext,
ProviderConfig = providerConfig, ProviderConfig = providerConfig,
}, },
CallOptions(tenantId.Value, ct)); CallOptions(tenantId.Value, token)).ResponseAsync,
ct);
} }
catch (RpcException exception) catch (RpcException exception)
{ {
@@ -3,6 +3,7 @@ using Deal.Contracts.Integrations.Models;
using Deal.Grpc.Ai; using Deal.Grpc.Ai;
using Deal.Infrastructure.Integrations.Exceptions; using Deal.Infrastructure.Integrations.Exceptions;
using Deal.Infrastructure.Integrations.Models; using Deal.Infrastructure.Integrations.Models;
using Deal.Infrastructure.Integrations.Resilience;
using Deal.SharedKernel.Tenants.Abstractions; using Deal.SharedKernel.Tenants.Abstractions;
using Deal.SharedKernel.Tenants.Models; using Deal.SharedKernel.Tenants.Models;
using Grpc.Core; using Grpc.Core;
@@ -10,9 +11,6 @@ using Microsoft.Extensions.Logging;
namespace Deal.Infrastructure.Integrations.Services; namespace Deal.Infrastructure.Integrations.Services;
/// <summary>
/// gRPC-адаптер порта <see cref="IAiTools"/> к автономному ai-service.
/// </summary>
public sealed class GrpcAiTools : IAiTools public sealed class GrpcAiTools : IAiTools
{ {
/// <summary> /// <summary>
@@ -70,7 +68,6 @@ public sealed class GrpcAiTools : IAiTools
_logger = logger; _logger = logger;
} }
/// <inheritdoc />
async Task<AiGenerateKeywordsResultDto> IAiTools.GenerateKeywordsAsync(string description, CancellationToken ct) async Task<AiGenerateKeywordsResultDto> IAiTools.GenerateKeywordsAsync(string description, CancellationToken ct)
{ {
TenantId tenantId = RequireTenant(); TenantId tenantId = RequireTenant();
@@ -78,13 +75,15 @@ public sealed class GrpcAiTools : IAiTools
{ {
AiService.AiServiceClient client = _connection.CreateClient(); AiService.AiServiceClient client = _connection.CreateClient();
ProviderConfig providerConfig = await _providerConfigBuilder.BuildAsync(ct); ProviderConfig providerConfig = await _providerConfigBuilder.BuildAsync(ct);
GenerateKeywordsReply reply = await client.GenerateKeywordsAsync( GenerateKeywordsReply reply = await GrpcRetry.ExecuteAsync(
token => client.GenerateKeywordsAsync(
new GenerateKeywordsRequest new GenerateKeywordsRequest
{ {
Description = SliceCodePoints(description ?? string.Empty, MaxDescriptionCodePoints), Description = SliceCodePoints(description ?? string.Empty, MaxDescriptionCodePoints),
ProviderConfig = providerConfig, ProviderConfig = providerConfig,
}, },
CallOptions(tenantId.Value, ct)); CallOptions(tenantId.Value, token)).ResponseAsync,
ct);
await _usageRecorder.AddAsync(reply.Usage, providerConfig.ProviderId, providerConfig.Model, ct); await _usageRecorder.AddAsync(reply.Usage, providerConfig.ProviderId, providerConfig.Model, ct);
return new AiGenerateKeywordsResultDto( return new AiGenerateKeywordsResultDto(
Ok: true, Ok: true,
@@ -104,7 +103,6 @@ public sealed class GrpcAiTools : IAiTools
} }
} }
/// <inheritdoc />
async Task<AiEvaluateFitResultDto> IAiTools.EvaluateFitAsync( async Task<AiEvaluateFitResultDto> IAiTools.EvaluateFitAsync(
string text, string text,
string description, string description,
@@ -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); await _usageRecorder.AddAsync(reply.Usage, providerConfig.ProviderId, providerConfig.Model, ct);
return new AiEvaluateFitResultDto( return new AiEvaluateFitResultDto(
Fit: reply.Fit, Fit: reply.Fit,
@@ -4,6 +4,7 @@ using Deal.Contracts.Integrations.Models;
using Deal.Grpc.Ml; using Deal.Grpc.Ml;
using Deal.Infrastructure.Integrations.Abstractions; using Deal.Infrastructure.Integrations.Abstractions;
using Deal.Infrastructure.Integrations.Models; using Deal.Infrastructure.Integrations.Models;
using Deal.Infrastructure.Integrations.Resilience;
using Deal.Modules.Kanban.Application.Abstractions; using Deal.Modules.Kanban.Application.Abstractions;
using Deal.Modules.Kanban.Application.Models; using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Settings.Application.Abstractions; using Deal.Modules.Settings.Application.Abstractions;
@@ -16,9 +17,6 @@ using Microsoft.Extensions.Logging;
namespace Deal.Infrastructure.Integrations.Services; namespace Deal.Infrastructure.Integrations.Services;
/// <summary>
/// gRPC-адаптер порта IMlClient к автономному ml-service.
/// </summary>
public sealed class GrpcMlClient : IMlClient, IMlTrainClient public sealed class GrpcMlClient : IMlClient, IMlTrainClient
{ {
/// <summary> /// <summary>
@@ -97,7 +95,6 @@ public sealed class GrpcMlClient : IMlClient, IMlTrainClient
_logger = logger; _logger = logger;
} }
/// <inheritdoc />
async Task<MlStatusResponseDto> IMlClient.StatusAsync(CancellationToken ct) async Task<MlStatusResponseDto> IMlClient.StatusAsync(CancellationToken ct)
{ {
TenantId tenantId = RequireTenant(); TenantId tenantId = RequireTenant();
@@ -122,16 +119,17 @@ public sealed class GrpcMlClient : IMlClient, IMlTrainClient
return new MlStatusResponseDto(Enabled: enabled, Service: snapshot.Service, Reachable: snapshot.Reachable, Stats: stats); return new MlStatusResponseDto(Enabled: enabled, Service: snapshot.Service, Reachable: snapshot.Reachable, Stats: stats);
} }
/// <inheritdoc />
async Task<MlPredictResultDto> IMlClient.PredictAsync(string text, CancellationToken ct) async Task<MlPredictResultDto> IMlClient.PredictAsync(string text, CancellationToken ct)
{ {
TenantId tenantId = RequireTenant(); TenantId tenantId = RequireTenant();
try try
{ {
MlService.MlServiceClient client = _connection.CreateClient(); MlService.MlServiceClient client = _connection.CreateClient();
PredictReply reply = await client.PredictAsync( PredictReply reply = await GrpcRetry.ExecuteAsync(
token => client.PredictAsync(
new PredictRequest { Text = text ?? string.Empty }, new PredictRequest { Text = text ?? string.Empty },
CallOptions(tenantId.Value, TimeSpan.FromSeconds(PredictDeadlineSeconds), ct)); CallOptions(tenantId.Value, TimeSpan.FromSeconds(PredictDeadlineSeconds), token)).ResponseAsync,
ct);
await _usageRecorder.AddEstimatedAsync(text, TokenUsageSources.Local, TokenUsageSources.Ml, ct); await _usageRecorder.AddEstimatedAsync(text, TokenUsageSources.Local, TokenUsageSources.Ml, ct);
return MapPredict(reply); return MapPredict(reply);
@@ -143,7 +141,6 @@ public sealed class GrpcMlClient : IMlClient, IMlTrainClient
} }
} }
/// <inheritdoc />
async Task<MlResetResultDto> IMlClient.ResetAsync(CancellationToken ct) async Task<MlResetResultDto> IMlClient.ResetAsync(CancellationToken ct)
{ {
TenantId tenantId = RequireTenant(); TenantId tenantId = RequireTenant();
@@ -171,7 +168,6 @@ public sealed class GrpcMlClient : IMlClient, IMlTrainClient
return new MlResetResultDto(Ok: true, Error: null); return new MlResetResultDto(Ok: true, Error: null);
} }
/// <inheritdoc />
async Task IMlClient.PushAsync( async Task IMlClient.PushAsync(
string text, string text,
string label, string label,
@@ -181,7 +177,6 @@ public sealed class GrpcMlClient : IMlClient, IMlTrainClient
await MlOutboxQueue.PushAsync(_learningStore, text, label, delta, ct); await MlOutboxQueue.PushAsync(_learningStore, text, label, delta, ct);
} }
/// <inheritdoc />
async Task<int> IMlTrainClient.TrainBatchAsync(IReadOnlyList<MlOutboxEntryDto> items, CancellationToken ct) async Task<int> IMlTrainClient.TrainBatchAsync(IReadOnlyList<MlOutboxEntryDto> items, CancellationToken ct)
{ {
TenantId tenantId = RequireTenant(); TenantId tenantId = RequireTenant();
@@ -228,9 +223,11 @@ public sealed class GrpcMlClient : IMlClient, IMlTrainClient
try try
{ {
MlService.MlServiceClient client = _connection.CreateClient(); MlService.MlServiceClient client = _connection.CreateClient();
StatusReply reply = await client.StatusAsync( StatusReply reply = await GrpcRetry.ExecuteAsync(
token => client.StatusAsync(
new StatusRequest(), new StatusRequest(),
CallOptions(tenantId.Value, TimeSpan.FromSeconds(StatusDeadlineSeconds), ct)); CallOptions(tenantId.Value, TimeSpan.FromSeconds(StatusDeadlineSeconds), token)).ResponseAsync,
ct);
MlServiceStatusDto service = MapStatus(reply); MlServiceStatusDto service = MapStatus(reply);
_statusCache.Set(tenantId.Value, service, reachable: true); _statusCache.Set(tenantId.Value, service, reachable: true);
return _statusCache.TryGet(tenantId.Value, out MlStatusCache.Snapshot updated) return _statusCache.TryGet(tenantId.Value, out MlStatusCache.Snapshot updated)
@@ -9,9 +9,6 @@ using Microsoft.Extensions.Logging;
namespace Deal.Infrastructure.Integrations.Services; namespace Deal.Infrastructure.Integrations.Services;
/// <summary>
/// gRPC-адаптер порта <see cref="ITelegramGateway"/> к автономному telegram-service.
/// </summary>
public sealed class GrpcTelegramClient : ITelegramGateway public sealed class GrpcTelegramClient : ITelegramGateway
{ {
/// <summary> /// <summary>
@@ -58,7 +55,6 @@ public sealed class GrpcTelegramClient : ITelegramGateway
_logger = logger; _logger = logger;
} }
/// <inheritdoc />
async Task<TelegramAccountStatusDto> ITelegramGateway.StatusAsync(CancellationToken ct) async Task<TelegramAccountStatusDto> ITelegramGateway.StatusAsync(CancellationToken ct)
{ {
TenantId tenantId = RequireTenant(); TenantId tenantId = RequireTenant();
@@ -81,7 +77,6 @@ public sealed class GrpcTelegramClient : ITelegramGateway
} }
} }
/// <inheritdoc />
async Task<TelegramAuthResultDto> ITelegramGateway.StartPhoneAsync( async Task<TelegramAuthResultDto> ITelegramGateway.StartPhoneAsync(
string phone, string phone,
int apiId, int apiId,
@@ -104,7 +99,6 @@ public sealed class GrpcTelegramClient : ITelegramGateway
} }
} }
/// <inheritdoc />
async Task<TelegramAuthResultDto> ITelegramGateway.StartQrAsync( async Task<TelegramAuthResultDto> ITelegramGateway.StartQrAsync(
int apiId, int apiId,
string apiHash, string apiHash,
@@ -128,7 +122,6 @@ public sealed class GrpcTelegramClient : ITelegramGateway
} }
} }
/// <inheritdoc />
async Task<string> ITelegramGateway.SendCodeAsync(string code, CancellationToken ct) async Task<string> ITelegramGateway.SendCodeAsync(string code, CancellationToken ct)
{ {
TenantId tenantId = RequireTenant(); TenantId tenantId = RequireTenant();
@@ -146,7 +139,6 @@ public sealed class GrpcTelegramClient : ITelegramGateway
} }
} }
/// <inheritdoc />
async Task<string> ITelegramGateway.SendPasswordAsync(string password, CancellationToken ct) async Task<string> ITelegramGateway.SendPasswordAsync(string password, CancellationToken ct)
{ {
TenantId tenantId = RequireTenant(); TenantId tenantId = RequireTenant();
@@ -164,7 +156,6 @@ public sealed class GrpcTelegramClient : ITelegramGateway
} }
} }
/// <inheritdoc />
async Task ITelegramGateway.LogoutAsync(CancellationToken ct) async Task ITelegramGateway.LogoutAsync(CancellationToken ct)
{ {
TenantId tenantId = RequireTenant(); TenantId tenantId = RequireTenant();
@@ -180,7 +171,6 @@ public sealed class GrpcTelegramClient : ITelegramGateway
} }
} }
/// <inheritdoc />
async Task<IReadOnlyList<TelegramDialogEntryDto>> ITelegramGateway.RefreshDialogsAsync(CancellationToken ct) async Task<IReadOnlyList<TelegramDialogEntryDto>> ITelegramGateway.RefreshDialogsAsync(CancellationToken ct)
{ {
TenantId tenantId = RequireTenant(); TenantId tenantId = RequireTenant();
@@ -197,7 +187,6 @@ public sealed class GrpcTelegramClient : ITelegramGateway
} }
} }
/// <inheritdoc />
async Task ITelegramGateway.SetMonitorAsync( async Task ITelegramGateway.SetMonitorAsync(
string dialogId, string dialogId,
bool enabled, bool enabled,
@@ -217,7 +206,6 @@ public sealed class GrpcTelegramClient : ITelegramGateway
} }
} }
/// <inheritdoc />
async Task ITelegramGateway.SetMonitorAllAsync(bool enabled, CancellationToken ct) async Task ITelegramGateway.SetMonitorAllAsync(bool enabled, CancellationToken ct)
{ {
TenantId tenantId = RequireTenant(); TenantId tenantId = RequireTenant();
@@ -234,7 +222,6 @@ public sealed class GrpcTelegramClient : ITelegramGateway
} }
} }
/// <inheritdoc />
async Task<int> ITelegramGateway.BackfillAsync( async Task<int> ITelegramGateway.BackfillAsync(
string dialogId, string dialogId,
bool force, bool force,
@@ -255,7 +242,6 @@ public sealed class GrpcTelegramClient : ITelegramGateway
} }
} }
/// <inheritdoc />
async Task<IReadOnlyList<TelegramRecentMessageDto>> ITelegramGateway.ReadRecentAsync( async Task<IReadOnlyList<TelegramRecentMessageDto>> ITelegramGateway.ReadRecentAsync(
string dialogId, string dialogId,
int limit, int limit,
@@ -278,7 +264,6 @@ public sealed class GrpcTelegramClient : ITelegramGateway
} }
} }
/// <inheritdoc />
async Task<TelegramSourceContentDto> ITelegramGateway.ReadSourceAsync( async Task<TelegramSourceContentDto> ITelegramGateway.ReadSourceAsync(
string dialogId, string dialogId,
long msgId, long msgId,
@@ -302,7 +287,6 @@ public sealed class GrpcTelegramClient : ITelegramGateway
} }
} }
/// <inheritdoc />
async Task<IReadOnlyList<TelegramDialogEntryDto>> ITelegramGateway.SearchAsync( async Task<IReadOnlyList<TelegramDialogEntryDto>> ITelegramGateway.SearchAsync(
string query, string query,
int limit, int limit,
@@ -323,7 +307,6 @@ public sealed class GrpcTelegramClient : ITelegramGateway
} }
} }
/// <inheritdoc />
async Task<TelegramChannelInfoDto> ITelegramGateway.InfoAsync(string dialogId, CancellationToken ct) async Task<TelegramChannelInfoDto> ITelegramGateway.InfoAsync(string dialogId, CancellationToken ct)
{ {
TenantId tenantId = RequireTenant(); TenantId tenantId = RequireTenant();
@@ -349,7 +332,6 @@ public sealed class GrpcTelegramClient : ITelegramGateway
} }
} }
/// <inheritdoc />
async Task<TelegramEvalReadDto> ITelegramGateway.ReadForEvalAsync( async Task<TelegramEvalReadDto> ITelegramGateway.ReadForEvalAsync(
string dialogId, string dialogId,
int limit, int limit,
@@ -380,7 +362,6 @@ public sealed class GrpcTelegramClient : ITelegramGateway
} }
} }
/// <inheritdoc />
async Task ITelegramGateway.JoinAsync(string username, CancellationToken ct) async Task ITelegramGateway.JoinAsync(string username, CancellationToken ct)
{ {
TenantId tenantId = RequireTenant(); TenantId tenantId = RequireTenant();
@@ -397,7 +378,6 @@ public sealed class GrpcTelegramClient : ITelegramGateway
} }
} }
/// <inheritdoc />
async Task ITelegramGateway.LeaveAsync(string dialogId, CancellationToken ct) async Task ITelegramGateway.LeaveAsync(string dialogId, CancellationToken ct)
{ {
TenantId tenantId = RequireTenant(); TenantId tenantId = RequireTenant();
@@ -3,20 +3,15 @@ using Deal.Contracts.Integrations.Models;
namespace Deal.Infrastructure.Integrations.Services; namespace Deal.Infrastructure.Integrations.Services;
/// <summary>
/// Локальная реализация <see cref="IAiTools"/> без внешнего ИИ-сервиса.
/// </summary>
public sealed class LocalAiTools : IAiTools public sealed class LocalAiTools : IAiTools
{ {
// Сообщение исключения методов (локальный режим = ai-service не подключён). // Сообщение исключения методов (локальный режим = ai-service не подключён).
private const string NotSupportedMessage = private const string NotSupportedMessage =
"ИИ-инструменты доступны только при подключённом ai-service (Services:Ai:UseLocal=false)."; "ИИ-инструменты доступны только при подключённом ai-service (Services:Ai:UseLocal=false).";
/// <inheritdoc />
Task<AiGenerateKeywordsResultDto> IAiTools.GenerateKeywordsAsync(string description, CancellationToken ct) Task<AiGenerateKeywordsResultDto> IAiTools.GenerateKeywordsAsync(string description, CancellationToken ct)
=> throw new NotSupportedException(NotSupportedMessage); => throw new NotSupportedException(NotSupportedMessage);
/// <inheritdoc />
Task<AiEvaluateFitResultDto> IAiTools.EvaluateFitAsync( Task<AiEvaluateFitResultDto> IAiTools.EvaluateFitAsync(
string text, string text,
string description, string description,
@@ -3,21 +3,16 @@ using Deal.Contracts.Integrations.Models;
namespace Deal.Infrastructure.Integrations.Services; namespace Deal.Infrastructure.Integrations.Services;
/// <summary>
/// Локальная заглушка <see cref="ITelegramGateway"/> без telegram-service.
/// </summary>
public sealed class LocalTelegramGateway : ITelegramGateway public sealed class LocalTelegramGateway : ITelegramGateway
{ {
// Фаза idle-формы (аккаунт не подключён — сервиса нет). // Фаза idle-формы (аккаунт не подключён — сервиса нет).
private const string IdlePhase = "idle"; private const string IdlePhase = "idle";
/// <inheritdoc />
Task<TelegramAccountStatusDto> ITelegramGateway.StatusAsync(CancellationToken ct) Task<TelegramAccountStatusDto> ITelegramGateway.StatusAsync(CancellationToken ct)
{ {
return Task.FromResult(new TelegramAccountStatusDto(IdlePhase, false, false, string.Empty, null, null)); return Task.FromResult(new TelegramAccountStatusDto(IdlePhase, false, false, string.Empty, null, null));
} }
/// <inheritdoc />
Task<TelegramAuthResultDto> ITelegramGateway.StartPhoneAsync( Task<TelegramAuthResultDto> ITelegramGateway.StartPhoneAsync(
string phone, string phone,
int apiId, int apiId,
@@ -25,76 +20,61 @@ public sealed class LocalTelegramGateway : ITelegramGateway
CancellationToken ct) CancellationToken ct)
=> Task.FromResult(new TelegramAuthResultDto(IdlePhase, null)); => Task.FromResult(new TelegramAuthResultDto(IdlePhase, null));
/// <inheritdoc />
Task<TelegramAuthResultDto> ITelegramGateway.StartQrAsync( Task<TelegramAuthResultDto> ITelegramGateway.StartQrAsync(
int apiId, int apiId,
string apiHash, string apiHash,
CancellationToken ct) CancellationToken ct)
=> Task.FromResult(new TelegramAuthResultDto(IdlePhase, null)); => Task.FromResult(new TelegramAuthResultDto(IdlePhase, null));
/// <inheritdoc /> Task<string> ITelegramGateway.SendCodeAsync(string code, CancellationToken ct) => Task.FromResult(IdlePhase);
public Task<string> SendCodeAsync(string code, CancellationToken ct) => Task.FromResult(IdlePhase);
/// <inheritdoc /> Task<string> ITelegramGateway.SendPasswordAsync(string password, CancellationToken ct) => Task.FromResult(IdlePhase);
public Task<string> SendPasswordAsync(string password, CancellationToken ct) => Task.FromResult(IdlePhase);
/// <inheritdoc />
Task ITelegramGateway.LogoutAsync(CancellationToken ct) => Task.CompletedTask; Task ITelegramGateway.LogoutAsync(CancellationToken ct) => Task.CompletedTask;
/// <inheritdoc />
Task<IReadOnlyList<TelegramDialogEntryDto>> ITelegramGateway.RefreshDialogsAsync(CancellationToken ct) Task<IReadOnlyList<TelegramDialogEntryDto>> ITelegramGateway.RefreshDialogsAsync(CancellationToken ct)
=> Task.FromResult<IReadOnlyList<TelegramDialogEntryDto>>([]); => Task.FromResult<IReadOnlyList<TelegramDialogEntryDto>>([]);
/// <inheritdoc />
Task ITelegramGateway.SetMonitorAsync( Task ITelegramGateway.SetMonitorAsync(
string dialogId, string dialogId,
bool enabled, bool enabled,
CancellationToken ct) => Task.CompletedTask; CancellationToken ct) => Task.CompletedTask;
/// <inheritdoc />
Task ITelegramGateway.SetMonitorAllAsync(bool enabled, CancellationToken ct) => Task.CompletedTask; Task ITelegramGateway.SetMonitorAllAsync(bool enabled, CancellationToken ct) => Task.CompletedTask;
/// <inheritdoc /> Task<int> ITelegramGateway.BackfillAsync(
public Task<int> BackfillAsync(
string dialogId, string dialogId,
bool force, bool force,
CancellationToken ct) => Task.FromResult(0); CancellationToken ct) => Task.FromResult(0);
/// <inheritdoc />
Task<IReadOnlyList<TelegramRecentMessageDto>> ITelegramGateway.ReadRecentAsync( Task<IReadOnlyList<TelegramRecentMessageDto>> ITelegramGateway.ReadRecentAsync(
string dialogId, string dialogId,
int limit, int limit,
CancellationToken ct) CancellationToken ct)
=> Task.FromResult<IReadOnlyList<TelegramRecentMessageDto>>([]); => Task.FromResult<IReadOnlyList<TelegramRecentMessageDto>>([]);
/// <inheritdoc />
Task<TelegramSourceContentDto> ITelegramGateway.ReadSourceAsync( Task<TelegramSourceContentDto> ITelegramGateway.ReadSourceAsync(
string dialogId, string dialogId,
long msgId, long msgId,
CancellationToken ct) CancellationToken ct)
=> Task.FromResult(new TelegramSourceContentDto(false, null, null)); => Task.FromResult(new TelegramSourceContentDto(false, null, null));
/// <inheritdoc />
Task<IReadOnlyList<TelegramDialogEntryDto>> ITelegramGateway.SearchAsync( Task<IReadOnlyList<TelegramDialogEntryDto>> ITelegramGateway.SearchAsync(
string query, string query,
int limit, int limit,
CancellationToken ct) CancellationToken ct)
=> Task.FromResult<IReadOnlyList<TelegramDialogEntryDto>>([]); => Task.FromResult<IReadOnlyList<TelegramDialogEntryDto>>([]);
/// <inheritdoc />
Task<TelegramChannelInfoDto> ITelegramGateway.InfoAsync(string dialogId, CancellationToken ct) Task<TelegramChannelInfoDto> ITelegramGateway.InfoAsync(string dialogId, CancellationToken ct)
=> Task.FromResult(new TelegramChannelInfoDto(dialogId, string.Empty, string.Empty, string.Empty, SourceDefaults.DefaultHue, null, false)); => Task.FromResult(new TelegramChannelInfoDto(dialogId, string.Empty, string.Empty, string.Empty, SourceDefaults.DefaultHue, null, false));
/// <inheritdoc />
Task<TelegramEvalReadDto> ITelegramGateway.ReadForEvalAsync( Task<TelegramEvalReadDto> ITelegramGateway.ReadForEvalAsync(
string dialogId, string dialogId,
int limit, int limit,
CancellationToken ct) CancellationToken ct)
=> Task.FromResult(new TelegramEvalReadDto(false, "no_history", [])); => Task.FromResult(new TelegramEvalReadDto(false, "no_history", []));
/// <inheritdoc />
Task ITelegramGateway.JoinAsync(string username, CancellationToken ct) => Task.CompletedTask; Task ITelegramGateway.JoinAsync(string username, CancellationToken ct) => Task.CompletedTask;
/// <inheritdoc />
Task ITelegramGateway.LeaveAsync(string dialogId, CancellationToken ct) => Task.CompletedTask; Task ITelegramGateway.LeaveAsync(string dialogId, CancellationToken ct) => Task.CompletedTask;
} }
@@ -3,9 +3,6 @@ using Deal.Contracts.Integrations.Models;
namespace Deal.Infrastructure.Integrations.Storage.Services; namespace Deal.Infrastructure.Integrations.Storage.Services;
/// <summary>
/// Локальное файловое хранилище вложений — каталог на диске.
/// </summary>
public sealed class LocalFileStorage : IFileStorage public sealed class LocalFileStorage : IFileStorage
{ {
// Размер буфера чтения при скачивании (async FileStream). // Размер буфера чтения при скачивании (async FileStream).
@@ -31,7 +28,6 @@ public sealed class LocalFileStorage : IFileStorage
/// <returns>Строка вида <c>LocalFileStorage (root: …)</c>.</returns> /// <returns>Строка вида <c>LocalFileStorage (root: …)</c>.</returns>
public override string ToString() => $"LocalFileStorage (root: {_rootPath})"; public override string ToString() => $"LocalFileStorage (root: {_rootPath})";
/// <inheritdoc />
async Task<string> IFileStorage.PutAsync( async Task<string> IFileStorage.PutAsync(
string objectKey, string objectKey,
Stream content, Stream content,
@@ -55,7 +51,6 @@ public sealed class LocalFileStorage : IFileStorage
return objectKey; return objectKey;
} }
/// <inheritdoc />
Task<Stream?> IFileStorage.GetAsync(string objectKey, CancellationToken ct) Task<Stream?> IFileStorage.GetAsync(string objectKey, CancellationToken ct)
{ {
string path = ResolvePath(objectKey); string path = ResolvePath(objectKey);
@@ -68,7 +63,6 @@ public sealed class LocalFileStorage : IFileStorage
return Task.FromResult<Stream?>(stream); return Task.FromResult<Stream?>(stream);
} }
/// <inheritdoc />
Task<FileMeta?> IFileStorage.StatAsync(string objectKey, CancellationToken ct) Task<FileMeta?> IFileStorage.StatAsync(string objectKey, CancellationToken ct)
{ {
string path = ResolvePath(objectKey); string path = ResolvePath(objectKey);
@@ -81,7 +75,6 @@ public sealed class LocalFileStorage : IFileStorage
return Task.FromResult<FileMeta?>(new FileMeta(objectKey, info.Length, string.Empty)); return Task.FromResult<FileMeta?>(new FileMeta(objectKey, info.Length, string.Empty));
} }
/// <inheritdoc />
Task IFileStorage.DeleteAsync(string objectKey, CancellationToken ct) Task IFileStorage.DeleteAsync(string objectKey, CancellationToken ct)
{ {
string path = ResolvePath(objectKey); string path = ResolvePath(objectKey);
@@ -9,9 +9,6 @@ using Minio.Exceptions;
namespace Deal.Infrastructure.Integrations.Storage.Services; namespace Deal.Infrastructure.Integrations.Storage.Services;
/// <summary>
/// Хранилище вложений на MinIO
/// </summary>
public sealed class MinioFileStorage : IFileStorage public sealed class MinioFileStorage : IFileStorage
{ {
private const string DefaultContentType = "application/octet-stream"; private const string DefaultContentType = "application/octet-stream";
@@ -66,7 +63,6 @@ public sealed class MinioFileStorage : IFileStorage
/// <returns>Строка вида <c>MinioFileStorage (endpoint: …; bucket: …)</c>.</returns> /// <returns>Строка вида <c>MinioFileStorage (endpoint: …; bucket: …)</c>.</returns>
public override string ToString() => $"MinioFileStorage (endpoint: {_endpoint}; bucket: {_bucket})"; public override string ToString() => $"MinioFileStorage (endpoint: {_endpoint}; bucket: {_bucket})";
/// <inheritdoc />
async Task<string> IFileStorage.PutAsync( async Task<string> IFileStorage.PutAsync(
string objectKey, string objectKey,
Stream content, Stream content,
@@ -98,7 +94,6 @@ public sealed class MinioFileStorage : IFileStorage
return objectKey; return objectKey;
} }
/// <inheritdoc />
async Task<Stream?> IFileStorage.GetAsync(string objectKey, CancellationToken ct) async Task<Stream?> IFileStorage.GetAsync(string objectKey, CancellationToken ct)
{ {
MemoryStream buffer = new(); MemoryStream buffer = new();
@@ -128,7 +123,6 @@ public sealed class MinioFileStorage : IFileStorage
return buffer; return buffer;
} }
/// <inheritdoc />
async Task<FileMeta?> IFileStorage.StatAsync(string objectKey, CancellationToken ct) async Task<FileMeta?> IFileStorage.StatAsync(string objectKey, CancellationToken ct)
{ {
try try
@@ -144,7 +138,6 @@ public sealed class MinioFileStorage : IFileStorage
} }
} }
/// <inheritdoc />
async Task IFileStorage.DeleteAsync(string objectKey, CancellationToken ct) async Task IFileStorage.DeleteAsync(string objectKey, CancellationToken ct)
{ {
try try
@@ -5,9 +5,6 @@ using Deal.Modules.Discovery.Application.Models;
namespace Deal.Infrastructure.Persistence.Repositories; namespace Deal.Infrastructure.Persistence.Repositories;
/// <summary>
/// EF-адаптер хранилища Discovery
/// </summary>
public sealed partial class DiscoveryStore : IDiscoveryStore public sealed partial class DiscoveryStore : IDiscoveryStore
{ {
private readonly TenantDbContext _dbContext; private readonly TenantDbContext _dbContext;
@@ -9,9 +9,6 @@ using Microsoft.EntityFrameworkCore;
namespace Deal.Infrastructure.Persistence.Repositories; namespace Deal.Infrastructure.Persistence.Repositories;
/// <summary>
/// EF-адаптер хранилища карточек и контейнеров
/// </summary>
public sealed partial class KanbanStore : ICardStore public sealed partial class KanbanStore : ICardStore
{ {
private readonly TenantDbContext _dbContext; private readonly TenantDbContext _dbContext;
@@ -6,9 +6,6 @@ using Microsoft.EntityFrameworkCore;
namespace Deal.Infrastructure.Persistence.Repositories; namespace Deal.Infrastructure.Persistence.Repositories;
/// <summary>
/// EF-адаптер хранилища лимитов ИИ-бюджета
/// </summary>
public sealed class TenantLimitStore : ITenantLimitStore public sealed class TenantLimitStore : ITenantLimitStore
{ {
private readonly DealDbContext _dbContext; private readonly DealDbContext _dbContext;
@@ -58,7 +55,6 @@ public sealed class TenantLimitStore : ITenantLimitStore
_utcNow = utcNow; _utcNow = utcNow;
} }
/// <inheritdoc />
async Task<TenantLimitDto> ITenantLimitStore.GetOrCreateAsync( async Task<TenantLimitDto> ITenantLimitStore.GetOrCreateAsync(
Guid tenantId, Guid tenantId,
CancellationToken ct, CancellationToken ct,
@@ -68,7 +64,6 @@ public sealed class TenantLimitStore : ITenantLimitStore
return ToLimitDto(entity); return ToLimitDto(entity);
} }
/// <inheritdoc />
async Task<BudgetStateDto> ITenantLimitStore.GetStateAsync(Guid tenantId, CancellationToken ct) async Task<BudgetStateDto> ITenantLimitStore.GetStateAsync(Guid tenantId, CancellationToken ct)
{ {
TenantLimitEntity entity = await LoadOrCreateAsync(tenantId, _defaults, ct); TenantLimitEntity entity = await LoadOrCreateAsync(tenantId, _defaults, ct);
@@ -76,7 +71,6 @@ public sealed class TenantLimitStore : ITenantLimitStore
return await ToStateDtoAsync(entity, ct); return await ToStateDtoAsync(entity, ct);
} }
/// <inheritdoc />
async Task<BudgetStateDto> ITenantLimitStore.AddUsageAsync( async Task<BudgetStateDto> ITenantLimitStore.AddUsageAsync(
Guid tenantId, Guid tenantId,
long tokens, long tokens,
@@ -109,7 +103,6 @@ public sealed class TenantLimitStore : ITenantLimitStore
return await ToStateDtoAsync(entity, ct); return await ToStateDtoAsync(entity, ct);
} }
/// <inheritdoc />
async Task<BudgetStateDto> ITenantLimitStore.UpdateBudgetAsync( async Task<BudgetStateDto> ITenantLimitStore.UpdateBudgetAsync(
Guid tenantId, Guid tenantId,
long budgetTokens, long budgetTokens,
@@ -132,7 +125,6 @@ public sealed class TenantLimitStore : ITenantLimitStore
return await ToStateDtoAsync(entity, ct); return await ToStateDtoAsync(entity, ct);
} }
/// <inheritdoc />
async Task<bool> ITenantLimitStore.TryMarkWarnedAsync(Guid tenantId, CancellationToken ct) async Task<bool> ITenantLimitStore.TryMarkWarnedAsync(Guid tenantId, CancellationToken ct)
{ {
TenantLimitEntity entity = await LoadOrCreateAsync(tenantId, _defaults, ct); TenantLimitEntity entity = await LoadOrCreateAsync(tenantId, _defaults, ct);
@@ -148,7 +140,6 @@ public sealed class TenantLimitStore : ITenantLimitStore
return true; return true;
} }
/// <inheritdoc />
async Task<bool> ITenantLimitStore.TryMarkNotifiedExhaustedAsync(Guid tenantId, CancellationToken ct) async Task<bool> ITenantLimitStore.TryMarkNotifiedExhaustedAsync(Guid tenantId, CancellationToken ct)
{ {
TenantLimitEntity entity = await LoadOrCreateAsync(tenantId, _defaults, ct); TenantLimitEntity entity = await LoadOrCreateAsync(tenantId, _defaults, ct);
@@ -219,7 +210,6 @@ public sealed class TenantLimitStore : ITenantLimitStore
return true; return true;
} }
/// <inheritdoc />
async Task<int> ITenantLimitStore.ResetExpiredPeriodsAsync(DateTimeOffset now, CancellationToken ct) async Task<int> ITenantLimitStore.ResetExpiredPeriodsAsync(DateTimeOffset now, CancellationToken ct)
{ {
// Трогаем только строки с накоплениями (расход/флаги): строки без накоплений чистить нечего. // Трогаем только строки с накоплениями (расход/флаги): строки без накоплений чистить нечего.
@@ -4,9 +4,6 @@ using Deal.Modules.Settings.Application.Abstractions;
namespace Deal.Infrastructure.Security; namespace Deal.Infrastructure.Security;
/// <summary>
/// AES-256-GCM-шифр секретов
/// </summary>
public sealed class AesGcmSecretCipher : ISecretCipher public sealed class AesGcmSecretCipher : ISecretCipher
{ {
// Префикс зашифрованного значения (маркер формата в хранилище). // Префикс зашифрованного значения (маркер формата в хранилище).
@@ -39,7 +36,6 @@ public sealed class AesGcmSecretCipher : ISecretCipher
_key = key; _key = key;
} }
/// <inheritdoc />
string ISecretCipher.Encrypt(string plainText) string ISecretCipher.Encrypt(string plainText)
{ {
if (string.IsNullOrEmpty(plainText)) if (string.IsNullOrEmpty(plainText))
@@ -65,7 +61,6 @@ public sealed class AesGcmSecretCipher : ISecretCipher
return EncryptedPrefix + Convert.ToBase64String(payload); return EncryptedPrefix + Convert.ToBase64String(payload);
} }
/// <inheritdoc />
string ISecretCipher.Decrypt(string cipherText) string ISecretCipher.Decrypt(string cipherText)
{ {
if (string.IsNullOrEmpty(cipherText) || !cipherText.StartsWith(EncryptedPrefix, StringComparison.Ordinal)) if (string.IsNullOrEmpty(cipherText) || !cipherText.StartsWith(EncryptedPrefix, StringComparison.Ordinal))
@@ -3,9 +3,6 @@ using Deal.Modules.Discovery.Application.Abstractions;
namespace Deal.Modules.Discovery.Application.Services; namespace Deal.Modules.Discovery.Application.Services;
/// <summary>
/// Потокобезопасная реализация <see cref="IDiscoverySearchErrorCounter"/>
/// </summary>
public sealed class DiscoverySearchErrorCounter : IDiscoverySearchErrorCounter public sealed class DiscoverySearchErrorCounter : IDiscoverySearchErrorCounter
{ {
/// <summary> /// <summary>
@@ -43,7 +40,6 @@ public sealed class DiscoverySearchErrorCounter : IDiscoverySearchErrorCounter
_utcNow = utcNow; _utcNow = utcNow;
} }
/// <inheritdoc />
int IDiscoverySearchErrorCounter.Next(string taskId) int IDiscoverySearchErrorCounter.Next(string taskId)
{ {
EvictExpired(); EvictExpired();
@@ -55,7 +51,6 @@ public sealed class DiscoverySearchErrorCounter : IDiscoverySearchErrorCounter
return fresh.Count; return fresh.Count;
} }
/// <inheritdoc />
void IDiscoverySearchErrorCounter.Reset(string taskId) void IDiscoverySearchErrorCounter.Reset(string taskId)
{ {
EvictExpired(); EvictExpired();
@@ -4,6 +4,7 @@ using Deal.Modules.Discovery.Application.Extensions;
using Deal.Modules.Discovery.Application.Models; using Deal.Modules.Discovery.Application.Models;
using Deal.Modules.Settings.Application.Abstractions; using Deal.Modules.Settings.Application.Abstractions;
using Deal.Modules.Settings.Application.Models; using Deal.Modules.Settings.Application.Models;
using Deal.SharedKernel.Errors;
namespace Deal.Modules.Discovery.Application.Services; namespace Deal.Modules.Discovery.Application.Services;
@@ -12,6 +13,9 @@ namespace Deal.Modules.Discovery.Application.Services;
/// </summary> /// </summary>
public sealed class DiscoveryTasksService(IDiscoveryStore store, DiscoveryPlanGuard planGuard, ISettingsStore settings) public sealed class DiscoveryTasksService(IDiscoveryStore store, DiscoveryPlanGuard planGuard, ISettingsStore settings)
{ {
// Имя сущности для текста ошибки «не найдено».
private const string TaskEntityName = "Задача поиска";
/// <summary> /// <summary>
/// 400 create: пустое название после Trim. /// 400 create: пустое название после Trim.
/// </summary> /// </summary>
@@ -35,10 +39,12 @@ public sealed class DiscoveryTasksService(IDiscoveryStore store, DiscoveryPlanGu
/// Одна задача по id. /// Одна задача по id.
/// </summary> /// </summary>
/// <param name="taskId">Id задачи (<c>dt_...</c>).</param> /// <param name="taskId">Id задачи (<c>dt_...</c>).</param>
/// <returns>Задача или null (404 «Задача не найдена» у эндпоинта).</returns> /// <returns>Задача.</returns>
public Task<DiscoveryTaskDto?> GetAsync(string taskId, CancellationToken ct) /// <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> /// <summary>
@@ -102,18 +108,16 @@ public sealed class DiscoveryTasksService(IDiscoveryStore store, DiscoveryPlanGu
/// </summary> /// </summary>
/// <param name="taskId">Id задачи (<c>dt_...</c>).</param> /// <param name="taskId">Id задачи (<c>dt_...</c>).</param>
/// <param name="patch">Изменяемые поля (null — не меняется).</param> /// <param name="patch">Изменяемые поля (null — не меняется).</param>
/// <returns>Обновлённая задача либо null (404 «Задача не найдена»).</returns> /// <returns>Обновлённая задача.</returns>
/// <exception cref="NotFoundException">Задача не найдена.</exception>
/// <exception cref="DiscoveryValidationException">Новый план вне границ / бюджет исчерпан.</exception> /// <exception cref="DiscoveryValidationException">Новый план вне границ / бюджет исчерпан.</exception>
public async Task<DiscoveryTaskDto?> PatchAsync( public async Task<DiscoveryTaskDto> PatchAsync(
string taskId, string taskId,
DiscoveryTaskPatch patch, DiscoveryTaskPatch patch,
CancellationToken ct) CancellationToken ct)
{ {
DiscoveryTaskDto? current = await store.GetTaskAsync(taskId, ct).ConfigureAwait(false); DiscoveryTaskDto current = await store.GetTaskAsync(taskId, ct).ConfigureAwait(false)
if (current is null) ?? throw new NotFoundException(TaskEntityName, taskId);
{
return null;
}
DiscoveryTaskPatch normalized = NormalizeTaskPatch(patch); DiscoveryTaskPatch normalized = NormalizeTaskPatch(patch);
if (normalized.PlanJoins is int newPlan) 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); 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>
/// Удаляет задачу вместе с кандидатами и логом. /// Удаляет задачу вместе с кандидатами и логом.
/// </summary> /// </summary>
/// <param name="taskId">Id задачи (<c>dt_...</c>).</param> /// <param name="taskId">Id задачи (<c>dt_...</c>).</param>
/// <returns>True — задача удалена; false — строки нет (404 у эндпоинта).</returns> /// <exception cref="NotFoundException">Задача не найдена.</exception>
public async Task<bool> DeleteAsync(string taskId, CancellationToken ct) public async Task DeleteAsync(string taskId, CancellationToken ct)
{ {
DiscoveryTaskDto? current = await store.GetTaskAsync(taskId, ct).ConfigureAwait(false); DiscoveryTaskDto current = await store.GetTaskAsync(taskId, ct).ConfigureAwait(false)
if (current is null) ?? throw new NotFoundException(TaskEntityName, taskId);
{
return false;
}
return await store.DeleteTaskAsync(taskId, ct).ConfigureAwait(false); await store.DeleteTaskAsync(current.Id, ct).ConfigureAwait(false);
} }
/// <summary> /// <summary>
/// Запускает поиск /// Запускает поиск
/// </summary> /// </summary>
/// <param name="taskId">Id задачи (<c>dt_...</c>).</param> /// <param name="taskId">Id задачи (<c>dt_...</c>).</param>
/// <returns>Задача в running либо null (404).</returns> /// <returns>Задача в running.</returns>
/// <exception cref="NotFoundException">Задача не найдена.</exception>
/// <exception cref="DiscoveryValidationException">Ключевых слов нет.</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); DiscoveryTaskDto current = await store.GetTaskAsync(taskId, ct).ConfigureAwait(false)
if (current is null) ?? throw new NotFoundException(TaskEntityName, taskId);
{
return null;
}
if (current.Keywords.Count == 0) if (current.Keywords.Count == 0)
{ {
@@ -171,24 +171,24 @@ public sealed class DiscoveryTasksService(IDiscoveryStore store, DiscoveryPlanGu
bool resetProgress = DiscoveryTaskStatuses.IsFinished(current.Status); bool resetProgress = DiscoveryTaskStatuses.IsFinished(current.Status);
await store.SetTaskRunningAsync(taskId, resetProgress, ct).ConfigureAwait(false); 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>
/// Ставит задачу на паузу. /// Ставит задачу на паузу.
/// </summary> /// </summary>
/// <param name="taskId">Id задачи (<c>dt_...</c>).</param> /// <param name="taskId">Id задачи (<c>dt_...</c>).</param>
/// <returns>Задача в paused либо null (404).</returns> /// <returns>Задача в paused.</returns>
public async Task<DiscoveryTaskDto?> PauseAsync(string taskId, CancellationToken ct) /// <exception cref="NotFoundException">Задача не найдена.</exception>
public async Task<DiscoveryTaskDto> PauseAsync(string taskId, CancellationToken ct)
{ {
DiscoveryTaskDto? current = await store.GetTaskAsync(taskId, ct).ConfigureAwait(false); DiscoveryTaskDto current = await store.GetTaskAsync(taskId, ct).ConfigureAwait(false)
if (current is null) ?? throw new NotFoundException(TaskEntityName, taskId);
{
return null;
}
await store.SetTaskPausedAsync(taskId, ct).ConfigureAwait(false); await store.SetTaskPausedAsync(current.Id, 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>
@@ -1,5 +1,6 @@
using Deal.Contracts.Integrations.Abstractions; using Deal.Contracts.Integrations.Abstractions;
using Deal.Modules.Kanban.Application.Models; using Deal.Modules.Kanban.Application.Models;
using Deal.SharedKernel.Errors;
namespace Deal.Modules.Kanban.Application.Services; namespace Deal.Modules.Kanban.Application.Services;
@@ -18,6 +19,10 @@ public sealed partial class CardsService
private const string DefaultAttachmentName = "file"; private const string DefaultAttachmentName = "file";
// Имена сущностей для текстов ошибок «не найдено».
private const string CardEntityName = "Карточка";
private const string CardFileEntityName = "Файл карточки";
/// <summary> /// <summary>
/// Добавляет файл карточке /// Добавляет файл карточке
/// </summary> /// </summary>
@@ -26,8 +31,9 @@ public sealed partial class CardsService
/// <param name="contentType">MIME-тип загрузки (может быть null/пустым — детект по расширению).</param> /// <param name="contentType">MIME-тип загрузки (может быть null/пустым — детект по расширению).</param>
/// <param name="content">Поток содержимого файла (читается хранилищем с позиции 0).</param> /// <param name="content">Поток содержимого файла (читается хранилищем с позиции 0).</param>
/// <param name="size">Длина содержимого в байтах (пишется в метаданные записи).</param> /// <param name="size">Длина содержимого в байтах (пишется в метаданные записи).</param>
/// <returns>Метаданные добавленного файла или null — карточки нет (404).</returns> /// <returns>Метаданные добавленного файла.</returns>
public async Task<CardFileDto?> AddFileAsync( /// <exception cref="NotFoundException">Карточка не найдена.</exception>
public async Task<CardFileDto> AddFileAsync(
string cardId, string cardId,
string fileName, string fileName,
string? contentType, string? contentType,
@@ -37,11 +43,8 @@ public sealed partial class CardsService
{ {
ArgumentNullException.ThrowIfNull(content); ArgumentNullException.ThrowIfNull(content);
CardDto? card = await _store.GetCardAsync(cardId, ct); CardDto card = await _store.GetCardAsync(cardId, ct)
if (card is null) ?? throw new NotFoundException(CardEntityName, cardId);
{
return null;
}
string name = string.IsNullOrWhiteSpace(fileName) ? DefaultAttachmentName : fileName; string name = string.IsNullOrWhiteSpace(fileName) ? DefaultAttachmentName : fileName;
CardFileKind kind = FileKindDetector.Detect(name, contentType); CardFileKind kind = FileKindDetector.Detect(name, contentType);
@@ -62,7 +65,7 @@ public sealed partial class CardsService
// Карточка исчезла между чтением и записью (гонка): объект-сирота в хранилище не нужен — // Карточка исчезла между чтением и записью (гонка): объект-сирота в хранилище не нужен —
// удаляем и отвечаем 404-семантикой (DeleteAsync сбои не бросает). // удаляем и отвечаем 404-семантикой (DeleteAsync сбои не бросает).
await _storage.DeleteAsync(objectKey, ct); await _storage.DeleteAsync(objectKey, ct);
return null; throw new NotFoundException(CardEntityName, cardId);
} }
return entry; return entry;
@@ -73,19 +76,18 @@ public sealed partial class CardsService
/// </summary> /// </summary>
/// <param name="cardId">Id карточки (<c>c_...</c>).</param> /// <param name="cardId">Id карточки (<c>c_...</c>).</param>
/// <param name="fileId">Id записи файла (<c>pf_...</c>).</param> /// <param name="fileId">Id записи файла (<c>pf_...</c>).</param>
/// <returns>Метаданные записи файла либо null (карточка/запись не найдены).</returns> /// <returns>Метаданные записи файла.</returns>
public async Task<CardFileDto?> GetFileEntryAsync( /// <exception cref="NotFoundException">Карточка или запись файла не найдены.</exception>
public async Task<CardFileDto> GetFileEntryAsync(
string cardId, string cardId,
string fileId, string fileId,
CancellationToken ct) CancellationToken ct)
{ {
CardDto? card = await _store.GetCardAsync(cardId, ct); CardDto card = await _store.GetCardAsync(cardId, ct)
if (card is null) ?? throw new NotFoundException(CardEntityName, cardId);
{
return null;
}
return card.Files.FirstOrDefault(file => file.Id == fileId); return card.Files.FirstOrDefault(file => file.Id == fileId)
?? throw new NotFoundException(CardFileEntityName, fileId);
} }
/// <summary> /// <summary>
@@ -93,17 +95,15 @@ public sealed partial class CardsService
/// </summary> /// </summary>
/// <param name="cardId">Id карточки (<c>c_...</c>).</param> /// <param name="cardId">Id карточки (<c>c_...</c>).</param>
/// <param name="fileId">Id удаляемой записи файла (<c>pf_...</c>).</param> /// <param name="fileId">Id удаляемой записи файла (<c>pf_...</c>).</param>
/// <returns>Карточка после удаления (без записи) либо null — карточки нет (404-семантика).</returns> /// <returns>Карточка после удаления (без записи).</returns>
public async Task<CardDto?> RemoveFileAsync( /// <exception cref="NotFoundException">Карточка или запись файла не найдены.</exception>
public async Task<CardDto> RemoveFileAsync(
string cardId, string cardId,
string fileId, string fileId,
CancellationToken ct) CancellationToken ct)
{ {
CardDto? card = await _store.GetCardAsync(cardId, ct); CardDto card = await _store.GetCardAsync(cardId, ct)
if (card is null) ?? throw new NotFoundException(CardEntityName, cardId);
{
return null;
}
CardFileDto? entry = card.Files.FirstOrDefault(file => file.Id == fileId); CardFileDto? entry = card.Files.FirstOrDefault(file => file.Id == fileId);
if (entry is not null && !string.IsNullOrWhiteSpace(entry.ObjectKey)) 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)) if (!await _store.RemoveFileAsync(cardId, fileId, ct))
{ {
return null; throw new NotFoundException(CardFileEntityName, fileId);
} }
return await _store.GetCardAsync(cardId, ct) return await _store.GetCardAsync(cardId, ct)
?? throw new InvalidOperationException("Карточка не прочиталась после удаления файла: " + cardId); ?? throw new NotFoundException(CardEntityName, cardId);
} }
private static string BuildObjectKey( private static string BuildObjectKey(
@@ -2,6 +2,7 @@ using Deal.Contracts.Integrations.Models;
using Deal.Modules.Cards.Application.Models; using Deal.Modules.Cards.Application.Models;
using Deal.Modules.Kanban.Application.Abstractions; using Deal.Modules.Kanban.Application.Abstractions;
using Deal.Modules.Kanban.Application.Models; using Deal.Modules.Kanban.Application.Models;
using Deal.SharedKernel.Errors;
namespace Deal.Modules.Kanban.Application.Services; namespace Deal.Modules.Kanban.Application.Services;
@@ -87,8 +88,9 @@ public sealed partial class CardsService
/// Перенос карточки в корзину /// Перенос карточки в корзину
/// </summary> /// </summary>
/// <param name="cardId">Id карточки (<c>c_...</c>).</param> /// <param name="cardId">Id карточки (<c>c_...</c>).</param>
/// <returns>Карточка после переноса (при no-op — как была) либо null — карточки нет (404).</returns> /// <returns>Карточка после переноса (при no-op — как была).</returns>
public Task<CardDto?> TrashCardAsync(string cardId, CancellationToken ct) /// <exception cref="NotFoundException">Карточка не найдена.</exception>
public Task<CardDto> TrashCardAsync(string cardId, CancellationToken ct)
{ {
return TrashCardAsync(cardId, teach: true, ct); return TrashCardAsync(cardId, teach: true, ct);
} }
@@ -98,17 +100,15 @@ public sealed partial class CardsService
/// </summary> /// </summary>
/// <param name="cardId">Id карточки (<c>c_...</c>).</param> /// <param name="cardId">Id карточки (<c>c_...</c>).</param>
/// <param name="teach">True — писать сигнал «спам» (действие пользователя); false — не писать.</param> /// <param name="teach">True — писать сигнал «спам» (действие пользователя); false — не писать.</param>
/// <returns>Карточка после переноса (при no-op — как была) либо null — карточки нет (404).</returns> /// <returns>Карточка после переноса (при no-op — как была).</returns>
public async Task<CardDto?> TrashCardAsync( /// <exception cref="NotFoundException">Карточка не найдена.</exception>
public async Task<CardDto> TrashCardAsync(
string cardId, string cardId,
bool teach, bool teach,
CancellationToken ct) CancellationToken ct)
{ {
CardDto? card = await _store.GetCardAsync(cardId, ct); CardDto card = await _store.GetCardAsync(cardId, ct)
if (card is null) ?? throw new NotFoundException(CardEntityName, cardId);
{
return null;
}
if (card.Col == CardIds.Trash) if (card.Col == CardIds.Trash)
{ {
@@ -122,21 +122,20 @@ public sealed partial class CardsService
await _mlClient.PushAsync(text, MlLearningLabels.Spam, PushWeightUser, ct); 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>
/// Возврат карточки из архива/корзины на канбан. /// Возврат карточки из архива/корзины на канбан.
/// </summary> /// </summary>
/// <param name="cardId">Id карточки (<c>c_...</c>).</param> /// <param name="cardId">Id карточки (<c>c_...</c>).</param>
/// <returns>Колонка возврата (inbox/доска) либо null — карточки нет (404).</returns> /// <returns>Колонка возврата (inbox/доска).</returns>
public async Task<string?> RestoreCardAsync(string cardId, CancellationToken ct) /// <exception cref="NotFoundException">Карточка не найдена.</exception>
public async Task<string> RestoreCardAsync(string cardId, CancellationToken ct)
{ {
CardDto? card = await _store.GetCardAsync(cardId, ct); CardDto card = await _store.GetCardAsync(cardId, ct)
if (card is null) ?? throw new NotFoundException(CardEntityName, cardId);
{
return null;
}
string back = await ResolveReturnColAsync(card.PrevCol, ct); string back = await ResolveReturnColAsync(card.PrevCol, ct);
string text = LearningText(card); string text = LearningText(card);
@@ -3,6 +3,7 @@ using Deal.Modules.Cards.Application.Models;
using Deal.Modules.Cards.Application.Sources; using Deal.Modules.Cards.Application.Sources;
using Deal.Modules.Kanban.Application.Abstractions; using Deal.Modules.Kanban.Application.Abstractions;
using Deal.Modules.Kanban.Application.Models; using Deal.Modules.Kanban.Application.Models;
using Deal.SharedKernel.Errors;
namespace Deal.Modules.Kanban.Application.Services; namespace Deal.Modules.Kanban.Application.Services;
@@ -101,14 +102,12 @@ public sealed partial class CardsService
/// «Взять в работу» /// «Взять в работу»
/// </summary> /// </summary>
/// <param name="cardId">Id карточки (<c>c_...</c>).</param> /// <param name="cardId">Id карточки (<c>c_...</c>).</param>
/// <returns>Карточка в стадии planned; null — карточки нет (404).</returns> /// <returns>Карточка в стадии planned.</returns>
public async Task<CardDto?> TakeCardAsync(string cardId, CancellationToken ct) /// <exception cref="NotFoundException">Карточка не найдена.</exception>
public async Task<CardDto> TakeCardAsync(string cardId, CancellationToken ct)
{ {
CardDto? card = await _store.GetCardAsync(cardId, ct); CardDto card = await _store.GetCardAsync(cardId, ct)
if (card is null) ?? throw new NotFoundException(CardEntityName, cardId);
{
return null;
}
if (CardsDefaultContainers.Contains(card.Col)) if (CardsDefaultContainers.Contains(card.Col))
{ {
@@ -120,14 +119,14 @@ public sealed partial class CardsService
if (!await _store.MoveCardStageAsync(cardId, PlannedStage, entry, nowMs, ct)) if (!await _store.MoveCardStageAsync(cardId, PlannedStage, entry, nowMs, ct))
{ {
// Карточка исчезла между чтением и переносом (гонка с удалением). // Карточка исчезла между чтением и переносом (гонка с удалением).
return null; throw new NotFoundException(CardEntityName, cardId);
} }
await _store.AddCommentAsync( await _store.AddCommentAsync(
PrefixId.New(KanbanIdPrefixes.Comment), cardId, CommentAuthor, TakenCommentText, ct); PrefixId.New(KanbanIdPrefixes.Comment), cardId, CommentAuthor, TakenCommentText, ct);
return await _store.GetCardAsync(cardId, ct) return await _store.GetCardAsync(cardId, ct)
?? throw new InvalidOperationException("Карточка не прочиталась после take: " + cardId); ?? throw new NotFoundException(CardEntityName, cardId);
} }
/// <summary> /// <summary>
@@ -135,8 +134,9 @@ public sealed partial class CardsService
/// </summary> /// </summary>
/// <param name="cardId">Id карточки (<c>c_...</c>).</param> /// <param name="cardId">Id карточки (<c>c_...</c>).</param>
/// <param name="body">Тело PATCH: ключ → JSON-значение (наличие ключа = поле меняется).</param> /// <param name="body">Тело PATCH: ключ → JSON-значение (наличие ключа = поле меняется).</param>
/// <returns>Обновлённая карточка или null — карточки нет (404).</returns> /// <returns>Обновлённая карточка.</returns>
public async Task<CardDto?> PatchCardAsync( /// <exception cref="NotFoundException">Карточка не найдена.</exception>
public async Task<CardDto> PatchCardAsync(
string cardId, string cardId,
IReadOnlyDictionary<string, JsonElement> body, IReadOnlyDictionary<string, JsonElement> body,
CancellationToken ct) CancellationToken ct)
@@ -144,7 +144,13 @@ public sealed partial class CardsService
ArgumentNullException.ThrowIfNull(body); ArgumentNullException.ThrowIfNull(body);
bool updated = await _store.PatchCardAsync(cardId, ResolvePatch(body), ct); 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> /// <summary>
@@ -4,6 +4,7 @@ using Deal.Modules.Kanban.Application.Abstractions;
using Deal.Modules.Kanban.Application.Models; using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Settings.Application.Abstractions; using Deal.Modules.Settings.Application.Abstractions;
using Deal.Modules.Settings.Application.Models; using Deal.Modules.Settings.Application.Models;
using Deal.SharedKernel.Errors;
namespace Deal.Modules.Kanban.Application.Services; namespace Deal.Modules.Kanban.Application.Services;
@@ -19,6 +20,9 @@ public sealed class ContainersService(ICardStore store, ISettingsStore settings)
/// </summary> /// </summary>
public const string DefaultContainerName = "Новая колонка"; public const string DefaultContainerName = "Новая колонка";
// Имя сущности для текста ошибки «не найдено».
private const string ContainerEntityName = "Контейнер";
// Палитра колонок по умолчанию: цвет = Palette[order % 8], если цвет не задан. // Палитра колонок по умолчанию: цвет = Palette[order % 8], если цвет не задан.
private static readonly string[] Palette = private static readonly string[] Palette =
["#818cf8", "#fbbf24", "#22d3ee", "#e879f9", "#34d399", "#fb7185", "#a78bfa", "#f97316"]; ["#818cf8", "#fbbf24", "#22d3ee", "#e879f9", "#34d399", "#fb7185", "#a78bfa", "#f97316"];
@@ -53,17 +57,15 @@ public sealed class ContainersService(ICardStore store, ISettingsStore settings)
} }
/// <summary> /// <summary>
/// Один контейнер со счётчиками; null — контейнера нет. /// Один контейнер со счётчиками
/// </summary> /// </summary>
/// <param name="containerId">Id контейнера.</param> /// <param name="containerId">Id контейнера.</param>
/// <returns>Контейнер со счётчиками либо null.</returns> /// <returns>Контейнер со счётчиками.</returns>
public async Task<ContainerDto?> GetAsync(string containerId, CancellationToken ct) /// <exception cref="NotFoundException">Контейнер не найден.</exception>
public async Task<ContainerDto> GetAsync(string containerId, CancellationToken ct)
{ {
ContainerDto? container = await store.GetContainerAsync(containerId, ct); ContainerDto container = await store.GetContainerAsync(containerId, ct)
if (container is null) ?? throw new NotFoundException(ContainerEntityName, containerId);
{
return null;
}
IReadOnlyDictionary<string, CardColumnCountDto> counts = await store.CountCardsByColAsync(ct); IReadOnlyDictionary<string, CardColumnCountDto> counts = await store.CountCardsByColAsync(ct);
ContainerCountsDto containerCounts = counts.TryGetValue(container.Id, out CardColumnCountDto? count) ContainerCountsDto containerCounts = counts.TryGetValue(container.Id, out CardColumnCountDto? count)
@@ -107,17 +109,15 @@ public sealed class ContainersService(ICardStore store, ISettingsStore settings)
/// </summary> /// </summary>
/// <param name="containerId">Id контейнера.</param> /// <param name="containerId">Id контейнера.</param>
/// <param name="patch">Изменения; null-поле означает «не менять».</param> /// <param name="patch">Изменения; null-поле означает «не менять».</param>
/// <returns>Контейнер после патча; null — контейнера нет (404 «Контейнер не найден»).</returns> /// <returns>Контейнер после патча.</returns>
public async Task<ContainerDto?> PatchAsync( /// <exception cref="NotFoundException">Контейнер не найден.</exception>
public async Task<ContainerDto> PatchAsync(
string containerId, string containerId,
ContainerPatchDto patch, ContainerPatchDto patch,
CancellationToken ct) CancellationToken ct)
{ {
ContainerDto? current = await store.GetContainerAsync(containerId, ct); ContainerDto current = await store.GetContainerAsync(containerId, ct)
if (current is null) ?? throw new NotFoundException(ContainerEntityName, containerId);
{
return null;
}
ContainerDto updated = ApplyPatch(current, patch); ContainerDto updated = ApplyPatch(current, patch);
await store.UpdateContainerAsync(updated, ct); await store.UpdateContainerAsync(updated, ct);
@@ -128,8 +128,9 @@ public sealed class ContainersService(ICardStore store, ISettingsStore settings)
/// Принимает ИИ-предложение /// Принимает ИИ-предложение
/// </summary> /// </summary>
/// <param name="containerId">Id контейнера-предложения.</param> /// <param name="containerId">Id контейнера-предложения.</param>
/// <returns>Контейнер после принятия; null — контейнера нет (404).</returns> /// <returns>Контейнер после принятия.</returns>
public Task<ContainerDto?> AcceptSuggestedAsync(string containerId, CancellationToken ct) /// <exception cref="NotFoundException">Контейнер не найден.</exception>
public Task<ContainerDto> AcceptSuggestedAsync(string containerId, CancellationToken ct)
{ {
return PatchAsync(containerId, new ContainerPatchDto( return PatchAsync(containerId, new ContainerPatchDto(
Name: null, Name: null,
@@ -3,14 +3,9 @@ using Isopoh.Cryptography.Argon2;
namespace Deal.Modules.Tenants.Application.Services; namespace Deal.Modules.Tenants.Application.Services;
/// <summary>
/// Реализация <see cref="IPasswordHasher"/> на Argon2id.
/// </summary>
public sealed class DefaultPasswordHasher : IPasswordHasher public sealed class DefaultPasswordHasher : IPasswordHasher
{ {
/// <inheritdoc /> string IPasswordHasher.Hash(string password) => Argon2.Hash(password);
public string Hash(string password) => Argon2.Hash(password);
/// <inheritdoc /> bool IPasswordHasher.Verify(string password, string encodedHash) => Argon2.Verify(encodedHash, password);
public bool 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, TestDiscoveryStore StoreB,
TestDiscoveryGateway GatewayA, TestDiscoveryGateway GatewayA,
TestDiscoveryGateway GatewayB, TestDiscoveryGateway GatewayB,
TenantContext TenantContext, ITenantContext TenantContext,
ListLogger Logs); ListLogger Logs);
[Fact] [Fact]
@@ -91,7 +91,7 @@ public sealed class DiscoveryWorkerSchedulerTests
private static Context CreateContext() private static Context CreateContext()
{ {
var tenants = new TestTenantRepository(Tenant(TenantA), Tenant(TenantB)); var tenants = new TestTenantRepository(Tenant(TenantA), Tenant(TenantB));
var tenantContext = new TenantContext(); ITenantContext tenantContext = new TenantContext();
var storeA = new TestDiscoveryStore(); var storeA = new TestDiscoveryStore();
var storeB = new TestDiscoveryStore(); var storeB = new TestDiscoveryStore();
var settingsA = new TestSettingsStore(); var settingsA = new TestSettingsStore();
@@ -41,7 +41,7 @@ public sealed class MlOutboxFlushSchedulerTests
{ {
var store = new TestMlLearningStore(); var store = new TestMlLearningStore();
SeedRows(store, count: 25, prefix: "a"); SeedRows(store, count: 25, prefix: "a");
var tenantContext = new TenantContext(); ITenantContext tenantContext = new TenantContext();
await using ServiceProvider provider = BuildProvider( await using ServiceProvider provider = BuildProvider(
port, port,
new TestTenantRepository(Tenant(TenantA)), new TestTenantRepository(Tenant(TenantA)),
@@ -67,7 +67,7 @@ public sealed class MlOutboxFlushSchedulerTests
service.TrainUnavailable = true; service.TrainUnavailable = true;
var store = new TestMlLearningStore(); var store = new TestMlLearningStore();
SeedRows(store, count: 5, prefix: "a"); SeedRows(store, count: 5, prefix: "a");
var tenantContext = new TenantContext(); ITenantContext tenantContext = new TenantContext();
await using ServiceProvider provider = BuildProvider( await using ServiceProvider provider = BuildProvider(
port, port,
new TestTenantRepository(Tenant(TenantA)), new TestTenantRepository(Tenant(TenantA)),
@@ -95,7 +95,7 @@ public sealed class MlOutboxFlushSchedulerTests
SeedRows(storeA, count: 12, prefix: "a"); SeedRows(storeA, count: 12, prefix: "a");
var storeB = new TestMlLearningStore(); var storeB = new TestMlLearningStore();
SeedRows(storeB, count: 3, prefix: "b"); SeedRows(storeB, count: 3, prefix: "b");
var tenantContext = new TenantContext(); ITenantContext tenantContext = new TenantContext();
await using ServiceProvider provider = BuildProvider( await using ServiceProvider provider = BuildProvider(
port, port,
new TestTenantRepository(Tenant(TenantA), Tenant(TenantB)), new TestTenantRepository(Tenant(TenantA), Tenant(TenantB)),
@@ -121,7 +121,7 @@ public sealed class MlOutboxFlushSchedulerTests
{ {
var store = new TestMlLearningStore(); var store = new TestMlLearningStore();
SeedRows(store, count: 105, prefix: "a"); SeedRows(store, count: 105, prefix: "a");
var tenantContext = new TenantContext(); ITenantContext tenantContext = new TenantContext();
await using ServiceProvider provider = BuildProvider( await using ServiceProvider provider = BuildProvider(
port, port,
new TestTenantRepository(Tenant(TenantA)), new TestTenantRepository(Tenant(TenantA)),
@@ -148,7 +148,7 @@ public sealed class MlOutboxFlushSchedulerTests
private static ServiceProvider BuildProvider( private static ServiceProvider BuildProvider(
int port, int port,
TestTenantRepository tenants, TestTenantRepository tenants,
TenantContext tenantContext, ITenantContext tenantContext,
Dictionary<Guid, TestMlLearningStore> storesByTenant) Dictionary<Guid, TestMlLearningStore> storesByTenant)
{ {
var services = new ServiceCollection(); var services = new ServiceCollection();
@@ -1,9 +1,10 @@
using Deal.Contracts.Integrations.Models; using Deal.Contracts.Integrations.Models;
using Deal.Tests.Unit.Support;
using Deal.Modules.Cards.Application.Sources; using Deal.Modules.Cards.Application.Sources;
using Deal.Modules.Kanban.Application.Models; using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Kanban.Application.Services; using Deal.Modules.Kanban.Application.Services;
using Deal.SharedKernel.Errors;
using Deal.Tests.Unit.Modules.Settings; using Deal.Tests.Unit.Modules.Settings;
using Deal.Tests.Unit.Support;
namespace Deal.Tests.Unit.Contracts; namespace Deal.Tests.Unit.Contracts;
@@ -343,13 +344,13 @@ public sealed class CardsServiceTests
} }
[Fact] [Fact]
public async Task Trash_CardMissing_ReturnsNull() public async Task Trash_CardMissing_ThrowsNotFound()
{ {
(CardsService service, _, _, TestMlClient ml) = Create(); (CardsService service, _, _, TestMlClient ml) = Create();
CardDto? result = await service.TrashCardAsync("l_ghost", CancellationToken.None); await Assert.ThrowsAsync<NotFoundException>(
() => service.TrashCardAsync("l_ghost", CancellationToken.None));
Assert.Null(result); // эндпоинт отвечает 404 «Карточка не найдена»
Assert.Empty(ml.Pushed); Assert.Empty(ml.Pushed);
} }
@@ -425,13 +426,12 @@ public sealed class CardsServiceTests
} }
[Fact] [Fact]
public async Task Restore_CardMissing_ReturnsNull() public async Task Restore_CardMissing_ThrowsNotFound()
{ {
(CardsService service, _, _, _) = Create(); (CardsService service, _, _, _) = Create();
string? back = await service.RestoreCardAsync("l_ghost", CancellationToken.None); await Assert.ThrowsAsync<NotFoundException>(
() => service.RestoreCardAsync("l_ghost", CancellationToken.None));
Assert.Null(back); // эндпоинт отвечает 404 «Карточка не найдена»
} }
@@ -4,6 +4,7 @@ using Deal.Infrastructure.Data;
using Deal.Infrastructure.Integrations.Abstractions; using Deal.Infrastructure.Integrations.Abstractions;
using Deal.Infrastructure.Integrations.Models; using Deal.Infrastructure.Integrations.Models;
using Deal.Infrastructure.Integrations.Options; using Deal.Infrastructure.Integrations.Options;
using Deal.Infrastructure.Integrations.Resilience;
using Deal.Infrastructure.Integrations.Services; using Deal.Infrastructure.Integrations.Services;
using Deal.Modules.Kanban.Application.Models; using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Settings.Application.Models; using Deal.Modules.Settings.Application.Models;
@@ -91,7 +92,8 @@ public sealed class GrpcMlClientTests
Assert.Null(result.Margin); Assert.Null(result.Margin);
Assert.Empty(result.Terms); Assert.Empty(result.Terms);
Assert.Null(result.Type); 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.Reachable);
Assert.False(down.Service.Ready); Assert.False(down.Service.Ready);
Assert.False(down.Stats.Reachable); Assert.False(down.Stats.Reachable);
Assert.Equal(1, service.StatusCalls); // При недоступности транспорта идёт повтор — считаем все попытки.
Assert.Equal(GrpcRetry.RetryCount + 1, service.StatusCalls);
// «Поднялся»: после TTL 15 с следующий StatusAsync обновляет кэш (ready=true, reachable=true). // «Поднялся»: после TTL 15 с следующий StatusAsync обновляет кэш (ready=true, reachable=true).
service.StatusUnavailable = false; service.StatusUnavailable = false;
@@ -165,7 +168,7 @@ public sealed class GrpcMlClientTests
Assert.True(up.Reachable); Assert.True(up.Reachable);
Assert.True(up.Service.Ready); Assert.True(up.Service.Ready);
Assert.Equal(3, up.Service.Learned); 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.Data;
using Deal.Infrastructure.Integrations.Models; using Deal.Infrastructure.Integrations.Models;
using Deal.Infrastructure.Integrations.Options; using Deal.Infrastructure.Integrations.Options;
using Deal.Infrastructure.Integrations.Resilience;
using Deal.Infrastructure.Integrations.Services; using Deal.Infrastructure.Integrations.Services;
using Deal.Modules.Kanban.Application.Models; using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Pipeline.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.AiFail);
Assert.Equal(1, result.AiStored); Assert.Equal(1, result.AiStored);
Assert.Single(result.CreatedCards); 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.Exceptions;
using Deal.Modules.Discovery.Application.Models; using Deal.Modules.Discovery.Application.Models;
using Deal.Tests.Unit.Support;
using Deal.Modules.Discovery.Application.Services; using Deal.Modules.Discovery.Application.Services;
using Deal.SharedKernel.Errors;
using Deal.Tests.Unit.Modules.Settings; using Deal.Tests.Unit.Modules.Settings;
using Deal.Tests.Unit.Support;
namespace Deal.Tests.Unit.Modules.Discovery; namespace Deal.Tests.Unit.Modules.Discovery;
@@ -131,14 +132,13 @@ public sealed class DiscoveryTasksServiceTests
} }
[Fact] [Fact]
public async Task Patch_MissingTask_ReturnsNull() public async Task Patch_MissingTask_ThrowsNotFound()
{ {
(DiscoveryTasksService service, _, _) = Create(); (DiscoveryTasksService service, _, _) = Create();
DiscoveryTaskDto? patched = await service.PatchAsync( await Assert.ThrowsAsync<NotFoundException>(
"dt_missing", new DiscoveryTaskPatch { Name = "Новое" }, CancellationToken.None); () => service.PatchAsync(
"dt_missing", new DiscoveryTaskPatch { Name = "Новое" }, CancellationToken.None));
Assert.Null(patched);
} }
[Fact] [Fact]
@@ -201,13 +201,12 @@ public sealed class DiscoveryTasksServiceTests
} }
[Fact] [Fact]
public async Task Start_MissingTask_ReturnsNull() public async Task Start_MissingTask_ThrowsNotFound()
{ {
(DiscoveryTasksService service, _, _) = Create(); (DiscoveryTasksService service, _, _) = Create();
DiscoveryTaskDto? task = await service.StartAsync("dt_missing", CancellationToken.None); await Assert.ThrowsAsync<NotFoundException>(
() => service.StartAsync("dt_missing", CancellationToken.None));
Assert.Null(task);
} }
[Fact] [Fact]
@@ -281,9 +280,8 @@ public sealed class DiscoveryTasksServiceTests
store.SeedCandidate(Candidate("c_2", "dt_2")); store.SeedCandidate(Candidate("c_2", "dt_2"));
await store.Store.UpsertBlacklistAsync("c_1", "Источник", "причина", CancellationToken.None); 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.Single(store.Tasks); // dt_2 осталась
Assert.Equal("dt_2", Assert.Single(store.Tasks).Id); Assert.Equal("dt_2", Assert.Single(store.Tasks).Id);
Assert.Single(store.Candidates); // кандидат dt_2 остался Assert.Single(store.Candidates); // кандидат dt_2 остался
@@ -292,13 +290,12 @@ public sealed class DiscoveryTasksServiceTests
} }
[Fact] [Fact]
public async Task Delete_MissingTask_ReturnsFalse() public async Task Delete_MissingTask_ThrowsNotFound()
{ {
(DiscoveryTasksService service, _, _) = Create(); (DiscoveryTasksService service, _, _) = Create();
bool deleted = await service.DeleteAsync("dt_missing", CancellationToken.None); await Assert.ThrowsAsync<NotFoundException>(
() => service.DeleteAsync("dt_missing", CancellationToken.None));
Assert.False(deleted);
} }
[Fact] [Fact]
@@ -1,6 +1,7 @@
using System.Text; using System.Text;
using Deal.Modules.Kanban.Application.Models; using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Kanban.Application.Services; using Deal.Modules.Kanban.Application.Services;
using Deal.SharedKernel.Errors;
using Deal.Tests.Unit.Contracts; using Deal.Tests.Unit.Contracts;
using Deal.Tests.Unit.Modules.Settings; using Deal.Tests.Unit.Modules.Settings;
@@ -73,14 +74,13 @@ public sealed class CardsServiceFilesTests
} }
[Fact] [Fact]
public async Task Add_CardMissing_ReturnsNullAndDoesNotWriteObject() public async Task Add_CardMissing_ThrowsAndDoesNotWriteObject()
{ {
(CardsService service, TestKanjStore store, TestFileStorage storage) = Create(); (CardsService service, TestKanjStore store, TestFileStorage storage) = Create();
CardFileDto? entry = await service.AddFileAsync( await Assert.ThrowsAsync<NotFoundException>(() => service.AddFileAsync(
"c_missing", "photo.png", "image/png", new MemoryStream("x"u8.ToArray()), 1, CancellationToken.None); "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(storage.StoredObjectKeys); // «add на несуществующей карточке не пишет объект»
Assert.Empty(store.CardDtos); Assert.Empty(store.CardDtos);
} }
@@ -151,25 +151,23 @@ public sealed class CardsServiceFilesTests
} }
[Fact] [Fact]
public async Task GetEntry_CardMissing_ReturnsNull() public async Task GetEntry_CardMissing_ThrowsNotFound()
{ {
(CardsService service, _, _) = Create(); (CardsService service, _, _) = Create();
CardFileDto? entry = await service.GetFileEntryAsync("c_missing", "pf_1", CancellationToken.None); await Assert.ThrowsAsync<NotFoundException>(
() => service.GetFileEntryAsync("c_missing", "pf_1", CancellationToken.None));
Assert.Null(entry); // 404 «Карточка не найдена» у эндпоинта
} }
[Fact] [Fact]
public async Task GetEntry_FileNotInMetadata_ReturnsNull() public async Task GetEntry_FileNotInMetadata_ThrowsNotFound()
{ {
(CardsService service, TestKanjStore store, _) = Create(); (CardsService service, TestKanjStore store, _) = Create();
store.SeedCard(Card("c_1") store.SeedCard(Card("c_1")
with { Files = new[] { new CardFileDto("pf_1", "tz.pdf", 100, "document", "Документ", "k") } }); with { Files = new[] { new CardFileDto("pf_1", "tz.pdf", 100, "document", "Документ", "k") } });
CardFileDto? entry = await service.GetFileEntryAsync("c_1", "pf_ghost", CancellationToken.None); await Assert.ThrowsAsync<NotFoundException>(
() => service.GetFileEntryAsync("c_1", "pf_ghost", CancellationToken.None));
Assert.Null(entry); // файла нет в метаданных карточки — 404-семантика
} }
@@ -225,13 +223,13 @@ public sealed class CardsServiceFilesTests
} }
[Fact] [Fact]
public async Task Remove_CardMissing_ReturnsNullWithoutStorageDelete() public async Task Remove_CardMissing_ThrowsWithoutStorageDelete()
{ {
(CardsService service, _, TestFileStorage storage) = Create(); (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); Assert.Empty(storage.DeletedKeys);
} }
@@ -1,6 +1,7 @@
using System.Text.Json; using System.Text.Json;
using Deal.Modules.Kanban.Application.Models; using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Kanban.Application.Services; using Deal.Modules.Kanban.Application.Services;
using Deal.SharedKernel.Errors;
using Deal.Tests.Unit.Contracts; using Deal.Tests.Unit.Contracts;
using Deal.Tests.Unit.Modules.Settings; using Deal.Tests.Unit.Modules.Settings;
@@ -99,13 +100,13 @@ public sealed class CardsServiceSelectedTests
[Fact] [Fact]
public async Task TakeCard_CardMissing_ReturnsNullAndCreatesNothing() public async Task TakeCard_CardMissing_ThrowsAndCreatesNothing()
{ {
(CardsService service, TestKanjStore store, _, _) = Create(); (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); Assert.Empty(store.CardDtos);
} }
@@ -122,8 +123,7 @@ public sealed class CardsServiceSelectedTests
stack: new[] { "Python", "aiogram" }, stack: new[] { "Python", "aiogram" },
budget: new CardBudgetDto(From: 1600, To: 2200, Cur: "USD"))); budget: new CardBudgetDto(From: 1600, To: 2200, Cur: "USD")));
CardDto card = await service.TakeCardAsync("c_1", CancellationToken.None) CardDto card = await service.TakeCardAsync("c_1", CancellationToken.None);
?? throw new InvalidOperationException("take вернул null при существующей карточке");
Assert.Equal("c_1", card.Id); Assert.Equal("c_1", card.Id);
Assert.Equal("planned", card.Col); Assert.Equal("planned", card.Col);
@@ -182,8 +182,7 @@ public sealed class CardsServiceSelectedTests
("tzText", "ТЗ"), ("tzText", "ТЗ"),
("stack", new[] { "C#", ".NET" }), // стек — полная замена массива ("stack", new[] { "C#", ".NET" }), // стек — полная замена массива
("budget", new { from = 500, cur = "EUR" })), ("budget", new { from = 500, cur = "EUR" })),
CancellationToken.None) CancellationToken.None);
?? throw new InvalidOperationException("patch вернул null при существующей карточке");
Assert.Equal("Новый заголовок", card.Title); Assert.Equal("Новый заголовок", card.Title);
Assert.Equal(string.Empty, card.Summary); // summary очищена пустой строкой Assert.Equal(string.Empty, card.Summary); // summary очищена пустой строкой
@@ -255,16 +254,14 @@ public sealed class CardsServiceSelectedTests
} }
[Fact] [Fact]
public async Task Patch_CardMissing_ReturnsNull() public async Task Patch_CardMissing_ThrowsNotFound()
{ {
(CardsService service, _, _, _) = Create(); (CardsService service, _, _, _) = Create();
CardDto? card = await service.PatchCardAsync( await Assert.ThrowsAsync<NotFoundException>(() => service.PatchCardAsync(
"c_missing", "c_missing",
PatchBody(("title", "Т")), PatchBody(("title", "Т")),
CancellationToken.None); CancellationToken.None));
Assert.Null(card);
} }
@@ -1,6 +1,7 @@
using System.Text.Json; using System.Text.Json;
using Deal.Modules.Kanban.Application.Models; using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Kanban.Application.Services; using Deal.Modules.Kanban.Application.Services;
using Deal.SharedKernel.Errors;
using Deal.Tests.Unit.Modules.Settings; using Deal.Tests.Unit.Modules.Settings;
namespace Deal.Tests.Unit.Support; namespace Deal.Tests.Unit.Support;
@@ -237,14 +238,12 @@ public sealed class ContainersServiceTests
} }
[Fact] [Fact]
public async Task Patch_UnknownContainer_ReturnsNull() public async Task Patch_UnknownContainer_ThrowsNotFound()
{ {
(ContainersService service, _, _) = Create(); (ContainersService service, _, _) = Create();
ContainerDto? result = await service.PatchAsync( await Assert.ThrowsAsync<NotFoundException>(
"b_missing", Patch(name: "X"), CancellationToken.None); () => service.PatchAsync("b_missing", Patch(name: "X"), CancellationToken.None));
Assert.Null(result); // эндпоинт отвечает 404 «Контейнер не найден»
} }
[Fact] [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,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, TestKanjStore KanjB,
SseSubscription SubscriptionB, SseSubscription SubscriptionB,
PipelinePumpGate PumpGate, PipelinePumpGate PumpGate,
TenantContext TenantContext, ITenantContext TenantContext,
ListLogger Logs); ListLogger Logs);
// ─── Цикл: pump каждого тенанта в собственном scope + new_card ───────── // ─── Цикл: pump каждого тенанта в собственном scope + new_card ─────────
@@ -149,7 +149,7 @@ public sealed class PipelineWorkerSchedulerTests
private static Context CreateContext(bool withThrowingQueueReadA = false) private static Context CreateContext(bool withThrowingQueueReadA = false)
{ {
var tenants = new TestTenantRepository(Tenant(TenantA), Tenant(TenantB)); var tenants = new TestTenantRepository(Tenant(TenantA), Tenant(TenantB));
var tenantContext = new TenantContext(); ITenantContext tenantContext = new TenantContext();
var pipelineA = new TestPipelineStore(throwOnList: withThrowingQueueReadA); var pipelineA = new TestPipelineStore(throwOnList: withThrowingQueueReadA);
var pipelineB = new TestPipelineStore(); var pipelineB = new TestPipelineStore();
var kanjA = new TestKanjStore(); 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"); TestKanjStore storeB = StoreWithExpiredInboxCard("l_b_old");
var settings = new TestSettingsStore(); var settings = new TestSettingsStore();
var tenants = new TestTenantRepository(Tenant(TenantA), Tenant(TenantB)); 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); await using ServiceProvider provider = BuildProvider(tenants, tenantContext, storeA, storeB, settings);
SseBroker broker = provider.GetRequiredService<SseBroker>(); SseBroker broker = provider.GetRequiredService<SseBroker>();
@@ -74,7 +74,7 @@ public sealed class StorageTickSchedulerTests
var storeB = new TestKanjStore(); var storeB = new TestKanjStore();
storeB.SeedCard(Card("l_b_fresh", KanbanColumns.Inbox, ReceivedAtMsAgo(TimeSpan.FromHours(1)))); storeB.SeedCard(Card("l_b_fresh", KanbanColumns.Inbox, ReceivedAtMsAgo(TimeSpan.FromHours(1))));
var settings = new TestSettingsStore(); var settings = new TestSettingsStore();
var tenantContext = new TenantContext(); ITenantContext tenantContext = new TenantContext();
await using ServiceProvider provider = BuildProvider( await using ServiceProvider provider = BuildProvider(
new TestTenantRepository(Tenant(TenantA), Tenant(TenantB)), new TestTenantRepository(Tenant(TenantA), Tenant(TenantB)),
tenantContext, tenantContext,
@@ -100,7 +100,7 @@ public sealed class StorageTickSchedulerTests
{ {
// У тенанта A настройки падают (имитация сбоя схемы/БД) — тик A логирует ошибку, B обрабатывается. // У тенанта A настройки падают (имитация сбоя схемы/БД) — тик A логирует ошибку, B обрабатывается.
TestKanjStore storeB = StoreWithExpiredInboxCard("l_b_old"); TestKanjStore storeB = StoreWithExpiredInboxCard("l_b_old");
var tenantContext = new TenantContext(); ITenantContext tenantContext = new TenantContext();
var settingsByTenant = new Dictionary<Guid, ISettingsStore> var settingsByTenant = new Dictionary<Guid, ISettingsStore>
{ {
[TenantA] = new ThrowingSettingsStore(), [TenantA] = new ThrowingSettingsStore(),
@@ -133,7 +133,7 @@ public sealed class StorageTickSchedulerTests
[Fact] [Fact]
public async Task RunCycle_TenantListFailure_DoesNotThrow() public async Task RunCycle_TenantListFailure_DoesNotThrow()
{ {
var tenantContext = new TenantContext(); ITenantContext tenantContext = new TenantContext();
await using ServiceProvider provider = BuildProvider( await using ServiceProvider provider = BuildProvider(
new ThrowingTenantRepository(), new ThrowingTenantRepository(),
tenantContext, tenantContext,
@@ -152,7 +152,7 @@ public sealed class StorageTickSchedulerTests
{ {
var kanjStore = new TestKanjStore(); var kanjStore = new TestKanjStore();
var settings = new TestSettingsStore(); var settings = new TestSettingsStore();
var tenantContext = new TenantContext(); ITenantContext tenantContext = new TenantContext();
var pipelineStoreA = new TestPipelineStore(); var pipelineStoreA = new TestPipelineStore();
long nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); long nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
pipelineStoreA.SeedRejected(Rejected("r_old_a", (long)(nowMs - TimeSpan.FromDays(4).TotalMilliseconds))); 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_future", title: "Будущий", reminderAtMs: NowMs() + 60_000));
cardStoreA.SeedCard(HoldCard("c_a_work", stage: "work", title: "В работе", reminderAtMs: NowMs() - 60_000)); cardStoreA.SeedCard(HoldCard("c_a_work", stage: "work", title: "В работе", reminderAtMs: NowMs() - 60_000));
var cardStoreB = new TestKanjStore(); var cardStoreB = new TestKanjStore();
var tenantContext = new TenantContext(); ITenantContext tenantContext = new TenantContext();
await using ServiceProvider provider = BuildProvider( await using ServiceProvider provider = BuildProvider(
new TestTenantRepository(Tenant(TenantA), Tenant(TenantB)).Repository, new TestTenantRepository(Tenant(TenantA), Tenant(TenantB)).Repository,
tenantContext, tenantContext,
@@ -221,7 +221,7 @@ public sealed class StorageTickSchedulerTests
cardStoreA.SeedCard(HoldCard("c_past", title: "Старое", reminderAtMs: NowMs() - 60_000)); cardStoreA.SeedCard(HoldCard("c_past", title: "Старое", reminderAtMs: NowMs() - 60_000));
var settingsA = new TestSettingsStore(); var settingsA = new TestSettingsStore();
settingsA.Preload(SettingsKeys.RemindersEnabled, "false"); settingsA.Preload(SettingsKeys.RemindersEnabled, "false");
var tenantContext = new TenantContext(); ITenantContext tenantContext = new TenantContext();
await using ServiceProvider provider = BuildProvider( await using ServiceProvider provider = BuildProvider(
new TestTenantRepository(Tenant(TenantA)).Repository, new TestTenantRepository(Tenant(TenantA)).Repository,
tenantContext, tenantContext,
@@ -247,7 +247,7 @@ public sealed class StorageTickSchedulerTests
// и тик A завершается, тенант B обрабатывается (автоархив + тост) — проход жив. // и тик A завершается, тенант B обрабатывается (автоархив + тост) — проход жив.
var cardStoreA = new TestKanjStore(throwOnDueReminders: true); var cardStoreA = new TestKanjStore(throwOnDueReminders: true);
TestKanjStore storeB = StoreWithExpiredInboxCard("l_b_old"); TestKanjStore storeB = StoreWithExpiredInboxCard("l_b_old");
var tenantContext = new TenantContext(); ITenantContext tenantContext = new TenantContext();
await using ServiceProvider provider = BuildProvider( await using ServiceProvider provider = BuildProvider(
new TestTenantRepository(Tenant(TenantA), Tenant(TenantB)).Repository, new TestTenantRepository(Tenant(TenantA), Tenant(TenantB)).Repository,
tenantContext, tenantContext,
@@ -280,7 +280,7 @@ public sealed class StorageTickSchedulerTests
// Возвращает: Провайдер с зарегистрированными сервисами теста. // Возвращает: Провайдер с зарегистрированными сервисами теста.
private static ServiceProvider BuildProvider( private static ServiceProvider BuildProvider(
TestTenantRepository tenants, TestTenantRepository tenants,
TenantContext tenantContext, ITenantContext tenantContext,
TestKanjStore storeA, TestKanjStore storeA,
TestKanjStore storeB, TestKanjStore storeB,
TestSettingsStore settings) TestSettingsStore settings)
@@ -301,7 +301,7 @@ public sealed class StorageTickSchedulerTests
// Возвращает: Провайдер с зарегистрированными сервисами теста. // Возвращает: Провайдер с зарегистрированными сервисами теста.
private static ServiceProvider BuildProvider( private static ServiceProvider BuildProvider(
ITenantRepository tenants, ITenantRepository tenants,
TenantContext tenantContext, ITenantContext tenantContext,
Dictionary<Guid, TestKanjStore> storesByTenant, Dictionary<Guid, TestKanjStore> storesByTenant,
Dictionary<Guid, ISettingsStore> settingsByTenant, Dictionary<Guid, ISettingsStore> settingsByTenant,
Dictionary<Guid, TestPipelineStore>? pipelineStoresByTenant = null) Dictionary<Guid, TestPipelineStore>? pipelineStoresByTenant = null)
@@ -30,6 +30,11 @@
<FrameworkReference Include="Microsoft.AspNetCore.App" /> <FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<!-- Общие доменные исключения и тексты ошибок (Deal.SharedKernel.Errors/Resources). -->
<ProjectReference Include="..\..\core\Deal.SharedKernel\Deal.SharedKernel.csproj" />
</ItemGroup>
<ItemGroup> <ItemGroup>
<!-- gRPC-сервер ASP.NET Core (Interceptor/AddGrpc) и стандартный gRPC-health (Ruling 12). --> <!-- gRPC-сервер ASP.NET Core (Interceptor/AddGrpc) и стандартный gRPC-health (Ruling 12). -->
<PackageReference Include="Grpc.AspNetCore" Version="2.83.0" /> <PackageReference Include="Grpc.AspNetCore" Version="2.83.0" />
@@ -45,6 +50,7 @@
ней нет; держим для будущего трейсинга). Версии — 1.17.x (Prometheus-экспортёр и gRPC-клиент ней нет; держим для будущего трейсинга). Версии — 1.17.x (Prometheus-экспортёр и gRPC-клиент
выпускаются только pre-release-линией; остальные пакеты 1.17.0 stable). --> выпускаются только pre-release-линией; остальные пакеты 1.17.0 stable). -->
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.17.0" /> <PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.17.0" />
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.17.0" />
<PackageReference Include="OpenTelemetry.Exporter.Prometheus.AspNetCore" Version="1.17.0-beta.1" /> <PackageReference Include="OpenTelemetry.Exporter.Prometheus.AspNetCore" Version="1.17.0-beta.1" />
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.17.0" /> <PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.17.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.17.0" /> <PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.17.0" />
@@ -2,6 +2,8 @@ using System.Diagnostics;
using Deal.Grpc.Hosting.Models; using Deal.Grpc.Hosting.Models;
using Deal.Grpc.Hosting.Options; using Deal.Grpc.Hosting.Options;
using Deal.Grpc.Hosting.Services; using Deal.Grpc.Hosting.Services;
using Deal.SharedKernel.Errors;
using Deal.SharedKernel.Resources;
using Grpc.Core; using Grpc.Core;
using Grpc.Core.Interceptors; using Grpc.Core.Interceptors;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
@@ -16,9 +18,6 @@ public sealed class RpcCallLoggingInterceptor : Interceptor
// Префикс методов стандартного gRPC-health — не логируется (инфраструктурный liveness). // Префикс методов стандартного gRPC-health — не логируется (инфраструктурный liveness).
private const string HealthMethodPrefix = "/grpc.health.v1.Health/"; private const string HealthMethodPrefix = "/grpc.health.v1.Health/";
// Деталь RpcException для сбоя реализации (фиксированный текст; детали ошибки не наружу).
private const string UnknownFailureDetail = "Внутренняя ошибка сервиса";
private readonly ILogger<RpcCallLoggingInterceptor> _logger; private readonly ILogger<RpcCallLoggingInterceptor> _logger;
/// <summary> /// <summary>
@@ -101,14 +100,46 @@ public sealed class RpcCallLoggingInterceptor : Interceptor
} }
catch (Exception exception) catch (Exception exception)
{ {
// «Прочие» сбои реализации gRPC показал бы клиенту как UNKNOWN мимо access-лога: логируем RpcException mapped = MapFailure(exception, context.Method);
// строку со статусом Unknown, пишем детали сбоя и переводим в RpcException (текст фиксирован). LogCall(context, startedAt, mapped.Status.StatusCode);
_logger.LogError(exception, "gRPC {RpcMethod}: необработанный сбой реализации", context.Method); throw mapped;
LogCall(context, startedAt, StatusCode.Unknown);
throw new RpcException(new Status(StatusCode.Unknown, UnknownFailureDetail));
} }
} }
// Переводит сбой реализации в 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/дуплексный). // Обёртка для handler-ов, возвращающих Task (server-streaming/дуплексный).
// context: Контекст вызова (метод — context.Method). // context: Контекст вызова (метод — context.Method).
// invoke: Вызов нижестоящего обработчика. // invoke: Вызов нижестоящего обработчика.
@@ -67,7 +67,8 @@ public static class DealLogging
loggerConfiguration loggerConfiguration
.MinimumLevel.Is(ParseMinimumLevel(configuration[MinimumLevelEnvKey])) .MinimumLevel.Is(ParseMinimumLevel(configuration[MinimumLevelEnvKey]))
.MinimumLevel.Override(GrpcCategory, LogEventLevel.Information) .MinimumLevel.Override(GrpcCategory, LogEventLevel.Information)
.Enrich.FromLogContext(); .Enrich.FromLogContext()
.Enrich.With<TraceContextEnricher>();
string logsDirectory = ResolveLogsDirectory(environment.ContentRootPath, configuration[LogsDirectoryEnvKey]); string logsDirectory = ResolveLogsDirectory(environment.ContentRootPath, configuration[LogsDirectoryEnvKey]);
Directory.CreateDirectory(logsDirectory); Directory.CreateDirectory(logsDirectory);
@@ -0,0 +1,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); DealLogging.Configure(builder, mlProcessName);
DealMetricsHosting.AddDealMetrics(builder, metricsPort); DealMetricsHosting.AddDealMetrics(builder, metricsPort);
DealTracingHosting.AddDealTracing(builder, mlProcessName);
}); });
DealMetricsHosting.MapDealMetrics(app); DealMetricsHosting.MapDealMetrics(app);
@@ -16,6 +16,7 @@ WebApplication app = StorageServiceHost.Create(
{ {
DealLogging.Configure(builder, storageProcessName); DealLogging.Configure(builder, storageProcessName);
DealMetricsHosting.AddDealMetrics(builder, metricsPort); DealMetricsHosting.AddDealMetrics(builder, metricsPort);
DealTracingHosting.AddDealTracing(builder, storageProcessName);
}); });
DealMetricsHosting.MapDealMetrics(app); DealMetricsHosting.MapDealMetrics(app);
@@ -11,9 +11,6 @@ using Deal.Telegram.Core;
namespace Deal.Telegram.Core; namespace Deal.Telegram.Core;
/// <summary>
/// Исходящий gRPC-канал в ядро
/// </summary>
public sealed class CoreIngressClient : ICoreIngressClient public sealed class CoreIngressClient : ICoreIngressClient
{ {
public const string TenantIdMetadataKey = "tenant-id"; public const string TenantIdMetadataKey = "tenant-id";
@@ -44,7 +41,6 @@ public sealed class CoreIngressClient : ICoreIngressClient
_mtlsCertificates = mtlsCertificates; _mtlsCertificates = mtlsCertificates;
} }
/// <inheritdoc />
async Task<PushSourceReply> ICoreIngressClient.PushSourceAsync( async Task<PushSourceReply> ICoreIngressClient.PushSourceAsync(
string tenantId, string tenantId,
PushSourceRequest request, PushSourceRequest request,
@@ -61,7 +57,6 @@ public sealed class CoreIngressClient : ICoreIngressClient
} }
} }
/// <inheritdoc />
async Task<IReadOnlyList<string>> ICoreIngressClient.SyncDialogsAsync( async Task<IReadOnlyList<string>> ICoreIngressClient.SyncDialogsAsync(
string tenantId, string tenantId,
IReadOnlyList<DialogEntry> entries, IReadOnlyList<DialogEntry> entries,
@@ -1,12 +1,8 @@
using Deal.Telegram.Dialogs; using Deal.Telegram.Dialogs;
namespace Deal.Telegram.Dialogs; namespace Deal.Telegram.Dialogs;
/// <summary>
/// Реальная реализация <see cref="IBackfillPacer"/>
/// </summary>
public sealed class RandomBackfillPacer : IBackfillPacer public sealed class RandomBackfillPacer : IBackfillPacer
{ {
/// <inheritdoc />
async Task IBackfillPacer.WaitAsync( async Task IBackfillPacer.WaitAsync(
double minSeconds, double minSeconds,
double maxSeconds, double maxSeconds,
@@ -18,6 +18,7 @@ WebApplication app = TelegramServiceHost.Create(
{ {
DealLogging.Configure(builder, telegramProcessName); DealLogging.Configure(builder, telegramProcessName);
DealMetricsHosting.AddDealMetrics(builder, metricsPort); DealMetricsHosting.AddDealMetrics(builder, metricsPort);
DealTracingHosting.AddDealTracing(builder, telegramProcessName);
}); });
DealMetricsHosting.MapDealMetrics(app); DealMetricsHosting.MapDealMetrics(app);
@@ -1,12 +1,8 @@
using Deal.Telegram.Telegram; using Deal.Telegram.Telegram;
namespace Deal.Telegram.Telegram; namespace Deal.Telegram.Telegram;
/// <summary>
/// Фабрика реальных клиентов WTelegramClient.
/// </summary>
public sealed class ClientFactory : ITelegramClientFactory public sealed class ClientFactory : ITelegramClientFactory
{ {
/// <inheritdoc />
ISessionClient ITelegramClientFactory.Create( ISessionClient ITelegramClientFactory.Create(
int apiId, int apiId,
string apiHash, string apiHash,
@@ -11,9 +11,6 @@ namespace Deal.Telegram.Telegram;
#pragma warning disable CS0618 // Auth_SendCode/Auth_SignIn используются осознанно: ручной веб-вход 1:1 с прототипом #pragma warning disable CS0618 // Auth_SendCode/Auth_SignIn используются осознанно: ручной веб-вход 1:1 с прототипом
/// <summary>
/// Реальная реализация <see cref="ISessionClient"/> поверх WTelegramClient.
/// </summary>
public sealed class WTelegramSessionClient : ISessionClient public sealed class WTelegramSessionClient : ISessionClient
{ {
private readonly Client _client; private readonly Client _client;
@@ -67,25 +64,19 @@ public sealed class WTelegramSessionClient : ISessionClient
_updateManager = new UpdateManager(_client, OnSingleUpdateAsync); _updateManager = new UpdateManager(_client, OnSingleUpdateAsync);
} }
/// <inheritdoc /> bool ISessionClient.IsAuthorized => _client.UserId != 0;
public bool IsAuthorized => _client.UserId != 0;
/// <inheritdoc /> bool ISessionClient.IsConnected => _connected && !_client.Disconnected;
public bool IsConnected => _connected && !_client.Disconnected;
/// <inheritdoc /> int ISessionClient.ApiId => _apiId;
public int ApiId => _apiId;
/// <inheritdoc /> string ISessionClient.ApiHash => _apiHash;
public string ApiHash => _apiHash;
/// <inheritdoc /> byte[]? ISessionClient.SessionBytes => Volatile.Read(ref _latestSessionBytes);
public byte[]? SessionBytes => Volatile.Read(ref _latestSessionBytes);
/// <inheritdoc />
async Task ISessionClient.ConnectAsync(CancellationToken cancellationToken) async Task ISessionClient.ConnectAsync(CancellationToken cancellationToken)
{ {
if (IsConnected) if (((ISessionClient)this).IsConnected)
{ {
return; return;
} }
@@ -94,7 +85,6 @@ public sealed class WTelegramSessionClient : ISessionClient
_connected = true; _connected = true;
} }
/// <inheritdoc />
async Task ISessionClient.RequestCodeAsync(string phone, CancellationToken cancellationToken) async Task ISessionClient.RequestCodeAsync(string phone, CancellationToken cancellationToken)
{ {
_phone = phone; _phone = phone;
@@ -118,7 +108,6 @@ public sealed class WTelegramSessionClient : ISessionClient
} }
} }
/// <inheritdoc />
async Task<string?> ISessionClient.SubmitCodeAsync(string code, CancellationToken cancellationToken) async Task<string?> ISessionClient.SubmitCodeAsync(string code, CancellationToken cancellationToken)
{ {
if (_phoneAlreadyAuthorized) if (_phoneAlreadyAuthorized)
@@ -158,7 +147,6 @@ public sealed class WTelegramSessionClient : ISessionClient
return null; return null;
} }
/// <inheritdoc />
async Task ISessionClient.SubmitPasswordAsync(string password, CancellationToken cancellationToken) async Task ISessionClient.SubmitPasswordAsync(string password, CancellationToken cancellationToken)
{ {
try try
@@ -178,7 +166,6 @@ public sealed class WTelegramSessionClient : ISessionClient
} }
} }
/// <inheritdoc />
async Task ISessionClient.StartQrAsync(Action<string> onQrUrl, CancellationToken cancellationToken) async Task ISessionClient.StartQrAsync(Action<string> onQrUrl, CancellationToken cancellationToken)
{ {
try try
@@ -196,13 +183,11 @@ public sealed class WTelegramSessionClient : ISessionClient
} }
} }
/// <inheritdoc />
async Task ISessionClient.LogOutAsync(CancellationToken cancellationToken) async Task ISessionClient.LogOutAsync(CancellationToken cancellationToken)
{ {
await _client.Auth_LogOut().WaitAsync(cancellationToken).ConfigureAwait(false); await _client.Auth_LogOut().WaitAsync(cancellationToken).ConfigureAwait(false);
} }
/// <inheritdoc />
async Task<string> ISessionClient.GetAccountAsync(CancellationToken cancellationToken) async Task<string> ISessionClient.GetAccountAsync(CancellationToken cancellationToken)
{ {
UserBase[] users = await _client.Users_GetUsers(InputUser.Self).WaitAsync(cancellationToken).ConfigureAwait(false); UserBase[] users = await _client.Users_GetUsers(InputUser.Self).WaitAsync(cancellationToken).ConfigureAwait(false);
@@ -221,7 +206,6 @@ public sealed class WTelegramSessionClient : ISessionClient
/// <inheritdoc /> /// <inheritdoc />
public event Func<TelegramMessage, Task>? MessageReceived; public event Func<TelegramMessage, Task>? MessageReceived;
/// <inheritdoc />
async Task<IReadOnlyList<TelegramDialog>> ISessionClient.GetDialogsAsync(int limit, CancellationToken cancellationToken) async Task<IReadOnlyList<TelegramDialog>> ISessionClient.GetDialogsAsync(int limit, CancellationToken cancellationToken)
{ {
Messages_DialogsBase result = await RunTlCallAsync(() => _client.Messages_GetDialogs(limit: limit), cancellationToken).ConfigureAwait(false); Messages_DialogsBase result = await RunTlCallAsync(() => _client.Messages_GetDialogs(limit: limit), cancellationToken).ConfigureAwait(false);
@@ -241,7 +225,6 @@ public sealed class WTelegramSessionClient : ISessionClient
return items; return items;
} }
/// <inheritdoc />
async Task<IReadOnlyList<TelegramMessage>> ISessionClient.GetMessagesAsync( async Task<IReadOnlyList<TelegramMessage>> ISessionClient.GetMessagesAsync(
string dialogId, string dialogId,
int limit, int limit,
@@ -265,7 +248,6 @@ public sealed class WTelegramSessionClient : ISessionClient
return items; return items;
} }
/// <inheritdoc />
async Task<TelegramMessage?> ISessionClient.GetMessageAsync( async Task<TelegramMessage?> ISessionClient.GetMessageAsync(
string dialogId, string dialogId,
long msgId, long msgId,
@@ -295,7 +277,6 @@ public sealed class WTelegramSessionClient : ISessionClient
return null; return null;
} }
/// <inheritdoc />
async Task ISessionClient.MarkReadAsync(string dialogId, CancellationToken cancellationToken) async Task ISessionClient.MarkReadAsync(string dialogId, CancellationToken cancellationToken)
{ {
InputPeer peer = await ResolvePeerAsync(dialogId, cancellationToken).ConfigureAwait(false); InputPeer peer = await ResolvePeerAsync(dialogId, cancellationToken).ConfigureAwait(false);
@@ -303,7 +284,6 @@ public sealed class WTelegramSessionClient : ISessionClient
} }
/// <inheritdoc />
async Task<IReadOnlyList<TelegramDialog>> ISessionClient.SearchAsync( async Task<IReadOnlyList<TelegramDialog>> ISessionClient.SearchAsync(
string query, string query,
int limit, int limit,
@@ -327,7 +307,6 @@ public sealed class WTelegramSessionClient : ISessionClient
return items; return items;
} }
/// <inheritdoc />
async Task<TelegramSourceInfo> ISessionClient.GetInfoAsync(string dialogId, CancellationToken cancellationToken) async Task<TelegramSourceInfo> ISessionClient.GetInfoAsync(string dialogId, CancellationToken cancellationToken)
{ {
TelegramSourceInfo unknown = DefaultSourceInfo(dialogId); TelegramSourceInfo unknown = DefaultSourceInfo(dialogId);
@@ -361,7 +340,6 @@ public sealed class WTelegramSessionClient : ISessionClient
return unknown; return unknown;
} }
/// <inheritdoc />
async Task<DiscoveryReadResult> ISessionClient.ReadForEvalAsync( async Task<DiscoveryReadResult> ISessionClient.ReadForEvalAsync(
string dialogId, string dialogId,
int limit, int limit,
@@ -410,7 +388,6 @@ public sealed class WTelegramSessionClient : ISessionClient
} }
} }
/// <inheritdoc />
async Task ISessionClient.JoinAsync(string username, CancellationToken cancellationToken) async Task ISessionClient.JoinAsync(string username, CancellationToken cancellationToken)
{ {
Contacts_ResolvedPeer resolved = await RunTlCallAsync(() => _client.Contacts_ResolveUsername(username), cancellationToken).ConfigureAwait(false); Contacts_ResolvedPeer resolved = await RunTlCallAsync(() => _client.Contacts_ResolveUsername(username), cancellationToken).ConfigureAwait(false);
@@ -425,7 +402,6 @@ public sealed class WTelegramSessionClient : ISessionClient
await RunTlCallAsync(() => _client.Channels_JoinChannel(new InputChannel(channel.id, channel.access_hash)), cancellationToken).ConfigureAwait(false); await RunTlCallAsync(() => _client.Channels_JoinChannel(new InputChannel(channel.id, channel.access_hash)), cancellationToken).ConfigureAwait(false);
} }
/// <inheritdoc />
async Task ISessionClient.LeaveAsync(string dialogId, CancellationToken cancellationToken) async Task ISessionClient.LeaveAsync(string dialogId, CancellationToken cancellationToken)
{ {
if (!TryParseSignedId(dialogId, out bool isChannel, out _, out _, out long rawId) || !isChannel) if (!TryParseSignedId(dialogId, out bool isChannel, out _, out _, out long rawId) || !isChannel)