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