Ролевая админка (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
+387 -8
View File
@@ -16,6 +16,8 @@ pub struct InvoiceRecord {
pub id: Uuid,
pub user_id: Uuid,
pub plan_name: String,
pub provider: String,
pub currency: String,
pub amount: i64,
pub status: String,
}
@@ -112,6 +114,12 @@ pub trait AppRepository: Send + Sync {
currency: &str,
) -> Result<Option<i64>, sqlx::Error>;
/// Активные тарифы с USD-ценой (в центах) — для плиточного выбора тарифа
/// в боте (см. `bot.rs::menu_pay_crypto`). USD выбран как единственная
/// валюта, гарантированно заведённая на каждый план (см. сид в
/// `0001_init.sql`), а не потому что CryptoBot требует именно её.
async fn list_active_plans_usd(&self) -> Result<Vec<(String, i64)>, sqlx::Error>;
async fn create_invoice(
&self,
user_id: Uuid,
@@ -148,6 +156,74 @@ pub trait AppRepository: Send + Sync {
async fn get_referral_stats(&self, user_id: Uuid) -> Result<(i64, i64), sqlx::Error>;
async fn apply_promo_code(&self, user_id: Uuid, code: &str) -> Result<i64, String>;
// --- РОЛЕВАЯ АДМИНКА: OWNER / MODERATOR / PARTNER ---
/// Создаёт Owner/Moderator/Partner-аккаунт (логин+пароль, не Telegram/seed).
/// `can_manage_nodes` осмыслен только для "moderator", `commission_percent`/
/// `partner_code` — только для "partner" (для остальных ролей передавать `None`).
#[allow(clippy::too_many_arguments)]
async fn create_staff_user(
&self,
username: &str,
password_hash: &str,
role: &str,
can_manage_nodes: bool,
commission_percent: Option<rust_decimal::Decimal>,
partner_code: Option<&str>,
) -> Result<User, sqlx::Error>;
async fn get_user_by_username(&self, username: &str) -> Result<Option<User>, sqlx::Error>;
async fn set_can_manage_nodes(
&self,
user_id: Uuid,
enabled: bool,
) -> Result<Option<User>, sqlx::Error>;
/// `role_filter` — `None` = все staff-роли (owner+moderator+partner).
async fn list_staff_users(&self, role_filter: Option<&str>) -> Result<Vec<User>, sqlx::Error>;
async fn find_partner_by_code(&self, code: &str) -> Result<Option<User>, sqlx::Error>;
/// Постоянная привязка покупателя к партнёру при первой регистрации по
/// диплинку. No-op (`Ok(false)`), если уже привязан — first-wins.
async fn set_referred_by_partner(
&self,
user_id: Uuid,
partner_id: Uuid,
) -> Result<bool, sqlx::Error>;
#[allow(clippy::too_many_arguments)]
async fn record_partner_commission(
&self,
partner_id: Uuid,
invoice_id: Uuid,
user_id: Uuid,
currency: &str,
amount: i64,
percent_at_time: rust_decimal::Decimal,
) -> Result<(), sqlx::Error>;
/// Разбивка заработка/вывода партнёра по валютам (без конвертации).
async fn get_partner_earnings(
&self,
partner_id: Uuid,
) -> Result<Vec<PartnerEarningsByCurrency>, sqlx::Error>;
async fn create_withdrawal_request(
&self,
partner_id: Uuid,
currency: &str,
amount: i64,
note: Option<&str>,
) -> Result<WithdrawalRequest, sqlx::Error>;
/// `status_filter` — `None` = все заявки (используется owner/moderator).
async fn list_withdrawal_requests(
&self,
status_filter: Option<&str>,
) -> Result<Vec<WithdrawalRequest>, sqlx::Error>;
async fn list_withdrawal_requests_for_partner(
&self,
partner_id: Uuid,
) -> Result<Vec<WithdrawalRequest>, sqlx::Error>;
async fn update_withdrawal_status(
&self,
id: Uuid,
new_status: &str,
processed_by: Uuid,
) -> Result<Option<WithdrawalRequest>, sqlx::Error>;
// --- VPN УЗЛЫ (CONTROL PLANE) ---
/// Ноды в статусе `online` — то, что видят обычные юзеры (`/nodes`, `/routing`, `/stats`).
async fn get_active_nodes(&self) -> Result<Vec<VpnNode>, sqlx::Error>;
@@ -200,7 +276,8 @@ impl AppRepository for PgRepository {
VALUES ($1, $2, $3) \
ON CONFLICT (tg_id) DO UPDATE SET tg_username = EXCLUDED.tg_username \
RETURNING id, tg_id, tg_username, role, balance, game_tickets, has_used_trial, seed_hash, last_hack_at, \
data_limit_bytes, data_used_bytes, data_period_reset_at"
data_limit_bytes, data_used_bytes, data_period_reset_at, \
username, password_hash, can_manage_nodes, commission_percent, partner_code, referred_by_partner_id"
)
.bind(Uuid::new_v4()).bind(tg_id).bind(username).fetch_one(&self.pool).await
}
@@ -209,7 +286,8 @@ impl AppRepository for PgRepository {
async fn get_user_by_id(&self, id: Uuid) -> Result<Option<User>, sqlx::Error> {
sqlx::query_as::<_, User>(
"SELECT id, tg_id, tg_username, role, balance, game_tickets, has_used_trial, seed_hash, last_hack_at, \
data_limit_bytes, data_used_bytes, data_period_reset_at \
data_limit_bytes, data_used_bytes, data_period_reset_at, \
username, password_hash, can_manage_nodes, commission_percent, partner_code, referred_by_partner_id \
FROM users WHERE id = $1"
)
.bind(id)
@@ -221,7 +299,8 @@ impl AppRepository for PgRepository {
async fn get_user_by_tg_id(&self, tg_id: i64) -> Result<Option<User>, sqlx::Error> {
sqlx::query_as::<_, User>(
"SELECT id, tg_id, tg_username, role, balance, game_tickets, has_used_trial, seed_hash, last_hack_at, \
data_limit_bytes, data_used_bytes, data_period_reset_at \
data_limit_bytes, data_used_bytes, data_period_reset_at, \
username, password_hash, can_manage_nodes, commission_percent, partner_code, referred_by_partner_id \
FROM users WHERE tg_id = $1"
)
.bind(tg_id)
@@ -234,7 +313,8 @@ impl AppRepository for PgRepository {
sqlx::query_as::<_, User>(
"INSERT INTO users (id, seed_hash) VALUES ($1, $2) \
RETURNING id, tg_id, tg_username, role, balance, game_tickets, has_used_trial, seed_hash, last_hack_at, \
data_limit_bytes, data_used_bytes, data_period_reset_at"
data_limit_bytes, data_used_bytes, data_period_reset_at, \
username, password_hash, can_manage_nodes, commission_percent, partner_code, referred_by_partner_id"
)
.bind(Uuid::new_v4()).bind(seed_hash).fetch_one(&self.pool).await
}
@@ -243,7 +323,8 @@ impl AppRepository for PgRepository {
async fn get_user_by_seed_hash(&self, hash: &str) -> Result<Option<User>, sqlx::Error> {
sqlx::query_as::<_, User>(
"SELECT id, tg_id, tg_username, role, balance, game_tickets, has_used_trial, seed_hash, last_hack_at, \
data_limit_bytes, data_used_bytes, data_period_reset_at \
data_limit_bytes, data_used_bytes, data_period_reset_at, \
username, password_hash, can_manage_nodes, commission_percent, partner_code, referred_by_partner_id \
FROM users WHERE seed_hash = $1"
)
.bind(hash)
@@ -277,7 +358,8 @@ impl AppRepository for PgRepository {
sqlx::query_as::<_, User>(
"UPDATE users SET data_limit_bytes = $1 WHERE id = $2 \
RETURNING id, tg_id, tg_username, role, balance, game_tickets, has_used_trial, seed_hash, last_hack_at, \
data_limit_bytes, data_used_bytes, data_period_reset_at",
data_limit_bytes, data_used_bytes, data_period_reset_at, \
username, password_hash, can_manage_nodes, commission_percent, partner_code, referred_by_partner_id",
)
.bind(limit_bytes)
.bind(user_id)
@@ -583,6 +665,18 @@ impl AppRepository for PgRepository {
Ok(res.map(|r| r.0))
}
#[instrument(skip(self))]
async fn list_active_plans_usd(&self) -> Result<Vec<(String, i64)>, sqlx::Error> {
sqlx::query_as(
"SELECT pp.plan_name, pp.amount FROM plan_prices pp \
JOIN plans p ON p.name = pp.plan_name \
WHERE pp.currency = 'USD' AND pp.is_active = TRUE AND p.is_active = TRUE \
ORDER BY pp.amount ASC",
)
.fetch_all(&self.pool)
.await
}
#[instrument(skip(self))]
async fn create_invoice(
&self,
@@ -611,7 +705,7 @@ impl AppRepository for PgRepository {
#[instrument(skip(self))]
async fn get_invoice(&self, invoice_id: Uuid) -> Result<Option<InvoiceRecord>, sqlx::Error> {
sqlx::query_as::<_, InvoiceRecord>(
"SELECT id, user_id, plan_name, amount, status FROM invoices WHERE id = $1",
"SELECT id, user_id, plan_name, provider, currency, amount, status FROM invoices WHERE id = $1",
)
.bind(invoice_id)
.fetch_optional(&self.pool)
@@ -621,7 +715,7 @@ impl AppRepository for PgRepository {
#[instrument(skip(self))]
async fn get_pending_invoices(&self) -> Result<Vec<InvoiceRecord>, sqlx::Error> {
sqlx::query_as::<_, InvoiceRecord>(
"SELECT id, user_id, plan_name, amount, status FROM invoices \
"SELECT id, user_id, plan_name, provider, currency, amount, status FROM invoices \
WHERE status = 'pending' AND created_at > NOW() - INTERVAL '24 hours' \
ORDER BY created_at DESC LIMIT 100",
)
@@ -828,6 +922,291 @@ impl AppRepository for PgRepository {
Ok(reward)
}
// =====================================================================
// РОЛЕВАЯ АДМИНКА: OWNER / MODERATOR / PARTNER
// =====================================================================
#[instrument(skip(self, password_hash))]
async fn create_staff_user(
&self,
username: &str,
password_hash: &str,
role: &str,
can_manage_nodes: bool,
commission_percent: Option<rust_decimal::Decimal>,
partner_code: Option<&str>,
) -> Result<User, sqlx::Error> {
sqlx::query_as::<_, User>(
"INSERT INTO users (id, username, password_hash, role, can_manage_nodes, commission_percent, partner_code) \
VALUES ($1, $2, $3, $4, $5, $6, $7) \
RETURNING id, tg_id, tg_username, role, balance, game_tickets, has_used_trial, seed_hash, last_hack_at, \
data_limit_bytes, data_used_bytes, data_period_reset_at, \
username, password_hash, can_manage_nodes, commission_percent, partner_code, referred_by_partner_id"
)
.bind(Uuid::new_v4())
.bind(username)
.bind(password_hash)
.bind(role)
.bind(can_manage_nodes)
.bind(commission_percent)
.bind(partner_code)
.fetch_one(&self.pool)
.await
}
#[instrument(skip(self))]
async fn get_user_by_username(&self, username: &str) -> Result<Option<User>, sqlx::Error> {
sqlx::query_as::<_, User>(
"SELECT id, tg_id, tg_username, role, balance, game_tickets, has_used_trial, seed_hash, last_hack_at, \
data_limit_bytes, data_used_bytes, data_period_reset_at, \
username, password_hash, can_manage_nodes, commission_percent, partner_code, referred_by_partner_id \
FROM users WHERE username = $1"
)
.bind(username)
.fetch_optional(&self.pool)
.await
}
#[instrument(skip(self))]
async fn set_can_manage_nodes(
&self,
user_id: Uuid,
enabled: bool,
) -> Result<Option<User>, sqlx::Error> {
sqlx::query_as::<_, User>(
"UPDATE users SET can_manage_nodes = $1 WHERE id = $2 \
RETURNING id, tg_id, tg_username, role, balance, game_tickets, has_used_trial, seed_hash, last_hack_at, \
data_limit_bytes, data_used_bytes, data_period_reset_at, \
username, password_hash, can_manage_nodes, commission_percent, partner_code, referred_by_partner_id"
)
.bind(enabled)
.bind(user_id)
.fetch_optional(&self.pool)
.await
}
#[instrument(skip(self))]
async fn list_staff_users(&self, role_filter: Option<&str>) -> Result<Vec<User>, sqlx::Error> {
let roles: Vec<&str> = match role_filter {
Some(r) => vec![r],
None => vec!["owner", "moderator", "partner"],
};
sqlx::query_as::<_, User>(
"SELECT id, tg_id, tg_username, role, balance, game_tickets, has_used_trial, seed_hash, last_hack_at, \
data_limit_bytes, data_used_bytes, data_period_reset_at, \
username, password_hash, can_manage_nodes, commission_percent, partner_code, referred_by_partner_id \
FROM users WHERE role = ANY($1) ORDER BY role, username"
)
.bind(&roles)
.fetch_all(&self.pool)
.await
}
#[instrument(skip(self))]
async fn find_partner_by_code(&self, code: &str) -> Result<Option<User>, sqlx::Error> {
sqlx::query_as::<_, User>(
"SELECT id, tg_id, tg_username, role, balance, game_tickets, has_used_trial, seed_hash, last_hack_at, \
data_limit_bytes, data_used_bytes, data_period_reset_at, \
username, password_hash, can_manage_nodes, commission_percent, partner_code, referred_by_partner_id \
FROM users WHERE partner_code = $1 AND role = 'partner'"
)
.bind(code)
.fetch_optional(&self.pool)
.await
}
#[instrument(skip(self))]
async fn set_referred_by_partner(
&self,
user_id: Uuid,
partner_id: Uuid,
) -> Result<bool, sqlx::Error> {
// first-wins: не перезаписывает уже проставленную привязку.
let result = sqlx::query(
"UPDATE users SET referred_by_partner_id = $1 \
WHERE id = $2 AND referred_by_partner_id IS NULL",
)
.bind(partner_id)
.bind(user_id)
.execute(&self.pool)
.await?;
Ok(result.rows_affected() > 0)
}
#[instrument(skip(self))]
async fn record_partner_commission(
&self,
partner_id: Uuid,
invoice_id: Uuid,
user_id: Uuid,
currency: &str,
amount: i64,
percent_at_time: rust_decimal::Decimal,
) -> Result<(), sqlx::Error> {
sqlx::query(
"INSERT INTO partner_commissions (id, partner_id, invoice_id, user_id, currency, amount, percent_at_time) \
VALUES ($1, $2, $3, $4, $5, $6, $7) \
ON CONFLICT (invoice_id) DO NOTHING",
)
.bind(Uuid::new_v4())
.bind(partner_id)
.bind(invoice_id)
.bind(user_id)
.bind(currency)
.bind(amount)
.bind(percent_at_time)
.execute(&self.pool)
.await?;
Ok(())
}
#[instrument(skip(self))]
async fn get_partner_earnings(
&self,
partner_id: Uuid,
) -> Result<Vec<PartnerEarningsByCurrency>, sqlx::Error> {
// Три отдельных сгруппированных запроса и слияние в Rust — проще и
// надёжнее одного сложного JOIN, объёмы тут крошечные.
let earned_rows: Vec<(String, i64)> = sqlx::query_as(
"SELECT currency, COALESCE(SUM(amount), 0)::BIGINT FROM partner_commissions \
WHERE partner_id = $1 GROUP BY currency",
)
.bind(partner_id)
.fetch_all(&self.pool)
.await?;
let withdrawn_rows: Vec<(String, i64)> = sqlx::query_as(
"SELECT currency, COALESCE(SUM(amount), 0)::BIGINT FROM withdrawal_requests \
WHERE partner_id = $1 AND status IN ('approved', 'paid') GROUP BY currency",
)
.bind(partner_id)
.fetch_all(&self.pool)
.await?;
let pending_rows: Vec<(String, i64)> = sqlx::query_as(
"SELECT currency, COALESCE(SUM(amount), 0)::BIGINT FROM withdrawal_requests \
WHERE partner_id = $1 AND status = 'pending' GROUP BY currency",
)
.bind(partner_id)
.fetch_all(&self.pool)
.await?;
fn entry_for(
map: &mut std::collections::BTreeMap<String, PartnerEarningsByCurrency>,
currency: String,
) -> &mut PartnerEarningsByCurrency {
map.entry(currency.clone())
.or_insert_with(|| PartnerEarningsByCurrency {
currency,
total_earned: 0,
total_withdrawn: 0,
pending_withdrawal: 0,
available: 0,
})
}
let mut by_currency: std::collections::BTreeMap<String, PartnerEarningsByCurrency> =
std::collections::BTreeMap::new();
for (currency, amount) in earned_rows {
entry_for(&mut by_currency, currency).total_earned = amount;
}
for (currency, amount) in withdrawn_rows {
entry_for(&mut by_currency, currency).total_withdrawn = amount;
}
for (currency, amount) in pending_rows {
entry_for(&mut by_currency, currency).pending_withdrawal = amount;
}
Ok(by_currency
.into_values()
.map(|mut e| {
e.available = e.total_earned - e.total_withdrawn - e.pending_withdrawal;
e
})
.collect())
}
#[instrument(skip(self))]
async fn create_withdrawal_request(
&self,
partner_id: Uuid,
currency: &str,
amount: i64,
note: Option<&str>,
) -> Result<WithdrawalRequest, sqlx::Error> {
sqlx::query_as::<_, WithdrawalRequest>(
"INSERT INTO withdrawal_requests (id, partner_id, currency, amount, note) \
VALUES ($1, $2, $3, $4, $5) \
RETURNING id, partner_id, currency, amount, status, note, created_at, processed_at, processed_by",
)
.bind(Uuid::new_v4())
.bind(partner_id)
.bind(currency)
.bind(amount)
.bind(note)
.fetch_one(&self.pool)
.await
}
#[instrument(skip(self))]
async fn list_withdrawal_requests(
&self,
status_filter: Option<&str>,
) -> Result<Vec<WithdrawalRequest>, sqlx::Error> {
match status_filter {
Some(status) => {
sqlx::query_as::<_, WithdrawalRequest>(
"SELECT id, partner_id, currency, amount, status, note, created_at, processed_at, processed_by \
FROM withdrawal_requests WHERE status = $1 ORDER BY created_at DESC",
)
.bind(status)
.fetch_all(&self.pool)
.await
}
None => {
sqlx::query_as::<_, WithdrawalRequest>(
"SELECT id, partner_id, currency, amount, status, note, created_at, processed_at, processed_by \
FROM withdrawal_requests ORDER BY (status = 'pending') DESC, created_at DESC",
)
.fetch_all(&self.pool)
.await
}
}
}
#[instrument(skip(self))]
async fn list_withdrawal_requests_for_partner(
&self,
partner_id: Uuid,
) -> Result<Vec<WithdrawalRequest>, sqlx::Error> {
sqlx::query_as::<_, WithdrawalRequest>(
"SELECT id, partner_id, currency, amount, status, note, created_at, processed_at, processed_by \
FROM withdrawal_requests WHERE partner_id = $1 ORDER BY created_at DESC",
)
.bind(partner_id)
.fetch_all(&self.pool)
.await
}
#[instrument(skip(self))]
async fn update_withdrawal_status(
&self,
id: Uuid,
new_status: &str,
processed_by: Uuid,
) -> Result<Option<WithdrawalRequest>, sqlx::Error> {
sqlx::query_as::<_, WithdrawalRequest>(
"UPDATE withdrawal_requests SET status = $1, processed_at = NOW(), processed_by = $2 \
WHERE id = $3 \
RETURNING id, partner_id, currency, amount, status, note, created_at, processed_at, processed_by",
)
.bind(new_status)
.bind(processed_by)
.bind(id)
.fetch_optional(&self.pool)
.await
}
// =====================================================================
// VPN УЗЛЫ (CONTROL PLANE)
// =====================================================================