Ролевая админка (Owner/Moderator/Partner) + оплата через @CryptoBot

Ролевая система вместо привязки к Telegram
----------------------------------------
Раньше единственный способ получить админ-доступ — admin_guard, пускавший
по ADMIN_TG_ID или role=="admin" (без сеттера для роли). Заменяет на
Owner/Moderator/Partner:

- migrations/0005_roles_and_partners.sql — username/password_hash на users
  (NULL у обычных Ghost/Telegram-юзеров, тот же паттерн, что и у seed_hash/
  tg_id), can_manage_nodes, commission_percent, partner_code,
  referred_by_partner_id; новые таблицы partner_commissions и
  withdrawal_requests.
- auth/guard.rs — admin_guard заменён на owner_guard/staff_guard/
  node_manage_guard/partner_guard; ADMIN_TG_ID убран из ApiState и main.rs.
- auth/service.rs — hash_password/login_staff со случайной Argon2-солью на
  каждого юзера (не общий фиксированный ARGON_SALT, который годится для
  высокоэнтропийных seed-логинов, но не для человеческих паролей).
- POST /api/v1/auth/admin/login — вход модератора/партнёра/овнера паролем,
  переиспользует существующий issue_session().
- --create-owner CLI-флаг (main.rs) со скрытым вводом пароля (rpassword) —
  вместо ручного SQL для создания первого владельца с VPS-консоли.
- src/modules/staff/ — эндпоинты создания модераторов/партнёров, переключения
  can_manage_nodes, списка заявок на вывод и их approve/reject/paid.
- Комиссия партнёра начисляется в BillingService::confirm_payment отдельным
  шагом после существующей рефералки, пересчитывается через
  get_plan_price(invoice.plan_name, invoice.currency) — НЕ через
  invoice.amount напрямую, потому что у провайдеров разная внутренняя
  единица (TON — nanoTON+2% буфер, CryptoBot — фиатные центы напрямую).
- bot.rs — /start p_XXXXXX привязывает покупателя к партнёру по коду
  (first-wins, до существующих веток numeric-tg_id-рефералки и кода входа).
- frontend/ — AdminLogin/AdminLayout/StaffPage/WithdrawalsPage/EarningsPage
  поверх существующего Vite-админ-фронта (без отдельного проекта).

Оплата через @CryptoBot
------------------------
- modules/billing/providers/cryptobot.rs — новый PaymentProvider (Crypto Pay
  API), плюс попутно исправлены два реальных бага в существующем коде:
  confirm_payment был жёстко завязан на TON-провайдера при любом инвойсе, и
  InvoiceRecord не хранил currency, из-за чего TON-курс молча применялся ко
  всем провайдерам вместо родной валюты инвойса.
