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