Ролевая админка (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:
@@ -0,0 +1,215 @@
|
||||
//! HTTP-роуты ролевой админки. Named-guard'ы (`owner_guard`/`staff_guard`/
|
||||
//! `partner_guard`) уже проверяют роль до входа в хендлер — сами хендлеры
|
||||
//! читают текущего юзера только через `Extension<User>`.
|
||||
|
||||
use axum::{
|
||||
extract::{Extension, Path, State},
|
||||
middleware,
|
||||
routing::{get, patch, post},
|
||||
Json, Router,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
api::ApiState,
|
||||
db::models::User,
|
||||
error::AppError,
|
||||
modules::auth::{auth_guard, owner_guard, partner_guard, staff_guard},
|
||||
};
|
||||
|
||||
/// Любой залогиненный видит свою роль/права — нужно фронту, чтобы решить,
|
||||
/// что показать и не пора ли редиректить на `/admin/login`.
|
||||
pub fn router(state: ApiState) -> Router<ApiState> {
|
||||
Router::new()
|
||||
.route("/me", get(get_my_role))
|
||||
.route_layer(middleware::from_fn_with_state(state, auth_guard))
|
||||
}
|
||||
|
||||
/// Только Owner: создание модераторов, выдача им права на управление нодами.
|
||||
pub fn owner_router(state: ApiState) -> Router<ApiState> {
|
||||
Router::new()
|
||||
.route("/moderators", post(create_moderator))
|
||||
.route("/users/{id}/permissions", patch(set_permissions))
|
||||
.route_layer(middleware::from_fn_with_state(state.clone(), owner_guard))
|
||||
.route_layer(middleware::from_fn_with_state(state, auth_guard))
|
||||
}
|
||||
|
||||
/// Owner + Moderator: создание партнёров, список staff-юзеров, заявки на вывод.
|
||||
pub fn staff_router(state: ApiState) -> Router<ApiState> {
|
||||
Router::new()
|
||||
.route("/partners", post(create_partner))
|
||||
.route("/staff-users", get(list_staff))
|
||||
.route("/withdrawals", get(list_withdrawals))
|
||||
.route("/withdrawals/{id}", patch(update_withdrawal))
|
||||
.route_layer(middleware::from_fn_with_state(state.clone(), staff_guard))
|
||||
.route_layer(middleware::from_fn_with_state(state, auth_guard))
|
||||
}
|
||||
|
||||
/// Только Partner: свой заработок и заявки на вывод.
|
||||
pub fn partner_router(state: ApiState) -> Router<ApiState> {
|
||||
Router::new()
|
||||
.route("/withdrawals", post(request_withdrawal))
|
||||
.route("/earnings", get(get_my_earnings))
|
||||
.route_layer(middleware::from_fn_with_state(state.clone(), partner_guard))
|
||||
.route_layer(middleware::from_fn_with_state(state, auth_guard))
|
||||
}
|
||||
|
||||
async fn get_my_role(Extension(user): Extension<User>) -> Json<Value> {
|
||||
Json(json!({
|
||||
"role": user.role,
|
||||
"can_manage_nodes": user.can_manage_nodes,
|
||||
"username": user.username,
|
||||
"partner_code": user.partner_code,
|
||||
"commission_percent": user.commission_percent,
|
||||
}))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct CreateModeratorPayload {
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
pub can_manage_nodes: bool,
|
||||
}
|
||||
|
||||
async fn create_moderator(
|
||||
State(state): State<ApiState>,
|
||||
Json(p): Json<CreateModeratorPayload>,
|
||||
) -> Result<Json<Value>, AppError> {
|
||||
let user = state
|
||||
.staff_service
|
||||
.create_moderator(&p.username, &p.password, p.can_manage_nodes)
|
||||
.await?;
|
||||
Ok(Json(json!({
|
||||
"id": user.id,
|
||||
"username": user.username,
|
||||
"role": user.role,
|
||||
"can_manage_nodes": user.can_manage_nodes,
|
||||
})))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct CreatePartnerPayload {
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
pub commission_percent: rust_decimal::Decimal,
|
||||
}
|
||||
|
||||
async fn create_partner(
|
||||
State(state): State<ApiState>,
|
||||
Json(p): Json<CreatePartnerPayload>,
|
||||
) -> Result<Json<Value>, AppError> {
|
||||
let user = state
|
||||
.staff_service
|
||||
.create_partner(&p.username, &p.password, p.commission_percent)
|
||||
.await?;
|
||||
Ok(Json(json!({
|
||||
"id": user.id,
|
||||
"username": user.username,
|
||||
"partner_code": user.partner_code,
|
||||
"commission_percent": user.commission_percent,
|
||||
})))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct SetPermissionsPayload {
|
||||
pub can_manage_nodes: bool,
|
||||
}
|
||||
|
||||
async fn set_permissions(
|
||||
State(state): State<ApiState>,
|
||||
Path(id): Path<Uuid>,
|
||||
Json(p): Json<SetPermissionsPayload>,
|
||||
) -> Result<Json<Value>, AppError> {
|
||||
let user = state
|
||||
.staff_service
|
||||
.set_can_manage_nodes(id, p.can_manage_nodes)
|
||||
.await?
|
||||
.ok_or(AppError::UserNotFound)?;
|
||||
Ok(Json(json!({
|
||||
"id": user.id,
|
||||
"can_manage_nodes": user.can_manage_nodes,
|
||||
})))
|
||||
}
|
||||
|
||||
async fn list_staff(State(state): State<ApiState>) -> Result<Json<Vec<Value>>, AppError> {
|
||||
let users = state.staff_service.list_staff().await?;
|
||||
Ok(Json(
|
||||
users
|
||||
.into_iter()
|
||||
.map(|u| {
|
||||
json!({
|
||||
"id": u.id,
|
||||
"username": u.username,
|
||||
"role": u.role,
|
||||
"can_manage_nodes": u.can_manage_nodes,
|
||||
"commission_percent": u.commission_percent,
|
||||
"partner_code": u.partner_code,
|
||||
})
|
||||
})
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
|
||||
async fn list_withdrawals(State(state): State<ApiState>) -> Result<Json<Value>, AppError> {
|
||||
let list = state.staff_service.list_all_withdrawals().await?;
|
||||
Ok(Json(json!(list)))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct UpdateWithdrawalPayload {
|
||||
pub status: String,
|
||||
}
|
||||
|
||||
async fn update_withdrawal(
|
||||
Extension(user): Extension<User>,
|
||||
State(state): State<ApiState>,
|
||||
Path(id): Path<Uuid>,
|
||||
Json(p): Json<UpdateWithdrawalPayload>,
|
||||
) -> Result<Json<Value>, AppError> {
|
||||
if !["approved", "rejected", "paid"].contains(&p.status.as_str()) {
|
||||
return Err(AppError::BusinessLogic("Недопустимый статус.".into()));
|
||||
}
|
||||
let req = state
|
||||
.staff_service
|
||||
.update_withdrawal(id, &p.status, user.id)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound("Заявка не найдена".into()))?;
|
||||
Ok(Json(json!(req)))
|
||||
}
|
||||
|
||||
async fn get_my_earnings(
|
||||
Extension(user): Extension<User>,
|
||||
State(state): State<ApiState>,
|
||||
) -> Result<Json<Value>, AppError> {
|
||||
let earnings = state.staff_service.get_earnings(user.id).await?;
|
||||
let withdrawals = state
|
||||
.staff_service
|
||||
.list_withdrawals_for_partner(user.id)
|
||||
.await?;
|
||||
Ok(Json(json!({
|
||||
"earnings": earnings,
|
||||
"withdrawals": withdrawals,
|
||||
"partner_code": user.partner_code,
|
||||
})))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct RequestWithdrawalPayload {
|
||||
pub currency: String,
|
||||
pub amount: i64,
|
||||
pub note: Option<String>,
|
||||
}
|
||||
|
||||
async fn request_withdrawal(
|
||||
Extension(user): Extension<User>,
|
||||
State(state): State<ApiState>,
|
||||
Json(p): Json<RequestWithdrawalPayload>,
|
||||
) -> Result<Json<Value>, AppError> {
|
||||
let req = state
|
||||
.staff_service
|
||||
.request_withdrawal(user.id, &p.currency, p.amount, p.note.as_deref())
|
||||
.await?;
|
||||
Ok(Json(json!(req)))
|
||||
}
|
||||
Reference in New Issue
Block a user