Ролевая админка (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
+53 -8
View File
@@ -18,7 +18,7 @@ use netrunner_control_plane::bot;
use netrunner_control_plane::db::repository::{AppRepository, PgRepository};
use netrunner_control_plane::modules::{
auth::service::AuthService, billing::service::BillingService, nodes::service::NodeService,
referrals::service::ReferralService, users::service::UserService,
referrals::service::ReferralService, staff::service::StaffService, users::service::UserService,
};
use netrunner_control_plane::tasks;
@@ -27,6 +27,12 @@ use netrunner_control_plane::tasks;
struct Args {
#[arg(long)]
migrate: bool,
/// Одноразовое создание первого Owner-аккаунта (логин+пароль, не
/// Telegram) — прогоняется руками на VPS: `--create-owner --username admin`.
#[arg(long)]
create_owner: bool,
#[arg(long)]
username: Option<String>,
}
fn init_tracing() {
@@ -149,6 +155,49 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
return Ok(());
}
// Бутстрап первого Owner'а — тоже требует только DATABASE_URL, тот же
// one-shot паттерн, что и --migrate. Пароль вводится скрыто в консоли
// (не через env/аргумент), чтобы не осесть в истории шелла.
if args.create_owner {
let username = args
.username
.clone()
.ok_or("--create-owner требует --username <имя>")?;
info!("🔐 Создание владельца (Owner): {}", username);
let password = rpassword::prompt_password("Пароль для владельца: ")?;
let password_confirm = rpassword::prompt_password("Повторите пароль: ")?;
if password != password_confirm {
return Err("Пароли не совпадают.".into());
}
if password.len() < 8 {
return Err("Пароль должен быть не короче 8 символов.".into());
}
let hash = AuthService::hash_password(&password)
.map_err(|e| format!("Ошибка хеширования пароля: {:?}", e))?;
let repo = PgRepository::new(pool.clone());
let user = repo
.create_staff_user(&username, &hash, "owner", false, None, None)
.await
.map_err(|e| {
if let sqlx::Error::Database(db_err) = &e {
if db_err.constraint() == Some("users_username_key") {
return format!("Логин '{}' уже занят.", username);
}
}
format!("Ошибка создания владельца: {e}")
})?;
info!("✅ Владелец создан: id={}, username={}", user.id, username);
println!(
"Владелец '{}' успешно создан. Войдите через /admin/login на фронтенде.",
username
);
return Ok(());
}
// --- Полный запуск сервера: проверяем все обязательные ресурсы ---
ensure_master_ssh_key()?;
@@ -174,12 +223,6 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
std::env::var("FRONTEND_DIR").expect("КРИТИЧЕСКАЯ ОШИБКА: FRONTEND_DIR не задан в .env!");
let port = std::env::var("PORT").expect("КРИТИЧЕСКАЯ ОШИБКА: PORT не задан в .env!");
// Считываем ADMIN_TG_ID один раз при старте сервера
let admin_tg_id = std::env::var("ADMIN_TG_ID")
.expect("КРИТИЧЕСКАЯ ОШИБКА: ADMIN_TG_ID не задан в .env!")
.parse::<i64>()
.expect("ADMIN_TG_ID должен быть числом");
info!("🚀 Запуск Netrunner Control Plane v2.0 (Modular Architecture)");
// Домен для Set-Cookie: пусто локально (host-only cookie), в проде
@@ -252,9 +295,9 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
node_service: NodeService::new(repo.clone()),
referral_service: ReferralService::new(repo.clone()),
user_service: UserService::new(repo.clone()),
staff_service: StaffService::new(repo.clone()),
jwt_secret,
bot_token: bot_token.clone(),
admin_tg_id,
bot_username,
cookie_domain,
cookie_secure,
@@ -279,6 +322,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
let bot_auth = state.auth_service.clone();
let bot_users = state.user_service.clone();
let bot_billing = state.billing_service.clone();
let bot_referrals = state.referral_service.clone();
let bot_token_for_task = bot_token.clone();
let bot_handle = tokio::spawn(async move {
@@ -289,6 +333,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
bot_auth,
bot_users,
bot_billing,
bot_referrals,
bot_cancel_token,
)
.await;