Compare commits
33 Commits
acfcdbe90e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| f034de43cf | |||
| dca8d067f3 | |||
| 74aab70a29 | |||
| d8dd97ba39 | |||
| 9d465167f4 | |||
| d91aef012e | |||
| a89721fbee | |||
| a84d5dc820 | |||
| 403a6127b1 | |||
| c617c05233 | |||
| 51788f73ee | |||
| 4e998d789b | |||
| 864d4bc852 | |||
| 583682f330 | |||
| 91e214dad2 | |||
| 4f6bba6885 | |||
| d543fb5585 | |||
| 3feb2f431c | |||
| ebc618743a | |||
| 379f2a26cf | |||
| 875f679fc3 | |||
| b902a28dae | |||
| 591643d364 | |||
| e6c81bf8fd | |||
| 7290f9483b | |||
| 375b333978 | |||
| c8eea8203e | |||
| 1a26403afb | |||
| 3af0c46130 | |||
| 818d4fa384 | |||
| 4f1efc6ff9 | |||
| 4a8816b908 | |||
| d22cd0de41 |
+43
-12
@@ -1,20 +1,48 @@
|
|||||||
# =====================================================================
|
# =====================================================================
|
||||||
# DEV VPS (docker-compose.dev-remote.yml). Скопируй в .env прямо на
|
# УСТАРЕЛО КАК ИНСТРУКЦИЯ: .env на dev VPS теперь собирает сам deploy.yml
|
||||||
# сервере (в DEPLOY_DIR) — файл гитигнорится, в репозиторий не попадает.
|
# (job deploy-dev) из repo Variables/Secrets — руками копировать больше не
|
||||||
|
# нужно. Файл остаётся только как справочник по составу переменных.
|
||||||
|
#
|
||||||
|
# DEV VPS (docker-compose.dev-remote.yml).
|
||||||
#
|
#
|
||||||
# ВАЖНО: Telegram Login Widget не работает на голом IP — /setdomain в
|
# ВАЖНО: Telegram Login Widget не работает на голом IP — /setdomain в
|
||||||
# BotFather принимает только доменное имя. На этот dev VPS (45.76.161.137)
|
# BotFather принимает только доменное имя. На этот dev VPS (45.76.161.137)
|
||||||
# заведён DNS A-record dev.netrunner-vpn.com — используй его во всех
|
# заведён DNS A-record dev.netrunner-vpn.com — используй его во всех
|
||||||
# URL ниже (и в аналогичном .env для netrunner-landing), а не IP напрямую.
|
# URL ниже (и в аналогичном .env для netrunner-landing), а не IP напрямую.
|
||||||
|
#
|
||||||
|
# ВАЖНО #2: Telegram Web Apps (кнопки бота "Личный Кабинет"/"Тарифы"/
|
||||||
|
# "Синдикат") требуют https:// URL, а Telegram Login Widget в обычном браузере
|
||||||
|
# требует именно порт 443 (его CSP frame-ancestors не учитывает порт и всегда
|
||||||
|
# подразумевает дефолтный для схемы) — оба жёсткие требования платформы, не
|
||||||
|
# нашего кода. HTTPS для dev поднят через Caddy (docker-compose.dev-remote.yml)
|
||||||
|
# на порту 443, сертификат — через DNS-01 challenge (Cloudflare, порт 80
|
||||||
|
# закрыт — обычный HTTP-01 не пройдёт). Нужен CF_API_TOKEN ниже. Порт 443
|
||||||
|
# раньше занимала VPN-нода — перенесена на другой порт на этом же VPS.
|
||||||
# =====================================================================
|
# =====================================================================
|
||||||
DB_USER=netrunner_admin
|
DB_USER=netrunner_admin
|
||||||
DB_PASSWORD=CHANGE_ME_DEV_DB_PASSWORD
|
DB_PASSWORD=CHANGE_ME_DEV_DB_PASSWORD
|
||||||
DB_NAME=netrunner
|
DB_NAME=netrunner
|
||||||
|
|
||||||
|
# Токен Cloudflare для DNS-01 (выпуск TLS-сертификата Caddy'ем без открытых
|
||||||
|
# 80/443) — создать в Cloudflare: My Profile → API Tokens → Create Token →
|
||||||
|
# шаблон "Edit zone DNS", ограниченный зоной netrunner-vpn.com. НЕ Global API Key.
|
||||||
|
CF_API_TOKEN=CHANGE_ME_CLOUDFLARE_DNS_EDIT_TOKEN
|
||||||
|
|
||||||
JWT_SECRET=CHANGE_ME_DEV_JWT_SECRET
|
JWT_SECRET=CHANGE_ME_DEV_JWT_SECRET
|
||||||
ARGON_SALT=CHANGE_ME_DEV_ARGON_SALT_MIN_16_CHARS
|
ARGON_SALT=CHANGE_ME_DEV_ARGON_SALT_MIN_16_CHARS
|
||||||
TON_MASTER_WALLET=UQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAKK8y
|
TON_MASTER_WALLET=UQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAKK8y
|
||||||
ADMIN_TG_ID=0
|
|
||||||
|
# Общий секрет с прокси-нодами этого dev-стенда (--require-auth) — обязателен,
|
||||||
|
# без него бэкенд не стартует. То же значение должно быть в .env.example
|
||||||
|
# netrunner-proxy на самих нодах.
|
||||||
|
PROXY_INTERNAL_SECRET=CHANGE_ME_DEV_PROXY_INTERNAL_SECRET
|
||||||
|
|
||||||
|
# Опционально: без него оплата через @CryptoBot просто вернёт "provider not
|
||||||
|
# supported", остальное продолжает работать как раньше. Токен берётся у
|
||||||
|
# @CryptoBot: /pay -> Create App -> Payment Token (лучше завести отдельное
|
||||||
|
# приложение для dev, чтобы инвойсы не путались с прод-платежами).
|
||||||
|
CRYPTOBOT_API_TOKEN=CHANGE_ME_CRYPTOBOT_API_TOKEN
|
||||||
|
CRYPTOBOT_TESTNET=true
|
||||||
|
|
||||||
BOT_ENABLED=true
|
BOT_ENABLED=true
|
||||||
# ОТДЕЛЬНЫЙ dev-бот, не прод-бот: у прод-бота в BotFather уже прописан
|
# ОТДЕЛЬНЫЙ dev-бот, не прод-бот: у прод-бота в BotFather уже прописан
|
||||||
@@ -25,21 +53,24 @@ BOT_ENABLED=true
|
|||||||
# использует прод-бота и Telegram ответит "Bot domain invalid".
|
# использует прод-бота и Telegram ответит "Bot domain invalid".
|
||||||
TELOXIDE_TOKEN=123456789:YOUR_DEV_BOT_TOKEN_HERE
|
TELOXIDE_TOKEN=123456789:YOUR_DEV_BOT_TOKEN_HERE
|
||||||
BOT_USERNAME=your_dev_bot_username
|
BOT_USERNAME=your_dev_bot_username
|
||||||
WEB_APP_BASE_URL=http://dev.netrunner-vpn.com:8080
|
# https, порт 443 — через Caddy (см. выше), не порт 8080 напрямую: WebApp
|
||||||
|
# от Telegram по http:// открывать откажется.
|
||||||
|
WEB_APP_BASE_URL=https://dev.netrunner-vpn.com
|
||||||
|
|
||||||
SUBSCRIPTION_CHECK_INTERVAL=1800
|
SUBSCRIPTION_CHECK_INTERVAL=1800
|
||||||
RUST_LOG=netrunner_control_plane=debug,axum=info,tower_http=debug,sqlx=warn
|
RUST_LOG=netrunner_control_plane=debug,axum=info,tower_http=debug,sqlx=warn
|
||||||
|
|
||||||
# dev.netrunner-vpn.com:3000 — netrunner-landing, задеплоенный на этот же dev
|
# dev.netrunner-vpn.com:3000 — netrunner-landing, задеплоенный на этот же dev
|
||||||
# VPS (docker-compose.dev-remote.yml в репозитории netrunner-landing).
|
# VPS (docker-compose.dev-remote.yml в репозитории netrunner-landing), обычный
|
||||||
|
# HTTP. dev.netrunner-vpn.com — сам бэкенд через Caddy (см. выше).
|
||||||
# COOKIE_DOMAIN нарочно пуст: host-only cookie уже расшаривается между
|
# COOKIE_DOMAIN нарочно пуст: host-only cookie уже расшаривается между
|
||||||
# портами одного и того же хоста браузером — Domain нужен только для
|
# портами одного и того же хоста браузером — Domain нужен только для
|
||||||
# реальных сабдоменов (прод).
|
# реальных сабдоменов (прод).
|
||||||
CORS_ALLOWED_ORIGINS=http://localhost:1420,http://localhost:5173,tauri://localhost,http://tauri.localhost,http://dev.netrunner-vpn.com:3000
|
CORS_ALLOWED_ORIGINS=http://localhost:1420,http://localhost:5173,tauri://localhost,http://tauri.localhost,http://dev.netrunner-vpn.com:3000,https://dev.netrunner-vpn.com
|
||||||
CSRF_ALLOWED_ORIGINS=http://localhost:1420,http://localhost:5173,http://dev.netrunner-vpn.com:3000
|
CSRF_ALLOWED_ORIGINS=http://localhost:1420,http://localhost:5173,http://dev.netrunner-vpn.com:3000,https://dev.netrunner-vpn.com
|
||||||
COOKIE_DOMAIN=
|
COOKIE_DOMAIN=
|
||||||
# ОБЯЗАТЕЛЬНО false: на этом VPS нет TLS (прямой HTTP на IP/домен, порт 443 занят
|
# true (как в проде): теперь у бэкенда есть настоящий TLS через Caddy на 443.
|
||||||
# VPN-нодой). Secure-cookie по голому HTTP браузер молча выбрасывает для любого
|
# Прямой доступ на голый :8080 (без Caddy) для браузерных auth-флоу больше не
|
||||||
# хоста, кроме localhost, — с COOKIE_SECURE=true (дефолт) сессия не переживёт
|
# годится — Secure-cookie по нему браузер по-прежнему отбросит, это ожидаемо:
|
||||||
# вообще ни одного запроса на этом стенде.
|
# все клиентские сценарии (бот, лендинг) идут через Caddy на 443.
|
||||||
COOKIE_SECURE=false
|
COOKIE_SECURE=true
|
||||||
|
|||||||
+10
-1
@@ -21,9 +21,18 @@ RUST_LOG=netrunner_control_plane=debug,axum=info,tower_http=debug,sqlx=warn
|
|||||||
JWT_SECRET=dev_jwt_secret_change_me
|
JWT_SECRET=dev_jwt_secret_change_me
|
||||||
ARGON_SALT=dev_stealth_salt_minimum_16_chars
|
ARGON_SALT=dev_stealth_salt_minimum_16_chars
|
||||||
TON_MASTER_WALLET=UQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAKK8y
|
TON_MASTER_WALLET=UQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAKK8y
|
||||||
ADMIN_TG_ID=0
|
|
||||||
TELOXIDE_TOKEN=123456789:YOUR_DEV_BOT_TOKEN_HERE
|
TELOXIDE_TOKEN=123456789:YOUR_DEV_BOT_TOKEN_HERE
|
||||||
|
|
||||||
|
# Общий секрет с локальным прокси (--require-auth, см. .env.example
|
||||||
|
# netrunner-proxy) — обязателен, без него бэкенд не стартует.
|
||||||
|
PROXY_INTERNAL_SECRET=dev_stealth_salt_minimum_16_chars_proxy
|
||||||
|
|
||||||
|
# Опционально: без него оплата через @CryptoBot вернёт "provider not
|
||||||
|
# supported", остальное работает как обычно. Токен — у @CryptoBot: /pay ->
|
||||||
|
# Create App -> Payment Token.
|
||||||
|
CRYPTOBOT_API_TOKEN=
|
||||||
|
CRYPTOBOT_TESTNET=true
|
||||||
|
|
||||||
# CORS: пускаем локальные дев-origin'ы Vite/Tauri-приложения.
|
# CORS: пускаем локальные дев-origin'ы Vite/Tauri-приложения.
|
||||||
CORS_ALLOWED_ORIGINS=http://localhost:1420,http://localhost:5173,tauri://localhost,http://tauri.localhost
|
CORS_ALLOWED_ORIGINS=http://localhost:1420,http://localhost:5173,tauri://localhost,http://tauri.localhost
|
||||||
CSRF_ALLOWED_ORIGINS=http://localhost:1420,http://localhost:5173
|
CSRF_ALLOWED_ORIGINS=http://localhost:1420,http://localhost:5173
|
||||||
|
|||||||
+86
-18
@@ -1,17 +1,37 @@
|
|||||||
|
# =====================================================================
|
||||||
|
# ЭТОТ ФАЙЛ БОЛЬШЕ НЕ КОПИРУЕТСЯ РУКАМИ НА СЕРВЕР. .env на проде (и на
|
||||||
|
# dev-стенде) теперь целиком собирается .gitea/workflows/deploy.yml (job
|
||||||
|
# deploy-prod/deploy-dev) из repo Variables/Secrets этого репозитория при
|
||||||
|
# каждом деплое — см. комментарии в самом deploy.yml, там точная карта
|
||||||
|
# "какая переменная .env ← какой vars.*/secrets.*". Файл ниже остаётся
|
||||||
|
# только как справочник по тому, какие ключи вообще существуют, и для
|
||||||
|
# локальной разработки (docker-compose.yml, см. .env.dev.example).
|
||||||
|
# =====================================================================
|
||||||
|
|
||||||
# =====================================================================
|
# =====================================================================
|
||||||
# === DATABASE CONFIGURATION ===
|
# === DATABASE CONFIGURATION ===
|
||||||
# =====================================================================
|
# =====================================================================
|
||||||
# ДЛЯ ЛОКАЛЬНЫХ ТЕСТОВ НА ТВОЕМ ПК:
|
# ВАЖНО: значения ниже — это плейсхолдеры-заглушки (CHANGE_ME_*), не реальный
|
||||||
# DATABASE_URL=postgres://netrunner_admin:dev_root_123@77.110.106.113:5432/netrunner
|
# пароль. Не копировать "как есть" — реальный пароль генерировать самостоятельно
|
||||||
|
# (например `openssl rand -base64 24`) и никогда не коммитить .env с настоящим
|
||||||
|
# значением (см. .gitignore — .env/.env.* уже исключены).
|
||||||
#
|
#
|
||||||
# ДЛЯ ЗАПУСКА НА САМОЙ ВПС (Через Docker Compose):
|
# Postgres физически стоит НЕ на этом VPS, а на отдельном сервере вместе с
|
||||||
# DATABASE_URL=postgres://netrunner_admin:dev_root_123@db:5432/netrunner
|
# Vaultwarden (см. netrunner-data/docker-compose.yml, сервис `db`) — этот
|
||||||
|
# бэкенд подключается к нему по сети. Полный DATABASE_URL собирает сам
|
||||||
DATABASE_URL=postgres://netrunner_admin:dev_root_123@77.110.106.113:5432/netrunner
|
# docker-compose.prod.yml из DB_USER/DB_PASSWORD/DB_HOST/DB_PORT/DB_NAME ниже
|
||||||
|
# (не пишите его здесь отдельной строкой — env_file не подставляет ${...},
|
||||||
|
# в отличие от самого docker-compose, так что отдельная строка DATABASE_URL
|
||||||
|
# тут просто не сработает). sslmode=require обязателен: без него подключение
|
||||||
|
# к hostssl-правилу в pg_hba.conf того сервера будет отклонено.
|
||||||
|
|
||||||
DB_USER=netrunner_admin
|
DB_USER=netrunner_admin
|
||||||
DB_NAME=netrunner
|
DB_NAME=netrunner
|
||||||
DB_PASSWORD=dev_root_123
|
DB_PASSWORD=CHANGE_ME_DB_PASSWORD
|
||||||
|
# IP VPS, где физически стоит Postgres (netrunner-data). Совпадает с DATA_VPS_IP
|
||||||
|
# в CI-переменных netrunner-data.
|
||||||
|
DB_HOST=CHANGE_ME_DB_HOST_IP
|
||||||
|
DB_PORT=5432
|
||||||
|
|
||||||
# Кеш курса TON (не источник истины — если недоступен, приложение просто
|
# Кеш курса TON (не источник истины — если недоступен, приложение просто
|
||||||
# ходит в TonAPI напрямую на каждый инвойс, как раньше).
|
# ходит в TonAPI напрямую на каждый инвойс, как раньше).
|
||||||
@@ -20,11 +40,37 @@ REDIS_URL=redis://redis:6379
|
|||||||
# =====================================================================
|
# =====================================================================
|
||||||
# === SECRETS & CRYPTO SECURITY ===
|
# === SECRETS & CRYPTO SECURITY ===
|
||||||
# =====================================================================
|
# =====================================================================
|
||||||
JWT_SECRET=YOUR_RANDOM_SECRET_KEY_CHANGE_ME_IN_PRODUCTION
|
JWT_SECRET=CHANGE_ME_JWT_SECRET_openssl_rand_hex_32
|
||||||
ARGON_SALT=какая-то-рандомная-длинная-строка-минимум-16-символов
|
ARGON_SALT=CHANGE_ME_ARGON_SALT_MIN_16_CHARS
|
||||||
GRAFANA_PASSWORD=netrunner_admin_2026
|
|
||||||
TON_MASTER_WALLET=UQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAKK8y
|
TON_MASTER_WALLET=UQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAKK8y
|
||||||
|
|
||||||
|
# Бэкапы Postgres (pg_dump + опциональный rclone) теперь настраиваются в
|
||||||
|
# netrunner-data/.env (там же, где физически стоит БД) — см.
|
||||||
|
# netrunner-data/scripts/backup.sh. Здесь их больше нет.
|
||||||
|
|
||||||
|
# =====================================================================
|
||||||
|
# === НАБЛЮДАЕМОСТЬ ===
|
||||||
|
# =====================================================================
|
||||||
|
# Prometheus/Loki/Grafana теперь единым стеком на VPS с данными (см.
|
||||||
|
# netrunner-data/docker-compose.observability.yml) — GRAFANA_PASSWORD
|
||||||
|
# больше не нужен здесь. Этот бэкенд только пушит логи туда через тонкий
|
||||||
|
# promtail (сервис `promtail` в docker-compose.prod.yml) — публичный адрес
|
||||||
|
# того VPS, порт 3100 (обязательно зафайрволенный там на IP этого хоста).
|
||||||
|
LOKI_PUSH_URL=http://CHANGE_ME_DATA_VPS_IP:3100
|
||||||
|
|
||||||
|
# Публичный URL Grafana за auth-гейтом Caddy (см.
|
||||||
|
# netrunner-data/observability/Caddyfile) — отдаётся фронту через
|
||||||
|
# GET /api/v1/config для встройки в ObservabilityPage.tsx (frontend/src/).
|
||||||
|
# Не задан — страница просто не показывает дашборды. Ставить ТОЛЬКО после
|
||||||
|
# того, как в Caddyfile настоящий backend-URL вместо CHANGE_ME_BACKEND_PUBLIC_URL —
|
||||||
|
# иначе будет вести на нерабочий auth-гейт.
|
||||||
|
GRAFANA_PUBLIC_URL=https://CHANGE_ME_GRAFANA_DOMAIN
|
||||||
|
|
||||||
|
# Опционально: без него оплата через @CryptoBot вернёт "provider not
|
||||||
|
# supported", остальные провайдеры продолжают работать. Токен — у @CryptoBot:
|
||||||
|
# /pay -> Create App -> Payment Token.
|
||||||
|
CRYPTOBOT_API_TOKEN=
|
||||||
|
|
||||||
# =====================================================================
|
# =====================================================================
|
||||||
# === TELEGRAM API INTEGRATION ===
|
# === TELEGRAM API INTEGRATION ===
|
||||||
# =====================================================================
|
# =====================================================================
|
||||||
@@ -34,6 +80,31 @@ TELOXIDE_TOKEN=123456789:YOUR_REAL_TELEGRAM_BOT_TOKEN_HERE
|
|||||||
BOT_USERNAME=ntrnr_vpn_bot
|
BOT_USERNAME=ntrnr_vpn_bot
|
||||||
WEB_APP_BASE_URL=https://account.netrunner-vpn.com
|
WEB_APP_BASE_URL=https://account.netrunner-vpn.com
|
||||||
|
|
||||||
|
# =====================================================================
|
||||||
|
# === ВНУТРЕННИЙ КОНТРАКТ С ПРОКСИ-НОДАМИ (обязателен, без него старт падает) ===
|
||||||
|
# =====================================================================
|
||||||
|
# Общий секрет между бэкендом и КАЖДОЙ прокси-нодой (X-Internal-Secret на
|
||||||
|
# POST /internal/validate и /internal/usage, см. modules::proxy_internal) —
|
||||||
|
# то же самое значение прописывается на нодах при провижининге
|
||||||
|
# (NodeService::provision_node_via_ssh) и в .env.example netrunner-proxy.
|
||||||
|
PROXY_INTERNAL_SECRET=CHANGE_ME_PROXY_INTERNAL_SECRET_openssl_rand_hex_32
|
||||||
|
|
||||||
|
# =====================================================================
|
||||||
|
# === РЕГИОНАЛЬНЫЕ ЦЕНЫ: GEOIP + VPN-ДЕТЕКТ (см. modules::geoip) ===
|
||||||
|
# =====================================================================
|
||||||
|
# Все три — опциональны, дефолты в main.rs указывают прямо на публичные
|
||||||
|
# раздачи, ничего заполнять не обязательно для старта:
|
||||||
|
# GEOIP_DB_URL_TEMPLATE — DB-IP Lite (.mmdb.gz, тот же формат, что
|
||||||
|
# MaxMind, но без регистрации/лицензионного ключа), "{}" — плейсхолдер
|
||||||
|
# под "YYYY-MM" (у DB-IP имя файла с датой, единой "latest"-ссылки нет).
|
||||||
|
# VPN_LIST_URL — X4BNet lists_vpn, открытый список CIDR-диапазонов
|
||||||
|
# известных VPN/датацентров (эвристика, не 100% точная — см. их README).
|
||||||
|
# GEOIP_REFRESH_INTERVAL — как часто (сек) перекачивать обе базы,
|
||||||
|
# по умолчанию раз в сутки (86400).
|
||||||
|
# GEOIP_DB_URL_TEMPLATE=https://download.db-ip.com/free/dbip-country-lite-{}.mmdb.gz
|
||||||
|
# VPN_LIST_URL=https://raw.githubusercontent.com/X4BNet/lists_vpn/main/output/vpn/ipv4.txt
|
||||||
|
# GEOIP_REFRESH_INTERVAL=86400
|
||||||
|
|
||||||
# =====================================================================
|
# =====================================================================
|
||||||
# === APPLICATION RUNTIME SETTINGS ===
|
# === APPLICATION RUNTIME SETTINGS ===
|
||||||
# =====================================================================
|
# =====================================================================
|
||||||
@@ -65,15 +136,12 @@ COOKIE_SECURE=true
|
|||||||
# =====================================================================
|
# =====================================================================
|
||||||
# === DEPLOYMENT & REPOSITORY SETTINGS ===
|
# === DEPLOYMENT & REPOSITORY SETTINGS ===
|
||||||
# =====================================================================
|
# =====================================================================
|
||||||
GHCR_TOKEN=ghp_ТВОЙ_ТОКЕН_ОТ_ГИТХАБА_ДЛЯ_ДОСТУПА_К_РЕЕСТРУ
|
# Токен Gitea (право write:package) — нужен самому бэкенду в рантайме для
|
||||||
VPS_IP=77.110.106.113
|
# провижининга новых нод (см. NodeService::provision_node_via_ssh, шлёт этот
|
||||||
|
# же токен на ноду, чтобы она тоже могла тянуть образ с Gitea Registry).
|
||||||
|
GT_REGISTRY_TOKEN=CHANGE_ME_GT_REGISTRY_TOKEN
|
||||||
|
VPS_IP=CHANGE_ME_VPS_IP
|
||||||
DEPLOY_DIR=/root/netrunner-core
|
DEPLOY_DIR=/root/netrunner-core
|
||||||
BINARY_NAME=netrunner-control-plane
|
BINARY_NAME=netrunner-control-plane
|
||||||
# Образ, который тянет прод-стек (docker-compose.prod.yml).
|
# Образ, который тянет прод-стек (docker-compose.prod.yml).
|
||||||
IMAGE=ghcr.io/nineap/netrunner-control-plane:latest
|
IMAGE=ghcr.io/nineap/netrunner-control-plane:latest
|
||||||
|
|
||||||
|
|
||||||
# =====================================================================
|
|
||||||
# === ADMIN ===
|
|
||||||
# =====================================================================
|
|
||||||
ADMIN_TG_ID=00000
|
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
# Копия .github/workflows/build.yml для Gitea Actions. Отличие от оригинала:
|
||||||
|
# реестр — встроенный Container Registry самой Gitea (gitea.netrunner-vpn.com),
|
||||||
|
# не ghcr.io — секрет secrets.GITHUB_TOKEN там валиден только на самом
|
||||||
|
# GitHub. Логин — токеном Gitea с правом write:package
|
||||||
|
# (secrets.GT_REGISTRY_TOKEN), один и тот же токен используется во всех
|
||||||
|
# репозиториях, где собираются образы (netrunner-backend/netrunner-landing/
|
||||||
|
# netrunner-proxy) — пакеты в Gitea принадлежат аккаунту, не конкретному
|
||||||
|
# репозиторию, заводить по секрету на каждый репозиторий не нужно, но
|
||||||
|
# значение нужно продублировать в Settings каждого из них (Gitea не даёт
|
||||||
|
# организационных секретов на уровне всего аккаунта для personal-репозиториев).
|
||||||
|
name: Build & Push Image
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
tags: ["v*"]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
env:
|
||||||
|
REGISTRY: gitea.netrunner-vpn.com
|
||||||
|
IMAGE_NAME: nineap/netrunner-control-plane
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
name: Build and push to Gitea Registry
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Log in to Gitea Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ env.REGISTRY }}
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GT_REGISTRY_TOKEN }}
|
||||||
|
|
||||||
|
- name: Extract metadata (tags, labels)
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||||
|
tags: |
|
||||||
|
type=raw,value=latest,enable={{is_default_branch}}
|
||||||
|
type=sha,format=long
|
||||||
|
type=ref,event=tag
|
||||||
|
|
||||||
|
- name: Build and push
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
# Кеш слоёв между прогонами — type=gha (GitHub Actions Cache) не
|
||||||
|
# работает на self-hosted Gitea Actions раннере (act_runner не
|
||||||
|
# реализует этот прокси-сервис, сборка виснет и падает по таймауту
|
||||||
|
# на попытке экспортировать кеш) — вместо этого кеш кладём в тот же
|
||||||
|
# registry отдельным тегом, тот же приём, что уже в netrunner-landing.
|
||||||
|
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:cache
|
||||||
|
cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:cache,mode=max
|
||||||
|
|
||||||
|
# Кастомная сборка Caddy (модуль caddy-dns/cloudflare, нужен для TLS на
|
||||||
|
# dev-стенде — см. Caddyfile.dev). Компилировать xcaddy'ем прямо на VPS
|
||||||
|
# оказалось слишком долго и один раз реально уронило SSH-деплой по таймауту
|
||||||
|
# (компиляция go-модулей на слабой машине не укладывалась в command_timeout
|
||||||
|
# appleboy/ssh-action) — та же причина, по которой Rust вообще не собирается
|
||||||
|
# на VPS. Собираем здесь и просто тянем готовый образ, как и основной.
|
||||||
|
build-caddy:
|
||||||
|
name: Build and push Caddy (Cloudflare DNS) image
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Log in to Gitea Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ env.REGISTRY }}
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GT_REGISTRY_TOKEN }}
|
||||||
|
|
||||||
|
- name: Build and push
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: ./Dockerfile.caddy
|
||||||
|
push: true
|
||||||
|
# Захардкожено, не ${{ github.repository_owner }}: он резолвится в
|
||||||
|
# "nineAp" (смешанный регистр), Docker такие теги отклоняет.
|
||||||
|
tags: ${{ env.REGISTRY }}/nineap/netrunner-caddy-cloudflare:latest
|
||||||
|
cache-from: type=registry,ref=${{ env.REGISTRY }}/nineap/netrunner-caddy-cloudflare:cache
|
||||||
|
cache-to: type=registry,ref=${{ env.REGISTRY }}/nineap/netrunner-caddy-cloudflare:cache,mode=max
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
env:
|
||||||
|
CARGO_TERM_COLOR: always
|
||||||
|
SQLX_OFFLINE: "true"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
check:
|
||||||
|
name: fmt + clippy + test
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
env:
|
||||||
|
POSTGRES_USER: postgres
|
||||||
|
POSTGRES_PASSWORD: postgres
|
||||||
|
POSTGRES_DB: postgres
|
||||||
|
ports:
|
||||||
|
- 5432:5432
|
||||||
|
options: >-
|
||||||
|
--health-cmd "pg_isready -U postgres"
|
||||||
|
--health-interval 5s
|
||||||
|
--health-timeout 5s
|
||||||
|
--health-retries 10
|
||||||
|
|
||||||
|
env:
|
||||||
|
# sqlx::test сам создаёт временные БД и накатывает ./migrations.
|
||||||
|
DATABASE_URL: postgres://postgres:postgres@localhost:5432/postgres
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install Rust toolchain
|
||||||
|
uses: dtolnay/rust-toolchain@stable
|
||||||
|
with:
|
||||||
|
components: rustfmt, clippy
|
||||||
|
|
||||||
|
- name: Cache cargo
|
||||||
|
uses: Swatinem/rust-cache@v2
|
||||||
|
|
||||||
|
- name: Format check
|
||||||
|
run: cargo fmt --all -- --check
|
||||||
|
|
||||||
|
- name: Clippy
|
||||||
|
run: cargo clippy --all-targets -- -D warnings
|
||||||
|
|
||||||
|
- name: Tests
|
||||||
|
run: cargo test --all-targets
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
name: Deploy Nginx
|
||||||
|
|
||||||
|
# Единственный публичный вход (443/80) на прод VPS (backend+лендинг вместе) —
|
||||||
|
# system nginx, тот же принцип, что и в netrunner-data. Только ручной запуск.
|
||||||
|
#
|
||||||
|
# Порядок первого запуска:
|
||||||
|
# 1. Запустить эту джобу (поднимет http-конфиг + сам nginx).
|
||||||
|
# 2. Один раз руками на сервере:
|
||||||
|
# sudo apt-get install -y certbot
|
||||||
|
# sudo certbot certonly --webroot -w /var/www/certbot \
|
||||||
|
# -d account.netrunner-vpn.com
|
||||||
|
# (если лендинг деплоится на этот же VPS первым — можно сразу одной
|
||||||
|
# командой на оба домена, см. deploy-nginx.yml у netrunner-landing)
|
||||||
|
# 3. Запустить эту джобу ещё раз — включит HTTPS.
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
name: Deploy nginx config for account.netrunner-vpn.com
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Sync nginx configs to VPS
|
||||||
|
uses: appleboy/scp-action@v0.1.7
|
||||||
|
with:
|
||||||
|
host: ${{ vars.VPS_IP }}
|
||||||
|
username: root
|
||||||
|
key: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||||
|
source: "nginx/account-http.conf,nginx/account-ssl.conf"
|
||||||
|
target: "/root/netrunner-core"
|
||||||
|
strip_components: 0
|
||||||
|
|
||||||
|
- name: Install/enable nginx via SSH
|
||||||
|
uses: appleboy/ssh-action@v1
|
||||||
|
with:
|
||||||
|
host: ${{ vars.VPS_IP }}
|
||||||
|
username: root
|
||||||
|
key: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||||
|
script: |
|
||||||
|
set -e
|
||||||
|
|
||||||
|
if ! command -v nginx >/dev/null 2>&1; then
|
||||||
|
apt-get update
|
||||||
|
apt-get install -y nginx
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p /var/www/certbot
|
||||||
|
rm -f /etc/nginx/sites-enabled/default
|
||||||
|
|
||||||
|
cp /root/netrunner-core/nginx/account-http.conf /etc/nginx/sites-available/account-http.conf
|
||||||
|
cp /root/netrunner-core/nginx/account-ssl.conf /etc/nginx/sites-available/account-ssl.conf
|
||||||
|
ln -sf /etc/nginx/sites-available/account-http.conf /etc/nginx/sites-enabled/account-http.conf
|
||||||
|
|
||||||
|
CERT=/etc/letsencrypt/live/account.netrunner-vpn.com/fullchain.pem
|
||||||
|
if [ -f "$CERT" ]; then
|
||||||
|
ln -sf /etc/nginx/sites-available/account-ssl.conf /etc/nginx/sites-enabled/account-ssl.conf
|
||||||
|
echo "🔒 Сертификат найден — HTTPS-конфиг включён."
|
||||||
|
else
|
||||||
|
rm -f /etc/nginx/sites-enabled/account-ssl.conf
|
||||||
|
echo "⚠️ Сертификата ещё нет ($CERT) — HTTPS-конфиг НЕ включён."
|
||||||
|
fi
|
||||||
|
|
||||||
|
nginx -t
|
||||||
|
systemctl enable --now nginx
|
||||||
|
systemctl reload nginx
|
||||||
|
|
||||||
|
echo "✅ nginx (account.netrunner-vpn.com) synchronized."
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
# Копия .github/workflows/deploy.yml для Gitea Actions. Образы тянутся из
|
||||||
|
# встроенного Container Registry самой Gitea (gitea.netrunner-vpn.com), не
|
||||||
|
# ghcr.io — secrets.GT_REGISTRY_TOKEN, не GHCR_TOKEN/GITHUB_TOKEN.
|
||||||
|
# Триггер workflow_run (авто-деплой после успешной сборки) поддерживается не
|
||||||
|
# во всех версиях Gitea Actions — если deploy-dev не срабатывает автоматически
|
||||||
|
# после build, проверить версию Gitea или временно дергать деплой вручную
|
||||||
|
# через workflow_dispatch.
|
||||||
|
name: Deploy
|
||||||
|
|
||||||
|
# Один пайплайн, две джобы:
|
||||||
|
# - deploy-dev: авто, срабатывает после успешного "Build & Push Image" на main.
|
||||||
|
# - deploy-prod: вручную из вкладки Actions ("Run workflow"), как и раньше.
|
||||||
|
#
|
||||||
|
# .env на обоих VPS теперь полностью собирается этим workflow из repo
|
||||||
|
# Variables/Secrets (тот же принцип, что и в netrunner-data) — руками на
|
||||||
|
# сервере ничего не создаётся и не редактируется, весь конфиг живёт в
|
||||||
|
# Settings → Actions этого репозитория.
|
||||||
|
on:
|
||||||
|
workflow_run:
|
||||||
|
workflows: ["Build & Push Image"]
|
||||||
|
types: [completed]
|
||||||
|
branches: [main]
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
image_tag:
|
||||||
|
description: "Тег образа в Gitea Registry для прод-деплоя"
|
||||||
|
required: true
|
||||||
|
default: "latest"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy-dev:
|
||||||
|
name: Auto-deploy to Dev VPS
|
||||||
|
if: github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success'
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
ref: ${{ github.event.workflow_run.head_sha }}
|
||||||
|
|
||||||
|
# Копируем compose-файл + Caddyfile (примонтированный конфиг, не образ —
|
||||||
|
# сам Caddy-образ, как и образ бэкенда, дев-сервер тянет из Gitea Registry,
|
||||||
|
# собран в build.yml::build-caddy. Собирать xcaddy прямо на VPS один раз уже не
|
||||||
|
# уложилось в таймаут SSH-деплоя и уронило весь прогон).
|
||||||
|
- name: Sync compose file to Dev VPS
|
||||||
|
uses: appleboy/scp-action@v0.1.7
|
||||||
|
with:
|
||||||
|
host: ${{ vars.DEV_VPS_IP }}
|
||||||
|
username: root
|
||||||
|
key: ${{ secrets.DEV_SSH_PRIVATE_KEY }}
|
||||||
|
source: "docker-compose.dev-remote.yml,Caddyfile.dev"
|
||||||
|
target: "/root/netrunner-core"
|
||||||
|
strip_components: 0
|
||||||
|
|
||||||
|
- name: Write .env and deploy via SSH
|
||||||
|
uses: appleboy/ssh-action@v1
|
||||||
|
with:
|
||||||
|
host: ${{ vars.DEV_VPS_IP }}
|
||||||
|
username: root
|
||||||
|
key: ${{ secrets.DEV_SSH_PRIVATE_KEY }}
|
||||||
|
envs: "DB_PASSWORD,CF_API_TOKEN,JWT_SECRET,ARGON_SALT,TON_MASTER_WALLET,PROXY_INTERNAL_SECRET,CRYPTOBOT_API_TOKEN,TELOXIDE_TOKEN,BOT_USERNAME,GT_REGISTRY_TOKEN"
|
||||||
|
script: |
|
||||||
|
set -e
|
||||||
|
cd /root/netrunner-core
|
||||||
|
|
||||||
|
# .env — статические значения (никогда не меняются между деплоями)
|
||||||
|
# зашиты здесь же литералом, секреты/domain-специфичное — из vars/secrets.
|
||||||
|
cat > .env <<EOF
|
||||||
|
DB_USER=netrunner_admin
|
||||||
|
DB_PASSWORD=${DB_PASSWORD}
|
||||||
|
DB_NAME=netrunner
|
||||||
|
CF_API_TOKEN=${CF_API_TOKEN}
|
||||||
|
JWT_SECRET=${JWT_SECRET}
|
||||||
|
ARGON_SALT=${ARGON_SALT}
|
||||||
|
TON_MASTER_WALLET=${TON_MASTER_WALLET}
|
||||||
|
PROXY_INTERNAL_SECRET=${PROXY_INTERNAL_SECRET}
|
||||||
|
# Нужен самому бэкенду в рантайме, не только CI — см.
|
||||||
|
# NodeService::provision_node_via_ssh (шлёт этот же токен на новую
|
||||||
|
# ноду, чтобы она тоже могла тянуть образ с gitea.netrunner-vpn.com).
|
||||||
|
GT_REGISTRY_TOKEN=${GT_REGISTRY_TOKEN}
|
||||||
|
CRYPTOBOT_API_TOKEN=${CRYPTOBOT_API_TOKEN}
|
||||||
|
CRYPTOBOT_TESTNET=true
|
||||||
|
BOT_ENABLED=true
|
||||||
|
TELOXIDE_TOKEN=${TELOXIDE_TOKEN}
|
||||||
|
BOT_USERNAME=${BOT_USERNAME}
|
||||||
|
WEB_APP_BASE_URL=https://dev.netrunner-vpn.com
|
||||||
|
SUBSCRIPTION_CHECK_INTERVAL=1800
|
||||||
|
RUST_LOG=netrunner_control_plane=debug,axum=info,tower_http=debug,sqlx=warn
|
||||||
|
CORS_ALLOWED_ORIGINS=http://localhost:1420,http://localhost:5173,tauri://localhost,http://tauri.localhost,http://dev.netrunner-vpn.com:3000,https://dev.netrunner-vpn.com
|
||||||
|
CSRF_ALLOWED_ORIGINS=http://localhost:1420,http://localhost:5173,http://dev.netrunner-vpn.com:3000,https://dev.netrunner-vpn.com
|
||||||
|
COOKIE_DOMAIN=
|
||||||
|
COOKIE_SECURE=true
|
||||||
|
EOF
|
||||||
|
chmod 600 .env
|
||||||
|
|
||||||
|
export IMAGE="gitea.netrunner-vpn.com/nineap/netrunner-control-plane:latest"
|
||||||
|
export CADDY_IMAGE="gitea.netrunner-vpn.com/nineap/netrunner-caddy-cloudflare:latest"
|
||||||
|
echo "🚀 Deploying ${IMAGE} to DEV"
|
||||||
|
|
||||||
|
echo "${{ secrets.GT_REGISTRY_TOKEN }}" | docker login gitea.netrunner-vpn.com -u ${{ github.actor }} --password-stdin
|
||||||
|
|
||||||
|
docker compose -f docker-compose.dev-remote.yml pull
|
||||||
|
docker compose -f docker-compose.dev-remote.yml up -d --remove-orphans
|
||||||
|
|
||||||
|
docker image prune -f
|
||||||
|
|
||||||
|
echo "✅ Dev synchronized."
|
||||||
|
env:
|
||||||
|
DB_PASSWORD: ${{ secrets.DEV_DB_PASSWORD }}
|
||||||
|
CF_API_TOKEN: ${{ secrets.DEV_CF_API_TOKEN }}
|
||||||
|
JWT_SECRET: ${{ secrets.DEV_JWT_SECRET }}
|
||||||
|
ARGON_SALT: ${{ secrets.DEV_ARGON_SALT }}
|
||||||
|
TON_MASTER_WALLET: ${{ vars.TON_MASTER_WALLET }}
|
||||||
|
PROXY_INTERNAL_SECRET: ${{ secrets.DEV_PROXY_INTERNAL_SECRET }}
|
||||||
|
CRYPTOBOT_API_TOKEN: ${{ secrets.DEV_CRYPTOBOT_API_TOKEN }}
|
||||||
|
TELOXIDE_TOKEN: ${{ secrets.DEV_TELOXIDE_TOKEN }}
|
||||||
|
BOT_USERNAME: ${{ vars.DEV_BOT_USERNAME }}
|
||||||
|
GT_REGISTRY_TOKEN: ${{ secrets.GT_REGISTRY_TOKEN }}
|
||||||
|
|
||||||
|
deploy-prod:
|
||||||
|
name: Pull & restart on Prod VPS
|
||||||
|
if: github.event_name == 'workflow_dispatch'
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Sync compose file to Prod VPS
|
||||||
|
uses: appleboy/scp-action@v0.1.7
|
||||||
|
with:
|
||||||
|
host: ${{ vars.VPS_IP }}
|
||||||
|
username: root
|
||||||
|
key: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||||
|
source: "docker-compose.prod.yml,promtail-config.yml"
|
||||||
|
target: "/root/netrunner-core"
|
||||||
|
strip_components: 0
|
||||||
|
|
||||||
|
- name: Write .env and deploy via SSH
|
||||||
|
uses: appleboy/ssh-action@v1
|
||||||
|
with:
|
||||||
|
host: ${{ vars.VPS_IP }}
|
||||||
|
username: root
|
||||||
|
key: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||||
|
envs: "DB_PASSWORD,DATA_VPS_IP,JWT_SECRET,ARGON_SALT,TON_MASTER_WALLET,GRAFANA_PUBLIC_URL,PROXY_INTERNAL_SECRET,CRYPTOBOT_API_TOKEN,TELOXIDE_TOKEN,BOT_USERNAME,WEB_APP_BASE_URL,GT_REGISTRY_TOKEN"
|
||||||
|
script: |
|
||||||
|
set -e
|
||||||
|
cd /root/netrunner-core
|
||||||
|
|
||||||
|
cat > .env <<EOF
|
||||||
|
DB_USER=netrunner_admin
|
||||||
|
DB_NAME=netrunner
|
||||||
|
DB_PASSWORD=${DB_PASSWORD}
|
||||||
|
DB_HOST=${DATA_VPS_IP}
|
||||||
|
DB_PORT=5432
|
||||||
|
REDIS_URL=redis://redis:6379
|
||||||
|
JWT_SECRET=${JWT_SECRET}
|
||||||
|
ARGON_SALT=${ARGON_SALT}
|
||||||
|
TON_MASTER_WALLET=${TON_MASTER_WALLET}
|
||||||
|
LOKI_PUSH_URL=http://${DATA_VPS_IP}:3100
|
||||||
|
GRAFANA_PUBLIC_URL=${GRAFANA_PUBLIC_URL}
|
||||||
|
CRYPTOBOT_API_TOKEN=${CRYPTOBOT_API_TOKEN}
|
||||||
|
BOT_ENABLED=true
|
||||||
|
TELOXIDE_TOKEN=${TELOXIDE_TOKEN}
|
||||||
|
BOT_USERNAME=${BOT_USERNAME}
|
||||||
|
WEB_APP_BASE_URL=${WEB_APP_BASE_URL}
|
||||||
|
PROXY_INTERNAL_SECRET=${PROXY_INTERNAL_SECRET}
|
||||||
|
# Нужен самому бэкенду в рантайме, не только CI — см.
|
||||||
|
# NodeService::provision_node_via_ssh.
|
||||||
|
GT_REGISTRY_TOKEN=${GT_REGISTRY_TOKEN}
|
||||||
|
PORT=8080
|
||||||
|
SUBSCRIPTION_CHECK_INTERVAL=1800
|
||||||
|
NODE_HEALTH_CHECK_INTERVAL=60
|
||||||
|
APP_ENV=production
|
||||||
|
RUST_LOG=netrunner_control_plane=info,axum=info,tower_http=info,sqlx=warn
|
||||||
|
CORS_ALLOWED_ORIGINS=https://netrunner-vpn.com,https://account.netrunner-vpn.com,tauri://localhost,http://tauri.localhost
|
||||||
|
CSRF_ALLOWED_ORIGINS=https://netrunner-vpn.com,https://account.netrunner-vpn.com
|
||||||
|
COOKIE_DOMAIN=.netrunner-vpn.com
|
||||||
|
COOKIE_SECURE=true
|
||||||
|
EOF
|
||||||
|
chmod 600 .env
|
||||||
|
|
||||||
|
export IMAGE="gitea.netrunner-vpn.com/nineap/netrunner-control-plane:${{ github.event.inputs.image_tag }}"
|
||||||
|
echo "🚀 Deploying ${IMAGE}"
|
||||||
|
|
||||||
|
echo "${{ secrets.GT_REGISTRY_TOKEN }}" | docker login gitea.netrunner-vpn.com -u ${{ github.actor }} --password-stdin
|
||||||
|
|
||||||
|
# Тянем свежий образ, прогоняем миграции (one-shot) и поднимаем сервисы.
|
||||||
|
docker compose -f docker-compose.prod.yml pull
|
||||||
|
docker compose -f docker-compose.prod.yml up -d --remove-orphans
|
||||||
|
|
||||||
|
# Чистим старые слои.
|
||||||
|
docker image prune -f
|
||||||
|
|
||||||
|
echo "✅ Uplink synchronized."
|
||||||
|
env:
|
||||||
|
DB_PASSWORD: ${{ secrets.DB_PASSWORD }}
|
||||||
|
DATA_VPS_IP: ${{ vars.DATA_VPS_IP }}
|
||||||
|
JWT_SECRET: ${{ secrets.JWT_SECRET }}
|
||||||
|
ARGON_SALT: ${{ secrets.ARGON_SALT }}
|
||||||
|
TON_MASTER_WALLET: ${{ vars.TON_MASTER_WALLET }}
|
||||||
|
GRAFANA_PUBLIC_URL: ${{ vars.GRAFANA_PUBLIC_URL }}
|
||||||
|
PROXY_INTERNAL_SECRET: ${{ secrets.PROXY_INTERNAL_SECRET }}
|
||||||
|
CRYPTOBOT_API_TOKEN: ${{ secrets.CRYPTOBOT_API_TOKEN }}
|
||||||
|
TELOXIDE_TOKEN: ${{ secrets.TELOXIDE_TOKEN }}
|
||||||
|
BOT_USERNAME: ${{ vars.BOT_USERNAME }}
|
||||||
|
WEB_APP_BASE_URL: ${{ vars.WEB_APP_BASE_URL }}
|
||||||
|
GT_REGISTRY_TOKEN: ${{ secrets.GT_REGISTRY_TOKEN }}
|
||||||
@@ -51,3 +51,42 @@ jobs:
|
|||||||
# Кеш слоёв между прогонами — повторные сборки в разы быстрее.
|
# Кеш слоёв между прогонами — повторные сборки в разы быстрее.
|
||||||
cache-from: type=gha
|
cache-from: type=gha
|
||||||
cache-to: type=gha,mode=max
|
cache-to: type=gha,mode=max
|
||||||
|
|
||||||
|
# Кастомная сборка Caddy (модуль caddy-dns/cloudflare, нужен для TLS на
|
||||||
|
# dev-стенде — см. Caddyfile.dev). Компилировать xcaddy'ем прямо на VPS
|
||||||
|
# оказалось слишком долго и один раз реально уронило SSH-деплой по таймауту
|
||||||
|
# (компиляция go-модулей на слабой машине не укладывалась в command_timeout
|
||||||
|
# appleboy/ssh-action) — та же причина, по которой Rust вообще не собирается
|
||||||
|
# на VPS. Собираем здесь и просто тянем готовый образ, как и основной.
|
||||||
|
build-caddy:
|
||||||
|
name: Build and push Caddy (Cloudflare DNS) image
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Log in to GHCR
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ env.REGISTRY }}
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Build and push
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: ./Dockerfile.caddy
|
||||||
|
push: true
|
||||||
|
# Захардкожено, не ${{ github.repository_owner }}: он резолвится в
|
||||||
|
# "nineAp" (смешанный регистр), Docker такие теги отклоняет — та же
|
||||||
|
# причина, по которой deploy.yml тоже хардкодит "nineap" для
|
||||||
|
# основного образа вместо репозиторного контекста.
|
||||||
|
tags: ${{ env.REGISTRY }}/nineap/netrunner-caddy-cloudflare:latest
|
||||||
|
cache-from: type=gha,scope=caddy
|
||||||
|
cache-to: type=gha,mode=max,scope=caddy
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
name: Deploy Nginx
|
||||||
|
|
||||||
|
# Единственный публичный вход (443/80) на прод VPS (backend+лендинг вместе) —
|
||||||
|
# system nginx, тот же принцип, что и в netrunner-data. Только ручной запуск.
|
||||||
|
#
|
||||||
|
# Порядок первого запуска:
|
||||||
|
# 1. Запустить эту джобу (поднимет http-конфиг + сам nginx).
|
||||||
|
# 2. Один раз руками на сервере:
|
||||||
|
# sudo apt-get install -y certbot
|
||||||
|
# sudo certbot certonly --webroot -w /var/www/certbot \
|
||||||
|
# -d account.netrunner-vpn.com
|
||||||
|
# (если лендинг деплоится на этот же VPS первым — можно сразу одной
|
||||||
|
# командой на оба домена, см. deploy-nginx.yml у netrunner-landing)
|
||||||
|
# 3. Запустить эту джобу ещё раз — включит HTTPS.
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
name: Deploy nginx config for account.netrunner-vpn.com
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Sync nginx configs to VPS
|
||||||
|
uses: appleboy/scp-action@v0.1.7
|
||||||
|
with:
|
||||||
|
host: ${{ vars.VPS_IP }}
|
||||||
|
username: root
|
||||||
|
key: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||||
|
source: "nginx/account-http.conf,nginx/account-ssl.conf"
|
||||||
|
target: "/root/netrunner-core"
|
||||||
|
strip_components: 0
|
||||||
|
|
||||||
|
- name: Install/enable nginx via SSH
|
||||||
|
uses: appleboy/ssh-action@v1
|
||||||
|
with:
|
||||||
|
host: ${{ vars.VPS_IP }}
|
||||||
|
username: root
|
||||||
|
key: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||||
|
script: |
|
||||||
|
set -e
|
||||||
|
|
||||||
|
if ! command -v nginx >/dev/null 2>&1; then
|
||||||
|
apt-get update
|
||||||
|
apt-get install -y nginx
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p /var/www/certbot
|
||||||
|
rm -f /etc/nginx/sites-enabled/default
|
||||||
|
|
||||||
|
cp /root/netrunner-core/nginx/account-http.conf /etc/nginx/sites-available/account-http.conf
|
||||||
|
cp /root/netrunner-core/nginx/account-ssl.conf /etc/nginx/sites-available/account-ssl.conf
|
||||||
|
ln -sf /etc/nginx/sites-available/account-http.conf /etc/nginx/sites-enabled/account-http.conf
|
||||||
|
|
||||||
|
CERT=/etc/letsencrypt/live/account.netrunner-vpn.com/fullchain.pem
|
||||||
|
if [ -f "$CERT" ]; then
|
||||||
|
ln -sf /etc/nginx/sites-available/account-ssl.conf /etc/nginx/sites-enabled/account-ssl.conf
|
||||||
|
echo "🔒 Сертификат найден — HTTPS-конфиг включён."
|
||||||
|
else
|
||||||
|
rm -f /etc/nginx/sites-enabled/account-ssl.conf
|
||||||
|
echo "⚠️ Сертификата ещё нет ($CERT) — HTTPS-конфиг НЕ включён."
|
||||||
|
fi
|
||||||
|
|
||||||
|
nginx -t
|
||||||
|
systemctl enable --now nginx
|
||||||
|
systemctl reload nginx
|
||||||
|
|
||||||
|
echo "✅ nginx (account.netrunner-vpn.com) synchronized."
|
||||||
@@ -3,6 +3,11 @@ name: Deploy
|
|||||||
# Один пайплайн, две джобы:
|
# Один пайплайн, две джобы:
|
||||||
# - deploy-dev: авто, срабатывает после успешного "Build & Push Image" на main.
|
# - deploy-dev: авто, срабатывает после успешного "Build & Push Image" на main.
|
||||||
# - deploy-prod: вручную из вкладки Actions ("Run workflow"), как и раньше.
|
# - deploy-prod: вручную из вкладки Actions ("Run workflow"), как и раньше.
|
||||||
|
#
|
||||||
|
# .env на обоих VPS теперь полностью собирается этим workflow из repo
|
||||||
|
# Variables/Secrets (тот же принцип, что и в netrunner-data) — руками на
|
||||||
|
# сервере ничего не создаётся и не редактируется, весь конфиг живёт в
|
||||||
|
# Settings → Actions этого репозитория.
|
||||||
on:
|
on:
|
||||||
workflow_run:
|
workflow_run:
|
||||||
workflows: ["Build & Push Image"]
|
workflows: ["Build & Push Image"]
|
||||||
@@ -25,29 +30,59 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
ref: ${{ github.event.workflow_run.head_sha }}
|
ref: ${{ github.event.workflow_run.head_sha }}
|
||||||
|
|
||||||
# Копируем только compose-файл — сам образ дев-сервер тянет из GHCR,
|
# Копируем compose-файл + Caddyfile (примонтированный конфиг, не образ —
|
||||||
# полный клон репозитория на нём не нужен.
|
# сам Caddy-образ, как и образ бэкенда, дев-сервер тянет из GHCR, собран
|
||||||
|
# в build.yml::build-caddy. Собирать xcaddy прямо на VPS один раз уже не
|
||||||
|
# уложилось в таймаут SSH-деплоя и уронило весь прогон).
|
||||||
- name: Sync compose file to Dev VPS
|
- name: Sync compose file to Dev VPS
|
||||||
uses: appleboy/scp-action@v0.1.7
|
uses: appleboy/scp-action@v0.1.7
|
||||||
with:
|
with:
|
||||||
host: ${{ secrets.DEV_VPS_IP }}
|
host: ${{ vars.DEV_VPS_IP }}
|
||||||
username: root
|
username: root
|
||||||
key: ${{ secrets.DEV_SSH_PRIVATE_KEY }}
|
key: ${{ secrets.DEV_SSH_PRIVATE_KEY }}
|
||||||
source: "docker-compose.dev-remote.yml"
|
source: "docker-compose.dev-remote.yml,Caddyfile.dev"
|
||||||
target: "/root/netrunner-core"
|
target: "/root/netrunner-core"
|
||||||
strip_components: 0
|
strip_components: 0
|
||||||
|
|
||||||
- name: Remote deploy via SSH
|
- name: Write .env and deploy via SSH
|
||||||
uses: appleboy/ssh-action@v1
|
uses: appleboy/ssh-action@v1
|
||||||
with:
|
with:
|
||||||
host: ${{ secrets.DEV_VPS_IP }}
|
host: ${{ vars.DEV_VPS_IP }}
|
||||||
username: root
|
username: root
|
||||||
key: ${{ secrets.DEV_SSH_PRIVATE_KEY }}
|
key: ${{ secrets.DEV_SSH_PRIVATE_KEY }}
|
||||||
|
envs: "DB_PASSWORD,CF_API_TOKEN,JWT_SECRET,ARGON_SALT,TON_MASTER_WALLET,PROXY_INTERNAL_SECRET,CRYPTOBOT_API_TOKEN,TELOXIDE_TOKEN,BOT_USERNAME"
|
||||||
script: |
|
script: |
|
||||||
set -e
|
set -e
|
||||||
cd /root/netrunner-core
|
cd /root/netrunner-core
|
||||||
|
|
||||||
|
# .env — статические значения (никогда не меняются между деплоями)
|
||||||
|
# зашиты здесь же литералом, секреты/domain-специфичное — из vars/secrets.
|
||||||
|
cat > .env <<EOF
|
||||||
|
DB_USER=netrunner_admin
|
||||||
|
DB_PASSWORD=${DB_PASSWORD}
|
||||||
|
DB_NAME=netrunner
|
||||||
|
CF_API_TOKEN=${CF_API_TOKEN}
|
||||||
|
JWT_SECRET=${JWT_SECRET}
|
||||||
|
ARGON_SALT=${ARGON_SALT}
|
||||||
|
TON_MASTER_WALLET=${TON_MASTER_WALLET}
|
||||||
|
PROXY_INTERNAL_SECRET=${PROXY_INTERNAL_SECRET}
|
||||||
|
CRYPTOBOT_API_TOKEN=${CRYPTOBOT_API_TOKEN}
|
||||||
|
CRYPTOBOT_TESTNET=true
|
||||||
|
BOT_ENABLED=true
|
||||||
|
TELOXIDE_TOKEN=${TELOXIDE_TOKEN}
|
||||||
|
BOT_USERNAME=${BOT_USERNAME}
|
||||||
|
WEB_APP_BASE_URL=https://dev.netrunner-vpn.com
|
||||||
|
SUBSCRIPTION_CHECK_INTERVAL=1800
|
||||||
|
RUST_LOG=netrunner_control_plane=debug,axum=info,tower_http=debug,sqlx=warn
|
||||||
|
CORS_ALLOWED_ORIGINS=http://localhost:1420,http://localhost:5173,tauri://localhost,http://tauri.localhost,http://dev.netrunner-vpn.com:3000,https://dev.netrunner-vpn.com
|
||||||
|
CSRF_ALLOWED_ORIGINS=http://localhost:1420,http://localhost:5173,http://dev.netrunner-vpn.com:3000,https://dev.netrunner-vpn.com
|
||||||
|
COOKIE_DOMAIN=
|
||||||
|
COOKIE_SECURE=true
|
||||||
|
EOF
|
||||||
|
chmod 600 .env
|
||||||
|
|
||||||
export IMAGE="ghcr.io/nineap/netrunner-control-plane:latest"
|
export IMAGE="ghcr.io/nineap/netrunner-control-plane:latest"
|
||||||
|
export CADDY_IMAGE="ghcr.io/nineap/netrunner-caddy-cloudflare:latest"
|
||||||
echo "🚀 Deploying ${IMAGE} to DEV"
|
echo "🚀 Deploying ${IMAGE} to DEV"
|
||||||
|
|
||||||
echo "${{ secrets.GHCR_TOKEN }}" | docker login ghcr.io -u ${{ github.repository_owner }} --password-stdin
|
echo "${{ secrets.GHCR_TOKEN }}" | docker login ghcr.io -u ${{ github.repository_owner }} --password-stdin
|
||||||
@@ -58,22 +93,75 @@ jobs:
|
|||||||
docker image prune -f
|
docker image prune -f
|
||||||
|
|
||||||
echo "✅ Dev synchronized."
|
echo "✅ Dev synchronized."
|
||||||
|
env:
|
||||||
|
DB_PASSWORD: ${{ secrets.DEV_DB_PASSWORD }}
|
||||||
|
CF_API_TOKEN: ${{ secrets.DEV_CF_API_TOKEN }}
|
||||||
|
JWT_SECRET: ${{ secrets.DEV_JWT_SECRET }}
|
||||||
|
ARGON_SALT: ${{ secrets.DEV_ARGON_SALT }}
|
||||||
|
TON_MASTER_WALLET: ${{ vars.TON_MASTER_WALLET }}
|
||||||
|
PROXY_INTERNAL_SECRET: ${{ secrets.DEV_PROXY_INTERNAL_SECRET }}
|
||||||
|
CRYPTOBOT_API_TOKEN: ${{ secrets.DEV_CRYPTOBOT_API_TOKEN }}
|
||||||
|
TELOXIDE_TOKEN: ${{ secrets.DEV_TELOXIDE_TOKEN }}
|
||||||
|
BOT_USERNAME: ${{ vars.DEV_BOT_USERNAME }}
|
||||||
|
|
||||||
deploy-prod:
|
deploy-prod:
|
||||||
name: Pull & restart on Prod VPS
|
name: Pull & restart on Prod VPS
|
||||||
if: github.event_name == 'workflow_dispatch'
|
if: github.event_name == 'workflow_dispatch'
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
steps:
|
steps:
|
||||||
- name: Remote deploy via SSH
|
- uses: actions/checkout@v4
|
||||||
uses: appleboy/ssh-action@v1
|
|
||||||
|
- name: Sync compose file to Prod VPS
|
||||||
|
uses: appleboy/scp-action@v0.1.7
|
||||||
with:
|
with:
|
||||||
host: ${{ secrets.VPS_IP }}
|
host: ${{ vars.VPS_IP }}
|
||||||
username: root
|
username: root
|
||||||
key: ${{ secrets.SSH_PRIVATE_KEY }}
|
key: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||||
|
source: "docker-compose.prod.yml"
|
||||||
|
target: "/root/netrunner-core"
|
||||||
|
strip_components: 0
|
||||||
|
|
||||||
|
- name: Write .env and deploy via SSH
|
||||||
|
uses: appleboy/ssh-action@v1
|
||||||
|
with:
|
||||||
|
host: ${{ vars.VPS_IP }}
|
||||||
|
username: root
|
||||||
|
key: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||||
|
envs: "DB_PASSWORD,DATA_VPS_IP,JWT_SECRET,ARGON_SALT,TON_MASTER_WALLET,GRAFANA_PUBLIC_URL,PROXY_INTERNAL_SECRET,CRYPTOBOT_API_TOKEN,TELOXIDE_TOKEN,BOT_USERNAME,WEB_APP_BASE_URL"
|
||||||
script: |
|
script: |
|
||||||
set -e
|
set -e
|
||||||
cd /root/netrunner-core
|
cd /root/netrunner-core
|
||||||
|
|
||||||
|
cat > .env <<EOF
|
||||||
|
DB_USER=netrunner_admin
|
||||||
|
DB_NAME=netrunner
|
||||||
|
DB_PASSWORD=${DB_PASSWORD}
|
||||||
|
DB_HOST=${DATA_VPS_IP}
|
||||||
|
DB_PORT=5432
|
||||||
|
REDIS_URL=redis://redis:6379
|
||||||
|
JWT_SECRET=${JWT_SECRET}
|
||||||
|
ARGON_SALT=${ARGON_SALT}
|
||||||
|
TON_MASTER_WALLET=${TON_MASTER_WALLET}
|
||||||
|
LOKI_PUSH_URL=http://${DATA_VPS_IP}:3100
|
||||||
|
GRAFANA_PUBLIC_URL=${GRAFANA_PUBLIC_URL}
|
||||||
|
CRYPTOBOT_API_TOKEN=${CRYPTOBOT_API_TOKEN}
|
||||||
|
BOT_ENABLED=true
|
||||||
|
TELOXIDE_TOKEN=${TELOXIDE_TOKEN}
|
||||||
|
BOT_USERNAME=${BOT_USERNAME}
|
||||||
|
WEB_APP_BASE_URL=${WEB_APP_BASE_URL}
|
||||||
|
PROXY_INTERNAL_SECRET=${PROXY_INTERNAL_SECRET}
|
||||||
|
PORT=8080
|
||||||
|
SUBSCRIPTION_CHECK_INTERVAL=1800
|
||||||
|
NODE_HEALTH_CHECK_INTERVAL=60
|
||||||
|
APP_ENV=production
|
||||||
|
RUST_LOG=netrunner_control_plane=info,axum=info,tower_http=info,sqlx=warn
|
||||||
|
CORS_ALLOWED_ORIGINS=https://netrunner-vpn.com,https://account.netrunner-vpn.com,tauri://localhost,http://tauri.localhost
|
||||||
|
CSRF_ALLOWED_ORIGINS=https://netrunner-vpn.com,https://account.netrunner-vpn.com
|
||||||
|
COOKIE_DOMAIN=.netrunner-vpn.com
|
||||||
|
COOKIE_SECURE=true
|
||||||
|
EOF
|
||||||
|
chmod 600 .env
|
||||||
|
|
||||||
export IMAGE="ghcr.io/nineap/netrunner-control-plane:${{ github.event.inputs.image_tag }}"
|
export IMAGE="ghcr.io/nineap/netrunner-control-plane:${{ github.event.inputs.image_tag }}"
|
||||||
echo "🚀 Deploying ${IMAGE}"
|
echo "🚀 Deploying ${IMAGE}"
|
||||||
|
|
||||||
@@ -87,3 +175,15 @@ jobs:
|
|||||||
docker image prune -f
|
docker image prune -f
|
||||||
|
|
||||||
echo "✅ Uplink synchronized."
|
echo "✅ Uplink synchronized."
|
||||||
|
env:
|
||||||
|
DB_PASSWORD: ${{ secrets.DB_PASSWORD }}
|
||||||
|
DATA_VPS_IP: ${{ vars.DATA_VPS_IP }}
|
||||||
|
JWT_SECRET: ${{ secrets.JWT_SECRET }}
|
||||||
|
ARGON_SALT: ${{ secrets.ARGON_SALT }}
|
||||||
|
TON_MASTER_WALLET: ${{ vars.TON_MASTER_WALLET }}
|
||||||
|
GRAFANA_PUBLIC_URL: ${{ vars.GRAFANA_PUBLIC_URL }}
|
||||||
|
PROXY_INTERNAL_SECRET: ${{ secrets.PROXY_INTERNAL_SECRET }}
|
||||||
|
CRYPTOBOT_API_TOKEN: ${{ secrets.CRYPTOBOT_API_TOKEN }}
|
||||||
|
TELOXIDE_TOKEN: ${{ secrets.TELOXIDE_TOKEN }}
|
||||||
|
BOT_USERNAME: ${{ vars.BOT_USERNAME }}
|
||||||
|
WEB_APP_BASE_URL: ${{ vars.WEB_APP_BASE_URL }}
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# Реверс-прокси с реальным TLS для dev VPS — нужен и потому, что Telegram Web
|
||||||
|
# Apps (кнопки бота "Личный Кабинет"/"Тарифы"/"Синдикат") требуют https:// URL
|
||||||
|
# (см. teloxide WebAppInfo::url), и потому, что Telegram Login Widget в
|
||||||
|
# обычном браузере требует именно порт 443 — его CSP `frame-ancestors` от
|
||||||
|
# oauth.telegram.org не учитывает порт в зарегистрированном в BotFather домене
|
||||||
|
# и всегда подразумевает дефолтный порт схемы (443 для https); на 8443 виджет
|
||||||
|
# рендерился, но браузер блокировал iframe как cross-origin.
|
||||||
|
#
|
||||||
|
# Порт 80 на этом VPS закрыт — обычный HTTP-01 ACME challenge не пройдёт,
|
||||||
|
# поэтому TLS через DNS-01 (Cloudflare, см. Dockerfile.caddy), которому
|
||||||
|
# открытые 80/443 не нужны для самого выпуска сертификата.
|
||||||
|
dev.netrunner-vpn.com {
|
||||||
|
tls {
|
||||||
|
dns cloudflare {env.CF_API_TOKEN}
|
||||||
|
}
|
||||||
|
reverse_proxy app:8080
|
||||||
|
}
|
||||||
Generated
+90
@@ -2,6 +2,12 @@
|
|||||||
# It is not intended for manual editing.
|
# It is not intended for manual editing.
|
||||||
version = 4
|
version = 4
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "adler2"
|
||||||
|
version = "2.0.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ahash"
|
name = "ahash"
|
||||||
version = "0.7.8"
|
version = "0.7.8"
|
||||||
@@ -649,6 +655,15 @@ version = "2.4.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5"
|
checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crc32fast"
|
||||||
|
version = "1.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crossbeam-epoch"
|
name = "crossbeam-epoch"
|
||||||
version = "0.9.18"
|
version = "0.9.18"
|
||||||
@@ -1056,6 +1071,16 @@ version = "0.1.9"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
|
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "flate2"
|
||||||
|
version = "1.1.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
|
||||||
|
dependencies = [
|
||||||
|
"crc32fast",
|
||||||
|
"miniz_oxide",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "flume"
|
name = "flume"
|
||||||
version = "0.11.1"
|
version = "0.11.1"
|
||||||
@@ -1856,6 +1881,15 @@ version = "2.12.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
|
checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ipnetwork"
|
||||||
|
version = "0.20.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bf466541e9d546596ee94f9f69590f89473455f88372423e0008fc1a7daf100e"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "iri-string"
|
name = "iri-string"
|
||||||
version = "0.7.12"
|
version = "0.7.12"
|
||||||
@@ -2055,6 +2089,18 @@ version = "0.8.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
|
checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "maxminddb"
|
||||||
|
version = "0.24.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d6087e5d8ea14861bb7c7f573afbc7be3798d3ef0fae87ec4fd9a4de9a127c3c"
|
||||||
|
dependencies = [
|
||||||
|
"ipnetwork",
|
||||||
|
"log",
|
||||||
|
"memchr",
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "md-5"
|
name = "md-5"
|
||||||
version = "0.10.6"
|
version = "0.10.6"
|
||||||
@@ -2135,6 +2181,16 @@ dependencies = [
|
|||||||
"unicase",
|
"unicase",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "miniz_oxide"
|
||||||
|
version = "0.8.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
|
||||||
|
dependencies = [
|
||||||
|
"adler2",
|
||||||
|
"simd-adler32",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mio"
|
name = "mio"
|
||||||
version = "1.2.0"
|
version = "1.2.0"
|
||||||
@@ -2173,14 +2229,18 @@ dependencies = [
|
|||||||
"chrono",
|
"chrono",
|
||||||
"clap",
|
"clap",
|
||||||
"dotenvy",
|
"dotenvy",
|
||||||
|
"flate2",
|
||||||
"hex",
|
"hex",
|
||||||
"hmac 0.13.0",
|
"hmac 0.13.0",
|
||||||
|
"ipnet",
|
||||||
"jsonwebtoken",
|
"jsonwebtoken",
|
||||||
|
"maxminddb",
|
||||||
"metrics",
|
"metrics",
|
||||||
"metrics-exporter-prometheus",
|
"metrics-exporter-prometheus",
|
||||||
"rand 0.8.6",
|
"rand 0.8.6",
|
||||||
"redis",
|
"redis",
|
||||||
"reqwest 0.11.27",
|
"reqwest 0.11.27",
|
||||||
|
"rpassword",
|
||||||
"rust_decimal",
|
"rust_decimal",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
@@ -3035,6 +3095,17 @@ dependencies = [
|
|||||||
"syn 1.0.109",
|
"syn 1.0.109",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rpassword"
|
||||||
|
version = "7.5.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2da316a15f47e3d053de9cb2c439650bd8fa4aaeb9365f2e5f27f492ff73c196"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"rtoolbox",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rsa"
|
name = "rsa"
|
||||||
version = "0.9.10"
|
version = "0.9.10"
|
||||||
@@ -3055,6 +3126,16 @@ dependencies = [
|
|||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rtoolbox"
|
||||||
|
version = "0.0.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "50a0e551c1e27e1731aba276dbeaeac73f53c7cd34d1bda485d02bd1e0f36844"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"windows-sys 0.59.0",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rust_decimal"
|
name = "rust_decimal"
|
||||||
version = "1.42.0"
|
version = "1.42.0"
|
||||||
@@ -3429,6 +3510,12 @@ dependencies = [
|
|||||||
"rand_core 0.6.4",
|
"rand_core 0.6.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "simd-adler32"
|
||||||
|
version = "0.3.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "simdutf8"
|
name = "simdutf8"
|
||||||
version = "0.1.5"
|
version = "0.1.5"
|
||||||
@@ -3553,6 +3640,7 @@ dependencies = [
|
|||||||
"memchr",
|
"memchr",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
|
"rust_decimal",
|
||||||
"rustls",
|
"rustls",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
@@ -3637,6 +3725,7 @@ dependencies = [
|
|||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"rand 0.8.6",
|
"rand 0.8.6",
|
||||||
"rsa",
|
"rsa",
|
||||||
|
"rust_decimal",
|
||||||
"serde",
|
"serde",
|
||||||
"sha1",
|
"sha1",
|
||||||
"sha2 0.10.9",
|
"sha2 0.10.9",
|
||||||
@@ -3676,6 +3765,7 @@ dependencies = [
|
|||||||
"memchr",
|
"memchr",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"rand 0.8.6",
|
"rand 0.8.6",
|
||||||
|
"rust_decimal",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sha2 0.10.9",
|
"sha2 0.10.9",
|
||||||
|
|||||||
+10
-2
@@ -12,7 +12,7 @@ tower_governor = "0.8"
|
|||||||
teloxide = { version = "0.17", features = ["macros", "rustls"] }
|
teloxide = { version = "0.17", features = ["macros", "rustls"] }
|
||||||
|
|
||||||
# 🔥 ИСПРАВЛЕНО: Включаем "migrate" для автоматического создания баз в тестах
|
# 🔥 ИСПРАВЛЕНО: Включаем "migrate" для автоматического создания баз в тестах
|
||||||
sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "postgres", "uuid", "chrono", "migrate"] }
|
sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "postgres", "uuid", "chrono", "migrate", "rust_decimal"] }
|
||||||
redis = { version = "1.2.0", features = ["tokio-comp", "aio", "connection-manager"] }
|
redis = { version = "1.2.0", features = ["tokio-comp", "aio", "connection-manager"] }
|
||||||
|
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
@@ -37,12 +37,20 @@ clap = { version = "4.6.1", features = ["derive"] }
|
|||||||
ssh2 = "0.9.5"
|
ssh2 = "0.9.5"
|
||||||
rand = "0.8"
|
rand = "0.8"
|
||||||
argon2 = "0.5"
|
argon2 = "0.5"
|
||||||
|
rpassword = "7.5"
|
||||||
rust_decimal = { version = "1.42.0", features = ["serde"] }
|
rust_decimal = { version = "1.42.0", features = ["serde"] }
|
||||||
reqwest = { version = "0.11", features = ["json"] }
|
reqwest = { version = "0.11", features = ["json"] }
|
||||||
|
maxminddb = "0.24"
|
||||||
|
ipnet = "2.10"
|
||||||
|
flate2 = "1.0"
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
opt-level = 3
|
opt-level = 3
|
||||||
lto = "thin"
|
lto = "thin"
|
||||||
codegen-units = 16
|
codegen-units = 16
|
||||||
panic = "abort"
|
# НЕ panic="abort": по умолчанию tokio ловит панику отдельной задачи (одного
|
||||||
|
# запроса) через catch_unwind на границе задачи и превращает её в JoinError —
|
||||||
|
# остальные соединения продолжают жить. panic="abort" эту изоляцию отключает:
|
||||||
|
# любая паника где угодно в обработчике одного запроса мгновенно убивает весь
|
||||||
|
# процесс и рвёт всех подключённых пользователей разом.
|
||||||
strip = true
|
strip = true
|
||||||
|
|||||||
+20
-5
@@ -61,6 +61,11 @@ Dev-окружение отдельного ручного шага не тре
|
|||||||
|
|
||||||
`GITHUB_TOKEN` для push в GHCR выдаётся автоматически.
|
`GITHUB_TOKEN` для push в GHCR выдаётся автоматически.
|
||||||
|
|
||||||
|
Отдельно на самом dev VPS (не GitHub-секрет, а переменная в `.env` рядом с
|
||||||
|
`docker-compose.dev-remote.yml`) — `CF_API_TOKEN`: Cloudflare-токен для Caddy
|
||||||
|
(DNS-01 challenge, см. ниже), шаблон "Edit zone DNS", ограниченный зоной
|
||||||
|
`netrunner-vpn.com`. Не Global API Key.
|
||||||
|
|
||||||
### Что должно лежать на VPS (`/root/netrunner-core`)
|
### Что должно лежать на VPS (`/root/netrunner-core`)
|
||||||
|
|
||||||
- `docker-compose.prod.yml`
|
- `docker-compose.prod.yml`
|
||||||
@@ -73,14 +78,24 @@ Dev-окружение отдельного ручного шага не тре
|
|||||||
|
|
||||||
### Что должно лежать на dev VPS (`/root/netrunner-core`)
|
### Что должно лежать на dev VPS (`/root/netrunner-core`)
|
||||||
|
|
||||||
- `docker-compose.dev-remote.yml`
|
- `docker-compose.dev-remote.yml`, `Dockerfile.caddy`, `Caddyfile.dev` (все три
|
||||||
|
синкает CI при каждом `deploy-dev`, руками копировать не нужно)
|
||||||
- `.env` (см. [`.env.dev-remote.example`](.env.dev-remote.example) — свои
|
- `.env` (см. [`.env.dev-remote.example`](.env.dev-remote.example) — свои
|
||||||
`JWT_SECRET`/`ARGON_SALT`/`DB_PASSWORD`, `CORS_ALLOWED_ORIGINS`/`CSRF_ALLOWED_ORIGINS`
|
`JWT_SECRET`/`ARGON_SALT`/`DB_PASSWORD`, `CF_API_TOKEN`,
|
||||||
включают origin dev-лендинга на этом же хосте)
|
`CORS_ALLOWED_ORIGINS`/`CSRF_ALLOWED_ORIGINS` включают origin dev-лендинга и
|
||||||
|
Caddy-порта на этом же хосте)
|
||||||
- `keys/netrunner_master_ed25519[.pub]` — свой мастер-ключ, отдельный от прода
|
- `keys/netrunner_master_ed25519[.pub]` — свой мастер-ключ, отдельный от прода
|
||||||
|
|
||||||
Dev-стек без мониторинга: `migrate` → `app` (порт `8080`, публично, без reverse-proxy)
|
Dev-стек: `migrate` → `app` (порт `8080`, обычный HTTP, без TLS) + `db` + `redis`
|
||||||
+ `db` + `redis`.
|
+ `caddy` (порт `443`, реальный TLS через DNS-01/Cloudflare — см.
|
||||||
|
[`Caddyfile.dev`](Caddyfile.dev)). Caddy здесь нужен по двум причинам: Telegram
|
||||||
|
Web Apps (кнопки бота "Личный Кабинет"/"Тарифы"/"Синдикат") требуют `https://`
|
||||||
|
URL, а Telegram Login Widget в обычном браузере требует именно порт 443 (его
|
||||||
|
CSP `frame-ancestors` не учитывает порт и всегда подразумевает дефолтный порт
|
||||||
|
схемы). Порт 80 на этом VPS закрыт — обычный HTTP-01 challenge не проходит,
|
||||||
|
поэтому DNS-01. Все браузерные auth-флоу (бот, лендинг) должны идти через
|
||||||
|
Caddy на 443, не напрямую в `:8080` — `COOKIE_SECURE=true` (как в проде), и
|
||||||
|
Secure-cookie по голому HTTP на `:8080` браузер по-прежнему отбросит.
|
||||||
|
|
||||||
### Локальная сборка образа (фолбэк, обычно не нужно)
|
### Локальная сборка образа (фолбэк, обычно не нужно)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# syntax=docker/dockerfile:1
|
||||||
|
# Кастомная сборка Caddy с DNS-провайдером Cloudflare — нужна для DNS-01
|
||||||
|
# ACME challenge на dev VPS, где порты 80/443 недоступны/заняты (443 —
|
||||||
|
# VPN-нодой, 80 закрыт). DNS-01 не требует открытых входящих 80/443 вообще.
|
||||||
|
FROM caddy:2-builder AS builder
|
||||||
|
RUN xcaddy build --with github.com/caddy-dns/cloudflare
|
||||||
|
|
||||||
|
FROM caddy:2
|
||||||
|
COPY --from=builder /usr/bin/caddy /usr/bin/caddy
|
||||||
@@ -69,6 +69,28 @@ make dev # docker compose: db + redis + app с hot-reload (cargo
|
|||||||
Приложение поднимется на `http://localhost:8080` (`/health`, `/health/ready`).
|
Приложение поднимется на `http://localhost:8080` (`/health`, `/health/ready`).
|
||||||
Остальные команды — `make help`.
|
Остальные команды — `make help`.
|
||||||
|
|
||||||
|
## Создание первого Owner-аккаунта админки
|
||||||
|
|
||||||
|
Единственный способ попасть в `/admin/login` (React-админка) — учётка с ролью
|
||||||
|
`owner`, логин+пароль (не Telegram). Создаётся one-shot CLI-флагом
|
||||||
|
`--create-owner` (нужен только `DATABASE_URL`, ни ключей, ни токенов;
|
||||||
|
пароль вводится скрыто в консоли, не через аргумент/env — не оседает в
|
||||||
|
истории шелла):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Локально/дев (бинарник собран, .env заполнен):
|
||||||
|
./netrunner-control-plane --create-owner --username admin
|
||||||
|
|
||||||
|
# На проде (задеплоено через docker-compose.prod.yml) — выполнить на самом
|
||||||
|
# VPS из /root/netrunner-core:
|
||||||
|
docker compose -f docker-compose.prod.yml run --rm app \
|
||||||
|
./netrunner-control-plane --create-owner --username admin
|
||||||
|
```
|
||||||
|
|
||||||
|
`docker compose run` (не `up`) — контейнер разовый, не публикует порт 8080,
|
||||||
|
не конфликтует с уже работающим `app`. Логин уже занят — упадёт с понятной
|
||||||
|
ошибкой, а не тихо перезапишет существующего владельца.
|
||||||
|
|
||||||
## Nodes, Cyberhack, биллинг — текущее состояние
|
## Nodes, Cyberhack, биллинг — текущее состояние
|
||||||
|
|
||||||
- **`GET /nodes/routing`** отдаёт полный `TunnelConfig`: `tunnel_*`-поля,
|
- **`GET /nodes/routing`** отдаёт полный `TunnelConfig`: `tunnel_*`-поля,
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
services:
|
services:
|
||||||
# One-shot: применяет миграции и завершается. Требует только DATABASE_URL.
|
# One-shot: применяет миграции и завершается. Требует только DATABASE_URL.
|
||||||
migrate:
|
migrate:
|
||||||
image: ${IMAGE:-ghcr.io/nineap/netrunner-control-plane:latest}
|
image: ${IMAGE:-gitea.netrunner-vpn.com/nineap/netrunner-control-plane:latest}
|
||||||
container_name: netrunner-migrate-dev
|
container_name: netrunner-migrate-dev
|
||||||
command: ["./netrunner-control-plane", "--migrate"]
|
command: ["./netrunner-control-plane", "--migrate"]
|
||||||
environment:
|
environment:
|
||||||
@@ -19,7 +19,7 @@ services:
|
|||||||
- netrunner_grid_dev
|
- netrunner_grid_dev
|
||||||
|
|
||||||
app:
|
app:
|
||||||
image: ${IMAGE:-ghcr.io/nineap/netrunner-control-plane:latest}
|
image: ${IMAGE:-gitea.netrunner-vpn.com/nineap/netrunner-control-plane:latest}
|
||||||
container_name: netrunner-app-dev-remote
|
container_name: netrunner-app-dev-remote
|
||||||
restart: always
|
restart: always
|
||||||
ports:
|
ports:
|
||||||
@@ -76,9 +76,36 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- netrunner_grid_dev
|
- netrunner_grid_dev
|
||||||
|
|
||||||
|
# Реверс-прокси с реальным TLS (DNS-01 через Cloudflare) — нужен для
|
||||||
|
# Telegram Web Apps (требуют https://) и для Telegram Login Widget в
|
||||||
|
# обычном браузере (требует именно порт 443 — см. Caddyfile.dev). VPN-нода,
|
||||||
|
# которая раньше занимала 443 на этом VPS, перенесена на другой порт.
|
||||||
|
#
|
||||||
|
# Образ собирается в GitHub Actions (build.yml::build-caddy), не на VPS:
|
||||||
|
# компиляция xcaddy (Go, тянет много модулей) на этой машине один раз уже
|
||||||
|
# не уложилась в таймаут SSH-деплоя и уронила весь прогон.
|
||||||
|
caddy:
|
||||||
|
image: ${CADDY_IMAGE:-gitea.netrunner-vpn.com/nineap/netrunner-caddy-cloudflare:latest}
|
||||||
|
container_name: netrunner-caddy-dev
|
||||||
|
restart: always
|
||||||
|
ports:
|
||||||
|
- "443:443"
|
||||||
|
environment:
|
||||||
|
- CF_API_TOKEN=${CF_API_TOKEN}
|
||||||
|
volumes:
|
||||||
|
- ./Caddyfile.dev:/etc/caddy/Caddyfile:ro
|
||||||
|
- caddy_data_dev:/data
|
||||||
|
- caddy_config_dev:/config
|
||||||
|
depends_on:
|
||||||
|
- app
|
||||||
|
networks:
|
||||||
|
- netrunner_grid_dev
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
netrunner_grid_dev:
|
netrunner_grid_dev:
|
||||||
driver: bridge
|
driver: bridge
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
netrunner_db_data_dev:
|
netrunner_db_data_dev:
|
||||||
|
caddy_data_dev:
|
||||||
|
caddy_config_dev:
|
||||||
|
|||||||
+25
-76
@@ -1,24 +1,30 @@
|
|||||||
# =====================================================================
|
# =====================================================================
|
||||||
# PRODUCTION — VPS только тянет готовый образ из GHCR и запускает его.
|
# PRODUCTION — VPS только тянет готовый образ из GHCR и запускает его.
|
||||||
# Никакой компиляции Rust на сервере. Деплой = pull + migrate + up.
|
# Никакой компиляции Rust на сервере. Деплой = pull + migrate + up.
|
||||||
|
#
|
||||||
|
# ВАЖНО: своего Postgres здесь больше нет. БД физически стоит на отдельном
|
||||||
|
# VPS (см. netrunner-data/docker-compose.yml — там же Vaultwarden, тот же
|
||||||
|
# Postgres-процесс, вторая база) и слушает по сети, не в одном
|
||||||
|
# Docker-стеке с этим compose. DATABASE_URL ниже указывает на DB_HOST:DB_PORT
|
||||||
|
# (внешний IP того VPS), не на "db" как раньше — и требует sslmode=require,
|
||||||
|
# т.к. трафик идёт по публичной сети между двумя разными серверами. Бэкапы
|
||||||
|
# тоже переехали туда же (netrunner-data/scripts/backup.sh) — дампить
|
||||||
|
# логичнее там, где физически лежат данные.
|
||||||
# =====================================================================
|
# =====================================================================
|
||||||
services:
|
services:
|
||||||
# One-shot: применяет миграции и завершается. Требует только DATABASE_URL.
|
# One-shot: применяет миграции и завершается. Требует только DATABASE_URL.
|
||||||
migrate:
|
migrate:
|
||||||
image: ${IMAGE:-ghcr.io/nineap/netrunner-control-plane:latest}
|
image: ${IMAGE:-gitea.netrunner-vpn.com/nineap/netrunner-control-plane:latest}
|
||||||
container_name: netrunner-migrate
|
container_name: netrunner-migrate
|
||||||
command: ["./netrunner-control-plane", "--migrate"]
|
command: ["./netrunner-control-plane", "--migrate"]
|
||||||
environment:
|
environment:
|
||||||
- DATABASE_URL=postgres://${DB_USER}:${DB_PASSWORD}@db:5432/${DB_NAME}
|
- DATABASE_URL=postgres://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT:-5432}/${DB_NAME}?sslmode=require
|
||||||
depends_on:
|
|
||||||
db:
|
|
||||||
condition: service_healthy
|
|
||||||
restart: "no"
|
restart: "no"
|
||||||
networks:
|
networks:
|
||||||
- netrunner_grid
|
- netrunner_grid
|
||||||
|
|
||||||
app:
|
app:
|
||||||
image: ${IMAGE:-ghcr.io/nineap/netrunner-control-plane:latest}
|
image: ${IMAGE:-gitea.netrunner-vpn.com/nineap/netrunner-control-plane:latest}
|
||||||
container_name: netrunner-app
|
container_name: netrunner-app
|
||||||
restart: always
|
restart: always
|
||||||
ports:
|
ports:
|
||||||
@@ -28,12 +34,10 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
- APP_ENV=production
|
- APP_ENV=production
|
||||||
- FRONTEND_DIR=/app/frontend/dist
|
- FRONTEND_DIR=/app/frontend/dist
|
||||||
- DATABASE_URL=postgres://${DB_USER}:${DB_PASSWORD}@db:5432/${DB_NAME}
|
- DATABASE_URL=postgres://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT:-5432}/${DB_NAME}?sslmode=require
|
||||||
volumes:
|
volumes:
|
||||||
- ./keys:/app/keys:ro
|
- ./keys:/app/keys:ro
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
|
||||||
condition: service_healthy
|
|
||||||
redis:
|
redis:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
migrate:
|
migrate:
|
||||||
@@ -47,24 +51,6 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- netrunner_grid
|
- netrunner_grid
|
||||||
|
|
||||||
db:
|
|
||||||
image: postgres:16-alpine
|
|
||||||
container_name: netrunner-db
|
|
||||||
restart: always
|
|
||||||
environment:
|
|
||||||
POSTGRES_PASSWORD: ${DB_PASSWORD}
|
|
||||||
POSTGRES_USER: ${DB_USER}
|
|
||||||
POSTGRES_DB: ${DB_NAME}
|
|
||||||
volumes:
|
|
||||||
- netrunner_db_data:/var/lib/postgresql/data
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD-SHELL", "pg_isready -U ${DB_USER} -d ${DB_NAME}"]
|
|
||||||
interval: 5s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 5
|
|
||||||
networks:
|
|
||||||
- netrunner_grid
|
|
||||||
|
|
||||||
redis:
|
redis:
|
||||||
image: redis:7-alpine
|
image: redis:7-alpine
|
||||||
container_name: netrunner-redis
|
container_name: netrunner-redis
|
||||||
@@ -76,68 +62,31 @@ services:
|
|||||||
- netrunner_grid
|
- netrunner_grid
|
||||||
|
|
||||||
# ==========================================
|
# ==========================================
|
||||||
# МОНИТОРИНГ
|
# НАБЛЮДАЕМОСТЬ
|
||||||
# ==========================================
|
# ==========================================
|
||||||
prometheus:
|
# Prometheus/Loki/Grafana больше НЕ здесь — единый стек теперь на VPS с
|
||||||
image: prom/prometheus:latest
|
# данными (netrunner-data/docker-compose.observability.yml), т.к. ему
|
||||||
container_name: netrunner-prometheus
|
# всё равно нужно видеть лендинг и все прокси-ноды, не только бэкенд.
|
||||||
volumes:
|
# /metrics бэкенда (см. src/api.rs) — тот central Prometheus скрейпит
|
||||||
- ./prometheus.yml:/etc/prometheus/prometheus.yml:ro
|
# удалённо; отсюда только пуш логов через тонкий promtail.
|
||||||
ports:
|
|
||||||
- "127.0.0.1:9090:9090"
|
|
||||||
restart: always
|
|
||||||
networks:
|
|
||||||
- netrunner_grid
|
|
||||||
|
|
||||||
loki:
|
|
||||||
image: grafana/loki:latest
|
|
||||||
container_name: netrunner-loki
|
|
||||||
volumes:
|
|
||||||
- ./loki-config.yml:/etc/loki/local-config.yaml:ro
|
|
||||||
ports:
|
|
||||||
- "127.0.0.1:3100:3100"
|
|
||||||
command: -config.file=/etc/loki/local-config.yaml
|
|
||||||
restart: always
|
|
||||||
networks:
|
|
||||||
- netrunner_grid
|
|
||||||
|
|
||||||
promtail:
|
promtail:
|
||||||
image: grafana/promtail:latest
|
image: grafana/promtail:latest
|
||||||
container_name: netrunner-promtail
|
container_name: netrunner-promtail
|
||||||
|
environment:
|
||||||
|
- LOKI_PUSH_URL=${LOKI_PUSH_URL}
|
||||||
volumes:
|
volumes:
|
||||||
- ./promtail-config.yml:/etc/promtail/config.yml:ro
|
- ./promtail-config.yml:/etc/promtail/config.yml:ro
|
||||||
- /var/lib/docker/containers:/var/lib/docker/containers:ro
|
- /var/lib/docker/containers:/var/lib/docker/containers:ro
|
||||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||||
command: -config.file=/etc/promtail/config.yml
|
# -config.expand-env: без него promtail не подставит ${LOKI_PUSH_URL}
|
||||||
|
# внутри самого config.yml — сама переменная передаётся выше через
|
||||||
|
# environment, файл конфига читает её только с этим флагом.
|
||||||
|
command: -config.file=/etc/promtail/config.yml -config.expand-env=true
|
||||||
restart: always
|
restart: always
|
||||||
user: root
|
user: root
|
||||||
depends_on:
|
|
||||||
- loki
|
|
||||||
networks:
|
|
||||||
- netrunner_grid
|
|
||||||
|
|
||||||
grafana:
|
|
||||||
image: grafana/grafana:latest
|
|
||||||
container_name: netrunner-grafana
|
|
||||||
ports:
|
|
||||||
- "127.0.0.1:3030:3000"
|
|
||||||
environment:
|
|
||||||
- GF_SECURITY_ADMIN_USER=admin
|
|
||||||
- GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_PASSWORD}
|
|
||||||
volumes:
|
|
||||||
- grafana_data:/var/lib/grafana
|
|
||||||
- ./grafana-datasources.yml:/etc/grafana/provisioning/datasources/datasources.yml:ro
|
|
||||||
depends_on:
|
|
||||||
- prometheus
|
|
||||||
- loki
|
|
||||||
restart: always
|
|
||||||
networks:
|
networks:
|
||||||
- netrunner_grid
|
- netrunner_grid
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
netrunner_grid:
|
netrunner_grid:
|
||||||
driver: bridge
|
driver: bridge
|
||||||
|
|
||||||
volumes:
|
|
||||||
netrunner_db_data:
|
|
||||||
grafana_data:
|
|
||||||
|
|||||||
+8
-1
@@ -39,4 +39,11 @@ pnpm lint
|
|||||||
```
|
```
|
||||||
|
|
||||||
Переменные окружения (`.env.production` / Vite `import.meta.env`):
|
Переменные окружения (`.env.production` / Vite `import.meta.env`):
|
||||||
`VITE_API_BASE` (по умолчанию `/api/v1`), `VITE_BOT_USERNAME`.
|
`VITE_API_BASE` (по умолчанию `/api/v1`).
|
||||||
|
|
||||||
|
Юзернейм бота (для Telegram Login Widget и реферальной ссылки) **не** build-time
|
||||||
|
переменная — этот SPA собирается один раз в CI и раздаётся одинаково и прод-, и
|
||||||
|
dev-стендом, так что зашитое в бандл значение не различалось бы между ними. Вместо
|
||||||
|
`VITE_BOT_USERNAME` `Auth.tsx`/`Profile.tsx` берут его рантаймом через
|
||||||
|
`GET /api/v1/config` (`api.ts::getPublicConfig`), который читает `BOT_USERNAME` из
|
||||||
|
окружения сервера заново на каждый запрос.
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { NavLink, Outlet, useLocation, useNavigate } from "react-router-dom";
|
||||||
|
import { Loader2, Server, Users, Wallet, DollarSign, LogOut, LifeBuoy, Activity } from "lucide-react";
|
||||||
|
import { fetchMyRole, logout, type StaffRole } from "./api";
|
||||||
|
|
||||||
|
const STAFF_ROLES = ["owner", "moderator", "partner", "support"];
|
||||||
|
|
||||||
|
export default function AdminLayout() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
const [role, setRole] = useState<StaffRole | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchMyRole()
|
||||||
|
.then((r) => {
|
||||||
|
if (!STAFF_ROLES.includes(r.role)) {
|
||||||
|
navigate("/admin/login");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Support не видит "Ноды" (индекс-роут /admin) — после логина сразу
|
||||||
|
// на единственный раздел, который ему доступен.
|
||||||
|
if (r.role === "support" && location.pathname === "/admin") {
|
||||||
|
navigate("/admin/tickets", { replace: true });
|
||||||
|
}
|
||||||
|
setRole(r);
|
||||||
|
})
|
||||||
|
.catch(() => navigate("/admin/login"))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-[#05050A]">
|
||||||
|
<Loader2 className="w-8 h-8 text-primary animate-spin" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!role) return null;
|
||||||
|
|
||||||
|
const navClass = ({ isActive }: { isActive: boolean }) =>
|
||||||
|
`flex items-center gap-2 px-4 py-2.5 rounded-lg text-xs font-bold uppercase tracking-widest transition-colors ${
|
||||||
|
isActive
|
||||||
|
? "bg-primary/10 text-primary"
|
||||||
|
: "text-muted-foreground hover:bg-white/5 hover:text-foreground"
|
||||||
|
}`;
|
||||||
|
|
||||||
|
const canSeeNodes = role.role === "owner" || (role.role === "moderator" && role.can_manage_nodes);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-[#05050A] font-mono">
|
||||||
|
<nav className="border-b border-white/10 px-6 py-4 flex items-center justify-between gap-4 flex-wrap">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
{canSeeNodes && (
|
||||||
|
<NavLink to="/admin" end className={navClass}>
|
||||||
|
<Server className="w-4 h-4" /> Ноды
|
||||||
|
</NavLink>
|
||||||
|
)}
|
||||||
|
{role.role === "owner" && (
|
||||||
|
<NavLink to="/admin/staff" className={navClass}>
|
||||||
|
<Users className="w-4 h-4" /> Staff
|
||||||
|
</NavLink>
|
||||||
|
)}
|
||||||
|
{role.role === "moderator" && (
|
||||||
|
<NavLink to="/admin/staff" className={navClass}>
|
||||||
|
<Users className="w-4 h-4" /> Партнёры
|
||||||
|
</NavLink>
|
||||||
|
)}
|
||||||
|
{(role.role === "owner" || role.role === "moderator") && (
|
||||||
|
<NavLink to="/admin/withdrawals" className={navClass}>
|
||||||
|
<Wallet className="w-4 h-4" /> Выводы
|
||||||
|
</NavLink>
|
||||||
|
)}
|
||||||
|
{(role.role === "owner" || role.role === "moderator" || role.role === "support") && (
|
||||||
|
<NavLink to="/admin/tickets" className={navClass}>
|
||||||
|
<LifeBuoy className="w-4 h-4" /> Тикеты
|
||||||
|
</NavLink>
|
||||||
|
)}
|
||||||
|
{(role.role === "owner" || role.role === "moderator") && (
|
||||||
|
<NavLink to="/admin/observability" className={navClass}>
|
||||||
|
<Activity className="w-4 h-4" /> Мониторинг
|
||||||
|
</NavLink>
|
||||||
|
)}
|
||||||
|
{role.role === "partner" && (
|
||||||
|
<NavLink to="/admin/earnings" className={navClass}>
|
||||||
|
<DollarSign className="w-4 h-4" /> Доходы
|
||||||
|
</NavLink>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={logout}
|
||||||
|
className="flex items-center gap-2 px-3 py-2 rounded-lg text-xs font-bold uppercase tracking-widest text-muted-foreground hover:bg-white/5 hover:text-red-500 transition-colors"
|
||||||
|
>
|
||||||
|
<LogOut className="w-4 h-4" /> Выход
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
<Outlet context={role} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { Shield, Loader2 } from "lucide-react";
|
||||||
|
import { adminLogin } from "./api";
|
||||||
|
|
||||||
|
export default function AdminLogin() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [username, setUsername] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError("");
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await adminLogin(username, password);
|
||||||
|
navigate("/admin");
|
||||||
|
} catch {
|
||||||
|
setError("Неверный логин или пароль.");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-[#05050A] font-mono px-4">
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
className="w-full max-w-sm bg-white/[0.02] border border-white/10 rounded-2xl p-8 space-y-6"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col items-center gap-3 text-center">
|
||||||
|
<Shield className="w-10 h-10 text-primary" />
|
||||||
|
<h1 className="text-lg font-black tracking-widest text-primary uppercase">
|
||||||
|
Admin Access
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-[10px] text-muted-foreground uppercase tracking-widest">
|
||||||
|
Логин
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
required
|
||||||
|
autoFocus
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
className="w-full bg-white/5 border border-white/10 rounded-lg p-3 text-sm outline-none focus:border-primary transition-colors"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-[10px] text-muted-foreground uppercase tracking-widest">
|
||||||
|
Пароль
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
required
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
className="w-full bg-white/5 border border-white/10 rounded-lg p-3 text-sm outline-none focus:border-primary transition-colors"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full bg-primary/10 hover:bg-primary/20 text-primary border border-primary/30 font-bold uppercase tracking-widest text-xs py-3 rounded-lg transition-all flex items-center justify-center gap-2 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loading ? <Loader2 className="w-4 h-4 animate-spin" /> : "Войти"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+16
-1
@@ -7,6 +7,13 @@ const Profile = lazy(() => import("./Profile"));
|
|||||||
const HackGame = lazy(() => import("./HackGame"));
|
const HackGame = lazy(() => import("./HackGame"));
|
||||||
const AdminDashboard = lazy(() => import("./AdminDashboard"));
|
const AdminDashboard = lazy(() => import("./AdminDashboard"));
|
||||||
const AuthClient = lazy(() => import("./Auth"));
|
const AuthClient = lazy(() => import("./Auth"));
|
||||||
|
const AdminLogin = lazy(() => import("./AdminLogin"));
|
||||||
|
const AdminLayout = lazy(() => import("./AdminLayout"));
|
||||||
|
const StaffPage = lazy(() => import("./StaffPage"));
|
||||||
|
const WithdrawalsPage = lazy(() => import("./WithdrawalsPage"));
|
||||||
|
const EarningsPage = lazy(() => import("./EarningsPage"));
|
||||||
|
const TicketsPage = lazy(() => import("./TicketsPage"));
|
||||||
|
const ObservabilityPage = lazy(() => import("./ObservabilityPage"));
|
||||||
|
|
||||||
const DICT = {
|
const DICT = {
|
||||||
en: { loading: "Loading protocols..." },
|
en: { loading: "Loading protocols..." },
|
||||||
@@ -83,7 +90,15 @@ export default function App() {
|
|||||||
<Route path="/" element={<AuthClient />} />
|
<Route path="/" element={<AuthClient />} />
|
||||||
<Route path="/profile" element={<Profile />} />
|
<Route path="/profile" element={<Profile />} />
|
||||||
<Route path="/hack" element={<HackGame />} />
|
<Route path="/hack" element={<HackGame />} />
|
||||||
<Route path="/admin" element={<AdminDashboard />} />
|
<Route path="/admin/login" element={<AdminLogin />} />
|
||||||
|
<Route path="/admin" element={<AdminLayout />}>
|
||||||
|
<Route index element={<AdminDashboard />} />
|
||||||
|
<Route path="staff" element={<StaffPage />} />
|
||||||
|
<Route path="withdrawals" element={<WithdrawalsPage />} />
|
||||||
|
<Route path="earnings" element={<EarningsPage />} />
|
||||||
|
<Route path="tickets" element={<TicketsPage />} />
|
||||||
|
<Route path="observability" element={<ObservabilityPage />} />
|
||||||
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
</AuthHandler>
|
</AuthHandler>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
|
|||||||
+12
-3
@@ -10,7 +10,7 @@ import {
|
|||||||
Key,
|
Key,
|
||||||
Download,
|
Download,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { apiFetch } from "./api";
|
import { apiFetch, getPublicConfig } from "./api";
|
||||||
|
|
||||||
export interface TelegramAuthUser {
|
export interface TelegramAuthUser {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -107,7 +107,15 @@ export default function AuthClient() {
|
|||||||
const widgetInjected = useRef(false);
|
const widgetInjected = useRef(false);
|
||||||
|
|
||||||
const API_BASE = import.meta.env.VITE_API_BASE || "/api/v1";
|
const API_BASE = import.meta.env.VITE_API_BASE || "/api/v1";
|
||||||
const BOT_USERNAME = import.meta.env.VITE_BOT_USERNAME || "ntrnr_vpn_bot";
|
const [BOT_USERNAME, setBotUsername] = useState("ntrnr_vpn_bot");
|
||||||
|
const [configLoaded, setConfigLoaded] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getPublicConfig().then((cfg) => {
|
||||||
|
setBotUsername(cfg.bot_username);
|
||||||
|
setConfigLoaded(true);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (initialized.current) return;
|
if (initialized.current) return;
|
||||||
@@ -179,6 +187,7 @@ export default function AuthClient() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (
|
if (
|
||||||
status === "awaiting" &&
|
status === "awaiting" &&
|
||||||
|
configLoaded &&
|
||||||
tgContainerRef.current &&
|
tgContainerRef.current &&
|
||||||
!widgetInjected.current
|
!widgetInjected.current
|
||||||
) {
|
) {
|
||||||
@@ -192,7 +201,7 @@ export default function AuthClient() {
|
|||||||
script.setAttribute("data-request-access", "write");
|
script.setAttribute("data-request-access", "write");
|
||||||
tgContainerRef.current.appendChild(script);
|
tgContainerRef.current.appendChild(script);
|
||||||
}
|
}
|
||||||
}, [status, BOT_USERNAME]);
|
}, [status, BOT_USERNAME, configLoaded]);
|
||||||
|
|
||||||
const handleGhostGenerate = async () => {
|
const handleGhostGenerate = async () => {
|
||||||
setStatus("processing");
|
setStatus("processing");
|
||||||
|
|||||||
@@ -0,0 +1,197 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { RefreshCw, DollarSign, Send, Copy, Check } from "lucide-react";
|
||||||
|
import {
|
||||||
|
fetchMyEarnings,
|
||||||
|
requestWithdrawal,
|
||||||
|
getPublicConfig,
|
||||||
|
type MyEarnings,
|
||||||
|
} from "./api";
|
||||||
|
|
||||||
|
export default function EarningsPage() {
|
||||||
|
const [data, setData] = useState<MyEarnings | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [botUsername, setBotUsername] = useState("");
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
const [currency, setCurrency] = useState("");
|
||||||
|
const [amount, setAmount] = useState("");
|
||||||
|
const [note, setNote] = useState("");
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const earnings = await fetchMyEarnings();
|
||||||
|
setData(earnings);
|
||||||
|
if (!currency && earnings.earnings.length > 0) {
|
||||||
|
setCurrency(earnings.earnings[0].currency);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
getPublicConfig().then((cfg) => setBotUsername(cfg.bot_username));
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const referralLink = data?.partner_code
|
||||||
|
? `https://t.me/${botUsername}?start=${data.partner_code}`
|
||||||
|
: "";
|
||||||
|
|
||||||
|
const handleCopy = async () => {
|
||||||
|
if (!referralLink) return;
|
||||||
|
await navigator.clipboard.writeText(referralLink);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 1500);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
await requestWithdrawal(currency, Math.round(Number(amount) * 100), note || undefined);
|
||||||
|
setAmount("");
|
||||||
|
setNote("");
|
||||||
|
await load();
|
||||||
|
} catch {
|
||||||
|
setError("Не удалось создать заявку (сумма превышает доступный остаток?).");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 sm:p-10 max-w-3xl mx-auto space-y-8">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h1 className="text-xl font-black tracking-widest text-primary uppercase">
|
||||||
|
Мои доходы
|
||||||
|
</h1>
|
||||||
|
<button
|
||||||
|
onClick={load}
|
||||||
|
className="p-2 bg-white/5 hover:bg-white/10 rounded-full transition-colors"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`w-4 h-4 text-secondary ${loading ? "animate-spin" : ""}`} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{referralLink && (
|
||||||
|
<div className="bg-black/40 border border-white/10 rounded-2xl p-5 flex items-center justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-[10px] text-muted-foreground uppercase tracking-widest mb-1">
|
||||||
|
Ваша реферальная ссылка
|
||||||
|
</p>
|
||||||
|
<p className="font-mono text-sm truncate">{referralLink}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleCopy}
|
||||||
|
className="p-2 bg-white/5 hover:bg-white/10 rounded-lg transition-colors shrink-0"
|
||||||
|
>
|
||||||
|
{copied ? <Check className="w-4 h-4 text-secondary" /> : <Copy className="w-4 h-4" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
{(data?.earnings ?? []).map((e) => (
|
||||||
|
<div
|
||||||
|
key={e.currency}
|
||||||
|
className="bg-black/60 border border-white/5 rounded-xl p-5 space-y-2"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 text-primary">
|
||||||
|
<DollarSign className="w-4 h-4" />
|
||||||
|
<span className="font-black text-lg">{e.currency}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Заработано: <span className="text-foreground font-bold">{(e.total_earned / 100).toFixed(2)}</span>
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Выведено: {(e.total_withdrawn / 100).toFixed(2)}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
В обработке: {(e.pending_withdrawal / 100).toFixed(2)}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm font-bold text-secondary">
|
||||||
|
Доступно: {(e.available / 100).toFixed(2)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{(data?.earnings ?? []).length === 0 && !loading && (
|
||||||
|
<p className="text-center py-10 text-muted-foreground text-sm uppercase tracking-widest sm:col-span-2">
|
||||||
|
Пока нет заработка
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
className="bg-black/40 border border-white/10 rounded-2xl p-6 space-y-3"
|
||||||
|
>
|
||||||
|
<h2 className="text-xs font-bold uppercase tracking-widest text-secondary flex items-center gap-2">
|
||||||
|
<Send className="w-4 h-4" /> Заявка на вывод
|
||||||
|
</h2>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<select
|
||||||
|
required
|
||||||
|
value={currency}
|
||||||
|
onChange={(e) => setCurrency(e.target.value)}
|
||||||
|
className="bg-white/5 border border-white/10 rounded-lg p-2.5 text-sm outline-none focus:border-secondary"
|
||||||
|
>
|
||||||
|
<option value="" disabled>
|
||||||
|
Валюта
|
||||||
|
</option>
|
||||||
|
{(data?.earnings ?? []).map((e) => (
|
||||||
|
<option key={e.currency} value={e.currency}>
|
||||||
|
{e.currency}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<input
|
||||||
|
required
|
||||||
|
type="number"
|
||||||
|
min="0.01"
|
||||||
|
step="0.01"
|
||||||
|
placeholder="Сумма"
|
||||||
|
value={amount}
|
||||||
|
onChange={(e) => setAmount(e.target.value)}
|
||||||
|
className="flex-1 bg-white/5 border border-white/10 rounded-lg p-2.5 text-sm outline-none focus:border-secondary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
placeholder="Комментарий (реквизиты и т.п., необязательно)"
|
||||||
|
value={note}
|
||||||
|
onChange={(e) => setNote(e.target.value)}
|
||||||
|
className="w-full bg-white/5 border border-white/10 rounded-lg p-2.5 text-sm outline-none focus:border-secondary"
|
||||||
|
/>
|
||||||
|
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="w-full bg-secondary/10 hover:bg-secondary/20 text-secondary border border-secondary/30 font-bold uppercase tracking-widest text-xs py-2.5 rounded-lg transition-all"
|
||||||
|
>
|
||||||
|
Отправить заявку
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h2 className="text-xs font-bold uppercase tracking-widest text-muted-foreground">
|
||||||
|
История заявок
|
||||||
|
</h2>
|
||||||
|
{(data?.withdrawals ?? []).map((w) => (
|
||||||
|
<div
|
||||||
|
key={w.id}
|
||||||
|
className="bg-black/60 border border-white/5 rounded-xl p-4 flex items-center justify-between"
|
||||||
|
>
|
||||||
|
<span className="font-mono text-sm">
|
||||||
|
{(w.amount / 100).toFixed(2)} {w.currency}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] uppercase tracking-widest font-bold text-muted-foreground">
|
||||||
|
{w.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { getPublicConfig } from "./api";
|
||||||
|
|
||||||
|
const DASHBOARDS = [
|
||||||
|
{ uid: "netrunner-backend", title: "Backend" },
|
||||||
|
{ uid: "netrunner-landing", title: "Лендинг" },
|
||||||
|
{ uid: "netrunner-proxy-nodes", title: "Прокси-ноды (ядро)" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export default function ObservabilityPage() {
|
||||||
|
const [grafanaUrl, setGrafanaUrl] = useState<string | null | undefined>(undefined);
|
||||||
|
const [active, setActive] = useState<(typeof DASHBOARDS)[number]["uid"]>(DASHBOARDS[0].uid);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getPublicConfig().then((cfg) => setGrafanaUrl(cfg.grafana_url));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (grafanaUrl === undefined) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!grafanaUrl) {
|
||||||
|
return (
|
||||||
|
<div className="p-6 sm:p-10 max-w-4xl mx-auto">
|
||||||
|
<p className="text-center py-20 text-muted-foreground text-sm uppercase tracking-widest">
|
||||||
|
Grafana ещё не настроена (GRAFANA_PUBLIC_URL не задан на сервере).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 sm:p-10 max-w-6xl mx-auto space-y-6">
|
||||||
|
<div className="flex items-center justify-between flex-wrap gap-4">
|
||||||
|
<h1 className="text-xl font-black tracking-widest text-primary uppercase">
|
||||||
|
Мониторинг
|
||||||
|
</h1>
|
||||||
|
<div className="flex gap-2 flex-wrap">
|
||||||
|
{DASHBOARDS.map((d) => (
|
||||||
|
<button
|
||||||
|
key={d.uid}
|
||||||
|
onClick={() => setActive(d.uid)}
|
||||||
|
className={`px-3 py-1.5 rounded-lg text-xs font-bold uppercase tracking-widest transition-colors ${
|
||||||
|
active === d.uid
|
||||||
|
? "bg-primary/10 text-primary border border-primary/30"
|
||||||
|
: "bg-white/5 text-muted-foreground hover:bg-white/10"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{d.title}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Ключ на iframe меняет DOM-узел при смене дашборда — проще, чем
|
||||||
|
городить логику перезагрузки src внутри одного и того же iframe. */}
|
||||||
|
<iframe
|
||||||
|
key={active}
|
||||||
|
title={`Grafana: ${active}`}
|
||||||
|
src={`${grafanaUrl}/d/${active}/?kiosk&theme=dark`}
|
||||||
|
className="w-full rounded-2xl border border-white/10"
|
||||||
|
style={{ height: "80vh" }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
activateTrial,
|
activateTrial,
|
||||||
initiatePayment,
|
initiatePayment,
|
||||||
confirmPayment,
|
confirmPayment,
|
||||||
|
getPublicConfig,
|
||||||
} from "./api";
|
} from "./api";
|
||||||
|
|
||||||
// --- ТИПИЗАЦИЯ ---
|
// --- ТИПИЗАЦИЯ ---
|
||||||
@@ -199,12 +200,16 @@ export default function Profile() {
|
|||||||
|
|
||||||
const [lang, setLang] = useState<"en" | "ru">(getLang);
|
const [lang, setLang] = useState<"en" | "ru">(getLang);
|
||||||
const t = DICT[lang];
|
const t = DICT[lang];
|
||||||
const botUsername = import.meta.env.VITE_BOT_USERNAME || "ntrnr_vpn_bot";
|
const [botUsername, setBotUsername] = useState("ntrnr_vpn_bot");
|
||||||
|
|
||||||
// TON Connect Hooks
|
// TON Connect Hooks
|
||||||
const [tonConnectUI] = useTonConnectUI();
|
const [tonConnectUI] = useTonConnectUI();
|
||||||
const userFriendlyAddress = useTonAddress();
|
const userFriendlyAddress = useTonAddress();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getPublicConfig().then((cfg) => setBotUsername(cfg.bot_username));
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchProfile()
|
fetchProfile()
|
||||||
.then((res: ProfileDataResponse) => setData(res))
|
.then((res: ProfileDataResponse) => setData(res))
|
||||||
|
|||||||
@@ -0,0 +1,277 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useOutletContext } from "react-router-dom";
|
||||||
|
import { UserPlus, RefreshCw } from "lucide-react";
|
||||||
|
import {
|
||||||
|
createModerator,
|
||||||
|
createPartner,
|
||||||
|
createSupport,
|
||||||
|
fetchStaffUsers,
|
||||||
|
setNodePermission,
|
||||||
|
type StaffRole,
|
||||||
|
type StaffUser,
|
||||||
|
} from "./api";
|
||||||
|
|
||||||
|
export default function StaffPage() {
|
||||||
|
const role = useOutletContext<StaffRole>();
|
||||||
|
const [staff, setStaff] = useState<StaffUser[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const [modUsername, setModUsername] = useState("");
|
||||||
|
const [modPassword, setModPassword] = useState("");
|
||||||
|
const [modCanManageNodes, setModCanManageNodes] = useState(false);
|
||||||
|
|
||||||
|
const [partnerUsername, setPartnerUsername] = useState("");
|
||||||
|
const [partnerPassword, setPartnerPassword] = useState("");
|
||||||
|
const [partnerPercent, setPartnerPercent] = useState("10");
|
||||||
|
|
||||||
|
const [supportUsername, setSupportUsername] = useState("");
|
||||||
|
const [supportPassword, setSupportPassword] = useState("");
|
||||||
|
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
setStaff(await fetchStaffUsers());
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCreateModerator = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
await createModerator(modUsername, modPassword, modCanManageNodes);
|
||||||
|
setModUsername("");
|
||||||
|
setModPassword("");
|
||||||
|
setModCanManageNodes(false);
|
||||||
|
await load();
|
||||||
|
} catch {
|
||||||
|
setError("Не удалось создать модератора (логин уже занят?).");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreatePartner = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
await createPartner(partnerUsername, partnerPassword, Number(partnerPercent));
|
||||||
|
setPartnerUsername("");
|
||||||
|
setPartnerPassword("");
|
||||||
|
setPartnerPercent("10");
|
||||||
|
await load();
|
||||||
|
} catch {
|
||||||
|
setError("Не удалось создать партнёра (логин уже занят?).");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateSupport = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
await createSupport(supportUsername, supportPassword);
|
||||||
|
setSupportUsername("");
|
||||||
|
setSupportPassword("");
|
||||||
|
await load();
|
||||||
|
} catch {
|
||||||
|
setError("Не удалось создать аккаунт саппорта (логин уже занят?).");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTogglePermission = async (userId: string, current: boolean) => {
|
||||||
|
try {
|
||||||
|
await setNodePermission(userId, !current);
|
||||||
|
await load();
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 sm:p-10 max-w-5xl mx-auto space-y-8">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h1 className="text-xl font-black tracking-widest text-primary uppercase">
|
||||||
|
Staff
|
||||||
|
</h1>
|
||||||
|
<button
|
||||||
|
onClick={load}
|
||||||
|
className="p-2 bg-white/5 hover:bg-white/10 rounded-full transition-colors"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`w-4 h-4 text-secondary ${loading ? "animate-spin" : ""}`} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
{role.role === "owner" && (
|
||||||
|
<form
|
||||||
|
onSubmit={handleCreateModerator}
|
||||||
|
className="bg-black/40 border border-white/10 rounded-2xl p-6 space-y-3"
|
||||||
|
>
|
||||||
|
<h2 className="text-xs font-bold uppercase tracking-widest text-secondary flex items-center gap-2">
|
||||||
|
<UserPlus className="w-4 h-4" /> Новый модератор
|
||||||
|
</h2>
|
||||||
|
<input
|
||||||
|
required
|
||||||
|
placeholder="Логин"
|
||||||
|
value={modUsername}
|
||||||
|
onChange={(e) => setModUsername(e.target.value)}
|
||||||
|
className="w-full bg-white/5 border border-white/10 rounded-lg p-2.5 text-sm outline-none focus:border-secondary"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
required
|
||||||
|
type="password"
|
||||||
|
placeholder="Пароль"
|
||||||
|
value={modPassword}
|
||||||
|
onChange={(e) => setModPassword(e.target.value)}
|
||||||
|
className="w-full bg-white/5 border border-white/10 rounded-lg p-2.5 text-sm outline-none focus:border-secondary"
|
||||||
|
/>
|
||||||
|
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer select-none">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={modCanManageNodes}
|
||||||
|
onChange={(e) => setModCanManageNodes(e.target.checked)}
|
||||||
|
className="accent-secondary"
|
||||||
|
/>
|
||||||
|
Право управлять нодами
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="w-full bg-secondary/10 hover:bg-secondary/20 text-secondary border border-secondary/30 font-bold uppercase tracking-widest text-xs py-2.5 rounded-lg transition-all"
|
||||||
|
>
|
||||||
|
Создать
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{role.role === "owner" && (
|
||||||
|
<form
|
||||||
|
onSubmit={handleCreateSupport}
|
||||||
|
className="bg-black/40 border border-white/10 rounded-2xl p-6 space-y-3"
|
||||||
|
>
|
||||||
|
<h2 className="text-xs font-bold uppercase tracking-widest text-secondary flex items-center gap-2">
|
||||||
|
<UserPlus className="w-4 h-4" /> Новый саппорт
|
||||||
|
</h2>
|
||||||
|
<input
|
||||||
|
required
|
||||||
|
placeholder="Логин"
|
||||||
|
value={supportUsername}
|
||||||
|
onChange={(e) => setSupportUsername(e.target.value)}
|
||||||
|
className="w-full bg-white/5 border border-white/10 rounded-lg p-2.5 text-sm outline-none focus:border-secondary"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
required
|
||||||
|
type="password"
|
||||||
|
placeholder="Пароль"
|
||||||
|
value={supportPassword}
|
||||||
|
onChange={(e) => setSupportPassword(e.target.value)}
|
||||||
|
className="w-full bg-white/5 border border-white/10 rounded-lg p-2.5 text-sm outline-none focus:border-secondary"
|
||||||
|
/>
|
||||||
|
<p className="text-[10px] text-muted-foreground">
|
||||||
|
Видит только раздел "Тикеты" — доступа к нодам/staff/выводам нет.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="w-full bg-secondary/10 hover:bg-secondary/20 text-secondary border border-secondary/30 font-bold uppercase tracking-widest text-xs py-2.5 rounded-lg transition-all"
|
||||||
|
>
|
||||||
|
Создать
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form
|
||||||
|
onSubmit={handleCreatePartner}
|
||||||
|
className="bg-black/40 border border-white/10 rounded-2xl p-6 space-y-3"
|
||||||
|
>
|
||||||
|
<h2 className="text-xs font-bold uppercase tracking-widest text-primary flex items-center gap-2">
|
||||||
|
<UserPlus className="w-4 h-4" /> Новый партнёр
|
||||||
|
</h2>
|
||||||
|
<input
|
||||||
|
required
|
||||||
|
placeholder="Логин"
|
||||||
|
value={partnerUsername}
|
||||||
|
onChange={(e) => setPartnerUsername(e.target.value)}
|
||||||
|
className="w-full bg-white/5 border border-white/10 rounded-lg p-2.5 text-sm outline-none focus:border-primary"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
required
|
||||||
|
type="password"
|
||||||
|
placeholder="Пароль"
|
||||||
|
value={partnerPassword}
|
||||||
|
onChange={(e) => setPartnerPassword(e.target.value)}
|
||||||
|
className="w-full bg-white/5 border border-white/10 rounded-lg p-2.5 text-sm outline-none focus:border-primary"
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
required
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
step="0.01"
|
||||||
|
value={partnerPercent}
|
||||||
|
onChange={(e) => setPartnerPercent(e.target.value)}
|
||||||
|
className="w-24 bg-white/5 border border-white/10 rounded-lg p-2.5 text-sm outline-none focus:border-primary"
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-muted-foreground">% с продаж</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="w-full bg-primary/10 hover:bg-primary/20 text-primary border border-primary/30 font-bold uppercase tracking-widest text-xs py-2.5 rounded-lg transition-all"
|
||||||
|
>
|
||||||
|
Создать
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h2 className="text-xs font-bold uppercase tracking-widest text-muted-foreground">
|
||||||
|
Список staff
|
||||||
|
</h2>
|
||||||
|
{staff.map((u) => (
|
||||||
|
<div
|
||||||
|
key={u.id}
|
||||||
|
className="bg-black/60 border border-white/5 rounded-xl p-4 flex items-center justify-between gap-4"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-bold text-sm">{u.username}</span>
|
||||||
|
<span className="text-[10px] bg-white/10 px-2 py-0.5 rounded uppercase text-muted-foreground">
|
||||||
|
{u.role}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{u.role === "partner" && (
|
||||||
|
<p className="text-xs text-muted-foreground font-mono mt-1">
|
||||||
|
{u.commission_percent}% · код: {u.partner_code}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{role.role === "owner" && u.role === "moderator" && (
|
||||||
|
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer select-none">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={u.can_manage_nodes}
|
||||||
|
onChange={() => handleTogglePermission(u.id, u.can_manage_nodes)}
|
||||||
|
className="accent-secondary"
|
||||||
|
/>
|
||||||
|
Ноды
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{staff.length === 0 && !loading && (
|
||||||
|
<p className="text-center py-10 text-muted-foreground text-sm uppercase tracking-widest">
|
||||||
|
Пока никого нет
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { RefreshCw, Send, CheckCircle2, MessageSquare } from "lucide-react";
|
||||||
|
import {
|
||||||
|
fetchTickets,
|
||||||
|
fetchTicket,
|
||||||
|
replyToTicket,
|
||||||
|
closeTicket,
|
||||||
|
type SupportTicket,
|
||||||
|
type SupportTicketSummary,
|
||||||
|
type SupportMessage,
|
||||||
|
} from "./api";
|
||||||
|
|
||||||
|
type StatusFilter = "open" | "closed";
|
||||||
|
|
||||||
|
export default function TicketsPage() {
|
||||||
|
const [filter, setFilter] = useState<StatusFilter>("open");
|
||||||
|
const [list, setList] = useState<SupportTicketSummary[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
|
const [ticket, setTicket] = useState<SupportTicket | null>(null);
|
||||||
|
const [messages, setMessages] = useState<SupportMessage[]>([]);
|
||||||
|
const [replyText, setReplyText] = useState("");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
const loadList = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
setList(await fetchTickets(filter));
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadList();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [filter]);
|
||||||
|
|
||||||
|
const loadThread = async (id: string) => {
|
||||||
|
setSelectedId(id);
|
||||||
|
try {
|
||||||
|
const { ticket, messages } = await fetchTicket(id);
|
||||||
|
setTicket(ticket);
|
||||||
|
setMessages(messages);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReply = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!selectedId || !replyText.trim()) return;
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await replyToTicket(selectedId, replyText);
|
||||||
|
setReplyText("");
|
||||||
|
await loadThread(selectedId);
|
||||||
|
await loadList();
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = async () => {
|
||||||
|
if (!selectedId) return;
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await closeTicket(selectedId);
|
||||||
|
setSelectedId(null);
|
||||||
|
setTicket(null);
|
||||||
|
setMessages([]);
|
||||||
|
await loadList();
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 sm:p-10 max-w-6xl mx-auto space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h1 className="text-xl font-black tracking-widest text-primary uppercase">
|
||||||
|
Тикеты
|
||||||
|
</h1>
|
||||||
|
<button
|
||||||
|
onClick={loadList}
|
||||||
|
className="p-2 bg-white/5 hover:bg-white/10 rounded-full transition-colors"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`w-4 h-4 text-secondary ${loading ? "animate-spin" : ""}`} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{(["open", "closed"] as StatusFilter[]).map((s) => (
|
||||||
|
<button
|
||||||
|
key={s}
|
||||||
|
onClick={() => {
|
||||||
|
setFilter(s);
|
||||||
|
setSelectedId(null);
|
||||||
|
setTicket(null);
|
||||||
|
}}
|
||||||
|
className={`px-3 py-1.5 rounded-lg text-xs font-bold uppercase tracking-widest transition-colors ${
|
||||||
|
filter === s
|
||||||
|
? "bg-primary/10 text-primary border border-primary/30"
|
||||||
|
: "bg-white/5 text-muted-foreground hover:bg-white/10"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{s === "open" ? "Открытые" : "Закрытые"}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-[minmax(0,1fr)_minmax(0,1.4fr)] gap-6">
|
||||||
|
<div className="space-y-3">
|
||||||
|
{list.map((t) => (
|
||||||
|
<button
|
||||||
|
key={t.id}
|
||||||
|
onClick={() => loadThread(t.id)}
|
||||||
|
className={`w-full text-left bg-black/60 border rounded-xl p-4 transition-colors ${
|
||||||
|
selectedId === t.id
|
||||||
|
? "border-primary/40"
|
||||||
|
: "border-white/5 hover:border-white/10"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span className="font-bold text-sm">
|
||||||
|
@{t.tg_username || t.tg_id || "аноним"}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-muted-foreground/60 font-mono">
|
||||||
|
{new Date(t.updated_at).toLocaleString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{t.last_message_preview && (
|
||||||
|
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">
|
||||||
|
{t.last_message_preview}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<p className="text-[10px] text-muted-foreground/60 mt-1">
|
||||||
|
{t.message_count} сообщ.
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{list.length === 0 && !loading && (
|
||||||
|
<p className="text-center py-10 text-muted-foreground text-sm uppercase tracking-widest">
|
||||||
|
Тикетов нет
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-black/40 border border-white/10 rounded-2xl p-6 flex flex-col min-h-[400px]">
|
||||||
|
{!ticket ? (
|
||||||
|
<div className="flex-1 flex items-center justify-center text-muted-foreground text-sm">
|
||||||
|
<MessageSquare className="w-5 h-5 mr-2" /> Выберите тикет слева
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<span className="text-xs font-bold uppercase tracking-widest text-muted-foreground">
|
||||||
|
Тикет {ticket.status === "open" ? "открыт" : "закрыт"}
|
||||||
|
</span>
|
||||||
|
{ticket.status === "open" && (
|
||||||
|
<button
|
||||||
|
onClick={handleClose}
|
||||||
|
disabled={busy}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 bg-green-500/10 hover:bg-green-500/20 text-green-500 border border-green-500/20 rounded-lg text-xs font-bold uppercase tracking-widest transition-colors disabled:opacity-40"
|
||||||
|
>
|
||||||
|
<CheckCircle2 className="w-3.5 h-3.5" /> Закрыть
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 space-y-3 overflow-y-auto max-h-[420px] pr-1">
|
||||||
|
{messages.map((m) => (
|
||||||
|
<div
|
||||||
|
key={m.id}
|
||||||
|
className={`max-w-[85%] rounded-xl p-3 text-sm ${
|
||||||
|
m.sender_role === "staff"
|
||||||
|
? "ml-auto bg-primary/10 border border-primary/20"
|
||||||
|
: "bg-white/5 border border-white/10"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<p className="whitespace-pre-wrap">{m.body}</p>
|
||||||
|
<p className="text-[10px] text-muted-foreground/60 font-mono mt-1">
|
||||||
|
{new Date(m.created_at).toLocaleString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{ticket.status === "open" && (
|
||||||
|
<form onSubmit={handleReply} className="mt-4 flex items-end gap-2">
|
||||||
|
<textarea
|
||||||
|
value={replyText}
|
||||||
|
onChange={(e) => setReplyText(e.target.value)}
|
||||||
|
placeholder="Ответ..."
|
||||||
|
rows={2}
|
||||||
|
className="flex-1 bg-white/5 border border-white/10 rounded-lg p-2.5 text-sm outline-none focus:border-primary resize-none"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={busy || !replyText.trim()}
|
||||||
|
className="p-2.5 bg-primary/10 hover:bg-primary/20 text-primary border border-primary/30 rounded-lg transition-colors disabled:opacity-40"
|
||||||
|
>
|
||||||
|
<Send className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { RefreshCw, Check, X, Banknote } from "lucide-react";
|
||||||
|
import { fetchWithdrawals, updateWithdrawal, type WithdrawalRequest } from "./api";
|
||||||
|
|
||||||
|
const STATUS_COLOR: Record<string, string> = {
|
||||||
|
pending: "text-yellow-500",
|
||||||
|
approved: "text-secondary",
|
||||||
|
paid: "text-green-500",
|
||||||
|
rejected: "text-red-500",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function WithdrawalsPage() {
|
||||||
|
const [list, setList] = useState<WithdrawalRequest[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [busyId, setBusyId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
setList(await fetchWithdrawals());
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleUpdate = async (id: string, status: "approved" | "rejected" | "paid") => {
|
||||||
|
setBusyId(id);
|
||||||
|
try {
|
||||||
|
await updateWithdrawal(id, status);
|
||||||
|
await load();
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
} finally {
|
||||||
|
setBusyId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 sm:p-10 max-w-4xl mx-auto space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h1 className="text-xl font-black tracking-widest text-primary uppercase">
|
||||||
|
Заявки на вывод
|
||||||
|
</h1>
|
||||||
|
<button
|
||||||
|
onClick={load}
|
||||||
|
className="p-2 bg-white/5 hover:bg-white/10 rounded-full transition-colors"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`w-4 h-4 text-secondary ${loading ? "animate-spin" : ""}`} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{list.map((w) => (
|
||||||
|
<div
|
||||||
|
key={w.id}
|
||||||
|
className="bg-black/60 border border-white/5 rounded-xl p-4 flex flex-col sm:flex-row sm:items-center justify-between gap-3"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Banknote className="w-4 h-4 text-muted-foreground" />
|
||||||
|
<span className="font-bold font-mono">
|
||||||
|
{(w.amount / 100).toFixed(2)} {w.currency}
|
||||||
|
</span>
|
||||||
|
<span className={`text-[10px] uppercase tracking-widest font-bold ${STATUS_COLOR[w.status]}`}>
|
||||||
|
{w.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{w.note && (
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">{w.note}</p>
|
||||||
|
)}
|
||||||
|
<p className="text-[10px] text-muted-foreground/60 font-mono mt-1">
|
||||||
|
{new Date(w.created_at).toLocaleString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{w.status === "pending" && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
disabled={busyId === w.id}
|
||||||
|
onClick={() => handleUpdate(w.id, "approved")}
|
||||||
|
className="p-2 bg-secondary/10 hover:bg-secondary/20 text-secondary border border-secondary/20 rounded-lg transition-colors disabled:opacity-40"
|
||||||
|
title="Одобрить"
|
||||||
|
>
|
||||||
|
<Check className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
disabled={busyId === w.id}
|
||||||
|
onClick={() => handleUpdate(w.id, "rejected")}
|
||||||
|
className="p-2 bg-red-500/10 hover:bg-red-500/20 text-red-500 border border-red-500/20 rounded-lg transition-colors disabled:opacity-40"
|
||||||
|
title="Отклонить"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{w.status === "approved" && (
|
||||||
|
<button
|
||||||
|
disabled={busyId === w.id}
|
||||||
|
onClick={() => handleUpdate(w.id, "paid")}
|
||||||
|
className="px-3 py-2 bg-green-500/10 hover:bg-green-500/20 text-green-500 border border-green-500/20 rounded-lg text-xs font-bold uppercase tracking-widest transition-colors disabled:opacity-40"
|
||||||
|
>
|
||||||
|
Отметить выплаченным
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{list.length === 0 && !loading && (
|
||||||
|
<p className="text-center py-10 text-muted-foreground text-sm uppercase tracking-widest">
|
||||||
|
Заявок нет
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+247
-1
@@ -1,5 +1,31 @@
|
|||||||
export const API_BASE = import.meta.env.VITE_API_BASE || "/api/v1";
|
export const API_BASE = import.meta.env.VITE_API_BASE || "/api/v1";
|
||||||
|
|
||||||
|
interface PublicConfig {
|
||||||
|
bot_username: string;
|
||||||
|
/// URL Grafana за auth-гейтом Caddy — `null`, пока GRAFANA_PUBLIC_URL не
|
||||||
|
/// задан на сервере (см. ObservabilityPage.tsx).
|
||||||
|
grafana_url: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let configPromise: Promise<PublicConfig> | null = null;
|
||||||
|
|
||||||
|
/// Юзернейм бота — намеренно не `import.meta.env.VITE_BOT_USERNAME`: этот SPA
|
||||||
|
/// собирается один раз в CI, и один и тот же статический бандл раздаётся и
|
||||||
|
/// прод-, и dev-стендом — build-time значение зашилось бы в файлы намертво и
|
||||||
|
/// не различалось бы между стендами (ровно так и вышло на практике: dev видел
|
||||||
|
/// прод-бота, у которого в BotFather другой домен — виджет логина отвечал
|
||||||
|
/// "Bot domain invalid"). `/api/v1/config` читает env на сервере заново на
|
||||||
|
/// каждый запрос, поэтому корректно отдаёт "свой" бот (и Grafana URL) для
|
||||||
|
/// каждого стенда.
|
||||||
|
export function getPublicConfig(): Promise<PublicConfig> {
|
||||||
|
if (!configPromise) {
|
||||||
|
configPromise = fetch(`${API_BASE}/config`)
|
||||||
|
.then((res) => res.json())
|
||||||
|
.catch(() => ({ bot_username: "ntrnr_vpn_bot", grafana_url: null }));
|
||||||
|
}
|
||||||
|
return configPromise;
|
||||||
|
}
|
||||||
|
|
||||||
// Сессия живёт только в HttpOnly cookie, которую ставит сервер — JS её не видит
|
// Сессия живёт только в HttpOnly cookie, которую ставит сервер — JS её не видит
|
||||||
// и не должен: раньше здесь читался `document.cookie` в расчёте на токен,
|
// и не должен: раньше здесь читался `document.cookie` в расчёте на токен,
|
||||||
// который сервер ставит как HttpOnly, поэтому чтение всегда возвращало null, а
|
// который сервер ставит как HttpOnly, поэтому чтение всегда возвращало null, а
|
||||||
@@ -35,6 +61,13 @@ function toRequestInit(options: RequestInit): RequestInit {
|
|||||||
/// Единая обёртка над fetch для авторизованных запросов: всегда с cookie,
|
/// Единая обёртка над fetch для авторизованных запросов: всегда с cookie,
|
||||||
/// при первом 401 пробует silent-refresh и повторяет запрос один раз, при
|
/// при первом 401 пробует silent-refresh и повторяет запрос один раз, при
|
||||||
/// провале — чистит сторону клиента и уводит на экран логина.
|
/// провале — чистит сторону клиента и уводит на экран логина.
|
||||||
|
///
|
||||||
|
/// Редирект пропускается, если мы и так уже на `/` (экран логина): иначе
|
||||||
|
/// `window.location.href = "/"` там же, где мы уже находимся, всё равно
|
||||||
|
/// вызывает полную перезагрузку страницы — React-приложение перемонтируется,
|
||||||
|
/// Auth.tsx заново дёргает тот же `/users/me`, получает тот же 401 (для
|
||||||
|
/// анонимного визита это нормальное, ожидаемое состояние, а не сбой) — и
|
||||||
|
/// уходит в бесконечный цикл перезагрузок.
|
||||||
export async function apiFetch(
|
export async function apiFetch(
|
||||||
path: string,
|
path: string,
|
||||||
options: RequestInit = {},
|
options: RequestInit = {},
|
||||||
@@ -46,7 +79,13 @@ export async function apiFetch(
|
|||||||
|
|
||||||
const refreshed = await refreshSession();
|
const refreshed = await refreshSession();
|
||||||
if (!refreshed) {
|
if (!refreshed) {
|
||||||
window.location.href = "/";
|
// Админка (Owner/Moderator/Partner) логинится отдельно от обычных
|
||||||
|
// Ghost/Telegram-юзеров — редирект на "/" был бы неверным экраном для неё.
|
||||||
|
const isAdminPath = window.location.pathname.startsWith("/admin");
|
||||||
|
const loginPath = isAdminPath ? "/admin/login" : "/";
|
||||||
|
if (window.location.pathname !== loginPath) {
|
||||||
|
window.location.href = loginPath;
|
||||||
|
}
|
||||||
return res;
|
return res;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,6 +173,213 @@ export const exchangeBootstrapToken = async (token: string): Promise<boolean> =>
|
|||||||
return res.ok;
|
return res.ok;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// --- Ролевая админка (Owner/Moderator/Partner) ---
|
||||||
|
|
||||||
|
export const adminLogin = async (username: string, password: string) => {
|
||||||
|
const res = await fetch(`${API_BASE}/auth/admin/login`, {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "same-origin",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ username, password }),
|
||||||
|
});
|
||||||
|
return parseOrThrow(res, "Login failed");
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface StaffRole {
|
||||||
|
role: "owner" | "moderator" | "partner" | "support" | "user" | "admin";
|
||||||
|
can_manage_nodes: boolean;
|
||||||
|
username: string | null;
|
||||||
|
partner_code: string | null;
|
||||||
|
commission_percent: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const fetchMyRole = async (): Promise<StaffRole> => {
|
||||||
|
const res = await apiFetch("/admin/staff/me");
|
||||||
|
return parseOrThrow(res, "Failed to fetch role");
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createModerator = async (
|
||||||
|
username: string,
|
||||||
|
password: string,
|
||||||
|
canManageNodes: boolean,
|
||||||
|
) => {
|
||||||
|
const res = await apiFetch("/admin/staff/moderators", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
can_manage_nodes: canManageNodes,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
return parseOrThrow(res, "Failed to create moderator");
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createSupport = async (username: string, password: string) => {
|
||||||
|
const res = await apiFetch("/admin/staff/support", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ username, password }),
|
||||||
|
});
|
||||||
|
return parseOrThrow(res, "Failed to create support account");
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createPartner = async (
|
||||||
|
username: string,
|
||||||
|
password: string,
|
||||||
|
commissionPercent: number,
|
||||||
|
) => {
|
||||||
|
const res = await apiFetch("/admin/staff/partners", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
commission_percent: commissionPercent,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
return parseOrThrow(res, "Failed to create partner");
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface StaffUser {
|
||||||
|
id: string;
|
||||||
|
username: string | null;
|
||||||
|
role: string;
|
||||||
|
can_manage_nodes: boolean;
|
||||||
|
commission_percent: string | null;
|
||||||
|
partner_code: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const fetchStaffUsers = async (): Promise<StaffUser[]> => {
|
||||||
|
const res = await apiFetch("/admin/staff/staff-users");
|
||||||
|
return parseOrThrow(res, "Failed to fetch staff users");
|
||||||
|
};
|
||||||
|
|
||||||
|
export const setNodePermission = async (userId: string, canManageNodes: boolean) => {
|
||||||
|
const res = await apiFetch(`/admin/staff/users/${userId}/permissions`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify({ can_manage_nodes: canManageNodes }),
|
||||||
|
});
|
||||||
|
return parseOrThrow(res, "Failed to update permissions");
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface WithdrawalRequest {
|
||||||
|
id: string;
|
||||||
|
partner_id: string;
|
||||||
|
currency: string;
|
||||||
|
amount: number;
|
||||||
|
status: "pending" | "approved" | "rejected" | "paid";
|
||||||
|
note: string | null;
|
||||||
|
created_at: string;
|
||||||
|
processed_at: string | null;
|
||||||
|
processed_by: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const fetchWithdrawals = async (): Promise<WithdrawalRequest[]> => {
|
||||||
|
const res = await apiFetch("/admin/staff/withdrawals");
|
||||||
|
return parseOrThrow(res, "Failed to fetch withdrawals");
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateWithdrawal = async (
|
||||||
|
id: string,
|
||||||
|
status: "approved" | "rejected" | "paid",
|
||||||
|
) => {
|
||||||
|
const res = await apiFetch(`/admin/staff/withdrawals/${id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify({ status }),
|
||||||
|
});
|
||||||
|
return parseOrThrow(res, "Failed to update withdrawal");
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface PartnerEarnings {
|
||||||
|
currency: string;
|
||||||
|
total_earned: number;
|
||||||
|
total_withdrawn: number;
|
||||||
|
pending_withdrawal: number;
|
||||||
|
available: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MyEarnings {
|
||||||
|
earnings: PartnerEarnings[];
|
||||||
|
withdrawals: WithdrawalRequest[];
|
||||||
|
partner_code: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const fetchMyEarnings = async (): Promise<MyEarnings> => {
|
||||||
|
const res = await apiFetch("/admin/partners/earnings");
|
||||||
|
return parseOrThrow(res, "Failed to fetch earnings");
|
||||||
|
};
|
||||||
|
|
||||||
|
export const requestWithdrawal = async (
|
||||||
|
currency: string,
|
||||||
|
amount: number,
|
||||||
|
note?: string,
|
||||||
|
) => {
|
||||||
|
const res = await apiFetch("/admin/partners/withdrawals", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ currency, amount, note }),
|
||||||
|
});
|
||||||
|
return parseOrThrow(res, "Failed to submit withdrawal request");
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Тикеты техподдержки (Owner/Moderator/Support) ---
|
||||||
|
|
||||||
|
export interface SupportTicketSummary {
|
||||||
|
id: string;
|
||||||
|
user_id: string;
|
||||||
|
tg_id: number | null;
|
||||||
|
tg_username: string | null;
|
||||||
|
status: "open" | "closed";
|
||||||
|
message_count: number;
|
||||||
|
last_message_preview: string | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SupportTicket {
|
||||||
|
id: string;
|
||||||
|
user_id: string;
|
||||||
|
status: "open" | "closed";
|
||||||
|
assigned_to: string | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SupportMessage {
|
||||||
|
id: string;
|
||||||
|
ticket_id: string;
|
||||||
|
sender_role: "user" | "staff";
|
||||||
|
sender_user_id: string | null;
|
||||||
|
body: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const fetchTickets = async (
|
||||||
|
status: "open" | "closed" | "all" = "open",
|
||||||
|
): Promise<SupportTicketSummary[]> => {
|
||||||
|
const res = await apiFetch(`/admin/support/tickets?status=${status}`);
|
||||||
|
return parseOrThrow(res, "Failed to fetch tickets");
|
||||||
|
};
|
||||||
|
|
||||||
|
export const fetchTicket = async (
|
||||||
|
id: string,
|
||||||
|
): Promise<{ ticket: SupportTicket; messages: SupportMessage[] }> => {
|
||||||
|
const res = await apiFetch(`/admin/support/tickets/${id}`);
|
||||||
|
return parseOrThrow(res, "Failed to fetch ticket");
|
||||||
|
};
|
||||||
|
|
||||||
|
export const replyToTicket = async (id: string, text: string) => {
|
||||||
|
const res = await apiFetch(`/admin/support/tickets/${id}/reply`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ text }),
|
||||||
|
});
|
||||||
|
return parseOrThrow(res, "Failed to send reply");
|
||||||
|
};
|
||||||
|
|
||||||
|
export const closeTicket = async (id: string) => {
|
||||||
|
const res = await apiFetch(`/admin/support/tickets/${id}/close`, {
|
||||||
|
method: "PATCH",
|
||||||
|
});
|
||||||
|
return parseOrThrow(res, "Failed to close ticket");
|
||||||
|
};
|
||||||
|
|
||||||
export const logout = async () => {
|
export const logout = async () => {
|
||||||
await fetch(`${API_BASE}/auth/logout`, {
|
await fetch(`${API_BASE}/auth/logout`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
apiVersion: 1
|
|
||||||
|
|
||||||
datasources:
|
|
||||||
- name: Prometheus
|
|
||||||
type: prometheus
|
|
||||||
access: proxy
|
|
||||||
url: http://prometheus:9090
|
|
||||||
isDefault: true
|
|
||||||
version: 1
|
|
||||||
editable: true
|
|
||||||
|
|
||||||
- name: Loki
|
|
||||||
type: loki
|
|
||||||
access: proxy
|
|
||||||
url: http://loki:3100
|
|
||||||
version: 1
|
|
||||||
editable: true
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
auth_enabled: false
|
|
||||||
|
|
||||||
server:
|
|
||||||
http_listen_port: 3100
|
|
||||||
grpc_listen_port: 9096
|
|
||||||
|
|
||||||
common:
|
|
||||||
instance_addr: 127.0.0.1
|
|
||||||
path_prefix: /tmp/loki
|
|
||||||
storage:
|
|
||||||
filesystem:
|
|
||||||
chunks_directory: /tmp/loki/chunks
|
|
||||||
rules_directory: /tmp/loki/rules
|
|
||||||
replication_factor: 1
|
|
||||||
ring:
|
|
||||||
kvstore:
|
|
||||||
store: inmemory
|
|
||||||
|
|
||||||
query_range:
|
|
||||||
results_cache:
|
|
||||||
cache:
|
|
||||||
embedded_cache:
|
|
||||||
enabled: true
|
|
||||||
max_size_mb: 100
|
|
||||||
|
|
||||||
schema_config:
|
|
||||||
configs:
|
|
||||||
- from: 2020-10-24
|
|
||||||
store: tsdb
|
|
||||||
object_store: filesystem
|
|
||||||
schema: v13
|
|
||||||
index:
|
|
||||||
prefix: index_
|
|
||||||
period: 24h
|
|
||||||
|
|
||||||
ruler:
|
|
||||||
alertmanager_url: http://localhost:9093
|
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
-- =====================================================================
|
||||||
|
-- ДИНАМИЧЕСКИЕ ЛИМИТЫ ТРАФИКА ПРОКСИ
|
||||||
|
-- =====================================================================
|
||||||
|
-- data_limit_bytes = NULL означает безлимит. Лимит читается прокси-сервером
|
||||||
|
-- на каждой проверке (POST /api/v1/internal/validate) и на каждом отчёте о
|
||||||
|
-- расходе (POST /api/v1/internal/usage) — админ меняет его в любой момент
|
||||||
|
-- через PATCH /api/v1/admin/users/{id}/limit без перезапуска прокси.
|
||||||
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS data_limit_bytes BIGINT;
|
||||||
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS data_used_bytes BIGINT NOT NULL DEFAULT 0;
|
||||||
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS data_period_reset_at TIMESTAMPTZ;
|
||||||
|
|
||||||
|
-- Дефолтный лимит тарифа — проставляется юзеру в data_limit_bytes при покупке
|
||||||
|
-- подписки (см. AppRepository::buy_subscription), чтобы не заводить отдельный
|
||||||
|
-- админ-флоу на каждую покупку. NULL = тариф без предустановленного лимита.
|
||||||
|
ALTER TABLE plans ADD COLUMN IF NOT EXISTS default_limit_bytes BIGINT;
|
||||||
|
|
||||||
|
-- =====================================================================
|
||||||
|
-- MAGIC-LINK ВХОД ЧЕРЕЗ TELEGRAM-БОТА ДЛЯ ДЕСКТОП/МОБИЛЬНОГО ПРИЛОЖЕНИЯ
|
||||||
|
-- =====================================================================
|
||||||
|
-- auth_sessions была создана в 0001_init.sql, но 0002_refresh_sessions.sql
|
||||||
|
-- (репурпоз черновика Magic Link под refresh-токены) её DROP'нул и не
|
||||||
|
-- пересоздал — на любой БД, накатываемой с нуля (CI, sqlx::test, новый
|
||||||
|
-- инстанс), следующий ALTER TABLE падал на несуществующей таблице. CREATE
|
||||||
|
-- TABLE IF NOT EXISTS чинит fresh-инсталл; ALTER/CREATE INDEX ниже остаются
|
||||||
|
-- IF NOT EXISTS на случай окружений, где таблица уже существует в старом виде.
|
||||||
|
CREATE TABLE IF NOT EXISTS auth_sessions (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID,
|
||||||
|
auth_code VARCHAR(8) NOT NULL UNIQUE,
|
||||||
|
is_verified BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
expires_at TIMESTAMPTZ NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
ALTER TABLE auth_sessions ADD COLUMN IF NOT EXISTS created_at TIMESTAMPTZ NOT NULL DEFAULT NOW();
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_auth_sessions_code ON auth_sessions(auth_code);
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
-- =====================================================================
|
||||||
|
-- РОЛЕВАЯ АДМИНКА: Owner / Moderator / Partner
|
||||||
|
-- =====================================================================
|
||||||
|
-- Раньше единственным способом получить админ-доступ была привязка к
|
||||||
|
-- ADMIN_TG_ID (см. AuthGuard::admin_guard) — один захардкоженный Telegram-
|
||||||
|
-- аккаунт на весь деплой. Теперь роль полноценно хранится в БД, у
|
||||||
|
-- Owner/Moderator/Partner есть логин+пароль (не Telegram/seed), а
|
||||||
|
-- ADMIN_TG_ID выпиливается из кода вместе с этой миграцией.
|
||||||
|
ALTER TABLE users DROP CONSTRAINT IF EXISTS users_role_check;
|
||||||
|
ALTER TABLE users
|
||||||
|
ADD CONSTRAINT users_role_check
|
||||||
|
CHECK (role IN ('user', 'owner', 'moderator', 'partner', 'admin'));
|
||||||
|
-- 'admin' оставлен в допустимых значениях только ради обратной
|
||||||
|
-- совместимости с уже существующими строками — новый код его не
|
||||||
|
-- присваивает нигде.
|
||||||
|
|
||||||
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS username VARCHAR(50) UNIQUE;
|
||||||
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS password_hash TEXT;
|
||||||
|
-- Право модератора управлять нодами — переключается Owner'ом поштучно,
|
||||||
|
-- а не зашито в саму роль (см. AuthGuard::node_manage_guard).
|
||||||
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS can_manage_nodes BOOLEAN NOT NULL DEFAULT FALSE;
|
||||||
|
-- Фиксированный процент партнёра с продаж приведённых им покупателей.
|
||||||
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS commission_percent NUMERIC(5,2);
|
||||||
|
-- Короткий код для диплинка t.me/<bot>?start=<partner_code> — префикс "p_"
|
||||||
|
-- нужен боту, чтобы однозначно отличать партнёрский код от numeric
|
||||||
|
-- referrer-tg_id и от auth_sessions.auth_code (см. bot.rs::Command::Start).
|
||||||
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS partner_code VARCHAR(20) UNIQUE;
|
||||||
|
-- Постоянная привязка покупателя к партнёру, выставляется один раз при
|
||||||
|
-- регистрации через диплинк (first-wins, см. set_referred_by_partner).
|
||||||
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS referred_by_partner_id UUID REFERENCES users(id);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_users_referred_by_partner ON users(referred_by_partner_id)
|
||||||
|
WHERE referred_by_partner_id IS NOT NULL;
|
||||||
|
|
||||||
|
-- Леджер начислений — по одной записи на оплаченный инвойс приведённого
|
||||||
|
-- покупателя, а не бегущий итог на одной строке (нужна история для вывода
|
||||||
|
-- средств). Валюта — валюта самого инвойса, без конвертации (см. BillingService::confirm_payment).
|
||||||
|
CREATE TABLE IF NOT EXISTS partner_commissions (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
partner_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
invoice_id UUID NOT NULL REFERENCES invoices(id) ON DELETE CASCADE,
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
currency VARCHAR(10) NOT NULL,
|
||||||
|
amount BIGINT NOT NULL,
|
||||||
|
percent_at_time NUMERIC(5,2) NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
UNIQUE(invoice_id) -- защита от повторного начисления при retry confirm_payment
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_partner_commissions_partner_currency
|
||||||
|
ON partner_commissions(partner_id, currency);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS withdrawal_requests (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
partner_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
currency VARCHAR(10) NOT NULL,
|
||||||
|
amount BIGINT NOT NULL,
|
||||||
|
status VARCHAR(20) NOT NULL DEFAULT 'pending'
|
||||||
|
CHECK (status IN ('pending', 'approved', 'rejected', 'paid')),
|
||||||
|
note TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
processed_at TIMESTAMPTZ,
|
||||||
|
processed_by UUID REFERENCES users(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_withdrawal_requests_partner_status
|
||||||
|
ON withdrawal_requests(partner_id, status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_withdrawal_requests_status ON withdrawal_requests(status);
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
-- =====================================================================
|
||||||
|
-- ТЕХПОДДЕРЖКА: роль Support + тикеты через Telegram-бота
|
||||||
|
-- =====================================================================
|
||||||
|
-- Единственный канал связи с поддержкой — бот (см. src/bot.rs). Кнопка
|
||||||
|
-- "Поддержка" переводит юзера в разовый режим ожидания сообщения
|
||||||
|
-- (awaiting_support_message), сбрасывается сразу после получения текста —
|
||||||
|
-- случайное сообщение мимо кнопки тикет не создаёт.
|
||||||
|
ALTER TABLE users DROP CONSTRAINT IF EXISTS users_role_check;
|
||||||
|
ALTER TABLE users
|
||||||
|
ADD CONSTRAINT users_role_check
|
||||||
|
CHECK (role IN ('user', 'owner', 'moderator', 'partner', 'support', 'admin'));
|
||||||
|
|
||||||
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS awaiting_support_message BOOLEAN NOT NULL DEFAULT FALSE;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS support_tickets (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
status VARCHAR(20) NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'closed')),
|
||||||
|
-- Информационно: кто последним ответил. Не enforced-назначение — любой
|
||||||
|
-- staff/support видит и отвечает на любой тикет.
|
||||||
|
assigned_to UUID REFERENCES users(id),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_support_tickets_status ON support_tickets(status, updated_at DESC);
|
||||||
|
-- Быстро найти "есть ли уже открытый тикет у юзера" — частичный индекс,
|
||||||
|
-- т.к. это единственный поиск по user_id, который реально нужен в горячем пути.
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_support_tickets_user_open ON support_tickets(user_id)
|
||||||
|
WHERE status = 'open';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS support_messages (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
ticket_id UUID NOT NULL REFERENCES support_tickets(id) ON DELETE CASCADE,
|
||||||
|
sender_role VARCHAR(10) NOT NULL CHECK (sender_role IN ('user', 'staff')),
|
||||||
|
sender_user_id UUID REFERENCES users(id),
|
||||||
|
body TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_support_messages_ticket ON support_messages(ticket_id, created_at);
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
-- Расширение регионального прайса: UAH/BYN/VND/UZS — по аналогии с уже
|
||||||
|
-- заведёнными USD/RUB/CNY/TRY/IRR (см. 0001_init.sql). Валюты выбраны по
|
||||||
|
-- уже сделанным в этой сессии локалям (uk/be/vi/uz).
|
||||||
|
--
|
||||||
|
-- Курс и скидка (~45% от чистой рыночной конвертации, тот же порядок, что
|
||||||
|
-- уже неявно заложен в RUB/CNY/TRY) — ПРИКИДКА для старта, не сверено с
|
||||||
|
-- реальными локальными конкурентами по каждому рынку. Легко поправить —
|
||||||
|
-- это просто UPDATE по этой же таблице, менять код не нужно.
|
||||||
|
--
|
||||||
|
-- VND/UZS — целыми единицами (без копеек/копий), та же логика, что уже у
|
||||||
|
-- IRR: у донга и сума нет ходовой разменной монеты, дробные суммы не имеют
|
||||||
|
-- смысла для конечного пользователя.
|
||||||
|
INSERT INTO plan_prices (plan_name, currency, amount) VALUES
|
||||||
|
-- =================================================================
|
||||||
|
-- УКРАИНА (UAH)
|
||||||
|
-- =================================================================
|
||||||
|
('GHOST_1M', 'UAH', 11900), -- 119.00 ₴
|
||||||
|
('GHOST_6M', 'UAH', 59900), -- 599.00 ₴
|
||||||
|
('GHOST_12M', 'UAH', 89900), -- 899.00 ₴
|
||||||
|
|
||||||
|
('NETRUNNER_1M', 'UAH', 21900), -- 219.00 ₴
|
||||||
|
('NETRUNNER_6M', 'UAH', 109900), -- 1 099.00 ₴
|
||||||
|
('NETRUNNER_12M', 'UAH', 149900), -- 1 499.00 ₴
|
||||||
|
|
||||||
|
('MAINFRAME_1M', 'UAH', 49900), -- 499.00 ₴
|
||||||
|
('MAINFRAME_6M', 'UAH', 229900), -- 2 299.00 ₴
|
||||||
|
('MAINFRAME_12M', 'UAH', 349900), -- 3 499.00 ₴
|
||||||
|
|
||||||
|
-- =================================================================
|
||||||
|
-- БЕЛАРУСЬ (BYN)
|
||||||
|
-- =================================================================
|
||||||
|
('GHOST_1M', 'BYN', 900), -- 9.00 Br
|
||||||
|
('GHOST_6M', 'BYN', 4400), -- 44.00 Br
|
||||||
|
('GHOST_12M', 'BYN', 6500), -- 65.00 Br
|
||||||
|
|
||||||
|
('NETRUNNER_1M', 'BYN', 1650), -- 16.50 Br
|
||||||
|
('NETRUNNER_6M', 'BYN', 8200), -- 82.00 Br
|
||||||
|
('NETRUNNER_12M', 'BYN', 10900), -- 109.00 Br
|
||||||
|
|
||||||
|
('MAINFRAME_1M', 'BYN', 3600), -- 36.00 Br
|
||||||
|
('MAINFRAME_6M', 'BYN', 17500), -- 175.00 Br
|
||||||
|
('MAINFRAME_12M', 'BYN', 26000), -- 260.00 Br
|
||||||
|
|
||||||
|
-- =================================================================
|
||||||
|
-- ВЬЕТНАМ (VND) — целыми донгами, без копеек (см. заголовок)
|
||||||
|
-- =================================================================
|
||||||
|
('GHOST_1M', 'VND', 69000), -- 69 000 ₫
|
||||||
|
('GHOST_6M', 'VND', 329000), -- 329 000 ₫
|
||||||
|
('GHOST_12M', 'VND', 499000), -- 499 000 ₫
|
||||||
|
|
||||||
|
('NETRUNNER_1M', 'VND', 125000), -- 125 000 ₫
|
||||||
|
('NETRUNNER_6M', 'VND', 619000), -- 619 000 ₫
|
||||||
|
('NETRUNNER_12M', 'VND', 829000), -- 829 000 ₫
|
||||||
|
|
||||||
|
('MAINFRAME_1M', 'VND', 279000), -- 279 000 ₫
|
||||||
|
('MAINFRAME_6M', 'VND', 1329000), -- 1 329 000 ₫
|
||||||
|
('MAINFRAME_12M', 'VND', 1999000), -- 1 999 000 ₫
|
||||||
|
|
||||||
|
-- =================================================================
|
||||||
|
-- УЗБЕКИСТАН (UZS) — целыми сумами, без копеек (см. заголовок)
|
||||||
|
-- =================================================================
|
||||||
|
('GHOST_1M', 'UZS', 35000), -- 35 000 сум
|
||||||
|
('GHOST_6M', 'UZS', 169000), -- 169 000 сум
|
||||||
|
('GHOST_12M', 'UZS', 249000), -- 249 000 сум
|
||||||
|
|
||||||
|
('NETRUNNER_1M', 'UZS', 63000), -- 63 000 сум
|
||||||
|
('NETRUNNER_6M', 'UZS', 319000), -- 319 000 сум
|
||||||
|
('NETRUNNER_12M', 'UZS', 419000), -- 419 000 сум
|
||||||
|
|
||||||
|
('MAINFRAME_1M', 'UZS', 139000), -- 139 000 сум
|
||||||
|
('MAINFRAME_6M', 'UZS', 669000), -- 669 000 сум
|
||||||
|
('MAINFRAME_12M', 'UZS', 999000) -- 999 000 сум
|
||||||
|
ON CONFLICT (plan_name, currency) DO UPDATE
|
||||||
|
SET amount = EXCLUDED.amount;
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
-- Старая IRR-цена (из 0001_init.sql) была рассчитана под курс риала в разы
|
||||||
|
-- крепче нынешнего — за прошедшее время из-за девальвации превратилась из
|
||||||
|
-- скидки в переплату (иранский пользователь платил на 65-186% БОЛЬШЕ, чем
|
||||||
|
-- американский, в реальном долларовом эквиваленте — см. обсуждение сессии).
|
||||||
|
--
|
||||||
|
-- Пересчитано по актуальному рыночному курсу (~1 820 500 ﷼/$, свободный
|
||||||
|
-- рынок, не официальный) с той же целевой скидкой ~55%, что и у остальных
|
||||||
|
-- региональных валют (RUB/CNY/TRY/UAH/BYN/VND/UZS — все в диапазоне 42-65%
|
||||||
|
-- от номинала). Получилась чистая формула: цена в риалах = цена в долларах
|
||||||
|
-- × 1 000 000 — легко проверить и поддерживать вручную при следующей
|
||||||
|
-- девальвации, не нужно каждый раз лезть в код.
|
||||||
|
UPDATE plan_prices SET amount = 5000000 WHERE plan_name = 'GHOST_1M' AND currency = 'IRR';
|
||||||
|
UPDATE plan_prices SET amount = 24000000 WHERE plan_name = 'GHOST_6M' AND currency = 'IRR';
|
||||||
|
UPDATE plan_prices SET amount = 36000000 WHERE plan_name = 'GHOST_12M' AND currency = 'IRR';
|
||||||
|
|
||||||
|
UPDATE plan_prices SET amount = 9000000 WHERE plan_name = 'NETRUNNER_1M' AND currency = 'IRR';
|
||||||
|
UPDATE plan_prices SET amount = 45000000 WHERE plan_name = 'NETRUNNER_6M' AND currency = 'IRR';
|
||||||
|
UPDATE plan_prices SET amount = 60000000 WHERE plan_name = 'NETRUNNER_12M' AND currency = 'IRR';
|
||||||
|
|
||||||
|
UPDATE plan_prices SET amount = 20000000 WHERE plan_name = 'MAINFRAME_1M' AND currency = 'IRR';
|
||||||
|
UPDATE plan_prices SET amount = 96000000 WHERE plan_name = 'MAINFRAME_6M' AND currency = 'IRR';
|
||||||
|
UPDATE plan_prices SET amount = 144000000 WHERE plan_name = 'MAINFRAME_12M' AND currency = 'IRR';
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# Часть 1/2 — включается всегда, с самого первого деплоя (см.
|
||||||
|
# .gitea/workflows/deploy-nginx.yml). Обслуживает ACME-challenge для certbot
|
||||||
|
# и редиректит всё остальное на HTTPS. account-ssl.conf (сам бэкенд) деплой
|
||||||
|
# включает отдельно, только когда сертификат уже существует.
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
listen [::]:80;
|
||||||
|
server_name account.netrunner-vpn.com;
|
||||||
|
|
||||||
|
location /.well-known/acme-challenge/ {
|
||||||
|
root /var/www/certbot;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
return 301 https://$host$request_uri;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# Часть 2/2 — деплой (.gitea/workflows/deploy-nginx.yml) включает этот файл
|
||||||
|
# ТОЛЬКО когда на сервере уже есть сертификат
|
||||||
|
# /etc/letsencrypt/live/account.netrunner-vpn.com/ (иначе nginx -t упадёт).
|
||||||
|
#
|
||||||
|
# Реальный IP посетителя восстанавливается из CF-Connecting-IP —
|
||||||
|
# см. /etc/nginx/conf.d/cloudflare-real-ip.conf на самом сервере (общий для
|
||||||
|
# всех доменов на этом VPS, не часть этого репозитория) — без него
|
||||||
|
# rate-limiting (tower_governor) считал бы все запросы приходящими с одного
|
||||||
|
# IP Cloudflare, а не реальных клиентов.
|
||||||
|
server {
|
||||||
|
listen 443 ssl;
|
||||||
|
listen [::]:443 ssl;
|
||||||
|
http2 on;
|
||||||
|
server_name account.netrunner-vpn.com;
|
||||||
|
|
||||||
|
ssl_certificate /etc/letsencrypt/live/account.netrunner-vpn.com/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/account.netrunner-vpn.com/privkey.pem;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:8080;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
global:
|
|
||||||
scrape_interval: 15s # Как часто опрашивать бэкенд
|
|
||||||
|
|
||||||
scrape_configs:
|
|
||||||
- job_name: "netrunner-backend"
|
|
||||||
static_configs:
|
|
||||||
- targets: ["app:8080"] # Адрес твоего Rust-бэкенда
|
|
||||||
+5
-1
@@ -1,3 +1,7 @@
|
|||||||
|
# Тонкий promtail — шлёт логи ЭТОГО хоста (app/migrate/redis) в центральный
|
||||||
|
# Loki на VPS с данными (см. netrunner-data/docker-compose.observability.yml).
|
||||||
|
# LOKI_PUSH_URL — публичный адрес того VPS, порт 3100, обязательно
|
||||||
|
# зафайрволенный там на IP этого хоста (см. шапку docker-compose.observability.yml).
|
||||||
server:
|
server:
|
||||||
http_listen_port: 9080
|
http_listen_port: 9080
|
||||||
grpc_listen_port: 0
|
grpc_listen_port: 0
|
||||||
@@ -6,7 +10,7 @@ positions:
|
|||||||
filename: /tmp/positions.yaml
|
filename: /tmp/positions.yaml
|
||||||
|
|
||||||
clients:
|
clients:
|
||||||
- url: http://loki:3100/loki/api/v1/push
|
- url: ${LOKI_PUSH_URL}/loki/api/v1/push
|
||||||
|
|
||||||
scrape_configs:
|
scrape_configs:
|
||||||
- job_name: docker_service_logs
|
- job_name: docker_service_logs
|
||||||
|
|||||||
+81
-5
@@ -17,7 +17,9 @@ use axum::{
|
|||||||
use metrics_exporter_prometheus::{Matcher, PrometheusBuilder};
|
use metrics_exporter_prometheus::{Matcher, PrometheusBuilder};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use std::{sync::Arc, time::Instant};
|
use std::{sync::Arc, time::Instant};
|
||||||
use tower_governor::{governor::GovernorConfigBuilder, GovernorLayer};
|
use tower_governor::{
|
||||||
|
governor::GovernorConfigBuilder, key_extractor::SmartIpKeyExtractor, GovernorLayer,
|
||||||
|
};
|
||||||
use tower_http::{
|
use tower_http::{
|
||||||
cors::CorsLayer,
|
cors::CorsLayer,
|
||||||
services::{ServeDir, ServeFile},
|
services::{ServeDir, ServeFile},
|
||||||
@@ -28,8 +30,12 @@ use crate::db::repository::AppRepository;
|
|||||||
use crate::modules::{
|
use crate::modules::{
|
||||||
auth::{controller as auth_ctrl, service::AuthService},
|
auth::{controller as auth_ctrl, service::AuthService},
|
||||||
billing::{controller as bill_ctrl, service::BillingService},
|
billing::{controller as bill_ctrl, service::BillingService},
|
||||||
|
geoip::service::GeoipService,
|
||||||
nodes::{controller as node_ctrl, service::NodeService},
|
nodes::{controller as node_ctrl, service::NodeService},
|
||||||
|
proxy_internal,
|
||||||
referrals::{controller as ref_ctrl, service::ReferralService},
|
referrals::{controller as ref_ctrl, service::ReferralService},
|
||||||
|
staff::{controller as staff_ctrl, service::StaffService},
|
||||||
|
support::{controller as support_ctrl, service::SupportService},
|
||||||
users::{controller as user_ctrl, service::UserService},
|
users::{controller as user_ctrl, service::UserService},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -40,9 +46,24 @@ pub struct ApiState {
|
|||||||
pub node_service: NodeService,
|
pub node_service: NodeService,
|
||||||
pub referral_service: ReferralService,
|
pub referral_service: ReferralService,
|
||||||
pub user_service: UserService,
|
pub user_service: UserService,
|
||||||
|
pub staff_service: StaffService,
|
||||||
|
pub support_service: SupportService,
|
||||||
|
/// Страна-по-IP + грубая VPN/датацентр-эвристика — определяет валюту
|
||||||
|
/// региональной цены в `GET /plans` (см. modules::geoip). Не используется
|
||||||
|
/// в боте (Telegram Bot API не отдаёт IP пользователя вообще) — там
|
||||||
|
/// валюта определяется по `language_code`, см. bot.rs.
|
||||||
|
pub geoip_service: GeoipService,
|
||||||
pub jwt_secret: String,
|
pub jwt_secret: String,
|
||||||
pub bot_token: String,
|
pub bot_token: String,
|
||||||
pub admin_tg_id: i64,
|
/// Юзернейм бота (без `@`) — отдаётся фронту через `GET /api/v1/config`.
|
||||||
|
/// Не публичный build-time env (`VITE_BOT_USERNAME`): фронт — статический
|
||||||
|
/// SPA-бандл, собираемый один раз в CI и одинаковый что для прод-, что для
|
||||||
|
/// dev-деплоя, значение зашилось бы намертво в конкретную сборку и не
|
||||||
|
/// подхватывало бы разные боты на разных стендах (ровно так и оказалось на
|
||||||
|
/// практике — dev-стенд показывал прод-бота, у которого домен в BotFather
|
||||||
|
/// не совпадает с dev-доменом → "Bot domain invalid"). Читается на сервере
|
||||||
|
/// заново на каждый запрос, поэтому корректно различается между стендами.
|
||||||
|
pub bot_username: String,
|
||||||
/// `Domain` для сессионных cookie. Пусто (по умолчанию, локальная разработка) →
|
/// `Domain` для сессионных cookie. Пусто (по умолчанию, локальная разработка) →
|
||||||
/// cookie host-only. В проде — `.netrunner-vpn.com`, чтобы одна сессия работала
|
/// cookie host-only. В проде — `.netrunner-vpn.com`, чтобы одна сессия работала
|
||||||
/// и на лендинге, и на ЛК (общий родительский домен, разные сабдомены).
|
/// и на лендинге, и на ЛК (общий родительский домен, разные сабдомены).
|
||||||
@@ -58,6 +79,9 @@ pub struct ApiState {
|
|||||||
/// (CSRF-проверка в `auth::guard`). Отдельно от `CORS_ALLOWED_ORIGINS`, т.к. туда
|
/// (CSRF-проверка в `auth::guard`). Отдельно от `CORS_ALLOWED_ORIGINS`, т.к. туда
|
||||||
/// же попадают Bearer-клиенты (Tauri), для которых CSRF неактуален.
|
/// же попадают Bearer-клиенты (Tauri), для которых CSRF неактуален.
|
||||||
pub csrf_allowed_origins: Vec<String>,
|
pub csrf_allowed_origins: Vec<String>,
|
||||||
|
/// Общий секрет для `netrunner-proxy` (`X-Internal-Secret`) — не пользовательская
|
||||||
|
/// авторизация, см. `modules::proxy_internal::internal_secret_guard`.
|
||||||
|
pub proxy_internal_secret: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Clone for ApiState {
|
impl Clone for ApiState {
|
||||||
@@ -69,12 +93,16 @@ impl Clone for ApiState {
|
|||||||
node_service: self.node_service.clone(),
|
node_service: self.node_service.clone(),
|
||||||
referral_service: self.referral_service.clone(),
|
referral_service: self.referral_service.clone(),
|
||||||
user_service: self.user_service.clone(),
|
user_service: self.user_service.clone(),
|
||||||
|
staff_service: self.staff_service.clone(),
|
||||||
|
support_service: self.support_service.clone(),
|
||||||
|
geoip_service: self.geoip_service.clone(),
|
||||||
jwt_secret: self.jwt_secret.clone(),
|
jwt_secret: self.jwt_secret.clone(),
|
||||||
bot_token: self.bot_token.clone(),
|
bot_token: self.bot_token.clone(),
|
||||||
admin_tg_id: self.admin_tg_id,
|
bot_username: self.bot_username.clone(),
|
||||||
cookie_domain: self.cookie_domain.clone(),
|
cookie_domain: self.cookie_domain.clone(),
|
||||||
cookie_secure: self.cookie_secure,
|
cookie_secure: self.cookie_secure,
|
||||||
csrf_allowed_origins: self.csrf_allowed_origins.clone(),
|
csrf_allowed_origins: self.csrf_allowed_origins.clone(),
|
||||||
|
proxy_internal_secret: self.proxy_internal_secret.clone(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -85,8 +113,15 @@ pub fn create_router(state: ApiState, frontend_dir: &str) -> Router {
|
|||||||
let static_path = std::path::PathBuf::from(frontend_dir);
|
let static_path = std::path::PathBuf::from(frontend_dir);
|
||||||
let index_path = static_path.join("index.html");
|
let index_path = static_path.join("index.html");
|
||||||
|
|
||||||
|
// SmartIpKeyExtractor читает x-forwarded-for/x-real-ip/forwarded (в этом
|
||||||
|
// порядке), падая на peer IP только если их нет. Без этого — дефолтный
|
||||||
|
// PeerIpKeyExtractor видел бы просто 127.0.0.1 (nginx проксирует локально)
|
||||||
|
// ОДИН И ТОТ ЖЕ для абсолютно всех посетителей сразу, и лимит превращался
|
||||||
|
// бы в глобальный на весь сервис, а не per-visitor (см. incident: 3 попытки
|
||||||
|
// логина от кого угодно — и /auth 429 для всех остальных до истечения окна).
|
||||||
let gov_conf = Arc::new(
|
let gov_conf = Arc::new(
|
||||||
GovernorConfigBuilder::default()
|
GovernorConfigBuilder::default()
|
||||||
|
.key_extractor(SmartIpKeyExtractor)
|
||||||
.per_millisecond(100)
|
.per_millisecond(100)
|
||||||
.burst_size(10)
|
.burst_size(10)
|
||||||
.finish()
|
.finish()
|
||||||
@@ -95,6 +130,7 @@ pub fn create_router(state: ApiState, frontend_dir: &str) -> Router {
|
|||||||
|
|
||||||
let seed_limit_conf = Arc::new(
|
let seed_limit_conf = Arc::new(
|
||||||
GovernorConfigBuilder::default()
|
GovernorConfigBuilder::default()
|
||||||
|
.key_extractor(SmartIpKeyExtractor)
|
||||||
.per_millisecond(5000)
|
.per_millisecond(5000)
|
||||||
.burst_size(3)
|
.burst_size(3)
|
||||||
.finish()
|
.finish()
|
||||||
@@ -116,12 +152,38 @@ pub fn create_router(state: ApiState, frontend_dir: &str) -> Router {
|
|||||||
auth_ctrl::router().layer(GovernorLayer::new(seed_limit_conf)),
|
auth_ctrl::router().layer(GovernorLayer::new(seed_limit_conf)),
|
||||||
)
|
)
|
||||||
.nest("/users", user_ctrl::router(state.clone()))
|
.nest("/users", user_ctrl::router(state.clone()))
|
||||||
.nest("/billing", bill_ctrl::router(state.clone()))
|
.nest(
|
||||||
|
"/billing",
|
||||||
|
bill_ctrl::router(state.clone()).merge(bill_ctrl::public_router()),
|
||||||
|
)
|
||||||
.nest("/nodes", node_ctrl::public_router(state.clone()))
|
.nest("/nodes", node_ctrl::public_router(state.clone()))
|
||||||
.nest("/referrals", ref_ctrl::router(state.clone()))
|
.nest("/referrals", ref_ctrl::router(state.clone()))
|
||||||
|
.route("/config", get(get_public_config))
|
||||||
.layer(GovernorLayer::new(gov_conf));
|
.layer(GovernorLayer::new(gov_conf));
|
||||||
|
|
||||||
let admin_routes_v1 = Router::new().nest("/nodes", node_ctrl::admin_router(state.clone()));
|
let admin_routes_v1 = Router::new()
|
||||||
|
.nest("/nodes", node_ctrl::admin_router(state.clone()))
|
||||||
|
.nest("/users", user_ctrl::admin_router(state.clone()))
|
||||||
|
.nest("/staff", staff_ctrl::router(state.clone()))
|
||||||
|
.nest("/staff", staff_ctrl::owner_router(state.clone()))
|
||||||
|
.nest("/staff", staff_ctrl::staff_router(state.clone()))
|
||||||
|
.nest("/partners", staff_ctrl::partner_router(state.clone()))
|
||||||
|
.nest("/support", support_ctrl::router(state.clone()))
|
||||||
|
.nest(
|
||||||
|
"/observability",
|
||||||
|
staff_ctrl::observability_router(state.clone()),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Внутренний контракт для прокси-нод — отдельный секрет, не auth_guard
|
||||||
|
// (прокси не пользовательская сессия) и не участвует в CORS ниже (никогда
|
||||||
|
// не вызывается из браузера).
|
||||||
|
let internal_routes = Router::new().nest(
|
||||||
|
"/internal",
|
||||||
|
proxy_internal::router().layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
proxy_internal::internal_secret_guard,
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
|
||||||
// Строгий CORS. Список разрешённых origin берём из env (через запятую),
|
// Строгий CORS. Список разрешённых origin берём из env (через запятую),
|
||||||
// чтобы помимо веб-фронта пускать и десктоп/мобайл-приложение (Tauri webview).
|
// чтобы помимо веб-фронта пускать и десктоп/мобайл-приложение (Tauri webview).
|
||||||
@@ -145,6 +207,7 @@ pub fn create_router(state: ApiState, frontend_dir: &str) -> Router {
|
|||||||
Router::new()
|
Router::new()
|
||||||
.nest("/api/v1", api_routes_v1)
|
.nest("/api/v1", api_routes_v1)
|
||||||
.nest("/api/v1/admin", admin_routes_v1)
|
.nest("/api/v1/admin", admin_routes_v1)
|
||||||
|
.nest("/api/v1", internal_routes)
|
||||||
// Liveness/readiness пробы для Docker/оркестратора и балансировщика.
|
// Liveness/readiness пробы для Docker/оркестратора и балансировщика.
|
||||||
.route("/health", get(health_live))
|
.route("/health", get(health_live))
|
||||||
.route("/health/ready", get(health_ready))
|
.route("/health/ready", get(health_ready))
|
||||||
@@ -163,6 +226,19 @@ pub fn create_router(state: ApiState, frontend_dir: &str) -> Router {
|
|||||||
.with_state(state)
|
.with_state(state)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Публичная (без auth_guard) рантайм-конфигурация для статического SPA —
|
||||||
|
/// см. `ApiState::bot_username` про то, почему это не build-time env фронта.
|
||||||
|
async fn get_public_config(State(state): State<ApiState>) -> impl IntoResponse {
|
||||||
|
// Публичный URL Grafana (за nginx + HTTP Basic Auth на VPS с данными,
|
||||||
|
// см. netrunner-data/nginx/) — тот же паттерн, что и
|
||||||
|
// bot_username выше: читается на сервере заново на каждый запрос, а не
|
||||||
|
// зашивается в SPA-бандл на этапе сборки, т.к. разный на разных стендах.
|
||||||
|
// `None`, пока GRAFANA_PUBLIC_URL не задан — ObservabilityPage.tsx в
|
||||||
|
// этом случае просто не показывает ссылку/iframe.
|
||||||
|
let grafana_url = std::env::var("GRAFANA_PUBLIC_URL").ok();
|
||||||
|
Json(json!({ "bot_username": state.bot_username, "grafana_url": grafana_url }))
|
||||||
|
}
|
||||||
|
|
||||||
/// Liveness: процесс жив и отвечает. Не трогает зависимости.
|
/// Liveness: процесс жив и отвечает. Не трогает зависимости.
|
||||||
async fn health_live() -> impl IntoResponse {
|
async fn health_live() -> impl IntoResponse {
|
||||||
(StatusCode::OK, Json(json!({ "status": "ok" })))
|
(StatusCode::OK, Json(json!({ "status": "ok" })))
|
||||||
|
|||||||
+365
-22
@@ -7,7 +7,9 @@
|
|||||||
//! `POST /auth/exchange`.
|
//! `POST /auth/exchange`.
|
||||||
|
|
||||||
use crate::modules::{
|
use crate::modules::{
|
||||||
auth::service::AuthService, billing::service::BillingService, users::service::UserService,
|
auth::service::AuthService, billing::service::BillingService,
|
||||||
|
referrals::service::ReferralService, support::service::SupportService,
|
||||||
|
users::service::UserService,
|
||||||
};
|
};
|
||||||
use teloxide::{
|
use teloxide::{
|
||||||
prelude::*,
|
prelude::*,
|
||||||
@@ -38,6 +40,8 @@ pub async fn run_bot(
|
|||||||
auth: AuthService,
|
auth: AuthService,
|
||||||
users: UserService,
|
users: UserService,
|
||||||
billing: BillingService,
|
billing: BillingService,
|
||||||
|
referrals: ReferralService,
|
||||||
|
support: SupportService,
|
||||||
cancel_token: CancellationToken,
|
cancel_token: CancellationToken,
|
||||||
) {
|
) {
|
||||||
let config = BotConfig {
|
let config = BotConfig {
|
||||||
@@ -48,18 +52,27 @@ pub async fn run_bot(
|
|||||||
.expect("КРИТИЧЕСКАЯ ОШИБКА: WEB_APP_BASE_URL не задан!"),
|
.expect("КРИТИЧЕСКАЯ ОШИБКА: WEB_APP_BASE_URL не задан!"),
|
||||||
};
|
};
|
||||||
|
|
||||||
let handler = Update::filter_message().branch(
|
// Ветка команд (/start, /menu) — первая. Всё, что не распозналось как
|
||||||
|
// команда, падает во вторую ветку: там же живёт разовый захват
|
||||||
|
// свободного текста для тикетов поддержки (см. handle_free_text) —
|
||||||
|
// единственный канал создания тикета, кнопка "Поддержка" выставляет
|
||||||
|
// User::awaiting_support_message перед этим.
|
||||||
|
let handler = Update::filter_message()
|
||||||
|
.branch(
|
||||||
dptree::entry()
|
dptree::entry()
|
||||||
.filter_command::<Command>()
|
.filter_command::<Command>()
|
||||||
.endpoint(handle_commands),
|
.endpoint(handle_commands),
|
||||||
);
|
)
|
||||||
|
.branch(dptree::entry().endpoint(handle_free_text));
|
||||||
let callback_handler = Update::filter_callback_query().endpoint(handle_callbacks);
|
let callback_handler = Update::filter_callback_query().endpoint(handle_callbacks);
|
||||||
|
|
||||||
let mut dispatcher = Dispatcher::builder(
|
let mut dispatcher = Dispatcher::builder(
|
||||||
bot,
|
bot,
|
||||||
dptree::entry().branch(handler).branch(callback_handler),
|
dptree::entry().branch(handler).branch(callback_handler),
|
||||||
)
|
)
|
||||||
.dependencies(dptree::deps![auth, users, billing, config])
|
.dependencies(dptree::deps![
|
||||||
|
auth, users, billing, referrals, support, config
|
||||||
|
])
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
@@ -68,6 +81,52 @@ pub async fn run_bot(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Свободный текст вне команд/кнопок. Единственное осмысленное действие —
|
||||||
|
/// сообщение в поддержку, и только когда юзер только что нажал кнопку
|
||||||
|
/// "Поддержка" (см. `menu_support` в `handle_callbacks`) — иначе (как и
|
||||||
|
/// раньше, до этого хендлера) молча игнорируется, никакой регрессии для
|
||||||
|
/// случайных сообщений мимо кнопки.
|
||||||
|
#[instrument(skip(bot, users, support), fields(chat_id = msg.chat.id.0))]
|
||||||
|
async fn handle_free_text(
|
||||||
|
bot: Bot,
|
||||||
|
msg: Message,
|
||||||
|
users: UserService,
|
||||||
|
support: SupportService,
|
||||||
|
) -> ResponseResult<()> {
|
||||||
|
let Some(text) = msg.text() else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
let tg_id = msg.chat.id.0;
|
||||||
|
|
||||||
|
let user = match users.get_user_profile(tg_id).await {
|
||||||
|
Ok(Some(u)) => u,
|
||||||
|
_ => return Ok(()),
|
||||||
|
};
|
||||||
|
|
||||||
|
if !user.awaiting_support_message {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
match support.handle_user_message(user.id, text).await {
|
||||||
|
Ok(()) => {
|
||||||
|
bot.send_message(
|
||||||
|
msg.chat.id,
|
||||||
|
"✅ Сообщение передано в поддержку. Мы ответим здесь же.",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!(error = ?e, tg_id, "Не удалось сохранить сообщение в поддержку");
|
||||||
|
bot.send_message(
|
||||||
|
msg.chat.id,
|
||||||
|
"Не удалось отправить сообщение, попробуйте ещё раз.",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[instrument(skip(bot, auth), fields(chat_id = msg.chat.id.0, username = ?msg.chat.username()))]
|
#[instrument(skip(bot, auth), fields(chat_id = msg.chat.id.0, username = ?msg.chat.username()))]
|
||||||
async fn handle_commands(
|
async fn handle_commands(
|
||||||
bot: Bot,
|
bot: Bot,
|
||||||
@@ -82,14 +141,46 @@ async fn handle_commands(
|
|||||||
let code = if ref_code.is_empty() {
|
let code = if ref_code.is_empty() {
|
||||||
None
|
None
|
||||||
} else {
|
} else {
|
||||||
Some(ref_code)
|
Some(ref_code.clone())
|
||||||
};
|
};
|
||||||
|
|
||||||
// Авторизация теперь в AuthService
|
// Авторизация теперь в AuthService
|
||||||
let _ = auth
|
let login_result = auth
|
||||||
.process_login(tg_id, msg.chat.username().map(String::from), code)
|
.process_login(tg_id, msg.chat.username().map(String::from), code)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
|
// Стартовый параметр, который не парсится как tg_id реферера — три
|
||||||
|
// взаимоисключающих случая по формату: партнёрский код (префикс
|
||||||
|
// "p_" — см. StaffService::generate_partner_code), иначе
|
||||||
|
// auth_code из десктоп/мобильного приложения (magic-link вход,
|
||||||
|
// см. AuthService::create_telegram_login_code и
|
||||||
|
// POST /auth/telegram/session).
|
||||||
|
if !ref_code.is_empty() && ref_code.parse::<i64>().is_err() {
|
||||||
|
if ref_code.starts_with("p_") {
|
||||||
|
// Партнёрская привязка — постоянная, first-wins (см.
|
||||||
|
// set_referred_by_partner), безопасно вызывать даже для
|
||||||
|
// уже существующих юзеров, поэтому не гейтим на "новый юзер".
|
||||||
|
if let Ok(user) = &login_result {
|
||||||
|
if let Ok(Some(partner)) = auth.find_partner_by_code(&ref_code).await {
|
||||||
|
let _ = auth.attribute_partner_referral(partner.id, user.id).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if let Ok(user) = &login_result {
|
||||||
|
if auth
|
||||||
|
.confirm_telegram_login_code(&ref_code, user.id)
|
||||||
|
.await
|
||||||
|
.unwrap_or(false)
|
||||||
|
{
|
||||||
|
bot.send_message(
|
||||||
|
msg.chat.id,
|
||||||
|
"✅ Вход подтверждён. Вернитесь в приложение Netrunner.",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
bot.send_message(msg.chat.id, main_menu_text())
|
bot.send_message(msg.chat.id, main_menu_text())
|
||||||
.parse_mode(ParseMode::Html)
|
.parse_mode(ParseMode::Html)
|
||||||
.reply_markup(main_menu_keyboard())
|
.reply_markup(main_menu_keyboard())
|
||||||
@@ -105,15 +196,24 @@ async fn handle_commands(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[instrument(skip(bot, auth, users, billing, config), fields(user_id = q.from.id.0))]
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
#[instrument(skip(bot, auth, users, billing, referrals, support, config), fields(user_id = q.from.id.0))]
|
||||||
async fn handle_callbacks(
|
async fn handle_callbacks(
|
||||||
bot: Bot,
|
bot: Bot,
|
||||||
q: CallbackQuery,
|
q: CallbackQuery,
|
||||||
auth: AuthService,
|
auth: AuthService,
|
||||||
users: UserService,
|
users: UserService,
|
||||||
billing: BillingService,
|
billing: BillingService,
|
||||||
|
referrals: ReferralService,
|
||||||
|
support: SupportService,
|
||||||
config: BotConfig,
|
config: BotConfig,
|
||||||
) -> ResponseResult<()> {
|
) -> ResponseResult<()> {
|
||||||
|
// Единственный доступный сигнал региона в боте — Bot API не отдаёт IP
|
||||||
|
// пользователя вообще (в отличие от сайта, см.
|
||||||
|
// billing::controller::list_plans_api). Грубее геолокации (язык клиента
|
||||||
|
// Telegram ≠ страна — диаспора, мультиязычные регионы), но лучше, чем
|
||||||
|
// показывать всем только USD.
|
||||||
|
let lang_code = q.from.language_code.clone();
|
||||||
if let (Some(data), Some(msg)) = (q.data, q.message) {
|
if let (Some(data), Some(msg)) = (q.data, q.message) {
|
||||||
let tg_id = q.from.id.0 as i64;
|
let tg_id = q.from.id.0 as i64;
|
||||||
info!(callback_data = %data, "Пользователь нажал кнопку в боте");
|
info!(callback_data = %data, "Пользователь нажал кнопку в боте");
|
||||||
@@ -129,12 +229,21 @@ async fn handle_callbacks(
|
|||||||
),
|
),
|
||||||
_ => "📡 Статус: <b>НЕТ ПОДПИСКИ</b>".to_string(),
|
_ => "📡 Статус: <b>НЕТ ПОДПИСКИ</b>".to_string(),
|
||||||
};
|
};
|
||||||
|
let traffic_status = match u.data_limit_bytes {
|
||||||
|
Some(limit) => format!(
|
||||||
|
"📶 Трафик: {} из {}",
|
||||||
|
format_bytes(u.data_used_bytes),
|
||||||
|
format_bytes(limit)
|
||||||
|
),
|
||||||
|
None => format!("📶 Трафик: {} (безлимит)", format_bytes(u.data_used_bytes)),
|
||||||
|
};
|
||||||
let text = format!(
|
let text = format!(
|
||||||
"🤖 <b>ПРОФИЛЬ ОПЕРАТИВНИКА</b>\n\n👤 Логин: @{}\n💰 Баланс: <code>{} E$</code>\n🎟 Билеты: <code>{}</code>\n\n{}",
|
"🤖 <b>ПРОФИЛЬ ОПЕРАТИВНИКА</b>\n\n👤 Логин: @{}\n💰 Баланс: <code>{} E$</code>\n🎟 Билеты: <code>{}</code>\n\n{}\n{}",
|
||||||
u.tg_username.unwrap_or_else(|| "Anon".into()),
|
u.tg_username.unwrap_or_else(|| "Anon".into()),
|
||||||
u.balance,
|
u.balance,
|
||||||
u.game_tickets,
|
u.game_tickets,
|
||||||
sub_status
|
sub_status,
|
||||||
|
traffic_status
|
||||||
);
|
);
|
||||||
(text, profile_keyboard())
|
(text, profile_keyboard())
|
||||||
}
|
}
|
||||||
@@ -159,11 +268,27 @@ async fn handle_callbacks(
|
|||||||
"🖥 Открыть Терминал (WebApp)",
|
"🖥 Открыть Терминал (WebApp)",
|
||||||
)
|
)
|
||||||
.await,
|
.await,
|
||||||
// Тарифы/Синдикат ведут в тот же ЛК (Profile.tsx уже рисует и оплату через
|
// Выбор способа оплаты: TonConnect (как раньше, через ЛК/Profile.tsx)
|
||||||
// TonConnect, и реферальный блок на одной странице) — единый WebApp-мост
|
// или прямо в чате через @CryptoBot (см. menu_pay_crypto).
|
||||||
// через bootstrap-токен, который фронт меняет на cookie-сессию через
|
"menu_pay" => (
|
||||||
// POST /auth/exchange (см. App.tsx::useBootstrapTokenExchange).
|
"💳 <b>ТАРИФНЫЙ ПЛАН</b>\n\nВыберите способ оплаты:".into(),
|
||||||
"menu_pay" => webapp_bridge_response(
|
InlineKeyboardMarkup::new(vec![
|
||||||
|
vec![InlineKeyboardButton::callback(
|
||||||
|
"💠 TonConnect (кошелёк)",
|
||||||
|
"menu_pay_ton",
|
||||||
|
)],
|
||||||
|
vec![InlineKeyboardButton::callback(
|
||||||
|
"💎 Крипта через @CryptoBot",
|
||||||
|
"menu_pay_crypto",
|
||||||
|
)],
|
||||||
|
vec![InlineKeyboardButton::callback("🔙 Назад", "menu_main")],
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
// Тот же ЛК (Profile.tsx уже рисует оплату через TonConnect и
|
||||||
|
// реферальный блок) — единый WebApp-мост через bootstrap-токен,
|
||||||
|
// который фронт меняет на cookie-сессию через POST /auth/exchange
|
||||||
|
// (см. App.tsx::useBootstrapTokenExchange).
|
||||||
|
"menu_pay_ton" => webapp_bridge_response(
|
||||||
&auth,
|
&auth,
|
||||||
&users,
|
&users,
|
||||||
&config,
|
&config,
|
||||||
@@ -172,15 +297,220 @@ async fn handle_callbacks(
|
|||||||
"💳 Выбрать тариф (WebApp)",
|
"💳 Выбрать тариф (WebApp)",
|
||||||
)
|
)
|
||||||
.await,
|
.await,
|
||||||
"menu_ref" => webapp_bridge_response(
|
// Оплата криптой прямо в чате, без WebApp/кошелька — юзер платит
|
||||||
&auth,
|
// внутри @CryptoBot, возвращается и жмёт "Я оплатил". Показываем
|
||||||
&users,
|
// цену в валюте региона (по language_code, см. выше) для
|
||||||
&config,
|
// наглядности — САМА оплата остаётся в USD/USDT (см.
|
||||||
tg_id,
|
// pay_plan-ветку ниже): CryptoBotProvider биллит 1:1 к
|
||||||
"🔗 <b>СИНДИКАТ</b>\n\nОткройте Терминал, чтобы увидеть свою реферальную ссылку и статистику.",
|
// USD-цене тарифа, менять валюту списания нельзя, иначе при
|
||||||
"🔗 Открыть Синдикат (WebApp)",
|
// показе, скажем, VND юзер спишет в разы больше/меньше нужного.
|
||||||
|
"menu_pay_crypto" => match billing
|
||||||
|
.list_plans_in_currency(
|
||||||
|
lang_code
|
||||||
|
.as_deref()
|
||||||
|
.and_then(crate::modules::geoip::currency::currency_for_telegram_lang)
|
||||||
|
.unwrap_or("USD"),
|
||||||
)
|
)
|
||||||
.await,
|
.await
|
||||||
|
{
|
||||||
|
Ok((display_currency, plans)) if !plans.is_empty() => {
|
||||||
|
let rows = plans
|
||||||
|
.chunks(1)
|
||||||
|
.map(|chunk| {
|
||||||
|
chunk
|
||||||
|
.iter()
|
||||||
|
.map(|(name, amount)| {
|
||||||
|
InlineKeyboardButton::callback(
|
||||||
|
format!(
|
||||||
|
"{} — {}",
|
||||||
|
name,
|
||||||
|
crate::modules::geoip::currency::format_amount(
|
||||||
|
&display_currency,
|
||||||
|
*amount
|
||||||
|
)
|
||||||
|
),
|
||||||
|
format!("pay_plan:{}", name),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let mut kb = rows;
|
||||||
|
kb.push(vec![InlineKeyboardButton::callback("🔙 Назад", "menu_pay")]);
|
||||||
|
(
|
||||||
|
"💎 <b>ОПЛАТА ЧЕРЕЗ CRYPTOBOT</b>\n\nВыберите тариф:".into(),
|
||||||
|
InlineKeyboardMarkup::new(kb),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Ok(_) => (
|
||||||
|
"Тарифы временно недоступны.".into(),
|
||||||
|
back_keyboard(),
|
||||||
|
),
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!(error = ?e, "Не удалось получить список тарифов");
|
||||||
|
("Ошибка при получении тарифов.".into(), back_keyboard())
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data if data.starts_with("pay_plan:") => {
|
||||||
|
let plan_name = &data["pay_plan:".len()..];
|
||||||
|
match users.get_user_profile(tg_id).await {
|
||||||
|
Ok(Some(u)) => match billing.initiate_payment(&u, plan_name, "CRYPTOBOT", "USD").await {
|
||||||
|
Ok(checkout) => {
|
||||||
|
let invoice_id = checkout["invoice_id"].as_str().unwrap_or_default();
|
||||||
|
let cryptobot_invoice_id =
|
||||||
|
checkout["cryptobot_invoice_id"].as_i64().unwrap_or_default();
|
||||||
|
let pay_url = checkout["pay_url"].as_str().unwrap_or_default();
|
||||||
|
let kb = InlineKeyboardMarkup::new(vec![
|
||||||
|
vec![InlineKeyboardButton::url(
|
||||||
|
"💎 Оплатить в CryptoBot",
|
||||||
|
pay_url.parse().unwrap_or_else(|_| {
|
||||||
|
"https://t.me/CryptoBot".parse().unwrap()
|
||||||
|
}),
|
||||||
|
)],
|
||||||
|
vec![InlineKeyboardButton::callback(
|
||||||
|
"✅ Я оплатил, проверить",
|
||||||
|
format!("pay_check:{}:{}", invoice_id, cryptobot_invoice_id),
|
||||||
|
)],
|
||||||
|
vec![InlineKeyboardButton::callback("🔙 Назад", "menu_pay_crypto")],
|
||||||
|
]);
|
||||||
|
(
|
||||||
|
format!(
|
||||||
|
"💎 <b>ОПЛАТА: {}</b>\n\nОткройте ссылку и оплатите в @CryptoBot, затем вернитесь и нажмите «Я оплатил».",
|
||||||
|
plan_name
|
||||||
|
),
|
||||||
|
kb,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!(error = ?e, tg_id, plan_name, "Не удалось создать инвойс CryptoBot");
|
||||||
|
(
|
||||||
|
"Не удалось создать счёт на оплату. Попробуйте позже.".into(),
|
||||||
|
back_keyboard(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Ok(None) => (
|
||||||
|
"Оперативник не найден в базе данных.".into(),
|
||||||
|
back_keyboard(),
|
||||||
|
),
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!(error = ?e, tg_id, "КРИТИЧЕСКАЯ ОШИБКА при загрузке профиля для оплаты");
|
||||||
|
("Ошибка связи с ядром системы.".into(), back_keyboard())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
data if data.starts_with("pay_check:") => {
|
||||||
|
let rest = &data["pay_check:".len()..];
|
||||||
|
let (invoice_id_str, cryptobot_invoice_id) =
|
||||||
|
rest.split_once(':').unwrap_or((rest, ""));
|
||||||
|
match (
|
||||||
|
uuid::Uuid::parse_str(invoice_id_str),
|
||||||
|
users.get_user_profile(tg_id).await,
|
||||||
|
) {
|
||||||
|
(Ok(invoice_id), Ok(Some(u))) => {
|
||||||
|
match billing
|
||||||
|
.confirm_payment(u.id, invoice_id, cryptobot_invoice_id)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(()) => (
|
||||||
|
"✅ Оплата подтверждена, тариф активирован!".into(),
|
||||||
|
back_keyboard(),
|
||||||
|
),
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(error = ?e, tg_id, "Оплата CryptoBot ещё не подтверждена");
|
||||||
|
(
|
||||||
|
"⏳ Оплата пока не найдена. Если вы уже оплатили — подождите немного и нажмите ещё раз.".into(),
|
||||||
|
InlineKeyboardMarkup::new(vec![
|
||||||
|
vec![InlineKeyboardButton::callback(
|
||||||
|
"🔄 Проверить снова",
|
||||||
|
data,
|
||||||
|
)],
|
||||||
|
vec![InlineKeyboardButton::callback("🔙 Назад", "menu_main")],
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => (
|
||||||
|
"Некорректные данные платежа.".into(),
|
||||||
|
back_keyboard(),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Рефералка выводится нативно (без WebApp) — все данные уже есть
|
||||||
|
// на бэкенде (ReferralService::get_stats), а оплата (единственное,
|
||||||
|
// что реально требует браузера/кошелька) сюда не входит.
|
||||||
|
"menu_ref" => match users.get_user_profile(tg_id).await {
|
||||||
|
Ok(Some(u)) => match referrals.get_stats(u.id).await {
|
||||||
|
Ok((count, total_earned)) => (
|
||||||
|
format!(
|
||||||
|
"🔗 <b>СИНДИКАТ</b>\n\nВаша реферальная ссылка:\n<code>https://t.me/{}?start={}</code>\n\n👥 Приглашено: <b>{}</b>\n💰 Заработано: <code>{} E$</code>",
|
||||||
|
config.bot_username, tg_id, count, total_earned
|
||||||
|
),
|
||||||
|
back_keyboard(),
|
||||||
|
),
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!(error = ?e, tg_id, "Не удалось получить статистику рефералки");
|
||||||
|
("Ошибка при получении статистики.".into(), back_keyboard())
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Ok(None) => (
|
||||||
|
"Оперативник не найден в базе данных.".into(),
|
||||||
|
back_keyboard(),
|
||||||
|
),
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!(error = ?e, tg_id, "КРИТИЧЕСКАЯ ОШИБКА при загрузке профиля для рефералки");
|
||||||
|
("Ошибка связи с ядром системы.".into(), back_keyboard())
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// Единственный канал связи с поддержкой — переводит юзера в
|
||||||
|
// разовый режим ожидания (см. handle_free_text), следующее его
|
||||||
|
// текстовое сообщение станет тикетом/ответом в уже открытый.
|
||||||
|
"menu_support" => match users.get_user_profile(tg_id).await {
|
||||||
|
Ok(Some(u)) => {
|
||||||
|
if let Err(e) = support.set_awaiting(u.id).await {
|
||||||
|
tracing::error!(error = ?e, tg_id, "Не удалось выставить awaiting_support_message");
|
||||||
|
}
|
||||||
|
let mut text = "🆘 <b>ПОДДЕРЖКА</b>\n\nОпишите проблему одним сообщением — мы ответим здесь же.".to_string();
|
||||||
|
if let Ok(Some((_, messages))) = support.get_open_ticket_thread_for_user(u.id).await {
|
||||||
|
if !messages.is_empty() {
|
||||||
|
text.push_str("\n\n<b>Текущий тикет:</b>");
|
||||||
|
for m in messages.iter().rev().take(3).rev() {
|
||||||
|
let sender = if m.sender_role == "staff" { "Поддержка" } else { "Вы" };
|
||||||
|
text.push_str(&format!("\n\n<i>{}:</i> {}", sender, m.body));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(text, back_keyboard())
|
||||||
|
}
|
||||||
|
Ok(None) => (
|
||||||
|
"Оперативник не найден в базе данных.".into(),
|
||||||
|
back_keyboard(),
|
||||||
|
),
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!(error = ?e, tg_id, "КРИТИЧЕСКАЯ ОШИБКА при загрузке профиля для поддержки");
|
||||||
|
("Ошибка связи с ядром системы.".into(), back_keyboard())
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// Код для входа в десктоп/мобильное приложение — см. докстринг
|
||||||
|
// AuthService::create_bot_login_code про то, почему это надёжнее
|
||||||
|
// deep-link-варианта (`?start=`).
|
||||||
|
"menu_app_login" => {
|
||||||
|
let username = q.from.username.clone();
|
||||||
|
match auth.create_bot_login_code(tg_id, username).await {
|
||||||
|
Ok(code) => (
|
||||||
|
format!(
|
||||||
|
"🔑 <b>ВХОД В ПРИЛОЖЕНИЕ</b>\n\nВаш код: <code>{}</code>\n\nВведите его в Netrunner-приложении на экране входа (вкладка Telegram). Код действует 5 минут.",
|
||||||
|
code
|
||||||
|
),
|
||||||
|
back_keyboard(),
|
||||||
|
),
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!(error = ?e, tg_id, "Не удалось создать код входа для приложения");
|
||||||
|
("Не удалось создать код, попробуйте ещё раз.".into(), back_keyboard())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
_ => ("В разработке...".into(), back_keyboard()),
|
_ => ("В разработке...".into(), back_keyboard()),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -232,6 +562,11 @@ async fn webapp_bridge_response(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn format_bytes(bytes: i64) -> String {
|
||||||
|
const GB: f64 = 1_000_000_000.0;
|
||||||
|
format!("{:.2} ГБ", bytes as f64 / GB)
|
||||||
|
}
|
||||||
|
|
||||||
fn main_menu_text() -> String {
|
fn main_menu_text() -> String {
|
||||||
"<b>NETRUNNER OS</b>\nДобро пожаловать в систему управления узлами.".into()
|
"<b>NETRUNNER OS</b>\nДобро пожаловать в систему управления узлами.".into()
|
||||||
}
|
}
|
||||||
@@ -245,6 +580,14 @@ fn main_menu_keyboard() -> InlineKeyboardMarkup {
|
|||||||
"🔗 Синдикат (Рефералы)",
|
"🔗 Синдикат (Рефералы)",
|
||||||
"menu_ref",
|
"menu_ref",
|
||||||
)],
|
)],
|
||||||
|
vec![InlineKeyboardButton::callback(
|
||||||
|
"🔑 Код для входа в приложение",
|
||||||
|
"menu_app_login",
|
||||||
|
)],
|
||||||
|
vec![InlineKeyboardButton::callback(
|
||||||
|
"🆘 Поддержка",
|
||||||
|
"menu_support",
|
||||||
|
)],
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
fn profile_keyboard() -> InlineKeyboardMarkup {
|
fn profile_keyboard() -> InlineKeyboardMarkup {
|
||||||
|
|||||||
@@ -19,6 +19,100 @@ pub struct User {
|
|||||||
pub has_used_trial: bool,
|
pub has_used_trial: bool,
|
||||||
pub seed_hash: Option<String>, // Новое поле
|
pub seed_hash: Option<String>, // Новое поле
|
||||||
pub last_hack_at: Option<chrono::DateTime<chrono::Utc>>,
|
pub last_hack_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||||
|
/// NULL = безлимит. Читается/пишется прокси через internal-эндпоинты.
|
||||||
|
pub data_limit_bytes: Option<i64>,
|
||||||
|
pub data_used_bytes: i64,
|
||||||
|
pub data_period_reset_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||||
|
/// Логин для входа Owner/Moderator/Partner (не Telegram/seed). NULL у
|
||||||
|
/// обычных VPN-пользователей.
|
||||||
|
pub username: Option<String>,
|
||||||
|
#[serde(skip_serializing)]
|
||||||
|
pub password_hash: Option<String>,
|
||||||
|
/// Право модератора управлять нодами — переключается Owner'ом поштучно
|
||||||
|
/// (см. `AuthGuard::node_manage_guard`), не зашито в саму роль.
|
||||||
|
pub can_manage_nodes: bool,
|
||||||
|
/// Фиксированный процент партнёра с продаж приведённых им покупателей.
|
||||||
|
pub commission_percent: Option<rust_decimal::Decimal>,
|
||||||
|
/// Код диплинка `t.me/<bot>?start=<partner_code>` — префикс `p_`.
|
||||||
|
pub partner_code: Option<String>,
|
||||||
|
/// Партнёр, чей диплинк привёл этого пользователя (проставляется один
|
||||||
|
/// раз при регистрации, first-wins — см. `set_referred_by_partner`).
|
||||||
|
pub referred_by_partner_id: Option<Uuid>,
|
||||||
|
/// Разовый флаг "юзер нажал кнопку Поддержка в боте, следующее его
|
||||||
|
/// текстовое сообщение — тикет/ответ в тикет". Сбрасывается сразу после
|
||||||
|
/// получения текста (см. `support::service::SupportService::handle_user_message`).
|
||||||
|
pub awaiting_support_message: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, FromRow, Serialize)]
|
||||||
|
pub struct SupportTicket {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub user_id: Uuid,
|
||||||
|
pub status: String,
|
||||||
|
pub assigned_to: Option<Uuid>,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
pub updated_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, FromRow, Serialize)]
|
||||||
|
pub struct SupportMessage {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub ticket_id: Uuid,
|
||||||
|
pub sender_role: String,
|
||||||
|
pub sender_user_id: Option<Uuid>,
|
||||||
|
pub body: String,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Для списка тикетов в админке — один JOIN-запрос вместо N+1
|
||||||
|
/// (идентичность юзера + превью последнего сообщения).
|
||||||
|
#[derive(Debug, Clone, FromRow, Serialize)]
|
||||||
|
pub struct SupportTicketSummary {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub user_id: Uuid,
|
||||||
|
pub tg_id: Option<i64>,
|
||||||
|
pub tg_username: Option<String>,
|
||||||
|
pub status: String,
|
||||||
|
pub message_count: i64,
|
||||||
|
pub last_message_preview: Option<String>,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
pub updated_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, FromRow, Serialize)]
|
||||||
|
pub struct PartnerCommission {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub partner_id: Uuid,
|
||||||
|
pub invoice_id: Uuid,
|
||||||
|
pub user_id: Uuid,
|
||||||
|
pub currency: String,
|
||||||
|
pub amount: i64,
|
||||||
|
pub percent_at_time: rust_decimal::Decimal,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, FromRow, Serialize)]
|
||||||
|
pub struct WithdrawalRequest {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub partner_id: Uuid,
|
||||||
|
pub currency: String,
|
||||||
|
pub amount: i64,
|
||||||
|
pub status: String,
|
||||||
|
pub note: Option<String>,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
pub processed_at: Option<DateTime<Utc>>,
|
||||||
|
pub processed_by: Option<Uuid>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Разбивка заработка/вывода партнёра по одной валюте — считается без
|
||||||
|
/// конвертации, партнёр видит несколько таких строк ("$45" + "₽690").
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct PartnerEarningsByCurrency {
|
||||||
|
pub currency: String,
|
||||||
|
pub total_earned: i64,
|
||||||
|
pub total_withdrawn: i64,
|
||||||
|
pub pending_withdrawal: i64,
|
||||||
|
pub available: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, FromRow)]
|
#[derive(Debug, Clone, FromRow)]
|
||||||
@@ -86,6 +180,9 @@ pub struct ProfileData {
|
|||||||
pub game_tickets: i32,
|
pub game_tickets: i32,
|
||||||
pub subscription: Option<SubscriptionData>,
|
pub subscription: Option<SubscriptionData>,
|
||||||
pub referrals: Vec<ReferralData>,
|
pub referrals: Vec<ReferralData>,
|
||||||
|
/// `None` = безлимит. Расходуется прокси, читается на `VpnStats`-индикаторе приложения.
|
||||||
|
pub data_limit_bytes: Option<i64>,
|
||||||
|
pub data_used_bytes: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
|||||||
+799
-9
@@ -16,6 +16,8 @@ pub struct InvoiceRecord {
|
|||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
pub user_id: Uuid,
|
pub user_id: Uuid,
|
||||||
pub plan_name: String,
|
pub plan_name: String,
|
||||||
|
pub provider: String,
|
||||||
|
pub currency: String,
|
||||||
pub amount: i64,
|
pub amount: i64,
|
||||||
pub status: String,
|
pub status: String,
|
||||||
}
|
}
|
||||||
@@ -39,6 +41,34 @@ pub trait AppRepository: Send + Sync {
|
|||||||
async fn create_seed_user(&self, seed_hash: &str) -> Result<User, sqlx::Error>;
|
async fn create_seed_user(&self, seed_hash: &str) -> Result<User, sqlx::Error>;
|
||||||
async fn get_user_by_seed_hash(&self, hash: &str) -> Result<Option<User>, sqlx::Error>;
|
async fn get_user_by_seed_hash(&self, hash: &str) -> Result<Option<User>, sqlx::Error>;
|
||||||
|
|
||||||
|
// --- ЛИМИТЫ ТРАФИКА (используется прокси через internal-эндпоинты) ---
|
||||||
|
/// Атомарно прибавляет `delta_bytes` к счётчику расхода и возвращает
|
||||||
|
/// свежие `(data_used_bytes, data_limit_bytes)`. `None`, если юзера нет.
|
||||||
|
async fn apply_usage_delta(
|
||||||
|
&self,
|
||||||
|
user_id: Uuid,
|
||||||
|
delta_bytes: i64,
|
||||||
|
) -> Result<Option<(i64, Option<i64>)>, sqlx::Error>;
|
||||||
|
async fn set_data_limit(
|
||||||
|
&self,
|
||||||
|
user_id: Uuid,
|
||||||
|
limit_bytes: Option<i64>,
|
||||||
|
) -> Result<Option<User>, sqlx::Error>;
|
||||||
|
|
||||||
|
// --- MAGIC-LINK ВХОД ЧЕРЕЗ TELEGRAM-БОТА (см. modules::auth) ---
|
||||||
|
async fn create_auth_session(&self, auth_code: &str) -> Result<(), sqlx::Error>;
|
||||||
|
async fn get_auth_session(
|
||||||
|
&self,
|
||||||
|
auth_code: &str,
|
||||||
|
) -> Result<Option<(Option<Uuid>, bool, chrono::DateTime<chrono::Utc>)>, sqlx::Error>;
|
||||||
|
/// Помечает сессию подтверждённой; `false`, если код не найден, уже
|
||||||
|
/// подтверждён или истёк (защита от повторного использования/гонки).
|
||||||
|
async fn verify_auth_session(
|
||||||
|
&self,
|
||||||
|
auth_code: &str,
|
||||||
|
user_id: Uuid,
|
||||||
|
) -> Result<bool, sqlx::Error>;
|
||||||
|
|
||||||
// --- REFRESH-СЕССИИ (единая cookie-авторизация) ---
|
// --- REFRESH-СЕССИИ (единая cookie-авторизация) ---
|
||||||
async fn create_refresh_session(
|
async fn create_refresh_session(
|
||||||
&self,
|
&self,
|
||||||
@@ -88,6 +118,21 @@ pub trait AppRepository: Send + Sync {
|
|||||||
currency: &str,
|
currency: &str,
|
||||||
) -> Result<Option<i64>, sqlx::Error>;
|
) -> Result<Option<i64>, sqlx::Error>;
|
||||||
|
|
||||||
|
/// Активные тарифы с USD-ценой (в центах) — для плиточного выбора тарифа
|
||||||
|
/// в боте (см. `bot.rs::menu_pay_crypto`). USD выбран как единственная
|
||||||
|
/// валюта, гарантированно заведённая на каждый план (см. сид в
|
||||||
|
/// `0001_init.sql`), а не потому что CryptoBot требует именно её.
|
||||||
|
async fn list_active_plans_usd(&self) -> Result<Vec<(String, i64)>, sqlx::Error>;
|
||||||
|
|
||||||
|
/// Обобщённая версия `list_active_plans_usd` — тарифы в произвольной
|
||||||
|
/// валюте (см. `modules::geoip` — валюта определяется по IP/языку).
|
||||||
|
/// Пустой Vec, если для этой валюты нет ни одной активной строки в
|
||||||
|
/// `plan_prices` (вызывающая сторона должна фолбэкнуться на USD).
|
||||||
|
async fn list_active_plans_by_currency(
|
||||||
|
&self,
|
||||||
|
currency: &str,
|
||||||
|
) -> Result<Vec<(String, i64)>, sqlx::Error>;
|
||||||
|
|
||||||
async fn create_invoice(
|
async fn create_invoice(
|
||||||
&self,
|
&self,
|
||||||
user_id: Uuid,
|
user_id: Uuid,
|
||||||
@@ -124,6 +169,117 @@ pub trait AppRepository: Send + Sync {
|
|||||||
async fn get_referral_stats(&self, user_id: Uuid) -> Result<(i64, i64), sqlx::Error>;
|
async fn get_referral_stats(&self, user_id: Uuid) -> Result<(i64, i64), sqlx::Error>;
|
||||||
async fn apply_promo_code(&self, user_id: Uuid, code: &str) -> Result<i64, String>;
|
async fn apply_promo_code(&self, user_id: Uuid, code: &str) -> Result<i64, String>;
|
||||||
|
|
||||||
|
// --- РОЛЕВАЯ АДМИНКА: OWNER / MODERATOR / PARTNER ---
|
||||||
|
/// Создаёт Owner/Moderator/Partner-аккаунт (логин+пароль, не Telegram/seed).
|
||||||
|
/// `can_manage_nodes` осмыслен только для "moderator", `commission_percent`/
|
||||||
|
/// `partner_code` — только для "partner" (для остальных ролей передавать `None`).
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
async fn create_staff_user(
|
||||||
|
&self,
|
||||||
|
username: &str,
|
||||||
|
password_hash: &str,
|
||||||
|
role: &str,
|
||||||
|
can_manage_nodes: bool,
|
||||||
|
commission_percent: Option<rust_decimal::Decimal>,
|
||||||
|
partner_code: Option<&str>,
|
||||||
|
) -> Result<User, sqlx::Error>;
|
||||||
|
async fn get_user_by_username(&self, username: &str) -> Result<Option<User>, sqlx::Error>;
|
||||||
|
async fn set_can_manage_nodes(
|
||||||
|
&self,
|
||||||
|
user_id: Uuid,
|
||||||
|
enabled: bool,
|
||||||
|
) -> Result<Option<User>, sqlx::Error>;
|
||||||
|
/// `role_filter` — `None` = все staff-роли (owner+moderator+partner).
|
||||||
|
async fn list_staff_users(&self, role_filter: Option<&str>) -> Result<Vec<User>, sqlx::Error>;
|
||||||
|
async fn find_partner_by_code(&self, code: &str) -> Result<Option<User>, sqlx::Error>;
|
||||||
|
/// Постоянная привязка покупателя к партнёру при первой регистрации по
|
||||||
|
/// диплинку. No-op (`Ok(false)`), если уже привязан — first-wins.
|
||||||
|
async fn set_referred_by_partner(
|
||||||
|
&self,
|
||||||
|
user_id: Uuid,
|
||||||
|
partner_id: Uuid,
|
||||||
|
) -> Result<bool, sqlx::Error>;
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
async fn record_partner_commission(
|
||||||
|
&self,
|
||||||
|
partner_id: Uuid,
|
||||||
|
invoice_id: Uuid,
|
||||||
|
user_id: Uuid,
|
||||||
|
currency: &str,
|
||||||
|
amount: i64,
|
||||||
|
percent_at_time: rust_decimal::Decimal,
|
||||||
|
) -> Result<(), sqlx::Error>;
|
||||||
|
/// Разбивка заработка/вывода партнёра по валютам (без конвертации).
|
||||||
|
async fn get_partner_earnings(
|
||||||
|
&self,
|
||||||
|
partner_id: Uuid,
|
||||||
|
) -> Result<Vec<PartnerEarningsByCurrency>, sqlx::Error>;
|
||||||
|
async fn create_withdrawal_request(
|
||||||
|
&self,
|
||||||
|
partner_id: Uuid,
|
||||||
|
currency: &str,
|
||||||
|
amount: i64,
|
||||||
|
note: Option<&str>,
|
||||||
|
) -> Result<WithdrawalRequest, sqlx::Error>;
|
||||||
|
/// Атомарная версия для записи: блокирует строку партнёра (`FOR UPDATE`) и
|
||||||
|
/// пересчитывает доступный остаток внутри той же транзакции перед вставкой
|
||||||
|
/// заявки — без этого несколько параллельных заявок на весь остаток могут
|
||||||
|
/// пройти одновременно и увести партнёра в минус (та же гонка, от которой
|
||||||
|
/// защищает `FOR UPDATE` в `apply_promo_code`). `Err("INSUFFICIENT_BALANCE")`
|
||||||
|
/// — запрошенная сумма больше доступного остатка.
|
||||||
|
async fn request_partner_withdrawal(
|
||||||
|
&self,
|
||||||
|
partner_id: Uuid,
|
||||||
|
currency: &str,
|
||||||
|
amount: i64,
|
||||||
|
note: Option<&str>,
|
||||||
|
) -> Result<WithdrawalRequest, String>;
|
||||||
|
/// `status_filter` — `None` = все заявки (используется owner/moderator).
|
||||||
|
async fn list_withdrawal_requests(
|
||||||
|
&self,
|
||||||
|
status_filter: Option<&str>,
|
||||||
|
) -> Result<Vec<WithdrawalRequest>, sqlx::Error>;
|
||||||
|
async fn list_withdrawal_requests_for_partner(
|
||||||
|
&self,
|
||||||
|
partner_id: Uuid,
|
||||||
|
) -> Result<Vec<WithdrawalRequest>, sqlx::Error>;
|
||||||
|
async fn update_withdrawal_status(
|
||||||
|
&self,
|
||||||
|
id: Uuid,
|
||||||
|
new_status: &str,
|
||||||
|
processed_by: Uuid,
|
||||||
|
) -> Result<Option<WithdrawalRequest>, sqlx::Error>;
|
||||||
|
|
||||||
|
// --- ТЕХПОДДЕРЖКА (ТИКЕТЫ ЧЕРЕЗ БОТА) ---
|
||||||
|
/// Разовый флаг "юзер нажал кнопку Поддержка, следующее сообщение — тикет".
|
||||||
|
async fn set_awaiting_support(&self, user_id: Uuid, value: bool) -> Result<(), sqlx::Error>;
|
||||||
|
async fn get_open_ticket_for_user(
|
||||||
|
&self,
|
||||||
|
user_id: Uuid,
|
||||||
|
) -> Result<Option<SupportTicket>, sqlx::Error>;
|
||||||
|
async fn create_support_ticket(&self, user_id: Uuid) -> Result<SupportTicket, sqlx::Error>;
|
||||||
|
/// Добавляет сообщение и бампает `support_tickets.updated_at` в одной транзакции.
|
||||||
|
async fn add_support_message(
|
||||||
|
&self,
|
||||||
|
ticket_id: Uuid,
|
||||||
|
sender_role: &str,
|
||||||
|
sender_user_id: Option<Uuid>,
|
||||||
|
body: &str,
|
||||||
|
) -> Result<SupportMessage, sqlx::Error>;
|
||||||
|
/// `status_filter` — `None` = все тикеты (open первыми, затем по дате).
|
||||||
|
async fn list_tickets(
|
||||||
|
&self,
|
||||||
|
status_filter: Option<&str>,
|
||||||
|
) -> Result<Vec<SupportTicketSummary>, sqlx::Error>;
|
||||||
|
async fn get_ticket(&self, id: Uuid) -> Result<Option<SupportTicket>, sqlx::Error>;
|
||||||
|
async fn get_ticket_messages(
|
||||||
|
&self,
|
||||||
|
ticket_id: Uuid,
|
||||||
|
) -> Result<Vec<SupportMessage>, sqlx::Error>;
|
||||||
|
async fn close_ticket(&self, id: Uuid) -> Result<Option<SupportTicket>, sqlx::Error>;
|
||||||
|
/// Информационно: кто последним ответил — не enforced-назначение.
|
||||||
|
async fn set_ticket_assigned(&self, id: Uuid, staff_id: Uuid) -> Result<(), sqlx::Error>;
|
||||||
|
|
||||||
// --- VPN УЗЛЫ (CONTROL PLANE) ---
|
// --- VPN УЗЛЫ (CONTROL PLANE) ---
|
||||||
/// Ноды в статусе `online` — то, что видят обычные юзеры (`/nodes`, `/routing`, `/stats`).
|
/// Ноды в статусе `online` — то, что видят обычные юзеры (`/nodes`, `/routing`, `/stats`).
|
||||||
async fn get_active_nodes(&self) -> Result<Vec<VpnNode>, sqlx::Error>;
|
async fn get_active_nodes(&self) -> Result<Vec<VpnNode>, sqlx::Error>;
|
||||||
@@ -175,7 +331,9 @@ impl AppRepository for PgRepository {
|
|||||||
"INSERT INTO users (id, tg_id, tg_username) \
|
"INSERT INTO users (id, tg_id, tg_username) \
|
||||||
VALUES ($1, $2, $3) \
|
VALUES ($1, $2, $3) \
|
||||||
ON CONFLICT (tg_id) DO UPDATE SET tg_username = EXCLUDED.tg_username \
|
ON CONFLICT (tg_id) DO UPDATE SET tg_username = EXCLUDED.tg_username \
|
||||||
RETURNING id, tg_id, tg_username, role, balance, game_tickets, has_used_trial, seed_hash, last_hack_at"
|
RETURNING id, tg_id, tg_username, role, balance, game_tickets, has_used_trial, seed_hash, last_hack_at, \
|
||||||
|
data_limit_bytes, data_used_bytes, data_period_reset_at, \
|
||||||
|
username, password_hash, can_manage_nodes, commission_percent, partner_code, referred_by_partner_id, awaiting_support_message"
|
||||||
)
|
)
|
||||||
.bind(Uuid::new_v4()).bind(tg_id).bind(username).fetch_one(&self.pool).await
|
.bind(Uuid::new_v4()).bind(tg_id).bind(username).fetch_one(&self.pool).await
|
||||||
}
|
}
|
||||||
@@ -183,7 +341,9 @@ impl AppRepository for PgRepository {
|
|||||||
#[instrument(skip(self))]
|
#[instrument(skip(self))]
|
||||||
async fn get_user_by_id(&self, id: Uuid) -> Result<Option<User>, sqlx::Error> {
|
async fn get_user_by_id(&self, id: Uuid) -> Result<Option<User>, sqlx::Error> {
|
||||||
sqlx::query_as::<_, User>(
|
sqlx::query_as::<_, User>(
|
||||||
"SELECT id, tg_id, tg_username, role, balance, game_tickets, has_used_trial, seed_hash, last_hack_at \
|
"SELECT id, tg_id, tg_username, role, balance, game_tickets, has_used_trial, seed_hash, last_hack_at, \
|
||||||
|
data_limit_bytes, data_used_bytes, data_period_reset_at, \
|
||||||
|
username, password_hash, can_manage_nodes, commission_percent, partner_code, referred_by_partner_id, awaiting_support_message \
|
||||||
FROM users WHERE id = $1"
|
FROM users WHERE id = $1"
|
||||||
)
|
)
|
||||||
.bind(id)
|
.bind(id)
|
||||||
@@ -194,7 +354,9 @@ impl AppRepository for PgRepository {
|
|||||||
#[instrument(skip(self))]
|
#[instrument(skip(self))]
|
||||||
async fn get_user_by_tg_id(&self, tg_id: i64) -> Result<Option<User>, sqlx::Error> {
|
async fn get_user_by_tg_id(&self, tg_id: i64) -> Result<Option<User>, sqlx::Error> {
|
||||||
sqlx::query_as::<_, User>(
|
sqlx::query_as::<_, User>(
|
||||||
"SELECT id, tg_id, tg_username, role, balance, game_tickets, has_used_trial, seed_hash, last_hack_at \
|
"SELECT id, tg_id, tg_username, role, balance, game_tickets, has_used_trial, seed_hash, last_hack_at, \
|
||||||
|
data_limit_bytes, data_used_bytes, data_period_reset_at, \
|
||||||
|
username, password_hash, can_manage_nodes, commission_percent, partner_code, referred_by_partner_id, awaiting_support_message \
|
||||||
FROM users WHERE tg_id = $1"
|
FROM users WHERE tg_id = $1"
|
||||||
)
|
)
|
||||||
.bind(tg_id)
|
.bind(tg_id)
|
||||||
@@ -206,7 +368,9 @@ impl AppRepository for PgRepository {
|
|||||||
async fn create_seed_user(&self, seed_hash: &str) -> Result<User, sqlx::Error> {
|
async fn create_seed_user(&self, seed_hash: &str) -> Result<User, sqlx::Error> {
|
||||||
sqlx::query_as::<_, User>(
|
sqlx::query_as::<_, User>(
|
||||||
"INSERT INTO users (id, seed_hash) VALUES ($1, $2) \
|
"INSERT INTO users (id, seed_hash) VALUES ($1, $2) \
|
||||||
RETURNING id, tg_id, tg_username, role, balance, game_tickets, has_used_trial, seed_hash, last_hack_at"
|
RETURNING id, tg_id, tg_username, role, balance, game_tickets, has_used_trial, seed_hash, last_hack_at, \
|
||||||
|
data_limit_bytes, data_used_bytes, data_period_reset_at, \
|
||||||
|
username, password_hash, can_manage_nodes, commission_percent, partner_code, referred_by_partner_id, awaiting_support_message"
|
||||||
)
|
)
|
||||||
.bind(Uuid::new_v4()).bind(seed_hash).fetch_one(&self.pool).await
|
.bind(Uuid::new_v4()).bind(seed_hash).fetch_one(&self.pool).await
|
||||||
}
|
}
|
||||||
@@ -214,7 +378,9 @@ impl AppRepository for PgRepository {
|
|||||||
#[instrument(skip(self))]
|
#[instrument(skip(self))]
|
||||||
async fn get_user_by_seed_hash(&self, hash: &str) -> Result<Option<User>, sqlx::Error> {
|
async fn get_user_by_seed_hash(&self, hash: &str) -> Result<Option<User>, sqlx::Error> {
|
||||||
sqlx::query_as::<_, User>(
|
sqlx::query_as::<_, User>(
|
||||||
"SELECT id, tg_id, tg_username, role, balance, game_tickets, has_used_trial, seed_hash, last_hack_at \
|
"SELECT id, tg_id, tg_username, role, balance, game_tickets, has_used_trial, seed_hash, last_hack_at, \
|
||||||
|
data_limit_bytes, data_used_bytes, data_period_reset_at, \
|
||||||
|
username, password_hash, can_manage_nodes, commission_percent, partner_code, referred_by_partner_id, awaiting_support_message \
|
||||||
FROM users WHERE seed_hash = $1"
|
FROM users WHERE seed_hash = $1"
|
||||||
)
|
)
|
||||||
.bind(hash)
|
.bind(hash)
|
||||||
@@ -222,6 +388,84 @@ impl AppRepository for PgRepository {
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[instrument(skip(self))]
|
||||||
|
async fn apply_usage_delta(
|
||||||
|
&self,
|
||||||
|
user_id: Uuid,
|
||||||
|
delta_bytes: i64,
|
||||||
|
) -> Result<Option<(i64, Option<i64>)>, sqlx::Error> {
|
||||||
|
let row: Option<(i64, Option<i64>)> = sqlx::query_as(
|
||||||
|
"UPDATE users SET data_used_bytes = data_used_bytes + $1 \
|
||||||
|
WHERE id = $2 RETURNING data_used_bytes, data_limit_bytes",
|
||||||
|
)
|
||||||
|
.bind(delta_bytes)
|
||||||
|
.bind(user_id)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[instrument(skip(self))]
|
||||||
|
async fn set_data_limit(
|
||||||
|
&self,
|
||||||
|
user_id: Uuid,
|
||||||
|
limit_bytes: Option<i64>,
|
||||||
|
) -> Result<Option<User>, sqlx::Error> {
|
||||||
|
sqlx::query_as::<_, User>(
|
||||||
|
"UPDATE users SET data_limit_bytes = $1 WHERE id = $2 \
|
||||||
|
RETURNING id, tg_id, tg_username, role, balance, game_tickets, has_used_trial, seed_hash, last_hack_at, \
|
||||||
|
data_limit_bytes, data_used_bytes, data_period_reset_at, \
|
||||||
|
username, password_hash, can_manage_nodes, commission_percent, partner_code, referred_by_partner_id, awaiting_support_message",
|
||||||
|
)
|
||||||
|
.bind(limit_bytes)
|
||||||
|
.bind(user_id)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[instrument(skip(self))]
|
||||||
|
async fn create_auth_session(&self, auth_code: &str) -> Result<(), sqlx::Error> {
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO auth_sessions (id, auth_code, expires_at) \
|
||||||
|
VALUES ($1, $2, NOW() + INTERVAL '5 minutes')",
|
||||||
|
)
|
||||||
|
.bind(Uuid::new_v4())
|
||||||
|
.bind(auth_code)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[instrument(skip(self))]
|
||||||
|
async fn get_auth_session(
|
||||||
|
&self,
|
||||||
|
auth_code: &str,
|
||||||
|
) -> Result<Option<(Option<Uuid>, bool, chrono::DateTime<chrono::Utc>)>, sqlx::Error> {
|
||||||
|
sqlx::query_as(
|
||||||
|
"SELECT user_id, is_verified, expires_at FROM auth_sessions WHERE auth_code = $1",
|
||||||
|
)
|
||||||
|
.bind(auth_code)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[instrument(skip(self))]
|
||||||
|
async fn verify_auth_session(
|
||||||
|
&self,
|
||||||
|
auth_code: &str,
|
||||||
|
user_id: Uuid,
|
||||||
|
) -> Result<bool, sqlx::Error> {
|
||||||
|
let r = sqlx::query(
|
||||||
|
"UPDATE auth_sessions SET user_id = $1, is_verified = TRUE \
|
||||||
|
WHERE auth_code = $2 AND is_verified = FALSE AND expires_at > NOW()",
|
||||||
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.bind(auth_code)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(r.rows_affected() > 0)
|
||||||
|
}
|
||||||
|
|
||||||
#[instrument(skip(self, token_hash))]
|
#[instrument(skip(self, token_hash))]
|
||||||
async fn create_refresh_session(
|
async fn create_refresh_session(
|
||||||
&self,
|
&self,
|
||||||
@@ -295,10 +539,13 @@ impl AppRepository for PgRepository {
|
|||||||
game_tickets: i32,
|
game_tickets: i32,
|
||||||
subscription: Option<Json<SubscriptionData>>,
|
subscription: Option<Json<SubscriptionData>>,
|
||||||
referrals: Json<Vec<ReferralData>>,
|
referrals: Json<Vec<ReferralData>>,
|
||||||
|
data_limit_bytes: Option<i64>,
|
||||||
|
data_used_bytes: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
let query = r#"
|
let query = r#"
|
||||||
SELECT u.id, u.tg_username, u.role, u.balance, u.game_tickets,
|
SELECT u.id, u.tg_username, u.role, u.balance, u.game_tickets,
|
||||||
|
u.data_limit_bytes, u.data_used_bytes,
|
||||||
(SELECT jsonb_build_object('plan_name', s.plan_name, 'expires_at', s.expires_at, 'status', s.status)
|
(SELECT jsonb_build_object('plan_name', s.plan_name, 'expires_at', s.expires_at, 'status', s.status)
|
||||||
FROM public.subscriptions s WHERE s.user_id = u.id AND s.status != 'expired' LIMIT 1
|
FROM public.subscriptions s WHERE s.user_id = u.id AND s.status != 'expired' LIMIT 1
|
||||||
) AS "subscription",
|
) AS "subscription",
|
||||||
@@ -322,6 +569,8 @@ impl AppRepository for PgRepository {
|
|||||||
game_tickets: r.game_tickets,
|
game_tickets: r.game_tickets,
|
||||||
subscription: r.subscription.map(|json| json.0),
|
subscription: r.subscription.map(|json| json.0),
|
||||||
referrals: r.referrals.0,
|
referrals: r.referrals.0,
|
||||||
|
data_limit_bytes: r.data_limit_bytes,
|
||||||
|
data_used_bytes: r.data_used_bytes,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -476,6 +725,34 @@ impl AppRepository for PgRepository {
|
|||||||
Ok(res.map(|r| r.0))
|
Ok(res.map(|r| r.0))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[instrument(skip(self))]
|
||||||
|
async fn list_active_plans_usd(&self) -> Result<Vec<(String, i64)>, sqlx::Error> {
|
||||||
|
sqlx::query_as(
|
||||||
|
"SELECT pp.plan_name, pp.amount FROM plan_prices pp \
|
||||||
|
JOIN plans p ON p.name = pp.plan_name \
|
||||||
|
WHERE pp.currency = 'USD' AND pp.is_active = TRUE AND p.is_active = TRUE \
|
||||||
|
ORDER BY pp.amount ASC",
|
||||||
|
)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[instrument(skip(self))]
|
||||||
|
async fn list_active_plans_by_currency(
|
||||||
|
&self,
|
||||||
|
currency: &str,
|
||||||
|
) -> Result<Vec<(String, i64)>, sqlx::Error> {
|
||||||
|
sqlx::query_as(
|
||||||
|
"SELECT pp.plan_name, pp.amount FROM plan_prices pp \
|
||||||
|
JOIN plans p ON p.name = pp.plan_name \
|
||||||
|
WHERE pp.currency = $1 AND pp.is_active = TRUE AND p.is_active = TRUE \
|
||||||
|
ORDER BY pp.amount ASC",
|
||||||
|
)
|
||||||
|
.bind(currency)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
#[instrument(skip(self))]
|
#[instrument(skip(self))]
|
||||||
async fn create_invoice(
|
async fn create_invoice(
|
||||||
&self,
|
&self,
|
||||||
@@ -504,7 +781,7 @@ impl AppRepository for PgRepository {
|
|||||||
#[instrument(skip(self))]
|
#[instrument(skip(self))]
|
||||||
async fn get_invoice(&self, invoice_id: Uuid) -> Result<Option<InvoiceRecord>, sqlx::Error> {
|
async fn get_invoice(&self, invoice_id: Uuid) -> Result<Option<InvoiceRecord>, sqlx::Error> {
|
||||||
sqlx::query_as::<_, InvoiceRecord>(
|
sqlx::query_as::<_, InvoiceRecord>(
|
||||||
"SELECT id, user_id, plan_name, amount, status FROM invoices WHERE id = $1",
|
"SELECT id, user_id, plan_name, provider, currency, amount, status FROM invoices WHERE id = $1",
|
||||||
)
|
)
|
||||||
.bind(invoice_id)
|
.bind(invoice_id)
|
||||||
.fetch_optional(&self.pool)
|
.fetch_optional(&self.pool)
|
||||||
@@ -514,7 +791,7 @@ impl AppRepository for PgRepository {
|
|||||||
#[instrument(skip(self))]
|
#[instrument(skip(self))]
|
||||||
async fn get_pending_invoices(&self) -> Result<Vec<InvoiceRecord>, sqlx::Error> {
|
async fn get_pending_invoices(&self) -> Result<Vec<InvoiceRecord>, sqlx::Error> {
|
||||||
sqlx::query_as::<_, InvoiceRecord>(
|
sqlx::query_as::<_, InvoiceRecord>(
|
||||||
"SELECT id, user_id, plan_name, amount, status FROM invoices \
|
"SELECT id, user_id, plan_name, provider, currency, amount, status FROM invoices \
|
||||||
WHERE status = 'pending' AND created_at > NOW() - INTERVAL '24 hours' \
|
WHERE status = 'pending' AND created_at > NOW() - INTERVAL '24 hours' \
|
||||||
ORDER BY created_at DESC LIMIT 100",
|
ORDER BY created_at DESC LIMIT 100",
|
||||||
)
|
)
|
||||||
@@ -567,6 +844,17 @@ impl AppRepository for PgRepository {
|
|||||||
DO UPDATE SET status = 'active', expires_at = NOW() + INTERVAL '30 days', plan_name = EXCLUDED.plan_name")
|
DO UPDATE SET status = 'active', expires_at = NOW() + INTERVAL '30 days', plan_name = EXCLUDED.plan_name")
|
||||||
.bind(user_id).bind(plan_name).execute(&mut *tx).await?;
|
.bind(user_id).bind(plan_name).execute(&mut *tx).await?;
|
||||||
|
|
||||||
|
// Проставляем дефолтный лимит трафика тарифа (NULL тарифа = не трогать
|
||||||
|
// текущий лимит юзера — например, если админ уже выставил кастомный).
|
||||||
|
sqlx::query(
|
||||||
|
"UPDATE users SET data_limit_bytes = (SELECT default_limit_bytes FROM plans WHERE name = $2) \
|
||||||
|
WHERE id = $1 AND (SELECT default_limit_bytes FROM plans WHERE name = $2) IS NOT NULL",
|
||||||
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.bind(plan_name)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
tx.commit().await?;
|
tx.commit().await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -609,7 +897,10 @@ impl AppRepository for PgRepository {
|
|||||||
#[instrument(skip(self))]
|
#[instrument(skip(self))]
|
||||||
async fn reward_referrer(&self, referred_id: Uuid, amount: i64) -> Result<bool, sqlx::Error> {
|
async fn reward_referrer(&self, referred_id: Uuid, amount: i64) -> Result<bool, sqlx::Error> {
|
||||||
let mut tx = self.pool.begin().await?;
|
let mut tx = self.pool.begin().await?;
|
||||||
let res: Option<(Uuid,)> = sqlx::query_as("UPDATE referrals SET status = 'rewarded', total_earned = total_earned + $1 WHERE referred_id = $2 RETURNING referrer_id")
|
// WHERE status = 'pending' — разовый бонус за первую покупку приведённого
|
||||||
|
// юзера, а не revenue share навсегда: без этого условия UPDATE срабатывал
|
||||||
|
// при каждом вызове (т.е. при каждой последующей покупке того же юзера).
|
||||||
|
let res: Option<(Uuid,)> = sqlx::query_as("UPDATE referrals SET status = 'rewarded', total_earned = total_earned + $1 WHERE referred_id = $2 AND status = 'pending' RETURNING referrer_id")
|
||||||
.bind(amount).bind(referred_id).fetch_optional(&mut *tx).await?;
|
.bind(amount).bind(referred_id).fetch_optional(&mut *tx).await?;
|
||||||
|
|
||||||
if let Some((r_id,)) = res {
|
if let Some((r_id,)) = res {
|
||||||
@@ -628,7 +919,7 @@ impl AppRepository for PgRepository {
|
|||||||
#[instrument(skip(self))]
|
#[instrument(skip(self))]
|
||||||
async fn get_referral_stats(&self, user_id: Uuid) -> Result<(i64, i64), sqlx::Error> {
|
async fn get_referral_stats(&self, user_id: Uuid) -> Result<(i64, i64), sqlx::Error> {
|
||||||
let row: (Option<i64>, Option<i64>) = sqlx::query_as(
|
let row: (Option<i64>, Option<i64>) = sqlx::query_as(
|
||||||
"SELECT COUNT(*), SUM(total_earned) FROM referrals WHERE referrer_id = $1",
|
"SELECT COUNT(*), SUM(total_earned)::BIGINT FROM referrals WHERE referrer_id = $1",
|
||||||
)
|
)
|
||||||
.bind(user_id)
|
.bind(user_id)
|
||||||
.fetch_one(&self.pool)
|
.fetch_one(&self.pool)
|
||||||
@@ -710,6 +1001,505 @@ impl AppRepository for PgRepository {
|
|||||||
Ok(reward)
|
Ok(reward)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =====================================================================
|
||||||
|
// РОЛЕВАЯ АДМИНКА: OWNER / MODERATOR / PARTNER
|
||||||
|
// =====================================================================
|
||||||
|
|
||||||
|
#[instrument(skip(self, password_hash))]
|
||||||
|
async fn create_staff_user(
|
||||||
|
&self,
|
||||||
|
username: &str,
|
||||||
|
password_hash: &str,
|
||||||
|
role: &str,
|
||||||
|
can_manage_nodes: bool,
|
||||||
|
commission_percent: Option<rust_decimal::Decimal>,
|
||||||
|
partner_code: Option<&str>,
|
||||||
|
) -> Result<User, sqlx::Error> {
|
||||||
|
sqlx::query_as::<_, User>(
|
||||||
|
"INSERT INTO users (id, username, password_hash, role, can_manage_nodes, commission_percent, partner_code) \
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7) \
|
||||||
|
RETURNING id, tg_id, tg_username, role, balance, game_tickets, has_used_trial, seed_hash, last_hack_at, \
|
||||||
|
data_limit_bytes, data_used_bytes, data_period_reset_at, \
|
||||||
|
username, password_hash, can_manage_nodes, commission_percent, partner_code, referred_by_partner_id, awaiting_support_message"
|
||||||
|
)
|
||||||
|
.bind(Uuid::new_v4())
|
||||||
|
.bind(username)
|
||||||
|
.bind(password_hash)
|
||||||
|
.bind(role)
|
||||||
|
.bind(can_manage_nodes)
|
||||||
|
.bind(commission_percent)
|
||||||
|
.bind(partner_code)
|
||||||
|
.fetch_one(&self.pool)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[instrument(skip(self))]
|
||||||
|
async fn get_user_by_username(&self, username: &str) -> Result<Option<User>, sqlx::Error> {
|
||||||
|
sqlx::query_as::<_, User>(
|
||||||
|
"SELECT id, tg_id, tg_username, role, balance, game_tickets, has_used_trial, seed_hash, last_hack_at, \
|
||||||
|
data_limit_bytes, data_used_bytes, data_period_reset_at, \
|
||||||
|
username, password_hash, can_manage_nodes, commission_percent, partner_code, referred_by_partner_id, awaiting_support_message \
|
||||||
|
FROM users WHERE username = $1"
|
||||||
|
)
|
||||||
|
.bind(username)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[instrument(skip(self))]
|
||||||
|
async fn set_can_manage_nodes(
|
||||||
|
&self,
|
||||||
|
user_id: Uuid,
|
||||||
|
enabled: bool,
|
||||||
|
) -> Result<Option<User>, sqlx::Error> {
|
||||||
|
sqlx::query_as::<_, User>(
|
||||||
|
"UPDATE users SET can_manage_nodes = $1 WHERE id = $2 \
|
||||||
|
RETURNING id, tg_id, tg_username, role, balance, game_tickets, has_used_trial, seed_hash, last_hack_at, \
|
||||||
|
data_limit_bytes, data_used_bytes, data_period_reset_at, \
|
||||||
|
username, password_hash, can_manage_nodes, commission_percent, partner_code, referred_by_partner_id, awaiting_support_message"
|
||||||
|
)
|
||||||
|
.bind(enabled)
|
||||||
|
.bind(user_id)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[instrument(skip(self))]
|
||||||
|
async fn list_staff_users(&self, role_filter: Option<&str>) -> Result<Vec<User>, sqlx::Error> {
|
||||||
|
let roles: Vec<&str> = match role_filter {
|
||||||
|
Some(r) => vec![r],
|
||||||
|
None => vec!["owner", "moderator", "partner", "support"],
|
||||||
|
};
|
||||||
|
sqlx::query_as::<_, User>(
|
||||||
|
"SELECT id, tg_id, tg_username, role, balance, game_tickets, has_used_trial, seed_hash, last_hack_at, \
|
||||||
|
data_limit_bytes, data_used_bytes, data_period_reset_at, \
|
||||||
|
username, password_hash, can_manage_nodes, commission_percent, partner_code, referred_by_partner_id, awaiting_support_message \
|
||||||
|
FROM users WHERE role = ANY($1) ORDER BY role, username"
|
||||||
|
)
|
||||||
|
.bind(&roles)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[instrument(skip(self))]
|
||||||
|
async fn find_partner_by_code(&self, code: &str) -> Result<Option<User>, sqlx::Error> {
|
||||||
|
sqlx::query_as::<_, User>(
|
||||||
|
"SELECT id, tg_id, tg_username, role, balance, game_tickets, has_used_trial, seed_hash, last_hack_at, \
|
||||||
|
data_limit_bytes, data_used_bytes, data_period_reset_at, \
|
||||||
|
username, password_hash, can_manage_nodes, commission_percent, partner_code, referred_by_partner_id, awaiting_support_message \
|
||||||
|
FROM users WHERE partner_code = $1 AND role = 'partner'"
|
||||||
|
)
|
||||||
|
.bind(code)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[instrument(skip(self))]
|
||||||
|
async fn set_referred_by_partner(
|
||||||
|
&self,
|
||||||
|
user_id: Uuid,
|
||||||
|
partner_id: Uuid,
|
||||||
|
) -> Result<bool, sqlx::Error> {
|
||||||
|
// first-wins: не перезаписывает уже проставленную привязку.
|
||||||
|
let result = sqlx::query(
|
||||||
|
"UPDATE users SET referred_by_partner_id = $1 \
|
||||||
|
WHERE id = $2 AND referred_by_partner_id IS NULL",
|
||||||
|
)
|
||||||
|
.bind(partner_id)
|
||||||
|
.bind(user_id)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(result.rows_affected() > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[instrument(skip(self))]
|
||||||
|
async fn record_partner_commission(
|
||||||
|
&self,
|
||||||
|
partner_id: Uuid,
|
||||||
|
invoice_id: Uuid,
|
||||||
|
user_id: Uuid,
|
||||||
|
currency: &str,
|
||||||
|
amount: i64,
|
||||||
|
percent_at_time: rust_decimal::Decimal,
|
||||||
|
) -> Result<(), sqlx::Error> {
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO partner_commissions (id, partner_id, invoice_id, user_id, currency, amount, percent_at_time) \
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7) \
|
||||||
|
ON CONFLICT (invoice_id) DO NOTHING",
|
||||||
|
)
|
||||||
|
.bind(Uuid::new_v4())
|
||||||
|
.bind(partner_id)
|
||||||
|
.bind(invoice_id)
|
||||||
|
.bind(user_id)
|
||||||
|
.bind(currency)
|
||||||
|
.bind(amount)
|
||||||
|
.bind(percent_at_time)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[instrument(skip(self))]
|
||||||
|
async fn get_partner_earnings(
|
||||||
|
&self,
|
||||||
|
partner_id: Uuid,
|
||||||
|
) -> Result<Vec<PartnerEarningsByCurrency>, sqlx::Error> {
|
||||||
|
// Три отдельных сгруппированных запроса и слияние в Rust — проще и
|
||||||
|
// надёжнее одного сложного JOIN, объёмы тут крошечные.
|
||||||
|
let earned_rows: Vec<(String, i64)> = sqlx::query_as(
|
||||||
|
"SELECT currency, COALESCE(SUM(amount), 0)::BIGINT FROM partner_commissions \
|
||||||
|
WHERE partner_id = $1 GROUP BY currency",
|
||||||
|
)
|
||||||
|
.bind(partner_id)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let withdrawn_rows: Vec<(String, i64)> = sqlx::query_as(
|
||||||
|
"SELECT currency, COALESCE(SUM(amount), 0)::BIGINT FROM withdrawal_requests \
|
||||||
|
WHERE partner_id = $1 AND status IN ('approved', 'paid') GROUP BY currency",
|
||||||
|
)
|
||||||
|
.bind(partner_id)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let pending_rows: Vec<(String, i64)> = sqlx::query_as(
|
||||||
|
"SELECT currency, COALESCE(SUM(amount), 0)::BIGINT FROM withdrawal_requests \
|
||||||
|
WHERE partner_id = $1 AND status = 'pending' GROUP BY currency",
|
||||||
|
)
|
||||||
|
.bind(partner_id)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
fn entry_for(
|
||||||
|
map: &mut std::collections::BTreeMap<String, PartnerEarningsByCurrency>,
|
||||||
|
currency: String,
|
||||||
|
) -> &mut PartnerEarningsByCurrency {
|
||||||
|
map.entry(currency.clone())
|
||||||
|
.or_insert_with(|| PartnerEarningsByCurrency {
|
||||||
|
currency,
|
||||||
|
total_earned: 0,
|
||||||
|
total_withdrawn: 0,
|
||||||
|
pending_withdrawal: 0,
|
||||||
|
available: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut by_currency: std::collections::BTreeMap<String, PartnerEarningsByCurrency> =
|
||||||
|
std::collections::BTreeMap::new();
|
||||||
|
for (currency, amount) in earned_rows {
|
||||||
|
entry_for(&mut by_currency, currency).total_earned = amount;
|
||||||
|
}
|
||||||
|
for (currency, amount) in withdrawn_rows {
|
||||||
|
entry_for(&mut by_currency, currency).total_withdrawn = amount;
|
||||||
|
}
|
||||||
|
for (currency, amount) in pending_rows {
|
||||||
|
entry_for(&mut by_currency, currency).pending_withdrawal = amount;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(by_currency
|
||||||
|
.into_values()
|
||||||
|
.map(|mut e| {
|
||||||
|
e.available = e.total_earned - e.total_withdrawn - e.pending_withdrawal;
|
||||||
|
e
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[instrument(skip(self))]
|
||||||
|
async fn create_withdrawal_request(
|
||||||
|
&self,
|
||||||
|
partner_id: Uuid,
|
||||||
|
currency: &str,
|
||||||
|
amount: i64,
|
||||||
|
note: Option<&str>,
|
||||||
|
) -> Result<WithdrawalRequest, sqlx::Error> {
|
||||||
|
sqlx::query_as::<_, WithdrawalRequest>(
|
||||||
|
"INSERT INTO withdrawal_requests (id, partner_id, currency, amount, note) \
|
||||||
|
VALUES ($1, $2, $3, $4, $5) \
|
||||||
|
RETURNING id, partner_id, currency, amount, status, note, created_at, processed_at, processed_by",
|
||||||
|
)
|
||||||
|
.bind(Uuid::new_v4())
|
||||||
|
.bind(partner_id)
|
||||||
|
.bind(currency)
|
||||||
|
.bind(amount)
|
||||||
|
.bind(note)
|
||||||
|
.fetch_one(&self.pool)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[instrument(skip(self))]
|
||||||
|
async fn request_partner_withdrawal(
|
||||||
|
&self,
|
||||||
|
partner_id: Uuid,
|
||||||
|
currency: &str,
|
||||||
|
amount: i64,
|
||||||
|
note: Option<&str>,
|
||||||
|
) -> Result<WithdrawalRequest, String> {
|
||||||
|
let mut tx = self.pool.begin().await.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
// Блокируем строку партнёра — сериализует конкурентные заявки на вывод
|
||||||
|
// от одного и того же партнёра, пока эта транзакция не завершится.
|
||||||
|
sqlx::query("SELECT id FROM users WHERE id = $1 FOR UPDATE")
|
||||||
|
.bind(partner_id)
|
||||||
|
.fetch_optional(&mut *tx)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let earned: i64 = sqlx::query_scalar(
|
||||||
|
"SELECT COALESCE(SUM(amount), 0)::BIGINT FROM partner_commissions \
|
||||||
|
WHERE partner_id = $1 AND currency = $2",
|
||||||
|
)
|
||||||
|
.bind(partner_id)
|
||||||
|
.bind(currency)
|
||||||
|
.fetch_one(&mut *tx)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let withdrawn: i64 = sqlx::query_scalar(
|
||||||
|
"SELECT COALESCE(SUM(amount), 0)::BIGINT FROM withdrawal_requests \
|
||||||
|
WHERE partner_id = $1 AND currency = $2 AND status IN ('approved', 'paid')",
|
||||||
|
)
|
||||||
|
.bind(partner_id)
|
||||||
|
.bind(currency)
|
||||||
|
.fetch_one(&mut *tx)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let pending: i64 = sqlx::query_scalar(
|
||||||
|
"SELECT COALESCE(SUM(amount), 0)::BIGINT FROM withdrawal_requests \
|
||||||
|
WHERE partner_id = $1 AND currency = $2 AND status = 'pending'",
|
||||||
|
)
|
||||||
|
.bind(partner_id)
|
||||||
|
.bind(currency)
|
||||||
|
.fetch_one(&mut *tx)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let available = earned - withdrawn - pending;
|
||||||
|
if amount > available {
|
||||||
|
tx.rollback().await.map_err(|e| e.to_string())?;
|
||||||
|
return Err("INSUFFICIENT_BALANCE".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let req = sqlx::query_as::<_, WithdrawalRequest>(
|
||||||
|
"INSERT INTO withdrawal_requests (id, partner_id, currency, amount, note) \
|
||||||
|
VALUES ($1, $2, $3, $4, $5) \
|
||||||
|
RETURNING id, partner_id, currency, amount, status, note, created_at, processed_at, processed_by",
|
||||||
|
)
|
||||||
|
.bind(Uuid::new_v4())
|
||||||
|
.bind(partner_id)
|
||||||
|
.bind(currency)
|
||||||
|
.bind(amount)
|
||||||
|
.bind(note)
|
||||||
|
.fetch_one(&mut *tx)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
tx.commit().await.map_err(|e| e.to_string())?;
|
||||||
|
Ok(req)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[instrument(skip(self))]
|
||||||
|
async fn list_withdrawal_requests(
|
||||||
|
&self,
|
||||||
|
status_filter: Option<&str>,
|
||||||
|
) -> Result<Vec<WithdrawalRequest>, sqlx::Error> {
|
||||||
|
match status_filter {
|
||||||
|
Some(status) => {
|
||||||
|
sqlx::query_as::<_, WithdrawalRequest>(
|
||||||
|
"SELECT id, partner_id, currency, amount, status, note, created_at, processed_at, processed_by \
|
||||||
|
FROM withdrawal_requests WHERE status = $1 ORDER BY created_at DESC",
|
||||||
|
)
|
||||||
|
.bind(status)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
sqlx::query_as::<_, WithdrawalRequest>(
|
||||||
|
"SELECT id, partner_id, currency, amount, status, note, created_at, processed_at, processed_by \
|
||||||
|
FROM withdrawal_requests ORDER BY (status = 'pending') DESC, created_at DESC",
|
||||||
|
)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[instrument(skip(self))]
|
||||||
|
async fn list_withdrawal_requests_for_partner(
|
||||||
|
&self,
|
||||||
|
partner_id: Uuid,
|
||||||
|
) -> Result<Vec<WithdrawalRequest>, sqlx::Error> {
|
||||||
|
sqlx::query_as::<_, WithdrawalRequest>(
|
||||||
|
"SELECT id, partner_id, currency, amount, status, note, created_at, processed_at, processed_by \
|
||||||
|
FROM withdrawal_requests WHERE partner_id = $1 ORDER BY created_at DESC",
|
||||||
|
)
|
||||||
|
.bind(partner_id)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[instrument(skip(self))]
|
||||||
|
async fn update_withdrawal_status(
|
||||||
|
&self,
|
||||||
|
id: Uuid,
|
||||||
|
new_status: &str,
|
||||||
|
processed_by: Uuid,
|
||||||
|
) -> Result<Option<WithdrawalRequest>, sqlx::Error> {
|
||||||
|
sqlx::query_as::<_, WithdrawalRequest>(
|
||||||
|
"UPDATE withdrawal_requests SET status = $1, processed_at = NOW(), processed_by = $2 \
|
||||||
|
WHERE id = $3 \
|
||||||
|
RETURNING id, partner_id, currency, amount, status, note, created_at, processed_at, processed_by",
|
||||||
|
)
|
||||||
|
.bind(new_status)
|
||||||
|
.bind(processed_by)
|
||||||
|
.bind(id)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
// =====================================================================
|
||||||
|
// ТЕХПОДДЕРЖКА (ТИКЕТЫ ЧЕРЕЗ БОТА)
|
||||||
|
// =====================================================================
|
||||||
|
|
||||||
|
#[instrument(skip(self))]
|
||||||
|
async fn set_awaiting_support(&self, user_id: Uuid, value: bool) -> Result<(), sqlx::Error> {
|
||||||
|
sqlx::query("UPDATE users SET awaiting_support_message = $1 WHERE id = $2")
|
||||||
|
.bind(value)
|
||||||
|
.bind(user_id)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[instrument(skip(self))]
|
||||||
|
async fn get_open_ticket_for_user(
|
||||||
|
&self,
|
||||||
|
user_id: Uuid,
|
||||||
|
) -> Result<Option<SupportTicket>, sqlx::Error> {
|
||||||
|
sqlx::query_as::<_, SupportTicket>(
|
||||||
|
"SELECT id, user_id, status, assigned_to, created_at, updated_at \
|
||||||
|
FROM support_tickets WHERE user_id = $1 AND status = 'open' \
|
||||||
|
ORDER BY created_at DESC LIMIT 1",
|
||||||
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[instrument(skip(self))]
|
||||||
|
async fn create_support_ticket(&self, user_id: Uuid) -> Result<SupportTicket, sqlx::Error> {
|
||||||
|
sqlx::query_as::<_, SupportTicket>(
|
||||||
|
"INSERT INTO support_tickets (id, user_id) VALUES ($1, $2) \
|
||||||
|
RETURNING id, user_id, status, assigned_to, created_at, updated_at",
|
||||||
|
)
|
||||||
|
.bind(Uuid::new_v4())
|
||||||
|
.bind(user_id)
|
||||||
|
.fetch_one(&self.pool)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[instrument(skip(self))]
|
||||||
|
async fn add_support_message(
|
||||||
|
&self,
|
||||||
|
ticket_id: Uuid,
|
||||||
|
sender_role: &str,
|
||||||
|
sender_user_id: Option<Uuid>,
|
||||||
|
body: &str,
|
||||||
|
) -> Result<SupportMessage, sqlx::Error> {
|
||||||
|
let mut tx = self.pool.begin().await?;
|
||||||
|
|
||||||
|
let msg = sqlx::query_as::<_, SupportMessage>(
|
||||||
|
"INSERT INTO support_messages (id, ticket_id, sender_role, sender_user_id, body) \
|
||||||
|
VALUES ($1, $2, $3, $4, $5) \
|
||||||
|
RETURNING id, ticket_id, sender_role, sender_user_id, body, created_at",
|
||||||
|
)
|
||||||
|
.bind(Uuid::new_v4())
|
||||||
|
.bind(ticket_id)
|
||||||
|
.bind(sender_role)
|
||||||
|
.bind(sender_user_id)
|
||||||
|
.bind(body)
|
||||||
|
.fetch_one(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
sqlx::query("UPDATE support_tickets SET updated_at = NOW() WHERE id = $1")
|
||||||
|
.bind(ticket_id)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
tx.commit().await?;
|
||||||
|
Ok(msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[instrument(skip(self))]
|
||||||
|
async fn list_tickets(
|
||||||
|
&self,
|
||||||
|
status_filter: Option<&str>,
|
||||||
|
) -> Result<Vec<SupportTicketSummary>, sqlx::Error> {
|
||||||
|
let query = "SELECT t.id, t.user_id, u.tg_id, u.tg_username, t.status, \
|
||||||
|
COUNT(m.id)::BIGINT AS message_count, \
|
||||||
|
(SELECT body FROM support_messages \
|
||||||
|
WHERE ticket_id = t.id ORDER BY created_at DESC LIMIT 1) AS last_message_preview, \
|
||||||
|
t.created_at, t.updated_at \
|
||||||
|
FROM support_tickets t \
|
||||||
|
JOIN users u ON u.id = t.user_id \
|
||||||
|
LEFT JOIN support_messages m ON m.ticket_id = t.id \
|
||||||
|
WHERE ($1::TEXT IS NULL OR t.status = $1) \
|
||||||
|
GROUP BY t.id, u.tg_id, u.tg_username \
|
||||||
|
ORDER BY (t.status = 'open') DESC, t.updated_at DESC";
|
||||||
|
sqlx::query_as::<_, SupportTicketSummary>(query)
|
||||||
|
.bind(status_filter)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[instrument(skip(self))]
|
||||||
|
async fn get_ticket(&self, id: Uuid) -> Result<Option<SupportTicket>, sqlx::Error> {
|
||||||
|
sqlx::query_as::<_, SupportTicket>(
|
||||||
|
"SELECT id, user_id, status, assigned_to, created_at, updated_at \
|
||||||
|
FROM support_tickets WHERE id = $1",
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[instrument(skip(self))]
|
||||||
|
async fn get_ticket_messages(
|
||||||
|
&self,
|
||||||
|
ticket_id: Uuid,
|
||||||
|
) -> Result<Vec<SupportMessage>, sqlx::Error> {
|
||||||
|
sqlx::query_as::<_, SupportMessage>(
|
||||||
|
"SELECT id, ticket_id, sender_role, sender_user_id, body, created_at \
|
||||||
|
FROM support_messages WHERE ticket_id = $1 ORDER BY created_at ASC",
|
||||||
|
)
|
||||||
|
.bind(ticket_id)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[instrument(skip(self))]
|
||||||
|
async fn close_ticket(&self, id: Uuid) -> Result<Option<SupportTicket>, sqlx::Error> {
|
||||||
|
sqlx::query_as::<_, SupportTicket>(
|
||||||
|
"UPDATE support_tickets SET status = 'closed', updated_at = NOW() \
|
||||||
|
WHERE id = $1 \
|
||||||
|
RETURNING id, user_id, status, assigned_to, created_at, updated_at",
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[instrument(skip(self))]
|
||||||
|
async fn set_ticket_assigned(&self, id: Uuid, staff_id: Uuid) -> Result<(), sqlx::Error> {
|
||||||
|
sqlx::query("UPDATE support_tickets SET assigned_to = $1 WHERE id = $2")
|
||||||
|
.bind(staff_id)
|
||||||
|
.bind(id)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
// =====================================================================
|
// =====================================================================
|
||||||
// VPN УЗЛЫ (CONTROL PLANE)
|
// VPN УЗЛЫ (CONTROL PLANE)
|
||||||
// =====================================================================
|
// =====================================================================
|
||||||
|
|||||||
+106
-10
@@ -17,8 +17,9 @@ use netrunner_control_plane::api::{create_router, ApiState};
|
|||||||
use netrunner_control_plane::bot;
|
use netrunner_control_plane::bot;
|
||||||
use netrunner_control_plane::db::repository::{AppRepository, PgRepository};
|
use netrunner_control_plane::db::repository::{AppRepository, PgRepository};
|
||||||
use netrunner_control_plane::modules::{
|
use netrunner_control_plane::modules::{
|
||||||
auth::service::AuthService, billing::service::BillingService, nodes::service::NodeService,
|
auth::service::AuthService, billing::service::BillingService, geoip::service::GeoipService,
|
||||||
referrals::service::ReferralService, users::service::UserService,
|
nodes::service::NodeService, referrals::service::ReferralService, staff::service::StaffService,
|
||||||
|
support::service::SupportService, users::service::UserService,
|
||||||
};
|
};
|
||||||
use netrunner_control_plane::tasks;
|
use netrunner_control_plane::tasks;
|
||||||
|
|
||||||
@@ -27,6 +28,12 @@ use netrunner_control_plane::tasks;
|
|||||||
struct Args {
|
struct Args {
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
migrate: bool,
|
migrate: bool,
|
||||||
|
/// Одноразовое создание первого Owner-аккаунта (логин+пароль, не
|
||||||
|
/// Telegram) — прогоняется руками на VPS: `--create-owner --username admin`.
|
||||||
|
#[arg(long)]
|
||||||
|
create_owner: bool,
|
||||||
|
#[arg(long)]
|
||||||
|
username: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn init_tracing() {
|
fn init_tracing() {
|
||||||
@@ -149,6 +156,49 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Бутстрап первого Owner'а — тоже требует только DATABASE_URL, тот же
|
||||||
|
// one-shot паттерн, что и --migrate. Пароль вводится скрыто в консоли
|
||||||
|
// (не через env/аргумент), чтобы не осесть в истории шелла.
|
||||||
|
if args.create_owner {
|
||||||
|
let username = args
|
||||||
|
.username
|
||||||
|
.clone()
|
||||||
|
.ok_or("--create-owner требует --username <имя>")?;
|
||||||
|
info!("🔐 Создание владельца (Owner): {}", username);
|
||||||
|
|
||||||
|
let password = rpassword::prompt_password("Пароль для владельца: ")?;
|
||||||
|
let password_confirm = rpassword::prompt_password("Повторите пароль: ")?;
|
||||||
|
if password != password_confirm {
|
||||||
|
return Err("Пароли не совпадают.".into());
|
||||||
|
}
|
||||||
|
if password.len() < 8 {
|
||||||
|
return Err("Пароль должен быть не короче 8 символов.".into());
|
||||||
|
}
|
||||||
|
|
||||||
|
let hash = AuthService::hash_password(&password)
|
||||||
|
.map_err(|e| format!("Ошибка хеширования пароля: {:?}", e))?;
|
||||||
|
|
||||||
|
let repo = PgRepository::new(pool.clone());
|
||||||
|
let user = repo
|
||||||
|
.create_staff_user(&username, &hash, "owner", false, None, None)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
if let sqlx::Error::Database(db_err) = &e {
|
||||||
|
if db_err.constraint() == Some("users_username_key") {
|
||||||
|
return format!("Логин '{}' уже занят.", username);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
format!("Ошибка создания владельца: {e}")
|
||||||
|
})?;
|
||||||
|
|
||||||
|
info!("✅ Владелец создан: id={}, username={}", user.id, username);
|
||||||
|
println!(
|
||||||
|
"Владелец '{}' успешно создан. Войдите через /admin/login на фронтенде.",
|
||||||
|
username
|
||||||
|
);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
// --- Полный запуск сервера: проверяем все обязательные ресурсы ---
|
// --- Полный запуск сервера: проверяем все обязательные ресурсы ---
|
||||||
ensure_master_ssh_key()?;
|
ensure_master_ssh_key()?;
|
||||||
|
|
||||||
@@ -163,18 +213,17 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
"КРИТИЧЕСКАЯ ОШИБКА: BOT_ENABLED=true, но TELOXIDE_TOKEN не задан в .env!".into(),
|
"КРИТИЧЕСКАЯ ОШИБКА: BOT_ENABLED=true, но TELOXIDE_TOKEN не задан в .env!".into(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
// Тот же бот, что читает bot.rs при старте — но здесь ещё и отдаётся фронту
|
||||||
|
// через GET /api/v1/config (см. ApiState::bot_username), не как VITE_-переменная,
|
||||||
|
// зашиваемая в SPA-бандл на этапе сборки в CI.
|
||||||
|
let bot_username =
|
||||||
|
std::env::var("BOT_USERNAME").unwrap_or_else(|_| "ntrnr_vpn_bot".to_string());
|
||||||
let jwt_secret =
|
let jwt_secret =
|
||||||
std::env::var("JWT_SECRET").expect("КРИТИЧЕСКАЯ ОШИБКА: JWT_SECRET не задан в .env!");
|
std::env::var("JWT_SECRET").expect("КРИТИЧЕСКАЯ ОШИБКА: JWT_SECRET не задан в .env!");
|
||||||
let frontend_dir =
|
let frontend_dir =
|
||||||
std::env::var("FRONTEND_DIR").expect("КРИТИЧЕСКАЯ ОШИБКА: FRONTEND_DIR не задан в .env!");
|
std::env::var("FRONTEND_DIR").expect("КРИТИЧЕСКАЯ ОШИБКА: FRONTEND_DIR не задан в .env!");
|
||||||
let port = std::env::var("PORT").expect("КРИТИЧЕСКАЯ ОШИБКА: PORT не задан в .env!");
|
let port = std::env::var("PORT").expect("КРИТИЧЕСКАЯ ОШИБКА: PORT не задан в .env!");
|
||||||
|
|
||||||
// Считываем ADMIN_TG_ID один раз при старте сервера
|
|
||||||
let admin_tg_id = std::env::var("ADMIN_TG_ID")
|
|
||||||
.expect("КРИТИЧЕСКАЯ ОШИБКА: ADMIN_TG_ID не задан в .env!")
|
|
||||||
.parse::<i64>()
|
|
||||||
.expect("ADMIN_TG_ID должен быть числом");
|
|
||||||
|
|
||||||
info!("🚀 Запуск Netrunner Control Plane v2.0 (Modular Architecture)");
|
info!("🚀 Запуск Netrunner Control Plane v2.0 (Modular Architecture)");
|
||||||
|
|
||||||
// Домен для Set-Cookie: пусто локально (host-only cookie), в проде
|
// Домен для Set-Cookie: пусто локально (host-only cookie), в проде
|
||||||
@@ -193,6 +242,12 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
Только для стендов без TLS, никогда не для прода!"
|
Только для стендов без TLS, никогда не для прода!"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
// Общий секрет, которым делятся все прокси-ноды при вызовах internal-эндпоинтов
|
||||||
|
// (`POST /internal/validate`, `POST /internal/usage`) — не пользовательская
|
||||||
|
// авторизация, поэтому отдельный env, не JWT_SECRET.
|
||||||
|
let proxy_internal_secret = std::env::var("PROXY_INTERNAL_SECRET")
|
||||||
|
.expect("КРИТИЧЕСКАЯ ОШИБКА: PROXY_INTERNAL_SECRET не задан в .env!");
|
||||||
|
|
||||||
let csrf_allowed_origins: Vec<String> = std::env::var("CSRF_ALLOWED_ORIGINS")
|
let csrf_allowed_origins: Vec<String> = std::env::var("CSRF_ALLOWED_ORIGINS")
|
||||||
.unwrap_or_else(|_| {
|
.unwrap_or_else(|_| {
|
||||||
"https://netrunner-vpn.com,https://account.netrunner-vpn.com".to_string()
|
"https://netrunner-vpn.com,https://account.netrunner-vpn.com".to_string()
|
||||||
@@ -234,6 +289,18 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool.clone()));
|
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool.clone()));
|
||||||
let cancel_token = CancellationToken::new();
|
let cancel_token = CancellationToken::new();
|
||||||
|
|
||||||
|
// DB-IP Lite (не MaxMind — тот же формат .mmdb, но без регистрации/ключа,
|
||||||
|
// см. modules::geoip) + X4BNet lists_vpn (открытый список VPN/датацентр
|
||||||
|
// CIDR-диапазонов). Дефолты указывают прямо на публичные раздачи — можно
|
||||||
|
// переопределить, если когда-нибудь понадобится своё зеркало.
|
||||||
|
let geoip_db_url_template = std::env::var("GEOIP_DB_URL_TEMPLATE").unwrap_or_else(|_| {
|
||||||
|
"https://download.db-ip.com/free/dbip-country-lite-{}.mmdb.gz".to_string()
|
||||||
|
});
|
||||||
|
let vpn_list_url = std::env::var("VPN_LIST_URL").unwrap_or_else(|_| {
|
||||||
|
"https://raw.githubusercontent.com/X4BNet/lists_vpn/main/output/vpn/ipv4.txt".to_string()
|
||||||
|
});
|
||||||
|
let geoip_service = GeoipService::new(geoip_db_url_template, vpn_list_url);
|
||||||
|
|
||||||
let state = ApiState {
|
let state = ApiState {
|
||||||
repo: repo.clone(),
|
repo: repo.clone(),
|
||||||
auth_service: AuthService::new(repo.clone()),
|
auth_service: AuthService::new(repo.clone()),
|
||||||
@@ -241,12 +308,16 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
node_service: NodeService::new(repo.clone()),
|
node_service: NodeService::new(repo.clone()),
|
||||||
referral_service: ReferralService::new(repo.clone()),
|
referral_service: ReferralService::new(repo.clone()),
|
||||||
user_service: UserService::new(repo.clone()),
|
user_service: UserService::new(repo.clone()),
|
||||||
|
staff_service: StaffService::new(repo.clone()),
|
||||||
|
support_service: SupportService::new(repo.clone()),
|
||||||
|
geoip_service: geoip_service.clone(),
|
||||||
jwt_secret,
|
jwt_secret,
|
||||||
bot_token: bot_token.clone(),
|
bot_token: bot_token.clone(),
|
||||||
admin_tg_id,
|
bot_username,
|
||||||
cookie_domain,
|
cookie_domain,
|
||||||
cookie_secure,
|
cookie_secure,
|
||||||
csrf_allowed_origins,
|
csrf_allowed_origins,
|
||||||
|
proxy_internal_secret,
|
||||||
};
|
};
|
||||||
|
|
||||||
let app = create_router(state.clone(), &frontend_dir);
|
let app = create_router(state.clone(), &frontend_dir);
|
||||||
@@ -266,6 +337,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
let bot_auth = state.auth_service.clone();
|
let bot_auth = state.auth_service.clone();
|
||||||
let bot_users = state.user_service.clone();
|
let bot_users = state.user_service.clone();
|
||||||
let bot_billing = state.billing_service.clone();
|
let bot_billing = state.billing_service.clone();
|
||||||
|
let bot_referrals = state.referral_service.clone();
|
||||||
|
let bot_support = state.support_service.clone();
|
||||||
let bot_token_for_task = bot_token.clone();
|
let bot_token_for_task = bot_token.clone();
|
||||||
|
|
||||||
let bot_handle = tokio::spawn(async move {
|
let bot_handle = tokio::spawn(async move {
|
||||||
@@ -276,6 +349,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
bot_auth,
|
bot_auth,
|
||||||
bot_users,
|
bot_users,
|
||||||
bot_billing,
|
bot_billing,
|
||||||
|
bot_referrals,
|
||||||
|
bot_support,
|
||||||
bot_cancel_token,
|
bot_cancel_token,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
@@ -286,12 +361,33 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Первая загрузка баз GeoIP/VPN-списка — не блокирует старт HTTP-сервера
|
||||||
|
// (сеть может быть недоступна/медленной на старте контейнера), но
|
||||||
|
// запускается сразу, не через сутки ожидания первого тика в
|
||||||
|
// run_background_tasks.
|
||||||
|
let startup_geoip = geoip_service.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
if let Err(e) = startup_geoip.refresh_geoip_db().await {
|
||||||
|
tracing::warn!(
|
||||||
|
"GeoIP: первичная загрузка базы страна-по-IP не удалась: {}",
|
||||||
|
e
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if let Err(e) = startup_geoip.refresh_vpn_list().await {
|
||||||
|
tracing::warn!(
|
||||||
|
"GeoIP: первичная загрузка списка VPN-диапазонов не удалась: {}",
|
||||||
|
e
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
let tasks_token = cancel_token.clone();
|
let tasks_token = cancel_token.clone();
|
||||||
let tasks_billing = state.billing_service.clone();
|
let tasks_billing = state.billing_service.clone();
|
||||||
let tasks_repo = repo.clone();
|
let tasks_repo = repo.clone();
|
||||||
|
let tasks_geoip = geoip_service.clone();
|
||||||
let tasks_handle = tokio::spawn(async move {
|
let tasks_handle = tokio::spawn(async move {
|
||||||
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
|
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
|
||||||
tasks::run_background_tasks(tasks_billing, tasks_repo, tasks_token).await;
|
tasks::run_background_tasks(tasks_billing, tasks_repo, tasks_geoip, tasks_token).await;
|
||||||
});
|
});
|
||||||
|
|
||||||
// === КООРДИНАТОР АВАРИЙНОГО И СТАНДАРТНОГО ШУТДАУНА ===
|
// === КООРДИНАТОР АВАРИЙНОГО И СТАНДАРТНОГО ШУТДАУНА ===
|
||||||
|
|||||||
@@ -47,9 +47,12 @@
|
|||||||
cookie**, дополнительно проверяет `Origin`/`Referer` против `CSRF_ALLOWED_ORIGINS` — это
|
cookie**, дополнительно проверяет `Origin`/`Referer` против `CSRF_ALLOWED_ORIGINS` — это
|
||||||
CSRF-защита. Bearer-запросы её не проходят и не должны: подделать `Authorization`-заголовок
|
CSRF-защита. Bearer-запросы её не проходят и не должны: подделать `Authorization`-заголовок
|
||||||
с чужого origin браузер не даст, поэтому CSRF для них неактуален.
|
с чужого origin браузер не даст, поэтому CSRF для них неактуален.
|
||||||
- `admin_guard` — должен идти **после** `auth_guard` в цепочке слоёв (читает юзера из
|
- `owner_guard` / `staff_guard` / `node_manage_guard` / `partner_guard` — ролевые гварды
|
||||||
extensions). Пропускает, если `tg_id` юзера совпадает с `ADMIN_TG_ID` из `.env` или его
|
админки (Owner/Moderator/Partner), должны идти **после** `auth_guard` в цепочке слоёв
|
||||||
`role == "admin"`.
|
(читают роль юзера из extensions, второй запрос к БД не нужен). Без завязки на Telegram —
|
||||||
|
роль назначается через `staff`-модуль (создание Owner — CLI `--create-owner`, дальше
|
||||||
|
Owner/Moderator создают друг друга через API). `node_manage_guard` — Owner всегда, либо
|
||||||
|
Moderator с выданным правом `can_manage_nodes`.
|
||||||
|
|
||||||
## Sybil-защита
|
## Sybil-защита
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,11 @@ use crate::{api::ApiState, error::AppError};
|
|||||||
use axum::http::header::{COOKIE, SET_COOKIE, USER_AGENT};
|
use axum::http::header::{COOKIE, SET_COOKIE, USER_AGENT};
|
||||||
use axum::http::HeaderMap;
|
use axum::http::HeaderMap;
|
||||||
use axum::response::{IntoResponse, Response};
|
use axum::response::{IntoResponse, Response};
|
||||||
use axum::{extract::State, routing::post, Json, Router};
|
use axum::{
|
||||||
|
extract::{Path, State},
|
||||||
|
routing::{get, post},
|
||||||
|
Json, Router,
|
||||||
|
};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use tracing::instrument;
|
use tracing::instrument;
|
||||||
@@ -29,11 +33,20 @@ pub struct ExchangePayload {
|
|||||||
pub token: String,
|
pub token: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Debug)]
|
||||||
|
pub struct AdminLoginPayload {
|
||||||
|
pub username: String,
|
||||||
|
pub password: String,
|
||||||
|
}
|
||||||
|
|
||||||
pub fn router() -> Router<ApiState> {
|
pub fn router() -> Router<ApiState> {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/telegram", post(auth_telegram_api))
|
.route("/telegram", post(auth_telegram_api))
|
||||||
|
.route("/telegram/session", post(create_telegram_session_api))
|
||||||
|
.route("/telegram/session/{code}", get(get_telegram_session_api))
|
||||||
.route("/seed/generate", post(generate_seed_api))
|
.route("/seed/generate", post(generate_seed_api))
|
||||||
.route("/seed/login", post(login_seed_api))
|
.route("/seed/login", post(login_seed_api))
|
||||||
|
.route("/admin/login", post(admin_login_api))
|
||||||
.route("/exchange", post(exchange_bootstrap_api))
|
.route("/exchange", post(exchange_bootstrap_api))
|
||||||
.route("/refresh", post(refresh_api))
|
.route("/refresh", post(refresh_api))
|
||||||
.route("/logout", post(logout_api))
|
.route("/logout", post(logout_api))
|
||||||
@@ -156,6 +169,46 @@ async fn auth_telegram_api(
|
|||||||
Ok((out_headers, Json(json!({ "status": "success" }))))
|
Ok((out_headers, Json(json!({ "status": "success" }))))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Приложение (десктоп/мобильный, без домена для Telegram Login Widget) создаёт
|
||||||
|
/// код и открывает `deep_link` в системном Telegram — юзер подтверждает вход
|
||||||
|
/// командой `/start <auth_code>` в боте (см. `bot.rs::handle_commands`), а
|
||||||
|
/// приложение тем временем поллит `GET .../session/{code}`.
|
||||||
|
async fn create_telegram_session_api(
|
||||||
|
State(state): State<ApiState>,
|
||||||
|
) -> Result<impl IntoResponse, AppError> {
|
||||||
|
let auth_code = state.auth_service.create_telegram_login_code().await?;
|
||||||
|
let deep_link = format!("https://t.me/{}?start={}", state.bot_username, auth_code);
|
||||||
|
Ok(Json(
|
||||||
|
json!({ "auth_code": auth_code, "deep_link": deep_link }),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[instrument(skip(state, headers))]
|
||||||
|
async fn get_telegram_session_api(
|
||||||
|
State(state): State<ApiState>,
|
||||||
|
Path(code): Path<String>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
) -> Result<impl IntoResponse, AppError> {
|
||||||
|
let session = state
|
||||||
|
.auth_service
|
||||||
|
.poll_telegram_login(&code, &state.jwt_secret, user_agent(&headers))
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let Some(session) = session else {
|
||||||
|
return Ok(with_headers(
|
||||||
|
HeaderMap::new(),
|
||||||
|
Json(json!({ "status": "pending" })),
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut out_headers = HeaderMap::new();
|
||||||
|
set_session_cookies(&mut out_headers, &state, &session);
|
||||||
|
Ok(with_headers(
|
||||||
|
out_headers,
|
||||||
|
Json(json!({ "status": "success", "token": session.access_token })),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
#[axum::debug_handler]
|
#[axum::debug_handler]
|
||||||
async fn generate_seed_api(
|
async fn generate_seed_api(
|
||||||
State(state): State<ApiState>,
|
State(state): State<ApiState>,
|
||||||
@@ -171,9 +224,11 @@ async fn generate_seed_api(
|
|||||||
let mut out_headers = HeaderMap::new();
|
let mut out_headers = HeaderMap::new();
|
||||||
set_session_cookies(&mut out_headers, &state, &session);
|
set_session_cookies(&mut out_headers, &state, &session);
|
||||||
|
|
||||||
|
// Cookie — для веб-фронта; access_token в теле — для Tauri-приложения
|
||||||
|
// (кросс-origin custom scheme, Lax-cookie туда не долетают, см. `src/lib/api.ts`).
|
||||||
Ok((
|
Ok((
|
||||||
out_headers,
|
out_headers,
|
||||||
Json(json!({ "status": "success", "seed": seed })),
|
Json(json!({ "status": "success", "seed": seed, "token": session.access_token })),
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,7 +251,36 @@ async fn login_seed_api(
|
|||||||
let mut out_headers = HeaderMap::new();
|
let mut out_headers = HeaderMap::new();
|
||||||
set_session_cookies(&mut out_headers, &state, &session);
|
set_session_cookies(&mut out_headers, &state, &session);
|
||||||
|
|
||||||
Ok((out_headers, Json(json!({ "status": "success" }))))
|
Ok((
|
||||||
|
out_headers,
|
||||||
|
Json(json!({ "status": "success", "token": session.access_token })),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Логин Owner/Moderator/Partner по логину+паролю — отдельная админка,
|
||||||
|
/// не завязанная на Telegram/seed (см. `AuthService::login_staff`).
|
||||||
|
async fn admin_login_api(
|
||||||
|
State(state): State<ApiState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Json(payload): Json<AdminLoginPayload>,
|
||||||
|
) -> Result<impl IntoResponse, AppError> {
|
||||||
|
let session = state
|
||||||
|
.auth_service
|
||||||
|
.login_staff(
|
||||||
|
&payload.username,
|
||||||
|
&payload.password,
|
||||||
|
&state.jwt_secret,
|
||||||
|
user_agent(&headers),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let mut out_headers = HeaderMap::new();
|
||||||
|
set_session_cookies(&mut out_headers, &state, &session);
|
||||||
|
|
||||||
|
Ok((
|
||||||
|
out_headers,
|
||||||
|
Json(json!({ "status": "success", "token": session.access_token })),
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Обменивает короткоживущий bootstrap-токен (см. `bot.rs`, кнопки
|
/// Обменивает короткоживущий bootstrap-токен (см. `bot.rs`, кнопки
|
||||||
|
|||||||
+330
-17
@@ -95,27 +95,340 @@ pub async fn auth_guard(
|
|||||||
Ok(next.run(req).await)
|
Ok(next.run(req).await)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Проверяет права администратора.
|
// Ролевые гварды админки (Owner/Moderator/Partner) — заменяют старый
|
||||||
pub async fn admin_guard(
|
// `admin_guard`, который держался на одном захардкоженном ADMIN_TG_ID.
|
||||||
State(state): State<ApiState>,
|
// Роль читается из уже инжектнутого auth_guard'ом User, второй запрос к БД
|
||||||
|
// не нужен.
|
||||||
|
|
||||||
|
fn current_user(req: &Request) -> Result<&User, AppError> {
|
||||||
|
req.extensions()
|
||||||
|
.get::<User>()
|
||||||
|
.ok_or_else(|| AppError::Unauthorized("Context lost: Auth required".into()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn deny(user: &User) -> AppError {
|
||||||
|
warn!(
|
||||||
|
"SECURITY_ALERT: Unauthorized admin access attempt by user_id={} role={:?}",
|
||||||
|
user.id, user.role
|
||||||
|
);
|
||||||
|
AppError::Unauthorized("Insufficient clearance level".into())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Только Owner.
|
||||||
|
pub async fn owner_guard(
|
||||||
|
State(_state): State<ApiState>,
|
||||||
req: Request,
|
req: Request,
|
||||||
next: Next,
|
next: Next,
|
||||||
) -> Result<Response, AppError> {
|
) -> Result<Response, AppError> {
|
||||||
let user = req
|
let user = current_user(&req)?;
|
||||||
.extensions()
|
if user.role == "owner" {
|
||||||
.get::<User>()
|
|
||||||
.ok_or_else(|| AppError::Unauthorized("Context lost: Auth required".into()))?;
|
|
||||||
|
|
||||||
// Используем закешированный в памяти ID
|
|
||||||
if user.tg_id == Some(state.admin_tg_id) || user.role == "admin" {
|
|
||||||
Ok(next.run(req).await)
|
Ok(next.run(req).await)
|
||||||
} else {
|
} else {
|
||||||
warn!(
|
Err(deny(user))
|
||||||
"SECURITY_ALERT: Unauthorized admin access attempt by @{:?}",
|
}
|
||||||
user.tg_username
|
}
|
||||||
);
|
|
||||||
Err(AppError::Unauthorized(
|
/// Owner или Moderator (любой, независимо от `can_manage_nodes`).
|
||||||
"Insufficient clearance level".into(),
|
pub async fn staff_guard(
|
||||||
))
|
State(_state): State<ApiState>,
|
||||||
|
req: Request,
|
||||||
|
next: Next,
|
||||||
|
) -> Result<Response, AppError> {
|
||||||
|
let user = current_user(&req)?;
|
||||||
|
if user.role == "owner" || user.role == "moderator" {
|
||||||
|
Ok(next.run(req).await)
|
||||||
|
} else {
|
||||||
|
Err(deny(user))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Owner ИЛИ Moderator с выданным правом `can_manage_nodes` — гейт для
|
||||||
|
/// управления нодами.
|
||||||
|
pub async fn node_manage_guard(
|
||||||
|
State(_state): State<ApiState>,
|
||||||
|
req: Request,
|
||||||
|
next: Next,
|
||||||
|
) -> Result<Response, AppError> {
|
||||||
|
let user = current_user(&req)?;
|
||||||
|
if user.role == "owner" || (user.role == "moderator" && user.can_manage_nodes) {
|
||||||
|
Ok(next.run(req).await)
|
||||||
|
} else {
|
||||||
|
Err(deny(user))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Owner, Moderator ИЛИ Support — доступ к тикетам техподдержки. Шире
|
||||||
|
/// `staff_guard` (тот не пускает Support).
|
||||||
|
pub async fn support_access_guard(
|
||||||
|
State(_state): State<ApiState>,
|
||||||
|
req: Request,
|
||||||
|
next: Next,
|
||||||
|
) -> Result<Response, AppError> {
|
||||||
|
let user = current_user(&req)?;
|
||||||
|
if user.role == "owner" || user.role == "moderator" || user.role == "support" {
|
||||||
|
Ok(next.run(req).await)
|
||||||
|
} else {
|
||||||
|
Err(deny(user))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Только Partner.
|
||||||
|
pub async fn partner_guard(
|
||||||
|
State(_state): State<ApiState>,
|
||||||
|
req: Request,
|
||||||
|
next: Next,
|
||||||
|
) -> Result<Response, AppError> {
|
||||||
|
let user = current_user(&req)?;
|
||||||
|
if user.role == "partner" {
|
||||||
|
Ok(next.run(req).await)
|
||||||
|
} else {
|
||||||
|
Err(deny(user))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::db::repository::{AppRepository, PgRepository};
|
||||||
|
use crate::modules::auth::service::AuthService;
|
||||||
|
use crate::modules::billing::service::BillingService;
|
||||||
|
use crate::modules::nodes::service::NodeService;
|
||||||
|
use crate::modules::referrals::service::ReferralService;
|
||||||
|
use crate::modules::staff::service::StaffService;
|
||||||
|
use crate::modules::users::service::UserService;
|
||||||
|
use axum::{
|
||||||
|
body::Body,
|
||||||
|
http::{Request as HttpRequest, StatusCode},
|
||||||
|
middleware,
|
||||||
|
routing::get,
|
||||||
|
Extension, Router,
|
||||||
|
};
|
||||||
|
use sqlx::PgPool;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tower::ServiceExt;
|
||||||
|
|
||||||
|
fn build_state(pool: PgPool) -> ApiState {
|
||||||
|
// BillingService::new паникует без этой переменной окружения; для
|
||||||
|
// тестов ролевых гвардов её конкретное значение не имеет значения.
|
||||||
|
std::env::set_var("TON_MASTER_WALLET", "UQtest_wallet_for_guard_tests");
|
||||||
|
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool));
|
||||||
|
ApiState {
|
||||||
|
repo: repo.clone(),
|
||||||
|
auth_service: AuthService::new(repo.clone()),
|
||||||
|
billing_service: BillingService::new(repo.clone(), None),
|
||||||
|
node_service: NodeService::new(repo.clone()),
|
||||||
|
referral_service: ReferralService::new(repo.clone()),
|
||||||
|
user_service: UserService::new(repo.clone()),
|
||||||
|
staff_service: StaffService::new(repo.clone()),
|
||||||
|
support_service: crate::modules::support::service::SupportService::new(repo.clone()),
|
||||||
|
geoip_service: crate::modules::geoip::service::GeoipService::new(
|
||||||
|
"https://example.invalid/{}.mmdb.gz".into(),
|
||||||
|
"https://example.invalid/vpn.txt".into(),
|
||||||
|
),
|
||||||
|
jwt_secret: "test-secret".into(),
|
||||||
|
bot_token: "test-bot-token".into(),
|
||||||
|
bot_username: "test_bot".into(),
|
||||||
|
cookie_domain: "".into(),
|
||||||
|
cookie_secure: false,
|
||||||
|
csrf_allowed_origins: vec![],
|
||||||
|
proxy_internal_secret: "test-internal-secret".into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn make_user(role: &str, can_manage_nodes: bool) -> User {
|
||||||
|
User {
|
||||||
|
id: uuid::Uuid::new_v4(),
|
||||||
|
tg_id: None,
|
||||||
|
tg_username: None,
|
||||||
|
role: role.into(),
|
||||||
|
balance: 0,
|
||||||
|
game_tickets: 0,
|
||||||
|
has_used_trial: false,
|
||||||
|
seed_hash: None,
|
||||||
|
last_hack_at: None,
|
||||||
|
data_limit_bytes: None,
|
||||||
|
data_used_bytes: 0,
|
||||||
|
data_period_reset_at: None,
|
||||||
|
username: None,
|
||||||
|
password_hash: None,
|
||||||
|
can_manage_nodes,
|
||||||
|
commission_percent: None,
|
||||||
|
partner_code: None,
|
||||||
|
referred_by_partner_id: None,
|
||||||
|
awaiting_support_message: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Строит одноразовый роутер с единственным guard'ом под тестом, инжектит
|
||||||
|
/// заданного пользователя напрямую в extensions (минуя настоящий auth_guard/JWT
|
||||||
|
/// — гварды читают только Extension<User>, см. `current_user`) и возвращает
|
||||||
|
/// итоговый HTTP-статус.
|
||||||
|
async fn status_for<G, Fut>(state: ApiState, user: User, guard: G) -> StatusCode
|
||||||
|
where
|
||||||
|
G: Fn(State<ApiState>, Request, Next) -> Fut + Clone + Send + Sync + 'static,
|
||||||
|
Fut: std::future::Future<Output = Result<Response, AppError>> + Send + 'static,
|
||||||
|
{
|
||||||
|
let app = Router::new()
|
||||||
|
.route("/", get(|| async { StatusCode::OK }))
|
||||||
|
.route_layer(middleware::from_fn_with_state(state.clone(), guard))
|
||||||
|
.layer(Extension(user))
|
||||||
|
.with_state(state);
|
||||||
|
|
||||||
|
let resp = app
|
||||||
|
.oneshot(HttpRequest::builder().uri("/").body(Body::empty()).unwrap())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
resp.status()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test]
|
||||||
|
async fn test_owner_guard_matrix(pool: PgPool) {
|
||||||
|
let state = build_state(pool);
|
||||||
|
assert_eq!(
|
||||||
|
status_for(state.clone(), make_user("owner", false), owner_guard).await,
|
||||||
|
StatusCode::OK
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
status_for(state.clone(), make_user("moderator", true), owner_guard).await,
|
||||||
|
StatusCode::UNAUTHORIZED
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
status_for(state.clone(), make_user("partner", false), owner_guard).await,
|
||||||
|
StatusCode::UNAUTHORIZED
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
status_for(state.clone(), make_user("user", false), owner_guard).await,
|
||||||
|
StatusCode::UNAUTHORIZED
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test]
|
||||||
|
async fn test_staff_guard_matrix(pool: PgPool) {
|
||||||
|
let state = build_state(pool);
|
||||||
|
assert_eq!(
|
||||||
|
status_for(state.clone(), make_user("owner", false), staff_guard).await,
|
||||||
|
StatusCode::OK
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
status_for(state.clone(), make_user("moderator", false), staff_guard).await,
|
||||||
|
StatusCode::OK
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
status_for(state.clone(), make_user("partner", false), staff_guard).await,
|
||||||
|
StatusCode::UNAUTHORIZED
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
status_for(state.clone(), make_user("user", false), staff_guard).await,
|
||||||
|
StatusCode::UNAUTHORIZED
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test]
|
||||||
|
async fn test_node_manage_guard_matrix(pool: PgPool) {
|
||||||
|
let state = build_state(pool);
|
||||||
|
assert_eq!(
|
||||||
|
status_for(state.clone(), make_user("owner", false), node_manage_guard).await,
|
||||||
|
StatusCode::OK,
|
||||||
|
"owner управляет нодами всегда, независимо от can_manage_nodes"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
status_for(
|
||||||
|
state.clone(),
|
||||||
|
make_user("moderator", true),
|
||||||
|
node_manage_guard
|
||||||
|
)
|
||||||
|
.await,
|
||||||
|
StatusCode::OK
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
status_for(
|
||||||
|
state.clone(),
|
||||||
|
make_user("moderator", false),
|
||||||
|
node_manage_guard
|
||||||
|
)
|
||||||
|
.await,
|
||||||
|
StatusCode::UNAUTHORIZED,
|
||||||
|
"модератор без выданного права can_manage_nodes не должен проходить"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
status_for(
|
||||||
|
state.clone(),
|
||||||
|
make_user("partner", false),
|
||||||
|
node_manage_guard
|
||||||
|
)
|
||||||
|
.await,
|
||||||
|
StatusCode::UNAUTHORIZED
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test]
|
||||||
|
async fn test_partner_guard_matrix(pool: PgPool) {
|
||||||
|
let state = build_state(pool);
|
||||||
|
assert_eq!(
|
||||||
|
status_for(state.clone(), make_user("partner", false), partner_guard).await,
|
||||||
|
StatusCode::OK
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
status_for(state.clone(), make_user("owner", false), partner_guard).await,
|
||||||
|
StatusCode::UNAUTHORIZED
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
status_for(state.clone(), make_user("moderator", true), partner_guard).await,
|
||||||
|
StatusCode::UNAUTHORIZED
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
status_for(state.clone(), make_user("user", false), partner_guard).await,
|
||||||
|
StatusCode::UNAUTHORIZED
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test]
|
||||||
|
async fn test_support_access_guard_matrix(pool: PgPool) {
|
||||||
|
let state = build_state(pool);
|
||||||
|
assert_eq!(
|
||||||
|
status_for(
|
||||||
|
state.clone(),
|
||||||
|
make_user("owner", false),
|
||||||
|
support_access_guard
|
||||||
|
)
|
||||||
|
.await,
|
||||||
|
StatusCode::OK
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
status_for(
|
||||||
|
state.clone(),
|
||||||
|
make_user("moderator", false),
|
||||||
|
support_access_guard
|
||||||
|
)
|
||||||
|
.await,
|
||||||
|
StatusCode::OK
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
status_for(
|
||||||
|
state.clone(),
|
||||||
|
make_user("support", false),
|
||||||
|
support_access_guard
|
||||||
|
)
|
||||||
|
.await,
|
||||||
|
StatusCode::OK
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
status_for(
|
||||||
|
state.clone(),
|
||||||
|
make_user("partner", false),
|
||||||
|
support_access_guard
|
||||||
|
)
|
||||||
|
.await,
|
||||||
|
StatusCode::UNAUTHORIZED
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
status_for(
|
||||||
|
state.clone(),
|
||||||
|
make_user("user", false),
|
||||||
|
support_access_guard
|
||||||
|
)
|
||||||
|
.await,
|
||||||
|
StatusCode::UNAUTHORIZED
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
//! Авторизация: Telegram Login Widget, анонимный "Ghost Protocol" (вход по seed-фразе)
|
//! Авторизация: Telegram Login Widget, анонимный "Ghost Protocol" (вход по seed-фразе),
|
||||||
//! и JWT-гварды (`auth_guard`, `admin_guard`). См. README.md рядом с этим файлом.
|
//! логин Owner/Moderator/Partner/Support по паролю, и ролевые JWT-гварды (`auth_guard` +
|
||||||
|
//! `owner_guard`/`staff_guard`/`node_manage_guard`/`partner_guard`/`support_access_guard`).
|
||||||
|
//! См. README.md рядом.
|
||||||
|
|
||||||
pub mod controller;
|
pub mod controller;
|
||||||
pub mod guard;
|
pub mod guard;
|
||||||
pub mod service;
|
pub mod service;
|
||||||
|
|
||||||
pub use guard::{admin_guard, auth_guard};
|
pub use guard::{
|
||||||
|
auth_guard, node_manage_guard, owner_guard, partner_guard, staff_guard, support_access_guard,
|
||||||
|
};
|
||||||
pub use service::{AuthService, Claims};
|
pub use service::{AuthService, Claims};
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ pub struct Claims {
|
|||||||
/// Пара токенов, выдаваемая при входе: короткий access-JWT (кладётся в
|
/// Пара токенов, выдаваемая при входе: короткий access-JWT (кладётся в
|
||||||
/// `Authorization`/cookie на каждый запрос) и непрозрачный refresh-токен
|
/// `Authorization`/cookie на каждый запрос) и непрозрачный refresh-токен
|
||||||
/// (только в отдельной cookie, только на `/auth/refresh`).
|
/// (только в отдельной cookie, только на `/auth/refresh`).
|
||||||
|
#[derive(Debug)]
|
||||||
pub struct Session {
|
pub struct Session {
|
||||||
pub access_token: String,
|
pub access_token: String,
|
||||||
pub refresh_token: String,
|
pub refresh_token: String,
|
||||||
@@ -239,6 +240,176 @@ impl AuthService {
|
|||||||
self.issue_session(user.id, user.tg_id, jwt_secret, user_agent)
|
self.issue_session(user.id, user.tg_id, jwt_secret, user_agent)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Логин Owner/Moderator/Partner по паролю (не Telegram/seed) ---
|
||||||
|
// Отдельная схема хеширования от `hash_seed_argon2`: там общая на все
|
||||||
|
// seed'ы фиксированная соль из ARGON_SALT — приемлемо для
|
||||||
|
// высокоэнтропийных сгенерированных seed'ов, но не для человеческих
|
||||||
|
// паролей (одна утечка ARGON_SALT — и все пароли колются разом одной
|
||||||
|
// radeoduga-таблицей). Здесь — самодостаточная PHC-строка со случайной
|
||||||
|
// солью на каждый пароль (`argon2::password_hash`).
|
||||||
|
|
||||||
|
/// Хеширует пароль со случайной солью. Используется и при создании
|
||||||
|
/// Owner/Moderator/Partner (`staff` controller), и в CLI-бутстрапе
|
||||||
|
/// (`--create-owner`) — важно, чтобы схема была идентичной везде.
|
||||||
|
pub fn hash_password(password: &str) -> Result<String, AppError> {
|
||||||
|
use argon2::password_hash::{rand_core::OsRng, PasswordHasher, SaltString};
|
||||||
|
let salt = SaltString::generate(&mut OsRng);
|
||||||
|
Argon2::default()
|
||||||
|
.hash_password(password.as_bytes(), &salt)
|
||||||
|
.map(|h| h.to_string())
|
||||||
|
.map_err(|e| AppError::Internal(format!("Ошибка хеширования пароля: {e}")))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Логин Owner/Moderator/Partner по логину+паролю. Одинаковая ошибка на
|
||||||
|
/// "нет юзера" и "неверный пароль" — без user enumeration.
|
||||||
|
#[instrument(skip(self, password, jwt_secret))]
|
||||||
|
pub async fn login_staff(
|
||||||
|
&self,
|
||||||
|
username: &str,
|
||||||
|
password: &str,
|
||||||
|
jwt_secret: &str,
|
||||||
|
user_agent: Option<&str>,
|
||||||
|
) -> Result<Session, AppError> {
|
||||||
|
use argon2::password_hash::{PasswordHash, PasswordVerifier};
|
||||||
|
|
||||||
|
let generic_err = || AppError::Unauthorized("Invalid credentials".into());
|
||||||
|
|
||||||
|
let user = self
|
||||||
|
.repo
|
||||||
|
.get_user_by_username(username)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(generic_err)?;
|
||||||
|
let hash = user.password_hash.as_deref().ok_or_else(generic_err)?;
|
||||||
|
let parsed_hash = PasswordHash::new(hash).map_err(|_| generic_err())?;
|
||||||
|
Argon2::default()
|
||||||
|
.verify_password(password.as_bytes(), &parsed_hash)
|
||||||
|
.map_err(|_| generic_err())?;
|
||||||
|
|
||||||
|
self.issue_session(user.id, user.tg_id, jwt_secret, user_agent)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ищет партнёра по коду диплинка (`t.me/<bot>?start=p_XXXXXX`).
|
||||||
|
pub async fn find_partner_by_code(&self, code: &str) -> Result<Option<User>, AppError> {
|
||||||
|
self.repo
|
||||||
|
.find_partner_by_code(code)
|
||||||
|
.await
|
||||||
|
.map_err(AppError::Database)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Привязывает покупателя к партнёру (first-wins, no-op если уже привязан).
|
||||||
|
pub async fn attribute_partner_referral(
|
||||||
|
&self,
|
||||||
|
partner_id: Uuid,
|
||||||
|
buyer_id: Uuid,
|
||||||
|
) -> Result<bool, AppError> {
|
||||||
|
self.repo
|
||||||
|
.set_referred_by_partner(buyer_id, partner_id)
|
||||||
|
.await
|
||||||
|
.map_err(AppError::Database)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Magic-link вход через Telegram-бота для десктоп/мобильного приложения ---
|
||||||
|
// Приложение не может встроить Telegram Login Widget (нет домена/WebView-моста
|
||||||
|
// как у веб-ЛК), поэтому вместо него: приложение создаёт auth_code и открывает
|
||||||
|
// `t.me/<bot>?start=<auth_code>` (см. `bot.rs::handle_commands`), а само поллит
|
||||||
|
// `GET /auth/telegram/session/{code}`, пока бот не подтвердит код.
|
||||||
|
|
||||||
|
/// Создаёт ожидающую подтверждения сессию, возвращает код для deep-link.
|
||||||
|
pub async fn create_telegram_login_code(&self) -> Result<String, AppError> {
|
||||||
|
let auth_code: String = rand::thread_rng()
|
||||||
|
.sample_iter(&Alphanumeric)
|
||||||
|
.take(8)
|
||||||
|
.map(char::from)
|
||||||
|
.collect();
|
||||||
|
self.repo
|
||||||
|
.create_auth_session(&auth_code)
|
||||||
|
.await
|
||||||
|
.map_err(AppError::Database)?;
|
||||||
|
Ok(auth_code)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Вызывается ботом на `/start <auth_code>`: если код существует, ещё не
|
||||||
|
/// подтверждён и не истёк — привязывает его к вошедшему в боте юзеру.
|
||||||
|
pub async fn confirm_telegram_login_code(
|
||||||
|
&self,
|
||||||
|
auth_code: &str,
|
||||||
|
user_id: Uuid,
|
||||||
|
) -> Result<bool, AppError> {
|
||||||
|
self.repo
|
||||||
|
.verify_auth_session(auth_code, user_id)
|
||||||
|
.await
|
||||||
|
.map_err(AppError::Database)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Код для входа, который генерирует **сам бот** по кнопке в меню — в
|
||||||
|
/// отличие от `create_telegram_login_code` (deep-link из приложения),
|
||||||
|
/// боту не нужен отдельный шаг подтверждения: сам факт диалога уже
|
||||||
|
/// удостоверяет tg_id, поэтому код создаётся сразу подтверждённым. Юзер
|
||||||
|
/// вручную вводит его в приложении. Deep-link-флоу ненадёжен для юзеров,
|
||||||
|
/// уже открывавших бота раньше — Telegram не пересылает `?start=` заново
|
||||||
|
/// для уже начатых чатов, только пустой `/start`.
|
||||||
|
pub async fn create_bot_login_code(
|
||||||
|
&self,
|
||||||
|
tg_id: i64,
|
||||||
|
username: Option<String>,
|
||||||
|
) -> Result<String, AppError> {
|
||||||
|
let user = self.process_login(tg_id, username, None).await?;
|
||||||
|
|
||||||
|
let auth_code: String = rand::thread_rng()
|
||||||
|
.sample_iter(&Alphanumeric)
|
||||||
|
.take(8)
|
||||||
|
.map(char::from)
|
||||||
|
.collect();
|
||||||
|
self.repo
|
||||||
|
.create_auth_session(&auth_code)
|
||||||
|
.await
|
||||||
|
.map_err(AppError::Database)?;
|
||||||
|
self.repo
|
||||||
|
.verify_auth_session(&auth_code, user.id)
|
||||||
|
.await
|
||||||
|
.map_err(AppError::Database)?;
|
||||||
|
Ok(auth_code)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Опрашивается приложением. `Ok(None)` — код существует, ждём подтверждения
|
||||||
|
/// в боте; `Err(Unauthorized)` — код не найден или истёк.
|
||||||
|
#[instrument(skip(self, jwt_secret))]
|
||||||
|
pub async fn poll_telegram_login(
|
||||||
|
&self,
|
||||||
|
auth_code: &str,
|
||||||
|
jwt_secret: &str,
|
||||||
|
user_agent: Option<&str>,
|
||||||
|
) -> Result<Option<Session>, AppError> {
|
||||||
|
let (user_id, is_verified, expires_at) = self
|
||||||
|
.repo
|
||||||
|
.get_auth_session(auth_code)
|
||||||
|
.await
|
||||||
|
.map_err(AppError::Database)?
|
||||||
|
.ok_or_else(|| AppError::Unauthorized("Invalid auth code".into()))?;
|
||||||
|
|
||||||
|
if expires_at < Utc::now() {
|
||||||
|
return Err(AppError::Unauthorized("Auth code expired".into()));
|
||||||
|
}
|
||||||
|
if !is_verified {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
let user_id = user_id
|
||||||
|
.ok_or_else(|| AppError::Internal("Verified auth session missing user_id".into()))?;
|
||||||
|
let user = self
|
||||||
|
.repo
|
||||||
|
.get_user_by_id(user_id)
|
||||||
|
.await
|
||||||
|
.map_err(AppError::Database)?
|
||||||
|
.ok_or(AppError::UserNotFound)?;
|
||||||
|
|
||||||
|
self.issue_session(user.id, user.tg_id, jwt_secret, user_agent)
|
||||||
|
.await
|
||||||
|
.map(Some)
|
||||||
|
}
|
||||||
|
|
||||||
// --- Логика Telegram (без изменений, кроме вызова генерации токена) ---
|
// --- Логика Telegram (без изменений, кроме вызова генерации токена) ---
|
||||||
|
|
||||||
// Сигнатура повторяет поля payload-а Telegram Login Widget — это намеренно.
|
// Сигнатура повторяет поля payload-а Telegram Login Widget — это намеренно.
|
||||||
@@ -321,7 +492,309 @@ impl AuthService {
|
|||||||
.create_referral(referrer.id, new_user_id)
|
.create_referral(referrer.id, new_user_id)
|
||||||
.await
|
.await
|
||||||
.ok();
|
.ok();
|
||||||
|
metrics::counter!("referral_signups_total").increment(1);
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::db::repository::PgRepository;
|
||||||
|
use sqlx::PgPool;
|
||||||
|
|
||||||
|
const SECRET: &str = "test-jwt-secret";
|
||||||
|
const SALT: &str = "test-seed-salt-min-16-chars";
|
||||||
|
|
||||||
|
// --- Ротация refresh-токена и обнаружение переиспользования ---
|
||||||
|
|
||||||
|
#[sqlx::test]
|
||||||
|
async fn test_refresh_session_rotates_and_revokes_old_token(pool: PgPool) {
|
||||||
|
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool));
|
||||||
|
let svc = AuthService::new(repo.clone());
|
||||||
|
|
||||||
|
let user = repo.upsert_user(1, None).await.unwrap();
|
||||||
|
let session = svc
|
||||||
|
.issue_session(user.id, Some(1), SECRET, None)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let rotated = svc
|
||||||
|
.refresh_session(&session.refresh_token, SECRET, None)
|
||||||
|
.await
|
||||||
|
.expect("первая ротация должна пройти");
|
||||||
|
assert_ne!(rotated.refresh_token, session.refresh_token);
|
||||||
|
|
||||||
|
// Старый refresh-токен теперь отозван — повторное предъявление отклоняется.
|
||||||
|
let err = svc
|
||||||
|
.refresh_session(&session.refresh_token, SECRET, None)
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(matches!(err, AppError::Unauthorized(_)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test]
|
||||||
|
async fn test_refresh_reuse_after_rotation_revokes_all_sessions(pool: PgPool) {
|
||||||
|
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool));
|
||||||
|
let svc = AuthService::new(repo.clone());
|
||||||
|
|
||||||
|
let user = repo.upsert_user(1, None).await.unwrap();
|
||||||
|
let session = svc
|
||||||
|
.issue_session(user.id, Some(1), SECRET, None)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let rotated = svc
|
||||||
|
.refresh_session(&session.refresh_token, SECRET, None)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Переиспользование уже отозванного (старого) токена — сигнал компрометации.
|
||||||
|
let err = svc
|
||||||
|
.refresh_session(&session.refresh_token, SECRET, None)
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(matches!(err, AppError::Unauthorized(_)));
|
||||||
|
|
||||||
|
// Даже свежая пара, выданная легитимной ротацией ДО обнаружения реюза,
|
||||||
|
// должна была быть отозвана целиком (revoke_all_refresh_sessions).
|
||||||
|
let err2 = svc
|
||||||
|
.refresh_session(&rotated.refresh_token, SECRET, None)
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(matches!(err2, AppError::Unauthorized(_)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test]
|
||||||
|
async fn test_refresh_session_expired_rejected(pool: PgPool) {
|
||||||
|
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool));
|
||||||
|
let svc = AuthService::new(repo.clone());
|
||||||
|
|
||||||
|
let user = repo.upsert_user(1, None).await.unwrap();
|
||||||
|
let raw_token = "manually-crafted-refresh-token";
|
||||||
|
let token_hash = AuthService::hash_refresh_token(raw_token);
|
||||||
|
repo.create_refresh_session(
|
||||||
|
user.id,
|
||||||
|
&token_hash,
|
||||||
|
None,
|
||||||
|
Utc::now() - chrono::Duration::days(1),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let err = svc
|
||||||
|
.refresh_session(raw_token, SECRET, None)
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(matches!(err, AppError::Unauthorized(_)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- login_staff: без user enumeration ---
|
||||||
|
|
||||||
|
#[sqlx::test]
|
||||||
|
async fn test_login_staff_success(pool: PgPool) {
|
||||||
|
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool));
|
||||||
|
let svc = AuthService::new(repo.clone());
|
||||||
|
|
||||||
|
let hash = AuthService::hash_password("correct horse battery staple").unwrap();
|
||||||
|
repo.create_staff_user("owner1", &hash, "owner", true, None, None)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
svc.login_staff("owner1", "correct horse battery staple", SECRET, None)
|
||||||
|
.await
|
||||||
|
.expect("верный логин/пароль должны пройти");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test]
|
||||||
|
async fn test_login_staff_generic_error_no_enumeration(pool: PgPool) {
|
||||||
|
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool));
|
||||||
|
let svc = AuthService::new(repo.clone());
|
||||||
|
|
||||||
|
let hash = AuthService::hash_password("correct horse battery staple").unwrap();
|
||||||
|
repo.create_staff_user("owner1", &hash, "owner", true, None, None)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
// Обычный Telegram-юзер без username/password_hash вообще.
|
||||||
|
repo.upsert_user(42, None).await.unwrap();
|
||||||
|
|
||||||
|
let err_wrong_pass = svc
|
||||||
|
.login_staff("owner1", "wrong password", SECRET, None)
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
let err_unknown_user = svc
|
||||||
|
.login_staff("nobody", "whatever", SECRET, None)
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
|
||||||
|
let msg = |e: AppError| match e {
|
||||||
|
AppError::Unauthorized(m) => m,
|
||||||
|
other => panic!("ожидался Unauthorized, получено {other:?}"),
|
||||||
|
};
|
||||||
|
// Обе ошибки должны быть текстуально неотличимы — иначе можно перебором
|
||||||
|
// узнать, существует ли username.
|
||||||
|
assert_eq!(msg(err_wrong_pass), msg(err_unknown_user));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_hash_password_roundtrip() {
|
||||||
|
use argon2::password_hash::{PasswordHash, PasswordVerifier};
|
||||||
|
let hash = AuthService::hash_password("hunter2").unwrap();
|
||||||
|
let parsed = PasswordHash::new(&hash).unwrap();
|
||||||
|
assert!(Argon2::default()
|
||||||
|
.verify_password(b"hunter2", &parsed)
|
||||||
|
.is_ok());
|
||||||
|
assert!(Argon2::default()
|
||||||
|
.verify_password(b"wrong", &parsed)
|
||||||
|
.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Ghost Protocol: seed-вход ---
|
||||||
|
|
||||||
|
#[sqlx::test]
|
||||||
|
async fn test_register_and_login_seed_roundtrip(pool: PgPool) {
|
||||||
|
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool));
|
||||||
|
let svc = AuthService::new(repo.clone());
|
||||||
|
|
||||||
|
let (seed, _session) = svc.register_seed(SALT, SECRET, None).await.unwrap();
|
||||||
|
svc.login_seed(&seed, SALT, SECRET, None)
|
||||||
|
.await
|
||||||
|
.expect("вход по только что сгенерированному seed должен пройти");
|
||||||
|
|
||||||
|
let err = svc
|
||||||
|
.login_seed("totally-wrong-seed-value", SALT, SECRET, None)
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(matches!(err, AppError::Unauthorized(_)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Telegram Login Widget: HMAC-подпись ---
|
||||||
|
|
||||||
|
fn compute_valid_tg_hash(
|
||||||
|
bot_token: &str,
|
||||||
|
id: i64,
|
||||||
|
username: Option<&str>,
|
||||||
|
auth_date: i64,
|
||||||
|
) -> String {
|
||||||
|
let mut data_check_arr = vec![format!("auth_date={}", auth_date), format!("id={}", id)];
|
||||||
|
if let Some(v) = username {
|
||||||
|
data_check_arr.push(format!("username={}", v));
|
||||||
|
}
|
||||||
|
data_check_arr.sort();
|
||||||
|
let data_check_string = data_check_arr.join("\n");
|
||||||
|
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
hasher.update(bot_token.trim().as_bytes());
|
||||||
|
let secret_key = hasher.finalize();
|
||||||
|
|
||||||
|
let mut mac = Hmac::<Sha256>::new_from_slice(&secret_key).unwrap();
|
||||||
|
mac.update(data_check_string.as_bytes());
|
||||||
|
hex::encode(mac.finalize().into_bytes())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test]
|
||||||
|
async fn test_verify_tg_widget_auth_accepts_valid_signature(pool: PgPool) {
|
||||||
|
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool));
|
||||||
|
let svc = AuthService::new(repo);
|
||||||
|
let bot_token = "123:ABC-fake-bot-token";
|
||||||
|
let auth_date = Utc::now().timestamp();
|
||||||
|
let hash = compute_valid_tg_hash(bot_token, 555, Some("neo"), auth_date);
|
||||||
|
|
||||||
|
svc.verify_tg_widget_auth(
|
||||||
|
bot_token,
|
||||||
|
555,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
Some("neo"),
|
||||||
|
None,
|
||||||
|
auth_date,
|
||||||
|
&hash,
|
||||||
|
)
|
||||||
|
.expect("валидная подпись должна приниматься");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test]
|
||||||
|
async fn test_verify_tg_widget_auth_rejects_tampered_signature(pool: PgPool) {
|
||||||
|
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool));
|
||||||
|
let svc = AuthService::new(repo);
|
||||||
|
let bot_token = "123:ABC-fake-bot-token";
|
||||||
|
let auth_date = Utc::now().timestamp();
|
||||||
|
let hash = compute_valid_tg_hash(bot_token, 555, Some("neo"), auth_date);
|
||||||
|
|
||||||
|
// Тот же хэш, но id подделан относительно того, что реально подписано.
|
||||||
|
let err = svc
|
||||||
|
.verify_tg_widget_auth(
|
||||||
|
bot_token,
|
||||||
|
666,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
Some("neo"),
|
||||||
|
None,
|
||||||
|
auth_date,
|
||||||
|
&hash,
|
||||||
|
)
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(matches!(err, AppError::Unauthorized(_)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test]
|
||||||
|
async fn test_verify_tg_widget_auth_rejects_expired_session(pool: PgPool) {
|
||||||
|
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool));
|
||||||
|
let svc = AuthService::new(repo);
|
||||||
|
let bot_token = "123:ABC-fake-bot-token";
|
||||||
|
let auth_date = Utc::now().timestamp() - 90_000; // > 86400 секунд назад
|
||||||
|
let hash = compute_valid_tg_hash(bot_token, 555, Some("neo"), auth_date);
|
||||||
|
|
||||||
|
let err = svc
|
||||||
|
.verify_tg_widget_auth(
|
||||||
|
bot_token,
|
||||||
|
555,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
Some("neo"),
|
||||||
|
None,
|
||||||
|
auth_date,
|
||||||
|
&hash,
|
||||||
|
)
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(matches!(err, AppError::Unauthorized(_)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- process_login: привязка реферала только для новых юзеров, без self-ref ---
|
||||||
|
|
||||||
|
#[sqlx::test]
|
||||||
|
async fn test_process_login_applies_referral_for_new_user_only(pool: PgPool) {
|
||||||
|
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool));
|
||||||
|
let svc = AuthService::new(repo.clone());
|
||||||
|
|
||||||
|
let referrer = repo.upsert_user(100, None).await.unwrap();
|
||||||
|
|
||||||
|
// Новый юзер с валидным реферальным кодом (tg_id реферера) -> связь создаётся.
|
||||||
|
svc.process_login(200, None, Some("100".to_string()))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let (count, _) = repo.get_referral_stats(referrer.id).await.unwrap();
|
||||||
|
assert_eq!(count, 1);
|
||||||
|
|
||||||
|
// Самореферал игнорируется.
|
||||||
|
svc.process_login(300, None, Some("300".to_string()))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let (self_ref_count, _) = repo
|
||||||
|
.get_referral_stats(repo.get_user_by_tg_id(300).await.unwrap().unwrap().id)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(self_ref_count, 0);
|
||||||
|
|
||||||
|
// Повторный логин уже существующего юзера с ref_code — не создаёт новую связь
|
||||||
|
// (is_new уже false на момент проверки).
|
||||||
|
svc.process_login(200, None, Some("100".to_string()))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let (count_after, _) = repo.get_referral_stats(referrer.id).await.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
count_after, 1,
|
||||||
|
"повторный логин не должен задваивать реферальную связь"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,14 +1,21 @@
|
|||||||
use axum::{
|
use axum::{
|
||||||
extract::{Extension, State},
|
extract::{ConnectInfo, Extension, State},
|
||||||
|
http::HeaderMap,
|
||||||
middleware,
|
middleware,
|
||||||
routing::post,
|
routing::{get, post},
|
||||||
Json, Router,
|
Json, Router,
|
||||||
};
|
};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
|
use std::net::{IpAddr, SocketAddr};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::{api::ApiState, db::models::User, error::AppError, modules::auth::guard::auth_guard};
|
use crate::{
|
||||||
|
api::ApiState,
|
||||||
|
db::models::User,
|
||||||
|
error::AppError,
|
||||||
|
modules::{auth::guard::auth_guard, geoip::currency::currency_for_country},
|
||||||
|
};
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
pub struct InitiatePaymentPayload {
|
pub struct InitiatePaymentPayload {
|
||||||
@@ -23,6 +30,13 @@ pub struct ConfirmPaymentPayload {
|
|||||||
pub external_tx_id: String,
|
pub external_tx_id: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Без auth_guard — цены должны быть видны анонимному посетителю сайта
|
||||||
|
/// (см. netrunner-landing/components/sections/Pricing.tsx), не только
|
||||||
|
/// залогиненным.
|
||||||
|
pub fn public_router() -> Router<ApiState> {
|
||||||
|
Router::new().route("/plans", get(list_plans_api))
|
||||||
|
}
|
||||||
|
|
||||||
pub fn router(state: ApiState) -> Router<ApiState> {
|
pub fn router(state: ApiState) -> Router<ApiState> {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/pay/initiate", post(initiate_payment_api))
|
.route("/pay/initiate", post(initiate_payment_api))
|
||||||
@@ -31,6 +45,68 @@ pub fn router(state: ApiState) -> Router<ApiState> {
|
|||||||
.route_layer(middleware::from_fn_with_state(state, auth_guard))
|
.route_layer(middleware::from_fn_with_state(state, auth_guard))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// IP реального посетителя — из `X-Real-IP` (его ставит nginx на проде, см.
|
||||||
|
/// netrunner-data/nginx/*, netrunner-backend/nginx/*, восстановлен из
|
||||||
|
/// `CF-Connecting-IP` через `set_real_ip_from`/`real_ip_header`), иначе
|
||||||
|
/// `X-Forwarded-For` (первый адрес в цепочке), иначе — реальный TCP-пир
|
||||||
|
/// (для локальной разработки без nginx впереди).
|
||||||
|
fn extract_client_ip(headers: &HeaderMap, connect_info: &SocketAddr) -> IpAddr {
|
||||||
|
if let Some(real_ip) = headers
|
||||||
|
.get("x-real-ip")
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.and_then(|s| s.trim().parse().ok())
|
||||||
|
{
|
||||||
|
return real_ip;
|
||||||
|
}
|
||||||
|
if let Some(forwarded) = headers
|
||||||
|
.get("x-forwarded-for")
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.and_then(|s| s.split(',').next())
|
||||||
|
.and_then(|s| s.trim().parse().ok())
|
||||||
|
{
|
||||||
|
return forwarded;
|
||||||
|
}
|
||||||
|
connect_info.ip()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Тарифы в валюте, определённой по региону посетителя. Если IP похож на
|
||||||
|
/// VPN/датацентр (см. `geoip_service.is_vpn`) — намеренно НЕ используем
|
||||||
|
/// региональную цену, отдаём базовую USD (иначе покупка "из региона" через
|
||||||
|
/// любой публичный VPN обесценивала бы всю схему региональных скидок).
|
||||||
|
async fn list_plans_api(
|
||||||
|
State(state): State<ApiState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
ConnectInfo(connect_info): ConnectInfo<SocketAddr>,
|
||||||
|
) -> Result<Json<Value>, AppError> {
|
||||||
|
let ip = extract_client_ip(&headers, &connect_info);
|
||||||
|
|
||||||
|
let is_vpn = state.geoip_service.is_vpn(ip).await;
|
||||||
|
let country = state.geoip_service.country_for_ip(ip).await;
|
||||||
|
let currency = if is_vpn {
|
||||||
|
"USD"
|
||||||
|
} else {
|
||||||
|
country
|
||||||
|
.as_deref()
|
||||||
|
.and_then(currency_for_country)
|
||||||
|
.unwrap_or("USD")
|
||||||
|
};
|
||||||
|
|
||||||
|
let (resolved_currency, plans) = state
|
||||||
|
.billing_service
|
||||||
|
.list_plans_in_currency(currency)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(Json(json!({
|
||||||
|
"currency": resolved_currency,
|
||||||
|
"detected_country": country,
|
||||||
|
"vpn_detected": is_vpn,
|
||||||
|
"plans": plans.into_iter().map(|(name, amount)| json!({
|
||||||
|
"plan": name,
|
||||||
|
"amount": amount,
|
||||||
|
})).collect::<Vec<_>>(),
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
/// Шаг 1: Запрашиваем у провайдера данные для оплаты (адрес, ссылку и т.д.)
|
/// Шаг 1: Запрашиваем у провайдера данные для оплаты (адрес, ссылку и т.д.)
|
||||||
async fn initiate_payment_api(
|
async fn initiate_payment_api(
|
||||||
Extension(user): Extension<User>,
|
Extension(user): Extension<User>,
|
||||||
|
|||||||
@@ -0,0 +1,148 @@
|
|||||||
|
//! Провайдер [@CryptoBot](https://t.me/CryptoBot) (Crypto Pay API) — оплата
|
||||||
|
//! криптой прямо в Telegram, без TonConnect-кошелька: юзер получает ссылку на
|
||||||
|
//! инвойс внутри CryptoBot, платит там, возвращается и жмёт "Я оплатил".
|
||||||
|
//!
|
||||||
|
//! Биллим в USDT 1:1 к USD-цене тарифа (см. `BillingService::initiate_payment` —
|
||||||
|
//! в отличие от TON, курс не нужен, `invoice.amount` уже приходит в USD-центах
|
||||||
|
//! из `plan_prices`). Своего вебхука пока нет — статус подтверждается либо
|
||||||
|
//! ручным нажатием (`verify_payment` бьёт по конкретному `invoice_id`), либо
|
||||||
|
//! никогда автоматически не подтягивается фоновой сверкой (в отличие от TON,
|
||||||
|
//! `get_recent_transactions` не переопределён — см. README рядом с `mod.rs`).
|
||||||
|
//!
|
||||||
|
//! Документация API: <https://help.crypt.bot/crypto-pay-api>.
|
||||||
|
|
||||||
|
use super::{InvoiceData, PaymentProvider};
|
||||||
|
use crate::error::AppError;
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
|
/// Актив, в котором выставляется инвойс. USDT — самый ликвидный и стабильный
|
||||||
|
/// (1:1 к USD, без своей волатильности) из поддерживаемых Crypto Pay API.
|
||||||
|
const ASSET: &str = "USDT";
|
||||||
|
|
||||||
|
pub struct CryptoBotProvider {
|
||||||
|
pub api_token: String,
|
||||||
|
/// `https://pay.crypt.bot/api` (mainnet) или `https://testnet-pay.crypt.bot/api`.
|
||||||
|
pub base_url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CryptoBotProvider {
|
||||||
|
fn client(&self) -> Result<reqwest::Client, AppError> {
|
||||||
|
use reqwest::header::{HeaderMap, HeaderValue};
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
headers.insert(
|
||||||
|
"Crypto-Pay-API-Token",
|
||||||
|
HeaderValue::from_str(&self.api_token).map_err(|e| {
|
||||||
|
AppError::Internal(format!("Некорректный CRYPTOBOT_API_TOKEN: {e}"))
|
||||||
|
})?,
|
||||||
|
);
|
||||||
|
reqwest::Client::builder()
|
||||||
|
.default_headers(headers)
|
||||||
|
.build()
|
||||||
|
.map_err(|e| AppError::Internal(format!("Ошибка создания HTTP-клиента: {e}")))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl PaymentProvider for CryptoBotProvider {
|
||||||
|
fn provider_id(&self) -> &'static str {
|
||||||
|
"CRYPTOBOT"
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_checkout_session(&self, invoice: &InvoiceData) -> Result<Value, AppError> {
|
||||||
|
// invoice.amount — USD-центы (см. docstring модуля), USDT биллится 1:1.
|
||||||
|
let amount_usdt = format!("{:.2}", invoice.amount as f64 / 100.0);
|
||||||
|
|
||||||
|
let res: Value = self
|
||||||
|
.client()?
|
||||||
|
.post(format!("{}/createInvoice", self.base_url))
|
||||||
|
.json(&json!({
|
||||||
|
"asset": ASSET,
|
||||||
|
"amount": amount_usdt,
|
||||||
|
"description": "Netrunner VPN — подписка",
|
||||||
|
"payload": invoice.invoice_id.to_string(),
|
||||||
|
}))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::Internal(format!("Ошибка сети CryptoBot API: {e}")))?
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::Internal(format!("Ошибка парсинга ответа CryptoBot: {e}")))?;
|
||||||
|
|
||||||
|
if res["ok"].as_bool() != Some(true) {
|
||||||
|
return Err(AppError::Internal(format!(
|
||||||
|
"CryptoBot createInvoice отказал: {}",
|
||||||
|
res["error"]
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = &res["result"];
|
||||||
|
Ok(json!({
|
||||||
|
// Наш собственный ID (не CryptoBot'а) — чтобы вызывающий код мог
|
||||||
|
// позже дёрнуть `confirm_payment(invoice_id, ...)`.
|
||||||
|
"invoice_id": invoice.invoice_id,
|
||||||
|
"cryptobot_invoice_id": result["invoice_id"],
|
||||||
|
"pay_url": result["bot_invoice_url"].as_str().or_else(|| result["pay_url"].as_str()),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `external_tx_id` здесь — не хэш транзакции, а собственный `invoice_id`
|
||||||
|
/// CryptoBot (полученный из `create_checkout_session`, см. `bot.rs::pay_check`
|
||||||
|
/// — он приходит в callback-данных, отдельно в БД не хранится).
|
||||||
|
async fn verify_payment(
|
||||||
|
&self,
|
||||||
|
external_tx_id: &str,
|
||||||
|
expected_amount: i64,
|
||||||
|
expected_invoice_id: &str,
|
||||||
|
) -> Result<bool, AppError> {
|
||||||
|
let res: Value = self
|
||||||
|
.client()?
|
||||||
|
.get(format!("{}/getInvoices", self.base_url))
|
||||||
|
.query(&[("invoice_ids", external_tx_id)])
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::Internal(format!("Ошибка сети CryptoBot API: {e}")))?
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::Internal(format!("Ошибка парсинга ответа CryptoBot: {e}")))?;
|
||||||
|
|
||||||
|
if res["ok"].as_bool() != Some(true) {
|
||||||
|
// Ответ API "не ok" (сбой/рейт-лимит/недоступность CryptoBot) — не то
|
||||||
|
// же самое, что "инвойс не найден/не оплачен". Раньше это тоже
|
||||||
|
// схлопывалось в `Ok(false)`, а confirm_payment необратимо помечает
|
||||||
|
// инвойс `failed` на любое `false` — реальная оплата терялась
|
||||||
|
// навсегда при простом временном сбое CryptoBot API. `Err` оставляет
|
||||||
|
// инвойс `pending`, юзер может нажать "Я оплатил" ещё раз.
|
||||||
|
return Err(AppError::Internal(format!(
|
||||||
|
"CryptoBot getInvoices вернул ошибку: {}",
|
||||||
|
res["error"]
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(item) = res["result"]["items"].as_array().and_then(|a| a.first()) else {
|
||||||
|
return Ok(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
let status = item["status"].as_str().unwrap_or("");
|
||||||
|
let payload = item["payload"].as_str().unwrap_or("");
|
||||||
|
let paid_amount: f64 = item["amount"]
|
||||||
|
.as_str()
|
||||||
|
.and_then(|s| s.parse().ok())
|
||||||
|
.unwrap_or(0.0);
|
||||||
|
let expected_usdt = expected_amount as f64 / 100.0;
|
||||||
|
|
||||||
|
if status == "paid" && payload == expected_invoice_id && paid_amount >= expected_usdt - 0.01
|
||||||
|
{
|
||||||
|
tracing::info!("CryptoBot invoice {} verified!", external_tx_id);
|
||||||
|
Ok(true)
|
||||||
|
} else {
|
||||||
|
tracing::warn!(
|
||||||
|
"CryptoBot mismatch. Expected invoice: {}, got payload: {}, status: {}",
|
||||||
|
expected_invoice_id,
|
||||||
|
payload,
|
||||||
|
status
|
||||||
|
);
|
||||||
|
Ok(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ use async_trait::async_trait;
|
|||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
pub mod cryptobot;
|
||||||
pub mod ton;
|
pub mod ton;
|
||||||
|
|
||||||
pub struct InvoiceData {
|
pub struct InvoiceData {
|
||||||
|
|||||||
@@ -62,7 +62,18 @@ impl PaymentProvider for TonProvider {
|
|||||||
.map_err(|e| AppError::Internal(format!("Ошибка запроса к TON API: {}", e)))?;
|
.map_err(|e| AppError::Internal(format!("Ошибка запроса к TON API: {}", e)))?;
|
||||||
|
|
||||||
if !res.status().is_success() {
|
if !res.status().is_success() {
|
||||||
return Ok(false); // Транзакция не найдена или еще не подтверждена
|
// Неуспешный HTTP-статус (429/5xx/недоступность TonAPI) — это НЕ то
|
||||||
|
// же самое, что "транзакция не найдена/невалидна". Раньше оба случая
|
||||||
|
// схлопывались в `Ok(false)`, а confirm_payment на любое `false`
|
||||||
|
// необратимо помечает инвойс `failed` — реальный платёж, наткнувшийся
|
||||||
|
// на временный сбой TonAPI именно в момент подтверждения, терялся
|
||||||
|
// навсегда: `reconcile_pending_payments` подбирает только `pending`,
|
||||||
|
// не `failed`. Возвращаем `Err`, чтобы `confirm_payment` НЕ трогал
|
||||||
|
// статус инвойса и юзер/сверка могли повторить попытку позже.
|
||||||
|
return Err(AppError::Internal(format!(
|
||||||
|
"TonAPI недоступен или транзакция ещё не проиндексирована: HTTP {}",
|
||||||
|
res.status()
|
||||||
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
let data: Value = res
|
let data: Value = res
|
||||||
|
|||||||
@@ -40,6 +40,23 @@ impl Clone for BillingService {
|
|||||||
"TON",
|
"TON",
|
||||||
Box::new(super::providers::ton::TonProvider { master_wallet }),
|
Box::new(super::providers::ton::TonProvider { master_wallet }),
|
||||||
);
|
);
|
||||||
|
// Опционален: без токена оплата через CryptoBot просто вернёт "provider
|
||||||
|
// not supported" (см. `initiate_payment`), а не уронит инстанс при
|
||||||
|
// старте — в отличие от обязательных секретов вроде TON_MASTER_WALLET.
|
||||||
|
if let Ok(api_token) = std::env::var("CRYPTOBOT_API_TOKEN") {
|
||||||
|
let base_url = if std::env::var("CRYPTOBOT_TESTNET").as_deref() == Ok("true") {
|
||||||
|
"https://testnet-pay.crypt.bot/api".to_string()
|
||||||
|
} else {
|
||||||
|
"https://pay.crypt.bot/api".to_string()
|
||||||
|
};
|
||||||
|
providers.insert(
|
||||||
|
"CRYPTOBOT",
|
||||||
|
Box::new(super::providers::cryptobot::CryptoBotProvider {
|
||||||
|
api_token,
|
||||||
|
base_url,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
repo: self.repo.clone(),
|
repo: self.repo.clone(),
|
||||||
@@ -60,6 +77,23 @@ impl BillingService {
|
|||||||
"TON",
|
"TON",
|
||||||
Box::new(super::providers::ton::TonProvider { master_wallet }),
|
Box::new(super::providers::ton::TonProvider { master_wallet }),
|
||||||
);
|
);
|
||||||
|
// Опционален: без токена оплата через CryptoBot просто вернёт "provider
|
||||||
|
// not supported" (см. `initiate_payment`), а не уронит инстанс при
|
||||||
|
// старте — в отличие от обязательных секретов вроде TON_MASTER_WALLET.
|
||||||
|
if let Ok(api_token) = std::env::var("CRYPTOBOT_API_TOKEN") {
|
||||||
|
let base_url = if std::env::var("CRYPTOBOT_TESTNET").as_deref() == Ok("true") {
|
||||||
|
"https://testnet-pay.crypt.bot/api".to_string()
|
||||||
|
} else {
|
||||||
|
"https://pay.crypt.bot/api".to_string()
|
||||||
|
};
|
||||||
|
providers.insert(
|
||||||
|
"CRYPTOBOT",
|
||||||
|
Box::new(super::providers::cryptobot::CryptoBotProvider {
|
||||||
|
api_token,
|
||||||
|
base_url,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
repo,
|
repo,
|
||||||
@@ -68,6 +102,38 @@ impl BillingService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Активные тарифы с USD-ценой — для плиточного выбора тарифа в боте.
|
||||||
|
pub async fn list_plans_usd(&self) -> Result<Vec<(String, i64)>, AppError> {
|
||||||
|
self.repo
|
||||||
|
.list_active_plans_usd()
|
||||||
|
.await
|
||||||
|
.map_err(AppError::Database)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Тарифы в конкретной валюте — для `GET /plans` (см.
|
||||||
|
/// `billing::controller::list_plans_api`) и мультивалютного бота
|
||||||
|
/// (`bot.rs`). Если для этой валюты в `plan_prices` нет ни одной активной
|
||||||
|
/// строки (валюта определилась, но регион не заведён — маловероятно,
|
||||||
|
/// т.к. валюта берётся из той же карты, что и миграция, но на всякий
|
||||||
|
/// случай) — тихо фолбэкается на USD, а не отдаёт пустой список.
|
||||||
|
pub async fn list_plans_in_currency(
|
||||||
|
&self,
|
||||||
|
currency: &str,
|
||||||
|
) -> Result<(String, Vec<(String, i64)>), AppError> {
|
||||||
|
if currency != "USD" {
|
||||||
|
let regional = self
|
||||||
|
.repo
|
||||||
|
.list_active_plans_by_currency(currency)
|
||||||
|
.await
|
||||||
|
.map_err(AppError::Database)?;
|
||||||
|
if !regional.is_empty() {
|
||||||
|
return Ok((currency.to_string(), regional));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let usd = self.list_plans_usd().await?;
|
||||||
|
Ok(("USD".to_string(), usd))
|
||||||
|
}
|
||||||
|
|
||||||
/// Курс TON к валюте из кеша; `None` при промахе/недоступности Redis —
|
/// Курс TON к валюте из кеша; `None` при промахе/недоступности Redis —
|
||||||
/// вызывающий код должен сходить за свежим значением и заполнить кеш сам.
|
/// вызывающий код должен сходить за свежим значением и заполнить кеш сам.
|
||||||
async fn get_cached_ton_rate(&self, currency_lower: &str) -> Option<rust_decimal::Decimal> {
|
async fn get_cached_ton_rate(&self, currency_lower: &str) -> Option<rust_decimal::Decimal> {
|
||||||
@@ -115,6 +181,11 @@ impl BillingService {
|
|||||||
AppError::BusinessLogic("Тариф недоступен для данного региона".into())
|
AppError::BusinessLogic("Тариф недоступен для данного региона".into())
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
|
// Курс TON нужен только самому TON-провайдеру — блокчейн понимает
|
||||||
|
// только nanoTON, а не фиат. Остальные провайдеры (CryptoBot: USDT
|
||||||
|
// 1:1 к USD; в будущем карточные эквайринги) биллятся напрямую в
|
||||||
|
// валюте `plan_prices`, без пересчёта.
|
||||||
|
let invoice_amount = if provider_name == "TON" {
|
||||||
// --- КУРС TON: сначала кеш (Redis, TTL 60с), иначе синхронный запрос к TonAPI ---
|
// --- КУРС TON: сначала кеш (Redis, TTL 60с), иначе синхронный запрос к TonAPI ---
|
||||||
let currency_lower = currency.to_lowercase();
|
let currency_lower = currency.to_lowercase();
|
||||||
let currency_upper = currency.to_uppercase();
|
let currency_upper = currency.to_uppercase();
|
||||||
@@ -136,7 +207,9 @@ impl BillingService {
|
|||||||
.get(&url)
|
.get(&url)
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| AppError::Internal(format!("Ошибка сети при запросе курса: {}", e)))?
|
.map_err(|e| {
|
||||||
|
AppError::Internal(format!("Ошибка сети при запросе курса: {}", e))
|
||||||
|
})?
|
||||||
.json()
|
.json()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| AppError::Internal(format!("Ошибка парсинга курса TON: {}", e)))?;
|
.map_err(|e| AppError::Internal(format!("Ошибка парсинга курса TON: {}", e)))?;
|
||||||
@@ -155,17 +228,22 @@ impl BillingService {
|
|||||||
};
|
};
|
||||||
// ----------------------------------------
|
// ----------------------------------------
|
||||||
|
|
||||||
let nanotons = Self::calculate_ton_invoice(fiat_amount_minimal, ton_rate)
|
Self::calculate_ton_invoice(fiat_amount_minimal, ton_rate)
|
||||||
.map_err(AppError::BusinessLogic)?;
|
.map_err(AppError::BusinessLogic)?
|
||||||
|
} else {
|
||||||
|
fiat_amount_minimal
|
||||||
|
};
|
||||||
|
|
||||||
let invoice_id = self
|
let invoice_id = self
|
||||||
.repo
|
.repo
|
||||||
.create_invoice(user.id, plan_name, provider_name, currency, nanotons)
|
.create_invoice(user.id, plan_name, provider_name, currency, invoice_amount)
|
||||||
.await?;
|
.await?;
|
||||||
|
metrics::counter!("payments_total", "provider" => provider_name.to_string(), "status" => "initiated")
|
||||||
|
.increment(1);
|
||||||
|
|
||||||
let invoice_data = InvoiceData {
|
let invoice_data = InvoiceData {
|
||||||
invoice_id,
|
invoice_id,
|
||||||
amount: nanotons,
|
amount: invoice_amount,
|
||||||
currency: currency.into(),
|
currency: currency.into(),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -199,8 +277,18 @@ impl BillingService {
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Получаем провайдера
|
// 2. Получаем провайдера, которым был выставлен именно этот инвойс —
|
||||||
let provider = self.providers.get("TON").unwrap();
|
// раньше здесь был захардкожен "TON", что ломало подтверждение любого
|
||||||
|
// другого провайдера (см. `invoice.provider`, заполняется в `create_invoice`).
|
||||||
|
let provider = self
|
||||||
|
.providers
|
||||||
|
.get(invoice.provider.as_str())
|
||||||
|
.ok_or_else(|| {
|
||||||
|
AppError::Internal(format!(
|
||||||
|
"Провайдер {} не сконфигурирован на этом инстансе",
|
||||||
|
invoice.provider
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
|
||||||
// 3. Провайдер лезет в блокчейн и проверяет транзакцию
|
// 3. Провайдер лезет в блокчейн и проверяет транзакцию
|
||||||
let is_valid = provider
|
let is_valid = provider
|
||||||
@@ -211,6 +299,8 @@ impl BillingService {
|
|||||||
self.repo
|
self.repo
|
||||||
.update_invoice_status(invoice_id, "failed", Some(external_tx_id))
|
.update_invoice_status(invoice_id, "failed", Some(external_tx_id))
|
||||||
.await?;
|
.await?;
|
||||||
|
metrics::counter!("payments_total", "provider" => invoice.provider.clone(), "status" => "failed")
|
||||||
|
.increment(1);
|
||||||
return Err(AppError::BusinessLogic(
|
return Err(AppError::BusinessLogic(
|
||||||
"Payment verification failed".into(),
|
"Payment verification failed".into(),
|
||||||
));
|
));
|
||||||
@@ -220,6 +310,8 @@ impl BillingService {
|
|||||||
self.repo
|
self.repo
|
||||||
.update_invoice_status(invoice_id, "paid", Some(external_tx_id))
|
.update_invoice_status(invoice_id, "paid", Some(external_tx_id))
|
||||||
.await?;
|
.await?;
|
||||||
|
metrics::counter!("payments_total", "provider" => invoice.provider.clone(), "status" => "paid")
|
||||||
|
.increment(1);
|
||||||
|
|
||||||
// 5. АКТИВИРУЕМ ТАРИФ
|
// 5. АКТИВИРУЕМ ТАРИФ
|
||||||
self.repo
|
self.repo
|
||||||
@@ -233,6 +325,74 @@ impl BillingService {
|
|||||||
.reward_referrer(invoice.user_id, revshare_bonus)
|
.reward_referrer(invoice.user_id, revshare_bonus)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
|
// 7. Партнёрская комиссия — независимо от рефералки выше, отдельная
|
||||||
|
// система (см. modules::staff). Считаем от фиатной цены тарифа В
|
||||||
|
// ВАЛЮТЕ ИНВОЙСА через get_plan_price, а НЕ от invoice.amount:
|
||||||
|
// invoice.amount для TON — это nanoTON (+2% буфер на конвертацию),
|
||||||
|
// не фиат; для CryptoBot это фиат-центы 1:1, но полагаться на то,
|
||||||
|
// каким провайдером оплачен именно этот инвойс, неверно —
|
||||||
|
// get_plan_price(plan_name, currency) корректен одинаково для всех
|
||||||
|
// провайдеров. Ошибка здесь не должна ронять подтверждение платежа
|
||||||
|
// (та же resilience, что у reward_referrer выше).
|
||||||
|
if let Err(e) = self.record_partner_commission_if_referred(&invoice).await {
|
||||||
|
tracing::warn!(
|
||||||
|
"Не удалось начислить партнёрскую комиссию по инвойсу {}: {:?}",
|
||||||
|
invoice.id,
|
||||||
|
e
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// См. комментарий в шаге 7 `confirm_payment` — вынесено отдельным
|
||||||
|
/// методом ради `?`-раннего-выхода без загрязнения основной функции.
|
||||||
|
async fn record_partner_commission_if_referred(
|
||||||
|
&self,
|
||||||
|
invoice: &crate::db::repository::InvoiceRecord,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
let Some(buyer) = self.repo.get_user_by_id(invoice.user_id).await? else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
let Some(partner_id) = buyer.referred_by_partner_id else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
let Some(partner) = self.repo.get_user_by_id(partner_id).await? else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
let Some(percent) = partner.commission_percent else {
|
||||||
|
return Ok(()); // партнёр без выставленного процента — молча пропускаем
|
||||||
|
};
|
||||||
|
let Some(fiat_amount_minimal) = self
|
||||||
|
.repo
|
||||||
|
.get_plan_price(&invoice.plan_name, &invoice.currency)
|
||||||
|
.await?
|
||||||
|
else {
|
||||||
|
return Ok(()); // тариф/валюта более не активны — пропускаем
|
||||||
|
};
|
||||||
|
|
||||||
|
use rust_decimal::prelude::{FromPrimitive, ToPrimitive};
|
||||||
|
let commission = (rust_decimal::Decimal::from_i64(fiat_amount_minimal).unwrap_or_default()
|
||||||
|
* percent
|
||||||
|
/ rust_decimal::Decimal::from(100))
|
||||||
|
.round()
|
||||||
|
.to_i64()
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
|
if commission > 0 {
|
||||||
|
self.repo
|
||||||
|
.record_partner_commission(
|
||||||
|
partner_id,
|
||||||
|
invoice.id,
|
||||||
|
invoice.user_id,
|
||||||
|
&invoice.currency,
|
||||||
|
commission,
|
||||||
|
percent,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
metrics::counter!("partner_commission_total", "currency" => invoice.currency.clone())
|
||||||
|
.increment(commission as u64);
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -356,3 +516,315 @@ impl BillingService {
|
|||||||
Ok(nanotons)
|
Ok(nanotons)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::db::repository::PgRepository;
|
||||||
|
use crate::modules::billing::providers::{InvoiceData, PaymentProvider};
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use rust_decimal::Decimal;
|
||||||
|
use sqlx::PgPool;
|
||||||
|
|
||||||
|
// --- calculate_ton_invoice: чистая арифметика, без БД ---
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_calculate_ton_invoice_applies_2_percent_buffer() {
|
||||||
|
// $10.00 (1000 центов) по курсу 2 USD/TON -> 5 TON чистых -> +2% буфер -> 5.1 TON
|
||||||
|
let rate = Decimal::new(2, 0);
|
||||||
|
let nanotons = BillingService::calculate_ton_invoice(1000, rate).unwrap();
|
||||||
|
assert_eq!(nanotons, 5_100_000_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_calculate_ton_invoice_zero_rate_errors() {
|
||||||
|
let err = BillingService::calculate_ton_invoice(1000, Decimal::ZERO).unwrap_err();
|
||||||
|
assert!(err.contains("не может быть нулевым"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- confirm_payment: владение, идемпотентность, маршрутизация провайдера ---
|
||||||
|
|
||||||
|
struct FakeProvider {
|
||||||
|
id: &'static str,
|
||||||
|
verify_result: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl PaymentProvider for FakeProvider {
|
||||||
|
fn provider_id(&self) -> &'static str {
|
||||||
|
self.id
|
||||||
|
}
|
||||||
|
async fn create_checkout_session(
|
||||||
|
&self,
|
||||||
|
_invoice: &InvoiceData,
|
||||||
|
) -> Result<serde_json::Value, AppError> {
|
||||||
|
Ok(serde_json::json!({}))
|
||||||
|
}
|
||||||
|
async fn verify_payment(
|
||||||
|
&self,
|
||||||
|
_external_tx_id: &str,
|
||||||
|
_expected_amount: i64,
|
||||||
|
_expected_invoice_id: &str,
|
||||||
|
) -> Result<bool, AppError> {
|
||||||
|
Ok(self.verify_result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn service_with_fake_provider(
|
||||||
|
repo: Arc<dyn AppRepository>,
|
||||||
|
verify_result: bool,
|
||||||
|
) -> BillingService {
|
||||||
|
let mut providers: HashMap<&'static str, Box<dyn PaymentProvider>> = HashMap::new();
|
||||||
|
providers.insert(
|
||||||
|
"TEST",
|
||||||
|
Box::new(FakeProvider {
|
||||||
|
id: "TEST",
|
||||||
|
verify_result,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
BillingService {
|
||||||
|
repo,
|
||||||
|
providers,
|
||||||
|
redis: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test]
|
||||||
|
async fn test_confirm_payment_rejects_non_owner(pool: PgPool) {
|
||||||
|
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool));
|
||||||
|
let svc = service_with_fake_provider(repo.clone(), true);
|
||||||
|
|
||||||
|
let owner = repo.upsert_user(1, None).await.unwrap();
|
||||||
|
let stranger = repo.upsert_user(2, None).await.unwrap();
|
||||||
|
let invoice_id = repo
|
||||||
|
.create_invoice(owner.id, "GHOST_1M", "TEST", "USD", 500)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let err = svc
|
||||||
|
.confirm_payment(stranger.id, invoice_id, "tx1")
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(matches!(err, AppError::Unauthorized(_)));
|
||||||
|
|
||||||
|
// Инвойс не должен был измениться.
|
||||||
|
let invoice = repo.get_invoice(invoice_id).await.unwrap().unwrap();
|
||||||
|
assert_eq!(invoice.status, "pending");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test]
|
||||||
|
async fn test_confirm_payment_idempotent_when_already_paid(pool: PgPool) {
|
||||||
|
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool));
|
||||||
|
// verify_result=false: если бы идемпотентность не сработала, второй вызов
|
||||||
|
// упал бы на верификации — но код обязан выйти раньше, на шаге "already paid".
|
||||||
|
let svc = service_with_fake_provider(repo.clone(), false);
|
||||||
|
|
||||||
|
let user = repo.upsert_user(1, None).await.unwrap();
|
||||||
|
let invoice_id = repo
|
||||||
|
.create_invoice(user.id, "GHOST_1M", "TEST", "USD", 500)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
repo.update_invoice_status(invoice_id, "paid", Some("tx0"))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
svc.confirm_payment(user.id, invoice_id, "tx1")
|
||||||
|
.await
|
||||||
|
.expect("повторное подтверждение уже оплаченного инвойса должно быть no-op");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test]
|
||||||
|
async fn test_confirm_payment_unknown_provider_errors(pool: PgPool) {
|
||||||
|
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool));
|
||||||
|
let svc = service_with_fake_provider(repo.clone(), true);
|
||||||
|
|
||||||
|
let user = repo.upsert_user(1, None).await.unwrap();
|
||||||
|
// Инвойс выставлен провайдером, которого нет в HashMap этого инстанса —
|
||||||
|
// регрессионный тест на баг "confirm_payment жёстко ходил в TON независимо
|
||||||
|
// от invoice.provider".
|
||||||
|
let invoice_id = repo
|
||||||
|
.create_invoice(user.id, "GHOST_1M", "NOT_CONFIGURED", "USD", 500)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let err = svc
|
||||||
|
.confirm_payment(user.id, invoice_id, "tx1")
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(matches!(err, AppError::Internal(_)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test]
|
||||||
|
async fn test_confirm_payment_failed_verification_marks_invoice_failed(pool: PgPool) {
|
||||||
|
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool));
|
||||||
|
let svc = service_with_fake_provider(repo.clone(), false);
|
||||||
|
|
||||||
|
let user = repo.upsert_user(1, None).await.unwrap();
|
||||||
|
let invoice_id = repo
|
||||||
|
.create_invoice(user.id, "GHOST_1M", "TEST", "USD", 500)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let err = svc
|
||||||
|
.confirm_payment(user.id, invoice_id, "tx1")
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(matches!(err, AppError::BusinessLogic(_)));
|
||||||
|
|
||||||
|
let invoice = repo.get_invoice(invoice_id).await.unwrap().unwrap();
|
||||||
|
assert_eq!(invoice.status, "failed");
|
||||||
|
|
||||||
|
// Подписка не должна была активироваться.
|
||||||
|
assert!(repo.get_subscription(user.id).await.unwrap().is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test]
|
||||||
|
async fn test_confirm_payment_success_activates_subscription_and_rewards_referrer(
|
||||||
|
pool: PgPool,
|
||||||
|
) {
|
||||||
|
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool));
|
||||||
|
let svc = service_with_fake_provider(repo.clone(), true);
|
||||||
|
|
||||||
|
let referrer = repo.upsert_user(1, None).await.unwrap();
|
||||||
|
let buyer = repo.upsert_user(2, None).await.unwrap();
|
||||||
|
repo.create_referral(referrer.id, buyer.id).await.unwrap();
|
||||||
|
|
||||||
|
let invoice_id = repo
|
||||||
|
.create_invoice(buyer.id, "GHOST_1M", "TEST", "USD", 500) // $5.00
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
svc.confirm_payment(buyer.id, invoice_id, "tx1")
|
||||||
|
.await
|
||||||
|
.expect("успешная верификация должна пройти");
|
||||||
|
|
||||||
|
let invoice = repo.get_invoice(invoice_id).await.unwrap().unwrap();
|
||||||
|
assert_eq!(invoice.status, "paid");
|
||||||
|
|
||||||
|
let sub = repo
|
||||||
|
.get_subscription(buyer.id)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.expect("подписка должна активироваться");
|
||||||
|
assert_eq!(sub.plan_name, "GHOST_1M");
|
||||||
|
assert_eq!(sub.status, "active");
|
||||||
|
|
||||||
|
// 10% от суммы инвойса (500) = 50, зачисляется рефереру на баланс.
|
||||||
|
let referrer_updated = repo.get_user_by_id(referrer.id).await.unwrap().unwrap();
|
||||||
|
assert_eq!(referrer_updated.balance, 50);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Партнёрская комиссия: считается от get_plan_price, не от invoice.amount ---
|
||||||
|
|
||||||
|
#[sqlx::test]
|
||||||
|
async fn test_partner_commission_uses_plan_price_not_invoice_amount(pool: PgPool) {
|
||||||
|
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool));
|
||||||
|
let svc = service_with_fake_provider(repo.clone(), true);
|
||||||
|
|
||||||
|
let partner = repo
|
||||||
|
.create_staff_user(
|
||||||
|
"partner1",
|
||||||
|
"hash",
|
||||||
|
"partner",
|
||||||
|
false,
|
||||||
|
Some(Decimal::new(10, 0)), // 10%
|
||||||
|
Some("p_abc123"),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let buyer = repo.upsert_user(2, None).await.unwrap();
|
||||||
|
repo.set_referred_by_partner(buyer.id, partner.id)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// invoice.amount = 999999 — намеренно НЕ совпадает с реальной ценой тарифа
|
||||||
|
// (симулирует TON-инвойс, где amount — nanoTON, а не фиат), чтобы доказать,
|
||||||
|
// что комиссия считается через get_plan_price("GHOST_1M", "USD") = 500,
|
||||||
|
// а не от этого числа.
|
||||||
|
let invoice_id = repo
|
||||||
|
.create_invoice(buyer.id, "GHOST_1M", "TEST", "USD", 999_999)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
svc.confirm_payment(buyer.id, invoice_id, "tx1")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let earnings = repo.get_partner_earnings(partner.id).await.unwrap();
|
||||||
|
let usd_earnings = earnings.iter().find(|e| e.currency == "USD").unwrap();
|
||||||
|
// 10% от 500 (реальная цена GHOST_1M/USD) = 50, а НЕ 10% от 999999.
|
||||||
|
assert_eq!(usd_earnings.total_earned, 50);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test]
|
||||||
|
async fn test_partner_commission_idempotent_on_duplicate_confirm(pool: PgPool) {
|
||||||
|
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool));
|
||||||
|
let svc = service_with_fake_provider(repo.clone(), true);
|
||||||
|
|
||||||
|
let partner = repo
|
||||||
|
.create_staff_user(
|
||||||
|
"partner1",
|
||||||
|
"hash",
|
||||||
|
"partner",
|
||||||
|
false,
|
||||||
|
Some(Decimal::new(10, 0)),
|
||||||
|
Some("p_abc123"),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let buyer = repo.upsert_user(2, None).await.unwrap();
|
||||||
|
repo.set_referred_by_partner(buyer.id, partner.id)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let invoice_id = repo
|
||||||
|
.create_invoice(buyer.id, "GHOST_1M", "TEST", "USD", 500)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
svc.confirm_payment(buyer.id, invoice_id, "tx1")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
// Второй вызов — инвойс уже "paid", подтверждение — no-op (см. тест выше),
|
||||||
|
// поэтому record_partner_commission_if_referred не должен вызваться снова.
|
||||||
|
svc.confirm_payment(buyer.id, invoice_id, "tx1")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let earnings = repo.get_partner_earnings(partner.id).await.unwrap();
|
||||||
|
let usd_earnings = earnings.iter().find(|e| e.currency == "USD").unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
usd_earnings.total_earned, 50,
|
||||||
|
"комиссия не должна начислиться дважды"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test]
|
||||||
|
async fn test_partner_commission_skipped_without_percent(pool: PgPool) {
|
||||||
|
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool));
|
||||||
|
let svc = service_with_fake_provider(repo.clone(), true);
|
||||||
|
|
||||||
|
// Партнёр без выставленного процента — комиссия не должна начисляться,
|
||||||
|
// и confirm_payment не должен упасть.
|
||||||
|
let partner = repo
|
||||||
|
.create_staff_user("partner1", "hash", "partner", false, None, Some("p_abc123"))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let buyer = repo.upsert_user(2, None).await.unwrap();
|
||||||
|
repo.set_referred_by_partner(buyer.id, partner.id)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let invoice_id = repo
|
||||||
|
.create_invoice(buyer.id, "GHOST_1M", "TEST", "USD", 500)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
svc.confirm_payment(buyer.id, invoice_id, "tx1")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let earnings = repo.get_partner_earnings(partner.id).await.unwrap();
|
||||||
|
assert!(earnings.is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
//! Статические таблицы "страна/язык → валюта". Не БД — список валют вообще
|
||||||
|
//! завязан на то, что реально заведено в `plan_prices` (см. `migrations/
|
||||||
|
//! 0001_init.sql` + `0007_regional_pricing.sql`), т.е. это код, а не
|
||||||
|
//! конфигурация — добавление новой валюты всё равно требует миграции.
|
||||||
|
|
||||||
|
/// ISO 3166-1 alpha-2 страна → код валюты в `plan_prices.currency`.
|
||||||
|
/// Нет записи — значит для этой страны своей региональной цены нет,
|
||||||
|
/// вызывающая сторона обязана фолбэкнуться на `"USD"`.
|
||||||
|
pub fn currency_for_country(country_code: &str) -> Option<&'static str> {
|
||||||
|
match country_code {
|
||||||
|
"RU" => Some("RUB"),
|
||||||
|
"CN" => Some("CNY"),
|
||||||
|
"TR" => Some("TRY"),
|
||||||
|
"IR" => Some("IRR"),
|
||||||
|
"UA" => Some("UAH"),
|
||||||
|
"BY" => Some("BYN"),
|
||||||
|
"VN" => Some("VND"),
|
||||||
|
"UZ" => Some("UZS"),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Telegram `language_code` → валюта — единственный доступный сигнал в
|
||||||
|
/// боте (Bot API не отдаёт IP пользователя вообще, см. `mod.rs`). Грубее
|
||||||
|
/// геолокации (язык ≠ страна: диаспора, мультиязычные регионы), но лучше,
|
||||||
|
/// чем everyone-USD.
|
||||||
|
pub fn currency_for_telegram_lang(lang_code: &str) -> Option<&'static str> {
|
||||||
|
// Telegram присылает "ru", "uk", иногда с региональным суффиксом
|
||||||
|
// ("en-US") — берём только первичный подтег.
|
||||||
|
let primary = lang_code.split(['-', '_']).next().unwrap_or(lang_code);
|
||||||
|
match primary {
|
||||||
|
"ru" => Some("RUB"),
|
||||||
|
"zh" => Some("CNY"),
|
||||||
|
"tr" => Some("TRY"),
|
||||||
|
"fa" => Some("IRR"),
|
||||||
|
"uk" => Some("UAH"),
|
||||||
|
"be" => Some("BYN"),
|
||||||
|
"vi" => Some("VND"),
|
||||||
|
"uz" => Some("UZS"),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Символ валюты для отображения (бот, потенциально фронт).
|
||||||
|
pub fn currency_symbol(currency: &str) -> &'static str {
|
||||||
|
match currency {
|
||||||
|
"USD" => "$",
|
||||||
|
"RUB" => "₽",
|
||||||
|
"CNY" => "¥",
|
||||||
|
"TRY" => "₺",
|
||||||
|
"IRR" => "﷼",
|
||||||
|
"UAH" => "₴",
|
||||||
|
"BYN" => "Br",
|
||||||
|
"VND" => "₫",
|
||||||
|
"UZS" => "сум",
|
||||||
|
_ => "$",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// IRR/VND/UZS хранятся в `plan_prices.amount` целыми единицами (не центами/
|
||||||
|
/// копейками) — см. комментарий в migrations/0007_regional_pricing.sql: у
|
||||||
|
/// этих валют нет ходовой разменной монеты, дробные суммы бессмысленны для
|
||||||
|
/// пользователя.
|
||||||
|
fn is_whole_unit_currency(currency: &str) -> bool {
|
||||||
|
matches!(currency, "IRR" | "VND" | "UZS")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `amount` — как хранится в `plan_prices` (центы/копейки для большинства
|
||||||
|
/// валют, целые единицы для IRR/VND/UZS, см. `is_whole_unit_currency`).
|
||||||
|
/// Возвращает готовую для показа строку с символом валюты, БЕЗ значка
|
||||||
|
/// тысячных разделителей (это I18n-забота фронта/бота, если понадобится).
|
||||||
|
pub fn format_amount(currency: &str, amount: i64) -> String {
|
||||||
|
let symbol = currency_symbol(currency);
|
||||||
|
if is_whole_unit_currency(currency) {
|
||||||
|
format!("{} {}", amount, symbol)
|
||||||
|
} else {
|
||||||
|
format!("{}{:.2}", symbol, amount as f64 / 100.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn country_lookup_known_and_unknown() {
|
||||||
|
assert_eq!(currency_for_country("RU"), Some("RUB"));
|
||||||
|
assert_eq!(currency_for_country("UZ"), Some("UZS"));
|
||||||
|
assert_eq!(currency_for_country("US"), None);
|
||||||
|
assert_eq!(currency_for_country(""), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn telegram_lang_strips_region_suffix() {
|
||||||
|
assert_eq!(currency_for_telegram_lang("ru"), Some("RUB"));
|
||||||
|
assert_eq!(currency_for_telegram_lang("ru-RU"), Some("RUB"));
|
||||||
|
assert_eq!(currency_for_telegram_lang("en-US"), None);
|
||||||
|
assert_eq!(currency_for_telegram_lang("en"), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
//! Определение страны по IP (self-hosted DB-IP Lite, формат `.mmdb` — тот же,
|
||||||
|
//! что у MaxMind, но без регистрации/лицензионного ключа, см. README ниже) и
|
||||||
|
//! грубая эвристика "это VPN/датацентр?" по открытому списку CIDR-диапазонов
|
||||||
|
//! (X4BNet `lists_vpn`, тоже без ключей/оплаты).
|
||||||
|
//!
|
||||||
|
//! Обе базы скачиваются в рантайме (см. `service::GeoipService::refresh_*`),
|
||||||
|
//! не коммитятся в репозиторий — только пути/URL в конфиге. Периодическое
|
||||||
|
//! обновление — фоновая задача в `tasks.rs`, не здесь.
|
||||||
|
//!
|
||||||
|
//! Region-based pricing работает только там, где есть IP посетителя (сайт,
|
||||||
|
//! `GET /plans` — см. `modules::billing::controller`) — у Telegram-бота IP
|
||||||
|
//! юзера не видно вообще (ограничение самого Bot API), поэтому там валюта
|
||||||
|
//! определяется по `language_code` (см. `currency::currency_for_telegram_lang`).
|
||||||
|
pub mod currency;
|
||||||
|
pub mod service;
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
//! Держит в памяти два периодически обновляемых источника:
|
||||||
|
//! - `.mmdb`-база страна-по-IP (DB-IP Lite, тот же формат, что MaxMind
|
||||||
|
//! GeoLite2, но без регистрации/лицензионного ключа — просто прямая
|
||||||
|
//! ссылка на скачивание, обновляется у них раз в месяц).
|
||||||
|
//! - список CIDR-диапазонов известных VPN/датацентров (X4BNet `lists_vpn`,
|
||||||
|
//! тоже открытый, без ключей) — грубая эвристика, не идеальная (см. их
|
||||||
|
//! же README), но достаточная для "не давать региональную скидку через
|
||||||
|
//! очевидный VPN", не для полноценного антифрода.
|
||||||
|
//!
|
||||||
|
//! Обновление обеих баз — фоновая периодическая задача (см. `tasks.rs`), не
|
||||||
|
//! часть этого файла: сервис только хранит текущее состояние и умеет его
|
||||||
|
//! перезаписать (`refresh_*`), вызывающая сторона решает, когда дёргать.
|
||||||
|
use flate2::read::GzDecoder;
|
||||||
|
use ipnet::IpNet;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use std::io::Read;
|
||||||
|
use std::net::IpAddr;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tokio::sync::RwLock;
|
||||||
|
use tracing::{info, warn};
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct MmdbCountryLookup<'a> {
|
||||||
|
#[serde(borrow)]
|
||||||
|
country: Option<MmdbCountryCode<'a>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct MmdbCountryCode<'a> {
|
||||||
|
iso_code: Option<&'a str>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct GeoipService {
|
||||||
|
reader: Arc<RwLock<Option<maxminddb::Reader<Vec<u8>>>>>,
|
||||||
|
vpn_ranges: Arc<RwLock<Vec<IpNet>>>,
|
||||||
|
/// Шаблон URL с одним `{}` под "YYYY-MM" — DB-IP публикует файл с датой
|
||||||
|
/// в имени, единой "latest"-ссылки у них нет.
|
||||||
|
geoip_url_template: String,
|
||||||
|
vpn_list_url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GeoipService {
|
||||||
|
pub fn new(geoip_url_template: String, vpn_list_url: String) -> Self {
|
||||||
|
Self {
|
||||||
|
reader: Arc::new(RwLock::new(None)),
|
||||||
|
vpn_ranges: Arc::new(RwLock::new(Vec::new())),
|
||||||
|
geoip_url_template,
|
||||||
|
vpn_list_url,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ISO 3166-1 alpha-2 код страны по IP, `None` — база ещё не загрузилась
|
||||||
|
/// (первые секунды после старта) или IP не нашёлся (приватный диапазон,
|
||||||
|
/// пробел в данных DB-IP).
|
||||||
|
pub async fn country_for_ip(&self, ip: IpAddr) -> Option<String> {
|
||||||
|
let guard = self.reader.read().await;
|
||||||
|
let reader = guard.as_ref()?;
|
||||||
|
let lookup: MmdbCountryLookup = reader.lookup(ip).ok()?;
|
||||||
|
lookup
|
||||||
|
.country
|
||||||
|
.and_then(|c| c.iso_code)
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Грубая проверка "похоже на VPN/датацентр" — see module doc про точность.
|
||||||
|
pub async fn is_vpn(&self, ip: IpAddr) -> bool {
|
||||||
|
let guard = self.vpn_ranges.read().await;
|
||||||
|
guard.iter().any(|net| net.contains(&ip))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Скачивает и перезагружает `.mmdb`. Пробует текущий месяц, затем
|
||||||
|
/// предыдущий (файл за новый месяц у DB-IP иногда появляется с
|
||||||
|
/// задержкой в первые дни) — до первого успешного скачивания.
|
||||||
|
pub async fn refresh_geoip_db(&self) -> Result<(), String> {
|
||||||
|
let now = chrono::Utc::now();
|
||||||
|
for months_back in 0..=1i64 {
|
||||||
|
let date = now - chrono::Duration::days(31 * months_back);
|
||||||
|
let month_tag = date.format("%Y-%m").to_string();
|
||||||
|
let url = self.geoip_url_template.replace("{}", &month_tag);
|
||||||
|
|
||||||
|
match Self::try_download_mmdb(&url).await {
|
||||||
|
Ok(reader) => {
|
||||||
|
*self.reader.write().await = Some(reader);
|
||||||
|
info!(url = %url, "GeoIP: база страна-по-IP обновлена");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
warn!(url = %url, error = %e, "GeoIP: не удалось скачать за этот месяц, пробую предыдущий");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err("GeoIP: не удалось скачать .mmdb ни за текущий, ни за прошлый месяц".into())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn try_download_mmdb(url: &str) -> Result<maxminddb::Reader<Vec<u8>>, String> {
|
||||||
|
let resp = reqwest::get(url)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("HTTP-запрос: {e}"))?;
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
return Err(format!("HTTP {}", resp.status()));
|
||||||
|
}
|
||||||
|
let compressed = resp
|
||||||
|
.bytes()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("тело ответа: {e}"))?;
|
||||||
|
|
||||||
|
let mut decoder = GzDecoder::new(&compressed[..]);
|
||||||
|
let mut decompressed = Vec::new();
|
||||||
|
decoder
|
||||||
|
.read_to_end(&mut decompressed)
|
||||||
|
.map_err(|e| format!("gzip-распаковка: {e}"))?;
|
||||||
|
|
||||||
|
maxminddb::Reader::from_source(decompressed).map_err(|e| format!("парсинг .mmdb: {e}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Скачивает и перезагружает список CIDR-диапазонов VPN/датацентров.
|
||||||
|
pub async fn refresh_vpn_list(&self) -> Result<(), String> {
|
||||||
|
let resp = reqwest::get(&self.vpn_list_url)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("HTTP-запрос: {e}"))?;
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
return Err(format!("HTTP {}", resp.status()));
|
||||||
|
}
|
||||||
|
let text = resp.text().await.map_err(|e| format!("тело ответа: {e}"))?;
|
||||||
|
|
||||||
|
let ranges: Vec<IpNet> = text
|
||||||
|
.lines()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|l| !l.is_empty() && !l.starts_with('#'))
|
||||||
|
.filter_map(|l| l.parse().ok())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if ranges.is_empty() {
|
||||||
|
return Err("список VPN-диапазонов пуст после парсинга — подозрительно, не перезаписываю текущий".into());
|
||||||
|
}
|
||||||
|
|
||||||
|
let count = ranges.len();
|
||||||
|
*self.vpn_ranges.write().await = ranges;
|
||||||
|
info!(count, "GeoIP: список VPN/датацентр-диапазонов обновлён");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,10 @@
|
|||||||
|
|
||||||
pub mod auth;
|
pub mod auth;
|
||||||
pub mod billing;
|
pub mod billing;
|
||||||
|
pub mod geoip;
|
||||||
pub mod nodes;
|
pub mod nodes;
|
||||||
|
pub mod proxy_internal;
|
||||||
pub mod referrals;
|
pub mod referrals;
|
||||||
|
pub mod staff;
|
||||||
|
pub mod support;
|
||||||
pub mod users;
|
pub mod users;
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ use crate::{
|
|||||||
db::models::{CreateNodePayload, User, VpnNode},
|
db::models::{CreateNodePayload, User, VpnNode},
|
||||||
error::AppError,
|
error::AppError,
|
||||||
modules::{
|
modules::{
|
||||||
auth::{admin_guard, auth_guard},
|
auth::{auth_guard, node_manage_guard},
|
||||||
nodes::service::NodeService,
|
nodes::service::NodeService,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -41,8 +41,10 @@ pub fn admin_router(state: ApiState) -> Router<ApiState> {
|
|||||||
.route("/", get(list_all_nodes).post(add_node))
|
.route("/", get(list_all_nodes).post(add_node))
|
||||||
.route("/{id}", delete(remove_node))
|
.route("/{id}", delete(remove_node))
|
||||||
.route("/{id}/restart", post(restart_node))
|
.route("/{id}/restart", post(restart_node))
|
||||||
// Теперь admin_guard использует from_fn_with_state
|
.route_layer(middleware::from_fn_with_state(
|
||||||
.route_layer(middleware::from_fn_with_state(state.clone(), admin_guard))
|
state.clone(),
|
||||||
|
node_manage_guard,
|
||||||
|
))
|
||||||
.route_layer(middleware::from_fn_with_state(state.clone(), auth_guard))
|
.route_layer(middleware::from_fn_with_state(state.clone(), auth_guard))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -86,11 +86,24 @@ impl NodeService {
|
|||||||
ssh_password: &str,
|
ssh_password: &str,
|
||||||
) -> Result<VpnNode, AppError> {
|
) -> Result<VpnNode, AppError> {
|
||||||
let node = self.repo.add_vpn_node_pending(payload).await?;
|
let node = self.repo.add_vpn_node_pending(payload).await?;
|
||||||
self.spawn_provisioning(node.id, node.ip_address.clone(), ssh_password.to_string());
|
self.spawn_provisioning(
|
||||||
|
node.id,
|
||||||
|
node.ip_address.clone(),
|
||||||
|
ssh_password.to_string(),
|
||||||
|
node.port,
|
||||||
|
node.sni_domain.clone(),
|
||||||
|
);
|
||||||
Ok(node)
|
Ok(node)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn spawn_provisioning(&self, node_id: Uuid, ip_address: String, password: String) {
|
fn spawn_provisioning(
|
||||||
|
&self,
|
||||||
|
node_id: Uuid,
|
||||||
|
ip_address: String,
|
||||||
|
password: String,
|
||||||
|
port: i32,
|
||||||
|
sni_domain: Option<String>,
|
||||||
|
) {
|
||||||
let repo = self.repo.clone();
|
let repo = self.repo.clone();
|
||||||
let semaphore = self.deploy_semaphore.clone();
|
let semaphore = self.deploy_semaphore.clone();
|
||||||
|
|
||||||
@@ -101,7 +114,7 @@ impl NodeService {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let provision_result = tokio::task::spawn_blocking(move || {
|
let provision_result = tokio::task::spawn_blocking(move || {
|
||||||
Self::provision_node_via_ssh(&ip_address, &password)
|
Self::provision_node_via_ssh(&ip_address, &password, port, sni_domain.as_deref())
|
||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
@@ -123,7 +136,12 @@ impl NodeService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
fn provision_node_via_ssh(ip: &str, password: &str) -> Result<(), AppError> {
|
fn provision_node_via_ssh(
|
||||||
|
ip: &str,
|
||||||
|
password: &str,
|
||||||
|
port: i32,
|
||||||
|
sni_domain: Option<&str>,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
use std::fs;
|
use std::fs;
|
||||||
|
|
||||||
// 1. Просто считываем уже гарантированно созданный на старте сервера ключ
|
// 1. Просто считываем уже гарантированно созданный на старте сервера ключ
|
||||||
@@ -135,9 +153,28 @@ impl NodeService {
|
|||||||
let sess = Self::connect_ssh(ip, "root", password)?;
|
let sess = Self::connect_ssh(ip, "root", password)?;
|
||||||
|
|
||||||
// 3. Подготавливаем команды автоматизации
|
// 3. Подготавливаем команды автоматизации
|
||||||
let gh_token = std::env::var("GHCR_TOKEN").map_err(|_| {
|
let gh_token = std::env::var("GT_REGISTRY_TOKEN").map_err(|_| {
|
||||||
AppError::Internal("GHCR_TOKEN не задан — провижининг ноды невозможен".into())
|
AppError::Internal("GT_REGISTRY_TOKEN не задан — провижининг ноды невозможен".into())
|
||||||
})?;
|
})?;
|
||||||
|
// Секрет и публичный URL этого же бэкенда — нода должна ходить именно
|
||||||
|
// сюда для проверки токенов/лимитов (--require-auth), а не работать
|
||||||
|
// без авторизации, как было раньше при отсутствии этих переменных.
|
||||||
|
let proxy_internal_secret = std::env::var("PROXY_INTERNAL_SECRET").map_err(|_| {
|
||||||
|
AppError::Internal(
|
||||||
|
"PROXY_INTERNAL_SECRET не задан — провижининг ноды с --require-auth невозможен"
|
||||||
|
.into(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let backend_url = std::env::var("WEB_APP_BASE_URL").map_err(|_| {
|
||||||
|
AppError::Internal("WEB_APP_BASE_URL не задан — провижининг ноды невозможен".into())
|
||||||
|
})?;
|
||||||
|
// Домен-декой конкретно этой ноды — если админ не указал при добавлении,
|
||||||
|
// используем тот же дефолт, что и сам netrunner-proxy без --decoy-host
|
||||||
|
// (netrunner_core::net::DEFAULT_DECOY_HOST в том, отдельном репозитории —
|
||||||
|
// здесь недоступен как зависимость, поэтому продублирован явно).
|
||||||
|
const DEFAULT_DECOY_HOST: &str = "www.debian.org";
|
||||||
|
let sni_domain = sni_domain.unwrap_or(DEFAULT_DECOY_HOST);
|
||||||
|
|
||||||
// Если скрипт инициализации отсутствует — жёстко падаем, а не деплоим
|
// Если скрипт инициализации отсутствует — жёстко падаем, а не деплоим
|
||||||
// полуживую ноду заглушкой `echo`.
|
// полуживую ноду заглушкой `echo`.
|
||||||
let init_script = fs::read_to_string("templates/init_node.sh").map_err(|e| {
|
let init_script = fs::read_to_string("templates/init_node.sh").map_err(|e| {
|
||||||
@@ -161,8 +198,8 @@ impl NodeService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
let full_command = format!(
|
let full_command = format!(
|
||||||
"NODE_IP='{}' GH_TOKEN='{}'\n{}\n{}",
|
"NODE_IP='{}' GH_TOKEN='{}' NODE_PORT='{}' SNI_DOMAIN='{}' BACKEND_URL='{}' PROXY_INTERNAL_SECRET='{}'\n{}\n{}",
|
||||||
ip, gh_token, init_script, harden_script
|
ip, gh_token, port, sni_domain, backend_url, proxy_internal_secret, init_script, harden_script
|
||||||
);
|
);
|
||||||
|
|
||||||
Self::exec_ssh_command(&sess, &full_command)?;
|
Self::exec_ssh_command(&sess, &full_command)?;
|
||||||
|
|||||||
@@ -0,0 +1,273 @@
|
|||||||
|
//! Внутренний контракт для `netrunner-proxy`: проверка JWT-токена клиента и учёт
|
||||||
|
//! расхода трафика. Это не пользовательская сессия — прокси не логинится как юзер,
|
||||||
|
//! поэтому вместо `auth_guard`/cookie здесь общий секрет (`X-Internal-Secret`,
|
||||||
|
//! см. [`internal_secret_guard`]), которым делятся все прокси-ноды и бэкенд.
|
||||||
|
|
||||||
|
use crate::{api::ApiState, error::AppError};
|
||||||
|
use axum::{
|
||||||
|
extract::{Request, State},
|
||||||
|
middleware::Next,
|
||||||
|
response::Response,
|
||||||
|
routing::post,
|
||||||
|
Json, Router,
|
||||||
|
};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
pub async fn internal_secret_guard(
|
||||||
|
State(state): State<ApiState>,
|
||||||
|
req: Request,
|
||||||
|
next: Next,
|
||||||
|
) -> Result<Response, AppError> {
|
||||||
|
let provided = req
|
||||||
|
.headers()
|
||||||
|
.get("X-Internal-Secret")
|
||||||
|
.and_then(|v| v.to_str().ok());
|
||||||
|
|
||||||
|
if provided != Some(state.proxy_internal_secret.as_str()) {
|
||||||
|
return Err(AppError::Unauthorized("Invalid internal secret".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(next.run(req).await)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn router() -> Router<ApiState> {
|
||||||
|
Router::new()
|
||||||
|
.route("/validate", post(validate_api))
|
||||||
|
.route("/usage", post(usage_api))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct ValidatePayload {
|
||||||
|
token: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Debug)]
|
||||||
|
struct ValidateResponse {
|
||||||
|
user_id: Uuid,
|
||||||
|
limit_bytes: Option<i64>,
|
||||||
|
used_bytes: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Декодирует access-JWT тем же путём, что и `auth::guard::auth_guard`, но без
|
||||||
|
/// cookie/CSRF-логики (прокси всегда шлёт Bearer в теле, не в заголовках браузера).
|
||||||
|
async fn validate_api(
|
||||||
|
State(state): State<ApiState>,
|
||||||
|
Json(payload): Json<ValidatePayload>,
|
||||||
|
) -> Result<Json<ValidateResponse>, AppError> {
|
||||||
|
let token_data = jsonwebtoken::decode::<crate::modules::auth::service::Claims>(
|
||||||
|
&payload.token,
|
||||||
|
&jsonwebtoken::DecodingKey::from_secret(state.jwt_secret.as_bytes()),
|
||||||
|
&jsonwebtoken::Validation::default(),
|
||||||
|
)
|
||||||
|
.map_err(|_| AppError::Unauthorized("Invalid or expired token".into()))?;
|
||||||
|
|
||||||
|
let user_id = Uuid::parse_str(&token_data.claims.sub)
|
||||||
|
.map_err(|_| AppError::Unauthorized("Invalid token payload".into()))?;
|
||||||
|
|
||||||
|
let user = state
|
||||||
|
.repo
|
||||||
|
.get_user_by_id(user_id)
|
||||||
|
.await?
|
||||||
|
.ok_or(AppError::UserNotFound)?;
|
||||||
|
|
||||||
|
Ok(Json(ValidateResponse {
|
||||||
|
user_id: user.id,
|
||||||
|
limit_bytes: user.data_limit_bytes,
|
||||||
|
used_bytes: user.data_used_bytes,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct UsagePayload {
|
||||||
|
user_id: Uuid,
|
||||||
|
delta_bytes: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Debug)]
|
||||||
|
struct UsageResponse {
|
||||||
|
used_bytes: i64,
|
||||||
|
limit_bytes: Option<i64>,
|
||||||
|
over_limit: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Прибавляет репортнутую прокси дельту трафика к счётчику юзера и сразу
|
||||||
|
/// отвечает, не превышен ли лимит — прокси разрывает сессию в тот же тик, если
|
||||||
|
/// `over_limit == true`, не делая отдельный round-trip.
|
||||||
|
async fn usage_api(
|
||||||
|
State(state): State<ApiState>,
|
||||||
|
Json(payload): Json<UsagePayload>,
|
||||||
|
) -> Result<Json<UsageResponse>, AppError> {
|
||||||
|
let (used_bytes, limit_bytes) = state
|
||||||
|
.repo
|
||||||
|
.apply_usage_delta(payload.user_id, payload.delta_bytes)
|
||||||
|
.await?
|
||||||
|
.ok_or(AppError::UserNotFound)?;
|
||||||
|
|
||||||
|
let over_limit = limit_bytes.is_some_and(|limit| used_bytes >= limit);
|
||||||
|
|
||||||
|
Ok(Json(UsageResponse {
|
||||||
|
used_bytes,
|
||||||
|
limit_bytes,
|
||||||
|
over_limit,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::db::repository::{AppRepository, PgRepository};
|
||||||
|
use crate::modules::auth::service::{AuthService, Claims};
|
||||||
|
use crate::modules::billing::service::BillingService;
|
||||||
|
use crate::modules::nodes::service::NodeService;
|
||||||
|
use crate::modules::referrals::service::ReferralService;
|
||||||
|
use crate::modules::staff::service::StaffService;
|
||||||
|
use crate::modules::users::service::UserService;
|
||||||
|
use jsonwebtoken::{encode, EncodingKey, Header};
|
||||||
|
use sqlx::PgPool;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
const SECRET: &str = "test-jwt-secret";
|
||||||
|
|
||||||
|
fn build_state(pool: PgPool) -> ApiState {
|
||||||
|
std::env::set_var(
|
||||||
|
"TON_MASTER_WALLET",
|
||||||
|
"UQtest_wallet_for_proxy_internal_tests",
|
||||||
|
);
|
||||||
|
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool));
|
||||||
|
ApiState {
|
||||||
|
repo: repo.clone(),
|
||||||
|
auth_service: AuthService::new(repo.clone()),
|
||||||
|
billing_service: BillingService::new(repo.clone(), None),
|
||||||
|
node_service: NodeService::new(repo.clone()),
|
||||||
|
referral_service: ReferralService::new(repo.clone()),
|
||||||
|
user_service: UserService::new(repo.clone()),
|
||||||
|
staff_service: StaffService::new(repo.clone()),
|
||||||
|
support_service: crate::modules::support::service::SupportService::new(repo.clone()),
|
||||||
|
geoip_service: crate::modules::geoip::service::GeoipService::new(
|
||||||
|
"https://example.invalid/{}.mmdb.gz".into(),
|
||||||
|
"https://example.invalid/vpn.txt".into(),
|
||||||
|
),
|
||||||
|
jwt_secret: SECRET.into(),
|
||||||
|
bot_token: "test-bot-token".into(),
|
||||||
|
bot_username: "test_bot".into(),
|
||||||
|
cookie_domain: "".into(),
|
||||||
|
cookie_secure: false,
|
||||||
|
csrf_allowed_origins: vec![],
|
||||||
|
proxy_internal_secret: "test-internal-secret".into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sign_token(user_id: uuid::Uuid) -> String {
|
||||||
|
let claims = Claims {
|
||||||
|
sub: user_id.to_string(),
|
||||||
|
tg_id: None,
|
||||||
|
exp: (chrono::Utc::now() + chrono::Duration::minutes(15)).timestamp() as usize,
|
||||||
|
};
|
||||||
|
encode(
|
||||||
|
&Header::default(),
|
||||||
|
&claims,
|
||||||
|
&EncodingKey::from_secret(SECRET.as_bytes()),
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test]
|
||||||
|
async fn test_validate_api_rejects_garbage_token(pool: PgPool) {
|
||||||
|
let state = build_state(pool);
|
||||||
|
let err = validate_api(
|
||||||
|
State(state),
|
||||||
|
Json(ValidatePayload {
|
||||||
|
token: "not-a-real-jwt".into(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(matches!(err, AppError::Unauthorized(_)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test]
|
||||||
|
async fn test_validate_api_accepts_valid_token_and_returns_limits(pool: PgPool) {
|
||||||
|
let state = build_state(pool.clone());
|
||||||
|
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool));
|
||||||
|
let user = repo.upsert_user(1, None).await.unwrap();
|
||||||
|
repo.set_data_limit(user.id, Some(1_000_000)).await.unwrap();
|
||||||
|
|
||||||
|
let token = sign_token(user.id);
|
||||||
|
let resp = validate_api(State(state), Json(ValidatePayload { token }))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(resp.user_id, user.id);
|
||||||
|
assert_eq!(resp.limit_bytes, Some(1_000_000));
|
||||||
|
assert_eq!(resp.used_bytes, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test]
|
||||||
|
async fn test_usage_api_unknown_user_not_found(pool: PgPool) {
|
||||||
|
let state = build_state(pool);
|
||||||
|
let err = usage_api(
|
||||||
|
State(state),
|
||||||
|
Json(UsagePayload {
|
||||||
|
user_id: uuid::Uuid::new_v4(),
|
||||||
|
delta_bytes: 100,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(matches!(err, AppError::UserNotFound));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test]
|
||||||
|
async fn test_usage_api_over_limit_boundary(pool: PgPool) {
|
||||||
|
let state = build_state(pool.clone());
|
||||||
|
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool));
|
||||||
|
let user = repo.upsert_user(1, None).await.unwrap();
|
||||||
|
repo.set_data_limit(user.id, Some(1000)).await.unwrap();
|
||||||
|
|
||||||
|
// 999 из 1000 — ещё не превышен лимит.
|
||||||
|
let resp1 = usage_api(
|
||||||
|
State(state.clone()),
|
||||||
|
Json(UsagePayload {
|
||||||
|
user_id: user.id,
|
||||||
|
delta_bytes: 999,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(resp1.used_bytes, 999);
|
||||||
|
assert!(!resp1.over_limit);
|
||||||
|
|
||||||
|
// Ровно 1000 из 1000 — лимит уже считается превышенным (>=, не >).
|
||||||
|
let resp2 = usage_api(
|
||||||
|
State(state),
|
||||||
|
Json(UsagePayload {
|
||||||
|
user_id: user.id,
|
||||||
|
delta_bytes: 1,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(resp2.used_bytes, 1000);
|
||||||
|
assert!(resp2.over_limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test]
|
||||||
|
async fn test_usage_api_unlimited_user_never_over_limit(pool: PgPool) {
|
||||||
|
let state = build_state(pool.clone());
|
||||||
|
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool));
|
||||||
|
let user = repo.upsert_user(1, None).await.unwrap();
|
||||||
|
// data_limit_bytes остаётся NULL — безлимит.
|
||||||
|
|
||||||
|
let resp = usage_api(
|
||||||
|
State(state),
|
||||||
|
Json(UsagePayload {
|
||||||
|
user_id: user.id,
|
||||||
|
delta_bytes: 999_999_999,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(!resp.over_limit);
|
||||||
|
assert_eq!(resp.limit_bytes, None);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
//! HTTP-роуты ролевой админки. Named-guard'ы (`owner_guard`/`staff_guard`/
|
||||||
|
//! `partner_guard`) уже проверяют роль до входа в хендлер — сами хендлеры
|
||||||
|
//! читают текущего юзера только через `Extension<User>`.
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
extract::{Extension, Path, State},
|
||||||
|
http::StatusCode,
|
||||||
|
middleware,
|
||||||
|
routing::{get, patch, post},
|
||||||
|
Json, Router,
|
||||||
|
};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
api::ApiState,
|
||||||
|
db::models::User,
|
||||||
|
error::AppError,
|
||||||
|
modules::auth::{auth_guard, owner_guard, partner_guard, staff_guard},
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Любой залогиненный видит свою роль/права — нужно фронту, чтобы решить,
|
||||||
|
/// что показать и не пора ли редиректить на `/admin/login`.
|
||||||
|
pub fn router(state: ApiState) -> Router<ApiState> {
|
||||||
|
Router::new()
|
||||||
|
.route("/me", get(get_my_role))
|
||||||
|
.route_layer(middleware::from_fn_with_state(state, auth_guard))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Не подключён ни к чему в проде: изначально задумывался как гейт для
|
||||||
|
/// Caddy `forward_auth` перед Grafana, но эту схему заменили на nginx +
|
||||||
|
/// HTTP Basic Auth прямо на VPS с данными (см. netrunner-data/nginx/) —
|
||||||
|
/// проще и не зависит от того, задеплоен ли уже этот бэкенд. Эндпоинт
|
||||||
|
/// оставлен на случай, если позже понадобится реальный SSO поверх
|
||||||
|
/// basic-auth. Тело ответа не важно, важен только статус: 200/401.
|
||||||
|
/// Бизнес-метрики (выручка, число юзеров) чувствительны — Support/Partner
|
||||||
|
/// сюда не допускаются, поэтому `staff_guard`, а не `support_access_guard`.
|
||||||
|
pub fn observability_router(state: ApiState) -> Router<ApiState> {
|
||||||
|
Router::new()
|
||||||
|
.route("/check", get(|| async { StatusCode::OK }))
|
||||||
|
.route_layer(middleware::from_fn_with_state(state.clone(), staff_guard))
|
||||||
|
.route_layer(middleware::from_fn_with_state(state, auth_guard))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Только Owner: создание модераторов/саппортов, выдача модераторам права
|
||||||
|
/// на управление нодами.
|
||||||
|
pub fn owner_router(state: ApiState) -> Router<ApiState> {
|
||||||
|
Router::new()
|
||||||
|
.route("/moderators", post(create_moderator))
|
||||||
|
.route("/support", post(create_support))
|
||||||
|
.route("/users/{id}/permissions", patch(set_permissions))
|
||||||
|
.route_layer(middleware::from_fn_with_state(state.clone(), owner_guard))
|
||||||
|
.route_layer(middleware::from_fn_with_state(state, auth_guard))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Owner + Moderator: создание партнёров, список staff-юзеров, заявки на вывод.
|
||||||
|
pub fn staff_router(state: ApiState) -> Router<ApiState> {
|
||||||
|
Router::new()
|
||||||
|
.route("/partners", post(create_partner))
|
||||||
|
.route("/staff-users", get(list_staff))
|
||||||
|
.route("/withdrawals", get(list_withdrawals))
|
||||||
|
.route("/withdrawals/{id}", patch(update_withdrawal))
|
||||||
|
.route_layer(middleware::from_fn_with_state(state.clone(), staff_guard))
|
||||||
|
.route_layer(middleware::from_fn_with_state(state, auth_guard))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Только Partner: свой заработок и заявки на вывод.
|
||||||
|
pub fn partner_router(state: ApiState) -> Router<ApiState> {
|
||||||
|
Router::new()
|
||||||
|
.route("/withdrawals", post(request_withdrawal))
|
||||||
|
.route("/earnings", get(get_my_earnings))
|
||||||
|
.route_layer(middleware::from_fn_with_state(state.clone(), partner_guard))
|
||||||
|
.route_layer(middleware::from_fn_with_state(state, auth_guard))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_my_role(Extension(user): Extension<User>) -> Json<Value> {
|
||||||
|
Json(json!({
|
||||||
|
"role": user.role,
|
||||||
|
"can_manage_nodes": user.can_manage_nodes,
|
||||||
|
"username": user.username,
|
||||||
|
"partner_code": user.partner_code,
|
||||||
|
"commission_percent": user.commission_percent,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct CreateModeratorPayload {
|
||||||
|
pub username: String,
|
||||||
|
pub password: String,
|
||||||
|
pub can_manage_nodes: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_moderator(
|
||||||
|
State(state): State<ApiState>,
|
||||||
|
Json(p): Json<CreateModeratorPayload>,
|
||||||
|
) -> Result<Json<Value>, AppError> {
|
||||||
|
let user = state
|
||||||
|
.staff_service
|
||||||
|
.create_moderator(&p.username, &p.password, p.can_manage_nodes)
|
||||||
|
.await?;
|
||||||
|
Ok(Json(json!({
|
||||||
|
"id": user.id,
|
||||||
|
"username": user.username,
|
||||||
|
"role": user.role,
|
||||||
|
"can_manage_nodes": user.can_manage_nodes,
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct CreateSupportPayload {
|
||||||
|
pub username: String,
|
||||||
|
pub password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_support(
|
||||||
|
State(state): State<ApiState>,
|
||||||
|
Json(p): Json<CreateSupportPayload>,
|
||||||
|
) -> Result<Json<Value>, AppError> {
|
||||||
|
let user = state
|
||||||
|
.staff_service
|
||||||
|
.create_support(&p.username, &p.password)
|
||||||
|
.await?;
|
||||||
|
Ok(Json(json!({
|
||||||
|
"id": user.id,
|
||||||
|
"username": user.username,
|
||||||
|
"role": user.role,
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct CreatePartnerPayload {
|
||||||
|
pub username: String,
|
||||||
|
pub password: String,
|
||||||
|
pub commission_percent: rust_decimal::Decimal,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_partner(
|
||||||
|
State(state): State<ApiState>,
|
||||||
|
Json(p): Json<CreatePartnerPayload>,
|
||||||
|
) -> Result<Json<Value>, AppError> {
|
||||||
|
let user = state
|
||||||
|
.staff_service
|
||||||
|
.create_partner(&p.username, &p.password, p.commission_percent)
|
||||||
|
.await?;
|
||||||
|
Ok(Json(json!({
|
||||||
|
"id": user.id,
|
||||||
|
"username": user.username,
|
||||||
|
"partner_code": user.partner_code,
|
||||||
|
"commission_percent": user.commission_percent,
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct SetPermissionsPayload {
|
||||||
|
pub can_manage_nodes: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn set_permissions(
|
||||||
|
State(state): State<ApiState>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
Json(p): Json<SetPermissionsPayload>,
|
||||||
|
) -> Result<Json<Value>, AppError> {
|
||||||
|
let user = state
|
||||||
|
.staff_service
|
||||||
|
.set_can_manage_nodes(id, p.can_manage_nodes)
|
||||||
|
.await?
|
||||||
|
.ok_or(AppError::UserNotFound)?;
|
||||||
|
Ok(Json(json!({
|
||||||
|
"id": user.id,
|
||||||
|
"can_manage_nodes": user.can_manage_nodes,
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_staff(State(state): State<ApiState>) -> Result<Json<Vec<Value>>, AppError> {
|
||||||
|
let users = state.staff_service.list_staff().await?;
|
||||||
|
Ok(Json(
|
||||||
|
users
|
||||||
|
.into_iter()
|
||||||
|
.map(|u| {
|
||||||
|
json!({
|
||||||
|
"id": u.id,
|
||||||
|
"username": u.username,
|
||||||
|
"role": u.role,
|
||||||
|
"can_manage_nodes": u.can_manage_nodes,
|
||||||
|
"commission_percent": u.commission_percent,
|
||||||
|
"partner_code": u.partner_code,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_withdrawals(State(state): State<ApiState>) -> Result<Json<Value>, AppError> {
|
||||||
|
let list = state.staff_service.list_all_withdrawals().await?;
|
||||||
|
Ok(Json(json!(list)))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct UpdateWithdrawalPayload {
|
||||||
|
pub status: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn update_withdrawal(
|
||||||
|
Extension(user): Extension<User>,
|
||||||
|
State(state): State<ApiState>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
Json(p): Json<UpdateWithdrawalPayload>,
|
||||||
|
) -> Result<Json<Value>, AppError> {
|
||||||
|
if !["approved", "rejected", "paid"].contains(&p.status.as_str()) {
|
||||||
|
return Err(AppError::BusinessLogic("Недопустимый статус.".into()));
|
||||||
|
}
|
||||||
|
let req = state
|
||||||
|
.staff_service
|
||||||
|
.update_withdrawal(id, &p.status, user.id)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| AppError::NotFound("Заявка не найдена".into()))?;
|
||||||
|
Ok(Json(json!(req)))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_my_earnings(
|
||||||
|
Extension(user): Extension<User>,
|
||||||
|
State(state): State<ApiState>,
|
||||||
|
) -> Result<Json<Value>, AppError> {
|
||||||
|
let earnings = state.staff_service.get_earnings(user.id).await?;
|
||||||
|
let withdrawals = state
|
||||||
|
.staff_service
|
||||||
|
.list_withdrawals_for_partner(user.id)
|
||||||
|
.await?;
|
||||||
|
Ok(Json(json!({
|
||||||
|
"earnings": earnings,
|
||||||
|
"withdrawals": withdrawals,
|
||||||
|
"partner_code": user.partner_code,
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct RequestWithdrawalPayload {
|
||||||
|
pub currency: String,
|
||||||
|
pub amount: i64,
|
||||||
|
pub note: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn request_withdrawal(
|
||||||
|
Extension(user): Extension<User>,
|
||||||
|
State(state): State<ApiState>,
|
||||||
|
Json(p): Json<RequestWithdrawalPayload>,
|
||||||
|
) -> Result<Json<Value>, AppError> {
|
||||||
|
let req = state
|
||||||
|
.staff_service
|
||||||
|
.request_withdrawal(user.id, &p.currency, p.amount, p.note.as_deref())
|
||||||
|
.await?;
|
||||||
|
Ok(Json(json!(req)))
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
//! Ролевая админка: Owner создаёт Moderator/Partner, Moderator тоже может
|
||||||
|
//! создавать Partner. Модератору отдельно выдаётся право на управление
|
||||||
|
//! нодами (`can_manage_nodes`). Партнёры видят свой заработок и подают
|
||||||
|
//! заявки на вывод; Owner/Moderator эти заявки рассматривают. См. README.md
|
||||||
|
//! рядом с этим файлом и Context в плане `idempotent-foraging-crane.md`.
|
||||||
|
|
||||||
|
pub mod controller;
|
||||||
|
pub mod service;
|
||||||
@@ -0,0 +1,328 @@
|
|||||||
|
//! Бизнес-логика ролевой админки: создание Owner/Moderator/Partner,
|
||||||
|
//! переключение права на управление нодами, заработок и заявки на вывод
|
||||||
|
//! партнёров. Начисление самой комиссии живёт в
|
||||||
|
//! `billing::service::confirm_payment` (отдельно от рефералки) — здесь
|
||||||
|
//! только чтение/создание записей.
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
db::models::{PartnerEarningsByCurrency, User, WithdrawalRequest},
|
||||||
|
db::repository::AppRepository,
|
||||||
|
error::AppError,
|
||||||
|
modules::auth::service::AuthService,
|
||||||
|
};
|
||||||
|
use rand::Rng;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct StaffService {
|
||||||
|
repo: Arc<dyn AppRepository>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Короткий код диплинка `t.me/<bot>?start=<partner_code>` — префикс `p_`
|
||||||
|
/// нужен боту, чтобы однозначно отличать партнёрский код от numeric
|
||||||
|
/// referrer-tg_id и от `auth_sessions.auth_code` (см. `bot.rs::Command::Start`).
|
||||||
|
fn generate_partner_code() -> String {
|
||||||
|
const CHARSET: &[u8] = b"abcdefghijklmnopqrstuvwxyz0123456789";
|
||||||
|
let mut rng = rand::thread_rng();
|
||||||
|
let code: String = (0..6)
|
||||||
|
.map(|_| CHARSET[rng.gen_range(0..CHARSET.len())] as char)
|
||||||
|
.collect();
|
||||||
|
format!("p_{code}")
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StaffService {
|
||||||
|
pub fn new(repo: Arc<dyn AppRepository>) -> Self {
|
||||||
|
Self { repo }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_moderator(
|
||||||
|
&self,
|
||||||
|
username: &str,
|
||||||
|
password: &str,
|
||||||
|
can_manage_nodes: bool,
|
||||||
|
) -> Result<User, AppError> {
|
||||||
|
let hash = AuthService::hash_password(password)?;
|
||||||
|
self.repo
|
||||||
|
.create_staff_user(username, &hash, "moderator", can_manage_nodes, None, None)
|
||||||
|
.await
|
||||||
|
.map_err(|e| map_create_error(e, "модератора"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Аккаунт саппорта — видит только тикеты (см. `support_access_guard`),
|
||||||
|
/// `can_manage_nodes`/`commission_percent`/`partner_code` неприменимы.
|
||||||
|
pub async fn create_support(&self, username: &str, password: &str) -> Result<User, AppError> {
|
||||||
|
let hash = AuthService::hash_password(password)?;
|
||||||
|
self.repo
|
||||||
|
.create_staff_user(username, &hash, "support", false, None, None)
|
||||||
|
.await
|
||||||
|
.map_err(|e| map_create_error(e, "саппорта"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_partner(
|
||||||
|
&self,
|
||||||
|
username: &str,
|
||||||
|
password: &str,
|
||||||
|
commission_percent: rust_decimal::Decimal,
|
||||||
|
) -> Result<User, AppError> {
|
||||||
|
let hash = AuthService::hash_password(password)?;
|
||||||
|
let code = generate_partner_code();
|
||||||
|
self.repo
|
||||||
|
.create_staff_user(
|
||||||
|
username,
|
||||||
|
&hash,
|
||||||
|
"partner",
|
||||||
|
false,
|
||||||
|
Some(commission_percent),
|
||||||
|
Some(&code),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| map_create_error(e, "партнёра"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn set_can_manage_nodes(
|
||||||
|
&self,
|
||||||
|
user_id: Uuid,
|
||||||
|
enabled: bool,
|
||||||
|
) -> Result<Option<User>, AppError> {
|
||||||
|
self.repo
|
||||||
|
.set_can_manage_nodes(user_id, enabled)
|
||||||
|
.await
|
||||||
|
.map_err(AppError::Database)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_staff(&self) -> Result<Vec<User>, AppError> {
|
||||||
|
self.repo
|
||||||
|
.list_staff_users(None)
|
||||||
|
.await
|
||||||
|
.map_err(AppError::Database)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_all_withdrawals(&self) -> Result<Vec<WithdrawalRequest>, AppError> {
|
||||||
|
self.repo
|
||||||
|
.list_withdrawal_requests(None)
|
||||||
|
.await
|
||||||
|
.map_err(AppError::Database)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update_withdrawal(
|
||||||
|
&self,
|
||||||
|
id: Uuid,
|
||||||
|
new_status: &str,
|
||||||
|
processed_by: Uuid,
|
||||||
|
) -> Result<Option<WithdrawalRequest>, AppError> {
|
||||||
|
self.repo
|
||||||
|
.update_withdrawal_status(id, new_status, processed_by)
|
||||||
|
.await
|
||||||
|
.map_err(AppError::Database)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_earnings(
|
||||||
|
&self,
|
||||||
|
partner_id: Uuid,
|
||||||
|
) -> Result<Vec<PartnerEarningsByCurrency>, AppError> {
|
||||||
|
self.repo
|
||||||
|
.get_partner_earnings(partner_id)
|
||||||
|
.await
|
||||||
|
.map_err(AppError::Database)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_withdrawals_for_partner(
|
||||||
|
&self,
|
||||||
|
partner_id: Uuid,
|
||||||
|
) -> Result<Vec<WithdrawalRequest>, AppError> {
|
||||||
|
self.repo
|
||||||
|
.list_withdrawal_requests_for_partner(partner_id)
|
||||||
|
.await
|
||||||
|
.map_err(AppError::Database)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Партнёр подаёт заявку на вывод — проверяем, что запрошенная сумма не
|
||||||
|
/// превышает доступный остаток именно в этой валюте (без конвертации).
|
||||||
|
/// Проверка баланса и вставка заявки — одна транзакция с блокировкой
|
||||||
|
/// строки партнёра (`request_partner_withdrawal`), иначе несколько
|
||||||
|
/// параллельных заявок на весь остаток могут пройти одновременно.
|
||||||
|
pub async fn request_withdrawal(
|
||||||
|
&self,
|
||||||
|
partner_id: Uuid,
|
||||||
|
currency: &str,
|
||||||
|
amount: i64,
|
||||||
|
note: Option<&str>,
|
||||||
|
) -> Result<WithdrawalRequest, AppError> {
|
||||||
|
if amount <= 0 {
|
||||||
|
return Err(AppError::BusinessLogic(
|
||||||
|
"Сумма вывода должна быть положительной.".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
self.repo
|
||||||
|
.request_partner_withdrawal(partner_id, currency, amount, note)
|
||||||
|
.await
|
||||||
|
.map_err(|e| match e.as_str() {
|
||||||
|
"INSUFFICIENT_BALANCE" => AppError::BusinessLogic(
|
||||||
|
"Запрошенная сумма превышает доступный остаток в этой валюте.".into(),
|
||||||
|
),
|
||||||
|
_ => AppError::Internal(format!("Ошибка обработки заявки на вывод: {e}")),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_create_error(e: sqlx::Error, kind: &str) -> AppError {
|
||||||
|
if let sqlx::Error::Database(db_err) = &e {
|
||||||
|
if db_err.constraint() == Some("users_username_key") {
|
||||||
|
return AppError::BusinessLogic(format!(
|
||||||
|
"Логин уже занят, выберите другой для {kind}."
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AppError::Database(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::db::repository::PgRepository;
|
||||||
|
use rust_decimal::Decimal;
|
||||||
|
use sqlx::PgPool;
|
||||||
|
|
||||||
|
#[sqlx::test]
|
||||||
|
async fn test_create_moderator_duplicate_username_is_friendly_error(pool: PgPool) {
|
||||||
|
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool));
|
||||||
|
let svc = StaffService::new(repo);
|
||||||
|
|
||||||
|
svc.create_moderator("mod1", "pass1", false).await.unwrap();
|
||||||
|
let err = svc
|
||||||
|
.create_moderator("mod1", "different-pass", true)
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(matches!(err, AppError::BusinessLogic(_)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test]
|
||||||
|
async fn test_create_partner_duplicate_username_is_friendly_error(pool: PgPool) {
|
||||||
|
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool));
|
||||||
|
let svc = StaffService::new(repo);
|
||||||
|
|
||||||
|
svc.create_partner("p1", "pass1", Decimal::new(10, 0))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let err = svc
|
||||||
|
.create_partner("p1", "different-pass", Decimal::new(20, 0))
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(matches!(err, AppError::BusinessLogic(_)));
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn seed_partner_with_earnings(repo: &Arc<dyn AppRepository>, earned: i64) -> Uuid {
|
||||||
|
let partner = repo
|
||||||
|
.create_staff_user(
|
||||||
|
"partner1",
|
||||||
|
"hash",
|
||||||
|
"partner",
|
||||||
|
false,
|
||||||
|
Some(Decimal::new(10, 0)),
|
||||||
|
Some("p_abc123"),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let buyer = repo.upsert_user(999, None).await.unwrap();
|
||||||
|
let invoice_id = repo
|
||||||
|
.create_invoice(buyer.id, "GHOST_1M", "TEST", "USD", 500)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
repo.record_partner_commission(
|
||||||
|
partner.id,
|
||||||
|
invoice_id,
|
||||||
|
buyer.id,
|
||||||
|
"USD",
|
||||||
|
earned,
|
||||||
|
Decimal::new(10, 0),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
partner.id
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test]
|
||||||
|
async fn test_request_withdrawal_rejects_non_positive_amount(pool: PgPool) {
|
||||||
|
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool));
|
||||||
|
let svc = StaffService::new(repo.clone());
|
||||||
|
let partner_id = seed_partner_with_earnings(&repo, 1000).await;
|
||||||
|
|
||||||
|
let err = svc
|
||||||
|
.request_withdrawal(partner_id, "USD", 0, None)
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(matches!(err, AppError::BusinessLogic(_)));
|
||||||
|
|
||||||
|
let err2 = svc
|
||||||
|
.request_withdrawal(partner_id, "USD", -50, None)
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(matches!(err2, AppError::BusinessLogic(_)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test]
|
||||||
|
async fn test_request_withdrawal_rejects_over_balance(pool: PgPool) {
|
||||||
|
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool));
|
||||||
|
let svc = StaffService::new(repo.clone());
|
||||||
|
let partner_id = seed_partner_with_earnings(&repo, 1000).await;
|
||||||
|
|
||||||
|
let err = svc
|
||||||
|
.request_withdrawal(partner_id, "USD", 1001, None)
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(matches!(err, AppError::BusinessLogic(_)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test]
|
||||||
|
async fn test_request_withdrawal_succeeds_within_balance_and_updates_available(pool: PgPool) {
|
||||||
|
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool));
|
||||||
|
let svc = StaffService::new(repo.clone());
|
||||||
|
let partner_id = seed_partner_with_earnings(&repo, 1000).await;
|
||||||
|
|
||||||
|
svc.request_withdrawal(partner_id, "USD", 400, None)
|
||||||
|
.await
|
||||||
|
.expect("400 из доступных 1000 должно пройти");
|
||||||
|
|
||||||
|
let earnings = svc.get_earnings(partner_id).await.unwrap();
|
||||||
|
let usd = earnings.iter().find(|e| e.currency == "USD").unwrap();
|
||||||
|
assert_eq!(usd.total_earned, 1000);
|
||||||
|
assert_eq!(usd.pending_withdrawal, 400);
|
||||||
|
assert_eq!(usd.available, 600);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Регрессионный тест на гонку: `request_partner_withdrawal` обязан
|
||||||
|
/// блокировать строку партнёра (`FOR UPDATE`), иначе несколько параллельных
|
||||||
|
/// заявок на весь остаток проходят одновременно и суммарно превышают
|
||||||
|
/// доступный баланс партнёра (было воспроизведено до фикса — все 10 из 10
|
||||||
|
/// параллельных заявок проходили).
|
||||||
|
#[sqlx::test]
|
||||||
|
async fn test_concurrent_withdrawal_requests_do_not_overdraw_balance(pool: PgPool) {
|
||||||
|
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool.clone()));
|
||||||
|
let partner_id = seed_partner_with_earnings(&repo, 1000).await;
|
||||||
|
|
||||||
|
let num_requests = 10;
|
||||||
|
let mut handles = Vec::new();
|
||||||
|
for _ in 0..num_requests {
|
||||||
|
let repo_clone: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool.clone()));
|
||||||
|
let svc = StaffService::new(repo_clone);
|
||||||
|
handles.push(tokio::spawn(async move {
|
||||||
|
svc.request_withdrawal(partner_id, "USD", 1000, None).await
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut success_count = 0;
|
||||||
|
for h in handles {
|
||||||
|
if h.await.unwrap().is_ok() {
|
||||||
|
success_count += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
success_count, 1,
|
||||||
|
"без блокировки строки несколько параллельных заявок на весь остаток \
|
||||||
|
могли пройти одновременно и увести партнёра в минус — request_withdrawal \
|
||||||
|
нужен FOR UPDATE по аналогии с apply_promo_code"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
//! HTTP-роуты тикетов техподдержки. Все — под `support_access_guard`
|
||||||
|
//! (Owner/Moderator/Support), см. `modules::auth::support_access_guard`.
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
extract::{Extension, Path, Query, State},
|
||||||
|
middleware,
|
||||||
|
routing::{get, patch, post},
|
||||||
|
Json, Router,
|
||||||
|
};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
api::ApiState,
|
||||||
|
db::models::User,
|
||||||
|
error::AppError,
|
||||||
|
modules::auth::{auth_guard, support_access_guard},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub fn router(state: ApiState) -> Router<ApiState> {
|
||||||
|
Router::new()
|
||||||
|
.route("/tickets", get(list_tickets))
|
||||||
|
.route("/tickets/{id}", get(get_ticket))
|
||||||
|
.route("/tickets/{id}/reply", post(reply))
|
||||||
|
.route("/tickets/{id}/close", patch(close))
|
||||||
|
.route_layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
support_access_guard,
|
||||||
|
))
|
||||||
|
.route_layer(middleware::from_fn_with_state(state, auth_guard))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct ListTicketsQuery {
|
||||||
|
pub status: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_tickets(
|
||||||
|
State(state): State<ApiState>,
|
||||||
|
Query(q): Query<ListTicketsQuery>,
|
||||||
|
) -> Result<Json<Value>, AppError> {
|
||||||
|
let status_filter = match q.status.as_deref() {
|
||||||
|
Some("all") | None => None,
|
||||||
|
Some(s) => Some(s),
|
||||||
|
};
|
||||||
|
let tickets = state.support_service.list_tickets(status_filter).await?;
|
||||||
|
Ok(Json(json!(tickets)))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_ticket(
|
||||||
|
State(state): State<ApiState>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
) -> Result<Json<Value>, AppError> {
|
||||||
|
let (ticket, messages) = state
|
||||||
|
.support_service
|
||||||
|
.get_ticket_with_messages(id)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| AppError::NotFound("Тикет не найден".into()))?;
|
||||||
|
Ok(Json(json!({
|
||||||
|
"ticket": ticket,
|
||||||
|
"messages": messages,
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct ReplyPayload {
|
||||||
|
pub text: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn reply(
|
||||||
|
Extension(staff): Extension<User>,
|
||||||
|
State(state): State<ApiState>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
Json(p): Json<ReplyPayload>,
|
||||||
|
) -> Result<Json<Value>, AppError> {
|
||||||
|
let ticket = state
|
||||||
|
.repo
|
||||||
|
.get_ticket(id)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| AppError::NotFound("Тикет не найден".into()))?;
|
||||||
|
let ticket_user = state.repo.get_user_by_id(ticket.user_id).await?;
|
||||||
|
|
||||||
|
state
|
||||||
|
.support_service
|
||||||
|
.reply_as_staff(
|
||||||
|
id,
|
||||||
|
staff.id,
|
||||||
|
&p.text,
|
||||||
|
&state.bot_token,
|
||||||
|
ticket_user.and_then(|u| u.tg_id),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(Json(json!({ "ok": true })))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn close(
|
||||||
|
State(state): State<ApiState>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
) -> Result<Json<Value>, AppError> {
|
||||||
|
let ticket = state.support_service.close_ticket(id).await?;
|
||||||
|
Ok(Json(json!(ticket)))
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
//! Тикеты техподдержки — единственный канал создания: кнопка "Поддержка" в
|
||||||
|
//! Telegram-боте (см. `crate::bot`). Ответ саппорта — из веб-админки,
|
||||||
|
//! доставляется юзеру обратно в тот же Telegram-чат. См. `service.rs`.
|
||||||
|
|
||||||
|
pub mod controller;
|
||||||
|
pub mod service;
|
||||||
|
|
||||||
|
pub use service::SupportService;
|
||||||
@@ -0,0 +1,268 @@
|
|||||||
|
//! Бизнес-логика тикетов техподдержки. Единственный канал создания
|
||||||
|
//! тикета/сообщения — бот (`bot.rs`), см. `handle_user_message`; ответ
|
||||||
|
//! саппорта приходит из веб-админки (`support::controller::reply`) и
|
||||||
|
//! доставляется юзеру обратно в тот же Telegram-чат через `reply_as_staff`.
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
db::models::{SupportMessage, SupportTicket, SupportTicketSummary},
|
||||||
|
db::repository::AppRepository,
|
||||||
|
error::AppError,
|
||||||
|
};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use teloxide::{payloads::SendMessageSetters, prelude::Requester, types::ChatId, Bot};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct SupportService {
|
||||||
|
repo: Arc<dyn AppRepository>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SupportService {
|
||||||
|
pub fn new(repo: Arc<dyn AppRepository>) -> Self {
|
||||||
|
Self { repo }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Кнопка "Поддержка" в боте — переводит юзера в разовый режим ожидания
|
||||||
|
/// сообщения. Сбрасывается сам в `handle_user_message`.
|
||||||
|
pub async fn set_awaiting(&self, user_id: Uuid) -> Result<(), AppError> {
|
||||||
|
self.repo
|
||||||
|
.set_awaiting_support(user_id, true)
|
||||||
|
.await
|
||||||
|
.map_err(AppError::Database)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Вызывается ботом ТОЛЬКО когда `user.awaiting_support_message == true`
|
||||||
|
/// (проверка — на вызывающей стороне, см. `bot.rs`). Переиспользует уже
|
||||||
|
/// открытый тикет юзера, если есть, иначе создаёт новый.
|
||||||
|
pub async fn handle_user_message(&self, user_id: Uuid, text: &str) -> Result<(), AppError> {
|
||||||
|
let ticket = match self
|
||||||
|
.repo
|
||||||
|
.get_open_ticket_for_user(user_id)
|
||||||
|
.await
|
||||||
|
.map_err(AppError::Database)?
|
||||||
|
{
|
||||||
|
Some(t) => t,
|
||||||
|
None => {
|
||||||
|
let t = self
|
||||||
|
.repo
|
||||||
|
.create_support_ticket(user_id)
|
||||||
|
.await
|
||||||
|
.map_err(AppError::Database)?;
|
||||||
|
metrics::counter!("support_tickets_total", "status" => "created").increment(1);
|
||||||
|
t
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
self.repo
|
||||||
|
.add_support_message(ticket.id, "user", Some(user_id), text)
|
||||||
|
.await
|
||||||
|
.map_err(AppError::Database)?;
|
||||||
|
|
||||||
|
// Сбрасываем флаг только после успешной записи сообщения — если БД
|
||||||
|
// недоступна, следующее сообщение юзера не потеряется молча.
|
||||||
|
self.repo
|
||||||
|
.set_awaiting_support(user_id, false)
|
||||||
|
.await
|
||||||
|
.map_err(AppError::Database)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_tickets(
|
||||||
|
&self,
|
||||||
|
status_filter: Option<&str>,
|
||||||
|
) -> Result<Vec<SupportTicketSummary>, AppError> {
|
||||||
|
self.repo
|
||||||
|
.list_tickets(status_filter)
|
||||||
|
.await
|
||||||
|
.map_err(AppError::Database)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Для кнопки "Поддержка" в боте — показать контекст уже открытого
|
||||||
|
/// тикета юзера, если есть (последние сообщения треда).
|
||||||
|
pub async fn get_open_ticket_thread_for_user(
|
||||||
|
&self,
|
||||||
|
user_id: Uuid,
|
||||||
|
) -> Result<Option<(SupportTicket, Vec<SupportMessage>)>, AppError> {
|
||||||
|
let Some(ticket) = self
|
||||||
|
.repo
|
||||||
|
.get_open_ticket_for_user(user_id)
|
||||||
|
.await
|
||||||
|
.map_err(AppError::Database)?
|
||||||
|
else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
let messages = self
|
||||||
|
.repo
|
||||||
|
.get_ticket_messages(ticket.id)
|
||||||
|
.await
|
||||||
|
.map_err(AppError::Database)?;
|
||||||
|
Ok(Some((ticket, messages)))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_ticket_with_messages(
|
||||||
|
&self,
|
||||||
|
ticket_id: Uuid,
|
||||||
|
) -> Result<Option<(SupportTicket, Vec<SupportMessage>)>, AppError> {
|
||||||
|
let Some(ticket) = self
|
||||||
|
.repo
|
||||||
|
.get_ticket(ticket_id)
|
||||||
|
.await
|
||||||
|
.map_err(AppError::Database)?
|
||||||
|
else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
let messages = self
|
||||||
|
.repo
|
||||||
|
.get_ticket_messages(ticket_id)
|
||||||
|
.await
|
||||||
|
.map_err(AppError::Database)?;
|
||||||
|
Ok(Some((ticket, messages)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Саппорт отвечает из админки — сохраняет сообщение и пытается
|
||||||
|
/// доставить его юзеру в Telegram. Ошибка отправки в Telegram (юзер
|
||||||
|
/// заблокировал бота, сетевой сбой у Telegram API и т.п.) не роняет
|
||||||
|
/// ответ целиком — сообщение уже сохранено в БД, саппорт увидит его в
|
||||||
|
/// треде даже если push не дошёл (та же resilience, что у расчёта
|
||||||
|
/// партнёрской комиссии в `BillingService::confirm_payment`).
|
||||||
|
pub async fn reply_as_staff(
|
||||||
|
&self,
|
||||||
|
ticket_id: Uuid,
|
||||||
|
staff_id: Uuid,
|
||||||
|
text: &str,
|
||||||
|
bot_token: &str,
|
||||||
|
user_tg_id: Option<i64>,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
self.repo
|
||||||
|
.add_support_message(ticket_id, "staff", Some(staff_id), text)
|
||||||
|
.await
|
||||||
|
.map_err(AppError::Database)?;
|
||||||
|
self.repo
|
||||||
|
.set_ticket_assigned(ticket_id, staff_id)
|
||||||
|
.await
|
||||||
|
.map_err(AppError::Database)?;
|
||||||
|
|
||||||
|
match user_tg_id {
|
||||||
|
Some(tg_id) => {
|
||||||
|
let bot = Bot::new(bot_token);
|
||||||
|
let message = format!("💬 <b>Ответ поддержки:</b>\n\n{}", text);
|
||||||
|
if let Err(e) = bot
|
||||||
|
.send_message(ChatId(tg_id), message)
|
||||||
|
.parse_mode(teloxide::types::ParseMode::Html)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
tracing::warn!(error = ?e, ticket_id = %ticket_id, "Не удалось доставить ответ поддержки в Telegram — сообщение сохранено в БД");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
tracing::warn!(ticket_id = %ticket_id, "У юзера тикета нет tg_id — ответ сохранён, но доставить в Telegram некуда");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn close_ticket(&self, ticket_id: Uuid) -> Result<SupportTicket, AppError> {
|
||||||
|
let ticket = self
|
||||||
|
.repo
|
||||||
|
.close_ticket(ticket_id)
|
||||||
|
.await
|
||||||
|
.map_err(AppError::Database)?
|
||||||
|
.ok_or_else(|| AppError::NotFound("Тикет не найден".into()))?;
|
||||||
|
metrics::counter!("support_tickets_total", "status" => "closed").increment(1);
|
||||||
|
Ok(ticket)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::db::repository::PgRepository;
|
||||||
|
use sqlx::PgPool;
|
||||||
|
|
||||||
|
#[sqlx::test]
|
||||||
|
async fn test_handle_user_message_creates_ticket_and_resets_flag(pool: PgPool) {
|
||||||
|
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool));
|
||||||
|
let svc = SupportService::new(repo.clone());
|
||||||
|
let user = repo.upsert_user(111, None).await.unwrap();
|
||||||
|
|
||||||
|
svc.set_awaiting(user.id).await.unwrap();
|
||||||
|
svc.handle_user_message(user.id, "не подключается на iOS")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let user_after = repo.get_user_by_id(user.id).await.unwrap().unwrap();
|
||||||
|
assert!(
|
||||||
|
!user_after.awaiting_support_message,
|
||||||
|
"флаг должен сброситься сразу после получения сообщения"
|
||||||
|
);
|
||||||
|
|
||||||
|
let ticket = repo
|
||||||
|
.get_open_ticket_for_user(user.id)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.expect("должен появиться открытый тикет");
|
||||||
|
let messages = repo.get_ticket_messages(ticket.id).await.unwrap();
|
||||||
|
assert_eq!(messages.len(), 1);
|
||||||
|
assert_eq!(messages[0].sender_role, "user");
|
||||||
|
assert_eq!(messages[0].body, "не подключается на iOS");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test]
|
||||||
|
async fn test_handle_user_message_reuses_open_ticket(pool: PgPool) {
|
||||||
|
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool));
|
||||||
|
let svc = SupportService::new(repo.clone());
|
||||||
|
let user = repo.upsert_user(222, None).await.unwrap();
|
||||||
|
|
||||||
|
svc.handle_user_message(user.id, "первое сообщение")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let first_ticket = repo
|
||||||
|
.get_open_ticket_for_user(user.id)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
svc.handle_user_message(user.id, "второе сообщение, тот же тикет")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let second_ticket = repo
|
||||||
|
.get_open_ticket_for_user(user.id)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
first_ticket.id, second_ticket.id,
|
||||||
|
"второе сообщение должно уйти в уже открытый тикет, а не создать новый"
|
||||||
|
);
|
||||||
|
let messages = repo.get_ticket_messages(first_ticket.id).await.unwrap();
|
||||||
|
assert_eq!(messages.len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test]
|
||||||
|
async fn test_handle_user_message_creates_new_ticket_if_previous_closed(pool: PgPool) {
|
||||||
|
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool));
|
||||||
|
let svc = SupportService::new(repo.clone());
|
||||||
|
let user = repo.upsert_user(333, None).await.unwrap();
|
||||||
|
|
||||||
|
svc.handle_user_message(user.id, "первое обращение")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let first_ticket = repo
|
||||||
|
.get_open_ticket_for_user(user.id)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
repo.close_ticket(first_ticket.id).await.unwrap();
|
||||||
|
|
||||||
|
svc.handle_user_message(user.id, "новое обращение")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let second_ticket = repo
|
||||||
|
.get_open_ticket_for_user(user.id)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.expect("должен появиться новый открытый тикет");
|
||||||
|
|
||||||
|
assert_ne!(first_ticket.id, second_ticket.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,14 +1,20 @@
|
|||||||
use axum::{
|
use axum::{
|
||||||
extract::{Extension, State},
|
extract::{Extension, Path, State},
|
||||||
middleware,
|
middleware,
|
||||||
routing::{get, post},
|
routing::{get, patch, post},
|
||||||
Json, Router,
|
Json, Router,
|
||||||
};
|
};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
use tracing::instrument;
|
use tracing::instrument;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::{api::ApiState, db::models::User, error::AppError, modules::auth::guard::auth_guard};
|
use crate::{
|
||||||
|
api::ApiState,
|
||||||
|
db::models::User,
|
||||||
|
error::AppError,
|
||||||
|
modules::auth::{guard::auth_guard, owner_guard},
|
||||||
|
};
|
||||||
|
|
||||||
#[derive(Deserialize, Debug)]
|
#[derive(Deserialize, Debug)]
|
||||||
pub struct HackResultPayload {
|
pub struct HackResultPayload {
|
||||||
@@ -26,6 +32,41 @@ pub fn router(state: ApiState) -> Router<ApiState> {
|
|||||||
.route_layer(middleware::from_fn_with_state(state, auth_guard))
|
.route_layer(middleware::from_fn_with_state(state, auth_guard))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct SetLimitPayload {
|
||||||
|
/// `None`/`null` — снять лимит (безлимит).
|
||||||
|
pub data_limit_bytes: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Секретный роутер для Nexus (Admin) — динамический лимит трафика на юзера,
|
||||||
|
/// читается прокси-нодами на каждой проверке (`internal/validate`, `internal/usage`),
|
||||||
|
/// поэтому вступает в силу без перезапуска/передеплоя прокси.
|
||||||
|
pub fn admin_router(state: ApiState) -> Router<ApiState> {
|
||||||
|
Router::new()
|
||||||
|
.route("/{id}/limit", patch(set_user_limit))
|
||||||
|
.route_layer(middleware::from_fn_with_state(state.clone(), owner_guard))
|
||||||
|
.route_layer(middleware::from_fn_with_state(state, auth_guard))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[instrument(skip(state, payload))]
|
||||||
|
async fn set_user_limit(
|
||||||
|
State(state): State<ApiState>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
Json(payload): Json<SetLimitPayload>,
|
||||||
|
) -> Result<Json<Value>, AppError> {
|
||||||
|
let user = state
|
||||||
|
.repo
|
||||||
|
.set_data_limit(id, payload.data_limit_bytes)
|
||||||
|
.await?
|
||||||
|
.ok_or(AppError::UserNotFound)?;
|
||||||
|
|
||||||
|
Ok(Json(json!({
|
||||||
|
"status": "success",
|
||||||
|
"user_id": user.id,
|
||||||
|
"data_limit_bytes": user.data_limit_bytes,
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
#[instrument(skip(state, user), fields(tg_id = user.tg_id))]
|
#[instrument(skip(state, user), fields(tg_id = user.tg_id))]
|
||||||
async fn get_me(
|
async fn get_me(
|
||||||
Extension(user): Extension<User>,
|
Extension(user): Extension<User>,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ use uuid::Uuid;
|
|||||||
|
|
||||||
/// Ответ на `POST /users/hack/start`: доска, которую сервер только что
|
/// Ответ на `POST /users/hack/start`: доска, которую сервер только что
|
||||||
/// сгенерировал и сохранил — клиент обязан играть строго на ней.
|
/// сгенерировал и сохранил — клиент обязан играть строго на ней.
|
||||||
|
#[derive(Debug)]
|
||||||
pub struct HackSessionStart {
|
pub struct HackSessionStart {
|
||||||
pub session_id: Uuid,
|
pub session_id: Uuid,
|
||||||
pub matrix: Vec<Vec<String>>,
|
pub matrix: Vec<Vec<String>>,
|
||||||
@@ -123,3 +124,144 @@ impl UserService {
|
|||||||
Ok(reward)
|
Ok(reward)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::db::repository::PgRepository;
|
||||||
|
use sqlx::PgPool;
|
||||||
|
|
||||||
|
async fn grant_tickets(pool: &PgPool, user_id: Uuid, tickets: i32) {
|
||||||
|
sqlx::query("UPDATE users SET game_tickets = $1 WHERE id = $2")
|
||||||
|
.bind(tickets)
|
||||||
|
.bind(user_id)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test]
|
||||||
|
async fn test_start_hack_session_no_tickets_errors(pool: PgPool) {
|
||||||
|
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool));
|
||||||
|
let svc = UserService::new(repo.clone());
|
||||||
|
let user = repo.upsert_user(1, None).await.unwrap();
|
||||||
|
|
||||||
|
let err = svc.start_hack_session(user.id).await.unwrap_err();
|
||||||
|
assert!(matches!(err, AppError::BusinessLogic(_)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test]
|
||||||
|
async fn test_start_hack_session_too_frequent_errors(pool: PgPool) {
|
||||||
|
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool.clone()));
|
||||||
|
let svc = UserService::new(repo.clone());
|
||||||
|
let user = repo.upsert_user(1, None).await.unwrap();
|
||||||
|
grant_tickets(&pool, user.id, 5).await;
|
||||||
|
|
||||||
|
svc.start_hack_session(user.id)
|
||||||
|
.await
|
||||||
|
.expect("первая игра должна пройти");
|
||||||
|
let err = svc.start_hack_session(user.id).await.unwrap_err();
|
||||||
|
assert!(
|
||||||
|
matches!(err, AppError::BusinessLogic(_)),
|
||||||
|
"повторный старт раньше 45с должен блокироваться rate-limit'ом"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test]
|
||||||
|
async fn test_start_hack_session_decrements_ticket_and_creates_session(pool: PgPool) {
|
||||||
|
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool.clone()));
|
||||||
|
let svc = UserService::new(repo.clone());
|
||||||
|
let user = repo.upsert_user(1, None).await.unwrap();
|
||||||
|
grant_tickets(&pool, user.id, 3).await;
|
||||||
|
|
||||||
|
let session = svc.start_hack_session(user.id).await.unwrap();
|
||||||
|
assert_eq!(session.matrix.len(), cyberhack::GRID_SIZE);
|
||||||
|
assert_eq!(session.targets.len(), 3);
|
||||||
|
|
||||||
|
let updated = repo.get_user_by_id(user.id).await.unwrap().unwrap();
|
||||||
|
assert_eq!(updated.game_tickets, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test]
|
||||||
|
async fn test_submit_hack_session_double_submit_rejected(pool: PgPool) {
|
||||||
|
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool.clone()));
|
||||||
|
let svc = UserService::new(repo.clone());
|
||||||
|
let user = repo.upsert_user(1, None).await.unwrap();
|
||||||
|
grant_tickets(&pool, user.id, 3).await;
|
||||||
|
|
||||||
|
let session = svc.start_hack_session(user.id).await.unwrap();
|
||||||
|
svc.submit_hack_session(user.id, session.session_id, vec![])
|
||||||
|
.await
|
||||||
|
.expect("первая сдача (даже пустым путём) должна пройти");
|
||||||
|
|
||||||
|
let err = svc
|
||||||
|
.submit_hack_session(user.id, session.session_id, vec![])
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(
|
||||||
|
matches!(err, AppError::BusinessLogic(_)),
|
||||||
|
"повторная сдача уже использованной сессии должна отклоняться"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test]
|
||||||
|
async fn test_submit_hack_session_wrong_user_rejected(pool: PgPool) {
|
||||||
|
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool.clone()));
|
||||||
|
let svc = UserService::new(repo.clone());
|
||||||
|
let owner = repo.upsert_user(1, None).await.unwrap();
|
||||||
|
let stranger = repo.upsert_user(2, None).await.unwrap();
|
||||||
|
grant_tickets(&pool, owner.id, 3).await;
|
||||||
|
|
||||||
|
let session = svc.start_hack_session(owner.id).await.unwrap();
|
||||||
|
let err = svc
|
||||||
|
.submit_hack_session(stranger.id, session.session_id, vec![])
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(matches!(err, AppError::BusinessLogic(_)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test]
|
||||||
|
async fn test_submit_hack_session_rejects_illegal_path_without_awarding_balance(pool: PgPool) {
|
||||||
|
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool.clone()));
|
||||||
|
let svc = UserService::new(repo.clone());
|
||||||
|
let user = repo.upsert_user(1, None).await.unwrap();
|
||||||
|
grant_tickets(&pool, user.id, 3).await;
|
||||||
|
|
||||||
|
let session = svc.start_hack_session(user.id).await.unwrap();
|
||||||
|
// Первый ход обязан быть в ряду 0 (active_index стартует в 0) — (1, 0) нелегален.
|
||||||
|
let illegal_path = vec![(1, 0)];
|
||||||
|
let err = svc
|
||||||
|
.submit_hack_session(user.id, session.session_id, illegal_path)
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(matches!(err, AppError::BusinessLogic(_)));
|
||||||
|
|
||||||
|
let unchanged = repo.get_user_by_id(user.id).await.unwrap().unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
unchanged.balance, 0,
|
||||||
|
"нелегальный путь не должен начислять награду"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test]
|
||||||
|
async fn test_submit_hack_session_end_to_end_credits_returned_reward(pool: PgPool) {
|
||||||
|
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool.clone()));
|
||||||
|
let svc = UserService::new(repo.clone());
|
||||||
|
let user = repo.upsert_user(1, None).await.unwrap();
|
||||||
|
grant_tickets(&pool, user.id, 3).await;
|
||||||
|
|
||||||
|
let session = svc.start_hack_session(user.id).await.unwrap();
|
||||||
|
// Первый ход всегда легален в ряду 0, независимо от содержимого доски.
|
||||||
|
let path = vec![(0, 0)];
|
||||||
|
let reward = svc
|
||||||
|
.submit_hack_session(user.id, session.session_id, path)
|
||||||
|
.await
|
||||||
|
.expect("легальный (пусть и не выигрышный) путь не должен отклоняться");
|
||||||
|
|
||||||
|
let updated = repo.get_user_by_id(user.id).await.unwrap().unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
updated.balance, reward,
|
||||||
|
"начисленный баланс должен совпадать с возвращённой суммой награды"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+33
-1
@@ -3,10 +3,13 @@
|
|||||||
//! подписки active -> grace -> expired и сверяет блокчейн на предмет платежей, чьё
|
//! подписки active -> grace -> expired и сверяет блокчейн на предмет платежей, чьё
|
||||||
//! подтверждение потерялось (см. `BillingService::reconcile_pending_payments`);
|
//! подтверждение потерялось (см. `BillingService::reconcile_pending_payments`);
|
||||||
//! - раз в `NODE_HEALTH_CHECK_INTERVAL` секунд (по умолчанию 60) пингует все VPN-ноды
|
//! - раз в `NODE_HEALTH_CHECK_INTERVAL` секунд (по умолчанию 60) пингует все VPN-ноды
|
||||||
//! по TCP и переоценивает их статус `online`/`offline` (см. `run_node_health_check`).
|
//! по TCP и переоценивает их статус `online`/`offline` (см. `run_node_health_check`);
|
||||||
|
//! - раз в `GEOIP_REFRESH_INTERVAL` секунд (по умолчанию раз в сутки) перекачивает
|
||||||
|
//! базу страна-по-IP и список VPN/датацентр-диапазонов (см. `modules::geoip`).
|
||||||
|
|
||||||
use crate::db::repository::AppRepository;
|
use crate::db::repository::AppRepository;
|
||||||
use crate::modules::billing::service::BillingService;
|
use crate::modules::billing::service::BillingService;
|
||||||
|
use crate::modules::geoip::service::GeoipService;
|
||||||
use std::{env, net::SocketAddr, sync::Arc, time::Duration};
|
use std::{env, net::SocketAddr, sync::Arc, time::Duration};
|
||||||
use tokio::{net::TcpStream, time};
|
use tokio::{net::TcpStream, time};
|
||||||
use tokio_util::sync::CancellationToken;
|
use tokio_util::sync::CancellationToken;
|
||||||
@@ -17,6 +20,7 @@ const HEALTH_CHECK_TIMEOUT: Duration = Duration::from_secs(3);
|
|||||||
pub async fn run_background_tasks(
|
pub async fn run_background_tasks(
|
||||||
billing: BillingService,
|
billing: BillingService,
|
||||||
repo: Arc<dyn AppRepository>,
|
repo: Arc<dyn AppRepository>,
|
||||||
|
geoip: GeoipService,
|
||||||
cancel_token: CancellationToken,
|
cancel_token: CancellationToken,
|
||||||
) {
|
) {
|
||||||
let interval_secs = env::var("SUBSCRIPTION_CHECK_INTERVAL")
|
let interval_secs = env::var("SUBSCRIPTION_CHECK_INTERVAL")
|
||||||
@@ -27,6 +31,10 @@ pub async fn run_background_tasks(
|
|||||||
.unwrap_or_else(|_| "60".to_string())
|
.unwrap_or_else(|_| "60".to_string())
|
||||||
.parse::<u64>()
|
.parse::<u64>()
|
||||||
.unwrap_or(60);
|
.unwrap_or(60);
|
||||||
|
let geoip_refresh_interval_secs = env::var("GEOIP_REFRESH_INTERVAL")
|
||||||
|
.unwrap_or_else(|_| "86400".to_string())
|
||||||
|
.parse::<u64>()
|
||||||
|
.unwrap_or(86400);
|
||||||
|
|
||||||
info!(
|
info!(
|
||||||
"🔄 Фоновый биллинг-воркер запущен (интервал: {} сек)",
|
"🔄 Фоновый биллинг-воркер запущен (интервал: {} сек)",
|
||||||
@@ -36,9 +44,16 @@ pub async fn run_background_tasks(
|
|||||||
"🩺 Health-check воркер VPN-нод запущен (интервал: {} сек)",
|
"🩺 Health-check воркер VPN-нод запущен (интервал: {} сек)",
|
||||||
health_check_interval_secs
|
health_check_interval_secs
|
||||||
);
|
);
|
||||||
|
info!(
|
||||||
|
"🌍 GeoIP-воркер запущен (интервал: {} сек)",
|
||||||
|
geoip_refresh_interval_secs
|
||||||
|
);
|
||||||
|
|
||||||
let mut billing_interval = time::interval(Duration::from_secs(interval_secs));
|
let mut billing_interval = time::interval(Duration::from_secs(interval_secs));
|
||||||
let mut health_interval = time::interval(Duration::from_secs(health_check_interval_secs));
|
let mut health_interval = time::interval(Duration::from_secs(health_check_interval_secs));
|
||||||
|
// Первый тик срабатывает сразу (не ждёт полный интервал) — иначе первые
|
||||||
|
// сутки после старта сервиса /plans работал бы вообще без базы GeoIP.
|
||||||
|
let mut geoip_interval = time::interval(Duration::from_secs(geoip_refresh_interval_secs));
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
@@ -63,6 +78,14 @@ pub async fn run_background_tasks(
|
|||||||
_ = health_interval.tick() => {
|
_ = health_interval.tick() => {
|
||||||
run_node_health_check(&repo).await;
|
run_node_health_check(&repo).await;
|
||||||
}
|
}
|
||||||
|
_ = geoip_interval.tick() => {
|
||||||
|
if let Err(e) = geoip.refresh_geoip_db().await {
|
||||||
|
error!("GeoIP: ошибка обновления базы страна-по-IP: {}", e);
|
||||||
|
}
|
||||||
|
if let Err(e) = geoip.refresh_vpn_list().await {
|
||||||
|
error!("GeoIP: ошибка обновления списка VPN-диапазонов: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
_ = cancel_token.cancelled() => { break; }
|
_ = cancel_token.cancelled() => { break; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -82,6 +105,9 @@ async fn run_node_health_check(repo: &Arc<dyn AppRepository>) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let total = nodes.len();
|
||||||
|
let mut online_count: u64 = 0;
|
||||||
|
|
||||||
for node in nodes {
|
for node in nodes {
|
||||||
if node.status == "provisioning" {
|
if node.status == "provisioning" {
|
||||||
continue;
|
continue;
|
||||||
@@ -102,6 +128,9 @@ async fn run_node_health_check(repo: &Arc<dyn AppRepository>) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let new_status = if is_reachable { "online" } else { "offline" };
|
let new_status = if is_reachable { "online" } else { "offline" };
|
||||||
|
if is_reachable {
|
||||||
|
online_count += 1;
|
||||||
|
}
|
||||||
if new_status != node.status {
|
if new_status != node.status {
|
||||||
info!(node_id = %node.id, name = %node.name, from = %node.status, to = %new_status, "Смена статуса ноды по health-check");
|
info!(node_id = %node.id, name = %node.name, from = %node.status, to = %new_status, "Смена статуса ноды по health-check");
|
||||||
}
|
}
|
||||||
@@ -110,4 +139,7 @@ async fn run_node_health_check(repo: &Arc<dyn AppRepository>) {
|
|||||||
error!(node_id = %node.id, error = ?e, "Health-check: не удалось обновить статус ноды");
|
error!(node_id = %node.id, error = ?e, "Health-check: не удалось обновить статус ноды");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
metrics::gauge!("nodes_online").set(online_count as f64);
|
||||||
|
metrics::gauge!("nodes_total").set(total as f64);
|
||||||
}
|
}
|
||||||
|
|||||||
+21
-7
@@ -10,13 +10,21 @@ if ! command -v docker &> /dev/null; then
|
|||||||
sh get-docker.sh
|
sh get-docker.sh
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "[*] Step 3: Logging into ghcr.io via Control Plane token..."
|
echo "[*] Step 3: Logging into Gitea Registry via Control Plane token..."
|
||||||
echo "$GH_TOKEN" | docker login ghcr.io -u nineap --password-stdin
|
echo "$GH_TOKEN" | docker login gitea.netrunner-vpn.com -u nineap --password-stdin
|
||||||
|
|
||||||
echo "[*] Step 4: Pulling the latest Netrunner image..."
|
echo "[*] Step 4: Pulling the latest Netrunner image..."
|
||||||
docker pull ghcr.io/nineap/netrunner-proxy:latest
|
docker pull gitea.netrunner-vpn.com/nineap/netrunner-proxy:latest
|
||||||
|
|
||||||
echo "[*] Step 5: Starting Netrunner Proxy at port 443..."
|
echo "[*] Step 5: Starting Netrunner Proxy at port ${NODE_PORT}..."
|
||||||
|
# NODE_PORT/SNI_DOMAIN/BACKEND_URL/PROXY_INTERNAL_SECRET подставляет
|
||||||
|
# NodeService::provision_node_via_ssh (см. src/modules/nodes/service.rs) —
|
||||||
|
# конкретные для ЭТОЙ ноды значения, а не общий дефолт на все ноды разом.
|
||||||
|
# Раньше control-plane URL был захардкожен прямо тут на конкретный IP, а
|
||||||
|
# --decoy-host/--require-auth/--health-port вообще не передавались — нода
|
||||||
|
# всегда стартовала с дефолтным декоем (www.debian.org, одинаковым для всех
|
||||||
|
# нод сразу) и БЕЗ проверки токена/лимитов трафика, независимо от того, что
|
||||||
|
# админ вводил при добавлении ноды.
|
||||||
docker run -d \
|
docker run -d \
|
||||||
--name netrunner-proxy \
|
--name netrunner-proxy \
|
||||||
--restart always \
|
--restart always \
|
||||||
@@ -25,9 +33,15 @@ docker run -d \
|
|||||||
--cap-add NET_RAW \
|
--cap-add NET_RAW \
|
||||||
--cap-add DAC_OVERRIDE \
|
--cap-add DAC_OVERRIDE \
|
||||||
--device /dev/net/tun:/dev/net/tun \
|
--device /dev/net/tun:/dev/net/tun \
|
||||||
-e CONTROL_PLANE_URL=http://147.45.43.70:8080 \
|
-e PROXY_INTERNAL_SECRET="$PROXY_INTERNAL_SECRET" \
|
||||||
-e NODE_IP=$NODE_IP \
|
gitea.netrunner-vpn.com/nineap/netrunner-proxy:latest \
|
||||||
ghcr.io/nineap/netrunner-proxy:latest
|
./netrunner-proxy \
|
||||||
|
--port "$NODE_PORT" \
|
||||||
|
--decoy-host "$SNI_DOMAIN" \
|
||||||
|
--require-auth \
|
||||||
|
--backend-url "$BACKEND_URL" \
|
||||||
|
--health-port 9091 \
|
||||||
|
--metrics-port 9093
|
||||||
|
|
||||||
echo "[*] Step 6: Starting Watchtower for auto-updates..."
|
echo "[*] Step 6: Starting Watchtower for auto-updates..."
|
||||||
docker run -d \
|
docker run -d \
|
||||||
|
|||||||
@@ -128,3 +128,41 @@ async fn test_apply_promo_code_race_condition(pool: PgPool) {
|
|||||||
let current_uses: i32 = promo_record.get("current_uses");
|
let current_uses: i32 = promo_record.get("current_uses");
|
||||||
assert_eq!(current_uses, 1);
|
assert_eq!(current_uses, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =====================================================================
|
||||||
|
// ТЕСТ 4: Реферальная награда — одноразовый бонус за первую покупку.
|
||||||
|
// `reward_referrer` фильтрует по `status = 'pending'`, поэтому повторная
|
||||||
|
// покупка того же приведённого юзера (см. BillingService::confirm_payment)
|
||||||
|
// не должна начислять рефереру ещё раз.
|
||||||
|
// =====================================================================
|
||||||
|
#[sqlx::test]
|
||||||
|
async fn test_reward_referrer_is_one_time_bonus_not_repeated(pool: PgPool) {
|
||||||
|
let repo = PgRepository::new(pool);
|
||||||
|
|
||||||
|
let referrer = repo.upsert_user(1, None).await.unwrap();
|
||||||
|
let referred = repo.upsert_user(2, None).await.unwrap();
|
||||||
|
repo.create_referral(referrer.id, referred.id)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let first = repo.reward_referrer(referred.id, 100).await.unwrap();
|
||||||
|
assert!(first, "первое начисление должно найти строку referrals");
|
||||||
|
let after_first = repo.get_user_by_id(referrer.id).await.unwrap().unwrap();
|
||||||
|
assert_eq!(after_first.balance, 100);
|
||||||
|
|
||||||
|
// Вторая "покупка" того же приведённого юзера — начисление НЕ должно
|
||||||
|
// сработать повторно, статус уже 'rewarded'.
|
||||||
|
let second = repo.reward_referrer(referred.id, 50).await.unwrap();
|
||||||
|
assert!(
|
||||||
|
!second,
|
||||||
|
"повторное начисление должно быть заблокировано статусом 'rewarded'"
|
||||||
|
);
|
||||||
|
let after_second = repo.get_user_by_id(referrer.id).await.unwrap().unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
after_second.balance, 100,
|
||||||
|
"баланс не должен измениться повторно"
|
||||||
|
);
|
||||||
|
|
||||||
|
let (_, total_earned) = repo.get_referral_stats(referrer.id).await.unwrap();
|
||||||
|
assert_eq!(total_earned, 100);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user