- bot.rs — экран выбора способа оплаты для CryptoBot-инвойсов.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-08 22:53:02 +07:00
parent c8eea8203e
commit 375b333978
31 changed files with 2607 additions and 115 deletions
+33
View File
@@ -33,6 +33,12 @@ pub struct ExchangePayload {
pub token: String,
}
#[derive(Deserialize, Debug)]
pub struct AdminLoginPayload {
pub username: String,
pub password: String,
}
pub fn router() -> Router<ApiState> {
Router::new()
.route("/telegram", post(auth_telegram_api))
@@ -40,6 +46,7 @@ pub fn router() -> Router<ApiState> {
.route("/telegram/session/{code}", get(get_telegram_session_api))
.route("/seed/generate", post(generate_seed_api))
.route("/seed/login", post(login_seed_api))
.route("/admin/login", post(admin_login_api))
.route("/exchange", post(exchange_bootstrap_api))
.route("/refresh", post(refresh_api))
.route("/logout", post(logout_api))
@@ -245,6 +252,32 @@ async fn login_seed_api(
))
}
/// Логин 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`, кнопки
/// «Личный Кабинет»/«Тарифы»/«Синдикат», токен приходит в `#token=` ссылки
/// WebApp) на полноценную cookie-сессию.
+68 -17
View File
@@ -95,27 +95,78 @@ pub async fn auth_guard(
Ok(next.run(req).await)
}
/// Проверяет права администратора.
pub async fn admin_guard(
State(state): State<ApiState>,
// Ролевые гварды админки (Owner/Moderator/Partner) — заменяют старый
// `admin_guard`, который держался на одном захардкоженном ADMIN_TG_ID.
// Роль читается из уже инжектнутого 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,
next: Next,
) -> Result<Response, AppError> {
let user = req
.extensions()
.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" {
let user = current_user(&req)?;
if user.role == "owner" {
Ok(next.run(req).await)
} else {
warn!(
"SECURITY_ALERT: Unauthorized admin access attempt by @{:?}",
user.tg_username
);
Err(AppError::Unauthorized(
"Insufficient clearance level".into(),
))
Err(deny(user))
}
}
/// 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))
}
}
+4 -3
View File
@@ -1,9 +1,10 @@
//! Авторизация: Telegram Login Widget, анонимный "Ghost Protocol" (вход по seed-фразе)
//! и JWT-гварды (`auth_guard`, `admin_guard`). См. README.md рядом с этим файлом.
//! Авторизация: Telegram Login Widget, анонимный "Ghost Protocol" (вход по seed-фразе),
//! логин Owner/Moderator/Partner по паролю, и ролевые JWT-гварды (`auth_guard` +
//! `owner_guard`/`staff_guard`/`node_manage_guard`/`partner_guard`). См. README.md рядом.
pub mod controller;
pub mod guard;
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};
+100
View File
@@ -239,6 +239,76 @@ impl AuthService {
self.issue_session(user.id, user.tg_id, jwt_secret, user_agent)
.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 и открывает
@@ -272,6 +342,36 @@ impl AuthService {
.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))]
+138
View File
@@ -0,0 +1,138 @@
//! Провайдер [@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)
}
}
}
+1
View File
@@ -7,6 +7,7 @@ use async_trait::async_trait;
use serde_json::Value;
use uuid::Uuid;
pub mod cryptobot;
pub mod ton;
pub struct InvoiceData {
+168 -43
View File
@@ -40,6 +40,23 @@ impl Clone for BillingService {
"TON",
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 {
repo: self.repo.clone(),
@@ -60,6 +77,23 @@ impl BillingService {
"TON",
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 {
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 —
/// вызывающий код должен сходить за свежим значением и заполнить кеш сам.
async fn get_cached_ton_rate(&self, currency_lower: &str) -> Option<rust_decimal::Decimal> {
@@ -115,57 +157,69 @@ impl BillingService {
AppError::BusinessLogic("Тариф недоступен для данного региона".into())
})?;
// --- КУРС TON: сначала кеш (Redis, TTL 60с), иначе синхронный запрос к TonAPI ---
let currency_lower = currency.to_lowercase();
let currency_upper = currency.to_uppercase();
// Курс TON нужен только самому TON-провайдеру — блокчейн понимает
// только nanoTON, а не фиат. Остальные провайдеры (CryptoBot: USDT
// 1:1 к USD; в будущем карточные эквайринги) биллятся напрямую в
// валюте `plan_prices`, без пересчёта.
let invoice_amount = if provider_name == "TON" {
// --- КУРС TON: сначала кеш (Redis, TTL 60с), иначе синхронный запрос к TonAPI ---
let currency_lower = currency.to_lowercase();
let currency_upper = currency.to_uppercase();
let ton_rate = if let Some(cached) = self.get_cached_ton_rate(&currency_lower).await {
cached
let ton_rate = if let Some(cached) = self.get_cached_ton_rate(&currency_lower).await {
cached
} else {
let client = reqwest::Client::new();
// TonAPI и запрашивать, и отдавать валюту нужно по одному и тому же
// коду: раньше здесь были жёстко зашиты "usd,rub" в запросе (курс для
// любой другой валюты из plan_prices — CNY/TRY/IRR — никогда бы не
// нашёлся), а ответ разбирался по НИЖНЕМУ регистру, хотя TonAPI
// возвращает ключи валют в ВЕРХНЕМ (`prices.USD`, не `prices.usd`).
let url = format!(
"https://tonapi.io/v2/rates?tokens=ton&currencies={}",
currency_lower
);
let rate_res: serde_json::Value = client
.get(&url)
.send()
.await
.map_err(|e| {
AppError::Internal(format!("Ошибка сети при запросе курса: {}", e))
})?
.json()
.await
.map_err(|e| {
AppError::Internal(format!("Ошибка парсинга курса TON: {}", e))
})?;
let rate_f64 = rate_res["rates"]["TON"]["prices"][&currency_upper]
.as_f64()
.ok_or_else(|| {
AppError::Internal("TonAPI не вернул курс для этой валюты".into())
})?;
let rate = rust_decimal::Decimal::from_f64(rate_f64)
.ok_or_else(|| AppError::Internal("Ошибка конвертации f64 в Decimal".into()))?;
self.cache_ton_rate(&currency_lower, rate).await;
rate
};
// ----------------------------------------
Self::calculate_ton_invoice(fiat_amount_minimal, ton_rate)
.map_err(AppError::BusinessLogic)?
} else {
let client = reqwest::Client::new();
// TonAPI и запрашивать, и отдавать валюту нужно по одному и тому же
// коду: раньше здесь были жёстко зашиты "usd,rub" в запросе (курс для
// любой другой валюты из plan_prices — CNY/TRY/IRR — никогда бы не
// нашёлся), а ответ разбирался по НИЖНЕМУ регистру, хотя TonAPI
// возвращает ключи валют в ВЕРХНЕМ (`prices.USD`, не `prices.usd`).
let url = format!(
"https://tonapi.io/v2/rates?tokens=ton&currencies={}",
currency_lower
);
let rate_res: serde_json::Value = client
.get(&url)
.send()
.await
.map_err(|e| AppError::Internal(format!("Ошибка сети при запросе курса: {}", e)))?
.json()
.await
.map_err(|e| AppError::Internal(format!("Ошибка парсинга курса TON: {}", e)))?;
let rate_f64 = rate_res["rates"]["TON"]["prices"][&currency_upper]
.as_f64()
.ok_or_else(|| {
AppError::Internal("TonAPI не вернул курс для этой валюты".into())
})?;
let rate = rust_decimal::Decimal::from_f64(rate_f64)
.ok_or_else(|| AppError::Internal("Ошибка конвертации f64 в Decimal".into()))?;
self.cache_ton_rate(&currency_lower, rate).await;
rate
fiat_amount_minimal
};
// ----------------------------------------
let nanotons = Self::calculate_ton_invoice(fiat_amount_minimal, ton_rate)
.map_err(AppError::BusinessLogic)?;
let invoice_id = self
.repo
.create_invoice(user.id, plan_name, provider_name, currency, nanotons)
.create_invoice(user.id, plan_name, provider_name, currency, invoice_amount)
.await?;
let invoice_data = InvoiceData {
invoice_id,
amount: nanotons,
amount: invoice_amount,
currency: currency.into(),
};
@@ -199,8 +253,15 @@ impl BillingService {
return Ok(());
}
// 2. Получаем провайдера
let provider = self.providers.get("TON").unwrap();
// 2. Получаем провайдера, которым был выставлен именно этот инвойс —
// раньше здесь был захардкожен "TON", что ломало подтверждение любого
// другого провайдера (см. `invoice.provider`, заполняется в `create_invoice`).
let provider = self.providers.get(invoice.provider.as_str()).ok_or_else(|| {
AppError::Internal(format!(
"Провайдер {} не сконфигурирован на этом инстансе",
invoice.provider
))
})?;
// 3. Провайдер лезет в блокчейн и проверяет транзакцию
let is_valid = provider
@@ -233,6 +294,70 @@ impl BillingService {
.reward_referrer(invoice.user_id, revshare_bonus)
.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(())
}
+1
View File
@@ -6,4 +6,5 @@ pub mod billing;
pub mod nodes;
pub mod proxy_internal;
pub mod referrals;
pub mod staff;
pub mod users;
+2 -3
View File
@@ -14,7 +14,7 @@ use crate::{
db::models::{CreateNodePayload, User, VpnNode},
error::AppError,
modules::{
auth::{admin_guard, auth_guard},
auth::{auth_guard, node_manage_guard},
nodes::service::NodeService,
},
};
@@ -41,8 +41,7 @@ pub fn admin_router(state: ApiState) -> Router<ApiState> {
.route("/", get(list_all_nodes).post(add_node))
.route("/{id}", delete(remove_node))
.route("/{id}/restart", post(restart_node))
// Теперь admin_guard использует from_fn_with_state
.route_layer(middleware::from_fn_with_state(state.clone(), admin_guard))
.route_layer(middleware::from_fn_with_state(state.clone(), node_manage_guard))
.route_layer(middleware::from_fn_with_state(state.clone(), auth_guard))
}
+215
View File
@@ -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)))
}
+8
View File
@@ -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;
+166
View File
@@ -0,0 +1,166 @@
//! Бизнес-логика ролевой админки: создание Owner/Moderator/Partner,
//! переключение права на управление нодами, заработок и заявки на вывод
//! партнёров. Начисление самой комиссии живёт в
//! `billing::service::confirm_payment` (отдельно от рефералки) — здесь
//! только чтение/создание записей.
use crate::{
db::models::{PartnerEarningsByCurrency, User, WithdrawalRequest},
error::AppError,
modules::auth::service::AuthService,
db::repository::AppRepository,
};
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)
}
/// Партнёр подаёт заявку на вывод — проверяем, что запрошенная сумма не
/// превышает доступный остаток именно в этой валюте (без конвертации).
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(),
));
}
let earnings = self.get_earnings(partner_id).await?;
let available = earnings
.iter()
.find(|e| e.currency == currency)
.map(|e| e.available)
.unwrap_or(0);
if amount > available {
return Err(AppError::BusinessLogic(
"Запрошенная сумма превышает доступный остаток в этой валюте.".into(),
));
}
self.repo
.create_withdrawal_request(partner_id, currency, amount, note)
.await
.map_err(AppError::Database)
}
}
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)
}
+2 -2
View File
@@ -13,7 +13,7 @@ use crate::{
api::ApiState,
db::models::User,
error::AppError,
modules::auth::{admin_guard, guard::auth_guard},
modules::auth::{guard::auth_guard, owner_guard},
};
#[derive(Deserialize, Debug)]
@@ -44,7 +44,7 @@ pub struct SetLimitPayload {
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(), admin_guard))
.route_layer(middleware::from_fn_with_state(state.clone(), owner_guard))
.route_layer(middleware::from_fn_with_state(state, auth_guard))
}