Ролевая админка (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:
+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(())
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user