Ролевая админка (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:
@@ -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
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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))]
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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(¤cy_lower).await {
|
||||
cached
|
||||
let ton_rate = if let Some(cached) = self.get_cached_ton_rate(¤cy_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¤cies={}",
|
||||
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"][¤cy_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(¤cy_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¤cies={}",
|
||||
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"][¤cy_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(¤cy_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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -6,4 +6,5 @@ pub mod billing;
|
||||
pub mod nodes;
|
||||
pub mod proxy_internal;
|
||||
pub mod referrals;
|
||||
pub mod staff;
|
||||
pub mod users;
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
@@ -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,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)
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user