Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e6c81bf8fd | |||
| 7290f9483b | |||
| 375b333978 | |||
| c8eea8203e | |||
| 1a26403afb | |||
| 3af0c46130 | |||
| 818d4fa384 | |||
| 4f1efc6ff9 | |||
| 4a8816b908 | |||
| d22cd0de41 |
+33
-9
@@ -6,16 +6,37 @@
|
|||||||
# 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
|
ADMIN_TG_ID=0
|
||||||
|
|
||||||
|
# Опционально: без него оплата через @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 уже прописан
|
||||||
# прод-домен через /setdomain, и один бот не может обслуживать два разных
|
# прод-домен через /setdomain, и один бот не может обслуживать два разных
|
||||||
@@ -25,21 +46,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
|
||||||
|
|||||||
@@ -24,6 +24,12 @@ TON_MASTER_WALLET=UQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAKK8y
|
|||||||
ADMIN_TG_ID=0
|
ADMIN_TG_ID=0
|
||||||
TELOXIDE_TOKEN=123456789:YOUR_DEV_BOT_TOKEN_HERE
|
TELOXIDE_TOKEN=123456789:YOUR_DEV_BOT_TOKEN_HERE
|
||||||
|
|
||||||
|
# Опционально: без него оплата через @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
|
||||||
|
|||||||
@@ -25,6 +25,11 @@ ARGON_SALT=какая-то-рандомная-длинная-строка-мин
|
|||||||
GRAFANA_PASSWORD=netrunner_admin_2026
|
GRAFANA_PASSWORD=netrunner_admin_2026
|
||||||
TON_MASTER_WALLET=UQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAKK8y
|
TON_MASTER_WALLET=UQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAKK8y
|
||||||
|
|
||||||
|
# Опционально: без него оплата через @CryptoBot вернёт "provider not
|
||||||
|
# supported", остальные провайдеры продолжают работать. Токен — у @CryptoBot:
|
||||||
|
# /pay -> Create App -> Payment Token.
|
||||||
|
CRYPTOBOT_API_TOKEN=
|
||||||
|
|
||||||
# =====================================================================
|
# =====================================================================
|
||||||
# === TELEGRAM API INTEGRATION ===
|
# === TELEGRAM API INTEGRATION ===
|
||||||
# =====================================================================
|
# =====================================================================
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
# Копия .github/workflows/build.yml для Gitea Actions. Отличие от оригинала:
|
||||||
|
# логин на ghcr.io использует secrets.GHCR_TOKEN (настоящий GitHub PAT с
|
||||||
|
# правами write:packages), а не secrets.GITHUB_TOKEN — автотокен Gitea валиден
|
||||||
|
# только для самого Gitea (API/пакеты этого инстанса), на ghcr.io им не
|
||||||
|
# залогиниться. Секрет GHCR_TOKEN нужно завести отдельно в настройках этого
|
||||||
|
# репозитория/организации в Gitea.
|
||||||
|
name: Build & Push Image
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
tags: ["v*"]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
env:
|
||||||
|
REGISTRY: ghcr.io
|
||||||
|
IMAGE_NAME: ${{ github.repository_owner }}/netrunner-control-plane
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
name: Build and push to GHCR
|
||||||
|
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.GHCR_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 }}
|
||||||
|
# Кеш слоёв между прогонами — повторные сборки в разы быстрее.
|
||||||
|
cache-from: type=gha
|
||||||
|
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.GHCR_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,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,98 @@
|
|||||||
|
# Копия .github/workflows/deploy.yml для Gitea Actions. secrets.GHCR_TOKEN
|
||||||
|
# здесь уже был настоящим PAT (не автотокеном), менять не пришлось.
|
||||||
|
# Триггер workflow_run (авто-деплой после успешной сборки) поддерживается не
|
||||||
|
# во всех версиях Gitea Actions — если deploy-dev не срабатывает автоматически
|
||||||
|
# после build, проверить версию Gitea или временно дергать деплой вручную
|
||||||
|
# через workflow_dispatch.
|
||||||
|
name: Deploy
|
||||||
|
|
||||||
|
# Один пайплайн, две джобы:
|
||||||
|
# - deploy-dev: авто, срабатывает после успешного "Build & Push Image" на main.
|
||||||
|
# - deploy-prod: вручную из вкладки Actions ("Run workflow"), как и раньше.
|
||||||
|
on:
|
||||||
|
workflow_run:
|
||||||
|
workflows: ["Build & Push Image"]
|
||||||
|
types: [completed]
|
||||||
|
branches: [main]
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
image_tag:
|
||||||
|
description: "Тег образа в GHCR для прод-деплоя"
|
||||||
|
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-образ, как и образ бэкенда, дев-сервер тянет из GHCR, собран
|
||||||
|
# в build.yml::build-caddy. Собирать xcaddy прямо на VPS один раз уже не
|
||||||
|
# уложилось в таймаут SSH-деплоя и уронило весь прогон).
|
||||||
|
- name: Sync compose file to Dev VPS
|
||||||
|
uses: appleboy/scp-action@v0.1.7
|
||||||
|
with:
|
||||||
|
host: ${{ secrets.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: Remote deploy via SSH
|
||||||
|
uses: appleboy/ssh-action@v1
|
||||||
|
with:
|
||||||
|
host: ${{ secrets.DEV_VPS_IP }}
|
||||||
|
username: root
|
||||||
|
key: ${{ secrets.DEV_SSH_PRIVATE_KEY }}
|
||||||
|
script: |
|
||||||
|
set -e
|
||||||
|
cd /root/netrunner-core
|
||||||
|
|
||||||
|
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 "${{ secrets.GHCR_TOKEN }}" | docker login ghcr.io -u ${{ github.repository_owner }} --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."
|
||||||
|
|
||||||
|
deploy-prod:
|
||||||
|
name: Pull & restart on Prod VPS
|
||||||
|
if: github.event_name == 'workflow_dispatch'
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
steps:
|
||||||
|
- name: Remote deploy via SSH
|
||||||
|
uses: appleboy/ssh-action@v1
|
||||||
|
with:
|
||||||
|
host: ${{ secrets.VPS_IP }}
|
||||||
|
username: root
|
||||||
|
key: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||||
|
script: |
|
||||||
|
set -e
|
||||||
|
cd /root/netrunner-core
|
||||||
|
|
||||||
|
export IMAGE="ghcr.io/nineap/netrunner-control-plane:${{ github.event.inputs.image_tag }}"
|
||||||
|
echo "🚀 Deploying ${IMAGE}"
|
||||||
|
|
||||||
|
echo "${{ secrets.GHCR_TOKEN }}" | docker login ghcr.io -u ${{ github.repository_owner }} --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."
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -25,15 +25,17 @@ 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: ${{ secrets.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
|
||||||
|
|
||||||
@@ -48,6 +50,7 @@ jobs:
|
|||||||
cd /root/netrunner-core
|
cd /root/netrunner-core
|
||||||
|
|
||||||
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
|
||||||
|
|||||||
@@ -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
+25
@@ -2181,6 +2181,7 @@ dependencies = [
|
|||||||
"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 +3036,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 +3067,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"
|
||||||
@@ -3553,6 +3575,7 @@ dependencies = [
|
|||||||
"memchr",
|
"memchr",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
|
"rust_decimal",
|
||||||
"rustls",
|
"rustls",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
@@ -3637,6 +3660,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 +3700,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",
|
||||||
|
|||||||
+2
-1
@@ -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,6 +37,7 @@ 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"] }
|
||||||
|
|
||||||
|
|||||||
+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
|
||||||
@@ -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:-ghcr.io/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:
|
||||||
|
|||||||
+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,85 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { NavLink, Outlet, useNavigate } from "react-router-dom";
|
||||||
|
import { Loader2, Server, Users, Wallet, DollarSign, LogOut } from "lucide-react";
|
||||||
|
import { fetchMyRole, logout, type StaffRole } from "./api";
|
||||||
|
|
||||||
|
const STAFF_ROLES = ["owner", "moderator", "partner"];
|
||||||
|
|
||||||
|
export default function AdminLayout() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
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 === "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>
|
||||||
|
);
|
||||||
|
}
|
||||||
+12
-1
@@ -7,6 +7,11 @@ 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 DICT = {
|
const DICT = {
|
||||||
en: { loading: "Loading protocols..." },
|
en: { loading: "Loading protocols..." },
|
||||||
@@ -83,7 +88,13 @@ 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>
|
||||||
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,225 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useOutletContext } from "react-router-dom";
|
||||||
|
import { UserPlus, RefreshCw } from "lucide-react";
|
||||||
|
import {
|
||||||
|
createModerator,
|
||||||
|
createPartner,
|
||||||
|
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 [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 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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<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,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>
|
||||||
|
);
|
||||||
|
}
|
||||||
+170
-1
@@ -1,5 +1,23 @@
|
|||||||
export const API_BASE = import.meta.env.VITE_API_BASE || "/api/v1";
|
export const API_BASE = import.meta.env.VITE_API_BASE || "/api/v1";
|
||||||
|
|
||||||
|
let configPromise: Promise<{ bot_username: string }> | null = null;
|
||||||
|
|
||||||
|
/// Юзернейм бота — намеренно не `import.meta.env.VITE_BOT_USERNAME`: этот SPA
|
||||||
|
/// собирается один раз в CI, и один и тот же статический бандл раздаётся и
|
||||||
|
/// прод-, и dev-стендом — build-time значение зашилось бы в файлы намертво и
|
||||||
|
/// не различалось бы между стендами (ровно так и вышло на практике: dev видел
|
||||||
|
/// прод-бота, у которого в BotFather другой домен — виджет логина отвечал
|
||||||
|
/// "Bot domain invalid"). `/api/v1/config` читает env на сервере заново на
|
||||||
|
/// каждый запрос, поэтому корректно отдаёт "свой" бот для каждого стенда.
|
||||||
|
export function getPublicConfig(): Promise<{ bot_username: string }> {
|
||||||
|
if (!configPromise) {
|
||||||
|
configPromise = fetch(`${API_BASE}/config`)
|
||||||
|
.then((res) => res.json())
|
||||||
|
.catch(() => ({ bot_username: "ntrnr_vpn_bot" }));
|
||||||
|
}
|
||||||
|
return configPromise;
|
||||||
|
}
|
||||||
|
|
||||||
// Сессия живёт только в HttpOnly cookie, которую ставит сервер — JS её не видит
|
// Сессия живёт только в HttpOnly cookie, которую ставит сервер — JS её не видит
|
||||||
// и не должен: раньше здесь читался `document.cookie` в расчёте на токен,
|
// и не должен: раньше здесь читался `document.cookie` в расчёте на токен,
|
||||||
// который сервер ставит как HttpOnly, поэтому чтение всегда возвращало null, а
|
// который сервер ставит как HttpOnly, поэтому чтение всегда возвращало null, а
|
||||||
@@ -35,6 +53,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 +71,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 +165,144 @@ 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" | "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 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");
|
||||||
|
};
|
||||||
|
|
||||||
export const logout = async () => {
|
export const logout = async () => {
|
||||||
await fetch(`${API_BASE}/auth/logout`, {
|
await fetch(`${API_BASE}/auth/logout`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|||||||
@@ -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);
|
||||||
+44
-3
@@ -29,7 +29,9 @@ 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},
|
||||||
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},
|
||||||
users::{controller as user_ctrl, service::UserService},
|
users::{controller as user_ctrl, service::UserService},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -40,9 +42,18 @@ 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 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 +69,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 +83,14 @@ 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(),
|
||||||
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(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -119,9 +135,27 @@ pub fn create_router(state: ApiState, frontend_dir: &str) -> Router {
|
|||||||
.nest("/billing", bill_ctrl::router(state.clone()))
|
.nest("/billing", bill_ctrl::router(state.clone()))
|
||||||
.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()));
|
||||||
|
|
||||||
|
// Внутренний контракт для прокси-нод — отдельный секрет, не 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 +179,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 +198,12 @@ 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 {
|
||||||
|
Json(json!({ "bot_username": state.bot_username }))
|
||||||
|
}
|
||||||
|
|
||||||
/// 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" })))
|
||||||
|
|||||||
+245
-20
@@ -7,7 +7,8 @@
|
|||||||
//! `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, users::service::UserService,
|
||||||
};
|
};
|
||||||
use teloxide::{
|
use teloxide::{
|
||||||
prelude::*,
|
prelude::*,
|
||||||
@@ -38,6 +39,7 @@ pub async fn run_bot(
|
|||||||
auth: AuthService,
|
auth: AuthService,
|
||||||
users: UserService,
|
users: UserService,
|
||||||
billing: BillingService,
|
billing: BillingService,
|
||||||
|
referrals: ReferralService,
|
||||||
cancel_token: CancellationToken,
|
cancel_token: CancellationToken,
|
||||||
) {
|
) {
|
||||||
let config = BotConfig {
|
let config = BotConfig {
|
||||||
@@ -59,7 +61,7 @@ pub async fn run_bot(
|
|||||||
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, config])
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
@@ -82,14 +84,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,13 +139,14 @@ async fn handle_commands(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[instrument(skip(bot, auth, users, billing, config), fields(user_id = q.from.id.0))]
|
#[instrument(skip(bot, auth, users, billing, referrals, 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,
|
||||||
config: BotConfig,
|
config: BotConfig,
|
||||||
) -> ResponseResult<()> {
|
) -> ResponseResult<()> {
|
||||||
if let (Some(data), Some(msg)) = (q.data, q.message) {
|
if let (Some(data), Some(msg)) = (q.data, q.message) {
|
||||||
@@ -129,12 +164,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 +203,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 +232,171 @@ async fn handle_callbacks(
|
|||||||
"💳 Выбрать тариф (WebApp)",
|
"💳 Выбрать тариф (WebApp)",
|
||||||
)
|
)
|
||||||
.await,
|
.await,
|
||||||
"menu_ref" => webapp_bridge_response(
|
// Оплата криптой прямо в чате, без WebApp/кошелька — юзер платит
|
||||||
&auth,
|
// внутри @CryptoBot, возвращается и жмёт "Я оплатил".
|
||||||
&users,
|
"menu_pay_crypto" => match billing.list_plans_usd().await {
|
||||||
&config,
|
Ok(plans) if !plans.is_empty() => {
|
||||||
tg_id,
|
let rows = plans
|
||||||
"🔗 <b>СИНДИКАТ</b>\n\nОткройте Терминал, чтобы увидеть свою реферальную ссылку и статистику.",
|
.chunks(1)
|
||||||
"🔗 Открыть Синдикат (WebApp)",
|
.map(|chunk| {
|
||||||
|
chunk
|
||||||
|
.iter()
|
||||||
|
.map(|(name, cents)| {
|
||||||
|
InlineKeyboardButton::callback(
|
||||||
|
format!("{} — ${:.2}", name, *cents as f64 / 100.0),
|
||||||
|
format!("pay_plan:{}", name),
|
||||||
)
|
)
|
||||||
.await,
|
})
|
||||||
|
.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())
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// Код для входа в десктоп/мобильное приложение — см. докстринг
|
||||||
|
// 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 +448,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 +466,10 @@ fn main_menu_keyboard() -> InlineKeyboardMarkup {
|
|||||||
"🔗 Синдикат (Рефералы)",
|
"🔗 Синдикат (Рефералы)",
|
||||||
"menu_ref",
|
"menu_ref",
|
||||||
)],
|
)],
|
||||||
|
vec![InlineKeyboardButton::callback(
|
||||||
|
"🔑 Код для входа в приложение",
|
||||||
|
"menu_app_login",
|
||||||
|
)],
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
fn profile_keyboard() -> InlineKeyboardMarkup {
|
fn profile_keyboard() -> InlineKeyboardMarkup {
|
||||||
|
|||||||
@@ -19,6 +19,61 @@ 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>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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 +141,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)]
|
||||||
|
|||||||
+602
-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,12 @@ 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>;
|
||||||
|
|
||||||
async fn create_invoice(
|
async fn create_invoice(
|
||||||
&self,
|
&self,
|
||||||
user_id: Uuid,
|
user_id: Uuid,
|
||||||
@@ -124,6 +160,87 @@ 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>;
|
||||||
|
|
||||||
// --- 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 +292,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"
|
||||||
)
|
)
|
||||||
.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 +302,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 \
|
||||||
FROM users WHERE id = $1"
|
FROM users WHERE id = $1"
|
||||||
)
|
)
|
||||||
.bind(id)
|
.bind(id)
|
||||||
@@ -194,7 +315,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 \
|
||||||
FROM users WHERE tg_id = $1"
|
FROM users WHERE tg_id = $1"
|
||||||
)
|
)
|
||||||
.bind(tg_id)
|
.bind(tg_id)
|
||||||
@@ -206,7 +329,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"
|
||||||
)
|
)
|
||||||
.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 +339,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 \
|
||||||
FROM users WHERE seed_hash = $1"
|
FROM users WHERE seed_hash = $1"
|
||||||
)
|
)
|
||||||
.bind(hash)
|
.bind(hash)
|
||||||
@@ -222,6 +349,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",
|
||||||
|
)
|
||||||
|
.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 +500,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 +530,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 +686,18 @@ 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))]
|
#[instrument(skip(self))]
|
||||||
async fn create_invoice(
|
async fn create_invoice(
|
||||||
&self,
|
&self,
|
||||||
@@ -504,7 +726,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 +736,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 +789,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 +842,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 +864,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 +946,363 @@ 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"
|
||||||
|
)
|
||||||
|
.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 \
|
||||||
|
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"
|
||||||
|
)
|
||||||
|
.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"],
|
||||||
|
};
|
||||||
|
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 \
|
||||||
|
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 \
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
// =====================================================================
|
// =====================================================================
|
||||||
// VPN УЗЛЫ (CONTROL PLANE)
|
// VPN УЗЛЫ (CONTROL PLANE)
|
||||||
// =====================================================================
|
// =====================================================================
|
||||||
|
|||||||
+66
-8
@@ -18,7 +18,7 @@ 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, nodes::service::NodeService,
|
||||||
referrals::service::ReferralService, users::service::UserService,
|
referrals::service::ReferralService, staff::service::StaffService, users::service::UserService,
|
||||||
};
|
};
|
||||||
use netrunner_control_plane::tasks;
|
use netrunner_control_plane::tasks;
|
||||||
|
|
||||||
@@ -27,6 +27,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 +155,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 +212,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 +241,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()
|
||||||
@@ -241,12 +295,14 @@ 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()),
|
||||||
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 +322,7 @@ 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_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 +333,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
bot_auth,
|
bot_auth,
|
||||||
bot_users,
|
bot_users,
|
||||||
bot_billing,
|
bot_billing,
|
||||||
|
bot_referrals,
|
||||||
bot_cancel_token,
|
bot_cancel_token,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|||||||
@@ -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`, кнопки
|
||||||
|
|||||||
+258
-16
@@ -95,27 +95,269 @@ 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
|
}
|
||||||
|
|
||||||
|
/// Owner или Moderator (любой, независимо от `can_manage_nodes`).
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Только 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()),
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Строит одноразовый роутер с единственным 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
|
||||||
);
|
);
|
||||||
Err(AppError::Unauthorized(
|
|
||||||
"Insufficient clearance level".into(),
|
|
||||||
))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
//! Авторизация: Telegram Login Widget, анонимный "Ghost Protocol" (вход по seed-фразе)
|
//! Авторизация: Telegram Login Widget, анонимный "Ghost Protocol" (вход по seed-фразе),
|
||||||
//! и JWT-гварды (`auth_guard`, `admin_guard`). См. README.md рядом с этим файлом.
|
//! логин Owner/Moderator/Partner по паролю, и ролевые JWT-гварды (`auth_guard` +
|
||||||
|
//! `owner_guard`/`staff_guard`/`node_manage_guard`/`partner_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};
|
||||||
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 — это намеренно.
|
||||||
@@ -325,3 +496,304 @@ impl AuthService {
|
|||||||
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,
|
||||||
|
"повторный логин не должен задваивать реферальную связь"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
//! Провайдер [@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) {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
|||||||
@@ -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,14 @@ 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)
|
||||||
|
}
|
||||||
|
|
||||||
/// Курс 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 +157,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 +183,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 +204,20 @@ 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?;
|
||||||
|
|
||||||
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 +251,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
|
||||||
@@ -233,6 +295,72 @@ 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?;
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -356,3 +484,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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,5 +4,7 @@
|
|||||||
pub mod auth;
|
pub mod auth;
|
||||||
pub mod billing;
|
pub mod billing;
|
||||||
pub mod nodes;
|
pub mod nodes;
|
||||||
|
pub mod proxy_internal;
|
||||||
pub mod referrals;
|
pub mod referrals;
|
||||||
|
pub mod staff;
|
||||||
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))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,268 @@
|
|||||||
|
//! Внутренний контракт для `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()),
|
||||||
|
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,215 @@
|
|||||||
|
//! HTTP-роуты ролевой админки. Named-guard'ы (`owner_guard`/`staff_guard`/
|
||||||
|
//! `partner_guard`) уже проверяют роль до входа в хендлер — сами хендлеры
|
||||||
|
//! читают текущего юзера только через `Extension<User>`.
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
extract::{Extension, Path, 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, 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))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Только Owner: создание модераторов, выдача им права на управление нодами.
|
||||||
|
pub fn owner_router(state: ApiState) -> Router<ApiState> {
|
||||||
|
Router::new()
|
||||||
|
.route("/moderators", post(create_moderator))
|
||||||
|
.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 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,318 @@
|
|||||||
|
//! Бизнес-логика ролевой админки: создание 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, "модератора"))
|
||||||
|
}
|
||||||
|
|
||||||
|
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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
"начисленный баланс должен совпадать с возвращённой суммой награды"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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