Compare commits
76
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
09358f07f2 | ||
|
|
ea4ed73327 | ||
|
|
6b7bfa5e4d | ||
|
|
78fe64f531 | ||
|
|
dda314cddc | ||
|
|
16f4270995 | ||
|
|
ac9d92d196 | ||
|
|
fb964f9b4f | ||
|
|
f7459069d1 | ||
|
|
37b7524665 | ||
|
|
d0e202fbc6 | ||
|
|
905129effa | ||
|
|
f31989a133 | ||
|
|
44d2ee61f7 | ||
|
|
28cf8f23ca | ||
|
|
b190ac75b1 | ||
|
|
b7c5e13fc3 | ||
|
|
04d7a0f6fb | ||
|
|
1fbe29a838 | ||
|
|
8dde35de49 | ||
|
|
71bbdd4dd5 | ||
|
|
482b345438 | ||
|
|
6a88032414 | ||
|
|
525759b232 | ||
|
|
a99bb4a713 | ||
|
|
c46bbe9332 | ||
|
|
ac2bf4b302 | ||
|
|
4a6fa374c5 | ||
|
|
ad5fa7c083 | ||
|
|
02a4f9c749 | ||
|
|
585397b9a3 | ||
|
|
75de70e09c | ||
|
|
891a894aed | ||
|
|
f93fb0fdd3 | ||
|
|
d2d6b81aa0 | ||
|
|
a9bfecf9cc | ||
|
|
13d7994511 | ||
|
|
a9f2b3a1ef | ||
|
|
a9bcd7c5f7 | ||
|
|
beaf20df42 | ||
|
|
4526532b1a | ||
|
|
79793a7635 | ||
|
|
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
|
name: ci
|
||||||
|
|
||||||
|
# Прогоняем CI в двух случаях:
|
||||||
|
# 1) МР: создан, переоткрыт или в ветку МР запушены новые коммиты (synchronize).
|
||||||
|
# 2) Влитие в main (пуш в main — в т.ч. мерж МР).
|
||||||
|
# Обычные коммиты в ветки без открытого МР CI не запускают.
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
pull_request:
|
pull_request:
|
||||||
|
types:
|
||||||
|
- opened
|
||||||
|
- synchronize
|
||||||
|
- reopened
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
|
# Новый прогон того же рефа (та же ветка МР / тот же main) отменяет незавершённый предыдущий:
|
||||||
|
# коммит-А стартовал, через пару минут коммит-Б — прогон А отменяется, если ещё не закончил.
|
||||||
|
# Завершённые (успех/фейл) прогоны не трогаются.
|
||||||
|
concurrency:
|
||||||
|
group: ci-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-test:
|
build-test:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# Dev-edge «Дейла» (compose.dev.yml, сервис frontend): SPA + /api на core.
|
||||||
|
# Отличие от prod-Caddyfile: HTTP без TLS и без плейсхолдер-домена — для локального просмотра UI.
|
||||||
|
# Статика — собранный SPA (Vite) в /srv, неизвестные пути отдают index.html (история браузера).
|
||||||
|
|
||||||
|
:80 {
|
||||||
|
# API core: /api/* уходит на core:5080 без перезаписи (контракт /api неизменен).
|
||||||
|
# SSE (/api/events), файлы и QR-SVG проходят reverse_proxy потоково.
|
||||||
|
handle /api/* {
|
||||||
|
reverse_proxy core:5080
|
||||||
|
}
|
||||||
|
|
||||||
|
handle {
|
||||||
|
# SPA/ассеты в dev не кэшируем: пересборка фронта должна подхватываться по F5.
|
||||||
|
header Cache-Control "no-cache"
|
||||||
|
root * /srv
|
||||||
|
try_files {path} /index.html
|
||||||
|
file_server
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -108,6 +108,9 @@ services:
|
|||||||
Storage__Minio__SecretKey: deal_minio_secret
|
Storage__Minio__SecretKey: deal_minio_secret
|
||||||
Storage__Minio__Bucket: deal-files
|
Storage__Minio__Bucket: deal-files
|
||||||
Storage__Minio__Secure: "false"
|
Storage__Minio__Secure: "false"
|
||||||
|
# Трейсинг OTel → коллектор профиля observability (без коллектора трейсы не экспортируются).
|
||||||
|
OTEL_EXPORTER_OTLP_ENDPOINT: ${DEAL_OTEL_ENDPOINT:-}
|
||||||
|
OTEL_SERVICE_NAME: core
|
||||||
ports:
|
ports:
|
||||||
- "5080:5080"
|
- "5080:5080"
|
||||||
- "5082:5082" # gRPC-ингресс telegram-service (сервисы ходят на http://core:5082 внутри сети)
|
- "5082:5082" # gRPC-ингресс telegram-service (сервисы ходят на http://core:5082 внутри сети)
|
||||||
@@ -146,6 +149,8 @@ services:
|
|||||||
DEAL_TELEGRAM_SESSION_KEY: ${DEAL_TELEGRAM_SESSION_KEY:-ZmVkY2JhOTg3NjU0MzIxMGZlZGNiYTk4NzY1NDMyMTA=}
|
DEAL_TELEGRAM_SESSION_KEY: ${DEAL_TELEGRAM_SESSION_KEY:-ZmVkY2JhOTg3NjU0MzIxMGZlZGNiYTk4NzY1NDMyMTA=}
|
||||||
DEAL_TELEGRAM_SESSION_DIR: /data/sessions
|
DEAL_TELEGRAM_SESSION_DIR: /data/sessions
|
||||||
DEAL_LOGS_DIR: /tmp/logs
|
DEAL_LOGS_DIR: /tmp/logs
|
||||||
|
OTEL_EXPORTER_OTLP_ENDPOINT: ${DEAL_OTEL_ENDPOINT:-}
|
||||||
|
OTEL_SERVICE_NAME: telegram-service
|
||||||
SERVICES__CORE__INGRESS: ${DEAL_CORE_INGRESS:-http://core:5082}
|
SERVICES__CORE__INGRESS: ${DEAL_CORE_INGRESS:-http://core:5082}
|
||||||
ports:
|
ports:
|
||||||
- "5101:5101"
|
- "5101:5101"
|
||||||
@@ -176,6 +181,8 @@ services:
|
|||||||
GRPC_PORT: "5102"
|
GRPC_PORT: "5102"
|
||||||
DEAL_SERVICE_TOKEN: ${DEAL_SERVICE_TOKEN:-deal_dev_service_token}
|
DEAL_SERVICE_TOKEN: ${DEAL_SERVICE_TOKEN:-deal_dev_service_token}
|
||||||
DEAL_LOGS_DIR: /tmp/logs
|
DEAL_LOGS_DIR: /tmp/logs
|
||||||
|
OTEL_EXPORTER_OTLP_ENDPOINT: ${DEAL_OTEL_ENDPOINT:-}
|
||||||
|
OTEL_SERVICE_NAME: ai-service
|
||||||
ports:
|
ports:
|
||||||
- "5102:5102"
|
- "5102:5102"
|
||||||
healthcheck:
|
healthcheck:
|
||||||
@@ -203,6 +210,8 @@ services:
|
|||||||
DEAL_SERVICE_TOKEN: ${DEAL_SERVICE_TOKEN:-deal_dev_service_token}
|
DEAL_SERVICE_TOKEN: ${DEAL_SERVICE_TOKEN:-deal_dev_service_token}
|
||||||
DEAL_ML_DATA_DIR: /data/ml # файлы моделей data/ml/<tenantId>.sqlite на volume deal_ml_data (Ruling 4/12)
|
DEAL_ML_DATA_DIR: /data/ml # файлы моделей data/ml/<tenantId>.sqlite на volume deal_ml_data (Ruling 4/12)
|
||||||
DEAL_LOGS_DIR: /tmp/logs
|
DEAL_LOGS_DIR: /tmp/logs
|
||||||
|
OTEL_EXPORTER_OTLP_ENDPOINT: ${DEAL_OTEL_ENDPOINT:-}
|
||||||
|
OTEL_SERVICE_NAME: ml-service
|
||||||
ports:
|
ports:
|
||||||
- "5103:5103"
|
- "5103:5103"
|
||||||
volumes:
|
volumes:
|
||||||
@@ -233,6 +242,8 @@ services:
|
|||||||
DEAL_STORAGE_BUCKET: deal-attachments
|
DEAL_STORAGE_BUCKET: deal-attachments
|
||||||
DEAL_STORAGE_SECURE: "false"
|
DEAL_STORAGE_SECURE: "false"
|
||||||
DEAL_LOGS_DIR: /tmp/logs
|
DEAL_LOGS_DIR: /tmp/logs
|
||||||
|
OTEL_EXPORTER_OTLP_ENDPOINT: ${DEAL_OTEL_ENDPOINT:-}
|
||||||
|
OTEL_SERVICE_NAME: storage-service
|
||||||
ports:
|
ports:
|
||||||
- "5104:5104"
|
- "5104:5104"
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -243,6 +254,18 @@ services:
|
|||||||
timeout: 3s
|
timeout: 3s
|
||||||
retries: 10
|
retries: 10
|
||||||
|
|
||||||
|
# Фронтенд (SPA) — сборка образа (Vite) и отдача через Caddy; /api → core:5080. UI — http://localhost:8080.
|
||||||
|
frontend:
|
||||||
|
build:
|
||||||
|
context: ..
|
||||||
|
dockerfile: src/frontend/Dockerfile
|
||||||
|
container_name: deal-frontend
|
||||||
|
ports:
|
||||||
|
- "8080:80"
|
||||||
|
depends_on:
|
||||||
|
core:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
# Prometheus (профиль observability, этап 12/пакет A) — сбор /metrics всех 4 процессов (:9464)
|
# Prometheus (профиль observability, этап 12/пакет A) — сбор /metrics всех 4 процессов (:9464)
|
||||||
# внутри dev-сети. Подъём: docker compose -f deploy/compose.dev.yml --profile observability up -d.
|
# внутри dev-сети. Подъём: docker compose -f deploy/compose.dev.yml --profile observability up -d.
|
||||||
# Конфиг — общий deploy/observability/prometheus.yml (те же имена сервисов и таргеты). UI — 9090.
|
# Конфиг — общий deploy/observability/prometheus.yml (те же имена сервисов и таргеты). UI — 9090.
|
||||||
@@ -261,6 +284,116 @@ services:
|
|||||||
- ./observability/prometheus-rules.yml:/etc/prometheus/prometheus-rules.yml:ro
|
- ./observability/prometheus-rules.yml:/etc/prometheus/prometheus-rules.yml:ro
|
||||||
- deal_prometheus_data:/prometheus
|
- deal_prometheus_data:/prometheus
|
||||||
|
|
||||||
|
# OpenTelemetry Collector — приём трейсов Deal-процессов (OTLP) → Tempo (профиль observability).
|
||||||
|
otel-collector:
|
||||||
|
image: otel/opentelemetry-collector-contrib:0.160.0
|
||||||
|
container_name: deal-otel-collector
|
||||||
|
profiles: ["observability"]
|
||||||
|
command: ["--config=/etc/otelcol-contrib/config.yaml"]
|
||||||
|
ports:
|
||||||
|
- "4317:4317"
|
||||||
|
- "4318:4318"
|
||||||
|
volumes:
|
||||||
|
- ./observability/otel-collector.yml:/etc/otelcol-contrib/config.yaml:ro
|
||||||
|
depends_on:
|
||||||
|
tempo:
|
||||||
|
condition: service_started
|
||||||
|
|
||||||
|
# Tempo — хранилище трейсов (OTLP от коллектора), UI/API — :3200 (профиль observability).
|
||||||
|
tempo:
|
||||||
|
image: grafana/tempo:2.8.1
|
||||||
|
container_name: deal-tempo
|
||||||
|
profiles: ["observability"]
|
||||||
|
command: ["-config.file=/etc/tempo.yml"]
|
||||||
|
ports:
|
||||||
|
- "3200:3200"
|
||||||
|
volumes:
|
||||||
|
- ./observability/tempo.yml:/etc/tempo.yml:ro
|
||||||
|
- deal_tempo_data:/var/tempo
|
||||||
|
|
||||||
|
# cAdvisor — ресурсы контейнеров (CPU/RAM/сеть/диск); scrape — job cadvisor.
|
||||||
|
cadvisor:
|
||||||
|
image: gcr.io/cadvisor/cadvisor:v0.52.1
|
||||||
|
container_name: deal-cadvisor
|
||||||
|
profiles: ["observability"]
|
||||||
|
privileged: true
|
||||||
|
devices:
|
||||||
|
- /dev/kmsg:/dev/kmsg
|
||||||
|
volumes:
|
||||||
|
- /:/rootfs:ro
|
||||||
|
- /var/run:/var/run:ro
|
||||||
|
- /sys:/sys:ro
|
||||||
|
- /var/lib/docker/:/var/lib/docker:ro
|
||||||
|
- /dev/disk/:/dev/disk:ro
|
||||||
|
|
||||||
|
# node-exporter — ресурсы хоста (CPU/RAM/диск/сеть); scrape — job node-exporter.
|
||||||
|
node-exporter:
|
||||||
|
image: prom/node-exporter:v1.9.1
|
||||||
|
container_name: deal-node-exporter
|
||||||
|
profiles: ["observability"]
|
||||||
|
command:
|
||||||
|
- --path.procfs=/host/proc
|
||||||
|
- --path.sysfs=/host/sys
|
||||||
|
- --path.rootfs=/host/root
|
||||||
|
- --collector.filesystem.mount-points-exclude=^/(sys|proc|dev|host|etc)($|/)
|
||||||
|
ports:
|
||||||
|
- "9100:9100"
|
||||||
|
volumes:
|
||||||
|
- /proc:/host/proc:ro
|
||||||
|
- /sys:/host/sys:ro
|
||||||
|
- /:/host/root:ro
|
||||||
|
pid: host
|
||||||
|
|
||||||
|
# Loki — хранилище логов (профиль observability), UI/API — :3100.
|
||||||
|
loki:
|
||||||
|
image: grafana/loki:3.4.2
|
||||||
|
container_name: deal-loki
|
||||||
|
profiles: ["observability"]
|
||||||
|
command: -config.file=/etc/loki/loki.yml
|
||||||
|
ports:
|
||||||
|
- "3100:3100"
|
||||||
|
volumes:
|
||||||
|
- ./observability/loki.yml:/etc/loki/loki.yml:ro
|
||||||
|
- deal_loki_data:/loki
|
||||||
|
|
||||||
|
# Promtail — сбор docker-логов deal-процессов в Loki (docker.sock, профиль observability).
|
||||||
|
promtail:
|
||||||
|
image: grafana/promtail:3.4.2
|
||||||
|
container_name: deal-promtail
|
||||||
|
profiles: ["observability"]
|
||||||
|
command: -config.file=/etc/promtail/promtail.yml
|
||||||
|
volumes:
|
||||||
|
- ./observability/promtail.yml:/etc/promtail/promtail.yml:ro
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||||
|
- deal_promtail_data:/var/lib/promtail
|
||||||
|
depends_on:
|
||||||
|
loki:
|
||||||
|
condition: service_started
|
||||||
|
|
||||||
|
# Grafana — UI логов/метрик/трейсов (профиль observability), локальный вход admin/admin.
|
||||||
|
grafana:
|
||||||
|
image: grafana/grafana:11.5.2
|
||||||
|
container_name: deal-grafana
|
||||||
|
profiles: ["observability"]
|
||||||
|
environment:
|
||||||
|
GF_SECURITY_ADMIN_USER: admin
|
||||||
|
GF_SECURITY_ADMIN_PASSWORD: admin
|
||||||
|
GF_USERS_ALLOW_SIGN_UP: "false"
|
||||||
|
GF_AUTH_ANONYMOUS_ENABLED: "false"
|
||||||
|
ports:
|
||||||
|
- "3001:3000"
|
||||||
|
volumes:
|
||||||
|
- ./observability/grafana/provisioning:/etc/grafana/provisioning:ro
|
||||||
|
- ./observability/grafana/dashboards:/var/lib/grafana/dashboards:ro
|
||||||
|
- deal_grafana_data:/var/lib/grafana
|
||||||
|
depends_on:
|
||||||
|
loki:
|
||||||
|
condition: service_started
|
||||||
|
prometheus:
|
||||||
|
condition: service_started
|
||||||
|
tempo:
|
||||||
|
condition: service_started
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
deal_pgdata:
|
deal_pgdata:
|
||||||
deal_minio_data:
|
deal_minio_data:
|
||||||
@@ -268,3 +401,7 @@ volumes:
|
|||||||
deal_ml_data:
|
deal_ml_data:
|
||||||
deal_api_data:
|
deal_api_data:
|
||||||
deal_prometheus_data:
|
deal_prometheus_data:
|
||||||
|
deal_tempo_data:
|
||||||
|
deal_loki_data:
|
||||||
|
deal_promtail_data:
|
||||||
|
deal_grafana_data:
|
||||||
|
|||||||
+111
-5
@@ -6,12 +6,12 @@
|
|||||||
# Состав (всё в одной внутренней сети compose, наружу — ТОЛЬКО caddy :80/:443):
|
# Состав (всё в одной внутренней сети compose, наружу — ТОЛЬКО caddy :80/:443):
|
||||||
# postgres, minio — хранилища БЕЗ host-портов (volume'ы);
|
# postgres, minio — хранилища БЕЗ host-портов (volume'ы);
|
||||||
# core (:5080 HTTP + :5082 gRPC-ингресс), telegram-service (:5101), ai-service (:5102),
|
# core (:5080 HTTP + :5082 gRPC-ингресс), telegram-service (:5101), ai-service (:5102),
|
||||||
# ml-service (:5103) — процессы «Дейла»; mTLS-транспорт — по env Ruling 6 (см. ниже);
|
# ml-service (:5103), storage-service (:5104) — процессы «Дейла»; mTLS-транспорт — по env Ruling 6 (см. ниже);
|
||||||
# caddy — edge: TLS-терминация, статика фронта, reverse_proxy /api → core.
|
# caddy — edge: TLS-терминация, статика фронта, reverse_proxy /api → core.
|
||||||
# loki/promtail/grafana/prometheus — observability (Ruling 7; метрики — этап 12, пакет A): ПРОФИЛЬ
|
# observability (ПРОФИЛЬ `observability`) — современный стек: otel-collector (приём трейсов OTLP),
|
||||||
# `observability` — поднимается только: docker compose --profile observability up -d
|
# tempo (хранилище трейсов), loki/promtail (логи), prometheus (метрики),
|
||||||
# (или ... up -d --profile observability). Prometheus scrape'ит /metrics
|
# cadvisor/node-exporter (потребление ресурсов контейнеров/хоста), grafana (UI).
|
||||||
# (порт 9464) всех 4 процессов; Grafana — логи (Loki) и метрики (Prometheus).
|
# Подъём: docker compose --profile observability up -d.
|
||||||
#
|
#
|
||||||
# Секреты — ТОЛЬКО из env: шаблон deploy/.env.prod.example → скопируйте в deploy/.env.prod,
|
# Секреты — ТОЛЬКО из env: шаблон deploy/.env.prod.example → скопируйте в deploy/.env.prod,
|
||||||
# заполните значения и запускайте с --env-file:
|
# заполните значения и запускайте с --env-file:
|
||||||
@@ -125,6 +125,7 @@ services:
|
|||||||
Services__Ai__Endpoint: ${DEAL_AI_ENDPOINT:-http://ai-service:5102}
|
Services__Ai__Endpoint: ${DEAL_AI_ENDPOINT:-http://ai-service:5102}
|
||||||
Services__Telegram__UseLocal: "false"
|
Services__Telegram__UseLocal: "false"
|
||||||
Services__Telegram__Endpoint: ${DEAL_TELEGRAM_ENDPOINT:-http://telegram-service:5101}
|
Services__Telegram__Endpoint: ${DEAL_TELEGRAM_ENDPOINT:-http://telegram-service:5101}
|
||||||
|
Services__Storage__Endpoint: ${DEAL_STORAGE_SERVICE_ENDPOINT:-http://storage-service:5104}
|
||||||
# Файлы — MinIO (внутренний http; TLS minio — вне этапа, при желании Storage__Minio__Secure=true
|
# Файлы — MinIO (внутренний http; TLS minio — вне этапа, при желании Storage__Minio__Secure=true
|
||||||
# + endpoint https и сертификаты).
|
# + endpoint https и сертификаты).
|
||||||
Storage__Minio__Endpoint: minio:9000
|
Storage__Minio__Endpoint: minio:9000
|
||||||
@@ -132,6 +133,9 @@ services:
|
|||||||
Storage__Minio__SecretKey: ${MINIO_ROOT_PASSWORD:?MINIO_ROOT_PASSWORD не задан}
|
Storage__Minio__SecretKey: ${MINIO_ROOT_PASSWORD:?MINIO_ROOT_PASSWORD не задан}
|
||||||
Storage__Minio__Bucket: deal-files
|
Storage__Minio__Bucket: deal-files
|
||||||
Storage__Minio__Secure: "false"
|
Storage__Minio__Secure: "false"
|
||||||
|
# Трейсинг OTel → коллектор профиля observability; без коллектора трейсы не экспортируются.
|
||||||
|
OTEL_EXPORTER_OTLP_ENDPOINT: ${DEAL_OTEL_ENDPOINT:-}
|
||||||
|
OTEL_SERVICE_NAME: core
|
||||||
# mTLS внутреннего gRPC (Ruling 6; пути — /etc/deal/certs, см. volume ниже).
|
# mTLS внутреннего gRPC (Ruling 6; пути — /etc/deal/certs, см. volume ниже).
|
||||||
DEAL_MTLS_ENABLED: ${DEAL_MTLS_ENABLED:-0}
|
DEAL_MTLS_ENABLED: ${DEAL_MTLS_ENABLED:-0}
|
||||||
DEAL_MTLS_CA_PEM: /etc/deal/certs/ca.pem
|
DEAL_MTLS_CA_PEM: /etc/deal/certs/ca.pem
|
||||||
@@ -172,6 +176,8 @@ services:
|
|||||||
DEAL_TELEGRAM_SESSION_KEY: ${DEAL_TELEGRAM_SESSION_KEY:?DEAL_TELEGRAM_SESSION_KEY не задан (ключ AES-GCM сессий)}
|
DEAL_TELEGRAM_SESSION_KEY: ${DEAL_TELEGRAM_SESSION_KEY:?DEAL_TELEGRAM_SESSION_KEY не задан (ключ AES-GCM сессий)}
|
||||||
DEAL_TELEGRAM_SESSION_DIR: /data/sessions
|
DEAL_TELEGRAM_SESSION_DIR: /data/sessions
|
||||||
DEAL_LOGS_DIR: /tmp/logs
|
DEAL_LOGS_DIR: /tmp/logs
|
||||||
|
OTEL_EXPORTER_OTLP_ENDPOINT: ${DEAL_OTEL_ENDPOINT:-}
|
||||||
|
OTEL_SERVICE_NAME: telegram-service
|
||||||
SERVICES__CORE__INGRESS: ${DEAL_CORE_INGRESS:-http://core:5082}
|
SERVICES__CORE__INGRESS: ${DEAL_CORE_INGRESS:-http://core:5082}
|
||||||
DEAL_MTLS_ENABLED: ${DEAL_MTLS_ENABLED:-0}
|
DEAL_MTLS_ENABLED: ${DEAL_MTLS_ENABLED:-0}
|
||||||
DEAL_MTLS_CA_PEM: /etc/deal/certs/ca.pem
|
DEAL_MTLS_CA_PEM: /etc/deal/certs/ca.pem
|
||||||
@@ -201,6 +207,8 @@ services:
|
|||||||
GRPC_PORT: "5102"
|
GRPC_PORT: "5102"
|
||||||
DEAL_SERVICE_TOKEN: ${DEAL_SERVICE_TOKEN:?DEAL_SERVICE_TOKEN не задан}
|
DEAL_SERVICE_TOKEN: ${DEAL_SERVICE_TOKEN:?DEAL_SERVICE_TOKEN не задан}
|
||||||
DEAL_LOGS_DIR: /tmp/logs
|
DEAL_LOGS_DIR: /tmp/logs
|
||||||
|
OTEL_EXPORTER_OTLP_ENDPOINT: ${DEAL_OTEL_ENDPOINT:-}
|
||||||
|
OTEL_SERVICE_NAME: ai-service
|
||||||
DEAL_MTLS_ENABLED: ${DEAL_MTLS_ENABLED:-0}
|
DEAL_MTLS_ENABLED: ${DEAL_MTLS_ENABLED:-0}
|
||||||
DEAL_MTLS_CA_PEM: /etc/deal/certs/ca.pem
|
DEAL_MTLS_CA_PEM: /etc/deal/certs/ca.pem
|
||||||
DEAL_MTLS_SERVER_CERT_PFX: /etc/deal/certs/ai-service-server.pfx
|
DEAL_MTLS_SERVER_CERT_PFX: /etc/deal/certs/ai-service-server.pfx
|
||||||
@@ -229,6 +237,8 @@ services:
|
|||||||
DEAL_SERVICE_TOKEN: ${DEAL_SERVICE_TOKEN:?DEAL_SERVICE_TOKEN не задан}
|
DEAL_SERVICE_TOKEN: ${DEAL_SERVICE_TOKEN:?DEAL_SERVICE_TOKEN не задан}
|
||||||
DEAL_ML_DATA_DIR: /data/ml
|
DEAL_ML_DATA_DIR: /data/ml
|
||||||
DEAL_LOGS_DIR: /tmp/logs
|
DEAL_LOGS_DIR: /tmp/logs
|
||||||
|
OTEL_EXPORTER_OTLP_ENDPOINT: ${DEAL_OTEL_ENDPOINT:-}
|
||||||
|
OTEL_SERVICE_NAME: ml-service
|
||||||
DEAL_MTLS_ENABLED: ${DEAL_MTLS_ENABLED:-0}
|
DEAL_MTLS_ENABLED: ${DEAL_MTLS_ENABLED:-0}
|
||||||
DEAL_MTLS_CA_PEM: /etc/deal/certs/ca.pem
|
DEAL_MTLS_CA_PEM: /etc/deal/certs/ca.pem
|
||||||
DEAL_MTLS_SERVER_CERT_PFX: /etc/deal/certs/ml-service-server.pfx
|
DEAL_MTLS_SERVER_CERT_PFX: /etc/deal/certs/ml-service-server.pfx
|
||||||
@@ -245,6 +255,44 @@ services:
|
|||||||
retries: 10
|
retries: 10
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# storage-service — общий gRPC-сервис данных (вложения источников), :5104. Бэкенд — MinIO.
|
||||||
|
# Без host-портов; защита — общий service-token (+ mTLS при DEAL_MTLS_ENABLED=1).
|
||||||
|
storage-service:
|
||||||
|
build:
|
||||||
|
context: ..
|
||||||
|
dockerfile: src/storage-service/Deal.Storage/Dockerfile
|
||||||
|
<<: *service-hardening
|
||||||
|
mem_limit: 512m
|
||||||
|
cpus: 1.0
|
||||||
|
environment:
|
||||||
|
GRPC_PORT: "5104"
|
||||||
|
DEAL_SERVICE_TOKEN: ${DEAL_SERVICE_TOKEN:?DEAL_SERVICE_TOKEN не задан}
|
||||||
|
DEAL_STORAGE_ENDPOINT: minio:9000
|
||||||
|
DEAL_STORAGE_ACCESS_KEY: ${MINIO_ROOT_USER:?MINIO_ROOT_USER не задан}
|
||||||
|
DEAL_STORAGE_SECRET_KEY: ${MINIO_ROOT_PASSWORD:?MINIO_ROOT_PASSWORD не задан}
|
||||||
|
DEAL_STORAGE_BUCKET: deal-attachments
|
||||||
|
DEAL_STORAGE_SECURE: "false"
|
||||||
|
DEAL_LOGS_DIR: /tmp/logs
|
||||||
|
OTEL_EXPORTER_OTLP_ENDPOINT: ${DEAL_OTEL_ENDPOINT:-}
|
||||||
|
OTEL_SERVICE_NAME: storage-service
|
||||||
|
DEAL_MTLS_ENABLED: ${DEAL_MTLS_ENABLED:-0}
|
||||||
|
DEAL_MTLS_CA_PEM: /etc/deal/certs/ca.pem
|
||||||
|
DEAL_MTLS_SERVER_CERT_PFX: /etc/deal/certs/storage-service-server.pfx
|
||||||
|
DEAL_MTLS_SERVER_CERT_PASSWORD: ${DEAL_MTLS_CERT_PASSWORD:-}
|
||||||
|
DEAL_MTLS_CLIENT_CERT_PFX: /etc/deal/certs/deal-client.pfx
|
||||||
|
DEAL_MTLS_CLIENT_CERT_PASSWORD: ${DEAL_MTLS_CERT_PASSWORD:-}
|
||||||
|
volumes:
|
||||||
|
- ${DEAL_CERTS_DIR:-./certs}:/etc/deal/certs:ro
|
||||||
|
depends_on:
|
||||||
|
minio:
|
||||||
|
condition: service_started
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "if [ \"$$DEAL_MTLS_ENABLED\" = \"1\" ]; then /bin/grpc_health_probe -addr=localhost:5104 -tls -tls-ca-cert=/etc/deal/certs/ca.pem -tls-client-cert=/etc/deal/certs/deal-client.crt -tls-client-key=/etc/deal/certs/deal-client.key -tls-server-name=localhost; else /bin/grpc_health_probe -addr=localhost:5104; fi"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 10
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
# caddy — edge: наружу только :80/:443. TLS — плейсхолдер tls internal (см. Caddyfile: домен,
|
# caddy — edge: наружу только :80/:443. TLS — плейсхолдер tls internal (см. Caddyfile: домен,
|
||||||
# реальный сертификат/Cloudflare, CSP/HSTS). Статика — ../src/frontend/dist (СОБРАТЬ ДО up).
|
# реальный сертификат/Cloudflare, CSP/HSTS). Статика — ../src/frontend/dist (СОБРАТЬ ДО up).
|
||||||
caddy:
|
caddy:
|
||||||
@@ -334,6 +382,63 @@ services:
|
|||||||
condition: service_started
|
condition: service_started
|
||||||
prometheus:
|
prometheus:
|
||||||
condition: service_started
|
condition: service_started
|
||||||
|
tempo:
|
||||||
|
condition: service_started
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# ── Трейсы и ресурсы (observability-стек) ─────────────────────────────────
|
||||||
|
# OpenTelemetry Collector — приёмник трейсов Deal-процессов (OTLP gRPC :4317 / HTTP :4318),
|
||||||
|
# батчит и перекладывает в Tempo. Наружу порты не публикуются (внутри compose-сети).
|
||||||
|
otel-collector:
|
||||||
|
image: otel/opentelemetry-collector-contrib:0.160.0
|
||||||
|
profiles: ["observability"]
|
||||||
|
command: ["--config=/etc/otelcol-contrib/config.yaml"]
|
||||||
|
volumes:
|
||||||
|
- ./observability/otel-collector.yml:/etc/otelcol-contrib/config.yaml:ro
|
||||||
|
depends_on:
|
||||||
|
tempo:
|
||||||
|
condition: service_started
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# Tempo — хранилище трейсов (OTLP от коллектора). Retention блоков — 7 суток (см. tempo.yml).
|
||||||
|
tempo:
|
||||||
|
image: grafana/tempo:2.8.1
|
||||||
|
profiles: ["observability"]
|
||||||
|
command: ["-config.file=/etc/tempo.yml"]
|
||||||
|
volumes:
|
||||||
|
- ./observability/tempo.yml:/etc/tempo.yml:ro
|
||||||
|
- deal_tempo_data:/var/tempo
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# cAdvisor — ресурсы контейнеров (CPU/RAM/сеть/диск); scrape — job cadvisor в prometheus.yml.
|
||||||
|
cadvisor:
|
||||||
|
image: gcr.io/cadvisor/cadvisor:v0.52.1
|
||||||
|
profiles: ["observability"]
|
||||||
|
privileged: true
|
||||||
|
devices:
|
||||||
|
- /dev/kmsg:/dev/kmsg
|
||||||
|
volumes:
|
||||||
|
- /:/rootfs:ro
|
||||||
|
- /var/run:/var/run:ro
|
||||||
|
- /sys:/sys:ro
|
||||||
|
- /var/lib/docker/:/var/lib/docker:ro
|
||||||
|
- /dev/disk/:/dev/disk:ro
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# node-exporter — ресурсы хоста (CPU/RAM/диск/сеть); scrape — job node-exporter в prometheus.yml.
|
||||||
|
node-exporter:
|
||||||
|
image: prom/node-exporter:v1.9.1
|
||||||
|
profiles: ["observability"]
|
||||||
|
command:
|
||||||
|
- --path.procfs=/host/proc
|
||||||
|
- --path.sysfs=/host/sys
|
||||||
|
- --path.rootfs=/host/root
|
||||||
|
- --collector.filesystem.mount-points-exclude=^/(sys|proc|dev|host|etc)($|/)
|
||||||
|
volumes:
|
||||||
|
- /proc:/host/proc:ro
|
||||||
|
- /sys:/host/sys:ro
|
||||||
|
- /:/host/root:ro
|
||||||
|
pid: host
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
@@ -343,6 +448,7 @@ volumes:
|
|||||||
deal_ml_data:
|
deal_ml_data:
|
||||||
deal_api_data:
|
deal_api_data:
|
||||||
deal_caddy_data:
|
deal_caddy_data:
|
||||||
|
deal_tempo_data:
|
||||||
deal_caddy_config:
|
deal_caddy_config:
|
||||||
deal_loki_data:
|
deal_loki_data:
|
||||||
deal_promtail_data:
|
deal_promtail_data:
|
||||||
|
|||||||
@@ -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
|
isDefault: true
|
||||||
jsonData:
|
jsonData:
|
||||||
maxLines: 1000
|
maxLines: 1000
|
||||||
|
# Клик по TraceId в логе открывает трейс в Tempo (обогащение логов TraceContextEnricher).
|
||||||
|
derivedFields:
|
||||||
|
- name: TraceID
|
||||||
|
matcherRegex: '"TraceId":"([0-9a-f]+)"'
|
||||||
|
datasourceUid: tempo
|
||||||
|
url: "$${__value.raw}"
|
||||||
|
|
||||||
- name: Prometheus
|
- name: Prometheus
|
||||||
# UID фиксирован: на него ссылаются панели дашборда Deal-Metrics-Overview (datasource uid: prometheus).
|
# UID фиксирован: на него ссылаются панели дашборда Deal-Metrics-Overview (datasource uid: prometheus).
|
||||||
@@ -26,3 +32,24 @@ datasources:
|
|||||||
# Prometheus хранит OTel-гистограммы в нативных bucket'ах — используем нативные histogram_quantile.
|
# Prometheus хранит OTel-гистограммы в нативных bucket'ах — используем нативные histogram_quantile.
|
||||||
httpMethod: POST
|
httpMethod: POST
|
||||||
timeInterval: 15s
|
timeInterval: 15s
|
||||||
|
|
||||||
|
- name: Tempo
|
||||||
|
# UID фиксирован: на него ссылаются панели Deal-Traces и derivedFields логов (datasource uid: tempo).
|
||||||
|
uid: tempo
|
||||||
|
type: tempo
|
||||||
|
access: proxy
|
||||||
|
url: http://tempo:3200
|
||||||
|
isDefault: false
|
||||||
|
jsonData:
|
||||||
|
# Из спана трейса — к логам того же сервиса и traceId (обратная корреляция Loki ↔ Tempo).
|
||||||
|
tracesToLogsV2:
|
||||||
|
datasourceUid: loki
|
||||||
|
filterByTraceID: true
|
||||||
|
filterBySpanID: false
|
||||||
|
spanStartTimeShift: -1m
|
||||||
|
spanEndTimeShift: 1m
|
||||||
|
# Карта сервисов и граф узлов — из метрик Prometheus.
|
||||||
|
serviceMap:
|
||||||
|
datasourceUid: prometheus
|
||||||
|
nodeGraph:
|
||||||
|
enabled: true
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# OpenTelemetry Collector — приёмник трейсов Deal-процессов (профиль observability).
|
||||||
|
#
|
||||||
|
# Процессы шлют OTLP (gRPC :4317 / HTTP :4318) на этот сервис; коллектор батчит и перекладывает
|
||||||
|
# трейсы в Tempo (OTLP). Запускается только профилем observability (deploy/compose.prod.yml);
|
||||||
|
# наружу порты не публикуются (внутри compose-сети).
|
||||||
|
#
|
||||||
|
# Конфиг монтируется в /etc/otelcol-contrib/config.yaml. Health-эндпоинт :13133 — для healthcheck.
|
||||||
|
|
||||||
|
receivers:
|
||||||
|
otlp:
|
||||||
|
protocols:
|
||||||
|
grpc:
|
||||||
|
endpoint: 0.0.0.0:4317
|
||||||
|
http:
|
||||||
|
endpoint: 0.0.0.0:4318
|
||||||
|
|
||||||
|
processors:
|
||||||
|
# Батчинг снижает число сетевых вызовов к Tempo (стандартный приём OTel).
|
||||||
|
batch: {}
|
||||||
|
|
||||||
|
exporters:
|
||||||
|
otlp/tempo:
|
||||||
|
endpoint: tempo:4317
|
||||||
|
tls:
|
||||||
|
insecure: true
|
||||||
|
|
||||||
|
extensions:
|
||||||
|
health_check:
|
||||||
|
endpoint: 0.0.0.0:13133
|
||||||
|
|
||||||
|
service:
|
||||||
|
extensions: [health_check]
|
||||||
|
telemetry:
|
||||||
|
logs:
|
||||||
|
level: warn
|
||||||
|
pipelines:
|
||||||
|
traces:
|
||||||
|
receivers: [otlp]
|
||||||
|
processors: [batch]
|
||||||
|
exporters: [otlp/tempo]
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
# Правила алертов Prometheus по потреблению ресурсов — ОТКЛЮЧЕНЫ ПО УМОЛЧАНИЮ.
|
||||||
|
#
|
||||||
|
# Заготовка: включить, добавив эту строку в `rule_files` файла prometheus.yml:
|
||||||
|
# rule_files:
|
||||||
|
# - prometheus-rules.yml
|
||||||
|
# - prometheus-resource-rules.yml
|
||||||
|
#
|
||||||
|
# Пороги выносятся в env (DEAL_ALERT_*); Prometheus не раскрывает значения env в конфиге, поэтому при
|
||||||
|
# включении файл рендерится из шаблона (подстановка порогов) или пороги проставляются вручную:
|
||||||
|
# DEAL_ALERT_HOST_CPU_PERCENT — загрузка CPU хоста, % (дефолт 90)
|
||||||
|
# DEAL_ALERT_HOST_MEMORY_PERCENT — занятая память хоста, % (дефолт 90)
|
||||||
|
# DEAL_ALERT_HOST_DISK_PERCENT — минимум свободного места, % (дефолт 15)
|
||||||
|
# DEAL_ALERT_CONTAINER_CPU — CPU контейнера (ядра) (дефолт 1.5)
|
||||||
|
# DEAL_ALERT_CONTAINER_MEMORY_PERCENT — память контейнера к лимиту, %(дефолт 90)
|
||||||
|
#
|
||||||
|
# Источники: node-exporter (хост) и cAdvisor (контейнеры), scrape — jobs node-exporter/cadvisor.
|
||||||
|
|
||||||
|
groups:
|
||||||
|
- name: deal-resources
|
||||||
|
rules:
|
||||||
|
# Хост: загрузка CPU. Дефолт порога — 90%.
|
||||||
|
- alert: DealHostHighCpu
|
||||||
|
expr: 100 - (avg(rate(node_cpu_seconds_total{mode="idle"}[5m])) * 100) > 90
|
||||||
|
for: 10m
|
||||||
|
labels:
|
||||||
|
severity: warning
|
||||||
|
annotations:
|
||||||
|
summary: "Высокая загрузка CPU хоста"
|
||||||
|
description: "Средняя загрузка CPU хоста выше 90% за 5 минут более 10 минут."
|
||||||
|
|
||||||
|
# Хост: занятая память. Дефолт порога — 90%.
|
||||||
|
- alert: DealHostHighMemory
|
||||||
|
expr: (1 - node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes) > 0.9
|
||||||
|
for: 10m
|
||||||
|
labels:
|
||||||
|
severity: warning
|
||||||
|
annotations:
|
||||||
|
summary: "Высокое потребление памяти хостом"
|
||||||
|
description: "Свободной памяти меньше 10% более 10 минут."
|
||||||
|
|
||||||
|
# Хост: свободное место на разделе. Дефолт порога — 15%.
|
||||||
|
- alert: DealHostDiskLow
|
||||||
|
expr: |
|
||||||
|
min by (mountpoint) (node_filesystem_avail_bytes{fstype!~"tmpfs|overlay"} / node_filesystem_size_bytes{fstype!~"tmpfs|overlay"}) < 0.15
|
||||||
|
for: 15m
|
||||||
|
labels:
|
||||||
|
severity: warning
|
||||||
|
annotations:
|
||||||
|
summary: "Мало места на диске ({{ $labels.mountpoint }})"
|
||||||
|
description: "Свободно менее 15% на разделе {{ $labels.mountpoint }} более 15 минут."
|
||||||
|
|
||||||
|
# Контейнеры Deal: CPU (ядра). Дефолт порога — 1.5.
|
||||||
|
- alert: DealContainerHighCpu
|
||||||
|
expr: |
|
||||||
|
sum by (name) (rate(container_cpu_usage_seconds_total{name=~".*deal.*|.*core.*|.*service.*"}[5m])) > 1.5
|
||||||
|
for: 10m
|
||||||
|
labels:
|
||||||
|
severity: warning
|
||||||
|
annotations:
|
||||||
|
summary: "Высокий CPU контейнера {{ $labels.name }}"
|
||||||
|
description: "Контейнер {{ $labels.name }} держит > 1.5 CPU за 5 минут более 10 минут."
|
||||||
|
|
||||||
|
# Контейнеры Deal: память к лимиту. Дефолт порога — 90%.
|
||||||
|
- alert: DealContainerHighMemory
|
||||||
|
expr: |
|
||||||
|
(container_memory_working_set_bytes{name=~".*deal.*|.*core.*|.*service.*"}
|
||||||
|
/ clamp_min(container_spec_memory_limit_bytes{name=~".*deal.*|.*core.*|.*service.*"}, 1)) > 0.9
|
||||||
|
for: 10m
|
||||||
|
labels:
|
||||||
|
severity: warning
|
||||||
|
annotations:
|
||||||
|
summary: "Контейнер {{ $labels.name }} близок к лимиту памяти"
|
||||||
|
description: "Контейнер {{ $labels.name }} использует более 90% лимита памяти более 10 минут."
|
||||||
@@ -79,3 +79,4 @@ groups:
|
|||||||
annotations:
|
annotations:
|
||||||
summary: "Растёт очередь обучения ML (outbox)"
|
summary: "Растёт очередь обучения ML (outbox)"
|
||||||
description: "Суммарная глубина MlOutbox держится выше 100 более 15 минут ({{ $value }})."
|
description: "Суммарная глубина MlOutbox держится выше 100 более 15 минут ({{ $value }})."
|
||||||
|
|
||||||
|
|||||||
@@ -46,3 +46,18 @@ scrape_configs:
|
|||||||
- targets: ["ml-service:9464"]
|
- targets: ["ml-service:9464"]
|
||||||
labels:
|
labels:
|
||||||
service: ml-service
|
service: ml-service
|
||||||
|
|
||||||
|
# Ресурсы контейнеров (cAdvisor) и хоста (node-exporter) — этап «observability-стек».
|
||||||
|
# cAdvisor отдаёт метрики контейнеров (CPU/RAM/сеть/диск), node-exporter — хоста.
|
||||||
|
- job_name: cadvisor
|
||||||
|
static_configs:
|
||||||
|
- targets: ["cadvisor:8080"]
|
||||||
|
|
||||||
|
- job_name: node-exporter
|
||||||
|
static_configs:
|
||||||
|
- targets: ["node-exporter:9100"]
|
||||||
|
|
||||||
|
# Темпо (хранилище трейсов) — само-мониторинг: метрики приёма/отдачи трейсов.
|
||||||
|
- job_name: tempo
|
||||||
|
static_configs:
|
||||||
|
- targets: ["tempo:3200"]
|
||||||
|
|||||||
@@ -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
|
||||||
+9
-10
@@ -136,17 +136,19 @@
|
|||||||
| `POST /dialogs/{dialog_id}/backfill` | Догнать сообщения одного диалога | — | `{ok: true, processed: int}` *(фронт не вызывает — только сервер)* |
|
| `POST /dialogs/{dialog_id}/backfill` | Догнать сообщения одного диалога | — | `{ok: true, processed: int}` *(фронт не вызывает — только сервер)* |
|
||||||
| `POST /dialogs/preview` | Последние сообщения диалога (свежие из TG, старые из БД) | `{dialogId, limit?=24 (clamp 1..50)}` | `{items: [§4.11 сообщение]}` |
|
| `POST /dialogs/preview` | Последние сообщения диалога (свежие из TG, старые из БД) | `{dialogId, limit?=24 (clamp 1..50)}` | `{items: [§4.11 сообщение]}` |
|
||||||
|
|
||||||
### 3.4 Settings / rates / meta (settings_routes.py) — 6
|
### 3.4 Settings / rates / meta (settings_routes.py) — 5
|
||||||
|
|
||||||
| METHOD /api/… | Назначение | Request body | Response |
|
| METHOD /api/… | Назначение | Request body | Response |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `GET /settings` | Публичные настройки (секреты замаскированы) | — | §4.6 (полный settings) |
|
| `GET /settings` | Публичные настройки (секреты замаскированы) | — | §4.6 (полный settings) |
|
||||||
| `PATCH /settings` | Частичное обновление (см. §4.6 список ключей). Инварианты: `archiveAfterDays` 1..30, `minLen` 10..500, `discJoinLimit` 1..200, `discJoinDelayMin/Max` 5..600 (min≤max), `discEvalSample` 3..30, `discEvalThreshold` 1..100; `aiConfigs` apiKey ≥8 → шифруется; `myPrompts` ≤100. ⚠ `tgKeys` удалён из настроек тенанта — ключи Telegram задаёт оператор глобально. ⚠ Ответ — **весь** public settings (фронт затирает локальное состояние ответом) | произвольный dict из публичных ключей | §4.6 |
|
| `PATCH /settings` | Частичное обновление (см. §4.6 список ключей). Инварианты: `archiveAfterDays` 1..30, `minLen` 10..500, `discJoinLimit` 1..200, `discJoinDelayMin/Max` 5..600 (min≤max), `discEvalSample` 3..30, `discEvalThreshold` 1..100; `myPrompts` ≤100. ⚠ `tgKeys`, `aiProvider` и `aiConfigs` удалены из настроек тенанта — ключи Telegram и конфигурацию ИИ задаёт оператор глобально. ⚠ Ответ — **весь** public settings (фронт затирает локальное состояние ответом) | произвольный dict из публичных ключей | §4.6 |
|
||||||
| `POST /ai/check` | Проверка подключения AI-провайдера | — | `{ok: bool, message: str}` + поля статуса провайдера |
|
|
||||||
| `GET /rates` | Курсы валют | — | `{base: "RUB", rates: {CODE: num}, source: "cbr"\|"mock", updatedAt: ms\|null}` |
|
| `GET /rates` | Курсы валют | — | `{base: "RUB", rates: {CODE: num}, source: "cbr"\|"mock", updatedAt: ms\|null}` |
|
||||||
| `POST /rates/refresh` | Принудительно обновить курсы (ЦБ/мок) | — | `{ok: bool, rates: {base, rates, source, updatedAt}}` — ⚠ фронт передаёт `r.rates` в `applyRates` |
|
| `POST /rates/refresh` | Принудительно обновить курсы (ЦБ/мок) | — | `{ok: bool, rates: {base, rates, source, updatedAt}}` — ⚠ фронт передаёт `r.rates` в `applyRates` |
|
||||||
| `GET /meta/constants` | Валюты/стадии/палитра | — | `{currencies: [{code,name,symbol}], stages: [§4.4], palette: ["#…"]}` *(фронт не вызывает — зашиты в data.js)* |
|
| `GET /meta/constants` | Валюты/стадии/палитра | — | `{currencies: [{code,name,symbol}], stages: [§4.4], palette: ["#…"]}` *(фронт не вызывает — зашиты в data.js)* |
|
||||||
|
|
||||||
|
⚠ `POST /ai/check` удалён: проверка связи с провайдером ИИ теперь операторская —
|
||||||
|
`POST /api/operator/settings/ai-config/check` (см. `2026-09-10-operator-analytics-contract.md`).
|
||||||
|
|
||||||
### 3.5 Детальные операции карточки (бывший Projects, projects_routes.py) — 15
|
### 3.5 Детальные операции карточки (бывший Projects, projects_routes.py) — 15
|
||||||
|
|
||||||
Все операции — над ресурсом `/api/cards/{cardId}` (см. §4.1); отдельного `/api/projects` больше нет.
|
Все операции — над ресурсом `/api/cards/{cardId}` (см. §4.1); отдельного `/api/projects` больше нет.
|
||||||
@@ -347,14 +349,12 @@ links/files/history/tzText/reminder` — модули; `isNew/prevCol/isVacancy/
|
|||||||
"discJoinLimit": 50, "discJoinDelayMin": 50, "discJoinDelayMax": 70,
|
"discJoinLimit": 50, "discJoinDelayMin": 50, "discJoinDelayMax": 70,
|
||||||
"discEvalSample": 10, "discEvalThreshold": 40, // (не используется фронтом)
|
"discEvalSample": 10, "discEvalThreshold": 40, // (не используется фронтом)
|
||||||
"discPaused": false, "colState": {}, // colState — то же, что GET /columns/state
|
"discPaused": false, "colState": {}, // colState — то же, что GET /columns/state
|
||||||
"aiProvider": "deepseek",
|
|
||||||
"aiConfigs": { "deepseek": {"baseUrl": "https://api.deepseek.com", "model": "…", "keySet": true, "keyMasked": "sk-12…3456"} },
|
|
||||||
"providers": [{"id":"deepseek","name":"DeepSeek","base":"…","local":false,"models":[…]}, …]
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
Ключи, которые фронт шлёт в PATCH (по одному/группами): `aiProvider`, `aiConfigs{<id>:{baseUrl,model,apiKey?}}`, `aiPrompt`, `cardPrompt`, `aiFilterPrompt`, `stopPhrases`, `domainDescription`, `domainKeywords`, `hireMarkers`, `levelTerms`, `resumeMarkers`, `blockResumes`, `myPrompts`, `autoArchive`, `archiveAfterDays`, `aiEnabled`, `aiFilterEnabled`, `minLen`, `conversionOn`, `targetCurrency`, `rateSource`, `remindersEnabled`, `mlEnabled`, `wantedType`, `budgetRequiredHire`, `budgetRequiredOrder`, `hireLabel`, `orderLabel`, `autoMonitorNew`, `discJoinLimit`, `discJoinDelayMin`, `discJoinDelayMax`, `discPaused`.
|
Ключи, которые фронт шлёт в PATCH (по одному/группами): `aiPrompt`, `cardPrompt`, `aiFilterPrompt`, `stopPhrases`, `domainDescription`, `domainKeywords`, `hireMarkers`, `levelTerms`, `resumeMarkers`, `blockResumes`, `myPrompts`, `autoArchive`, `archiveAfterDays`, `aiEnabled`, `aiFilterEnabled`, `minLen`, `conversionOn`, `targetCurrency`, `rateSource`, `remindersEnabled`, `mlEnabled`, `wantedType`, `budgetRequiredHire`, `budgetRequiredOrder`, `hireLabel`, `orderLabel`, `autoMonitorNew`, `discJoinLimit`, `discJoinDelayMin`, `discJoinDelayMax`, `discPaused`.
|
||||||
⚠ Ответ PATCH — **полный** settings: `schedulePersist`/`saveAiSettings`/`saveDiscQuota` применяют его целиком к локальному state (источник истины после клампов).
|
⚠ Ответ PATCH — **полный** settings: `schedulePersist`/`saveDiscQuota` применяют его целиком к локальному state (источник истины после клампов).
|
||||||
⚠ **Изменение (решение владельца, вариант A):** ключей Telegram (`api_id`/`api_hash`) в настройках тенанта больше нет — они задаются оператором глобально (ТЗ §4.1/§8.1), см. `docs/architecture/2026-09-10-operator-analytics-contract.md` (раздел «Операторские настройки»). Вкладка Telegram у тенанта остаётся (подключение аккаунта, `GET /api/tg/status`).
|
⚠ **Изменение (решение владельца, вариант A):** ключей Telegram (`api_id`/`api_hash`) в настройках тенанта больше нет — они задаются оператором глобально (ТЗ §4.1/§8.1), см. `docs/architecture/2026-09-10-operator-analytics-contract.md` (раздел «Операторские настройки»). Вкладка Telegram у тенанта остаётся (подключение аккаунта, `GET /api/tg/status`).
|
||||||
|
⚠ **Изменение (2026-09-14):** настройки ИИ-провайдера (`aiProvider`, `aiConfigs`, `providers`) из настроек тенанта убраны — провайдера, модель, адрес и ключ задаёт оператор в консоли (раздел «ИИ», `GET/PUT /api/operator/settings/ai-config`); все ИИ-вызовы всех пользователей идут на эту конфигурацию.
|
||||||
|
|
||||||
### 4.7 Промпты
|
### 4.7 Промпты
|
||||||
- `aiPrompt`, `aiFilterPrompt`, `cardPrompt` — plain string, редактируются на вкладке ИИ; содержат плейсхолдеры `{domain}`/`{keywords}`.
|
- `aiPrompt`, `aiFilterPrompt`, `cardPrompt` — plain string, редактируются на вкладке ИИ; содержат плейсхолдеры `{domain}`/`{keywords}`.
|
||||||
@@ -381,7 +381,6 @@ links/files/history/tzText/reminder` — модули; `isNew/prevCol/isVacancy/
|
|||||||
`{passed, wouldCreateCard, targetContainer, matchHits, parsed, stages:[{stage, pass, skipped, reason, kw, label}]}`.
|
`{passed, wouldCreateCard, targetContainer, matchHits, parsed, stages:[{stage, pass, skipped, reason, kw, label}]}`.
|
||||||
Коды `stage`: `length|stop|resume|type|exclude|ml|ai|spam_ai|budget`; `skipped=true` — этап выключен
|
Коды `stage`: `length|stop|resume|type|exclude|ml|ai|spam_ai|budget`; `skipped=true` — этап выключен
|
||||||
настройкой. `parsed` — разбор текста (поля карточки) либо null. Запись в систему не производится.
|
настройкой. `parsed` — разбор текста (поля карточки) либо null. Запись в систему не производится.
|
||||||
- **`POST /api/ai/check`**: `{ok: bool, message: string, local?, keySet?}`.
|
|
||||||
- Комментарии карточки: `{id, by: string, text, time: string}` — `by` всегда «Вы», `time` «только что».
|
- Комментарии карточки: `{id, by: string, text, time: string}` — `by` всегда «Вы», `time` «только что».
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -400,7 +399,7 @@ links/files/history/tzText/reminder` — модули; `isNew/prevCol/isVacancy/
|
|||||||
|---|---:|
|
|---|---:|
|
||||||
| Auth `/api/auth` | 4 |
|
| Auth `/api/auth` | 4 |
|
||||||
| Telegram `/api/tg` | 14 |
|
| Telegram `/api/tg` | 14 |
|
||||||
| Settings/rates/meta (`/api/settings`, `/api/ai/check`, `/api/rates`) | 5 |
|
| Settings/rates/meta (`/api/settings`, `/api/rates`) | 4 |
|
||||||
| Processing `/api/pipeline` (+ `/api/admin/check-message`) | 7 |
|
| Processing `/api/pipeline` (+ `/api/admin/check-message`) | 7 |
|
||||||
| ML `/api/ml` | 5 |
|
| ML `/api/ml` | 5 |
|
||||||
| Discovery `/api/discovery` | 13 |
|
| Discovery `/api/discovery` | 13 |
|
||||||
|
|||||||
@@ -149,8 +149,14 @@
|
|||||||
"actorType": "tenant",
|
"actorType": "tenant",
|
||||||
"actorId": "1f2e3d4c-5b6a-7980-1234-56789abcdef0",
|
"actorId": "1f2e3d4c-5b6a-7980-1234-56789abcdef0",
|
||||||
"tenantId": "aabbccdd-eeff-0011-2233-445566778899",
|
"tenantId": "aabbccdd-eeff-0011-2233-445566778899",
|
||||||
|
"userName": "owner@example.com",
|
||||||
|
"tenantName": "ООО «Ромашка»",
|
||||||
"ip": "203.0.113.7",
|
"ip": "203.0.113.7",
|
||||||
"detailJson": "{\"cardId\":\"c_1a2b3c4d5e6f\",\"to\":\"planned\"}",
|
"changes": [
|
||||||
|
{ "field": "cardId", "to": "c_1a2b3c4d5e6f" },
|
||||||
|
{ "field": "to", "to": "planned" }
|
||||||
|
],
|
||||||
|
"detailJson": "{\"changes\":[{\"field\":\"cardId\",\"to\":\"c_1a2b3c4d5e6f\"},{\"field\":\"to\",\"to\":\"planned\"}]}",
|
||||||
"at": "2026-09-10T15:22:46.123Z",
|
"at": "2026-09-10T15:22:46.123Z",
|
||||||
"id": 1042
|
"id": 1042
|
||||||
}
|
}
|
||||||
@@ -162,7 +168,19 @@
|
|||||||
```
|
```
|
||||||
|
|
||||||
- `items` — новые сверху (`at` DESC). `total` — полное число по фильтру (без `limit`/`offset`).
|
- `items` — новые сверху (`at` DESC). `total` — полное число по фильтру (без `limit`/`offset`).
|
||||||
- `detailJson` — **строка** JSON деталей события (без секретов), может быть `null`.
|
- `userName` — логин реального пользователя (владелец пространства либо логин попытки); `null`, если не разрешён.
|
||||||
|
- `tenantName` — имя пространства события (join с реестром); `null`, если не разрешено.
|
||||||
|
- `changes` — человекочитаемые изменения параметров события (см. «Детали события»); `[]`, если деталей нет.
|
||||||
|
- `detailJson` — **строка** сырого JSON деталей события (без секретов) для спойлера; может быть `null`.
|
||||||
|
|
||||||
|
### Детали события (`changes`)
|
||||||
|
|
||||||
|
Детали события хранятся как JSON вида `{ "changes": [ { "field": "<код>", "from": "<было>", "to": "<стало>" } ] }`,
|
||||||
|
где `field` — стабильный код параметра, `from` — предыдущее значение (`null` — параметр задан впервые),
|
||||||
|
`to` — новое. Сериализация деталей — `AuditService.ToDetailJson`, форма изменения — `AuditDetails.Set`/
|
||||||
|
`AuditDetails.Change`. Ядро отдаёт `changes` как есть; человекочитаемые названия событий, акторов, параметров
|
||||||
|
и значений — в ресурсах интерфейса (`ru.js`: `operator.event`/`operator.actor`/`operator.field`/`operator.value`).
|
||||||
|
Прежние записи плоского формата (`{ "login": "..." }`, пары `old*`/`new*`) читаются обратно совместимо.
|
||||||
|
|
||||||
**Коды**: `200`, `401`.
|
**Коды**: `200`, `401`.
|
||||||
|
|
||||||
@@ -250,3 +268,73 @@
|
|||||||
> Примечание для вкладки Telegram у тенанта: `GET /api/tg/status` остаётся (подключение аккаунта),
|
> Примечание для вкладки Telegram у тенанта: `GET /api/tg/status` остаётся (подключение аккаунта),
|
||||||
> поле `keysSet` отражает глобальные ключи; команды `start-phone`/`start-qr` без ключей отвечают
|
> поле `keysSet` отражает глобальные ключи; команды `start-phone`/`start-qr` без ключей отвечают
|
||||||
> `400 { "detail": "Ключи Telegram не заданы оператором" }`.
|
> `400 { "detail": "Ключи Telegram не заданы оператором" }`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Операторские настройки: глобальная конфигурация ИИ
|
||||||
|
|
||||||
|
Провайдер, модель, адрес API и ключ ИИ задаёт оператор **глобально**, едины для всех тенантов
|
||||||
|
(в настройках тенанта ключей `aiProvider`/`aiConfigs` больше нет). Хранилище — системная таблица
|
||||||
|
`public.global_settings` (ключ `aiConfig`), `apiKey` хранится зашифрованным (`enc:`) и наружу
|
||||||
|
не отдаётся. На эту конфигурацию работают все ИИ-вызовы всех тенантов: классификация, фильтр,
|
||||||
|
ключи поиска, карточки (`AiProviderConfigBuilder`).
|
||||||
|
|
||||||
|
### GET /api/operator/settings/ai-config
|
||||||
|
|
||||||
|
Маскированный снимок конфигурации вместе с каталогом провайдеров для выбора.
|
||||||
|
|
||||||
|
**200**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"providerId": "deepseek",
|
||||||
|
"baseUrl": "https://api.deepseek.com",
|
||||||
|
"model": "deepseek-v4-flash",
|
||||||
|
"keySet": true,
|
||||||
|
"keyMasked": "sk-o…-123",
|
||||||
|
"providers": [
|
||||||
|
{ "id": "deepseek", "name": "DeepSeek", "base": "https://api.deepseek.com", "local": false, "models": ["…"] }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `providerId` — пусто, если конфигурацию ещё не задавали.
|
||||||
|
- `baseUrl`/`model` — эффективные значения (адрес каталога, первая модель каталога), если не переопределены.
|
||||||
|
- `keyMasked` — **маска** (пусто / `x…` / `1234…5678`); открытый ключ не возвращается никогда.
|
||||||
|
- `providers` — каталог `AiProviders` (`id`/`name`/`base`/`local`/`models`); адрес каталогных облачных
|
||||||
|
провайдеров фиксирован (SSRF-гейт), свой `baseUrl` задаётся только локальным и `custom`.
|
||||||
|
|
||||||
|
**Коды**: `200`, `401`.
|
||||||
|
|
||||||
|
### PUT /api/operator/settings/ai-config
|
||||||
|
|
||||||
|
Сохранение/смена. Поля можно передавать **по отдельности** (частичное обновление): непереданное поле
|
||||||
|
(`null` или отсутствие в JSON) сохраняет текущее значение. Если конфигурации ещё нет, `providerId` обязателен.
|
||||||
|
При смене провайдера `baseUrl`/`model`/`apiKey` не переносятся от старого (дефолты каталога); ключ
|
||||||
|
меняется только при явной передаче (маска не принимается).
|
||||||
|
|
||||||
|
**Тело**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "providerId": "deepseek", "model": "deepseek-v4-pro", "apiKey": "sk-…" }
|
||||||
|
```
|
||||||
|
|
||||||
|
**200** — маскированный снимок (форма как у GET).
|
||||||
|
|
||||||
|
**Ошибки**
|
||||||
|
|
||||||
|
- `400 { "detail": "Укажите хотя бы одно поле (providerId, baseUrl, model, apiKey)" }`
|
||||||
|
- `400 { "detail": "Провайдер не из списка разрешённых" }`
|
||||||
|
- `400 { "detail": "Конфигурация ИИ ещё не задана — укажите providerId" }`
|
||||||
|
- `400 { "detail": "Укажите model — у выбранного провайдера нет моделей по умолчанию" }`
|
||||||
|
- `400 { "detail": "API-ключ должен быть не короче 8 символов, без маски" }`
|
||||||
|
- `401 { "detail": "Требуется вход оператора" }`
|
||||||
|
|
||||||
|
### POST /api/operator/settings/ai-config/check
|
||||||
|
|
||||||
|
Проверка связи с сохранённым провайдером (`200` — результат `AiCheckResultDto`). Локальный провайдер
|
||||||
|
отвечает `ok: true` без HTTP; облачный — запрос к списку моделей (приватные адреса запрещены, SSRF-гейт).
|
||||||
|
`400 { "detail": "Сначала сохраните конфигурацию ИИ" }` — конфигурации ещё нет.
|
||||||
|
|
||||||
|
**Аудит**: событие `ai_config_changed` (актор `operator`, `tenantId: null`, детали
|
||||||
|
`{providerId, baseUrl, model, keySet}` — без ключа).
|
||||||
|
|||||||
@@ -103,6 +103,25 @@
|
|||||||
- Асинхронность: суффикс `Async`, `CancellationToken` пробрасывать до конца; `.Result` / `.Wait()`
|
- Асинхронность: суффикс `Async`, `CancellationToken` пробрасывать до конца; `.Result` / `.Wait()`
|
||||||
запрещены — только `await`.
|
запрещены — только `await`.
|
||||||
|
|
||||||
|
### 4.1. Фабрики и билдеры
|
||||||
|
|
||||||
|
- **Нетривиальные объекты с интерфейсом создаются только фабриками.** Реализация сервиса/адаптера,
|
||||||
|
у которого есть порт-интерфейс, не создаётся прямым `new` в прикладном коде или композиционном корне —
|
||||||
|
только внутри фабрики. Форма пары: `IXxxFactory` (порт фабрики) + `XxxFactory` (реализация), метод
|
||||||
|
`Create(...)` возвращает **интерфейс** готового объекта (`ISecretCipher`, `IFileStorage`, …).
|
||||||
|
- Фабрика сама регистрируется в DI (`AddScoped`/`AddSingleton<IXxxFactory, XxxFactory>()`) — контейнер
|
||||||
|
конструирует её без `new`; зависимости фабрики — тоже DI.
|
||||||
|
- **Билдер** (`IXxxBuilder`/`XxxBuilder`) добавляется, когда объект собирается итеративно из многих частей
|
||||||
|
или опций; фабрика делегирует сборку билдеру, а не повторяет её.
|
||||||
|
- Исключения из правила (прямой `new` допустим):
|
||||||
|
- DTO, рекорды, value-объекты, `Options`/`Settings`-снимки;
|
||||||
|
- исключения (`*Exception`) и примитивы/BCL-типы (`StringBuilder`, `NpgsqlConnection`, `MinioClient`, …);
|
||||||
|
- статические классы и хэлперы без состояния (фабрику для них не заводим);
|
||||||
|
- EF-конфигурации (`IEntityTypeConfiguration`) — это метаданные модели, а не прикладные объекты;
|
||||||
|
- обёртки ресурсов без порт-интерфейса (gRPC-соединения с `IDisposable`);
|
||||||
|
- объекты без порт-интерфейса, создаваемые контейнером (`AddScoped<Concrete>()`).
|
||||||
|
- Тесты могут конструировать проверяемый тип прямым `new` — это часть самого теста, а не прикладного кода.
|
||||||
|
|
||||||
## 5. Комментирование кода
|
## 5. Комментирование кода
|
||||||
|
|
||||||
Все комментарии — на русском языке.
|
Все комментарии — на русском языке.
|
||||||
@@ -123,6 +142,8 @@
|
|||||||
- **`<param>`/`<returns>`** — только если смысл не очевиден из имени/типа; не переписывать сигнатуру.
|
- **`<param>`/`<returns>`** — только если смысл не очевиден из имени/типа; не переписывать сигнатуру.
|
||||||
- **`<summary>` — только блочный.** Открывающий `<summary>` и закрывающий `</summary>` — **каждый на
|
- **`<summary>` — только блочный.** Открывающий `<summary>` и закрывающий `</summary>` — **каждый на
|
||||||
своей строке**; запись в одну строку (`/// <summary>текст</summary>`) **не допускается**. **[изм.]**
|
своей строке**; запись в одну строку (`/// <summary>текст</summary>`) **не допускается**. **[изм.]**
|
||||||
|
- **Конструкторы не документируем** — `<summary>`/`<param>` на них не нужны: назначение очевидно из
|
||||||
|
типа и сигнатуры. В частности, не документируем конструкторы классов, реализующих интерфейс. **[изм.]**
|
||||||
|
|
||||||
Правильно:
|
Правильно:
|
||||||
```csharp
|
```csharp
|
||||||
@@ -221,21 +242,37 @@
|
|||||||
|
|
||||||
- `try-catch` — только для непредвиденных ошибок, не для управления ходом программы.
|
- `try-catch` — только для непредвиденных ошибок, не для управления ходом программы.
|
||||||
- При пробрасывании выше — `throw;`, а **не** `throw ex;`.
|
- При пробрасывании выше — `throw;`, а **не** `throw ex;`.
|
||||||
- Свои исключения наследовать от `Exception`.
|
- **Свои доменные исключения наследовать от `DealException`** (`Deal.SharedKernel.Errors`) — базовый тип
|
||||||
|
хранит код ошибки (`ErrorCode`) и умеет брать текст из ресурсов. Состав: `NotFoundException`,
|
||||||
|
`ValidationException`, `ConflictException`, `ServiceUnavailableException`; новые — по тому же образцу.
|
||||||
|
- **Не возвращать `null` как штатный результат «не найдено»/ошибки.** Доменный сервис, у которого объект
|
||||||
|
не найден, бросает `NotFoundException` (эндпоинт отдаёт 404 через общий обработчик, а не проверкой
|
||||||
|
`is null` в каждом хендлере). `null` допустим только для **опциональных значений** — парсеры/извлечение
|
||||||
|
полей, выборки-запросы («нет строки» — нормальный результат), `Try*`-паттерн; такие методы должны быть
|
||||||
|
nullable-аннотированы и явно описаны в XML-doc.
|
||||||
- Исключение создавать всегда, когда функция не может быть выполнена (неверные параметры, нет доступа к
|
- Исключение создавать всегда, когда функция не может быть выполнена (неверные параметры, нет доступа к
|
||||||
БД, неизвестные идентификаторы и т.п.).
|
БД, неизвестные идентификаторы и т.п.).
|
||||||
- Все исключения должны быть залогированы или показаны пользователю; пустые `catch` запрещены.
|
- Все исключения должны быть залогированы или показаны пользователю; **пустые `catch` запрещены**.
|
||||||
- В лог об ошибке, как правило, писать `StackTrace`.
|
- **Единый формат лога ошибки:** понятный русский текст + структурированный контекст (операция, `tenantId`,
|
||||||
|
id сущности, `traceId`). Стектрейс пишется **только в лог**; в ответ/сообщение клиенту он не попадает —
|
||||||
|
наружу отдаётся обобщённый текст и код (обработчики на границах: `DealExceptionHandler`, gRPC-интерцептор).
|
||||||
|
- **Тексты исключений/ошибок не хардкодить** — держать в ресурсах (`ErrorMessages.resx`, доступ через
|
||||||
|
`ErrorResources.Format(ErrorResourceKeys.*)` и шаблоны `DealException`), чтобы переводы добавлялись
|
||||||
|
отдельной культурой (`.resx`-спутник) без правок кода.
|
||||||
|
|
||||||
## 11. Интерфейсы
|
## 11. Интерфейсы
|
||||||
|
|
||||||
- **Не дублировать `<summary>` интерфейса в реализации.** Если член объявлен в интерфейсе с XML-doc,
|
- **В реализациях интерфейсов XML-doc не пишем вообще.** Если тип или член объявлен в интерфейсе,
|
||||||
в классе-реализации достаточно `/// <inheritdoc/>` (или вообще ничего, если doc наследуется настройкой).
|
класс-реализация не документируется: ни `<summary>`, ни `<inheritdoc/>` (и ни `<param>` на
|
||||||
Текст описания пишется **один раз** — у интерфейса.
|
конструкторе). Описание живёт **один раз** — в интерфейсе; реализации вызываются только через порт.
|
||||||
|
Под этот запрет попадает и сам класс-реализация (его `<summary>` тоже лишний — есть у интерфейса).
|
||||||
|
- **XML-doc уместен только там, где нет интерфейса:** public-типы/члены без порта (статика, константы,
|
||||||
|
extension-классы), `protected`-члены и DTO/модели. **[изм. 2026-09-13, решение владельца]**
|
||||||
- **Явная реализация интерфейсов — по умолчанию** (`Task ICardStore.GetAsync(...)`). **[изм. 2026-09-11,
|
- **Явная реализация интерфейсов — по умолчанию** (`Task ICardStore.GetAsync(...)`). **[изм. 2026-09-11,
|
||||||
решение владельца]** Классы напрямую не вызываются — только через интерфейсы; исключения: DTO/модели
|
решение владельца]** Классы напрямую не вызываются — только через интерфейсы; исключения: DTO/модели
|
||||||
(напр. `Card` и семейство `I*Card`), хелперы, extension-классы. Весь прод-код уже переведён на явные
|
(напр. `Card` и семейство `I*Card`), хелперы, extension-классы. Весь прод-код уже переведён на явные
|
||||||
реализации (codemod `scripts/make_explicit.py`, идемпотентный).
|
реализации (codemod'ы `scripts/make_explicit.py` и `scripts/strip_implementation_docs.py` — идемпотентны,
|
||||||
|
`--apply` применяет правки, без флага — dry-run-отчёт).
|
||||||
- Один публичный тип интерфейса = один файл (как и для классов); имя файла = имя типа.
|
- Один публичный тип интерфейса = один файл (как и для классов); имя файла = имя типа.
|
||||||
- **Маркерные классы не используются** — если нужен маркер, это маркерный интерфейс
|
- **Маркерные классы не используются** — если нужен маркер, это маркерный интерфейс
|
||||||
(`IKanbanModule`, `ISharedKernel` и т.п.). **[изм. 2026-09-11]**
|
(`IKanbanModule`, `ISharedKernel` и т.п.). **[изм. 2026-09-11]**
|
||||||
|
|||||||
@@ -189,9 +189,9 @@ Telegram-аккаунт, выбирает каналы/группы для мо
|
|||||||
## 8. Настройки тенанта
|
## 8. Настройки тенанта
|
||||||
|
|
||||||
- Telegram: ключи приложения (оператор), подключение аккаунта, авто-мониторинг новых.
|
- Telegram: ключи приложения (оператор), подключение аккаунта, авто-мониторинг новых.
|
||||||
- ИИ: провайдер (один; включая локальные), модель, ключ (хранится зашифрованно),
|
- ИИ: провайдер (один; включая локальные), модель и ключ задаёт **оператор** глобально (едины для всех
|
||||||
промпты (базовый + свой), библиотека готовых промптов по сферам + «мои промпты»,
|
тенантов; в консоли оператора раздел «ИИ», ключ хранится зашифрованно); пользователю — промпты
|
||||||
вкл/выкл ИИ, вкл/выкл ИИ-фильтр.
|
(базовый + свой), библиотека готовых промптов по сферам + «мои промпты», вкл/выкл ИИ, вкл/выкл ИИ-фильтр.
|
||||||
- ML: вкл/выкл, обучение на действиях, проверка на сообщении/канале, сброс, самооценка
|
- ML: вкл/выкл, обучение на действиях, проверка на сообщении/канале, сброс, самооценка
|
||||||
(«ML справляется с последними N сообщениями — ИИ можно отключить»).
|
(«ML справляется с последними N сообщениями — ИИ можно отключить»).
|
||||||
- Обработка: стоп-фразы, длина, резюме, тип заявки, домен/ключи, маркеры найма/заказа.
|
- Обработка: стоп-фразы, длина, резюме, тип заявки, домен/ключи, маркеры найма/заказа.
|
||||||
@@ -219,6 +219,8 @@ Telegram-аккаунт, выбирает каналы/группы для мо
|
|||||||
## 10. Админка оператора
|
## 10. Админка оператора
|
||||||
|
|
||||||
- Тенанты: создание, инвайты, статус, лимиты/бюджеты, приостановка.
|
- Тенанты: создание, инвайты, статус, лимиты/бюджеты, приостановка.
|
||||||
|
- Глобальные настройки сервиса: ключи приложения Telegram и конфигурация ИИ-провайдера
|
||||||
|
(провайдер/модель/baseUrl/ключ; ключ зашифрован, наружу — маска) с проверкой связи.
|
||||||
- Health всех сервисов и очередей.
|
- Health всех сервисов и очередей.
|
||||||
- Аудит: входы/выходы, инвайты, impersonation, действия оператора и пользователей тенанта
|
- Аудит: входы/выходы, инвайты, impersonation, действия оператора и пользователей тенанта
|
||||||
(создание/перенос/удаление карточек, комментарии, контейнеры, настройки, каналы).
|
(создание/перенос/удаление карточек, комментарии, контейнеры, настройки, каналы).
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
| Файлы | MinIO (S3-совместимое хранилище) |
|
| Файлы | MinIO (S3-совместимое хранилище) |
|
||||||
| Фронтенд | Vue 3 + Vite + Tailwind |
|
| Фронтенд | Vue 3 + Vite + Tailwind |
|
||||||
| Межсервисно | gRPC + Protobuf (mTLS — за флагом `DEAL_MTLS_*`, §10/§13.8) |
|
| Межсервисно | gRPC + Protobuf (mTLS — за флагом `DEAL_MTLS_*`, §10/§13.8) |
|
||||||
| Наблюдаемость | Serilog (JSON: консоль + rolling-файл) → Promtail → Loki → Grafana; метрики OTel → Prometheus → Grafana |
|
| Наблюдаемость | Serilog (JSON: консоль + rolling-файл) → Promtail → Loki → Grafana; метрики OTel → Prometheus; трейсы OTel → Collector → Tempo; ресурсы cAdvisor/node-exporter → Prometheus; единый UI — Grafana |
|
||||||
| Прокси/edge | Caddy (TLS, security-заголовки); Cloudflare/k8s — вне этапа (§10/§11) |
|
| Прокси/edge | Caddy (TLS, security-заголовки); Cloudflare/k8s — вне этапа (§10/§11) |
|
||||||
| Контейнеры | Docker / docker compose (VPS); k8s — позже |
|
| Контейнеры | Docker / docker compose (VPS); k8s — позже |
|
||||||
| Бэкапы | Ежедневные: pg_dump + MinIO + сессии |
|
| Бэкапы | Ежедневные: pg_dump + MinIO + сессии |
|
||||||
@@ -119,8 +119,10 @@ global_settings(key, value, updated_at)
|
|||||||
|
|
||||||
> `token_usage_events` — история расхода токенов (этап 10, T2; подробнее — §13.10).
|
> `token_usage_events` — история расхода токенов (этап 10, T2; подробнее — §13.10).
|
||||||
> `global_settings` — глобальные настройки уровня сервиса; сейчас хранит ключи приложения Telegram
|
> `global_settings` — глобальные настройки уровня сервиса; сейчас хранит ключи приложения Telegram
|
||||||
> (`telegramKeys`: `api_id`/`api_hash`, hash — в `enc:`), которые задаёт **оператор** глобально
|
> (`telegramKeys`: `api_id`/`api_hash`, hash — в `enc:`) и конфигурацию ИИ-провайдера (`aiConfig`:
|
||||||
> (ручки `GET/PUT /api/operator/settings/telegram-keys`); тенант ключи не видит/не задаёт.
|
> `providerId`/`baseUrl`/`model`/`apiKey` — в `enc:`), которые задаёт **оператор** глобально
|
||||||
|
> (ручки `GET/PUT /api/operator/settings/telegram-keys` и `/ai-config`, проверка связи —
|
||||||
|
> `POST /api/operator/settings/ai-config/check`); тенант эти настройки не видит и не задаёт.
|
||||||
> Операторские таблицы этапа 7 (`operators`, `operator_sessions`, `tenant_limits`, `audit_log`) и их
|
> Операторские таблицы этапа 7 (`operators`, `operator_sessions`, `tenant_limits`, `audit_log`) и их
|
||||||
> контур описаны в §13.8. С этапа 12 счётчики распределённого rate-limit и попыток входа —
|
> контур описаны в §13.8. С этапа 12 счётчики распределённого rate-limit и попыток входа —
|
||||||
> `public.rate_limit_counters` (см. §10).
|
> `public.rate_limit_counters` (см. §10).
|
||||||
@@ -236,7 +238,9 @@ settings(Key varchar(200) PK, ValueJson text, UpdatedAt timestamptz) --
|
|||||||
(нужен `DEAL_GRAFANA_ADMIN_PASSWORD` в `.env.prod`; порты Grafana/Prometheus — только loopback). Остановка —
|
(нужен `DEAL_GRAFANA_ADMIN_PASSWORD` в `.env.prod`; порты Grafana/Prometheus — только loopback). Остановка —
|
||||||
`docker compose -f deploy/compose.prod.yml --profile observability down`.
|
`docker compose -f deploy/compose.prod.yml --profile observability down`.
|
||||||
- **Провижининг Grafana — как код** (`deploy/observability/grafana/provisioning`, монтируется в
|
- **Провижининг Grafana — как код** (`deploy/observability/grafana/provisioning`, монтируется в
|
||||||
контейнер): `datasources/datasources.yml` — датасорс Loki (uid `loki`, URL `http://loki:3100`);
|
контейнер): `datasources/datasources.yml` — датасорсы Loki (uid `loki`, default), Prometheus
|
||||||
|
(uid `prometheus`) и Tempo (uid `tempo`); у Loki — `derivedFields` TraceID → Tempo (клик по traceId
|
||||||
|
в логе открывает трейс), у Tempo — `tracesToLogsV2` → Loki и `serviceMap`/`nodeGraph` по метрикам;
|
||||||
`dashboards/dashboards.yml` — папка `Дейл` из `/var/lib/grafana/dashboards`. Дашборды — файлы
|
`dashboards/dashboards.yml` — папка `Дейл` из `/var/lib/grafana/dashboards`. Дашборды — файлы
|
||||||
`deploy/observability/grafana/dashboards/*.json`: правки только в репозитории, UI-изменения не
|
`deploy/observability/grafana/dashboards/*.json`: правки только в репозитории, UI-изменения не
|
||||||
сохраняются (`allowUiUpdates: false`).
|
сохраняются (`allowUiUpdates: false`).
|
||||||
@@ -251,7 +255,10 @@ settings(Key varchar(200) PK, ValueJson text, UpdatedAt timestamptz) --
|
|||||||
access-лога core) и активация инвайтов (`/api/join`);
|
access-лога core) и активация инвайтов (`/api/join`);
|
||||||
- `Deal-Errors` — HTTP 5xx, необработанные исключения (`@x`), Error/Fatal, ошибки gRPC и общая лента;
|
- `Deal-Errors` — HTTP 5xx, необработанные исключения (`@x`), Error/Fatal, ошибки gRPC и общая лента;
|
||||||
- `Deal-Rps` — нагрузка HTTP+gRPC (RPS), top-путей/методов и p50/p95 длительности запроса;
|
- `Deal-Rps` — нагрузка HTTP+gRPC (RPS), top-путей/методов и p50/p95 длительности запроса;
|
||||||
- `Deal-Logs` — обзор логов с фильтрами по сервису и уровню, активность по тенантам (AI/ML/Telegram).
|
- `Deal-Logs` — обзор логов с фильтрами по сервису и уровню, активность по тенантам (AI/ML/Telegram);
|
||||||
|
- `Deal-Traces` — поиск трейсов (Tempo, TraceQL), спаны по сервисам, переход к логам по traceId;
|
||||||
|
- `Deal-Resources` — потребление ресурсов контейнерами (cAdvisor) и хостом (node-exporter): CPU/RAM,
|
||||||
|
свободное место на дисках.
|
||||||
**Актор в логах:** с BL-LOG-ACTOR access-лог включает `actor` (login пользователя/оператора) и
|
**Актор в логах:** с BL-LOG-ACTOR access-лог включает `actor` (login пользователя/оператора) и
|
||||||
`tenant`; полная лента действий с деталями — `public.audit_log` (append-only) через
|
`tenant`; полная лента действий с деталями — `public.audit_log` (append-only) через
|
||||||
`GET /api/operator/audit` / экран «Аудит» оператор-консоли.
|
`GET /api/operator/audit` / экран «Аудит» оператор-консоли.
|
||||||
@@ -295,9 +302,37 @@ settings(Key varchar(200) PK, ValueJson text, UpdatedAt timestamptz) --
|
|||||||
`deal_ml_outbox_depth`), пропажа метрик ядра (`absent(deal_sessions_active)`). Замечание: правила
|
`deal_ml_outbox_depth`), пропажа метрик ядра (`absent(deal_sessions_active)`). Замечание: правила
|
||||||
бюджета токенов нет — метрика бюджета в Prometheus отсутствует (см. §6/§10), поэтому алерт не вводится.
|
бюджета токенов нет — метрика бюджета в Prometheus отсутствует (см. §6/§10), поэтому алерт не вводится.
|
||||||
- Как поднять/проверить: `docker compose --env-file deploy/.env.prod -f deploy/compose.prod.yml
|
- Как поднять/проверить: `docker compose --env-file deploy/.env.prod -f deploy/compose.prod.yml
|
||||||
--profile observability up -d` → Prometheus `/targets` (все 4 UP) → Grafana → папка «Дейл» →
|
--profile observability up -d` → Prometheus `/targets` (все UP) → Grafana → папка «Дейл» →
|
||||||
`Deal-Metrics-Overview`. Быстрая проверка экспортёра без Grafana: `curl http://<процесс>:9464/metrics`
|
`Deal-Metrics-Overview`/`Deal-Resources`/`Deal-Traces`. Быстрая проверка экспортёра без Grafana:
|
||||||
изнутри сети.
|
`curl http://<процесс>:9464/metrics` изнутри сети.
|
||||||
|
|
||||||
|
### Трейсы (OpenTelemetry Collector + Tempo)
|
||||||
|
|
||||||
|
- **Экспорт из 5 процессов**: OpenTelemetry SDK → OTLP → **OpenTelemetry Collector** (`otel-collector:
|
||||||
|
4317`) → **Tempo** (`tempo:4317`, хранилище трейсов, retention 7 суток). Настройка — общая в
|
||||||
|
`Deal.Grpc.Hosting` (`DealTracingHosting`) для telegram/ai/ml/storage и `Deal.Api/Observability/
|
||||||
|
DealTracingHosting.cs` для ядра. Инструментируется входящий HTTP/gRPC (AspNetCore), исходящие
|
||||||
|
HTTP-клиенты и gRPC-клиенты (GrpcNetClient) — трейсы сквозные от входа до БД/внешних сервисов.
|
||||||
|
- **Включение — опт-ин через env** `OTEL_EXPORTER_OTLP_ENDPOINT` (адрес коллектора, напр.
|
||||||
|
`http://otel-collector:4317`); без него трейсинг выключен. В compose env задан пустым
|
||||||
|
(`${DEAL_OTEL_ENDPOINT:-}`) — чтобы включить, задайте `DEAL_OTEL_ENDPOINT` в `.env`. Имя сервиса в
|
||||||
|
трейсах — `OTEL_SERVICE_NAME` (дефолт по процессу: `core`, `telegram-service`, `ai-service`,
|
||||||
|
`ml-service`, `storage-service`).
|
||||||
|
- **Корреляция с логами**: Serilog обогащается `TraceId`/`SpanId` из `Activity.Current`
|
||||||
|
(`TraceContextEnricher`) — в Loki-логе есть `TraceId`, а датасорс Loki `derivedFields` даёт переход
|
||||||
|
из лога в трейс Tempo (и обратно — `tracesToLogsV2`).
|
||||||
|
- Сервисы профиля: `otel-collector` (`otel/opentelemetry-collector-contrib:0.160.0`, конфиг
|
||||||
|
`deploy/observability/otel-collector.yml`) и `tempo` (`grafana/tempo:2.8.1`, конфиг
|
||||||
|
`deploy/observability/tempo.yml`, volume `deal_tempo_data`). Наружу порты не публикуются (dev — для отладки).
|
||||||
|
|
||||||
|
### Ресурсы (cAdvisor + node-exporter)
|
||||||
|
|
||||||
|
- **cAdvisor** (`gcr.io/cadvisor/cadvisor:v0.52.1`) — потребление ресурсов **контейнерами**
|
||||||
|
(CPU/RAM/сеть/диск); **node-exporter** (`prom/node-exporter:v1.9.1`) — ресурсы **хоста** (CPU/RAM/
|
||||||
|
диски/сеть). Оба scrape'ит Prometheus (jobs `cadvisor`, `node-exporter` в `prometheus.yml`).
|
||||||
|
- Дашборд `Deal-Resources` (uid `deal-resources`): CPU/RAM контейнеров, CPU/RAM хоста, свободное место
|
||||||
|
на дисках. Правила алертов по ресурсам — в отдельном файле `prometheus-resource-rules.yml`,
|
||||||
|
**отключены по умолчанию** (не входят в `rule_files`); пороги — через env `DEAL_ALERT_*` при включении.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -330,10 +365,11 @@ settings(Key varchar(200) PK, ValueJson text, UpdatedAt timestamptz) --
|
|||||||
- Одна внутренняя сеть; наружу — только **caddy** (80/443): TLS (шапка `deploy/caddy/Caddyfile` —
|
- Одна внутренняя сеть; наружу — только **caddy** (80/443): TLS (шапка `deploy/caddy/Caddyfile` —
|
||||||
`tls internal` для dev/интранет, для реального домена заменить на Cloudflare-origin/сертификаты),
|
`tls internal` для dev/интранет, для реального домена заменить на Cloudflare-origin/сертификаты),
|
||||||
статика `src/frontend/dist`, `reverse_proxy /api → core:5080`, security-заголовки (CSP/HSTS — здесь).
|
статика `src/frontend/dist`, `reverse_proxy /api → core:5080`, security-заголовки (CSP/HSTS — здесь).
|
||||||
- `core` (:5080 http + :5082 gRPC-ингресс), `telegram/ai/ml-service` (mTLS-env, Ruling 6),
|
- `core` (:5080 http + :5082 gRPC-ингресс), `telegram/ai/ml/storage-service` (mTLS-env, Ruling 6),
|
||||||
`postgres`/`minio` **без host-портов**; healthcheck'и — `grpc_health_probe` (при mTLS — TLS-проба с
|
`postgres`/`minio` **без host-портов**; healthcheck'и — `grpc_health_probe` (при mTLS — TLS-проба с
|
||||||
PEM `deal-client.crt/.key`)/`pg_isready`.
|
PEM `deal-client.crt/.key`)/`pg_isready`.
|
||||||
- Профиль `observability`: `loki`/`promtail`/`grafana` + `prometheus` (метрики — этап 12, пакет A; см. §7).
|
- Профиль `observability`: `otel-collector`/`tempo` (трейсы), `loki`/`promtail` (логи),
|
||||||
|
`prometheus`/`cadvisor`/`node-exporter` (метрики и ресурсы), `grafana` (UI); см. §7.
|
||||||
Секреты — только из `.env.prod`
|
Секреты — только из `.env.prod`
|
||||||
(шаблон `deploy/.env.prod.example`, без дефолтных паролей; отсутствие → fail-fast `:?`).
|
(шаблон `deploy/.env.prod.example`, без дефолтных паролей; отсутствие → fail-fast `:?`).
|
||||||
Rate limiting включён (`RateLimit__Enabled: true`), CORS — явный `Security__AllowedOrigins`
|
Rate limiting включён (`RateLimit__Enabled: true`), CORS — явный `Security__AllowedOrigins`
|
||||||
@@ -462,7 +498,8 @@ DEAL_MTLS_ENABLED=0|1 DEAL_MTLS_CERT_PASSWORD=... DEAL_DEFAULT_AI_BU
|
|||||||
ответ — полный снимок; секреты наружу только масками `keyMasked`/`apiId`; внутренние ключи
|
ответ — полный снимок; секреты наружу только масками `keyMasked`/`apiId`; внутренние ключи
|
||||||
`ratesCache`/`mlDecisions`/`aiDecisions` не публикуются);
|
`ratesCache`/`mlDecisions`/`aiDecisions` не публикуются);
|
||||||
- шифрование секретов AI/Telegram: AES-256-GCM, в БД — `enc:` + Base64 (ключ — env/file, см. §13.4a);
|
- шифрование секретов AI/Telegram: AES-256-GCM, в БД — `enc:` + Base64 (ключ — env/file, см. §13.4a);
|
||||||
- проверка подключения ИИ: `POST /api/ai/check` (локальный провайдер / HTTP-проверка облачного);
|
- проверка подключения ИИ: `POST /api/operator/settings/ai-config/check` (операторская; локальный провайдер /
|
||||||
|
HTTP-проверка облачного);
|
||||||
- курсы валют: `GET /api/rates`, `POST /api/rates/refresh` (кэш `ratesCache` в settings; `mock`/ЦБ);
|
- курсы валют: `GET /api/rates`, `POST /api/rates/refresh` (кэш `ratesCache` в settings; `mock`/ЦБ);
|
||||||
- ML-панель на детерминированной заглушке: `GET /api/ml/status`, `POST /api/ml/reset|predict`
|
- ML-панель на детерминированной заглушке: `GET /api/ml/status`, `POST /api/ml/reset|predict`
|
||||||
(candidates → `{items:[]}`, apply → 404 — нет telegram-данных до этапа 6);
|
(candidates → `{items:[]}`, apply → 404 — нет telegram-данных до этапа 6);
|
||||||
@@ -740,28 +777,30 @@ health — `GET /api/health` → `{"ok":true,"service":"deal"}`.
|
|||||||
|
|
||||||
### 4a. Шифрование секретов настроек (ключи AI/Telegram)
|
### 4a. Шифрование секретов настроек (ключи AI/Telegram)
|
||||||
|
|
||||||
Секреты (`aiConfigs[].apiKey`) хранятся в `settings.ValueJson` шифротекстом:
|
Секреты хранятся шифротекстом `enc:` + Base64(nonce‖ct‖tag), AES-256-GCM (nonce 12 Б, tag 16 Б).
|
||||||
`enc:` + Base64(nonce‖ct‖tag), AES-256-GCM (nonce 12 Б, tag 16 Б). Ключ шифрования — env
|
Ключ шифрования — env `DEAL_ENCRYPTION_KEY` (32 байта в urlsafe-Base64); при отсутствии в dev
|
||||||
`DEAL_ENCRYPTION_KEY` (32 байта в urlsafe-Base64); при отсутствии в dev берётся/создаётся файл
|
берётся/создаётся файл `<ContentRoot>/data/encryption.key` (путь переопределяется env
|
||||||
`<ContentRoot>/data/encryption.key` (путь переопределяется env `DEAL_ENCRYPTION_KEY_FILE`) —
|
`DEAL_ENCRYPTION_KEY_FILE`) — при генерации лог-warning. Невалидный env-ключ — ошибка при старте.
|
||||||
при генерации лог-warning. Невалидный env-ключ — ошибка при старте. Наружу секреты не отдаются:
|
Наружу секреты не отдаются: только маски `keyMasked` (первые 4 + «…» + последние 4, len≤8 — как есть)
|
||||||
в GET/PATCH `/api/settings` только маски `keyMasked` (первые 4 + «…» + последние 4, len≤8 — как есть)
|
|
||||||
и `keySet`.
|
и `keySet`.
|
||||||
|
|
||||||
> Исторический раздел (этап 2). С этапа 12 ключей Telegram (`tgKeys`/`apiId`/`apiHash`) в настройках
|
> Исторический раздел (этап 2). С этапа 12 ключей Telegram (`tgKeys`/`apiId`/`apiHash`) в настройках
|
||||||
> тенанта нет — они задаются **оператором** глобально (таблица `public.global_settings`,
|
> тенанта нет — они задаются **оператором** глобально (таблица `public.global_settings`,
|
||||||
> `GET/PUT /api/operator/settings/telegram-keys`; hash шифруется тем же AES-256-GCM).
|
> `GET/PUT /api/operator/settings/telegram-keys`; hash шифруется тем же AES-256-GCM).
|
||||||
|
>
|
||||||
|
> С 2026-09-14 там же живёт и конфигурация ИИ-провайдера (ключ `aiConfig` в `public.global_settings`,
|
||||||
|
> `GET/PUT /api/operator/settings/ai-config`): провайдер, модель, baseUrl и API-ключ задаёт оператор,
|
||||||
|
> все ИИ-вызовы всех тенантов идут на эту конфигурацию; в настройках тенанта ключей `aiProvider`/
|
||||||
|
> `aiConfigs` больше нет.
|
||||||
|
|
||||||
### 4b. Эндпоинты этапа 2 (настройки тенанта; сессия `deal_session` обязательна, иначе 401)
|
### 4b. Эндпоинты этапа 2 (настройки тенанта; сессия `deal_session` обязательна, иначе 401)
|
||||||
|
|
||||||
- `GET /api/settings` — публичный снимок дерева настроек: дефолты модуля, перекрытые
|
- `GET /api/settings` — публичный снимок дерева настроек: дефолты модуля, перекрытые
|
||||||
переопределениями из `settings` тенанта; включает списки `providers`/`aiConfigs`/`tgKeys`/`myPrompts`.
|
переопределениями из `settings` тенанта; включает `myPrompts` и колонки, но не настройки
|
||||||
|
ИИ-провайдера (они операторские).
|
||||||
`PATCH /api/settings` — частичное обновление (невалидное поле мягко пропускается, ответ — полный
|
`PATCH /api/settings` — частичное обновление (невалидное поле мягко пропускается, ответ — полный
|
||||||
снимок). Побочные эффекты: при `rateSource` — фоновый refresh курсов. Внутренние ключи
|
снимок). Побочные эффекты: при `rateSource` — фоновый refresh курсов. Внутренние ключи
|
||||||
(`ratesCache`, `mlDecisions`, `aiDecisions`) в GET/PATCH не участвуют.
|
(`ratesCache`, `mlDecisions`, `aiDecisions`) в GET/PATCH не участвуют.
|
||||||
- `POST /api/ai/check` — проверка подключения активного провайдера (`aiProvider` + `aiConfigs`, ключ
|
|
||||||
расшифровывается): локальный провайдер → `ok:true` «Локальный сервер…»; облачный — HTTP `GET
|
|
||||||
{base}/models`; без ключа → «Не задан API-ключ».
|
|
||||||
- `GET /api/rates` / `POST /api/rates/refresh` — курсы к RUB (`base` = `RUB`); источник по `rateSource`
|
- `GET /api/rates` / `POST /api/rates/refresh` — курсы к RUB (`base` = `RUB`); источник по `rateSource`
|
||||||
(`mock` — константа, `cbr` — ЦБ РФ, ≤4 запроса/сутки, интервал 6 ч; `USDT`=`USD`); кэш — внутренняя
|
(`mock` — константа, `cbr` — ЦБ РФ, ≤4 запроса/сутки, интервал 6 ч; `USDT`=`USD`); кэш — внутренняя
|
||||||
настройка `ratesCache` `{rates, source, updatedAtMs}`.
|
настройка `ratesCache` `{rates, source, updatedAtMs}`.
|
||||||
@@ -1115,8 +1154,9 @@ docker compose -f deploy/compose.dev.yml down # погасить ст
|
|||||||
(`PUT /api/operator/settings/telegram-keys`, hash шифруется) → `POST /api/tg/start-qr` → QR-скан →
|
(`PUT /api/operator/settings/telegram-keys`, hash шифруется) → `POST /api/tg/start-qr` → QR-скан →
|
||||||
фаза `ready` («Telegram подключён, сессия сохранена»), затем реальные диалоги/мониторинг/«Перечитать»/
|
фаза `ready` («Telegram подключён, сессия сохранена»), затем реальные диалоги/мониторинг/«Перечитать»/
|
||||||
discovery-поиск и вступления. В настройках тенанта ключей нет (решение владельца, вариант A).
|
discovery-поиск и вступления. В настройках тенанта ключей нет (решение владельца, вариант A).
|
||||||
- LLM: `PATCH /api/settings` `aiConfigs`/`aiProvider` (напр. DeepSeek или локальный OpenAI-совместимый) →
|
- LLM: оператор задаёт провайдера и модель в консоли (`PUT /api/operator/settings/ai-config`, напр. DeepSeek
|
||||||
`POST /api/ai/check`; реальная классификация/фильтр/генерация ключей при `Services__Ai__UseLocal=false`.
|
или локальный OpenAI-совместимый) → `POST /api/operator/settings/ai-config/check`; реальная
|
||||||
|
классификация/фильтр/генерация ключей при `Services__Ai__UseLocal=false`.
|
||||||
- Сквозной smoke стека — `scripts/dev-smoke.sh` (одна команда; Docker Desktop должен быть поднят).
|
- Сквозной smoke стека — `scripts/dev-smoke.sh` (одна команда; Docker Desktop должен быть поднят).
|
||||||
|
|
||||||
### 8. Этап 7 — SaaS-контур (Tasks 1–14; бэкапы — §13.9; финальные доки — Task 16): оператор/инвайты/лимиты/аудит/rate-limit/mTLS/логи/compose-prod
|
### 8. Этап 7 — SaaS-контур (Tasks 1–14; бэкапы — §13.9; финальные доки — Task 16): оператор/инвайты/лимиты/аудит/rate-limit/mTLS/логи/compose-prod
|
||||||
@@ -1152,11 +1192,12 @@ docker compose -f deploy/compose.dev.yml down # погасить ст
|
|||||||
(RpcCallLoggingInterceptor; gRPC-health не логируется). **Метрики** — OTel → Prometheus: `/metrics`
|
(RpcCallLoggingInterceptor; gRPC-health не логируется). **Метрики** — OTel → Prometheus: `/metrics`
|
||||||
(HTTP/1.1 :9464) + прикладные `deal.*` (токены/вызовы AI/ML, аудит, глубины очередей, сессии) — см. §7.
|
(HTTP/1.1 :9464) + прикладные `deal.*` (токены/вызовы AI/ML, аудит, глубины очередей, сессии) — см. §7.
|
||||||
PROD-стек: docker-логи → Promtail → Loki (retention 7 сут.) → Grafana (`127.0.0.1:3001`, SSH-туннель),
|
PROD-стек: docker-логи → Promtail → Loki (retention 7 сут.) → Grafana (`127.0.0.1:3001`, SSH-туннель),
|
||||||
метрики → Prometheus (`127.0.0.1:9090`) → Grafana; профиль `observability` compose.prod.
|
метрики → Prometheus (`127.0.0.1:9090`) → Grafana, трейсы OTel → otel-collector → Tempo, ресурсы
|
||||||
|
cAdvisor/node-exporter → Prometheus; профиль `observability` compose.prod.
|
||||||
- **compose.prod** (Ruling 9): `deploy/compose.prod.yml` — postgres/minio (без host-портов), core + telegram/ai/ml
|
- **compose.prod** (Ruling 9): `deploy/compose.prod.yml` — postgres/minio (без host-портов), core + telegram/ai/ml
|
||||||
(mTLS env; healthcheck — `grpc_health_probe`, при mTLS — TLS-проба с PEM), `caddy` (80/443: статика
|
(mTLS env; healthcheck — `grpc_health_probe`, при mTLS — TLS-проба с PEM), `caddy` (80/443: статика
|
||||||
`src/frontend/dist` + `reverse_proxy /api → core:5080`, security-заголовки; домен/TLS/Cloudflare — шапка
|
`src/frontend/dist` + `reverse_proxy /api → core:5080`, security-заголовки; домен/TLS/Cloudflare — шапка
|
||||||
`deploy/caddy/Caddyfile`), профиль `observability` (loki/promtail/grafana/prometheus). Секреты — только из `.env.prod`
|
`deploy/caddy/Caddyfile`), профиль `observability` (otel-collector/tempo/loki/promtail/prometheus/cadvisor/node-exporter/grafana). Секреты — только из `.env.prod`
|
||||||
(шаблон `deploy/.env.prod.example`, без дефолтных паролей, fail-fast `:?`). Запуск:
|
(шаблон `deploy/.env.prod.example`, без дефолтных паролей, fail-fast `:?`). Запуск:
|
||||||
`docker compose --env-file deploy/.env.prod -f deploy/compose.prod.yml up -d --build` (+ `--profile observability`);
|
`docker compose --env-file deploy/.env.prod -f deploy/compose.prod.yml up -d --build` (+ `--profile observability`);
|
||||||
авто-проверка — `... config` rc=0.
|
авто-проверка — `... config` rc=0.
|
||||||
@@ -1305,7 +1346,8 @@ docker compose -f deploy/compose.dev.yml start core telegram-service ml-service
|
|||||||
(`container_created`, `container_updated`, `container_deleted`), `settings_updated`, `channel_enabled`,
|
(`container_created`, `container_updated`, `container_deleted`), `settings_updated`, `channel_enabled`,
|
||||||
`telegram_linked` (таблица — в контракте; `channel_created` зарезервирован, но не эмитится).
|
`telegram_linked` (таблица — в контракте; `channel_created` зарезервирован, но не эмитится).
|
||||||
- **Наблюдаемость** (Grafana provisioning + promtail-лейблы, дашборды `Deal-Auth/Errors/Rps/Logs`; с этапа 12 —
|
- **Наблюдаемость** (Grafana provisioning + promtail-лейблы, дашборды `Deal-Auth/Errors/Rps/Logs`; с этапа 12 —
|
||||||
метрики OTel → Prometheus и дашборд `Deal-Metrics-Overview`, см. §7).
|
метрики OTel → Prometheus и дашборд `Deal-Metrics-Overview`; трейсы OTel → Collector → Tempo (`Deal-Traces`)
|
||||||
|
и ресурсы cAdvisor/node-exporter (`Deal-Resources`), см. §7).
|
||||||
- **Как открыть (dev):** `docker compose -f deploy/compose.dev.yml up -d --build` (или core на `:5080`
|
- **Как открыть (dev):** `docker compose -f deploy/compose.dev.yml up -d --build` (или core на `:5080`
|
||||||
с Postgres `:5433`, AI в Local-режиме) → фронт `cd src/frontend && npm run dev` (`:5173`, прокси `/api`)
|
с Postgres `:5433`, AI в Local-режиме) → фронт `cd src/frontend && npm run dev` (`:5173`, прокси `/api`)
|
||||||
→ **оператор:** `http://localhost:5173/#/operator`, вход `operator`/`operator` (dev-дефолт; в Production —
|
→ **оператор:** `http://localhost:5173/#/operator`, вход `operator`/`operator` (dev-дефолт; в Production —
|
||||||
|
|||||||
@@ -184,12 +184,13 @@
|
|||||||
**Настройки → Telegram:** подключение аккаунта, авто-мониторинг новых чатов.
|
**Настройки → Telegram:** подключение аккаунта, авто-мониторинг новых чатов.
|
||||||
|
|
||||||
**Настройки → ИИ:**
|
**Настройки → ИИ:**
|
||||||
- провайдер и модель (можно выбрать один, включая локальные OpenAI-совместимые);
|
|
||||||
- ключ API (хранится зашифрованно);
|
|
||||||
- **промпты**: базовый (не меняется) + свой промпт; библиотека готовых промптов по сферам
|
|
||||||
с поиском и категориями; сохранённые свои промпты («Мои промпты»);
|
|
||||||
- вкл/выкл ИИ и ИИ-фильтр. Если ML уже уверенно обрабатывает поток — система подскажет,
|
- вкл/выкл ИИ и ИИ-фильтр. Если ML уже уверенно обрабатывает поток — система подскажет,
|
||||||
что ИИ можно отключить.
|
что ИИ можно отключить;
|
||||||
|
- **промпты**: базовый (не меняется) + свой промпт; библиотека готовых промптов по сферам
|
||||||
|
с поиском и категориями; сохранённые свои промпты («Мои промпты»).
|
||||||
|
|
||||||
|
Провайдера, модель и API-ключ задаёт оператор сервиса — они едины для всех пользователей
|
||||||
|
и в кабинете не настраиваются.
|
||||||
|
|
||||||
**Настройки → ML:** включение, обучение на ваших действиях, проверка модели на сообщении/канале,
|
**Настройки → ML:** включение, обучение на ваших действиях, проверка модели на сообщении/канале,
|
||||||
сброс обучения, показатели самооценки.
|
сброс обучения, показатели самооценки.
|
||||||
@@ -257,6 +258,9 @@
|
|||||||
пространствам и лимитам) с фильтрами по типу события, актору, пространству и периоду; есть пагинация.
|
пространствам и лимитам) с фильтрами по типу события, актору, пространству и периоду; есть пагинация.
|
||||||
- **Аналитика** — обзор за период (число пространств, расход токенов, входы/выходы/неудачные входы),
|
- **Аналитика** — обзор за период (число пространств, расход токенов, входы/выходы/неудачные входы),
|
||||||
расход токенов с группировкой по дням/пространствам/провайдерам/моделям и лента действий.
|
расход токенов с группировкой по дням/пространствам/провайдерам/моделям и лента действий.
|
||||||
|
- **ИИ** — глобальный провайдер ИИ: выбор провайдера из каталога, модель, адрес API (для локальных
|
||||||
|
и «Другого») и API-ключ, а также проверка связи. Конфигурация единая для всех пользователей;
|
||||||
|
ключ хранится зашифрованным и показывается только маской.
|
||||||
- **Состояние системы** — доступность ядра, базы данных и сервисов (Telegram, ИИ, ML).
|
- **Состояние системы** — доступность ядра, базы данных и сервисов (Telegram, ИИ, ML).
|
||||||
|
|
||||||
> **Dev-окружение:** вход в консоль — `operator`/`operator`. В обычной (прод) сборке учётные
|
> **Dev-окружение:** вход в консоль — `operator`/`operator`. В обычной (прод) сборке учётные
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
namespace Deal.Ai;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Имена операций AiService для аудита
|
||||||
|
/// </summary>
|
||||||
|
public static class AiOperations
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Операция ИИ-фильтра входящих сообщений.
|
||||||
|
/// </summary>
|
||||||
|
public const string Filter = "filter";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Операция полного разбора лида.
|
||||||
|
/// </summary>
|
||||||
|
public const string Classify = "classify";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Операция генерации ключевых слов discovery-задачи.
|
||||||
|
/// </summary>
|
||||||
|
public const string GenerateKeywords = "generate_keywords";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Операция оценки соответствия сообщения задаче поиска.
|
||||||
|
/// </summary>
|
||||||
|
public const string EvaluateFit = "evaluate_fit";
|
||||||
|
}
|
||||||
@@ -150,7 +150,7 @@ public sealed class AiServiceImpl : AiService.AiServiceBase
|
|||||||
}
|
}
|
||||||
catch (LlmCallException callError)
|
catch (LlmCallException callError)
|
||||||
{
|
{
|
||||||
LogAiUnavailable("filter", tenantId, config, callError);
|
LogAiUnavailable(AiOperations.Filter, tenantId, config, callError);
|
||||||
throw ToUnavailable(callError);
|
throw ToUnavailable(callError);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -191,7 +191,7 @@ public sealed class AiServiceImpl : AiService.AiServiceBase
|
|||||||
}
|
}
|
||||||
catch (LlmCallException callError)
|
catch (LlmCallException callError)
|
||||||
{
|
{
|
||||||
LogAiUnavailable("classify", tenantId, config, callError);
|
LogAiUnavailable(AiOperations.Classify, tenantId, config, callError);
|
||||||
throw ToUnavailable(callError);
|
throw ToUnavailable(callError);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -226,7 +226,7 @@ public sealed class AiServiceImpl : AiService.AiServiceBase
|
|||||||
}
|
}
|
||||||
catch (LlmCallException callError)
|
catch (LlmCallException callError)
|
||||||
{
|
{
|
||||||
LogAiUnavailable("generate_keywords", tenantId, config, callError);
|
LogAiUnavailable(AiOperations.GenerateKeywords, tenantId, config, callError);
|
||||||
throw ToUnavailable(callError);
|
throw ToUnavailable(callError);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -267,7 +267,7 @@ public sealed class AiServiceImpl : AiService.AiServiceBase
|
|||||||
}
|
}
|
||||||
catch (LlmCallException callError)
|
catch (LlmCallException callError)
|
||||||
{
|
{
|
||||||
LogAiUnavailable("evaluate_fit", tenantId, config, callError);
|
LogAiUnavailable(AiOperations.EvaluateFit, tenantId, config, callError);
|
||||||
throw ToUnavailable(callError);
|
throw ToUnavailable(callError);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ WORKDIR /repo
|
|||||||
# Restore-слой: только csproj/props (кэш слоёв Docker — restore не повторяется при правке исходников).
|
# Restore-слой: только csproj/props (кэш слоёв Docker — restore не повторяется при правке исходников).
|
||||||
COPY src/contracts/Deal.Proto.csproj src/contracts/
|
COPY src/contracts/Deal.Proto.csproj src/contracts/
|
||||||
COPY src/grpc-hosting/Deal.Grpc.Hosting/Deal.Grpc.Hosting.csproj src/grpc-hosting/Deal.Grpc.Hosting/
|
COPY src/grpc-hosting/Deal.Grpc.Hosting/Deal.Grpc.Hosting.csproj src/grpc-hosting/Deal.Grpc.Hosting/
|
||||||
|
COPY src/core/Deal.SharedKernel/Deal.SharedKernel.csproj src/core/Deal.SharedKernel/
|
||||||
COPY src/ai-service/Directory.Build.props src/ai-service/
|
COPY src/ai-service/Directory.Build.props src/ai-service/
|
||||||
COPY src/ai-service/Deal.Ai/Deal.Ai.csproj src/ai-service/Deal.Ai/
|
COPY src/ai-service/Deal.Ai/Deal.Ai.csproj src/ai-service/Deal.Ai/
|
||||||
RUN dotnet restore src/ai-service/Deal.Ai/Deal.Ai.csproj
|
RUN dotnet restore src/ai-service/Deal.Ai/Deal.Ai.csproj
|
||||||
@@ -19,6 +20,7 @@ RUN dotnet restore src/ai-service/Deal.Ai/Deal.Ai.csproj
|
|||||||
# Исходники: контракты (.proto) + общая gRPC-обвязка + проект сервиса.
|
# Исходники: контракты (.proto) + общая gRPC-обвязка + проект сервиса.
|
||||||
COPY src/contracts/ src/contracts/
|
COPY src/contracts/ src/contracts/
|
||||||
COPY src/grpc-hosting/ src/grpc-hosting/
|
COPY src/grpc-hosting/ src/grpc-hosting/
|
||||||
|
COPY src/core/Deal.SharedKernel/ src/core/Deal.SharedKernel/
|
||||||
COPY src/ai-service/Deal.Ai/ src/ai-service/Deal.Ai/
|
COPY src/ai-service/Deal.Ai/ src/ai-service/Deal.Ai/
|
||||||
RUN dotnet publish src/ai-service/Deal.Ai/Deal.Ai.csproj -c Release -o /app/publish
|
RUN dotnet publish src/ai-service/Deal.Ai/Deal.Ai.csproj -c Release -o /app/publish
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
namespace Deal.Ai.Llm;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Роли сообщений чата в wire-формате ИИ-провайдеров
|
||||||
|
/// </summary>
|
||||||
|
public static class LlmChatRoles
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Роль системного сообщения.
|
||||||
|
/// </summary>
|
||||||
|
public const string System = "system";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Роль пользовательского сообщения.
|
||||||
|
/// </summary>
|
||||||
|
public const string User = "user";
|
||||||
|
}
|
||||||
@@ -4,9 +4,6 @@ using System.Text.Json.Nodes;
|
|||||||
|
|
||||||
namespace Deal.Ai.Llm;
|
namespace Deal.Ai.Llm;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// HTTP-реализация <see cref="IProviderClient"/>
|
|
||||||
/// </summary>
|
|
||||||
public sealed class LlmHttpClient : IProviderClient
|
public sealed class LlmHttpClient : IProviderClient
|
||||||
{
|
{
|
||||||
// Относительный путь OpenAI-совместимого эндпоинта (база уже без хвостового «/»).
|
// Относительный путь OpenAI-совместимого эндпоинта (база уже без хвостового «/»).
|
||||||
@@ -58,13 +55,6 @@ public sealed class LlmHttpClient : IProviderClient
|
|||||||
_anthropicCallTimeout = anthropicCallTimeout;
|
_anthropicCallTimeout = anthropicCallTimeout;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Выполняет один вызов модели по выбранной схеме API.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="config">Конфиг провайдера (стиль — <c>ApiStyle</c>).</param>
|
|
||||||
/// <param name="systemPrompt">Системный промпт.</param>
|
|
||||||
/// <param name="userText">Пользовательское сообщение/контекст.</param>
|
|
||||||
/// <returns>Текст ответа и usage API-ответа (null при его отсутствии).</returns>
|
|
||||||
async Task<ProviderChatResult> IProviderClient.ChatAsync(
|
async Task<ProviderChatResult> IProviderClient.ChatAsync(
|
||||||
LlmConfig config,
|
LlmConfig config,
|
||||||
string systemPrompt,
|
string systemPrompt,
|
||||||
@@ -110,12 +100,12 @@ public sealed class LlmHttpClient : IProviderClient
|
|||||||
|
|
||||||
var body = new JsonObject
|
var body = new JsonObject
|
||||||
{
|
{
|
||||||
["model"] = config.Model,
|
[LlmWireKeys.Model] = config.Model,
|
||||||
["messages"] = new JsonArray(
|
[LlmWireKeys.Messages] = new JsonArray(
|
||||||
ChatMessage("system", systemPrompt),
|
ChatMessage(LlmChatRoles.System, systemPrompt),
|
||||||
ChatMessage("user", userText)),
|
ChatMessage(LlmChatRoles.User, userText)),
|
||||||
["temperature"] = Temperature,
|
[LlmWireKeys.Temperature] = Temperature,
|
||||||
["max_tokens"] = MaxResponseTokens,
|
[LlmWireKeys.MaxTokens] = MaxResponseTokens,
|
||||||
};
|
};
|
||||||
|
|
||||||
request.Content = JsonBody(body);
|
request.Content = JsonBody(body);
|
||||||
@@ -133,10 +123,10 @@ public sealed class LlmHttpClient : IProviderClient
|
|||||||
|
|
||||||
var body = new JsonObject
|
var body = new JsonObject
|
||||||
{
|
{
|
||||||
["model"] = config.Model,
|
[LlmWireKeys.Model] = config.Model,
|
||||||
["max_tokens"] = MaxResponseTokens,
|
[LlmWireKeys.MaxTokens] = MaxResponseTokens,
|
||||||
["system"] = systemPrompt,
|
[LlmWireKeys.System] = systemPrompt,
|
||||||
["messages"] = new JsonArray(ChatMessage("user", userText)),
|
[LlmWireKeys.Messages] = new JsonArray(ChatMessage(LlmChatRoles.User, userText)),
|
||||||
};
|
};
|
||||||
|
|
||||||
request.Content = JsonBody(body);
|
request.Content = JsonBody(body);
|
||||||
@@ -166,7 +156,7 @@ public sealed class LlmHttpClient : IProviderClient
|
|||||||
private static ProviderChatResult ReadOpenAiBody(string body)
|
private static ProviderChatResult ReadOpenAiBody(string body)
|
||||||
{
|
{
|
||||||
JsonObject? payload = ParseObjectOrThrow(body);
|
JsonObject? payload = ParseObjectOrThrow(body);
|
||||||
JsonArray? choices = payload["choices"] as JsonArray;
|
JsonArray? choices = payload[LlmWireKeys.Choices] as JsonArray;
|
||||||
if (choices is null || choices.Count == 0)
|
if (choices is null || choices.Count == 0)
|
||||||
{
|
{
|
||||||
// Тип сбоя без содержимого тела (замечание code-review: тело ответа наружу/в лог не уходит).
|
// Тип сбоя без содержимого тела (замечание code-review: тело ответа наружу/в лог не уходит).
|
||||||
@@ -174,14 +164,14 @@ public sealed class LlmHttpClient : IProviderClient
|
|||||||
}
|
}
|
||||||
|
|
||||||
// choices[i] — объект выбора {message, finish_reason, …}; текст — в message.content.
|
// choices[i] — объект выбора {message, finish_reason, …}; текст — в message.content.
|
||||||
JsonObject? message = (choices[0] as JsonObject)?["message"] as JsonObject;
|
JsonObject? message = (choices[0] as JsonObject)?[LlmWireKeys.Message] as JsonObject;
|
||||||
string? content = ReadStringField(message, "content");
|
string? content = ReadStringField(message, LlmWireKeys.Content);
|
||||||
if (string.IsNullOrEmpty(content) && !string.IsNullOrEmpty(ReadStringField(message, "reasoning_content")))
|
if (string.IsNullOrEmpty(content) && !string.IsNullOrEmpty(ReadStringField(message, LlmWireKeys.ReasoningContent)))
|
||||||
{
|
{
|
||||||
throw new LlmHttpException("Модель вернула только reasoning без ответа");
|
throw new LlmHttpException("Модель вернула только reasoning без ответа");
|
||||||
}
|
}
|
||||||
|
|
||||||
return new ProviderChatResult(content ?? string.Empty, ReadOpenAiUsage(payload["usage"]));
|
return new ProviderChatResult(content ?? string.Empty, ReadOpenAiUsage(payload[LlmWireKeys.Usage]));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static ProviderChatResult ReadAnthropicBody(string body)
|
private static ProviderChatResult ReadAnthropicBody(string body)
|
||||||
@@ -189,18 +179,18 @@ public sealed class LlmHttpClient : IProviderClient
|
|||||||
JsonObject? payload = ParseObjectOrThrow(body);
|
JsonObject? payload = ParseObjectOrThrow(body);
|
||||||
|
|
||||||
var text = new StringBuilder();
|
var text = new StringBuilder();
|
||||||
if (payload["content"] is JsonArray contentBlocks)
|
if (payload[LlmWireKeys.Content] is JsonArray contentBlocks)
|
||||||
{
|
{
|
||||||
foreach (JsonNode? blockNode in contentBlocks)
|
foreach (JsonNode? blockNode in contentBlocks)
|
||||||
{
|
{
|
||||||
if (blockNode is JsonObject block)
|
if (blockNode is JsonObject block)
|
||||||
{
|
{
|
||||||
text.Append(ReadStringField(block, "text"));
|
text.Append(ReadStringField(block, LlmWireKeys.Text));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return new ProviderChatResult(text.ToString(), ReadAnthropicUsage(payload["usage"]));
|
return new ProviderChatResult(text.ToString(), ReadAnthropicUsage(payload[LlmWireKeys.Usage]));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Возвращает usage OpenAI-совместимого ответа (prompt/completion/total_tokens) либо null.
|
// Возвращает usage OpenAI-совместимого ответа (prompt/completion/total_tokens) либо null.
|
||||||
@@ -212,9 +202,9 @@ public sealed class LlmHttpClient : IProviderClient
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
int? promptTokens = ReadIntField(usage, "prompt_tokens");
|
int? promptTokens = ReadIntField(usage, LlmWireKeys.PromptTokens);
|
||||||
int? completionTokens = ReadIntField(usage, "completion_tokens");
|
int? completionTokens = ReadIntField(usage, LlmWireKeys.CompletionTokens);
|
||||||
int? totalTokens = ReadIntField(usage, "total_tokens");
|
int? totalTokens = ReadIntField(usage, LlmWireKeys.TotalTokens);
|
||||||
return promptTokens is null || completionTokens is null || totalTokens is null
|
return promptTokens is null || completionTokens is null || totalTokens is null
|
||||||
? null
|
? null
|
||||||
: new ProviderUsage(promptTokens.Value, completionTokens.Value, totalTokens.Value);
|
: new ProviderUsage(promptTokens.Value, completionTokens.Value, totalTokens.Value);
|
||||||
@@ -229,8 +219,8 @@ public sealed class LlmHttpClient : IProviderClient
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
int? inputTokens = ReadIntField(usage, "input_tokens");
|
int? inputTokens = ReadIntField(usage, LlmWireKeys.InputTokens);
|
||||||
int? outputTokens = ReadIntField(usage, "output_tokens");
|
int? outputTokens = ReadIntField(usage, LlmWireKeys.OutputTokens);
|
||||||
return inputTokens is null || outputTokens is null
|
return inputTokens is null || outputTokens is null
|
||||||
? null
|
? null
|
||||||
: new ProviderUsage(inputTokens.Value, outputTokens.Value, inputTokens.Value + outputTokens.Value);
|
: new ProviderUsage(inputTokens.Value, outputTokens.Value, inputTokens.Value + outputTokens.Value);
|
||||||
@@ -264,8 +254,8 @@ public sealed class LlmHttpClient : IProviderClient
|
|||||||
private static JsonObject ChatMessage(string role, string content)
|
private static JsonObject ChatMessage(string role, string content)
|
||||||
=> new()
|
=> new()
|
||||||
{
|
{
|
||||||
["role"] = role,
|
[LlmWireKeys.Role] = role,
|
||||||
["content"] = content,
|
[LlmWireKeys.Content] = content,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Создаёт JSON-содержимое запроса (application/json).
|
// Создаёт JSON-содержимое запроса (application/json).
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
namespace Deal.Ai.Llm;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// JSON-ключи wire-формата запросов и ответов ИИ-провайдеров
|
||||||
|
/// </summary>
|
||||||
|
public static class LlmWireKeys
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Ключ имени модели.
|
||||||
|
/// </summary>
|
||||||
|
public const string Model = "model";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ключ списка сообщений чата.
|
||||||
|
/// </summary>
|
||||||
|
public const string Messages = "messages";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ключ системного промпта (Anthropic — отдельным полем).
|
||||||
|
/// </summary>
|
||||||
|
public const string System = "system";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ключ температуры генерации (OpenAI-совместимый формат).
|
||||||
|
/// </summary>
|
||||||
|
public const string Temperature = "temperature";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ключ лимита токенов ответа.
|
||||||
|
/// </summary>
|
||||||
|
public const string MaxTokens = "max_tokens";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ключ списка вариантов ответа (OpenAI-совместимый формат).
|
||||||
|
/// </summary>
|
||||||
|
public const string Choices = "choices";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ключ выбранного варианта ответа (OpenAI-совместимый формат).
|
||||||
|
/// </summary>
|
||||||
|
public const string Message = "message";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ключ текстового содержимого.
|
||||||
|
/// </summary>
|
||||||
|
public const string Content = "content";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ключ текста рассуждений модели (reasoning).
|
||||||
|
/// </summary>
|
||||||
|
public const string ReasoningContent = "reasoning_content";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ключ оценки токенов вызова.
|
||||||
|
/// </summary>
|
||||||
|
public const string Usage = "usage";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ключ текста блока контента (Anthropic).
|
||||||
|
/// </summary>
|
||||||
|
public const string Text = "text";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ключ числа токенов запроса (OpenAI-совместимый формат).
|
||||||
|
/// </summary>
|
||||||
|
public const string PromptTokens = "prompt_tokens";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ключ числа токенов ответа (OpenAI-совместимый формат).
|
||||||
|
/// </summary>
|
||||||
|
public const string CompletionTokens = "completion_tokens";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ключ суммарного числа токенов (OpenAI-совместимый формат).
|
||||||
|
/// </summary>
|
||||||
|
public const string TotalTokens = "total_tokens";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ключ числа входных токенов (Anthropic).
|
||||||
|
/// </summary>
|
||||||
|
public const string InputTokens = "input_tokens";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ключ числа выходных токенов (Anthropic).
|
||||||
|
/// </summary>
|
||||||
|
public const string OutputTokens = "output_tokens";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ключ роли сообщения чата.
|
||||||
|
/// </summary>
|
||||||
|
public const string Role = "role";
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ WebApplication app = AiServiceHost.Create(
|
|||||||
{
|
{
|
||||||
DealLogging.Configure(builder, aiProcessName);
|
DealLogging.Configure(builder, aiProcessName);
|
||||||
DealMetricsHosting.AddDealMetrics(builder, metricsPort);
|
DealMetricsHosting.AddDealMetrics(builder, metricsPort);
|
||||||
|
DealTracingHosting.AddDealTracing(builder, aiProcessName);
|
||||||
});
|
});
|
||||||
|
|
||||||
DealMetricsHosting.MapDealMetrics(app);
|
DealMetricsHosting.MapDealMetrics(app);
|
||||||
|
|||||||
@@ -42,6 +42,7 @@
|
|||||||
Версии — 1.17.x (Prometheus-экспортёр и gRPC-клиент только pre-release-линией; остальные —
|
Версии — 1.17.x (Prometheus-экспортёр и gRPC-клиент только pre-release-линией; остальные —
|
||||||
1.17.0 stable). Настройка — Deal.Api/Observability/DealMetricsHosting.cs. -->
|
1.17.0 stable). Настройка — Deal.Api/Observability/DealMetricsHosting.cs. -->
|
||||||
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.17.0" />
|
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.17.0" />
|
||||||
|
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.17.0" />
|
||||||
<PackageReference Include="OpenTelemetry.Exporter.Prometheus.AspNetCore" Version="1.17.0-beta.1" />
|
<PackageReference Include="OpenTelemetry.Exporter.Prometheus.AspNetCore" Version="1.17.0-beta.1" />
|
||||||
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.17.0" />
|
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.17.0" />
|
||||||
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.17.0" />
|
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.17.0" />
|
||||||
|
|||||||
@@ -1,153 +0,0 @@
|
|||||||
using System.Text.Json;
|
|
||||||
using Deal.Api.Extensions;
|
|
||||||
using Deal.Api.Services;
|
|
||||||
using Deal.Modules.Settings.Application.Abstractions;
|
|
||||||
using Deal.Modules.Settings.Application.Models;
|
|
||||||
|
|
||||||
namespace Deal.Api.Endpoints;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Эндпоинт проверки подключения AI-провайдера
|
|
||||||
/// </summary>
|
|
||||||
public static class AiCheckEndpoint
|
|
||||||
{
|
|
||||||
private const string ApiGroupPrefix = "/api";
|
|
||||||
|
|
||||||
// Путь проверки подключения AI-провайдера.
|
|
||||||
private const string AiCheckPath = "/ai/check";
|
|
||||||
|
|
||||||
private const string OpenApiTag = "settings";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Регистрирует POST /api/ai/check.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="app">Построитель маршрутов приложения.</param>
|
|
||||||
/// <returns>Построитель маршрутов для цепочки вызовов.</returns>
|
|
||||||
public static IEndpointRouteBuilder MapAiCheckEndpoint(this IEndpointRouteBuilder app)
|
|
||||||
{
|
|
||||||
var group = app.MapGroup(ApiGroupPrefix).WithTags(OpenApiTag);
|
|
||||||
group.MapPost(AiCheckPath, CheckAsync);
|
|
||||||
return app;
|
|
||||||
}
|
|
||||||
|
|
||||||
// POST /api/ai/check: проверка соединения с активным AI-провайдером тенанта.
|
|
||||||
private static async Task<IResult> CheckAsync(HttpContext context, CancellationToken ct)
|
|
||||||
{
|
|
||||||
if (context.GetCurrentUser() is null)
|
|
||||||
{
|
|
||||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Резолв после 401-гейта: ISettingsStore — scoped на TenantDbContext (tenant-контекст запроса).
|
|
||||||
ISettingsStore store = context.RequestServices.GetRequiredService<ISettingsStore>();
|
|
||||||
ISecretCipher secretCipher = context.RequestServices.GetRequiredService<ISecretCipher>();
|
|
||||||
IAiConnectionChecker checker = context.RequestServices.GetRequiredService<IAiConnectionChecker>();
|
|
||||||
|
|
||||||
AiCheckRequest checkRequest = await BuildActiveCheckRequestAsync(store, secretCipher, ct);
|
|
||||||
AiCheckResultDto result = await checker.CheckAsync(checkRequest, ct);
|
|
||||||
return Results.Ok(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<AiCheckRequest> BuildActiveCheckRequestAsync(
|
|
||||||
ISettingsStore store,
|
|
||||||
ISecretCipher secretCipher,
|
|
||||||
CancellationToken ct)
|
|
||||||
{
|
|
||||||
string providerId = await ReadActiveProviderIdAsync(store, ct);
|
|
||||||
AiProviderDefinition? meta = AiProviders.All.FirstOrDefault(provider => provider.Id == providerId);
|
|
||||||
|
|
||||||
// Неизвестный id (ручное вмешательство в БД — PATCH-гейт SettingsService не даёт сохранить):
|
|
||||||
// HTTP не выполняется — ответит SSRF-гейт checker (allowlist).
|
|
||||||
if (meta is null)
|
|
||||||
{
|
|
||||||
return new AiCheckRequest(providerId, string.Empty, string.Empty, string.Empty, IsLocal: false, ApiStyle: null);
|
|
||||||
}
|
|
||||||
|
|
||||||
AiConfigSetting config = await ReadEffectiveConfigAsync(store, providerId, ct);
|
|
||||||
string apiKey = secretCipher.Decrypt(config.ApiKey);
|
|
||||||
string baseUrl = string.IsNullOrEmpty(config.BaseUrl) ? meta.Base : config.BaseUrl;
|
|
||||||
string model = string.IsNullOrEmpty(config.Model)
|
|
||||||
? meta.Models.FirstOrDefault() ?? string.Empty
|
|
||||||
: config.Model;
|
|
||||||
|
|
||||||
return new AiCheckRequest(providerId, baseUrl, model, apiKey, meta.Local, meta.ApiStyle);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Читает активный провайдер: сохранённый aiProvider или дефолт (повреждённое значение — дефолт).
|
|
||||||
// store: KV-хранилище настроек тенанта.
|
|
||||||
// ct: Токен отмены.
|
|
||||||
// Возвращает: id провайдера.
|
|
||||||
private static async Task<string> ReadActiveProviderIdAsync(ISettingsStore store, CancellationToken ct)
|
|
||||||
{
|
|
||||||
SettingValue? row = await store.GetAsync(SettingsKeys.AiProvider, ct);
|
|
||||||
if (row is null)
|
|
||||||
{
|
|
||||||
return SettingsDefaults.AiProvider;
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
using JsonDocument document = JsonDocument.Parse(row.ValueJson);
|
|
||||||
if (document.RootElement.ValueKind == JsonValueKind.String)
|
|
||||||
{
|
|
||||||
return document.RootElement.GetString() ?? SettingsDefaults.AiProvider;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (JsonException)
|
|
||||||
{
|
|
||||||
// Повреждённая строка — дефолт (мягкая семантика, как в SettingsService).
|
|
||||||
}
|
|
||||||
|
|
||||||
return SettingsDefaults.AiProvider;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Эффективный конфиг провайдера: дефолт SettingsDefaults, перекрытый сохранённым aiConfigs.
|
|
||||||
// store: KV-хранилище настроек тенанта.
|
|
||||||
// providerId: Активный провайдер (id из каталога).
|
|
||||||
// ct: Токен отмены.
|
|
||||||
// Возвращает: Конфиг {apiKey, baseUrl, model}; повреждённая строка aiConfigs — дефолт.
|
|
||||||
private static async Task<AiConfigSetting> ReadEffectiveConfigAsync(
|
|
||||||
ISettingsStore store,
|
|
||||||
string providerId,
|
|
||||||
CancellationToken ct)
|
|
||||||
{
|
|
||||||
AiConfigSetting defaults = SettingsDefaults.AiConfigs[providerId];
|
|
||||||
SettingValue? row = await store.GetAsync(SettingsKeys.AiConfigs, ct);
|
|
||||||
if (row is null)
|
|
||||||
{
|
|
||||||
return defaults;
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
using JsonDocument document = JsonDocument.Parse(row.ValueJson);
|
|
||||||
JsonElement root = document.RootElement;
|
|
||||||
if (root.ValueKind == JsonValueKind.Object
|
|
||||||
&& root.TryGetProperty(providerId, out JsonElement entry)
|
|
||||||
&& entry.ValueKind == JsonValueKind.Object)
|
|
||||||
{
|
|
||||||
return new AiConfigSetting(
|
|
||||||
ApiKey: ReadField(entry, "apiKey") ?? defaults.ApiKey,
|
|
||||||
BaseUrl: ReadField(entry, "baseUrl") ?? defaults.BaseUrl,
|
|
||||||
Model: ReadField(entry, "model") ?? defaults.Model);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (JsonException)
|
|
||||||
{
|
|
||||||
// Повреждённая строка — дефолт (не роняем проверку).
|
|
||||||
}
|
|
||||||
|
|
||||||
return defaults;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Читает строковое поле объекта конфигурации (имена полей camelCase, как пишет SettingsService).
|
|
||||||
// entry: JSON-объект конфигурации провайдера.
|
|
||||||
// field: Имя поля (apiKey/baseUrl/model).
|
|
||||||
// Возвращает: Значение или null, если поле отсутствует/не строка.
|
|
||||||
private static string? ReadField(JsonElement entry, string field)
|
|
||||||
{
|
|
||||||
return entry.TryGetProperty(field, out JsonElement value) && value.ValueKind == JsonValueKind.String
|
|
||||||
? value.GetString()
|
|
||||||
: null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -68,7 +68,7 @@ public static class AuthEndpoints
|
|||||||
ActorId: result.UserId,
|
ActorId: result.UserId,
|
||||||
TenantId: result.TenantId,
|
TenantId: result.TenantId,
|
||||||
Ip: ClientIp(context),
|
Ip: ClientIp(context),
|
||||||
DetailJson: AuditService.ToDetailJson(new { login = NormalizeLogin(body.Login) })), ct);
|
DetailJson: AuditService.ToDetailJson([AuditDetails.Set(AuditFields.Login, NormalizeLogin(body.Login))])), ct);
|
||||||
|
|
||||||
return EndpointResults.Forbidden(TenantSuspendedDetail);
|
return EndpointResults.Forbidden(TenantSuspendedDetail);
|
||||||
}
|
}
|
||||||
@@ -84,7 +84,7 @@ public static class AuthEndpoints
|
|||||||
ActorId: null,
|
ActorId: null,
|
||||||
TenantId: null,
|
TenantId: null,
|
||||||
Ip: ClientIp(context),
|
Ip: ClientIp(context),
|
||||||
DetailJson: AuditService.ToDetailJson(new { login = attemptedLogin })), ct);
|
DetailJson: AuditService.ToDetailJson([AuditDetails.Set(AuditFields.Login, attemptedLogin)])), ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
return EndpointResults.Unauthorized(InvalidCredentialsDetail);
|
return EndpointResults.Unauthorized(InvalidCredentialsDetail);
|
||||||
@@ -98,7 +98,7 @@ public static class AuthEndpoints
|
|||||||
ActorId: result.UserId,
|
ActorId: result.UserId,
|
||||||
TenantId: result.TenantId,
|
TenantId: result.TenantId,
|
||||||
Ip: ClientIp(context),
|
Ip: ClientIp(context),
|
||||||
DetailJson: AuditService.ToDetailJson(new { login = result.Login })), ct);
|
DetailJson: AuditService.ToDetailJson([AuditDetails.Set(AuditFields.Login, result.Login)])), ct);
|
||||||
|
|
||||||
SessionCookieWriter.Append(context, cookieOptions.Value, result.Token);
|
SessionCookieWriter.Append(context, cookieOptions.Value, result.Token);
|
||||||
return Results.Ok(new { ok = true, login = result.Login });
|
return Results.Ok(new { ok = true, login = result.Login });
|
||||||
@@ -125,12 +125,12 @@ public static class AuthEndpoints
|
|||||||
ActorId: logout.OperatorId,
|
ActorId: logout.OperatorId,
|
||||||
TenantId: logout.TenantId,
|
TenantId: logout.TenantId,
|
||||||
Ip: ClientIp(context),
|
Ip: ClientIp(context),
|
||||||
DetailJson: AuditService.ToDetailJson(new { login = logout.Login })), ct);
|
DetailJson: AuditService.ToDetailJson([AuditDetails.Set(AuditFields.Login, logout.Login)])), ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (user is not null)
|
if (user is not null)
|
||||||
{
|
{
|
||||||
await AuditAppender.AppendTenantAsync(context, AuditEvents.TenantLogout, new { login = user.Login }, ct);
|
await AuditAppender.AppendTenantAsync(context, AuditEvents.TenantLogout, [AuditDetails.Set(AuditFields.Login, user.Login)], ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
context.Response.Cookies.Delete(cookieName);
|
context.Response.Cookies.Delete(cookieName);
|
||||||
|
|||||||
@@ -120,7 +120,7 @@ public static class CardDetailsEndpoints
|
|||||||
CardsService service = context.RequestServices.GetRequiredService<CardsService>();
|
CardsService service = context.RequestServices.GetRequiredService<CardsService>();
|
||||||
CardDto created = await service.CreateLocalCardAsync(ToCreateLocalDto(body), ct);
|
CardDto created = await service.CreateLocalCardAsync(ToCreateLocalDto(body), ct);
|
||||||
|
|
||||||
await AuditAppender.AppendTenantAsync(context, AuditEvents.CardCreated, new { cardId = created.Id }, ct);
|
await AuditAppender.AppendTenantAsync(context, AuditEvents.CardCreated, [AuditDetails.Set(AuditFields.CardId, created.Id)], ct);
|
||||||
return await ReadCardAsync(context, created.Id, ct);
|
return await ReadCardAsync(context, created.Id, ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,10 +136,8 @@ public static class CardDetailsEndpoints
|
|||||||
}
|
}
|
||||||
|
|
||||||
CardsService service = context.RequestServices.GetRequiredService<CardsService>();
|
CardsService service = context.RequestServices.GetRequiredService<CardsService>();
|
||||||
CardDto? card = await service.TakeCardAsync(body.CardId ?? body.LeadId ?? string.Empty, ct);
|
CardDto card = await service.TakeCardAsync(body.CardId ?? body.LeadId ?? string.Empty, ct);
|
||||||
return card is null
|
return await ReadCardAsync(context, card.Id, ct);
|
||||||
? EndpointResults.NotFound(CardNotFoundDetail)
|
|
||||||
: await ReadCardAsync(context, card.Id, ct);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST /api/cards/clear-rejected: полная очистка терминальной стадии «Отклонено».
|
// POST /api/cards/clear-rejected: полная очистка терминальной стадии «Отклонено».
|
||||||
@@ -204,9 +202,7 @@ public static class CardDetailsEndpoints
|
|||||||
return EndpointResults.BadRequest(result.Error);
|
return EndpointResults.BadRequest(result.Error);
|
||||||
}
|
}
|
||||||
|
|
||||||
return result.Card is null
|
return await ReadCardAsync(context, cardId, ct);
|
||||||
? EndpointResults.NotFound(CardNotFoundDetail)
|
|
||||||
: await ReadCardAsync(context, cardId, ct);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// DELETE /api/cards/{cardId}/links/{linkId}: удалить ссылку. Ответ — карточка.
|
// DELETE /api/cards/{cardId}/links/{linkId}: удалить ссылку. Ответ — карточка.
|
||||||
@@ -222,10 +218,8 @@ public static class CardDetailsEndpoints
|
|||||||
}
|
}
|
||||||
|
|
||||||
CardsService service = context.RequestServices.GetRequiredService<CardsService>();
|
CardsService service = context.RequestServices.GetRequiredService<CardsService>();
|
||||||
CardResultDto result = await service.RemoveLinkAsync(cardId, linkId, ct);
|
await service.RemoveLinkAsync(cardId, linkId, ct);
|
||||||
return result.Card is null
|
return await ReadCardAsync(context, cardId, ct);
|
||||||
? EndpointResults.NotFound(CardNotFoundDetail)
|
|
||||||
: await ReadCardAsync(context, cardId, ct);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST /api/cards/{cardId}/files: загрузка вложений (multipart/form-data, поле files).
|
// POST /api/cards/{cardId}/files: загрузка вложений (multipart/form-data, поле files).
|
||||||
@@ -242,10 +236,7 @@ public static class CardDetailsEndpoints
|
|||||||
}
|
}
|
||||||
|
|
||||||
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
|
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
|
||||||
if (await cardsService.GetCardAsync(cardId, ct) is null)
|
await cardsService.GetCardAsync(cardId, ct);
|
||||||
{
|
|
||||||
return EndpointResults.NotFound(CardNotFoundDetail);
|
|
||||||
}
|
|
||||||
|
|
||||||
IFormCollection form;
|
IFormCollection form;
|
||||||
try
|
try
|
||||||
@@ -261,11 +252,7 @@ public static class CardDetailsEndpoints
|
|||||||
foreach (IFormFile file in form.Files)
|
foreach (IFormFile file in form.Files)
|
||||||
{
|
{
|
||||||
await using Stream content = file.OpenReadStream();
|
await using Stream content = file.OpenReadStream();
|
||||||
CardFileDto? entry = await cardsService.AddFileAsync(cardId, file.FileName, file.ContentType, content, file.Length, ct);
|
await cardsService.AddFileAsync(cardId, file.FileName, file.ContentType, content, file.Length, ct);
|
||||||
if (entry is null)
|
|
||||||
{
|
|
||||||
return EndpointResults.NotFound(CardNotFoundDetail);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return await ReadCardAsync(context, cardId, ct);
|
return await ReadCardAsync(context, cardId, ct);
|
||||||
@@ -286,11 +273,7 @@ public static class CardDetailsEndpoints
|
|||||||
}
|
}
|
||||||
|
|
||||||
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
|
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
|
||||||
CardFileDto? entry = await cardsService.GetFileEntryAsync(cardId, fileId, ct);
|
CardFileDto entry = await cardsService.GetFileEntryAsync(cardId, fileId, ct);
|
||||||
if (entry is null)
|
|
||||||
{
|
|
||||||
return EndpointResults.NotFound(CardNotFoundDetail);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(entry.ObjectKey))
|
if (string.IsNullOrWhiteSpace(entry.ObjectKey))
|
||||||
{
|
{
|
||||||
@@ -339,10 +322,8 @@ public static class CardDetailsEndpoints
|
|||||||
}
|
}
|
||||||
|
|
||||||
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
|
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
|
||||||
CardDto? card = await cardsService.RemoveFileAsync(cardId, fileId, ct);
|
await cardsService.RemoveFileAsync(cardId, fileId, ct);
|
||||||
return card is null
|
return await ReadCardAsync(context, cardId, ct);
|
||||||
? EndpointResults.NotFound(CardNotFoundDetail)
|
|
||||||
: await ReadCardAsync(context, cardId, ct);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST /api/cards/{cardId}/reminder {at: epoch-ms}: установить напоминание. Ответ — карточка.
|
// POST /api/cards/{cardId}/reminder {at: epoch-ms}: установить напоминание. Ответ — карточка.
|
||||||
@@ -369,9 +350,7 @@ public static class CardDetailsEndpoints
|
|||||||
return EndpointResults.BadRequest(result.Error);
|
return EndpointResults.BadRequest(result.Error);
|
||||||
}
|
}
|
||||||
|
|
||||||
return result.Card is null
|
return await ReadCardAsync(context, cardId, ct);
|
||||||
? EndpointResults.NotFound(CardNotFoundDetail)
|
|
||||||
: await ReadCardAsync(context, cardId, ct);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// DELETE /api/cards/{cardId}/reminder: снять напоминание. Ответ — карточка.
|
// DELETE /api/cards/{cardId}/reminder: снять напоминание. Ответ — карточка.
|
||||||
@@ -386,9 +365,8 @@ public static class CardDetailsEndpoints
|
|||||||
}
|
}
|
||||||
|
|
||||||
CardsService service = context.RequestServices.GetRequiredService<CardsService>();
|
CardsService service = context.RequestServices.GetRequiredService<CardsService>();
|
||||||
return await service.ClearReminderAsync(cardId, ct)
|
await service.ClearReminderAsync(cardId, ct);
|
||||||
? await ReadCardAsync(context, cardId, ct)
|
return await ReadCardAsync(context, cardId, ct);
|
||||||
: EndpointResults.NotFound(CardNotFoundDetail);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST /api/cards/{cardId}/reminder/snooze: «напомнить позже» (now + 24 ч). Ответ — карточка.
|
// POST /api/cards/{cardId}/reminder/snooze: «напомнить позже» (now + 24 ч). Ответ — карточка.
|
||||||
@@ -403,9 +381,8 @@ public static class CardDetailsEndpoints
|
|||||||
}
|
}
|
||||||
|
|
||||||
CardsService service = context.RequestServices.GetRequiredService<CardsService>();
|
CardsService service = context.RequestServices.GetRequiredService<CardsService>();
|
||||||
return await service.SnoozeReminderAsync(cardId, ct)
|
await service.SnoozeReminderAsync(cardId, ct);
|
||||||
? await ReadCardAsync(context, cardId, ct)
|
return await ReadCardAsync(context, cardId, ct);
|
||||||
: EndpointResults.NotFound(CardNotFoundDetail);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Читает карточку через единый сервис и возвращает её как ответ (404 — карточки нет).
|
// Читает карточку через единый сервис и возвращает её как ответ (404 — карточки нет).
|
||||||
@@ -424,11 +401,7 @@ public static class CardDetailsEndpoints
|
|||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
|
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
|
||||||
CardDto? card = await cardsService.GetCardAsync(cardId, ct);
|
CardDto card = await cardsService.GetCardAsync(cardId, ct);
|
||||||
if (card is null)
|
|
||||||
{
|
|
||||||
return EndpointResults.NotFound(CardNotFoundDetail);
|
|
||||||
}
|
|
||||||
|
|
||||||
SourceContent content = await cardsService.ResolveSourceAsync(card, ct);
|
SourceContent content = await cardsService.ResolveSourceAsync(card, ct);
|
||||||
return Results.Ok(content);
|
return Results.Ok(content);
|
||||||
@@ -440,10 +413,8 @@ public static class CardDetailsEndpoints
|
|||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
|
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
|
||||||
CardDto? card = await cardsService.GetCardAsync(cardId, ct);
|
CardDto card = await cardsService.GetCardAsync(cardId, ct);
|
||||||
return card is null
|
return Results.Ok(card);
|
||||||
? EndpointResults.NotFound(CardNotFoundDetail)
|
|
||||||
: Results.Ok(card);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Имя файла для Content-Disposition без кавычек «"».
|
// Имя файла для Content-Disposition без кавычек «"».
|
||||||
|
|||||||
@@ -105,15 +105,15 @@ public static class CardsEndpoints
|
|||||||
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
|
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
|
||||||
CardCountsDto counts = await cardsService.CountsAsync(ct);
|
CardCountsDto counts = await cardsService.CountsAsync(ct);
|
||||||
|
|
||||||
var wire = new Dictionary<string, object> { ["new"] = counts.New };
|
var wire = new Dictionary<string, object> { [KanbanWireKeys.New] = counts.New };
|
||||||
foreach ((string col, CardColumnCountDto column) in counts.Columns)
|
foreach ((string col, CardColumnCountDto column) in counts.Columns)
|
||||||
{
|
{
|
||||||
wire[col] = column;
|
wire[col] = column;
|
||||||
}
|
}
|
||||||
|
|
||||||
wire["learning"] = counts.Learning;
|
wire[KanbanWireKeys.Learning] = counts.Learning;
|
||||||
wire["ml"] = counts.Ml;
|
wire[KanbanWireKeys.Ml] = counts.Ml;
|
||||||
wire["ai"] = counts.Ai;
|
wire[KanbanWireKeys.Ai] = counts.Ai;
|
||||||
return Results.Ok(wire);
|
return Results.Ok(wire);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,10 +128,8 @@ public static class CardsEndpoints
|
|||||||
}
|
}
|
||||||
|
|
||||||
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
|
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
|
||||||
CardDto? card = await cardsService.GetCardAsync(cardId, ct);
|
CardDto card = await cardsService.GetCardAsync(cardId, ct);
|
||||||
return card is null
|
return Results.Ok(card);
|
||||||
? EndpointResults.NotFound(CardNotFoundDetail)
|
|
||||||
: Results.Ok(card);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IResult> MarkAllSeenAsync(HttpContext context, CancellationToken ct)
|
private static async Task<IResult> MarkAllSeenAsync(HttpContext context, CancellationToken ct)
|
||||||
@@ -184,18 +182,18 @@ public static class CardsEndpoints
|
|||||||
return EndpointResults.BadRequest(outcome.Error);
|
return EndpointResults.BadRequest(outcome.Error);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!outcome.Exists)
|
await AuditAppender.AppendTenantAsync(
|
||||||
{
|
context,
|
||||||
return EndpointResults.NotFound(CardNotFoundDetail);
|
AuditEvents.CardMoved,
|
||||||
}
|
[
|
||||||
|
AuditDetails.Set(AuditFields.CardId, cardId),
|
||||||
await AuditAppender.AppendTenantAsync(context, AuditEvents.CardMoved, new { cardId, to = body.To }, ct);
|
AuditDetails.Change(AuditFields.ContainerId, outcome.From, outcome.To),
|
||||||
|
],
|
||||||
|
ct);
|
||||||
|
|
||||||
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
|
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
|
||||||
CardDto? unified = await cardsService.GetCardAsync(cardId, ct);
|
CardDto unified = await cardsService.GetCardAsync(cardId, ct);
|
||||||
return unified is null
|
return Results.Ok(unified);
|
||||||
? EndpointResults.NotFound(CardNotFoundDetail)
|
|
||||||
: Results.Ok(unified);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IResult> TrashAsync(
|
private static async Task<IResult> TrashAsync(
|
||||||
@@ -209,13 +207,9 @@ public static class CardsEndpoints
|
|||||||
}
|
}
|
||||||
|
|
||||||
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
|
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
|
||||||
CardDto? card = await cardsService.TrashCardAsync(cardId, ct);
|
await cardsService.TrashCardAsync(cardId, ct);
|
||||||
if (card is null)
|
|
||||||
{
|
|
||||||
return EndpointResults.NotFound(CardNotFoundDetail);
|
|
||||||
}
|
|
||||||
|
|
||||||
await AuditAppender.AppendTenantAsync(context, AuditEvents.CardTrashed, new { cardId }, ct);
|
await AuditAppender.AppendTenantAsync(context, AuditEvents.CardTrashed, [AuditDetails.Set(AuditFields.CardId, cardId)], ct);
|
||||||
return Results.Ok(new { ok = true });
|
return Results.Ok(new { ok = true });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -230,13 +224,9 @@ public static class CardsEndpoints
|
|||||||
}
|
}
|
||||||
|
|
||||||
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
|
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
|
||||||
string? col = await cardsService.RestoreCardAsync(cardId, ct);
|
string col = await cardsService.RestoreCardAsync(cardId, ct);
|
||||||
if (col is null)
|
|
||||||
{
|
|
||||||
return EndpointResults.NotFound(CardNotFoundDetail);
|
|
||||||
}
|
|
||||||
|
|
||||||
await AuditAppender.AppendTenantAsync(context, AuditEvents.CardRestored, new { cardId, col }, ct);
|
await AuditAppender.AppendTenantAsync(context, AuditEvents.CardRestored, [AuditDetails.Set(AuditFields.CardId, cardId), AuditDetails.Set(AuditFields.Column, col)], ct);
|
||||||
return Results.Ok(new { ok = true, col });
|
return Results.Ok(new { ok = true, col });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -257,7 +247,7 @@ public static class CardsEndpoints
|
|||||||
return EndpointResults.NotFound(CardNotFoundDetail);
|
return EndpointResults.NotFound(CardNotFoundDetail);
|
||||||
}
|
}
|
||||||
|
|
||||||
await AuditAppender.AppendTenantAsync(context, AuditEvents.CardDeleted, new { cardId }, ct);
|
await AuditAppender.AppendTenantAsync(context, AuditEvents.CardDeleted, [AuditDetails.Set(AuditFields.CardId, cardId)], ct);
|
||||||
return Results.Ok(new { ok = true });
|
return Results.Ok(new { ok = true });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -301,7 +291,7 @@ public static class CardsEndpoints
|
|||||||
return EndpointResults.NotFound(CardNotFoundDetail);
|
return EndpointResults.NotFound(CardNotFoundDetail);
|
||||||
}
|
}
|
||||||
|
|
||||||
await AuditAppender.AppendTenantAsync(context, AuditEvents.CardCommentAdded, new { cardId }, ct);
|
await AuditAppender.AppendTenantAsync(context, AuditEvents.CardCommentAdded, [AuditDetails.Set(AuditFields.CardId, cardId)], ct);
|
||||||
return Results.Ok(new { comments = result.Comments });
|
return Results.Ok(new { comments = result.Comments });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -361,11 +351,7 @@ public static class CardsEndpoints
|
|||||||
}
|
}
|
||||||
|
|
||||||
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
|
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
|
||||||
CardDto? card = await cardsService.GetCardAsync(cardId, ct);
|
CardDto card = await cardsService.GetCardAsync(cardId, ct);
|
||||||
if (card is null)
|
|
||||||
{
|
|
||||||
return EndpointResults.NotFound(CardNotFoundDetail);
|
|
||||||
}
|
|
||||||
|
|
||||||
CardReclassifier reclassifier = context.RequestServices.GetRequiredService<CardReclassifier>();
|
CardReclassifier reclassifier = context.RequestServices.GetRequiredService<CardReclassifier>();
|
||||||
ReclassifyResultDto result = await reclassifier.ReclassifyCardAsync(card, ct);
|
ReclassifyResultDto result = await reclassifier.ReclassifyCardAsync(card, ct);
|
||||||
@@ -422,7 +408,12 @@ public static class CardsEndpoints
|
|||||||
return AuditAppender.AppendTenantAsync(
|
return AuditAppender.AppendTenantAsync(
|
||||||
context,
|
context,
|
||||||
AuditEvents.CardReclassified,
|
AuditEvents.CardReclassified,
|
||||||
new { attempted = result.Attempted, reclassified = result.Reclassified, moved = result.Moved, trashed = result.Trashed },
|
[
|
||||||
|
AuditDetails.Set(AuditFields.Attempted, result.Attempted),
|
||||||
|
AuditDetails.Set(AuditFields.Reclassified, result.Reclassified),
|
||||||
|
AuditDetails.Set(AuditFields.Moved, result.Moved),
|
||||||
|
AuditDetails.Set(AuditFields.Trashed, result.Trashed),
|
||||||
|
],
|
||||||
ct);
|
ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,9 +19,6 @@ public static class ContainersEndpoints
|
|||||||
// OpenAPI-тег группы.
|
// OpenAPI-тег группы.
|
||||||
private const string OpenApiTag = "containers";
|
private const string OpenApiTag = "containers";
|
||||||
|
|
||||||
// 404 PATCH/accept: контейнер не найден.
|
|
||||||
private const string ContainerNotFoundDetail = "Контейнер не найден";
|
|
||||||
|
|
||||||
// 400: отсутствующий/явный null name контейнера.
|
// 400: отсутствующий/явный null name контейнера.
|
||||||
private const string ContainerNameRequiredDetail = "Укажите название колонки";
|
private const string ContainerNameRequiredDetail = "Укажите название колонки";
|
||||||
|
|
||||||
@@ -97,7 +94,7 @@ public static class ContainersEndpoints
|
|||||||
Note: body.Note ?? string.Empty),
|
Note: body.Note ?? string.Empty),
|
||||||
ct);
|
ct);
|
||||||
|
|
||||||
await AuditAppender.AppendTenantAsync(context, AuditEvents.ContainerCreated, new { id = created.Id, name = created.Name }, ct);
|
await AuditAppender.AppendTenantAsync(context, AuditEvents.ContainerCreated, [AuditDetails.Set(AuditFields.ContainerId, created.Id), AuditDetails.Set(AuditFields.Name, created.Name)], ct);
|
||||||
return Results.Ok(new { id = created.Id });
|
return Results.Ok(new { id = created.Id });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,7 +140,8 @@ public static class ContainersEndpoints
|
|||||||
}
|
}
|
||||||
|
|
||||||
ContainersService containers = context.RequestServices.GetRequiredService<ContainersService>();
|
ContainersService containers = context.RequestServices.GetRequiredService<ContainersService>();
|
||||||
ContainerDto? updated = await containers.PatchAsync(
|
ContainerDto before = await containers.GetAsync(containerId, ct);
|
||||||
|
ContainerDto updated = await containers.PatchAsync(
|
||||||
containerId,
|
containerId,
|
||||||
new ContainerPatchDto(
|
new ContainerPatchDto(
|
||||||
patchBody.Name,
|
patchBody.Name,
|
||||||
@@ -155,12 +153,14 @@ public static class ContainersEndpoints
|
|||||||
NormalizeWireRules(patchBody.Rules),
|
NormalizeWireRules(patchBody.Rules),
|
||||||
patchBody.Policy),
|
patchBody.Policy),
|
||||||
ct);
|
ct);
|
||||||
if (updated is null)
|
|
||||||
{
|
|
||||||
return EndpointResults.NotFound(ContainerNotFoundDetail);
|
|
||||||
}
|
|
||||||
|
|
||||||
await AuditAppender.AppendTenantAsync(context, AuditEvents.ContainerUpdated, new { id = updated.Id }, ct);
|
await AuditAppender.AppendTenantAsync(
|
||||||
|
context,
|
||||||
|
AuditEvents.ContainerUpdated,
|
||||||
|
string.Equals(before.Name, updated.Name, StringComparison.Ordinal)
|
||||||
|
? [AuditDetails.Set(AuditFields.ContainerId, updated.Id)]
|
||||||
|
: [AuditDetails.Change(AuditFields.Name, before.Name, updated.Name)],
|
||||||
|
ct);
|
||||||
return Results.Ok(new { id = updated.Id });
|
return Results.Ok(new { id = updated.Id });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -176,13 +176,9 @@ public static class ContainersEndpoints
|
|||||||
}
|
}
|
||||||
|
|
||||||
ContainersService containers = context.RequestServices.GetRequiredService<ContainersService>();
|
ContainersService containers = context.RequestServices.GetRequiredService<ContainersService>();
|
||||||
ContainerDto? accepted = await containers.AcceptSuggestedAsync(containerId, ct);
|
ContainerDto accepted = await containers.AcceptSuggestedAsync(containerId, ct);
|
||||||
if (accepted is null)
|
|
||||||
{
|
|
||||||
return EndpointResults.NotFound(ContainerNotFoundDetail);
|
|
||||||
}
|
|
||||||
|
|
||||||
await AuditAppender.AppendTenantAsync(context, AuditEvents.ContainerUpdated, new { id = accepted.Id }, ct);
|
await AuditAppender.AppendTenantAsync(context, AuditEvents.ContainerUpdated, [AuditDetails.Set(AuditFields.ContainerId, accepted.Id)], ct);
|
||||||
return Results.Ok(accepted);
|
return Results.Ok(accepted);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -200,7 +196,7 @@ public static class ContainersEndpoints
|
|||||||
ContainersService containers = context.RequestServices.GetRequiredService<ContainersService>();
|
ContainersService containers = context.RequestServices.GetRequiredService<ContainersService>();
|
||||||
int moved = await containers.DeleteAsync(containerId, ct);
|
int moved = await containers.DeleteAsync(containerId, ct);
|
||||||
|
|
||||||
await AuditAppender.AppendTenantAsync(context, AuditEvents.ContainerDeleted, new { id = containerId }, ct);
|
await AuditAppender.AppendTenantAsync(context, AuditEvents.ContainerDeleted, [AuditDetails.Set(AuditFields.ContainerId, containerId)], ct);
|
||||||
return Results.Ok(new { ok = true, movedToInbox = moved });
|
return Results.Ok(new { ok = true, movedToInbox = moved });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -299,12 +295,12 @@ public static class ContainersEndpoints
|
|||||||
var wire = new Dictionary<string, object>();
|
var wire = new Dictionary<string, object>();
|
||||||
if (state.Collapsed is { } collapsed)
|
if (state.Collapsed is { } collapsed)
|
||||||
{
|
{
|
||||||
wire["collapsed"] = collapsed;
|
wire[KanbanWireKeys.Collapsed] = collapsed;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state.Width is not null)
|
if (state.Width is not null)
|
||||||
{
|
{
|
||||||
wire["width"] = state.Width;
|
wire[KanbanWireKeys.Width] = state.Width;
|
||||||
}
|
}
|
||||||
|
|
||||||
return wire;
|
return wire;
|
||||||
|
|||||||
@@ -55,10 +55,6 @@ public static class DiscoveryEndpoints
|
|||||||
// Путь лога задачи (GET).
|
// Путь лога задачи (GET).
|
||||||
private const string TaskLogPath = "/tasks/{task_id}/log";
|
private const string TaskLogPath = "/tasks/{task_id}/log";
|
||||||
|
|
||||||
private const string TaskNotFoundDetail = "Задача не найдена";
|
|
||||||
|
|
||||||
private const string CandidateNotFoundDetail = "Кандидат не найден";
|
|
||||||
|
|
||||||
private const string AlreadyJoinedDetail = "Уже вступили в этот источник";
|
private const string AlreadyJoinedDetail = "Уже вступили в этот источник";
|
||||||
|
|
||||||
private const string JoinedRejectDetail = "Уже вступили — удалите источник из каналов";
|
private const string JoinedRejectDetail = "Уже вступили — удалите источник из каналов";
|
||||||
@@ -149,8 +145,8 @@ public static class DiscoveryEndpoints
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
DiscoveryTasksService tasks = context.RequestServices.GetRequiredService<DiscoveryTasksService>();
|
DiscoveryTasksService tasks = context.RequestServices.GetRequiredService<DiscoveryTasksService>();
|
||||||
DiscoveryTaskDto? task = await tasks.PatchAsync(task_id, ToPatch(body), ct);
|
DiscoveryTaskDto task = await tasks.PatchAsync(task_id, ToPatch(body), ct);
|
||||||
return task is null ? EndpointResults.NotFound(TaskNotFoundDetail) : Results.Ok(task);
|
return Results.Ok(task);
|
||||||
}
|
}
|
||||||
catch (DiscoveryValidationException exception)
|
catch (DiscoveryValidationException exception)
|
||||||
{
|
{
|
||||||
@@ -169,8 +165,8 @@ public static class DiscoveryEndpoints
|
|||||||
}
|
}
|
||||||
|
|
||||||
DiscoveryTasksService tasks = context.RequestServices.GetRequiredService<DiscoveryTasksService>();
|
DiscoveryTasksService tasks = context.RequestServices.GetRequiredService<DiscoveryTasksService>();
|
||||||
bool deleted = await tasks.DeleteAsync(task_id, ct);
|
await tasks.DeleteAsync(task_id, ct);
|
||||||
return deleted ? Results.Ok(new { ok = true }) : EndpointResults.NotFound(TaskNotFoundDetail);
|
return Results.Ok(new { ok = true });
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IResult> StartTaskAsync(
|
private static async Task<IResult> StartTaskAsync(
|
||||||
@@ -186,8 +182,8 @@ public static class DiscoveryEndpoints
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
DiscoveryTasksService tasks = context.RequestServices.GetRequiredService<DiscoveryTasksService>();
|
DiscoveryTasksService tasks = context.RequestServices.GetRequiredService<DiscoveryTasksService>();
|
||||||
DiscoveryTaskDto? task = await tasks.StartAsync(task_id, ct);
|
DiscoveryTaskDto task = await tasks.StartAsync(task_id, ct);
|
||||||
return task is null ? EndpointResults.NotFound(TaskNotFoundDetail) : Results.Ok(task);
|
return Results.Ok(task);
|
||||||
}
|
}
|
||||||
catch (DiscoveryValidationException exception)
|
catch (DiscoveryValidationException exception)
|
||||||
{
|
{
|
||||||
@@ -206,8 +202,8 @@ public static class DiscoveryEndpoints
|
|||||||
}
|
}
|
||||||
|
|
||||||
DiscoveryTasksService tasks = context.RequestServices.GetRequiredService<DiscoveryTasksService>();
|
DiscoveryTasksService tasks = context.RequestServices.GetRequiredService<DiscoveryTasksService>();
|
||||||
DiscoveryTaskDto? task = await tasks.PauseAsync(task_id, ct);
|
DiscoveryTaskDto task = await tasks.PauseAsync(task_id, ct);
|
||||||
return task is null ? EndpointResults.NotFound(TaskNotFoundDetail) : Results.Ok(task);
|
return Results.Ok(task);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IResult> GenerateKeywordsAsync(
|
private static async Task<IResult> GenerateKeywordsAsync(
|
||||||
@@ -221,11 +217,7 @@ public static class DiscoveryEndpoints
|
|||||||
}
|
}
|
||||||
|
|
||||||
DiscoveryTasksService tasks = context.RequestServices.GetRequiredService<DiscoveryTasksService>();
|
DiscoveryTasksService tasks = context.RequestServices.GetRequiredService<DiscoveryTasksService>();
|
||||||
DiscoveryTaskDto? task = await tasks.GetAsync(task_id, ct);
|
DiscoveryTaskDto task = await tasks.GetAsync(task_id, ct);
|
||||||
if (task is null)
|
|
||||||
{
|
|
||||||
return EndpointResults.NotFound(TaskNotFoundDetail);
|
|
||||||
}
|
|
||||||
|
|
||||||
ISettingsStore settings = context.RequestServices.GetRequiredService<ISettingsStore>();
|
ISettingsStore settings = context.RequestServices.GetRequiredService<ISettingsStore>();
|
||||||
if (!await ReadAiEnabledAsync(settings, ct))
|
if (!await ReadAiEnabledAsync(settings, ct))
|
||||||
@@ -268,11 +260,7 @@ public static class DiscoveryEndpoints
|
|||||||
}
|
}
|
||||||
|
|
||||||
DiscoveryTasksService tasks = context.RequestServices.GetRequiredService<DiscoveryTasksService>();
|
DiscoveryTasksService tasks = context.RequestServices.GetRequiredService<DiscoveryTasksService>();
|
||||||
DiscoveryTaskDto? task = await tasks.GetAsync(task_id, ct);
|
DiscoveryTaskDto task = await tasks.GetAsync(task_id, ct);
|
||||||
if (task is null)
|
|
||||||
{
|
|
||||||
return EndpointResults.NotFound(TaskNotFoundDetail);
|
|
||||||
}
|
|
||||||
|
|
||||||
DiscoveryCandidatesService candidates = context.RequestServices.GetRequiredService<DiscoveryCandidatesService>();
|
DiscoveryCandidatesService candidates = context.RequestServices.GetRequiredService<DiscoveryCandidatesService>();
|
||||||
IReadOnlyList<DiscoveryCandidateDto> items = await candidates.ListAsync(task_id, status, ct);
|
IReadOnlyList<DiscoveryCandidateDto> items = await candidates.ListAsync(task_id, status, ct);
|
||||||
@@ -290,11 +278,7 @@ public static class DiscoveryEndpoints
|
|||||||
}
|
}
|
||||||
|
|
||||||
DiscoveryCandidatesService candidates = context.RequestServices.GetRequiredService<DiscoveryCandidatesService>();
|
DiscoveryCandidatesService candidates = context.RequestServices.GetRequiredService<DiscoveryCandidatesService>();
|
||||||
DiscoveryCandidateDto? row = await candidates.GetAsync(dialog_id, ct);
|
DiscoveryCandidateDto row = await candidates.GetAsync(dialog_id, ct);
|
||||||
if (row is null)
|
|
||||||
{
|
|
||||||
return EndpointResults.NotFound(CandidateNotFoundDetail);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (row.Status == DiscoveryCandidateStatuses.Joined)
|
if (row.Status == DiscoveryCandidateStatuses.Joined)
|
||||||
{
|
{
|
||||||
@@ -323,8 +307,8 @@ public static class DiscoveryEndpoints
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
DiscoveryCandidateDto? joined = await candidates.MarkJoinedAsync(dialog_id, auto: false, ct);
|
DiscoveryCandidateDto joined = await candidates.MarkJoinedAsync(dialog_id, auto: false, ct);
|
||||||
return joined is null ? EndpointResults.NotFound(CandidateNotFoundDetail) : Results.Ok(joined);
|
return Results.Ok(joined);
|
||||||
}
|
}
|
||||||
catch (DiscoveryValidationException exception)
|
catch (DiscoveryValidationException exception)
|
||||||
{
|
{
|
||||||
@@ -343,11 +327,7 @@ public static class DiscoveryEndpoints
|
|||||||
}
|
}
|
||||||
|
|
||||||
DiscoveryCandidatesService candidates = context.RequestServices.GetRequiredService<DiscoveryCandidatesService>();
|
DiscoveryCandidatesService candidates = context.RequestServices.GetRequiredService<DiscoveryCandidatesService>();
|
||||||
DiscoveryCandidateDto? row = await candidates.GetAsync(dialog_id, ct);
|
DiscoveryCandidateDto row = await candidates.GetAsync(dialog_id, ct);
|
||||||
if (row is null)
|
|
||||||
{
|
|
||||||
return EndpointResults.NotFound(CandidateNotFoundDetail);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (row.Status == DiscoveryCandidateStatuses.Joined)
|
if (row.Status == DiscoveryCandidateStatuses.Joined)
|
||||||
{
|
{
|
||||||
@@ -356,8 +336,8 @@ public static class DiscoveryEndpoints
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
DiscoveryCandidateDto? rejected = await candidates.MarkRejectedAsync(dialog_id, ManualRejectReason, ct);
|
DiscoveryCandidateDto rejected = await candidates.MarkRejectedAsync(dialog_id, ManualRejectReason, ct);
|
||||||
return rejected is null ? EndpointResults.NotFound(CandidateNotFoundDetail) : Results.Ok(rejected);
|
return Results.Ok(rejected);
|
||||||
}
|
}
|
||||||
catch (DiscoveryValidationException exception)
|
catch (DiscoveryValidationException exception)
|
||||||
{
|
{
|
||||||
@@ -403,11 +383,7 @@ public static class DiscoveryEndpoints
|
|||||||
}
|
}
|
||||||
|
|
||||||
DiscoveryTasksService tasks = context.RequestServices.GetRequiredService<DiscoveryTasksService>();
|
DiscoveryTasksService tasks = context.RequestServices.GetRequiredService<DiscoveryTasksService>();
|
||||||
DiscoveryTaskDto? task = await tasks.GetAsync(task_id, ct);
|
DiscoveryTaskDto task = await tasks.GetAsync(task_id, ct);
|
||||||
if (task is null)
|
|
||||||
{
|
|
||||||
return EndpointResults.NotFound(TaskNotFoundDetail);
|
|
||||||
}
|
|
||||||
|
|
||||||
DiscoveryLogService log = context.RequestServices.GetRequiredService<DiscoveryLogService>();
|
DiscoveryLogService log = context.RequestServices.GetRequiredService<DiscoveryLogService>();
|
||||||
IReadOnlyList<DiscoveryLogDto> items = await log.TaskLogAsync(task_id, ct);
|
IReadOnlyList<DiscoveryLogDto> items = await log.TaskLogAsync(task_id, ct);
|
||||||
|
|||||||
@@ -69,7 +69,8 @@ public static class JoinEndpoint
|
|||||||
TenantId: result.TenantId,
|
TenantId: result.TenantId,
|
||||||
Ip: ClientIp(context),
|
Ip: ClientIp(context),
|
||||||
// Код инвайта — capability-токен: в аудит пишется только SHA-256-хэш (Security review).
|
// Код инвайта — capability-токен: в аудит пишется только SHA-256-хэш (Security review).
|
||||||
DetailJson: AuditService.ToDetailJson(new { email = result.Login, codeHash = SessionTokens.HashToken(body.Code?.Trim() ?? string.Empty) })), ct);
|
DetailJson: AuditService.ToDetailJson(
|
||||||
|
[AuditDetails.Set(AuditFields.Email, result.Login), AuditDetails.Set(AuditFields.CodeHash, SessionTokens.HashToken(body.Code?.Trim() ?? string.Empty))])), ct);
|
||||||
|
|
||||||
return Results.Ok(new { ok = true, login = result.Login });
|
return Results.Ok(new { ok = true, login = result.Login });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,8 +37,6 @@ public static class MlEndpoints
|
|||||||
|
|
||||||
private const string EnterTextDetail = "Введите текст";
|
private const string EnterTextDetail = "Введите текст";
|
||||||
|
|
||||||
private const string MessageNotFoundDetail = "Исходное сообщение не найдено";
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Регистрирует группу /api/ml
|
/// Регистрирует группу /api/ml
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -141,12 +139,7 @@ public static class MlEndpoints
|
|||||||
}
|
}
|
||||||
|
|
||||||
MlReviewService review = context.RequestServices.GetRequiredService<MlReviewService>();
|
MlReviewService review = context.RequestServices.GetRequiredService<MlReviewService>();
|
||||||
MlApplyResult? result = await review.ApplyAsync(body.DialogId, body.MsgId, body.Action, ct);
|
MlApplyResult result = await review.ApplyAsync(body.DialogId, body.MsgId, body.Action, ct);
|
||||||
if (result is null)
|
|
||||||
{
|
|
||||||
return EndpointResults.NotFound(MessageNotFoundDetail);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result.Error is not null)
|
if (result.Error is not null)
|
||||||
{
|
{
|
||||||
return EndpointResults.BadRequest(result.Error);
|
return EndpointResults.BadRequest(result.Error);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using Deal.Api.Extensions;
|
using Deal.Api.Extensions;
|
||||||
using Deal.Api.Services;
|
using Deal.Api.Services;
|
||||||
|
using Deal.Modules.Tenants.Application.Abstractions;
|
||||||
using Deal.Modules.Tenants.Application.Models;
|
using Deal.Modules.Tenants.Application.Models;
|
||||||
using Deal.Modules.Tenants.Application.Services;
|
using Deal.Modules.Tenants.Application.Services;
|
||||||
|
|
||||||
@@ -132,6 +133,9 @@ public static class OperatorAnalyticsEndpoints
|
|||||||
int? offset,
|
int? offset,
|
||||||
HttpContext context,
|
HttpContext context,
|
||||||
AnalyticsService analyticsService,
|
AnalyticsService analyticsService,
|
||||||
|
ITenantRepository tenantRepository,
|
||||||
|
IAuthStore authStore,
|
||||||
|
IAuditReferenceResolver referenceResolver,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (context.GetCurrentOperator() is null)
|
if (context.GetCurrentOperator() is null)
|
||||||
@@ -141,7 +145,8 @@ public static class OperatorAnalyticsEndpoints
|
|||||||
|
|
||||||
AnalyticsActivityDto activity = await analyticsService.ActivityAsync(
|
AnalyticsActivityDto activity = await analyticsService.ActivityAsync(
|
||||||
eventType, actorType, actorId, tenantId, from, to, limit, offset, ct);
|
eventType, actorType, actorId, tenantId, from, to, limit, offset, ct);
|
||||||
return Results.Ok(activity);
|
IReadOnlyList<AuditRecordViewDto> view = await AuditViewFactory.ProjectAsync(activity.Items, tenantRepository, authStore, referenceResolver, ct);
|
||||||
|
return Results.Ok(new { items = view, total = activity.Total, limit = activity.Limit, offset = activity.Offset });
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using Deal.Api.Extensions;
|
using Deal.Api.Extensions;
|
||||||
using Deal.Api.Services;
|
using Deal.Api.Services;
|
||||||
|
using Deal.Modules.Tenants.Application.Abstractions;
|
||||||
using Deal.Modules.Tenants.Application.Models;
|
using Deal.Modules.Tenants.Application.Models;
|
||||||
using Deal.Modules.Tenants.Application.Services;
|
using Deal.Modules.Tenants.Application.Services;
|
||||||
|
|
||||||
@@ -39,6 +40,9 @@ public static class OperatorAuditEndpoints
|
|||||||
int? offset,
|
int? offset,
|
||||||
HttpContext context,
|
HttpContext context,
|
||||||
AuditService auditService,
|
AuditService auditService,
|
||||||
|
ITenantRepository tenantRepository,
|
||||||
|
IAuthStore authStore,
|
||||||
|
IAuditReferenceResolver referenceResolver,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
var operatorIdentity = context.GetCurrentOperator();
|
var operatorIdentity = context.GetCurrentOperator();
|
||||||
@@ -51,7 +55,8 @@ public static class OperatorAuditEndpoints
|
|||||||
eventType, actorType, tenantId, from, to, NormalizeLimit(limit), actorId, NormalizeOffset(offset));
|
eventType, actorType, tenantId, from, to, NormalizeLimit(limit), actorId, NormalizeOffset(offset));
|
||||||
IReadOnlyList<AuditRecordDto> items = await auditService.QueryAsync(filter, ct);
|
IReadOnlyList<AuditRecordDto> items = await auditService.QueryAsync(filter, ct);
|
||||||
int total = await auditService.CountAsync(filter, ct);
|
int total = await auditService.CountAsync(filter, ct);
|
||||||
return Results.Ok(new { items, total });
|
IReadOnlyList<AuditRecordViewDto> view = await AuditViewFactory.ProjectAsync(items, tenantRepository, authStore, referenceResolver, ct);
|
||||||
|
return Results.Ok(new { items = view, total });
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ public static class OperatorAuthEndpoints
|
|||||||
ActorId: null,
|
ActorId: null,
|
||||||
TenantId: null,
|
TenantId: null,
|
||||||
Ip: ClientIp(context),
|
Ip: ClientIp(context),
|
||||||
DetailJson: AuditService.ToDetailJson(new { login = attemptedLogin })), ct);
|
DetailJson: AuditService.ToDetailJson([AuditDetails.Set(AuditFields.Login, attemptedLogin)])), ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
return EndpointResults.Unauthorized(InvalidCredentialsDetail);
|
return EndpointResults.Unauthorized(InvalidCredentialsDetail);
|
||||||
@@ -78,7 +78,7 @@ public static class OperatorAuthEndpoints
|
|||||||
ActorId: result.OperatorId,
|
ActorId: result.OperatorId,
|
||||||
TenantId: null,
|
TenantId: null,
|
||||||
Ip: ClientIp(context),
|
Ip: ClientIp(context),
|
||||||
DetailJson: AuditService.ToDetailJson(new { login = result.Login })), ct);
|
DetailJson: AuditService.ToDetailJson([AuditDetails.Set(AuditFields.Login, result.Login)])), ct);
|
||||||
|
|
||||||
SetOperatorSessionCookie(context, cookieOptions.Value, result.Token);
|
SetOperatorSessionCookie(context, cookieOptions.Value, result.Token);
|
||||||
return Results.Ok(new { ok = true, login = result.Login });
|
return Results.Ok(new { ok = true, login = result.Login });
|
||||||
@@ -100,7 +100,7 @@ public static class OperatorAuthEndpoints
|
|||||||
|
|
||||||
if (operatorIdentity is not null)
|
if (operatorIdentity is not null)
|
||||||
{
|
{
|
||||||
await AuditAppender.AppendOperatorAsync(context, AuditEvents.OperatorLogout, new { login = operatorIdentity.Login }, ct);
|
await AuditAppender.AppendOperatorAsync(context, AuditEvents.OperatorLogout, [AuditDetails.Set(AuditFields.Login, operatorIdentity.Login)], ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Results.Ok(new { ok = true });
|
return Results.Ok(new { ok = true });
|
||||||
|
|||||||
@@ -92,7 +92,8 @@ public static class OperatorInvitesEndpoints
|
|||||||
Ip: ClientIp(context),
|
Ip: ClientIp(context),
|
||||||
// Код инвайта — capability-токен (по нему активируется приглашение): в аудит пишется
|
// Код инвайта — capability-токен (по нему активируется приглашение): в аудит пишется
|
||||||
// только его SHA-256-хэш, чтобы утечка ленты не давала рабочие коды (Security review).
|
// только его SHA-256-хэш, чтобы утечка ленты не давала рабочие коды (Security review).
|
||||||
DetailJson: AuditService.ToDetailJson(new { email = result.Invite.Email, codeHash = SessionTokens.HashToken(result.Invite.Code) })), ct);
|
DetailJson: AuditService.ToDetailJson(
|
||||||
|
[AuditDetails.Set(AuditFields.Email, result.Invite.Email), AuditDetails.Set(AuditFields.CodeHash, SessionTokens.HashToken(result.Invite.Code))])), ct);
|
||||||
|
|
||||||
InviteDto invite = result.Invite;
|
InviteDto invite = result.Invite;
|
||||||
return Results.Ok(new { invite.Code, invite.Email, invite.TenantId, invite.ExpiresAt, invite.Status });
|
return Results.Ok(new { invite.Code, invite.Email, invite.TenantId, invite.ExpiresAt, invite.Status });
|
||||||
@@ -126,7 +127,8 @@ public static class OperatorInvitesEndpoints
|
|||||||
ActorId: operatorIdentity.OperatorId,
|
ActorId: operatorIdentity.OperatorId,
|
||||||
TenantId: null,
|
TenantId: null,
|
||||||
Ip: ClientIp(context),
|
Ip: ClientIp(context),
|
||||||
DetailJson: AuditService.ToDetailJson(new { email = result.Invite!.Email, codeHash = SessionTokens.HashToken(result.Invite.Code) })), ct);
|
DetailJson: AuditService.ToDetailJson(
|
||||||
|
[AuditDetails.Set(AuditFields.Email, result.Invite!.Email), AuditDetails.Set(AuditFields.CodeHash, SessionTokens.HashToken(result.Invite.Code))])), ct);
|
||||||
|
|
||||||
return Results.Ok(new { ok = true });
|
return Results.Ok(new { ok = true });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ public static class OperatorLimitsEndpoints
|
|||||||
private static async Task<IResult> ListSummaryAsync(
|
private static async Task<IResult> ListSummaryAsync(
|
||||||
HttpContext context,
|
HttpContext context,
|
||||||
ITenantRepository tenantRepository,
|
ITenantRepository tenantRepository,
|
||||||
|
IAuthStore authStore,
|
||||||
ITenantLimitStore limitStore,
|
ITenantLimitStore limitStore,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
@@ -68,6 +69,8 @@ public static class OperatorLimitsEndpoints
|
|||||||
}
|
}
|
||||||
|
|
||||||
IReadOnlyList<TenantRecordDto> tenants = await tenantRepository.ListAsync(ct);
|
IReadOnlyList<TenantRecordDto> tenants = await tenantRepository.ListAsync(ct);
|
||||||
|
IReadOnlyDictionary<Guid, string> owners = await authStore.FindOwnerLoginsByTenantIdsAsync(
|
||||||
|
tenants.Select(tenant => tenant.Id).ToArray(), ct);
|
||||||
var items = new List<object>(tenants.Count);
|
var items = new List<object>(tenants.Count);
|
||||||
foreach (TenantRecordDto tenant in tenants)
|
foreach (TenantRecordDto tenant in tenants)
|
||||||
{
|
{
|
||||||
@@ -75,6 +78,7 @@ public static class OperatorLimitsEndpoints
|
|||||||
items.Add(new
|
items.Add(new
|
||||||
{
|
{
|
||||||
tenantId = tenant.Id,
|
tenantId = tenant.Id,
|
||||||
|
ownerLogin = owners.GetValueOrDefault(tenant.Id),
|
||||||
name = tenant.Name,
|
name = tenant.Name,
|
||||||
budget = state.BudgetTokens,
|
budget = state.BudgetTokens,
|
||||||
period = state.Period,
|
period = state.Period,
|
||||||
@@ -92,6 +96,7 @@ public static class OperatorLimitsEndpoints
|
|||||||
Guid id,
|
Guid id,
|
||||||
HttpContext context,
|
HttpContext context,
|
||||||
ITenantRepository tenantRepository,
|
ITenantRepository tenantRepository,
|
||||||
|
IAuthStore authStore,
|
||||||
ITenantLimitStore limitStore,
|
ITenantLimitStore limitStore,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
@@ -108,7 +113,7 @@ public static class OperatorLimitsEndpoints
|
|||||||
}
|
}
|
||||||
|
|
||||||
BudgetStateDto state = await limitStore.GetStateAsync(id, ct);
|
BudgetStateDto state = await limitStore.GetStateAsync(id, ct);
|
||||||
return Results.Ok(BuildDetailDto(tenant.Name, state));
|
return Results.Ok(BuildDetailDto(tenant.Name, await ResolveOwnerLoginAsync(authStore, id, ct), state));
|
||||||
}
|
}
|
||||||
|
|
||||||
// PATCH /api/operator/tenants/{id}/limit: смена бюджета/периода (сброс флагов + аудит tenant_limit_changed).
|
// PATCH /api/operator/tenants/{id}/limit: смена бюджета/периода (сброс флагов + аудит tenant_limit_changed).
|
||||||
@@ -117,6 +122,7 @@ public static class OperatorLimitsEndpoints
|
|||||||
OperatorLimitUpdateRequest? body,
|
OperatorLimitUpdateRequest? body,
|
||||||
HttpContext context,
|
HttpContext context,
|
||||||
ITenantRepository tenantRepository,
|
ITenantRepository tenantRepository,
|
||||||
|
IAuthStore authStore,
|
||||||
ITenantLimitStore limitStore,
|
ITenantLimitStore limitStore,
|
||||||
AuditService auditService,
|
AuditService auditService,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
@@ -154,10 +160,11 @@ public static class OperatorLimitsEndpoints
|
|||||||
BudgetStateDto current = await limitStore.GetStateAsync(id, ct);
|
BudgetStateDto current = await limitStore.GetStateAsync(id, ct);
|
||||||
long newBudget = body.Budget ?? current.BudgetTokens;
|
long newBudget = body.Budget ?? current.BudgetTokens;
|
||||||
string newPeriod = body.Period ?? current.Period;
|
string newPeriod = body.Period ?? current.Period;
|
||||||
|
string? ownerLogin = await ResolveOwnerLoginAsync(authStore, id, ct);
|
||||||
if (newBudget == current.BudgetTokens && newPeriod == current.Period)
|
if (newBudget == current.BudgetTokens && newPeriod == current.Period)
|
||||||
{
|
{
|
||||||
// Идемпотентный повторный PATCH: без изменения хранилища и без дубля аудита.
|
// Идемпотентный повторный PATCH: без изменения хранилища и без дубля аудита.
|
||||||
return Results.Ok(BuildDetailDto(tenant.Name, current));
|
return Results.Ok(BuildDetailDto(tenant.Name, ownerLogin, current));
|
||||||
}
|
}
|
||||||
|
|
||||||
BudgetStateDto updated = await limitStore.UpdateBudgetAsync(id, newBudget, newPeriod, ct);
|
BudgetStateDto updated = await limitStore.UpdateBudgetAsync(id, newBudget, newPeriod, ct);
|
||||||
@@ -167,16 +174,10 @@ public static class OperatorLimitsEndpoints
|
|||||||
ActorId: operatorIdentity.OperatorId,
|
ActorId: operatorIdentity.OperatorId,
|
||||||
TenantId: id,
|
TenantId: id,
|
||||||
Ip: ClientIp(context),
|
Ip: ClientIp(context),
|
||||||
DetailJson: AuditService.ToDetailJson(new
|
DetailJson: AuditService.ToDetailJson(
|
||||||
{
|
[AuditDetails.Change(AuditFields.Budget, current.BudgetTokens, newBudget), AuditDetails.Change(AuditFields.Period, current.Period, newPeriod)])), ct);
|
||||||
tenantId = id,
|
|
||||||
oldBudget = current.BudgetTokens,
|
|
||||||
oldPeriod = current.Period,
|
|
||||||
budgetTokens = newBudget,
|
|
||||||
period = newPeriod,
|
|
||||||
})), ct);
|
|
||||||
|
|
||||||
return Results.Ok(BuildDetailDto(tenant.Name, updated));
|
return Results.Ok(BuildDetailDto(tenant.Name, ownerLogin, updated));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -197,11 +198,13 @@ public static class OperatorLimitsEndpoints
|
|||||||
|
|
||||||
// Форма деталей лимита тенанта (GET и ответ PATCH — единая).
|
// Форма деталей лимита тенанта (GET и ответ PATCH — единая).
|
||||||
// name: Имя тенанта (реестр).
|
// name: Имя тенанта (реестр).
|
||||||
|
// ownerLogin: Логин владельца пространства; null, если пользователей нет.
|
||||||
// state: Состояние бюджета (после ленивого reset).
|
// state: Состояние бюджета (после ленивого reset).
|
||||||
// Возвращает: Объект ответа: лимит + расход + флаги порогов + статус тенанта.
|
// Возвращает: Объект ответа: лимит + расход + флаги порогов + статус тенанта.
|
||||||
private static object BuildDetailDto(string name, BudgetStateDto state) => new
|
private static object BuildDetailDto(string name, string? ownerLogin, BudgetStateDto state) => new
|
||||||
{
|
{
|
||||||
tenantId = state.TenantId,
|
tenantId = state.TenantId,
|
||||||
|
ownerLogin,
|
||||||
name,
|
name,
|
||||||
status = state.Status,
|
status = state.Status,
|
||||||
allowed = state.Allowed,
|
allowed = state.Allowed,
|
||||||
@@ -215,6 +218,17 @@ public static class OperatorLimitsEndpoints
|
|||||||
notifiedExhausted = state.NotifiedExhausted,
|
notifiedExhausted = state.NotifiedExhausted,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Логин владельца пространства (первый пользователь по времени создания).
|
||||||
|
// authStore: Хранилище пользователей.
|
||||||
|
// tenantId: Идентификатор пространства.
|
||||||
|
// ct: Токен отмены.
|
||||||
|
// Возвращает: Логин либо null.
|
||||||
|
private static async Task<string?> ResolveOwnerLoginAsync(IAuthStore authStore, Guid tenantId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
IReadOnlyDictionary<Guid, string> owners = await authStore.FindOwnerLoginsByTenantIdsAsync([tenantId], ct);
|
||||||
|
return owners.GetValueOrDefault(tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
// IP-адрес клиента для аудита (без порта; null, если недоступен).
|
// IP-адрес клиента для аудита (без порта; null, если недоступен).
|
||||||
// context: Контекст запроса.
|
// context: Контекст запроса.
|
||||||
// Возвращает: Строковое представление IP или null.
|
// Возвращает: Строковое представление IP или null.
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ using Deal.Api.Endpoints.RequestModels;
|
|||||||
using Deal.Api.Extensions;
|
using Deal.Api.Extensions;
|
||||||
using Deal.Api.Services;
|
using Deal.Api.Services;
|
||||||
using Deal.Api.Telegram;
|
using Deal.Api.Telegram;
|
||||||
|
using Deal.Modules.Settings.Application.Abstractions;
|
||||||
|
using Deal.Modules.Settings.Application.Models;
|
||||||
|
using Deal.Modules.Settings.Application.Services;
|
||||||
using Deal.Modules.Tenants.Application.Models;
|
using Deal.Modules.Tenants.Application.Models;
|
||||||
using Deal.Modules.Tenants.Application.Services;
|
using Deal.Modules.Tenants.Application.Services;
|
||||||
|
|
||||||
@@ -21,6 +24,9 @@ public static class OperatorSettingsEndpoints
|
|||||||
// Относительный путь глобальных ключей Telegram (GET/PUT).
|
// Относительный путь глобальных ключей Telegram (GET/PUT).
|
||||||
private const string TelegramKeysPath = "/telegram-keys";
|
private const string TelegramKeysPath = "/telegram-keys";
|
||||||
|
|
||||||
|
// Относительный путь глобальной конфигурации ИИ (GET/PUT/POST-check).
|
||||||
|
private const string AiConfigPath = "/ai-config";
|
||||||
|
|
||||||
// Текст 400: пустое тело PUT (ни одного поля).
|
// Текст 400: пустое тело PUT (ни одного поля).
|
||||||
private const string EmptyBodyDetail = "Укажите api_id и api_hash";
|
private const string EmptyBodyDetail = "Укажите api_id и api_hash";
|
||||||
|
|
||||||
@@ -33,6 +39,24 @@ public static class OperatorSettingsEndpoints
|
|||||||
// Текст 400: api_hash пустой/маска/с префиксом enc:.
|
// Текст 400: api_hash пустой/маска/с префиксом enc:.
|
||||||
private const string InvalidApiHashDetail = "Укажите непустой api_hash";
|
private const string InvalidApiHashDetail = "Укажите непустой api_hash";
|
||||||
|
|
||||||
|
// Текст 400: пустое тело PUT конфига ИИ (ни одного поля).
|
||||||
|
private const string EmptyAiConfigBodyDetail = "Укажите хотя бы одно поле (providerId, baseUrl, model, apiKey)";
|
||||||
|
|
||||||
|
// Текст 400: провайдер не из каталога AiProviders.
|
||||||
|
private const string InvalidProviderDetail = "Провайдер не из списка разрешённых";
|
||||||
|
|
||||||
|
// Текст 400: api-ключ короче 8 символов/маска/с префиксом enc:.
|
||||||
|
private const string InvalidAiKeyDetail = "API-ключ должен быть не короче 8 символов, без маски";
|
||||||
|
|
||||||
|
// Текст 400: конфигурации ИИ ещё нет, а провайдер в PUT не передан.
|
||||||
|
private const string MissingProviderDetail = "Конфигурация ИИ ещё не задана — укажите providerId";
|
||||||
|
|
||||||
|
// Текст 400: custom-провайдер без модели (в каталоге нет моделей-дефолтов).
|
||||||
|
private const string MissingAiModelDetail = "Укажите model — у выбранного провайдера нет моделей по умолчанию";
|
||||||
|
|
||||||
|
// Текст 400: проверка связи при незаданной конфигурации ИИ.
|
||||||
|
private const string AiConfigNotSetDetail = "Сначала сохраните конфигурацию ИИ";
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Регистрирует группу /api/operator/settings
|
/// Регистрирует группу /api/operator/settings
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -43,6 +67,9 @@ public static class OperatorSettingsEndpoints
|
|||||||
var group = app.MapGroup(SettingsGroupPrefix).WithTags(SettingsOpenApiTag);
|
var group = app.MapGroup(SettingsGroupPrefix).WithTags(SettingsOpenApiTag);
|
||||||
group.MapGet(TelegramKeysPath, GetTelegramKeysAsync);
|
group.MapGet(TelegramKeysPath, GetTelegramKeysAsync);
|
||||||
group.MapPut(TelegramKeysPath, PutTelegramKeysAsync);
|
group.MapPut(TelegramKeysPath, PutTelegramKeysAsync);
|
||||||
|
group.MapGet(AiConfigPath, GetAiConfigAsync);
|
||||||
|
group.MapPut(AiConfigPath, PutAiConfigAsync);
|
||||||
|
group.MapPost(AiConfigPath + "/check", CheckAiConfigAsync);
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,12 +155,159 @@ public static class OperatorSettingsEndpoints
|
|||||||
ActorId: operatorIdentity.OperatorId,
|
ActorId: operatorIdentity.OperatorId,
|
||||||
TenantId: null,
|
TenantId: null,
|
||||||
Ip: ClientIp(context),
|
Ip: ClientIp(context),
|
||||||
DetailJson: AuditService.ToDetailJson(new { apiId = effectiveApiId, apiHashSet = true })), ct);
|
DetailJson: AuditService.ToDetailJson(
|
||||||
|
[AuditDetails.Set(AuditFields.ApiId, effectiveApiId), AuditDetails.Set(AuditFields.ApiHashSet, true)])), ct);
|
||||||
|
|
||||||
TelegramKeysMaskedDto snapshot = await keys.GetMaskedAsync(ct);
|
TelegramKeysMaskedDto snapshot = await keys.GetMaskedAsync(ct);
|
||||||
return Results.Ok(snapshot);
|
return Results.Ok(snapshot);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GET /api/operator/settings/ai-config: маскированная глобальная конфигурация ИИ.
|
||||||
|
// context: Контекст запроса.
|
||||||
|
// config: Сервис глобальной конфигурации ИИ (scoped).
|
||||||
|
// ct: Токен отмены.
|
||||||
|
// Возвращает: 200 маскированный снимок или 401 без операторской сессии.
|
||||||
|
private static async Task<IResult> GetAiConfigAsync(
|
||||||
|
HttpContext context,
|
||||||
|
AiGlobalConfigService config,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (context.GetCurrentOperator() is null)
|
||||||
|
{
|
||||||
|
return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail);
|
||||||
|
}
|
||||||
|
|
||||||
|
AiGlobalConfigMaskedDto snapshot = await config.GetMaskedAsync(ct);
|
||||||
|
return Results.Ok(snapshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
// PUT /api/operator/settings/ai-config: частичное сохранение глобальной конфигурации ИИ.
|
||||||
|
// Поля передаются по отдельности: непереданное (null) сохраняет текущее значение. Если
|
||||||
|
// конфигурации ещё нет, providerId обязателен. Ключ передаётся только при смене (маска не
|
||||||
|
// принимается). Смена провайдера сохраняет ключ, только если передан явно.
|
||||||
|
// body: Тело {providerId?, baseUrl?, model?, apiKey?} (хотя бы одно поле).
|
||||||
|
// context: Контекст запроса.
|
||||||
|
// config: Сервис глобальной конфигурации ИИ (scoped).
|
||||||
|
// auditService: Сервис аудита (событие ai_config_changed).
|
||||||
|
// ct: Токен отмены.
|
||||||
|
// Возвращает: 200 маскированный снимок, 400 при невалидных/недостающих полях или 401 без операторской сессии.
|
||||||
|
private static async Task<IResult> PutAiConfigAsync(
|
||||||
|
OperatorAiConfigRequest? body,
|
||||||
|
HttpContext context,
|
||||||
|
AiGlobalConfigService config,
|
||||||
|
AuditService auditService,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
var operatorIdentity = context.GetCurrentOperator();
|
||||||
|
if (operatorIdentity is null)
|
||||||
|
{
|
||||||
|
return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body is null)
|
||||||
|
{
|
||||||
|
return EndpointResults.BadRequest(EmptyAiConfigBodyDetail);
|
||||||
|
}
|
||||||
|
|
||||||
|
// null — поле не передано (сохраняем текущее); явное значение валидируется ниже.
|
||||||
|
string? providerId = body.ProviderId?.Trim();
|
||||||
|
string? baseUrl = body.BaseUrl?.Trim();
|
||||||
|
string? model = body.Model?.Trim();
|
||||||
|
string? apiKey = body.ApiKey?.Trim();
|
||||||
|
if (providerId is null && baseUrl is null && model is null && apiKey is null)
|
||||||
|
{
|
||||||
|
return EndpointResults.BadRequest(EmptyAiConfigBodyDetail);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (providerId is not null
|
||||||
|
&& AiProviders.All.All(provider => provider.Id != providerId))
|
||||||
|
{
|
||||||
|
return EndpointResults.BadRequest(InvalidProviderDetail);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (apiKey is not null && apiKey.Length > 0 && !AiGlobalConfigService.IsValidApiKey(apiKey))
|
||||||
|
{
|
||||||
|
return EndpointResults.BadRequest(InvalidAiKeyDetail);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Частичное обновление: недостающие поля берём из текущей конфигурации. При смене
|
||||||
|
// провайдера поля не переносятся от старого (дефолты каталога), ключ — только если
|
||||||
|
// передан явно.
|
||||||
|
AiGlobalConfigSnapshot current = await config.GetSnapshotAsync(ct);
|
||||||
|
string effectiveProviderId = providerId ?? current.ProviderId;
|
||||||
|
if (effectiveProviderId.Length == 0)
|
||||||
|
{
|
||||||
|
return EndpointResults.BadRequest(MissingProviderDetail);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool providerChanged = providerId is not null && providerId != current.ProviderId;
|
||||||
|
string effectiveBaseUrl = baseUrl ?? (providerChanged ? string.Empty : current.BaseUrl);
|
||||||
|
string effectiveModel = model ?? (providerChanged ? string.Empty : current.Model);
|
||||||
|
string effectiveApiKey = apiKey ?? (providerChanged ? string.Empty : current.ApiKey);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await config.SaveAsync(effectiveProviderId, effectiveBaseUrl, effectiveModel, effectiveApiKey, ct);
|
||||||
|
}
|
||||||
|
catch (ArgumentException)
|
||||||
|
{
|
||||||
|
// Остаточный случай валидации: custom-провайдер без модели (провайдер и ключ проверены выше).
|
||||||
|
return EndpointResults.BadRequest(MissingAiModelDetail);
|
||||||
|
}
|
||||||
|
|
||||||
|
AiGlobalConfigSnapshot saved = await config.GetSnapshotAsync(ct);
|
||||||
|
await auditService.AppendAsync(new AuditRecordDto(
|
||||||
|
AuditEvents.AiConfigChanged,
|
||||||
|
AuditActorTypes.Operator,
|
||||||
|
ActorId: operatorIdentity.OperatorId,
|
||||||
|
TenantId: null,
|
||||||
|
Ip: ClientIp(context),
|
||||||
|
DetailJson: AuditService.ToDetailJson(
|
||||||
|
[
|
||||||
|
AuditDetails.Set(AuditFields.ProviderId, saved.ProviderId),
|
||||||
|
AuditDetails.Set(AuditFields.BaseUrl, saved.BaseUrl),
|
||||||
|
AuditDetails.Set(AuditFields.Model, saved.Model),
|
||||||
|
AuditDetails.Set(AuditFields.KeySet, saved.ApiKey.Length > 0),
|
||||||
|
])), ct);
|
||||||
|
|
||||||
|
AiGlobalConfigMaskedDto snapshot = await config.GetMaskedAsync(ct);
|
||||||
|
return Results.Ok(snapshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/operator/settings/ai-config/check: проверка связи с сохранённым ИИ-провайдером.
|
||||||
|
// context: Контекст запроса.
|
||||||
|
// config: Сервис глобальной конфигурации ИИ (scoped).
|
||||||
|
// checker: Проверка подключения провайдера (HTTP к списку моделей провайдера).
|
||||||
|
// ct: Токен отмены.
|
||||||
|
// Возвращает: 200 результат проверки, 400 без сохранённой конфигурации или 401 без операторской сессии.
|
||||||
|
private static async Task<IResult> CheckAiConfigAsync(
|
||||||
|
HttpContext context,
|
||||||
|
AiGlobalConfigService config,
|
||||||
|
IAiConnectionChecker checker,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (context.GetCurrentOperator() is null)
|
||||||
|
{
|
||||||
|
return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail);
|
||||||
|
}
|
||||||
|
|
||||||
|
AiGlobalConfigSnapshot snapshot = await config.GetSnapshotAsync(ct);
|
||||||
|
if (snapshot.ProviderId.Length == 0 || snapshot.Meta is null)
|
||||||
|
{
|
||||||
|
return EndpointResults.BadRequest(AiConfigNotSetDetail);
|
||||||
|
}
|
||||||
|
|
||||||
|
var request = new AiCheckRequest(
|
||||||
|
snapshot.ProviderId,
|
||||||
|
snapshot.BaseUrl,
|
||||||
|
snapshot.Model,
|
||||||
|
snapshot.ApiKey,
|
||||||
|
snapshot.Meta.Local,
|
||||||
|
snapshot.Meta.ApiStyle);
|
||||||
|
AiCheckResultDto result = await checker.CheckAsync(request, ct);
|
||||||
|
return Results.Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
// IP-адрес клиента для аудита (без порта; null, если недоступен).
|
// IP-адрес клиента для аудита (без порта; null, если недоступен).
|
||||||
// context: Контекст запроса.
|
// context: Контекст запроса.
|
||||||
// Возвращает: Строковое представление IP или null.
|
// Возвращает: Строковое представление IP или null.
|
||||||
|
|||||||
@@ -113,7 +113,8 @@ public static class OperatorTenantsEndpoints
|
|||||||
ActorId: operatorIdentity.OperatorId,
|
ActorId: operatorIdentity.OperatorId,
|
||||||
TenantId: createdTenant.Id,
|
TenantId: createdTenant.Id,
|
||||||
Ip: ClientIp(context),
|
Ip: ClientIp(context),
|
||||||
DetailJson: AuditService.ToDetailJson(new { tenantId = createdTenant.Id, name = createdTenant.Name, email = result.OwnerLogin })), ct);
|
DetailJson: AuditService.ToDetailJson(
|
||||||
|
[AuditDetails.Set(AuditFields.Name, createdTenant.Name), AuditDetails.Set(AuditFields.Email, result.OwnerLogin)])), ct);
|
||||||
|
|
||||||
if (result.OwnerLogin is not null)
|
if (result.OwnerLogin is not null)
|
||||||
{
|
{
|
||||||
@@ -144,12 +145,7 @@ public static class OperatorTenantsEndpoints
|
|||||||
return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail);
|
return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail);
|
||||||
}
|
}
|
||||||
|
|
||||||
TenantDetailDto? tenant = await tenantAdminService.GetAsync(id, ct);
|
TenantDetailDto tenant = await tenantAdminService.GetAsync(id, ct);
|
||||||
if (tenant is null)
|
|
||||||
{
|
|
||||||
return EndpointResults.NotFound(TenantNotFoundDetail);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Results.Ok(tenant);
|
return Results.Ok(tenant);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -207,7 +203,8 @@ public static class OperatorTenantsEndpoints
|
|||||||
ActorId: operatorIdentity.OperatorId,
|
ActorId: operatorIdentity.OperatorId,
|
||||||
TenantId: result.Tenant.Id,
|
TenantId: result.Tenant.Id,
|
||||||
Ip: ClientIp(context),
|
Ip: ClientIp(context),
|
||||||
DetailJson: AuditService.ToDetailJson(new { tenantId = result.Tenant.Id, status = result.Tenant.Status })), ct);
|
DetailJson: AuditService.ToDetailJson(
|
||||||
|
[AuditDetails.Change(AuditFields.Status, result.PreviousStatus, result.Tenant.Status)])), ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Results.Ok(new { ok = true, status = result.Tenant.Status });
|
return Results.Ok(new { ok = true, status = result.Tenant.Status });
|
||||||
@@ -247,7 +244,7 @@ public static class OperatorTenantsEndpoints
|
|||||||
ActorId: operatorIdentity.OperatorId,
|
ActorId: operatorIdentity.OperatorId,
|
||||||
TenantId: result.TenantId,
|
TenantId: result.TenantId,
|
||||||
Ip: ClientIp(context),
|
Ip: ClientIp(context),
|
||||||
DetailJson: AuditService.ToDetailJson(new { targetLogin = result.Login, tenantId = result.TenantId })), ct);
|
DetailJson: AuditService.ToDetailJson([AuditDetails.Set(AuditFields.TargetLogin, result.Login)])), ct);
|
||||||
|
|
||||||
// Токен — это tenant-сессия (как после /api/auth/login): СТАВИМ ту же httpOnly-куку deal_session
|
// Токен — это tenant-сессия (как после /api/auth/login): СТАВИМ ту же httpOnly-куку deal_session
|
||||||
// на ответ, чтобы браузер оператора сразу получил tenant-сессию (JS не может записать httpOnly-куку).
|
// на ответ, чтобы браузер оператора сразу получил tenant-сессию (JS не может записать httpOnly-куку).
|
||||||
|
|||||||
@@ -33,8 +33,6 @@ public static class PipelineEndpoints
|
|||||||
// Путь возврата записи отсева в обработку (POST).
|
// Путь возврата записи отсева в обработку (POST).
|
||||||
private const string RejectedReturnPath = "/rejected/{rejId}/return";
|
private const string RejectedReturnPath = "/rejected/{rejId}/return";
|
||||||
|
|
||||||
private const string RejectedNotFoundDetail = "Запись не найдена";
|
|
||||||
|
|
||||||
private const int DefaultPageSize = 100;
|
private const int DefaultPageSize = 100;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -140,11 +138,7 @@ public static class PipelineEndpoints
|
|||||||
}
|
}
|
||||||
|
|
||||||
PipelineProcessingService processing = context.RequestServices.GetRequiredService<PipelineProcessingService>();
|
PipelineProcessingService processing = context.RequestServices.GetRequiredService<PipelineProcessingService>();
|
||||||
RejectReturnResultDto? result = await processing.ReturnAsync(rejId, body.Reason ?? string.Empty, ct);
|
RejectReturnResultDto result = await processing.ReturnAsync(rejId, body.Reason ?? string.Empty, ct);
|
||||||
if (result is null)
|
|
||||||
{
|
|
||||||
return EndpointResults.NotFound(RejectedNotFoundDetail);
|
|
||||||
}
|
|
||||||
|
|
||||||
return result.Error is not null
|
return result.Error is not null
|
||||||
? EndpointResults.BadRequest(result.Error)
|
? EndpointResults.BadRequest(result.Error)
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
namespace Deal.Api.Endpoints.RequestModels;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Тело PUT /api/operator/settings/ai-config
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="ProviderId">Id провайдера из каталога AiProviders; null — не менялся.</param>
|
||||||
|
/// <param name="BaseUrl">Базовый URL API (учитывается для local/custom провайдеров); null — не менялся.</param>
|
||||||
|
/// <param name="Model">Активная модель; null — не менялась (у каталогных провайдеров пустая = первая из каталога).</param>
|
||||||
|
/// <param name="ApiKey">API-ключ открытым текстом (хранится зашифрованным); null — не менялся (маска не принимается).</param>
|
||||||
|
public sealed record OperatorAiConfigRequest(
|
||||||
|
string? ProviderId,
|
||||||
|
string? BaseUrl,
|
||||||
|
string? Model,
|
||||||
|
string? ApiKey);
|
||||||
@@ -5,6 +5,7 @@ using Deal.Modules.Settings.Application.Abstractions;
|
|||||||
using Deal.Modules.Settings.Application.Models;
|
using Deal.Modules.Settings.Application.Models;
|
||||||
using Deal.Modules.Settings.Application.Services;
|
using Deal.Modules.Settings.Application.Services;
|
||||||
using Deal.Modules.Tenants.Application.Models;
|
using Deal.Modules.Tenants.Application.Models;
|
||||||
|
using Deal.SharedKernel.Utilities;
|
||||||
|
|
||||||
namespace Deal.Api.Endpoints;
|
namespace Deal.Api.Endpoints;
|
||||||
|
|
||||||
@@ -18,6 +19,15 @@ public static class SettingsEndpoints
|
|||||||
private const string SettingsOpenApiTag = "settings";
|
private const string SettingsOpenApiTag = "settings";
|
||||||
private const string InvalidBodyDetail = "Тело запроса должно быть JSON-объектом";
|
private const string InvalidBodyDetail = "Тело запроса должно быть JSON-объектом";
|
||||||
|
|
||||||
|
// Максимальная длина значения в деталях аудита (промпты/списки бывают длинными).
|
||||||
|
private const int MaxAuditValueLength = 120;
|
||||||
|
|
||||||
|
// Пометка для составных настроек, чьи значения в аудит не пишутся (секреты, снимки состояния).
|
||||||
|
private const string ChangedMarker = "изменено";
|
||||||
|
|
||||||
|
// Опции сериализации снимка настроек в деталях аудита (camelCase, как на wire).
|
||||||
|
private static readonly JsonSerializerOptions AuditJsonOptions = new(JsonSerializerDefaults.Web);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Регистрирует GET/PATCH /api/settings.
|
/// Регистрирует GET/PATCH /api/settings.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -72,9 +82,10 @@ public static class SettingsEndpoints
|
|||||||
}
|
}
|
||||||
|
|
||||||
SettingsService settingsService = context.RequestServices.GetRequiredService<SettingsService>();
|
SettingsService settingsService = context.RequestServices.GetRequiredService<SettingsService>();
|
||||||
|
PublicSettingsDto before = await settingsService.GetPublicAsync(ct);
|
||||||
PublicSettingsDto result = await settingsService.ApplyPatchAsync(body, ct);
|
PublicSettingsDto result = await settingsService.ApplyPatchAsync(body, ct);
|
||||||
|
|
||||||
await AuditAppender.AppendTenantAsync(context, AuditEvents.SettingsUpdated, new { fields = body.Keys }, ct);
|
await AuditAppender.AppendTenantAsync(context, AuditEvents.SettingsUpdated, BuildSettingsChanges(before, result, body.Keys), ct);
|
||||||
|
|
||||||
if (ShouldScheduleRatesRefresh(body))
|
if (ShouldScheduleRatesRefresh(body))
|
||||||
{
|
{
|
||||||
@@ -84,6 +95,60 @@ public static class SettingsEndpoints
|
|||||||
return Results.Ok(result);
|
return Results.Ok(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Строит изменения настроек «поле: было → стало» по применённому PATCH.
|
||||||
|
// before: Снимок настроек до применения.
|
||||||
|
// after: Снимок настроек после применения.
|
||||||
|
// keys: Ключи PATCH-тела (изменённые).
|
||||||
|
// Возвращает: Изменения по каждому ключу (составные — пометкой «изменено»).
|
||||||
|
private static IReadOnlyList<AuditChangeDto> BuildSettingsChanges(
|
||||||
|
PublicSettingsDto before,
|
||||||
|
PublicSettingsDto after,
|
||||||
|
IEnumerable<string> keys)
|
||||||
|
{
|
||||||
|
using JsonDocument beforeDoc = JsonDocument.Parse(JsonSerializer.Serialize(before, AuditJsonOptions));
|
||||||
|
using JsonDocument afterDoc = JsonDocument.Parse(JsonSerializer.Serialize(after, AuditJsonOptions));
|
||||||
|
var changes = new List<AuditChangeDto>();
|
||||||
|
var keyList = new List<string>();
|
||||||
|
foreach (string key in keys)
|
||||||
|
{
|
||||||
|
keyList.Add(key);
|
||||||
|
SettingKind? kind = SettingsKeys.FindPublicKind(key);
|
||||||
|
if (kind is SettingKind.Dict or SettingKind.MyPrompts)
|
||||||
|
{
|
||||||
|
changes.Add(AuditDetails.Set(key, ChangedMarker));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
changes.Add(AuditDetails.Change(
|
||||||
|
key,
|
||||||
|
ReadSettingText(beforeDoc.RootElement, key),
|
||||||
|
ReadSettingText(afterDoc.RootElement, key)));
|
||||||
|
}
|
||||||
|
|
||||||
|
return changes.Count > 0
|
||||||
|
? changes
|
||||||
|
: [AuditDetails.Set(AuditFields.Fields, string.Join(", ", keyList))];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Читает значение ключа снимка настроек как текст для деталей аудита.
|
||||||
|
private static string? ReadSettingText(JsonElement root, string key) =>
|
||||||
|
root.TryGetProperty(key, out JsonElement value) ? ToText(value) : null;
|
||||||
|
|
||||||
|
// Приводит значение JSON к строке отображения (длинные значения обрезаются).
|
||||||
|
private static string? ToText(JsonElement value) => value.ValueKind switch
|
||||||
|
{
|
||||||
|
JsonValueKind.Null or JsonValueKind.Undefined => null,
|
||||||
|
JsonValueKind.String => Truncate(value.GetString()),
|
||||||
|
JsonValueKind.True => BoolText.True,
|
||||||
|
JsonValueKind.False => BoolText.False,
|
||||||
|
JsonValueKind.Array => Truncate(string.Join(", ", value.EnumerateArray().Select(ToText))),
|
||||||
|
_ => Truncate(value.GetRawText()),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Обрезает длинное значение деталей аудита.
|
||||||
|
private static string? Truncate(string? value) =>
|
||||||
|
value is not null && value.Length > MaxAuditValueLength ? value[..MaxAuditValueLength] + "…" : value;
|
||||||
|
|
||||||
private static bool ShouldScheduleRatesRefresh(Dictionary<string, JsonElement> body)
|
private static bool ShouldScheduleRatesRefresh(Dictionary<string, JsonElement> body)
|
||||||
{
|
{
|
||||||
if (!body.TryGetValue(SettingsKeys.RateSource, out JsonElement element))
|
if (!body.TryGetValue(SettingsKeys.RateSource, out JsonElement element))
|
||||||
|
|||||||
@@ -67,7 +67,12 @@ public static class TelegramEndpoints
|
|||||||
|
|
||||||
private const string NotConnectedReason = "not-connected";
|
private const string NotConnectedReason = "not-connected";
|
||||||
|
|
||||||
private const string ReadyPhase = "ready";
|
private const string ReadyPhase = TelegramAuthPhases.Ready;
|
||||||
|
|
||||||
|
// Русские подписи видов диалогов для операторского UI.
|
||||||
|
private const string RussianChannelLabel = "канал";
|
||||||
|
private const string RussianGroupLabel = "группа";
|
||||||
|
private const string RussianChatLabel = "чат";
|
||||||
|
|
||||||
private const int PreviewDefaultLimit = 24;
|
private const int PreviewDefaultLimit = 24;
|
||||||
|
|
||||||
@@ -170,7 +175,7 @@ public static class TelegramEndpoints
|
|||||||
TelegramAuthResultDto result = await gateway.StartQrAsync(apiId, keys.ApiHash, ct);
|
TelegramAuthResultDto result = await gateway.StartQrAsync(apiId, keys.ApiHash, ct);
|
||||||
if (result.Phase == ReadyPhase)
|
if (result.Phase == ReadyPhase)
|
||||||
{
|
{
|
||||||
await AuditAppender.AppendTenantAsync(context, AuditEvents.TelegramLinked, new { phase = result.Phase }, ct);
|
await AuditAppender.AppendTenantAsync(context, AuditEvents.TelegramLinked, [AuditDetails.Set(AuditFields.Phase, result.Phase)], ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Results.Ok(new { phase = result.Phase, qrUrl = result.QrUrl ?? string.Empty });
|
return Results.Ok(new { phase = result.Phase, qrUrl = result.QrUrl ?? string.Empty });
|
||||||
@@ -197,7 +202,7 @@ public static class TelegramEndpoints
|
|||||||
string phase = await gateway.SendCodeAsync((body.Code ?? string.Empty).Trim(), ct);
|
string phase = await gateway.SendCodeAsync((body.Code ?? string.Empty).Trim(), ct);
|
||||||
if (phase == ReadyPhase)
|
if (phase == ReadyPhase)
|
||||||
{
|
{
|
||||||
await AuditAppender.AppendTenantAsync(context, AuditEvents.TelegramLinked, new { phase }, ct);
|
await AuditAppender.AppendTenantAsync(context, AuditEvents.TelegramLinked, [AuditDetails.Set(AuditFields.Phase, phase)], ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Results.Ok(new { phase });
|
return Results.Ok(new { phase });
|
||||||
@@ -224,7 +229,7 @@ public static class TelegramEndpoints
|
|||||||
string phase = await gateway.SendPasswordAsync(body.Password ?? string.Empty, ct);
|
string phase = await gateway.SendPasswordAsync(body.Password ?? string.Empty, ct);
|
||||||
if (phase == ReadyPhase)
|
if (phase == ReadyPhase)
|
||||||
{
|
{
|
||||||
await AuditAppender.AppendTenantAsync(context, AuditEvents.TelegramLinked, new { phase }, ct);
|
await AuditAppender.AppendTenantAsync(context, AuditEvents.TelegramLinked, [AuditDetails.Set(AuditFields.Phase, phase)], ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Results.Ok(new { phase });
|
return Results.Ok(new { phase });
|
||||||
@@ -343,7 +348,7 @@ public static class TelegramEndpoints
|
|||||||
|
|
||||||
if (body.Enabled)
|
if (body.Enabled)
|
||||||
{
|
{
|
||||||
await AuditAppender.AppendTenantAsync(context, AuditEvents.ChannelEnabled, new { all = true, count = result.Count }, ct);
|
await AuditAppender.AppendTenantAsync(context, AuditEvents.ChannelEnabled, [AuditDetails.Set(AuditFields.All, true), AuditDetails.Set(AuditFields.Count, result.Count)], ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Results.Ok(new { ok = true, count = result.Count, enabled = body.Enabled });
|
return Results.Ok(new { ok = true, count = result.Count, enabled = body.Enabled });
|
||||||
@@ -393,7 +398,7 @@ public static class TelegramEndpoints
|
|||||||
|
|
||||||
if (result.Enabled)
|
if (result.Enabled)
|
||||||
{
|
{
|
||||||
await AuditAppender.AppendTenantAsync(context, AuditEvents.ChannelEnabled, new { dialogId = dialog_id }, ct);
|
await AuditAppender.AppendTenantAsync(context, AuditEvents.ChannelEnabled, [AuditDetails.Set(AuditFields.DialogId, dialog_id)], ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Results.Ok(new { ok = true, enabled = result.Enabled });
|
return Results.Ok(new { ok = true, enabled = result.Enabled });
|
||||||
@@ -482,9 +487,9 @@ public static class TelegramEndpoints
|
|||||||
{
|
{
|
||||||
return kind switch
|
return kind switch
|
||||||
{
|
{
|
||||||
"channel" => "канал",
|
TelegramDialogKinds.Channel => RussianChannelLabel,
|
||||||
"group" or "forum" => "группа",
|
TelegramDialogKinds.Group or TelegramDialogKinds.Forum => RussianGroupLabel,
|
||||||
"chat" => "чат",
|
TelegramDialogKinds.Chat => RussianChatLabel,
|
||||||
_ => kind,
|
_ => kind,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,7 +62,8 @@ internal static class DealLogging
|
|||||||
.MinimumLevel.Is(ParseMinimumLevel(configuration[MinimumLevelEnvKey]))
|
.MinimumLevel.Is(ParseMinimumLevel(configuration[MinimumLevelEnvKey]))
|
||||||
.MinimumLevel.Override(EntityFrameworkCoreCategory, LogEventLevel.Warning)
|
.MinimumLevel.Override(EntityFrameworkCoreCategory, LogEventLevel.Warning)
|
||||||
.MinimumLevel.Override(GrpcCategory, LogEventLevel.Information)
|
.MinimumLevel.Override(GrpcCategory, LogEventLevel.Information)
|
||||||
.Enrich.FromLogContext();
|
.Enrich.FromLogContext()
|
||||||
|
.Enrich.With<TraceContextEnricher>();
|
||||||
|
|
||||||
string logsDirectory = ResolveLogsDirectory(environment.ContentRootPath, configuration[LogsDirectoryEnvKey]);
|
string logsDirectory = ResolveLogsDirectory(environment.ContentRootPath, configuration[LogsDirectoryEnvKey]);
|
||||||
Directory.CreateDirectory(logsDirectory);
|
Directory.CreateDirectory(logsDirectory);
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
using Serilog.Core;
|
||||||
|
using Serilog.Events;
|
||||||
|
|
||||||
|
namespace Deal.Api.Logging;
|
||||||
|
|
||||||
|
internal sealed class TraceContextEnricher : ILogEventEnricher
|
||||||
|
{
|
||||||
|
void ILogEventEnricher.Enrich(LogEvent logEvent, ILogEventPropertyFactory propertyFactory)
|
||||||
|
{
|
||||||
|
Activity? activity = Activity.Current;
|
||||||
|
if (activity is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logEvent.AddPropertyIfAbsent(propertyFactory.CreateProperty("TraceId", activity.TraceId.ToString()));
|
||||||
|
logEvent.AddPropertyIfAbsent(propertyFactory.CreateProperty("SpanId", activity.SpanId.ToString()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
using Deal.SharedKernel.Errors;
|
||||||
|
using Deal.SharedKernel.Resources;
|
||||||
|
using Deal.SharedKernel.Tenants.Abstractions;
|
||||||
|
using Microsoft.AspNetCore.Diagnostics;
|
||||||
|
|
||||||
|
namespace Deal.Api.Middleware;
|
||||||
|
|
||||||
|
public sealed class DealExceptionHandler(ILogger<DealExceptionHandler> logger) : IExceptionHandler
|
||||||
|
{
|
||||||
|
public async ValueTask<bool> TryHandleAsync(
|
||||||
|
HttpContext httpContext,
|
||||||
|
Exception exception,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
(int statusCode, string errorCode, string detail) = Resolve(exception);
|
||||||
|
LogFailure(httpContext, exception, statusCode, errorCode);
|
||||||
|
httpContext.Response.StatusCode = statusCode;
|
||||||
|
await httpContext.Response.WriteAsJsonAsync(
|
||||||
|
new { detail, code = errorCode },
|
||||||
|
cancellationToken);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Доменные ошибки отдаются по коду; прочие — обобщённый 500 без деталей и стектрейса.
|
||||||
|
private static (int StatusCode, string ErrorCode, string Detail) Resolve(Exception exception)
|
||||||
|
=> exception is DealException dealException
|
||||||
|
? (MapStatusCode(dealException.ErrorCode), dealException.ErrorCode, dealException.Message)
|
||||||
|
: (StatusCodes.Status500InternalServerError,
|
||||||
|
DealErrorCodes.Internal,
|
||||||
|
ErrorResources.Format(ErrorResourceKeys.UnexpectedError));
|
||||||
|
|
||||||
|
// Код ошибки Deal → статус HTTP.
|
||||||
|
private static int MapStatusCode(string errorCode) => errorCode switch
|
||||||
|
{
|
||||||
|
DealErrorCodes.NotFound => StatusCodes.Status404NotFound,
|
||||||
|
DealErrorCodes.Validation => StatusCodes.Status400BadRequest,
|
||||||
|
DealErrorCodes.Conflict => StatusCodes.Status409Conflict,
|
||||||
|
DealErrorCodes.Unavailable => StatusCodes.Status503ServiceUnavailable,
|
||||||
|
_ => StatusCodes.Status500InternalServerError,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Доменные ошибки — Warning без стектрейса; непредвиденные — Error со стектрейсом (только в лог).
|
||||||
|
private void LogFailure(
|
||||||
|
HttpContext context,
|
||||||
|
Exception exception,
|
||||||
|
int statusCode,
|
||||||
|
string errorCode)
|
||||||
|
{
|
||||||
|
string method = context.Request.Method;
|
||||||
|
string path = context.Request.Path.Value ?? "/";
|
||||||
|
string tenantId = ResolveTenantId(context);
|
||||||
|
if (exception is DealException dealException)
|
||||||
|
{
|
||||||
|
logger.LogWarning(
|
||||||
|
"HTTP {Method} {Path} -> {StatusCode} {ErrorCode}; tenant={TenantId} trace={TraceId}: {Message}",
|
||||||
|
method,
|
||||||
|
path,
|
||||||
|
statusCode,
|
||||||
|
errorCode,
|
||||||
|
tenantId,
|
||||||
|
context.TraceIdentifier,
|
||||||
|
dealException.Message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.LogError(
|
||||||
|
exception,
|
||||||
|
"HTTP {Method} {Path} -> {StatusCode} {ErrorCode}; tenant={TenantId} trace={TraceId}",
|
||||||
|
method,
|
||||||
|
path,
|
||||||
|
statusCode,
|
||||||
|
errorCode,
|
||||||
|
tenantId,
|
||||||
|
context.TraceIdentifier);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Идентификатор тенанта запроса; вне tenant-запроса — "-".
|
||||||
|
private static string ResolveTenantId(HttpContext context)
|
||||||
|
{
|
||||||
|
ITenantContext? tenantContext = context.RequestServices?.GetService<ITenantContext>();
|
||||||
|
return tenantContext?.TenantId?.Value ?? "-";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
using Microsoft.AspNetCore.Builder;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using OpenTelemetry.Resources;
|
||||||
|
using OpenTelemetry.Trace;
|
||||||
|
|
||||||
|
namespace Deal.Api.Observability;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Настройка трейсинга ядра Deal.Api
|
||||||
|
/// </summary>
|
||||||
|
public static class DealTracingHosting
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Env-ключ OTLP-endpoint коллектора
|
||||||
|
/// </summary>
|
||||||
|
public const string OtlpEndpointEnvKey = "OTEL_EXPORTER_OTLP_ENDPOINT";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Env-ключ имени сервиса в трейсах
|
||||||
|
/// </summary>
|
||||||
|
public const string ServiceNameEnvKey = "OTEL_SERVICE_NAME";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Регистрирует трейсинг OpenTelemetry с экспортом OTLP
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="builder">Билдер ядра.</param>
|
||||||
|
/// <param name="defaultServiceName">Имя сервиса, если env OTEL_SERVICE_NAME не задан.</param>
|
||||||
|
public static void AddDealTracing(WebApplicationBuilder builder, string defaultServiceName)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(builder);
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(defaultServiceName);
|
||||||
|
|
||||||
|
// Трейсинг выключен без OTLP-endpoint (dev без профиля observability): иначе экспортёр
|
||||||
|
// вхолостую спамит ошибками соединения.
|
||||||
|
string? endpoint = Environment.GetEnvironmentVariable(OtlpEndpointEnvKey);
|
||||||
|
if (string.IsNullOrWhiteSpace(endpoint))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
string serviceName = Environment.GetEnvironmentVariable(ServiceNameEnvKey) is { Length: > 0 } configured
|
||||||
|
? configured
|
||||||
|
: defaultServiceName;
|
||||||
|
|
||||||
|
builder.Services
|
||||||
|
.AddOpenTelemetry()
|
||||||
|
.WithTracing(tracing => tracing
|
||||||
|
.SetResourceBuilder(ResourceBuilder.CreateDefault().AddService(serviceName))
|
||||||
|
.AddAspNetCoreInstrumentation()
|
||||||
|
.AddHttpClientInstrumentation()
|
||||||
|
.AddGrpcClientInstrumentation()
|
||||||
|
.AddOtlpExporter(options => options.Endpoint = new Uri(endpoint)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,6 +25,7 @@ using Deal.Modules.Kanban.Application.Registrars;
|
|||||||
using Deal.Modules.Pipeline.Application.Registrars;
|
using Deal.Modules.Pipeline.Application.Registrars;
|
||||||
using Deal.Modules.Settings.Application.Abstractions;
|
using Deal.Modules.Settings.Application.Abstractions;
|
||||||
using Deal.Modules.Settings.Application.Registrars;
|
using Deal.Modules.Settings.Application.Registrars;
|
||||||
|
using Deal.Modules.Settings.Application.Services;
|
||||||
using Deal.Modules.Telegram.Application;
|
using Deal.Modules.Telegram.Application;
|
||||||
using Deal.Modules.Tenants.Application.Models;
|
using Deal.Modules.Tenants.Application.Models;
|
||||||
using Deal.Modules.Tenants.Application.Registrars;
|
using Deal.Modules.Tenants.Application.Registrars;
|
||||||
@@ -66,6 +67,7 @@ DealLogging.Configure(builder, coreProcessName);
|
|||||||
|
|
||||||
int metricsPort = DealMetricsHosting.ResolveMetricsPort(DealMetricsHosting.DefaultMetricsPort);
|
int metricsPort = DealMetricsHosting.ResolveMetricsPort(DealMetricsHosting.DefaultMetricsPort);
|
||||||
DealMetricsHosting.AddDealMetrics(builder, metricsPort);
|
DealMetricsHosting.AddDealMetrics(builder, metricsPort);
|
||||||
|
DealTracingHosting.AddDealTracing(builder, coreProcessName);
|
||||||
|
|
||||||
// Строка подключения Postgres — ТОЛЬКО из конфигурации (env/appsettings): dev-пароль в коде отсутствует
|
// Строка подключения Postgres — ТОЛЬКО из конфигурации (env/appsettings): dev-пароль в коде отсутствует
|
||||||
// (Security review). Отсутствие строки = fail-fast на старте, а не тихий уход на несуществующую dev-БД.
|
// (Security review). Отсутствие строки = fail-fast на старте, а не тихий уход на несуществующую dev-БД.
|
||||||
@@ -132,6 +134,8 @@ TokenLimitDefaults tenantLimitDefaults = new(
|
|||||||
builder.Services.AddDealPersistence(tenantLimitDefaults);
|
builder.Services.AddDealPersistence(tenantLimitDefaults);
|
||||||
|
|
||||||
builder.Services.AddDealSecurity(builder.Environment.ContentRootPath);
|
builder.Services.AddDealSecurity(builder.Environment.ContentRootPath);
|
||||||
|
builder.Services.AddExceptionHandler<DealExceptionHandler>();
|
||||||
|
builder.Services.AddProblemDetails();
|
||||||
|
|
||||||
MlServiceOptions mlOptions = builder.Configuration.GetSection(servicesSectionName).Get<MlServiceOptions>() ?? new MlServiceOptions();
|
MlServiceOptions mlOptions = builder.Configuration.GetSection(servicesSectionName).Get<MlServiceOptions>() ?? new MlServiceOptions();
|
||||||
builder.Services.AddSingleton(mlOptions);
|
builder.Services.AddSingleton(mlOptions);
|
||||||
@@ -157,11 +161,14 @@ builder.Services.AddDiscoveryModule();
|
|||||||
|
|
||||||
builder.Services.AddScoped<TgStatusService>();
|
builder.Services.AddScoped<TgStatusService>();
|
||||||
builder.Services.AddScoped<TelegramKeysService>();
|
builder.Services.AddScoped<TelegramKeysService>();
|
||||||
|
builder.Services.AddScoped<AiGlobalConfigService>();
|
||||||
|
|
||||||
builder.Services.AddSingleton<TelegramBackfillScheduler>();
|
builder.Services.AddSingleton<TelegramBackfillScheduler>();
|
||||||
|
|
||||||
builder.Services.AddScoped<AdminTickOrchestrator>();
|
builder.Services.AddScoped<AdminTickOrchestrator>();
|
||||||
|
|
||||||
|
builder.Services.AddScoped<IAuditReferenceResolver, AuditReferenceResolver>();
|
||||||
|
|
||||||
builder.Services.AddScoped<FtsMaintenance>();
|
builder.Services.AddScoped<FtsMaintenance>();
|
||||||
|
|
||||||
builder.Services.AddSingleton<SseBroker>();
|
builder.Services.AddSingleton<SseBroker>();
|
||||||
@@ -325,6 +332,7 @@ if (forwardedHeadersConfig.Enabled)
|
|||||||
app.UseForwardedHeaders(BuildForwardedHeadersOptions(forwardedHeadersConfig));
|
app.UseForwardedHeaders(BuildForwardedHeadersOptions(forwardedHeadersConfig));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
app.UseExceptionHandler();
|
||||||
app.UseMiddleware<HttpAccessLogMiddleware>();
|
app.UseMiddleware<HttpAccessLogMiddleware>();
|
||||||
|
|
||||||
app.UseCors(corsPolicyName);
|
app.UseCors(corsPolicyName);
|
||||||
@@ -350,7 +358,6 @@ app.MapOperatorSettingsEndpoints();
|
|||||||
app.MapOperatorMaintenanceEndpoints();
|
app.MapOperatorMaintenanceEndpoints();
|
||||||
app.MapJoinEndpoint();
|
app.MapJoinEndpoint();
|
||||||
app.MapSettingsEndpoints();
|
app.MapSettingsEndpoints();
|
||||||
app.MapAiCheckEndpoint();
|
|
||||||
app.MapRatesEndpoints();
|
app.MapRatesEndpoints();
|
||||||
app.MapMlEndpoints();
|
app.MapMlEndpoints();
|
||||||
app.MapFilterTesterEndpoints();
|
app.MapFilterTesterEndpoints();
|
||||||
|
|||||||
@@ -13,12 +13,14 @@ public static class AuditAppender
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Пишет событие действия пользователя тенанта
|
/// Пишет событие действия пользователя тенанта
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="context">Контекст запроса (сессия пользователя, IP).</param>
|
||||||
/// <param name="eventType">Тип события — константа <see cref="AuditEvents"/>.</param>
|
/// <param name="eventType">Тип события — константа <see cref="AuditEvents"/>.</param>
|
||||||
/// <param name="details">Минимальные детали события (обычно анонимный объект) или null.</param>
|
/// <param name="changes">Изменения параметров события.</param>
|
||||||
|
/// <param name="ct">Токен отмены.</param>
|
||||||
public static async Task AppendTenantAsync(
|
public static async Task AppendTenantAsync(
|
||||||
HttpContext context,
|
HttpContext context,
|
||||||
string eventType,
|
string eventType,
|
||||||
object? details,
|
IReadOnlyList<AuditChangeDto> changes,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
CurrentUser? user = context.GetCurrentUser();
|
CurrentUser? user = context.GetCurrentUser();
|
||||||
@@ -35,19 +37,21 @@ public static class AuditAppender
|
|||||||
ActorId: user.UserId,
|
ActorId: user.UserId,
|
||||||
TenantId: user.TenantId,
|
TenantId: user.TenantId,
|
||||||
Ip: ClientIp(context),
|
Ip: ClientIp(context),
|
||||||
DetailJson: DetailJson(details)),
|
DetailJson: DetailJson(changes)),
|
||||||
ct);
|
ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Пишет событие действия оператора
|
/// Пишет событие действия оператора
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="context">Контекст запроса (операторская сессия, IP).</param>
|
||||||
/// <param name="eventType">Тип события — константа <see cref="AuditEvents"/>.</param>
|
/// <param name="eventType">Тип события — константа <see cref="AuditEvents"/>.</param>
|
||||||
/// <param name="details">Минимальные детали события (обычно анонимный объект) или null.</param>
|
/// <param name="changes">Изменения параметров события.</param>
|
||||||
|
/// <param name="ct">Токен отмены.</param>
|
||||||
public static async Task AppendOperatorAsync(
|
public static async Task AppendOperatorAsync(
|
||||||
HttpContext context,
|
HttpContext context,
|
||||||
string eventType,
|
string eventType,
|
||||||
object? details,
|
IReadOnlyList<AuditChangeDto> changes,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
CurrentOperator? operatorIdentity = context.GetCurrentOperator();
|
CurrentOperator? operatorIdentity = context.GetCurrentOperator();
|
||||||
@@ -64,18 +68,16 @@ public static class AuditAppender
|
|||||||
ActorId: operatorIdentity.OperatorId,
|
ActorId: operatorIdentity.OperatorId,
|
||||||
TenantId: null,
|
TenantId: null,
|
||||||
Ip: ClientIp(context),
|
Ip: ClientIp(context),
|
||||||
DetailJson: DetailJson(details)),
|
DetailJson: DetailJson(changes)),
|
||||||
ct);
|
ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Сериализует детали события (null — деталей нет).
|
// Сериализует детали события (пустой список — деталей нет).
|
||||||
// details: Объект деталей или null.
|
// changes: Изменения параметров события.
|
||||||
// Возвращает: JSON деталей (camelCase) или null.
|
// Возвращает: JSON деталей или null.
|
||||||
private static string? DetailJson(object? details) =>
|
private static string? DetailJson(IReadOnlyList<AuditChangeDto> changes) =>
|
||||||
details is null ? null : AuditService.ToDetailJson(details);
|
changes.Count == 0 ? null : AuditService.ToDetailJson(changes);
|
||||||
|
|
||||||
// IP-адрес клиента для аудита (без порта; null, если недоступен).
|
// IP-адрес клиента для аудита (без порта; null, если недоступен).
|
||||||
// context: Контекст запроса.
|
|
||||||
// Возвращает: Строковое представление IP или null.
|
|
||||||
private static string? ClientIp(HttpContext context) => context.Connection.RemoteIpAddress?.ToString();
|
private static string? ClientIp(HttpContext context) => context.Connection.RemoteIpAddress?.ToString();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
using Deal.Modules.Cards.Application.Models;
|
||||||
|
using Deal.Modules.Kanban.Application.Abstractions;
|
||||||
|
using Deal.Modules.Kanban.Application.Models;
|
||||||
|
using Deal.Modules.Tenants.Application.Models;
|
||||||
|
using Deal.SharedKernel.Tenants.Abstractions;
|
||||||
|
using Deal.SharedKernel.Tenants.Models;
|
||||||
|
|
||||||
|
namespace Deal.Api.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Резолвер ссылок аудита на данные тенанта
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="scopeFactory">Фабрика scope для чтения схемы тенанта.</param>
|
||||||
|
/// <param name="logger">Логгер сбоев разрешения ссылок.</param>
|
||||||
|
public sealed class AuditReferenceResolver(
|
||||||
|
IServiceScopeFactory scopeFactory,
|
||||||
|
ILogger<AuditReferenceResolver> logger) : IAuditReferenceResolver
|
||||||
|
{
|
||||||
|
async Task<IReadOnlyDictionary<string, string>> IAuditReferenceResolver.ResolveAsync(
|
||||||
|
IReadOnlyList<AuditRecordDto> records,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
var names = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||||
|
foreach (IGrouping<Guid, AuditRecordDto> group in GroupByTenant(records))
|
||||||
|
{
|
||||||
|
await ResolveTenantAsync(group.Key, group, names, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
return names;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IEnumerable<IGrouping<Guid, AuditRecordDto>> GroupByTenant(IReadOnlyList<AuditRecordDto> records) =>
|
||||||
|
records
|
||||||
|
.Where(record => record.TenantId is not null)
|
||||||
|
.GroupBy(record => record.TenantId!.Value);
|
||||||
|
|
||||||
|
private async Task ResolveTenantAsync(
|
||||||
|
Guid tenantId,
|
||||||
|
IEnumerable<AuditRecordDto> records,
|
||||||
|
Dictionary<string, string> names,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
var cardIds = new HashSet<string>(StringComparer.Ordinal);
|
||||||
|
var boardIds = new HashSet<string>(StringComparer.Ordinal);
|
||||||
|
foreach (AuditRecordDto record in records)
|
||||||
|
{
|
||||||
|
foreach (AuditChangeDto change in record.AuditChanges())
|
||||||
|
{
|
||||||
|
Collect(change.From, cardIds, boardIds);
|
||||||
|
Collect(change.To, cardIds, boardIds);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cardIds.Count == 0 && boardIds.Count == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await using AsyncServiceScope scope = scopeFactory.CreateAsyncScope();
|
||||||
|
ITenantContext tenantContext = scope.ServiceProvider.GetRequiredService<ITenantContext>();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
tenantContext.SetTenant(new TenantId(tenantId.ToString("N")));
|
||||||
|
ICardStore store = scope.ServiceProvider.GetRequiredService<ICardStore>();
|
||||||
|
await ResolveCardsAsync(store, cardIds, names, ct);
|
||||||
|
await ResolveContainersAsync(store, boardIds, names, ct);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
logger.LogWarning(exception, "Резолвер аудита: ссылки тенанта {TenantId} не разрешены", tenantId);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
tenantContext.Reset();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Collect(
|
||||||
|
string? value,
|
||||||
|
HashSet<string> cardIds,
|
||||||
|
HashSet<string> boardIds)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(value))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value.StartsWith(CardIds.CardPrefix, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
cardIds.Add(value);
|
||||||
|
}
|
||||||
|
else if (value.StartsWith(KanbanIdPrefixes.Board, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
boardIds.Add(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task ResolveCardsAsync(
|
||||||
|
ICardStore store,
|
||||||
|
HashSet<string> cardIds,
|
||||||
|
Dictionary<string, string> names,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
foreach (string cardId in cardIds)
|
||||||
|
{
|
||||||
|
CardDto? card = await store.GetCardAsync(cardId, ct);
|
||||||
|
if (card is not null && !string.IsNullOrWhiteSpace(card.Title))
|
||||||
|
{
|
||||||
|
names[cardId] = card.Title;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task ResolveContainersAsync(
|
||||||
|
ICardStore store,
|
||||||
|
HashSet<string> boardIds,
|
||||||
|
Dictionary<string, string> names,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
foreach (string boardId in boardIds)
|
||||||
|
{
|
||||||
|
ContainerDto? container = await store.GetContainerAsync(boardId, ct);
|
||||||
|
if (container is not null && !string.IsNullOrWhiteSpace(container.Name))
|
||||||
|
{
|
||||||
|
names[boardId] = container.Name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
using Deal.Modules.Tenants.Application.Abstractions;
|
||||||
|
using Deal.Modules.Tenants.Application.Models;
|
||||||
|
|
||||||
|
namespace Deal.Api.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Проекция записей аудита для операторской консоли
|
||||||
|
/// </summary>
|
||||||
|
public static class AuditViewFactory
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Дополняет записи аудита реальными пользователями и разбирает изменения деталей
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="records">Записи аудита.</param>
|
||||||
|
/// <param name="tenantRepository">Реестр пространств для разрешения имён.</param>
|
||||||
|
/// <param name="authStore">Хранилище пользователей для разрешения логинов владельцев.</param>
|
||||||
|
/// <param name="referenceResolver">Резолвер ссылок на карточки и колонки.</param>
|
||||||
|
/// <param name="ct">Токен отмены.</param>
|
||||||
|
/// <returns>Записи для чтения оператором.</returns>
|
||||||
|
public static async Task<IReadOnlyList<AuditRecordViewDto>> ProjectAsync(
|
||||||
|
IReadOnlyList<AuditRecordDto> records,
|
||||||
|
ITenantRepository tenantRepository,
|
||||||
|
IAuthStore authStore,
|
||||||
|
IAuditReferenceResolver referenceResolver,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
Guid[] tenantIds = DistinctTenantIds(records);
|
||||||
|
IReadOnlyDictionary<Guid, string> names = await ResolveNamesAsync(tenantIds, tenantRepository, ct);
|
||||||
|
IReadOnlyDictionary<Guid, string> owners = await ResolveOwnerLoginsAsync(tenantIds, authStore, ct);
|
||||||
|
IReadOnlyDictionary<string, string> references = await referenceResolver.ResolveAsync(records, ct);
|
||||||
|
|
||||||
|
var view = new List<AuditRecordViewDto>(records.Count);
|
||||||
|
foreach (AuditRecordDto record in records)
|
||||||
|
{
|
||||||
|
string? tenantName = record.TenantId is { } tenantId && names.TryGetValue(tenantId, out string? name)
|
||||||
|
? name
|
||||||
|
: null;
|
||||||
|
view.Add(new AuditRecordViewDto(
|
||||||
|
record.EventType,
|
||||||
|
record.ActorType,
|
||||||
|
record.ActorId,
|
||||||
|
record.TenantId,
|
||||||
|
ResolveUserName(record, owners),
|
||||||
|
tenantName,
|
||||||
|
record.Ip,
|
||||||
|
ResolveChanges(record, references),
|
||||||
|
record.DetailJson,
|
||||||
|
record.At,
|
||||||
|
record.Id));
|
||||||
|
}
|
||||||
|
|
||||||
|
return view;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Заменяет идентификаторы ссылок изменения значениями из карты имён и убирает пустые изменения.
|
||||||
|
// record: Запись аудита.
|
||||||
|
// references: Имена по идентификаторам ссылок.
|
||||||
|
// Возвращает: Изменения с читаемыми значениями ссылок без пар «без изменения».
|
||||||
|
private static IReadOnlyList<AuditChangeDto> ResolveChanges(
|
||||||
|
AuditRecordDto record,
|
||||||
|
IReadOnlyDictionary<string, string> references)
|
||||||
|
{
|
||||||
|
IReadOnlyList<AuditChangeDto> changes = record.AuditChanges();
|
||||||
|
var resolved = new List<AuditChangeDto>(changes.Count);
|
||||||
|
foreach (AuditChangeDto change in changes)
|
||||||
|
{
|
||||||
|
string? from = ResolveReference(references, change.From);
|
||||||
|
string? to = ResolveReference(references, change.To);
|
||||||
|
if (IsUnchanged(from, to))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
resolved.Add(change with { From = from, To = to });
|
||||||
|
}
|
||||||
|
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сравнивает значения «было» и «стало».
|
||||||
|
// from: Значение до изменения.
|
||||||
|
// to: Значение после изменения.
|
||||||
|
// Возвращает: True — значения заданы и совпадают.
|
||||||
|
private static bool IsUnchanged(string? from, string? to) =>
|
||||||
|
from is not null && to is not null && string.Equals(from, to, StringComparison.Ordinal);
|
||||||
|
|
||||||
|
// Подставляет имя вместо идентификатора ссылки, если оно известно.
|
||||||
|
// references: Имена по идентификаторам ссылок.
|
||||||
|
// value: Значение изменения.
|
||||||
|
// Возвращает: Имя ссылки либо исходное значение.
|
||||||
|
private static string? ResolveReference(
|
||||||
|
IReadOnlyDictionary<string, string> references,
|
||||||
|
string? value) =>
|
||||||
|
value is not null && references.TryGetValue(value, out string? name) ? name : value;
|
||||||
|
|
||||||
|
// Логин реального пользователя: владелец пространства, иначе логин/email из деталей события.
|
||||||
|
// record: Запись аудита.
|
||||||
|
// owners: Логины владельцев по идентификаторам пространств.
|
||||||
|
// Возвращает: Логин либо null.
|
||||||
|
private static string? ResolveUserName(
|
||||||
|
AuditRecordDto record,
|
||||||
|
IReadOnlyDictionary<Guid, string> owners)
|
||||||
|
{
|
||||||
|
if (record.TenantId is { } tenantId && owners.TryGetValue(tenantId, out string? owner))
|
||||||
|
{
|
||||||
|
return owner;
|
||||||
|
}
|
||||||
|
|
||||||
|
return record.DetailValue(AuditFields.Login) ?? record.DetailValue(AuditFields.Email);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Уникальные идентификаторы пространств выборки.
|
||||||
|
private static Guid[] DistinctTenantIds(IReadOnlyList<AuditRecordDto> records) =>
|
||||||
|
records
|
||||||
|
.Select(record => record.TenantId)
|
||||||
|
.Where(id => id is not null)
|
||||||
|
.Select(id => id!.Value)
|
||||||
|
.Distinct()
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
// Разрешает имена пространств выборкой по уникальным идентификаторам.
|
||||||
|
private static async Task<IReadOnlyDictionary<Guid, string>> ResolveNamesAsync(
|
||||||
|
Guid[] tenantIds,
|
||||||
|
ITenantRepository tenantRepository,
|
||||||
|
CancellationToken ct) =>
|
||||||
|
tenantIds.Length == 0
|
||||||
|
? new Dictionary<Guid, string>()
|
||||||
|
: await tenantRepository.FindNamesByIdsAsync(tenantIds, ct);
|
||||||
|
|
||||||
|
// Разрешает логины владельцев пространств выборкой по уникальным идентификаторам.
|
||||||
|
private static async Task<IReadOnlyDictionary<Guid, string>> ResolveOwnerLoginsAsync(
|
||||||
|
Guid[] tenantIds,
|
||||||
|
IAuthStore authStore,
|
||||||
|
CancellationToken ct) =>
|
||||||
|
tenantIds.Length == 0
|
||||||
|
? new Dictionary<Guid, string>()
|
||||||
|
: await authStore.FindOwnerLoginsByTenantIdsAsync(tenantIds, ct);
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
using Deal.Modules.Tenants.Application.Models;
|
||||||
|
|
||||||
|
namespace Deal.Api.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Разрешает идентификаторы ссылок аудита в читаемые имена
|
||||||
|
/// </summary>
|
||||||
|
public interface IAuditReferenceResolver
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Разрешает ссылки на карточки и колонки в их заголовки и имена
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="records">Записи аудита выборки.</param>
|
||||||
|
/// <param name="ct">Токен отмены.</param>
|
||||||
|
/// <returns>Имена по идентификаторам ссылок; неразрешённые идентификаторы отсутствуют.</returns>
|
||||||
|
public Task<IReadOnlyDictionary<string, string>> ResolveAsync(
|
||||||
|
IReadOnlyList<AuditRecordDto> records,
|
||||||
|
CancellationToken ct);
|
||||||
|
}
|
||||||
@@ -12,6 +12,10 @@ public sealed class TelegramBackfillScheduler(
|
|||||||
IHostApplicationLifetime applicationLifetime,
|
IHostApplicationLifetime applicationLifetime,
|
||||||
ILogger<TelegramBackfillScheduler> logger)
|
ILogger<TelegramBackfillScheduler> logger)
|
||||||
{
|
{
|
||||||
|
// Имена фоновых задач (метки запуска).
|
||||||
|
private const string BackfillMonitoredJob = "backfill_monitored";
|
||||||
|
private const string FirstBackfillJob = "first_backfill";
|
||||||
|
|
||||||
// Флаг in-flight «Перечитать» всех каналов (Interlocked): повторный вызов не плодит
|
// Флаг in-flight «Перечитать» всех каналов (Interlocked): повторный вызов не плодит
|
||||||
// параллельные полные перечитывания (Security review, как RatesRefreshScheduler).
|
// параллельные полные перечитывания (Security review, как RatesRefreshScheduler).
|
||||||
private int _readRecentInProgress;
|
private int _readRecentInProgress;
|
||||||
@@ -27,7 +31,7 @@ public sealed class TelegramBackfillScheduler(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
RunBackground("backfill_monitored", RunReadRecentAsync);
|
RunBackground(BackfillMonitoredJob, RunReadRecentAsync);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -50,7 +54,7 @@ public sealed class TelegramBackfillScheduler(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
RunBackground("first_backfill", (provider, runLogger, ct) => RunFirstBackfillsAsync(provider, runLogger, dialogIds, ct));
|
RunBackground(FirstBackfillJob, (provider, runLogger, ct) => RunFirstBackfillsAsync(provider, runLogger, dialogIds, ct));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Выполняет работу в собственном scope; любые ошибки — warning в лог (как RatesRefreshScheduler).
|
// Выполняет работу в собственном scope; любые ошибки — warning в лог (как RatesRefreshScheduler).
|
||||||
|
|||||||
@@ -21,6 +21,11 @@ public sealed class SourceIngressGrpcService(
|
|||||||
IngressTenantResolver tenants,
|
IngressTenantResolver tenants,
|
||||||
ILogger<SourceIngressGrpcService> logger) : SourceIngressService.SourceIngressServiceBase
|
ILogger<SourceIngressGrpcService> logger) : SourceIngressService.SourceIngressServiceBase
|
||||||
{
|
{
|
||||||
|
// Исходы приёма источника для лога аудита.
|
||||||
|
private const string DuplicateOutcome = "duplicate";
|
||||||
|
private const string NoOpOutcome = "no-op";
|
||||||
|
private const string QueuedOutcome = "queued";
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// PushSource — запись источника в очередь пайплайна тенанта.
|
/// PushSource — запись источника в очередь пайплайна тенанта.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -57,7 +62,7 @@ public sealed class SourceIngressGrpcService(
|
|||||||
tenant.Id,
|
tenant.Id,
|
||||||
item.Source.Kind,
|
item.Source.Kind,
|
||||||
item.Source.ExternalId ?? "-",
|
item.Source.ExternalId ?? "-",
|
||||||
result.Duplicate ? "duplicate" : result.Id is null ? "no-op" : "queued");
|
result.Duplicate ? DuplicateOutcome : result.Id is null ? NoOpOutcome : QueuedOutcome);
|
||||||
|
|
||||||
return new PushSourceReply
|
return new PushSourceReply
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -26,6 +26,15 @@ public sealed class TelegramKeysService(IGlobalSettingsStore store, ISecretCiphe
|
|||||||
|
|
||||||
private const string EncryptedPrefix = "enc:";
|
private const string EncryptedPrefix = "enc:";
|
||||||
|
|
||||||
|
// Ключи JSON значения telegramKeys (camelCase, как пишет SaveAsync).
|
||||||
|
private const string ApiIdProperty = "apiId";
|
||||||
|
private const string ApiHashProperty = "apiHash";
|
||||||
|
|
||||||
|
// Геометрия маски секрета: короткий (≤8) → «x…»; иначе «1234…5678».
|
||||||
|
private const int MaskShortMaxLength = 8;
|
||||||
|
private const int MaskShortVisibleChars = 1;
|
||||||
|
private const int MaskEdgeVisibleChars = 4;
|
||||||
|
|
||||||
// Опции JSON значения telegramKeys: camelCase (как пишет SaveAsync) + терпимость регистра.
|
// Опции JSON значения telegramKeys: camelCase (как пишет SaveAsync) + терпимость регистра.
|
||||||
private static readonly JsonSerializerOptions KeysJsonOptions = new()
|
private static readonly JsonSerializerOptions KeysJsonOptions = new()
|
||||||
{
|
{
|
||||||
@@ -49,8 +58,8 @@ public sealed class TelegramKeysService(IGlobalSettingsStore store, ISecretCiphe
|
|||||||
{
|
{
|
||||||
using JsonDocument document = JsonDocument.Parse(row.ValueJson);
|
using JsonDocument document = JsonDocument.Parse(row.ValueJson);
|
||||||
JsonElement root = document.RootElement;
|
JsonElement root = document.RootElement;
|
||||||
string apiId = ReadString(root, "apiId");
|
string apiId = ReadString(root, ApiIdProperty);
|
||||||
string apiHash = ReadString(root, "apiHash");
|
string apiHash = ReadString(root, ApiHashProperty);
|
||||||
return new TgKeysSnapshot(apiId, cipher.Decrypt(apiHash));
|
return new TgKeysSnapshot(apiId, cipher.Decrypt(apiHash));
|
||||||
}
|
}
|
||||||
catch (JsonException)
|
catch (JsonException)
|
||||||
@@ -143,8 +152,11 @@ public sealed class TelegramKeysService(IGlobalSettingsStore store, ISecretCiphe
|
|||||||
return value.Length switch
|
return value.Length switch
|
||||||
{
|
{
|
||||||
0 => string.Empty,
|
0 => string.Empty,
|
||||||
<= 8 => string.Concat(value.AsSpan(0, 1), MaskEllipsis),
|
<= MaskShortMaxLength => string.Concat(value.AsSpan(0, MaskShortVisibleChars), MaskEllipsis),
|
||||||
_ => string.Concat(value.AsSpan(0, 4), MaskEllipsis, value.AsSpan(value.Length - 4)),
|
_ => string.Concat(
|
||||||
|
value.AsSpan(0, MaskEdgeVisibleChars),
|
||||||
|
MaskEllipsis,
|
||||||
|
value.AsSpan(value.Length - MaskEdgeVisibleChars)),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ public sealed class TgStatusService(
|
|||||||
TelegramKeysService keys)
|
TelegramKeysService keys)
|
||||||
{
|
{
|
||||||
private const string IdlePhase = "idle";
|
private const string IdlePhase = "idle";
|
||||||
|
private const string ReadyPhase = "ready";
|
||||||
|
|
||||||
// Опции JSON KV-значений статуса: camelCase (как пишет ингресс) + терпимость регистра.
|
// Опции JSON KV-значений статуса: camelCase (как пишет ингресс) + терпимость регистра.
|
||||||
private static readonly JsonSerializerOptions KvJsonOptions = new()
|
private static readonly JsonSerializerOptions KvJsonOptions = new()
|
||||||
@@ -37,13 +38,19 @@ public sealed class TgStatusService(
|
|||||||
public async Task<TgStatusDto> GetAsync(CancellationToken ct)
|
public async Task<TgStatusDto> GetAsync(CancellationToken ct)
|
||||||
{
|
{
|
||||||
TelegramAccountStatusDto live = await ReadLiveAsync(ct).ConfigureAwait(false);
|
TelegramAccountStatusDto live = await ReadLiveAsync(ct).ConfigureAwait(false);
|
||||||
string account = await ReadAccountAsync(ct).ConfigureAwait(false);
|
// «Подключён» для UI = авторизован (phase ready). Транспортный connected сервиса
|
||||||
|
// означает лишь живость соединения и не гарантирует вход — в UI он даёт «зависание».
|
||||||
|
bool authorized = string.Equals(live.Phase, ReadyPhase, StringComparison.Ordinal);
|
||||||
|
// Живой account (имя из Telegram) приоритетнее KV: при QR-входе KV ещё не заполнен.
|
||||||
|
string account = string.IsNullOrEmpty(live.Account)
|
||||||
|
? await ReadAccountAsync(ct).ConfigureAwait(false)
|
||||||
|
: live.Account;
|
||||||
int monitored = (await dialogs.ListMonitoredIdsAsync(ct).ConfigureAwait(false)).Count;
|
int monitored = (await dialogs.ListMonitoredIdsAsync(ct).ConfigureAwait(false)).Count;
|
||||||
TgKeysSnapshot snapshot = await keys.GetAsync(ct).ConfigureAwait(false);
|
TgKeysSnapshot snapshot = await keys.GetAsync(ct).ConfigureAwait(false);
|
||||||
|
|
||||||
return new TgStatusDto(
|
return new TgStatusDto(
|
||||||
Phase: live.Phase,
|
Phase: live.Phase,
|
||||||
Connected: live.Connected,
|
Connected: authorized,
|
||||||
Listener: live.Listener,
|
Listener: live.Listener,
|
||||||
Account: account,
|
Account: account,
|
||||||
Monitored: monitored,
|
Monitored: monitored,
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
namespace Deal.Contracts.Integrations.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Типы контактов заказчика
|
||||||
|
/// </summary>
|
||||||
|
public static class CardContactTypes
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Telegram (@username)
|
||||||
|
/// </summary>
|
||||||
|
public const string Telegram = "tg";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Телефон
|
||||||
|
/// </summary>
|
||||||
|
public const string Phone = "phone";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Email
|
||||||
|
/// </summary>
|
||||||
|
public const string Email = "email";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// WhatsApp
|
||||||
|
/// </summary>
|
||||||
|
public const string WhatsApp = "whatsapp";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// LinkedIn
|
||||||
|
/// </summary>
|
||||||
|
public const string LinkedIn = "linkedin";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Сайт
|
||||||
|
/// </summary>
|
||||||
|
public const string Site = "site";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Прочее (не опознано)
|
||||||
|
/// </summary>
|
||||||
|
public const string Other = "other";
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
namespace Deal.Contracts.Integrations.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Фазы входа в аккаунт Telegram
|
||||||
|
/// </summary>
|
||||||
|
public static class TelegramAuthPhases
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Аккаунт не подключён
|
||||||
|
/// </summary>
|
||||||
|
public const string Idle = "idle";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ожидание номера телефона
|
||||||
|
/// </summary>
|
||||||
|
public const string Phone = "phone";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ожидание кода
|
||||||
|
/// </summary>
|
||||||
|
public const string Code = "code";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ожидание облачного пароля
|
||||||
|
/// </summary>
|
||||||
|
public const string Password = "password";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ожидание сканирования QR
|
||||||
|
/// </summary>
|
||||||
|
public const string Qr = "qr";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Аккаунт авторизован
|
||||||
|
/// </summary>
|
||||||
|
public const string Ready = "ready";
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
namespace Deal.Contracts.Integrations.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Виды диалогов Telegram
|
||||||
|
/// </summary>
|
||||||
|
public static class TelegramDialogKinds
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Канал
|
||||||
|
/// </summary>
|
||||||
|
public const string Channel = "channel";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Группа
|
||||||
|
/// </summary>
|
||||||
|
public const string Group = "group";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Форум (темы внутри группы)
|
||||||
|
/// </summary>
|
||||||
|
public const string Forum = "forum";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Личный чат
|
||||||
|
/// </summary>
|
||||||
|
public const string Chat = "chat";
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
namespace Deal.Contracts.Integrations.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Имена операций Telegram-гейта для логов и диагностики
|
||||||
|
/// </summary>
|
||||||
|
public static class TelegramOperations
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Статус аккаунта
|
||||||
|
/// </summary>
|
||||||
|
public const string Status = "status";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Вход по номеру телефона
|
||||||
|
/// </summary>
|
||||||
|
public const string StartPhone = "start_phone";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Вход по QR
|
||||||
|
/// </summary>
|
||||||
|
public const string StartQr = "start_qr";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Подтверждение кода
|
||||||
|
/// </summary>
|
||||||
|
public const string SendCode = "send_code";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Подтверждение облачного пароля
|
||||||
|
/// </summary>
|
||||||
|
public const string SendPassword = "send_password";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Выход из аккаунта
|
||||||
|
/// </summary>
|
||||||
|
public const string Logout = "logout";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Обновление каталога диалогов
|
||||||
|
/// </summary>
|
||||||
|
public const string RefreshDialogs = "refresh_dialogs";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Включение/выключение мониторинга диалога
|
||||||
|
/// </summary>
|
||||||
|
public const string SetMonitor = "set_monitor";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Включение/выключение мониторинга всех диалогов
|
||||||
|
/// </summary>
|
||||||
|
public const string SetMonitorAll = "set_monitor_all";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Перечитать последние сообщения
|
||||||
|
/// </summary>
|
||||||
|
public const string Backfill = "backfill";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Чтение последних сообщений
|
||||||
|
/// </summary>
|
||||||
|
public const string ReadRecent = "read_recent";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Чтение исходного сообщения
|
||||||
|
/// </summary>
|
||||||
|
public const string ReadSource = "read_source";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Поиск источников
|
||||||
|
/// </summary>
|
||||||
|
public const string Search = "search";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Информация об источнике
|
||||||
|
/// </summary>
|
||||||
|
public const string GetInfo = "get_info";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Чтение сообщений для оценки
|
||||||
|
/// </summary>
|
||||||
|
public const string ReadForEval = "read_for_eval";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Вступление в источник
|
||||||
|
/// </summary>
|
||||||
|
public const string Join = "join";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Выход из источника
|
||||||
|
/// </summary>
|
||||||
|
public const string Leave = "leave";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Информация об источнике (Discovery)
|
||||||
|
/// </summary>
|
||||||
|
public const string DiscoveryInfo = "discovery_info";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Чтение сообщений источника (Discovery)
|
||||||
|
/// </summary>
|
||||||
|
public const string DiscoveryRead = "discovery_read";
|
||||||
|
}
|
||||||
@@ -3,22 +3,17 @@ using Deal.SharedKernel.Tenants.Models;
|
|||||||
|
|
||||||
namespace Deal.Infrastructure.Data;
|
namespace Deal.Infrastructure.Data;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Контекст тенанта на AsyncLocal
|
|
||||||
/// </summary>
|
|
||||||
public sealed class TenantContext : ITenantContext
|
public sealed class TenantContext : ITenantContext
|
||||||
{
|
{
|
||||||
private static readonly AsyncLocal<TenantId?> Current = new();
|
private static readonly AsyncLocal<TenantId?> Current = new();
|
||||||
|
|
||||||
public TenantId? TenantId => Current.Value;
|
TenantId? ITenantContext.TenantId => Current.Value;
|
||||||
|
|
||||||
public bool HasTenant => Current.Value is not null;
|
bool ITenantContext.HasTenant => Current.Value is not null;
|
||||||
|
|
||||||
public string? SchemaName => Current.Value?.SchemaName;
|
string? ITenantContext.SchemaName => Current.Value?.SchemaName;
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
void ITenantContext.SetTenant(TenantId tenantId) => Current.Value = tenantId;
|
void ITenantContext.SetTenant(TenantId tenantId) => Current.Value = tenantId;
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
void ITenantContext.Reset() => Current.Value = null;
|
void ITenantContext.Reset() => Current.Value = null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
using Deal.Contracts.Integrations.Abstractions;
|
||||||
|
|
||||||
|
namespace Deal.Infrastructure.Integrations.Abstractions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Фабрика ИИ-классификатора с бюджетным гейтом
|
||||||
|
/// </summary>
|
||||||
|
public interface IAiClassifierFactory
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Создаёт ИИ-классификатор
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>Готовый порт классификации.</returns>
|
||||||
|
public IAiClassifier Create();
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
using Deal.Contracts.Integrations.Abstractions;
|
||||||
|
|
||||||
|
namespace Deal.Infrastructure.Integrations.Abstractions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Фабрика ИИ-инструментов с бюджетным гейтом
|
||||||
|
/// </summary>
|
||||||
|
public interface IAiToolsFactory
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Создаёт ИИ-инструменты
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>Готовый порт ИИ-инструментов.</returns>
|
||||||
|
public IAiTools Create();
|
||||||
|
}
|
||||||
@@ -6,6 +6,9 @@ namespace Deal.Infrastructure.Integrations.Extensions;
|
|||||||
// Расширения Uri для SSRF-гейта интеграций
|
// Расширения Uri для SSRF-гейта интеграций
|
||||||
internal static class UriExtensions
|
internal static class UriExtensions
|
||||||
{
|
{
|
||||||
|
// Имя loopback-хоста.
|
||||||
|
private const string LocalhostHost = "localhost";
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Проверяет, указывает ли URL на приватный/loopback/link-local адрес
|
/// Проверяет, указывает ли URL на приватный/loopback/link-local адрес
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -14,7 +17,7 @@ internal static class UriExtensions
|
|||||||
public static bool IsPrivateEndpoint(this Uri uri)
|
public static bool IsPrivateEndpoint(this Uri uri)
|
||||||
{
|
{
|
||||||
string host = uri.Host;
|
string host = uri.Host;
|
||||||
if (string.Equals(host, "localhost", StringComparison.OrdinalIgnoreCase))
|
if (string.Equals(host, LocalhostHost, StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
|
|
||||||
|
using Deal.SharedKernel.Utilities;
|
||||||
|
|
||||||
namespace Deal.Infrastructure.Integrations.Options;
|
namespace Deal.Infrastructure.Integrations.Options;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -86,13 +88,16 @@ public sealed class MtlsOptions
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Строковое представление включённого флага числом.
|
||||||
|
private const string OneLiteral = "1";
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Разбирает значение флага DEAL_MTLS_ENABLED
|
/// Разбирает значение флага DEAL_MTLS_ENABLED
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="rawValue">Сырое значение env (null/пусто — выключено).</param>
|
/// <param name="rawValue">Сырое значение env (null/пусто — выключено).</param>
|
||||||
public static bool IsEnabled(string? rawValue)
|
public static bool IsEnabled(string? rawValue)
|
||||||
=> string.Equals(rawValue, "1", StringComparison.Ordinal)
|
=> string.Equals(rawValue, OneLiteral, StringComparison.Ordinal)
|
||||||
|| string.Equals(rawValue, "true", StringComparison.OrdinalIgnoreCase);
|
|| string.Equals(rawValue, BoolText.True, StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
// Обрезает путь конфигурации (env-значения с пробелами/кавычками не передаются в файловые API).
|
// Обрезает путь конфигурации (env-значения с пробелами/кавычками не передаются в файловые API).
|
||||||
// rawValue: Сырое значение env.
|
// rawValue: Сырое значение env.
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
using Deal.Contracts.Integrations.Abstractions;
|
||||||
|
using Deal.Infrastructure.Integrations.Abstractions;
|
||||||
|
using Deal.Modules.Tenants.Application.Abstractions;
|
||||||
|
using Deal.SharedKernel.Tenants.Abstractions;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Deal.Infrastructure.Integrations.Services;
|
||||||
|
|
||||||
|
public sealed class AiClassifierFactory : IAiClassifierFactory
|
||||||
|
{
|
||||||
|
private readonly GrpcAiClassifier _paidClassifier;
|
||||||
|
private readonly LocalAiClassifier _localClassifier;
|
||||||
|
private readonly ITenantLimitStore _tenantLimits;
|
||||||
|
private readonly ITenantContext _tenantContext;
|
||||||
|
private readonly ILogger<BudgetedAiClassifier> _logger;
|
||||||
|
|
||||||
|
public AiClassifierFactory(
|
||||||
|
GrpcAiClassifier paidClassifier,
|
||||||
|
LocalAiClassifier localClassifier,
|
||||||
|
ITenantLimitStore tenantLimits,
|
||||||
|
ITenantContext tenantContext,
|
||||||
|
ILogger<BudgetedAiClassifier> logger)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(paidClassifier);
|
||||||
|
ArgumentNullException.ThrowIfNull(localClassifier);
|
||||||
|
ArgumentNullException.ThrowIfNull(tenantLimits);
|
||||||
|
ArgumentNullException.ThrowIfNull(tenantContext);
|
||||||
|
ArgumentNullException.ThrowIfNull(logger);
|
||||||
|
_paidClassifier = paidClassifier;
|
||||||
|
_localClassifier = localClassifier;
|
||||||
|
_tenantLimits = tenantLimits;
|
||||||
|
_tenantContext = tenantContext;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
IAiClassifier IAiClassifierFactory.Create() =>
|
||||||
|
new BudgetedAiClassifier(_paidClassifier, _localClassifier, _tenantLimits, _tenantContext, _logger);
|
||||||
|
}
|
||||||
@@ -6,9 +6,6 @@ using Deal.Modules.Settings.Application.Models;
|
|||||||
|
|
||||||
namespace Deal.Infrastructure.Integrations.Services;
|
namespace Deal.Infrastructure.Integrations.Services;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// HTTP-реализация проверки подключения к AI-провайдеру.
|
|
||||||
/// </summary>
|
|
||||||
public sealed class AiConnectionChecker : IAiConnectionChecker
|
public sealed class AiConnectionChecker : IAiConnectionChecker
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -78,7 +75,6 @@ public sealed class AiConnectionChecker : IAiConnectionChecker
|
|||||||
_httpClient = httpClient;
|
_httpClient = httpClient;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
async Task<AiCheckResultDto> IAiConnectionChecker.CheckAsync(AiCheckRequest request, CancellationToken ct)
|
async Task<AiCheckResultDto> IAiConnectionChecker.CheckAsync(AiCheckRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(request);
|
ArgumentNullException.ThrowIfNull(request);
|
||||||
@@ -87,8 +83,8 @@ public sealed class AiConnectionChecker : IAiConnectionChecker
|
|||||||
string name = meta?.Name ?? request.ProviderId;
|
string name = meta?.Name ?? request.ProviderId;
|
||||||
|
|
||||||
// SSRF-гейт (allowlist, preflight): проверка возможна только для провайдера фиксированного
|
// SSRF-гейт (allowlist, preflight): проверка возможна только для провайдера фиксированного
|
||||||
// каталога AiProviders. В штатном потоке недостижимо (PATCH-гейт aiProvider/aiConfigs в
|
// каталога AiProviders. В штатном потоке недостижимо (конфигурацию задаёт оператор из
|
||||||
// SettingsService) — защита от ручного изменения БД/повреждённого хранилища.
|
// каталога через OperatorSettingsEndpoints) — защита от ручного изменения БД.
|
||||||
if (meta is null)
|
if (meta is null)
|
||||||
{
|
{
|
||||||
return BuildResult(request, name, ok: false, message: ProviderNotAllowedMessage);
|
return BuildResult(request, name, ok: false, message: ProviderNotAllowedMessage);
|
||||||
|
|||||||
@@ -1,150 +1,49 @@
|
|||||||
using System.Text.Json.Nodes;
|
|
||||||
using Deal.Grpc.Ai;
|
using Deal.Grpc.Ai;
|
||||||
using Deal.Modules.Settings.Application.Abstractions;
|
using Deal.Infrastructure.Integrations.Exceptions;
|
||||||
using Deal.Modules.Settings.Application.Models;
|
using Deal.Modules.Settings.Application.Models;
|
||||||
|
using Deal.Modules.Settings.Application.Services;
|
||||||
|
|
||||||
namespace Deal.Infrastructure.Integrations.Services;
|
namespace Deal.Infrastructure.Integrations.Services;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Собирает конфиг активного ИИ-провайдера для запросов ai-service.
|
/// Собирает конфиг ИИ-провайдера для запросов ai-service из глобальной конфигурации
|
||||||
|
/// оператора (общая для всех тенантов; провайдера, модель и ключ задаёт оператор).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class AiProviderConfigBuilder
|
/// <param name="configService">Сервис глобальной конфигурации ИИ (ключ aiConfig).</param>
|
||||||
|
public sealed class AiProviderConfigBuilder(AiGlobalConfigService configService)
|
||||||
{
|
{
|
||||||
// Ключ aiConfigs: поле apiKey переопределения провайдера.
|
// Текст AiUnavailableException, когда оператор ещё не сохранил конфигурацию ИИ.
|
||||||
private const string ApiKeyField = "apiKey";
|
private const string AiNotConfiguredMessage = "ИИ не настроен оператором системы";
|
||||||
|
|
||||||
// Ключ aiConfigs: поле baseUrl переопределения провайдера.
|
|
||||||
private const string BaseUrlField = "baseUrl";
|
|
||||||
|
|
||||||
// Ключ aiConfigs: поле model переопределения провайдера.
|
|
||||||
private const string ModelField = "model";
|
|
||||||
|
|
||||||
private const string EncryptedPrefix = "enc:";
|
|
||||||
|
|
||||||
private readonly ISettingsStore _store;
|
|
||||||
private readonly ISecretCipher _secretCipher;
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Создаёт сборщик конфига провайдера.
|
/// Собирает ProviderConfig для тела запроса ai-service
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="store">KV-хранилище настроек тенанта (aiProvider/aiConfigs).</param>
|
/// <returns>Конфиг: provider_id/base/model/api_key/api_style (см. ai.proto).</returns>
|
||||||
/// <param name="secretCipher">Расшифровка секрета aiConfigs.apiKey.</param>
|
/// <exception cref="AiUnavailableException">Конфигурация ИИ не задана оператором.</exception>
|
||||||
public AiProviderConfigBuilder(ISettingsStore store, ISecretCipher secretCipher)
|
|
||||||
{
|
|
||||||
ArgumentNullException.ThrowIfNull(store);
|
|
||||||
ArgumentNullException.ThrowIfNull(secretCipher);
|
|
||||||
_store = store;
|
|
||||||
_secretCipher = secretCipher;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Собирает ProviderConfig активного провайдера для тела запроса ai-service.
|
|
||||||
/// </summary>
|
|
||||||
/// <returns>Конфиг: provider_id/base/model/api_key (расшифрованный)/api_style (см. ai.proto).</returns>
|
|
||||||
public async Task<ProviderConfig> BuildAsync(CancellationToken ct)
|
public async Task<ProviderConfig> BuildAsync(CancellationToken ct)
|
||||||
{
|
{
|
||||||
string providerId = await ReadProviderIdAsync(ct);
|
AiGlobalConfigSnapshot snapshot = await configService.GetSnapshotAsync(ct);
|
||||||
AiProviderDefinition meta = AiProviders.All.FirstOrDefault(provider => provider.Id == providerId)
|
if (!snapshot.Configured)
|
||||||
?? AiProviders.All[0]; // неизвестный id — дефолтный провайдер (python L28)
|
|
||||||
|
|
||||||
JsonObject? overrides = await ReadAiConfigsOverrideAsync(ct);
|
|
||||||
JsonObject? raw = overrides?[meta.Id] as JsonObject;
|
|
||||||
|
|
||||||
string apiKey = ReadField(raw, ApiKeyField);
|
|
||||||
if (apiKey.StartsWith(EncryptedPrefix, StringComparison.Ordinal))
|
|
||||||
{
|
{
|
||||||
apiKey = _secretCipher.Decrypt(apiKey);
|
throw new AiUnavailableException(AiNotConfiguredMessage);
|
||||||
}
|
|
||||||
|
|
||||||
string baseUrl = ReadField(raw, BaseUrlField);
|
|
||||||
if (baseUrl.Length == 0)
|
|
||||||
{
|
|
||||||
baseUrl = meta.Base; // python L90: cfg.baseUrl or meta.base
|
|
||||||
}
|
|
||||||
|
|
||||||
string model = ReadField(raw, ModelField);
|
|
||||||
if (model.Length == 0)
|
|
||||||
{
|
|
||||||
model = meta.Models.FirstOrDefault() ?? string.Empty; // python L91: cfg.model or models[0]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var config = new ProviderConfig
|
var config = new ProviderConfig
|
||||||
{
|
{
|
||||||
ProviderId = meta.Id,
|
ProviderId = snapshot.ProviderId,
|
||||||
BaseUrl = baseUrl,
|
BaseUrl = snapshot.BaseUrl,
|
||||||
Model = model,
|
Model = snapshot.Model,
|
||||||
};
|
};
|
||||||
if (apiKey.Length > 0)
|
if (snapshot.ApiKey.Length > 0)
|
||||||
{
|
{
|
||||||
config.ApiKey = apiKey;
|
config.ApiKey = snapshot.ApiKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (meta.ApiStyle is { Length: > 0 } apiStyle)
|
if (snapshot.Meta?.ApiStyle is { Length: > 0 } apiStyle)
|
||||||
{
|
{
|
||||||
config.ApiStyle = apiStyle;
|
config.ApiStyle = apiStyle;
|
||||||
}
|
}
|
||||||
|
|
||||||
return config;
|
return config;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<string> ReadProviderIdAsync(CancellationToken ct)
|
|
||||||
{
|
|
||||||
SettingValue? row = await _store.GetAsync(SettingsKeys.AiProvider, ct);
|
|
||||||
if (row is null)
|
|
||||||
{
|
|
||||||
return SettingsDefaults.AiProvider;
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
JsonNode? value = JsonNode.Parse(row.ValueJson);
|
|
||||||
if (value is JsonValue scalar && scalar.TryGetValue<string>(out string? providerId)
|
|
||||||
&& !string.IsNullOrWhiteSpace(providerId))
|
|
||||||
{
|
|
||||||
return providerId;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (System.Text.Json.JsonException)
|
|
||||||
{
|
|
||||||
// Повреждённая строка — дефолт (мягкая семантика, как в SettingsService).
|
|
||||||
}
|
|
||||||
|
|
||||||
return SettingsDefaults.AiProvider;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Переопределение aiConfigs тенанта (JSON-объект «id провайдера → конфиг»); null — дефолты.
|
|
||||||
// ct: Токен отмены.
|
|
||||||
// Возвращает: Объект переопределения или null.
|
|
||||||
private async Task<JsonObject?> ReadAiConfigsOverrideAsync(CancellationToken ct)
|
|
||||||
{
|
|
||||||
SettingValue? row = await _store.GetAsync(SettingsKeys.AiConfigs, ct);
|
|
||||||
if (row is null)
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
return JsonNode.Parse(row.ValueJson) as JsonObject;
|
|
||||||
}
|
|
||||||
catch (System.Text.Json.JsonException)
|
|
||||||
{
|
|
||||||
// Повреждённая строка — дефолты (мягкая семантика, как в SettingsService).
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Строковое поле конфига провайдера (отсутствие/null/не-строка → пустая строка).
|
|
||||||
// config: Объект конфига провайдера (может быть null — дефолты).
|
|
||||||
// field: Имя поля (apiKey/baseUrl/model).
|
|
||||||
// Возвращает: Значение строкой или пустая строка.
|
|
||||||
private static string ReadField(JsonObject? config, string field)
|
|
||||||
{
|
|
||||||
if (config is null || !config.TryGetPropertyValue(field, out JsonNode? node) || node is not JsonValue value)
|
|
||||||
{
|
|
||||||
return string.Empty;
|
|
||||||
}
|
|
||||||
|
|
||||||
return value.TryGetValue<string>(out string? text) ? text ?? string.Empty : string.Empty;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
using Deal.Contracts.Integrations.Abstractions;
|
||||||
|
using Deal.Infrastructure.Integrations.Abstractions;
|
||||||
|
using Deal.Modules.Tenants.Application.Abstractions;
|
||||||
|
using Deal.SharedKernel.Tenants.Abstractions;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Deal.Infrastructure.Integrations.Services;
|
||||||
|
|
||||||
|
public sealed class AiToolsFactory : IAiToolsFactory
|
||||||
|
{
|
||||||
|
private readonly GrpcAiTools _paidTools;
|
||||||
|
private readonly ITenantLimitStore _tenantLimits;
|
||||||
|
private readonly ITenantContext _tenantContext;
|
||||||
|
private readonly ILogger<BudgetedAiTools> _logger;
|
||||||
|
|
||||||
|
public AiToolsFactory(
|
||||||
|
GrpcAiTools paidTools,
|
||||||
|
ITenantLimitStore tenantLimits,
|
||||||
|
ITenantContext tenantContext,
|
||||||
|
ILogger<BudgetedAiTools> logger)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(paidTools);
|
||||||
|
ArgumentNullException.ThrowIfNull(tenantLimits);
|
||||||
|
ArgumentNullException.ThrowIfNull(tenantContext);
|
||||||
|
ArgumentNullException.ThrowIfNull(logger);
|
||||||
|
_paidTools = paidTools;
|
||||||
|
_tenantLimits = tenantLimits;
|
||||||
|
_tenantContext = tenantContext;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
IAiTools IAiToolsFactory.Create() => new BudgetedAiTools(_paidTools, _tenantLimits, _tenantContext, _logger);
|
||||||
|
}
|
||||||
@@ -9,9 +9,6 @@ using Microsoft.Extensions.Logging;
|
|||||||
|
|
||||||
namespace Deal.Infrastructure.Integrations.Services;
|
namespace Deal.Infrastructure.Integrations.Services;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Декоратор бюджетного гейта порта <see cref="IAiClassifier"/>
|
|
||||||
/// </summary>
|
|
||||||
public sealed class BudgetedAiClassifier : IAiClassifier
|
public sealed class BudgetedAiClassifier : IAiClassifier
|
||||||
{
|
{
|
||||||
// Текст ошибки вызова вне tenant-контекста (гейт читает лимиты по тенанту).
|
// Текст ошибки вызова вне tenant-контекста (гейт читает лимиты по тенанту).
|
||||||
@@ -54,7 +51,6 @@ public sealed class BudgetedAiClassifier : IAiClassifier
|
|||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
async Task<AiFilterResultDto> IAiClassifier.FilterAsync(string text, CancellationToken ct)
|
async Task<AiFilterResultDto> IAiClassifier.FilterAsync(string text, CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (await IsPaidAllowedAsync(ct))
|
if (await IsPaidAllowedAsync(ct))
|
||||||
@@ -67,7 +63,6 @@ public sealed class BudgetedAiClassifier : IAiClassifier
|
|||||||
return await _localClassifier.FilterAsync(text, ct);
|
return await _localClassifier.FilterAsync(text, ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
async Task<AiParsedCardDto> IAiClassifier.ClassifyAsync(string text, CancellationToken ct)
|
async Task<AiParsedCardDto> IAiClassifier.ClassifyAsync(string text, CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (await IsPaidAllowedAsync(ct))
|
if (await IsPaidAllowedAsync(ct))
|
||||||
|
|||||||
@@ -9,9 +9,6 @@ using Microsoft.Extensions.Logging;
|
|||||||
|
|
||||||
namespace Deal.Infrastructure.Integrations.Services;
|
namespace Deal.Infrastructure.Integrations.Services;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Декоратор бюджетного гейта порта <see cref="IAiTools"/>
|
|
||||||
/// </summary>
|
|
||||||
public sealed class BudgetedAiTools : IAiTools
|
public sealed class BudgetedAiTools : IAiTools
|
||||||
{
|
{
|
||||||
private const string ExhaustedKeywordsError = "ИИ-бюджет исчерпан — генерация ключевых слов недоступна";
|
private const string ExhaustedKeywordsError = "ИИ-бюджет исчерпан — генерация ключевых слов недоступна";
|
||||||
@@ -50,7 +47,6 @@ public sealed class BudgetedAiTools : IAiTools
|
|||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
async Task<AiGenerateKeywordsResultDto> IAiTools.GenerateKeywordsAsync(string description, CancellationToken ct)
|
async Task<AiGenerateKeywordsResultDto> IAiTools.GenerateKeywordsAsync(string description, CancellationToken ct)
|
||||||
{
|
{
|
||||||
BudgetStateDto state = await GateStateAsync(ct);
|
BudgetStateDto state = await GateStateAsync(ct);
|
||||||
@@ -69,7 +65,6 @@ public sealed class BudgetedAiTools : IAiTools
|
|||||||
Error: state.Status == TenantStatuses.Suspended ? SuspendedKeywordsError : ExhaustedKeywordsError);
|
Error: state.Status == TenantStatuses.Suspended ? SuspendedKeywordsError : ExhaustedKeywordsError);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
async Task<AiEvaluateFitResultDto> IAiTools.EvaluateFitAsync(
|
async Task<AiEvaluateFitResultDto> IAiTools.EvaluateFitAsync(
|
||||||
string text,
|
string text,
|
||||||
string description,
|
string description,
|
||||||
|
|||||||
@@ -5,9 +5,6 @@ using Microsoft.Extensions.Logging;
|
|||||||
|
|
||||||
namespace Deal.Infrastructure.Integrations.Services;
|
namespace Deal.Infrastructure.Integrations.Services;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// HTTP-источник курсов ЦБ РФ
|
|
||||||
/// </summary>
|
|
||||||
public sealed class CbrRateSource : IRatesSource
|
public sealed class CbrRateSource : IRatesSource
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -47,7 +44,6 @@ public sealed class CbrRateSource : IRatesSource
|
|||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
async Task<Dictionary<string, double>?> IRatesSource.FetchAsync(CancellationToken ct)
|
async Task<Dictionary<string, double>?> IRatesSource.FetchAsync(CancellationToken ct)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
|
|||||||
@@ -11,9 +11,6 @@ using Microsoft.Extensions.Logging;
|
|||||||
|
|
||||||
namespace Deal.Infrastructure.Integrations.Services;
|
namespace Deal.Infrastructure.Integrations.Services;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// gRPC-адаптер порта <see cref="IAiClassifier"/> к автономному ai-service.
|
|
||||||
/// </summary>
|
|
||||||
public sealed class GrpcAiClassifier : IAiClassifier
|
public sealed class GrpcAiClassifier : IAiClassifier
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -70,7 +67,6 @@ public sealed class GrpcAiClassifier : IAiClassifier
|
|||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
async Task<AiFilterResultDto> IAiClassifier.FilterAsync(string text, CancellationToken ct)
|
async Task<AiFilterResultDto> IAiClassifier.FilterAsync(string text, CancellationToken ct)
|
||||||
{
|
{
|
||||||
TenantId tenantId = RequireTenant();
|
TenantId tenantId = RequireTenant();
|
||||||
@@ -106,7 +102,6 @@ public sealed class GrpcAiClassifier : IAiClassifier
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
async Task<AiParsedCardDto> IAiClassifier.ClassifyAsync(string text, CancellationToken ct)
|
async Task<AiParsedCardDto> IAiClassifier.ClassifyAsync(string text, CancellationToken ct)
|
||||||
{
|
{
|
||||||
TenantId tenantId = RequireTenant();
|
TenantId tenantId = RequireTenant();
|
||||||
|
|||||||
@@ -10,9 +10,6 @@ using Microsoft.Extensions.Logging;
|
|||||||
|
|
||||||
namespace Deal.Infrastructure.Integrations.Services;
|
namespace Deal.Infrastructure.Integrations.Services;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// gRPC-адаптер порта <see cref="IAiTools"/> к автономному ai-service.
|
|
||||||
/// </summary>
|
|
||||||
public sealed class GrpcAiTools : IAiTools
|
public sealed class GrpcAiTools : IAiTools
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -70,7 +67,6 @@ public sealed class GrpcAiTools : IAiTools
|
|||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
async Task<AiGenerateKeywordsResultDto> IAiTools.GenerateKeywordsAsync(string description, CancellationToken ct)
|
async Task<AiGenerateKeywordsResultDto> IAiTools.GenerateKeywordsAsync(string description, CancellationToken ct)
|
||||||
{
|
{
|
||||||
TenantId tenantId = RequireTenant();
|
TenantId tenantId = RequireTenant();
|
||||||
@@ -104,7 +100,6 @@ public sealed class GrpcAiTools : IAiTools
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
async Task<AiEvaluateFitResultDto> IAiTools.EvaluateFitAsync(
|
async Task<AiEvaluateFitResultDto> IAiTools.EvaluateFitAsync(
|
||||||
string text,
|
string text,
|
||||||
string description,
|
string description,
|
||||||
|
|||||||
@@ -16,9 +16,6 @@ using Microsoft.Extensions.Logging;
|
|||||||
|
|
||||||
namespace Deal.Infrastructure.Integrations.Services;
|
namespace Deal.Infrastructure.Integrations.Services;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// gRPC-адаптер порта IMlClient к автономному ml-service.
|
|
||||||
/// </summary>
|
|
||||||
public sealed class GrpcMlClient : IMlClient, IMlTrainClient
|
public sealed class GrpcMlClient : IMlClient, IMlTrainClient
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -97,7 +94,6 @@ public sealed class GrpcMlClient : IMlClient, IMlTrainClient
|
|||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
async Task<MlStatusResponseDto> IMlClient.StatusAsync(CancellationToken ct)
|
async Task<MlStatusResponseDto> IMlClient.StatusAsync(CancellationToken ct)
|
||||||
{
|
{
|
||||||
TenantId tenantId = RequireTenant();
|
TenantId tenantId = RequireTenant();
|
||||||
@@ -122,7 +118,6 @@ public sealed class GrpcMlClient : IMlClient, IMlTrainClient
|
|||||||
return new MlStatusResponseDto(Enabled: enabled, Service: snapshot.Service, Reachable: snapshot.Reachable, Stats: stats);
|
return new MlStatusResponseDto(Enabled: enabled, Service: snapshot.Service, Reachable: snapshot.Reachable, Stats: stats);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
async Task<MlPredictResultDto> IMlClient.PredictAsync(string text, CancellationToken ct)
|
async Task<MlPredictResultDto> IMlClient.PredictAsync(string text, CancellationToken ct)
|
||||||
{
|
{
|
||||||
TenantId tenantId = RequireTenant();
|
TenantId tenantId = RequireTenant();
|
||||||
@@ -143,7 +138,6 @@ public sealed class GrpcMlClient : IMlClient, IMlTrainClient
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
async Task<MlResetResultDto> IMlClient.ResetAsync(CancellationToken ct)
|
async Task<MlResetResultDto> IMlClient.ResetAsync(CancellationToken ct)
|
||||||
{
|
{
|
||||||
TenantId tenantId = RequireTenant();
|
TenantId tenantId = RequireTenant();
|
||||||
@@ -171,7 +165,6 @@ public sealed class GrpcMlClient : IMlClient, IMlTrainClient
|
|||||||
return new MlResetResultDto(Ok: true, Error: null);
|
return new MlResetResultDto(Ok: true, Error: null);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
async Task IMlClient.PushAsync(
|
async Task IMlClient.PushAsync(
|
||||||
string text,
|
string text,
|
||||||
string label,
|
string label,
|
||||||
@@ -181,7 +174,6 @@ public sealed class GrpcMlClient : IMlClient, IMlTrainClient
|
|||||||
await MlOutboxQueue.PushAsync(_learningStore, text, label, delta, ct);
|
await MlOutboxQueue.PushAsync(_learningStore, text, label, delta, ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
async Task<int> IMlTrainClient.TrainBatchAsync(IReadOnlyList<MlOutboxEntryDto> items, CancellationToken ct)
|
async Task<int> IMlTrainClient.TrainBatchAsync(IReadOnlyList<MlOutboxEntryDto> items, CancellationToken ct)
|
||||||
{
|
{
|
||||||
TenantId tenantId = RequireTenant();
|
TenantId tenantId = RequireTenant();
|
||||||
|
|||||||
@@ -9,9 +9,6 @@ using Microsoft.Extensions.Logging;
|
|||||||
|
|
||||||
namespace Deal.Infrastructure.Integrations.Services;
|
namespace Deal.Infrastructure.Integrations.Services;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// gRPC-адаптер порта <see cref="ITelegramGateway"/> к автономному telegram-service.
|
|
||||||
/// </summary>
|
|
||||||
public sealed class GrpcTelegramClient : ITelegramGateway
|
public sealed class GrpcTelegramClient : ITelegramGateway
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -58,7 +55,6 @@ public sealed class GrpcTelegramClient : ITelegramGateway
|
|||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
async Task<TelegramAccountStatusDto> ITelegramGateway.StatusAsync(CancellationToken ct)
|
async Task<TelegramAccountStatusDto> ITelegramGateway.StatusAsync(CancellationToken ct)
|
||||||
{
|
{
|
||||||
TenantId tenantId = RequireTenant();
|
TenantId tenantId = RequireTenant();
|
||||||
@@ -77,11 +73,10 @@ public sealed class GrpcTelegramClient : ITelegramGateway
|
|||||||
}
|
}
|
||||||
catch (Exception exception)
|
catch (Exception exception)
|
||||||
{
|
{
|
||||||
throw TranslateTransportFailure(exception, tenantId, "status");
|
throw TranslateTransportFailure(exception, tenantId, TelegramOperations.Status);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
async Task<TelegramAuthResultDto> ITelegramGateway.StartPhoneAsync(
|
async Task<TelegramAuthResultDto> ITelegramGateway.StartPhoneAsync(
|
||||||
string phone,
|
string phone,
|
||||||
int apiId,
|
int apiId,
|
||||||
@@ -100,11 +95,10 @@ public sealed class GrpcTelegramClient : ITelegramGateway
|
|||||||
}
|
}
|
||||||
catch (Exception exception)
|
catch (Exception exception)
|
||||||
{
|
{
|
||||||
throw TranslateTransportFailure(exception, tenantId, "start_phone");
|
throw TranslateTransportFailure(exception, tenantId, TelegramOperations.StartPhone);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
async Task<TelegramAuthResultDto> ITelegramGateway.StartQrAsync(
|
async Task<TelegramAuthResultDto> ITelegramGateway.StartQrAsync(
|
||||||
int apiId,
|
int apiId,
|
||||||
string apiHash,
|
string apiHash,
|
||||||
@@ -124,11 +118,10 @@ public sealed class GrpcTelegramClient : ITelegramGateway
|
|||||||
}
|
}
|
||||||
catch (Exception exception)
|
catch (Exception exception)
|
||||||
{
|
{
|
||||||
throw TranslateTransportFailure(exception, tenantId, "start_qr");
|
throw TranslateTransportFailure(exception, tenantId, TelegramOperations.StartQr);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
async Task<string> ITelegramGateway.SendCodeAsync(string code, CancellationToken ct)
|
async Task<string> ITelegramGateway.SendCodeAsync(string code, CancellationToken ct)
|
||||||
{
|
{
|
||||||
TenantId tenantId = RequireTenant();
|
TenantId tenantId = RequireTenant();
|
||||||
@@ -142,11 +135,10 @@ public sealed class GrpcTelegramClient : ITelegramGateway
|
|||||||
}
|
}
|
||||||
catch (Exception exception)
|
catch (Exception exception)
|
||||||
{
|
{
|
||||||
throw TranslateTransportFailure(exception, tenantId, "send_code");
|
throw TranslateTransportFailure(exception, tenantId, TelegramOperations.SendCode);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
async Task<string> ITelegramGateway.SendPasswordAsync(string password, CancellationToken ct)
|
async Task<string> ITelegramGateway.SendPasswordAsync(string password, CancellationToken ct)
|
||||||
{
|
{
|
||||||
TenantId tenantId = RequireTenant();
|
TenantId tenantId = RequireTenant();
|
||||||
@@ -160,11 +152,10 @@ public sealed class GrpcTelegramClient : ITelegramGateway
|
|||||||
}
|
}
|
||||||
catch (Exception exception)
|
catch (Exception exception)
|
||||||
{
|
{
|
||||||
throw TranslateTransportFailure(exception, tenantId, "send_password");
|
throw TranslateTransportFailure(exception, tenantId, TelegramOperations.SendPassword);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
async Task ITelegramGateway.LogoutAsync(CancellationToken ct)
|
async Task ITelegramGateway.LogoutAsync(CancellationToken ct)
|
||||||
{
|
{
|
||||||
TenantId tenantId = RequireTenant();
|
TenantId tenantId = RequireTenant();
|
||||||
@@ -176,11 +167,10 @@ public sealed class GrpcTelegramClient : ITelegramGateway
|
|||||||
}
|
}
|
||||||
catch (Exception exception)
|
catch (Exception exception)
|
||||||
{
|
{
|
||||||
throw TranslateTransportFailure(exception, tenantId, "logout");
|
throw TranslateTransportFailure(exception, tenantId, TelegramOperations.Logout);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
async Task<IReadOnlyList<TelegramDialogEntryDto>> ITelegramGateway.RefreshDialogsAsync(CancellationToken ct)
|
async Task<IReadOnlyList<TelegramDialogEntryDto>> ITelegramGateway.RefreshDialogsAsync(CancellationToken ct)
|
||||||
{
|
{
|
||||||
TenantId tenantId = RequireTenant();
|
TenantId tenantId = RequireTenant();
|
||||||
@@ -193,11 +183,10 @@ public sealed class GrpcTelegramClient : ITelegramGateway
|
|||||||
}
|
}
|
||||||
catch (Exception exception)
|
catch (Exception exception)
|
||||||
{
|
{
|
||||||
throw TranslateTransportFailure(exception, tenantId, "refresh_dialogs");
|
throw TranslateTransportFailure(exception, tenantId, TelegramOperations.RefreshDialogs);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
async Task ITelegramGateway.SetMonitorAsync(
|
async Task ITelegramGateway.SetMonitorAsync(
|
||||||
string dialogId,
|
string dialogId,
|
||||||
bool enabled,
|
bool enabled,
|
||||||
@@ -213,11 +202,10 @@ public sealed class GrpcTelegramClient : ITelegramGateway
|
|||||||
}
|
}
|
||||||
catch (Exception exception)
|
catch (Exception exception)
|
||||||
{
|
{
|
||||||
throw TranslateTransportFailure(exception, tenantId, "set_monitor");
|
throw TranslateTransportFailure(exception, tenantId, TelegramOperations.SetMonitor);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
async Task ITelegramGateway.SetMonitorAllAsync(bool enabled, CancellationToken ct)
|
async Task ITelegramGateway.SetMonitorAllAsync(bool enabled, CancellationToken ct)
|
||||||
{
|
{
|
||||||
TenantId tenantId = RequireTenant();
|
TenantId tenantId = RequireTenant();
|
||||||
@@ -230,11 +218,10 @@ public sealed class GrpcTelegramClient : ITelegramGateway
|
|||||||
}
|
}
|
||||||
catch (Exception exception)
|
catch (Exception exception)
|
||||||
{
|
{
|
||||||
throw TranslateTransportFailure(exception, tenantId, "set_monitor_all");
|
throw TranslateTransportFailure(exception, tenantId, TelegramOperations.SetMonitorAll);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
async Task<int> ITelegramGateway.BackfillAsync(
|
async Task<int> ITelegramGateway.BackfillAsync(
|
||||||
string dialogId,
|
string dialogId,
|
||||||
bool force,
|
bool force,
|
||||||
@@ -251,11 +238,10 @@ public sealed class GrpcTelegramClient : ITelegramGateway
|
|||||||
}
|
}
|
||||||
catch (Exception exception)
|
catch (Exception exception)
|
||||||
{
|
{
|
||||||
throw TranslateTransportFailure(exception, tenantId, "backfill");
|
throw TranslateTransportFailure(exception, tenantId, TelegramOperations.Backfill);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
async Task<IReadOnlyList<TelegramRecentMessageDto>> ITelegramGateway.ReadRecentAsync(
|
async Task<IReadOnlyList<TelegramRecentMessageDto>> ITelegramGateway.ReadRecentAsync(
|
||||||
string dialogId,
|
string dialogId,
|
||||||
int limit,
|
int limit,
|
||||||
@@ -274,11 +260,10 @@ public sealed class GrpcTelegramClient : ITelegramGateway
|
|||||||
}
|
}
|
||||||
catch (Exception exception)
|
catch (Exception exception)
|
||||||
{
|
{
|
||||||
throw TranslateTransportFailure(exception, tenantId, "read_recent");
|
throw TranslateTransportFailure(exception, tenantId, TelegramOperations.ReadRecent);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
async Task<TelegramSourceContentDto> ITelegramGateway.ReadSourceAsync(
|
async Task<TelegramSourceContentDto> ITelegramGateway.ReadSourceAsync(
|
||||||
string dialogId,
|
string dialogId,
|
||||||
long msgId,
|
long msgId,
|
||||||
@@ -298,11 +283,10 @@ public sealed class GrpcTelegramClient : ITelegramGateway
|
|||||||
}
|
}
|
||||||
catch (Exception exception)
|
catch (Exception exception)
|
||||||
{
|
{
|
||||||
throw TranslateTransportFailure(exception, tenantId, "read_source");
|
throw TranslateTransportFailure(exception, tenantId, TelegramOperations.ReadSource);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
async Task<IReadOnlyList<TelegramDialogEntryDto>> ITelegramGateway.SearchAsync(
|
async Task<IReadOnlyList<TelegramDialogEntryDto>> ITelegramGateway.SearchAsync(
|
||||||
string query,
|
string query,
|
||||||
int limit,
|
int limit,
|
||||||
@@ -319,11 +303,10 @@ public sealed class GrpcTelegramClient : ITelegramGateway
|
|||||||
}
|
}
|
||||||
catch (Exception exception)
|
catch (Exception exception)
|
||||||
{
|
{
|
||||||
throw TranslateTransportFailure(exception, tenantId, "search");
|
throw TranslateTransportFailure(exception, tenantId, TelegramOperations.Search);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
async Task<TelegramChannelInfoDto> ITelegramGateway.InfoAsync(string dialogId, CancellationToken ct)
|
async Task<TelegramChannelInfoDto> ITelegramGateway.InfoAsync(string dialogId, CancellationToken ct)
|
||||||
{
|
{
|
||||||
TenantId tenantId = RequireTenant();
|
TenantId tenantId = RequireTenant();
|
||||||
@@ -345,11 +328,10 @@ public sealed class GrpcTelegramClient : ITelegramGateway
|
|||||||
}
|
}
|
||||||
catch (Exception exception)
|
catch (Exception exception)
|
||||||
{
|
{
|
||||||
throw TranslateTransportFailure(exception, tenantId, "get_info");
|
throw TranslateTransportFailure(exception, tenantId, TelegramOperations.GetInfo);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
async Task<TelegramEvalReadDto> ITelegramGateway.ReadForEvalAsync(
|
async Task<TelegramEvalReadDto> ITelegramGateway.ReadForEvalAsync(
|
||||||
string dialogId,
|
string dialogId,
|
||||||
int limit,
|
int limit,
|
||||||
@@ -376,11 +358,10 @@ public sealed class GrpcTelegramClient : ITelegramGateway
|
|||||||
}
|
}
|
||||||
catch (Exception exception)
|
catch (Exception exception)
|
||||||
{
|
{
|
||||||
throw TranslateTransportFailure(exception, tenantId, "read_for_eval");
|
throw TranslateTransportFailure(exception, tenantId, TelegramOperations.ReadForEval);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
async Task ITelegramGateway.JoinAsync(string username, CancellationToken ct)
|
async Task ITelegramGateway.JoinAsync(string username, CancellationToken ct)
|
||||||
{
|
{
|
||||||
TenantId tenantId = RequireTenant();
|
TenantId tenantId = RequireTenant();
|
||||||
@@ -393,11 +374,10 @@ public sealed class GrpcTelegramClient : ITelegramGateway
|
|||||||
}
|
}
|
||||||
catch (Exception exception)
|
catch (Exception exception)
|
||||||
{
|
{
|
||||||
throw TranslateTransportFailure(exception, tenantId, "join");
|
throw TranslateTransportFailure(exception, tenantId, TelegramOperations.Join);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
async Task ITelegramGateway.LeaveAsync(string dialogId, CancellationToken ct)
|
async Task ITelegramGateway.LeaveAsync(string dialogId, CancellationToken ct)
|
||||||
{
|
{
|
||||||
TenantId tenantId = RequireTenant();
|
TenantId tenantId = RequireTenant();
|
||||||
@@ -410,7 +390,7 @@ public sealed class GrpcTelegramClient : ITelegramGateway
|
|||||||
}
|
}
|
||||||
catch (Exception exception)
|
catch (Exception exception)
|
||||||
{
|
{
|
||||||
throw TranslateTransportFailure(exception, tenantId, "leave");
|
throw TranslateTransportFailure(exception, tenantId, TelegramOperations.Leave);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,20 +3,15 @@ using Deal.Contracts.Integrations.Models;
|
|||||||
|
|
||||||
namespace Deal.Infrastructure.Integrations.Services;
|
namespace Deal.Infrastructure.Integrations.Services;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Локальная реализация <see cref="IAiTools"/> без внешнего ИИ-сервиса.
|
|
||||||
/// </summary>
|
|
||||||
public sealed class LocalAiTools : IAiTools
|
public sealed class LocalAiTools : IAiTools
|
||||||
{
|
{
|
||||||
// Сообщение исключения методов (локальный режим = ai-service не подключён).
|
// Сообщение исключения методов (локальный режим = ai-service не подключён).
|
||||||
private const string NotSupportedMessage =
|
private const string NotSupportedMessage =
|
||||||
"ИИ-инструменты доступны только при подключённом ai-service (Services:Ai:UseLocal=false).";
|
"ИИ-инструменты доступны только при подключённом ai-service (Services:Ai:UseLocal=false).";
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
Task<AiGenerateKeywordsResultDto> IAiTools.GenerateKeywordsAsync(string description, CancellationToken ct)
|
Task<AiGenerateKeywordsResultDto> IAiTools.GenerateKeywordsAsync(string description, CancellationToken ct)
|
||||||
=> throw new NotSupportedException(NotSupportedMessage);
|
=> throw new NotSupportedException(NotSupportedMessage);
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
Task<AiEvaluateFitResultDto> IAiTools.EvaluateFitAsync(
|
Task<AiEvaluateFitResultDto> IAiTools.EvaluateFitAsync(
|
||||||
string text,
|
string text,
|
||||||
string description,
|
string description,
|
||||||
|
|||||||
@@ -39,8 +39,6 @@ ContainersService containersService) : IColumnSuggester
|
|||||||
|
|
||||||
private const string KeywordsEmptyReason = "ИИ не смог выделить ключи — попробуйте ещё раз";
|
private const string KeywordsEmptyReason = "ИИ не смог выделить ключи — попробуйте ещё раз";
|
||||||
|
|
||||||
private const string RulesModeAny = "any";
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<SuggestColumnsResultDto> SuggestColumnsAsync(CancellationToken ct)
|
public async Task<SuggestColumnsResultDto> SuggestColumnsAsync(CancellationToken ct)
|
||||||
{
|
{
|
||||||
@@ -121,7 +119,7 @@ ContainersService containersService) : IColumnSuggester
|
|||||||
foreach (SuggestedColumnPlan plan in plans)
|
foreach (SuggestedColumnPlan plan in plans)
|
||||||
{
|
{
|
||||||
var rules = new ContainerRulesDto(
|
var rules = new ContainerRulesDto(
|
||||||
Mode: RulesModeAny,
|
Mode: ColumnRuleModes.Any,
|
||||||
Direction: Array.Empty<string>(),
|
Direction: Array.Empty<string>(),
|
||||||
Keywords: [plan.Word],
|
Keywords: [plan.Word],
|
||||||
Stack: Array.Empty<string>(),
|
Stack: Array.Empty<string>(),
|
||||||
|
|||||||
@@ -3,21 +3,18 @@ using Deal.Contracts.Integrations.Models;
|
|||||||
|
|
||||||
namespace Deal.Infrastructure.Integrations.Services;
|
namespace Deal.Infrastructure.Integrations.Services;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Локальная заглушка <see cref="ITelegramGateway"/> без telegram-service.
|
|
||||||
/// </summary>
|
|
||||||
public sealed class LocalTelegramGateway : ITelegramGateway
|
public sealed class LocalTelegramGateway : ITelegramGateway
|
||||||
{
|
{
|
||||||
|
// Причина пустого чтения: сообщений в источнике нет.
|
||||||
|
private const string NoHistoryReason = "no_history";
|
||||||
// Фаза idle-формы (аккаунт не подключён — сервиса нет).
|
// Фаза idle-формы (аккаунт не подключён — сервиса нет).
|
||||||
private const string IdlePhase = "idle";
|
private const string IdlePhase = "idle";
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
Task<TelegramAccountStatusDto> ITelegramGateway.StatusAsync(CancellationToken ct)
|
Task<TelegramAccountStatusDto> ITelegramGateway.StatusAsync(CancellationToken ct)
|
||||||
{
|
{
|
||||||
return Task.FromResult(new TelegramAccountStatusDto(IdlePhase, false, false, string.Empty, null, null));
|
return Task.FromResult(new TelegramAccountStatusDto(IdlePhase, false, false, string.Empty, null, null));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
Task<TelegramAuthResultDto> ITelegramGateway.StartPhoneAsync(
|
Task<TelegramAuthResultDto> ITelegramGateway.StartPhoneAsync(
|
||||||
string phone,
|
string phone,
|
||||||
int apiId,
|
int apiId,
|
||||||
@@ -25,76 +22,61 @@ public sealed class LocalTelegramGateway : ITelegramGateway
|
|||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
=> Task.FromResult(new TelegramAuthResultDto(IdlePhase, null));
|
=> Task.FromResult(new TelegramAuthResultDto(IdlePhase, null));
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
Task<TelegramAuthResultDto> ITelegramGateway.StartQrAsync(
|
Task<TelegramAuthResultDto> ITelegramGateway.StartQrAsync(
|
||||||
int apiId,
|
int apiId,
|
||||||
string apiHash,
|
string apiHash,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
=> Task.FromResult(new TelegramAuthResultDto(IdlePhase, null));
|
=> Task.FromResult(new TelegramAuthResultDto(IdlePhase, null));
|
||||||
|
|
||||||
/// <inheritdoc />
|
Task<string> ITelegramGateway.SendCodeAsync(string code, CancellationToken ct) => Task.FromResult(IdlePhase);
|
||||||
public Task<string> SendCodeAsync(string code, CancellationToken ct) => Task.FromResult(IdlePhase);
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
Task<string> ITelegramGateway.SendPasswordAsync(string password, CancellationToken ct) => Task.FromResult(IdlePhase);
|
||||||
public Task<string> SendPasswordAsync(string password, CancellationToken ct) => Task.FromResult(IdlePhase);
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
Task ITelegramGateway.LogoutAsync(CancellationToken ct) => Task.CompletedTask;
|
Task ITelegramGateway.LogoutAsync(CancellationToken ct) => Task.CompletedTask;
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
Task<IReadOnlyList<TelegramDialogEntryDto>> ITelegramGateway.RefreshDialogsAsync(CancellationToken ct)
|
Task<IReadOnlyList<TelegramDialogEntryDto>> ITelegramGateway.RefreshDialogsAsync(CancellationToken ct)
|
||||||
=> Task.FromResult<IReadOnlyList<TelegramDialogEntryDto>>([]);
|
=> Task.FromResult<IReadOnlyList<TelegramDialogEntryDto>>([]);
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
Task ITelegramGateway.SetMonitorAsync(
|
Task ITelegramGateway.SetMonitorAsync(
|
||||||
string dialogId,
|
string dialogId,
|
||||||
bool enabled,
|
bool enabled,
|
||||||
CancellationToken ct) => Task.CompletedTask;
|
CancellationToken ct) => Task.CompletedTask;
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
Task ITelegramGateway.SetMonitorAllAsync(bool enabled, CancellationToken ct) => Task.CompletedTask;
|
Task ITelegramGateway.SetMonitorAllAsync(bool enabled, CancellationToken ct) => Task.CompletedTask;
|
||||||
|
|
||||||
/// <inheritdoc />
|
Task<int> ITelegramGateway.BackfillAsync(
|
||||||
public Task<int> BackfillAsync(
|
|
||||||
string dialogId,
|
string dialogId,
|
||||||
bool force,
|
bool force,
|
||||||
CancellationToken ct) => Task.FromResult(0);
|
CancellationToken ct) => Task.FromResult(0);
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
Task<IReadOnlyList<TelegramRecentMessageDto>> ITelegramGateway.ReadRecentAsync(
|
Task<IReadOnlyList<TelegramRecentMessageDto>> ITelegramGateway.ReadRecentAsync(
|
||||||
string dialogId,
|
string dialogId,
|
||||||
int limit,
|
int limit,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
=> Task.FromResult<IReadOnlyList<TelegramRecentMessageDto>>([]);
|
=> Task.FromResult<IReadOnlyList<TelegramRecentMessageDto>>([]);
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
Task<TelegramSourceContentDto> ITelegramGateway.ReadSourceAsync(
|
Task<TelegramSourceContentDto> ITelegramGateway.ReadSourceAsync(
|
||||||
string dialogId,
|
string dialogId,
|
||||||
long msgId,
|
long msgId,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
=> Task.FromResult(new TelegramSourceContentDto(false, null, null));
|
=> Task.FromResult(new TelegramSourceContentDto(false, null, null));
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
Task<IReadOnlyList<TelegramDialogEntryDto>> ITelegramGateway.SearchAsync(
|
Task<IReadOnlyList<TelegramDialogEntryDto>> ITelegramGateway.SearchAsync(
|
||||||
string query,
|
string query,
|
||||||
int limit,
|
int limit,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
=> Task.FromResult<IReadOnlyList<TelegramDialogEntryDto>>([]);
|
=> Task.FromResult<IReadOnlyList<TelegramDialogEntryDto>>([]);
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
Task<TelegramChannelInfoDto> ITelegramGateway.InfoAsync(string dialogId, CancellationToken ct)
|
Task<TelegramChannelInfoDto> ITelegramGateway.InfoAsync(string dialogId, CancellationToken ct)
|
||||||
=> Task.FromResult(new TelegramChannelInfoDto(dialogId, string.Empty, string.Empty, string.Empty, SourceDefaults.DefaultHue, null, false));
|
=> Task.FromResult(new TelegramChannelInfoDto(dialogId, string.Empty, string.Empty, string.Empty, SourceDefaults.DefaultHue, null, false));
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
Task<TelegramEvalReadDto> ITelegramGateway.ReadForEvalAsync(
|
Task<TelegramEvalReadDto> ITelegramGateway.ReadForEvalAsync(
|
||||||
string dialogId,
|
string dialogId,
|
||||||
int limit,
|
int limit,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
=> Task.FromResult(new TelegramEvalReadDto(false, "no_history", []));
|
=> Task.FromResult(new TelegramEvalReadDto(false, NoHistoryReason, []));
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
Task ITelegramGateway.JoinAsync(string username, CancellationToken ct) => Task.CompletedTask;
|
Task ITelegramGateway.JoinAsync(string username, CancellationToken ct) => Task.CompletedTask;
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
Task ITelegramGateway.LeaveAsync(string dialogId, CancellationToken ct) => Task.CompletedTask;
|
Task ITelegramGateway.LeaveAsync(string dialogId, CancellationToken ct) => Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
using Deal.Contracts.Integrations.Abstractions;
|
||||||
|
|
||||||
|
namespace Deal.Infrastructure.Integrations.Storage.Abstractions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Фабрика файлового хранилища вложений (Local или MinIO по конфигурации)
|
||||||
|
/// </summary>
|
||||||
|
public interface IFileStorageFactory
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Создаёт файловое хранилище
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>Готовый порт файлового хранилища.</returns>
|
||||||
|
public IFileStorage Create();
|
||||||
|
}
|
||||||
@@ -1,8 +1,13 @@
|
|||||||
|
using Deal.SharedKernel.Utilities;
|
||||||
|
|
||||||
namespace Deal.Infrastructure.Integrations.Storage.Extensions;
|
namespace Deal.Infrastructure.Integrations.Storage.Extensions;
|
||||||
|
|
||||||
// Расширения string для разбора конфигурационных значений.
|
// Расширения string для разбора конфигурационных значений.
|
||||||
internal static class StringExtensions
|
internal static class StringExtensions
|
||||||
{
|
{
|
||||||
|
// Строковое представление включённого флага числом.
|
||||||
|
private const string OneLiteral = "1";
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Разбирает строковое значение как булев флаг конфигурации
|
/// Разбирает строковое значение как булев флаг конфигурации
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -10,6 +15,6 @@ internal static class StringExtensions
|
|||||||
/// <returns>True — значение распознано как включённое.</returns>
|
/// <returns>True — значение распознано как включённое.</returns>
|
||||||
public static bool IsTrue(this string raw)
|
public static bool IsTrue(this string raw)
|
||||||
{
|
{
|
||||||
return string.Equals(raw, "true", StringComparison.OrdinalIgnoreCase) || raw == "1";
|
return string.Equals(raw, BoolText.True, StringComparison.OrdinalIgnoreCase) || raw == OneLiteral;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
namespace Deal.Infrastructure.Integrations.Storage.Options;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Абсолютный путь корня локального файлового хранилища
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="Path">Абсолютный путь каталога вложений.</param>
|
||||||
|
public sealed record LocalStorageRoot(string Path);
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
using Deal.Contracts.Integrations.Abstractions;
|
||||||
|
using Deal.Infrastructure.Integrations.Storage.Abstractions;
|
||||||
|
using Deal.Infrastructure.Integrations.Storage.Extensions;
|
||||||
|
using Deal.Infrastructure.Integrations.Storage.Options;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Deal.Infrastructure.Integrations.Storage.Services;
|
||||||
|
|
||||||
|
public sealed class FileStorageFactory : IFileStorageFactory
|
||||||
|
{
|
||||||
|
private readonly StorageOptions _options;
|
||||||
|
private readonly LocalStorageRoot _localRoot;
|
||||||
|
private readonly ILogger<MinioFileStorage> _minioLogger;
|
||||||
|
|
||||||
|
public FileStorageFactory(
|
||||||
|
StorageOptions options,
|
||||||
|
LocalStorageRoot localRoot,
|
||||||
|
ILogger<MinioFileStorage> minioLogger)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(options);
|
||||||
|
ArgumentNullException.ThrowIfNull(localRoot);
|
||||||
|
ArgumentNullException.ThrowIfNull(minioLogger);
|
||||||
|
_options = options;
|
||||||
|
_localRoot = localRoot;
|
||||||
|
_minioLogger = minioLogger;
|
||||||
|
}
|
||||||
|
|
||||||
|
IFileStorage IFileStorageFactory.Create() =>
|
||||||
|
_options.Minio.IsConfigured()
|
||||||
|
? new MinioFileStorage(_options.Minio, _minioLogger)
|
||||||
|
: new LocalFileStorage(_localRoot.Path);
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
using Deal.Contracts.Integrations.Abstractions;
|
using Deal.Contracts.Integrations.Abstractions;
|
||||||
|
using Deal.Infrastructure.Integrations.Storage.Abstractions;
|
||||||
using Deal.Infrastructure.Integrations.Storage.Extensions;
|
using Deal.Infrastructure.Integrations.Storage.Extensions;
|
||||||
using Deal.Infrastructure.Integrations.Storage.Options;
|
using Deal.Infrastructure.Integrations.Storage.Options;
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
@@ -48,16 +49,12 @@ public static class FileStorageRegistrar
|
|||||||
ArgumentNullException.ThrowIfNull(configuration);
|
ArgumentNullException.ThrowIfNull(configuration);
|
||||||
|
|
||||||
StorageOptions options = ReadOptions(configuration);
|
StorageOptions options = ReadOptions(configuration);
|
||||||
|
|
||||||
if (options.Minio.IsConfigured())
|
|
||||||
{
|
|
||||||
services.AddSingleton<IFileStorage>(serviceProvider =>
|
|
||||||
new MinioFileStorage(options.Minio, serviceProvider.GetRequiredService<ILogger<MinioFileStorage>>()));
|
|
||||||
return services;
|
|
||||||
}
|
|
||||||
|
|
||||||
string rootPath = ResolveLocalRoot(options.Local, contentRootPath);
|
string rootPath = ResolveLocalRoot(options.Local, contentRootPath);
|
||||||
services.AddSingleton<IFileStorage>(new LocalFileStorage(rootPath));
|
services.AddSingleton(options);
|
||||||
|
services.AddSingleton(new LocalStorageRoot(rootPath));
|
||||||
|
services.AddSingleton<IFileStorageFactory, FileStorageFactory>();
|
||||||
|
services.AddSingleton<IFileStorage>(serviceProvider =>
|
||||||
|
serviceProvider.GetRequiredService<IFileStorageFactory>().Create());
|
||||||
return services;
|
return services;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,9 +3,6 @@ using Deal.Contracts.Integrations.Models;
|
|||||||
|
|
||||||
namespace Deal.Infrastructure.Integrations.Storage.Services;
|
namespace Deal.Infrastructure.Integrations.Storage.Services;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Локальное файловое хранилище вложений — каталог на диске.
|
|
||||||
/// </summary>
|
|
||||||
public sealed class LocalFileStorage : IFileStorage
|
public sealed class LocalFileStorage : IFileStorage
|
||||||
{
|
{
|
||||||
// Размер буфера чтения при скачивании (async FileStream).
|
// Размер буфера чтения при скачивании (async FileStream).
|
||||||
@@ -31,7 +28,6 @@ public sealed class LocalFileStorage : IFileStorage
|
|||||||
/// <returns>Строка вида <c>LocalFileStorage (root: …)</c>.</returns>
|
/// <returns>Строка вида <c>LocalFileStorage (root: …)</c>.</returns>
|
||||||
public override string ToString() => $"LocalFileStorage (root: {_rootPath})";
|
public override string ToString() => $"LocalFileStorage (root: {_rootPath})";
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
async Task<string> IFileStorage.PutAsync(
|
async Task<string> IFileStorage.PutAsync(
|
||||||
string objectKey,
|
string objectKey,
|
||||||
Stream content,
|
Stream content,
|
||||||
@@ -55,7 +51,6 @@ public sealed class LocalFileStorage : IFileStorage
|
|||||||
return objectKey;
|
return objectKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
Task<Stream?> IFileStorage.GetAsync(string objectKey, CancellationToken ct)
|
Task<Stream?> IFileStorage.GetAsync(string objectKey, CancellationToken ct)
|
||||||
{
|
{
|
||||||
string path = ResolvePath(objectKey);
|
string path = ResolvePath(objectKey);
|
||||||
@@ -68,7 +63,6 @@ public sealed class LocalFileStorage : IFileStorage
|
|||||||
return Task.FromResult<Stream?>(stream);
|
return Task.FromResult<Stream?>(stream);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
Task<FileMeta?> IFileStorage.StatAsync(string objectKey, CancellationToken ct)
|
Task<FileMeta?> IFileStorage.StatAsync(string objectKey, CancellationToken ct)
|
||||||
{
|
{
|
||||||
string path = ResolvePath(objectKey);
|
string path = ResolvePath(objectKey);
|
||||||
@@ -81,7 +75,6 @@ public sealed class LocalFileStorage : IFileStorage
|
|||||||
return Task.FromResult<FileMeta?>(new FileMeta(objectKey, info.Length, string.Empty));
|
return Task.FromResult<FileMeta?>(new FileMeta(objectKey, info.Length, string.Empty));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
Task IFileStorage.DeleteAsync(string objectKey, CancellationToken ct)
|
Task IFileStorage.DeleteAsync(string objectKey, CancellationToken ct)
|
||||||
{
|
{
|
||||||
string path = ResolvePath(objectKey);
|
string path = ResolvePath(objectKey);
|
||||||
|
|||||||
@@ -9,9 +9,6 @@ using Minio.Exceptions;
|
|||||||
|
|
||||||
namespace Deal.Infrastructure.Integrations.Storage.Services;
|
namespace Deal.Infrastructure.Integrations.Storage.Services;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Хранилище вложений на MinIO
|
|
||||||
/// </summary>
|
|
||||||
public sealed class MinioFileStorage : IFileStorage
|
public sealed class MinioFileStorage : IFileStorage
|
||||||
{
|
{
|
||||||
private const string DefaultContentType = "application/octet-stream";
|
private const string DefaultContentType = "application/octet-stream";
|
||||||
@@ -66,7 +63,6 @@ public sealed class MinioFileStorage : IFileStorage
|
|||||||
/// <returns>Строка вида <c>MinioFileStorage (endpoint: …; bucket: …)</c>.</returns>
|
/// <returns>Строка вида <c>MinioFileStorage (endpoint: …; bucket: …)</c>.</returns>
|
||||||
public override string ToString() => $"MinioFileStorage (endpoint: {_endpoint}; bucket: {_bucket})";
|
public override string ToString() => $"MinioFileStorage (endpoint: {_endpoint}; bucket: {_bucket})";
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
async Task<string> IFileStorage.PutAsync(
|
async Task<string> IFileStorage.PutAsync(
|
||||||
string objectKey,
|
string objectKey,
|
||||||
Stream content,
|
Stream content,
|
||||||
@@ -98,7 +94,6 @@ public sealed class MinioFileStorage : IFileStorage
|
|||||||
return objectKey;
|
return objectKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
async Task<Stream?> IFileStorage.GetAsync(string objectKey, CancellationToken ct)
|
async Task<Stream?> IFileStorage.GetAsync(string objectKey, CancellationToken ct)
|
||||||
{
|
{
|
||||||
MemoryStream buffer = new();
|
MemoryStream buffer = new();
|
||||||
@@ -128,7 +123,6 @@ public sealed class MinioFileStorage : IFileStorage
|
|||||||
return buffer;
|
return buffer;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
async Task<FileMeta?> IFileStorage.StatAsync(string objectKey, CancellationToken ct)
|
async Task<FileMeta?> IFileStorage.StatAsync(string objectKey, CancellationToken ct)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -144,7 +138,6 @@ public sealed class MinioFileStorage : IFileStorage
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
async Task IFileStorage.DeleteAsync(string objectKey, CancellationToken ct)
|
async Task IFileStorage.DeleteAsync(string objectKey, CancellationToken ct)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
using Deal.Modules.Tenants.Application.Abstractions;
|
||||||
|
|
||||||
|
namespace Deal.Infrastructure.Persistence.Abstractions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Фабрика хранилища лимитов ИИ-бюджета
|
||||||
|
/// </summary>
|
||||||
|
public interface ITenantLimitStoreFactory
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Создаёт хранилище лимитов
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>Готовый порт лимитов тенанта.</returns>
|
||||||
|
public ITenantLimitStore Create();
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using Deal.Modules.Cards.Application.Models;
|
||||||
using NpgsqlTypes;
|
using NpgsqlTypes;
|
||||||
|
|
||||||
namespace Deal.Infrastructure.Persistence.Entities;
|
namespace Deal.Infrastructure.Persistence.Entities;
|
||||||
@@ -130,7 +131,7 @@ public sealed class CardEntity
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Предыдущая колонка
|
/// Предыдущая колонка
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string PrevCol { get; set; } = "inbox";
|
public string PrevCol { get; set; } = CardIds.Inbox;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Время помещения в архив
|
/// Время помещения в архив
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using Deal.Modules.Kanban.Application.Models;
|
||||||
using NpgsqlTypes;
|
using NpgsqlTypes;
|
||||||
|
|
||||||
namespace Deal.Infrastructure.Persistence.Entities;
|
namespace Deal.Infrastructure.Persistence.Entities;
|
||||||
@@ -35,12 +36,12 @@ public sealed class ContainerEntity
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Вид контейнера: board
|
/// Вид контейнера: board
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string Kind { get; set; } = "board";
|
public string Kind { get; set; } = ContainerKinds.Board;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Пространство: dashboard | selected
|
/// Пространство: dashboard | selected
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string Space { get; set; } = "dashboard";
|
public string Space { get; set; } = ContainerSpaces.Dashboard;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Свёрнутость колонки на дашборде
|
/// Свёрнутость колонки на дашборде
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using Deal.Modules.Discovery.Application.Models;
|
||||||
|
|
||||||
namespace Deal.Infrastructure.Persistence.Entities;
|
namespace Deal.Infrastructure.Persistence.Entities;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -28,7 +30,7 @@ public sealed class DiscCandidateEntity
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Тип источника: channel|group|forum.
|
/// Тип источника: channel|group|forum.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string Kind { get; set; } = "channel";
|
public string Kind { get; set; } = DiscoveryCandidateKinds.Channel;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Цвет источника из палитры DIALOG_HUES
|
/// Цвет источника из палитры DIALOG_HUES
|
||||||
@@ -63,7 +65,7 @@ public sealed class DiscCandidateEntity
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Статус кандидата
|
/// Статус кандидата
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string Status { get; set; } = "new";
|
public string Status { get; set; } = DiscoveryCandidateStatuses.New;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Вступили автоматически
|
/// Вступили автоматически
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using Deal.Modules.Discovery.Application.Models;
|
||||||
|
|
||||||
namespace Deal.Infrastructure.Persistence.Entities;
|
namespace Deal.Infrastructure.Persistence.Entities;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -55,7 +57,7 @@ public sealed class DiscTaskEntity
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Статус задачи: draft|running|paused|done|failed.
|
/// Статус задачи: draft|running|paused|done|failed.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string Status { get; set; } = "draft";
|
public string Status { get; set; } = DiscoveryTaskStatuses.Draft;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Индекс текущего ключа поиска
|
/// Индекс текущего ключа поиска
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using Deal.Modules.Tenants.Application.Models;
|
||||||
|
|
||||||
namespace Deal.Infrastructure.Persistence.Entities;
|
namespace Deal.Infrastructure.Persistence.Entities;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -20,7 +22,7 @@ public sealed class InviteEntity
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public Guid? TenantId { get; set; }
|
public Guid? TenantId { get; set; }
|
||||||
|
|
||||||
public string Status { get; set; } = "pending";
|
public string Status { get; set; } = InviteStatuses.Pending;
|
||||||
|
|
||||||
public DateTimeOffset ExpiresAt { get; set; }
|
public DateTimeOffset ExpiresAt { get; set; }
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using Deal.Modules.Tenants.Application.Models;
|
||||||
|
|
||||||
namespace Deal.Infrastructure.Persistence.Entities;
|
namespace Deal.Infrastructure.Persistence.Entities;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -14,7 +16,7 @@ public sealed class OperatorEntity
|
|||||||
|
|
||||||
public string PasswordHash { get; set; } = string.Empty;
|
public string PasswordHash { get; set; } = string.Empty;
|
||||||
|
|
||||||
public string Status { get; set; } = "active";
|
public string Status { get; set; } = TenantStatuses.Active;
|
||||||
|
|
||||||
public DateTimeOffset CreatedAt { get; set; }
|
public DateTimeOffset CreatedAt { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using Deal.Modules.Pipeline.Application.Models;
|
||||||
|
|
||||||
namespace Deal.Infrastructure.Persistence.Entities;
|
namespace Deal.Infrastructure.Persistence.Entities;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -38,7 +40,7 @@ public sealed class QueueItemEntity
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Статус строки: <c>new</c> — ждёт разбора воркером, <c>filtered</c> — прошла фильтры и ждёт ИИ/ML.
|
/// Статус строки: <c>new</c> — ждёт разбора воркером, <c>filtered</c> — прошла фильтры и ждёт ИИ/ML.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string Status { get; set; } = "new";
|
public string Status { get; set; } = PipelineQueueStatuses.New;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Признак возврата из отсева
|
/// Признак возврата из отсева
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
using NpgsqlTypes;
|
using NpgsqlTypes;
|
||||||
|
|
||||||
|
using Deal.Modules.Pipeline.Application.Models;
|
||||||
|
|
||||||
namespace Deal.Infrastructure.Persistence.Entities;
|
namespace Deal.Infrastructure.Persistence.Entities;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -50,7 +52,7 @@ public sealed class RejectedItemEntity
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Кто вынес решение
|
/// Кто вынес решение
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string Source { get; set; } = "stop";
|
public string Source { get; set; } = PipelineRejectSources.Rules;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Время получения исходного сообщения
|
/// Время получения исходного сообщения
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using Deal.Modules.Tenants.Application.Models;
|
||||||
|
|
||||||
namespace Deal.Infrastructure.Persistence.Entities;
|
namespace Deal.Infrastructure.Persistence.Entities;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -9,7 +11,7 @@ public sealed class TenantEntity
|
|||||||
|
|
||||||
public string Name { get; set; } = string.Empty;
|
public string Name { get; set; } = string.Empty;
|
||||||
|
|
||||||
public string Status { get; set; } = "active";
|
public string Status { get; set; } = TenantStatuses.Active;
|
||||||
|
|
||||||
public DateTimeOffset CreatedAt { get; set; }
|
public DateTimeOffset CreatedAt { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using Deal.Modules.Tenants.Application.Models;
|
||||||
|
|
||||||
namespace Deal.Infrastructure.Persistence.Entities;
|
namespace Deal.Infrastructure.Persistence.Entities;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -18,7 +20,7 @@ public sealed class TenantLimitEntity
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Тип периода: month|day.
|
/// Тип периода: month|day.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string Period { get; set; } = "month";
|
public string Period { get; set; } = TenantLimitPeriods.Month;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Начало текущего периода
|
/// Начало текущего периода
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user