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