Ролевая админка (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
+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(())
}