Files
netrunner-backend/src/modules/staff/controller.rs
T
nineap 91e214dad2 Единый мониторинг: бизнес-метрики + вынос Prometheus/Loki/Grafana на VPS с данными
Prometheus/Loki/Grafana больше не живут внутри backend-стека — единый
стек теперь на VPS с данными (netrunner-data), т.к. ему нужно видеть
ещё лендинг и все прокси-ноды, не только бэкенд. Отсюда только тонкий
promtail, пушащий логи по сети (LOKI_PUSH_URL).

Новые бизнес-метрики поверх уже существующих generic HTTP-метрик:
payments_total{provider,status}, nodes_online/nodes_total,
support_tickets_total{status}, referral_signups_total,
partner_commission_total{currency} — тем же макросом metrics::counter!/
gauge!, что уже был в api.rs/error.rs.

Новый гейт-эндпоинт GET /api/v1/admin/observability/check (только
Owner/Moderator) — для Caddy forward_auth перед Grafana (см.
netrunner-data/observability/Caddyfile), встройка в админку через
GRAFANA_PUBLIC_URL в GET /api/v1/config.

templates/init_node.sh — добавляет --metrics-port 9093 в провижининг
новых прокси-нод.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-09 23:20:44 +07:00

252 lines
8.3 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! HTTP-роуты ролевой админки. Named-guard'ы (`owner_guard`/`staff_guard`/
//! `partner_guard`) уже проверяют роль до входа в хендлер — сами хендлеры
//! читают текущего юзера только через `Extension<User>`.
use axum::{
extract::{Extension, Path, State},
http::StatusCode,
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/Moderator — гейт для Caddy `forward_auth` перед Grafana
/// (см. netrunner-data/observability/Caddyfile). Тело ответа не важно, важен
/// только статус: 200 — Caddy пускает дальше в Grafana, 401 — блокирует.
/// Бизнес-метрики (выручка, число юзеров) чувствительны — Support/Partner
/// сюда не допускаются, поэтому `staff_guard`, а не `support_access_guard`.
pub fn observability_router(state: ApiState) -> Router<ApiState> {
Router::new()
.route("/check", get(|| async { StatusCode::OK }))
.route_layer(middleware::from_fn_with_state(state.clone(), staff_guard))
.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("/support", post(create_support))
.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 CreateSupportPayload {
pub username: String,
pub password: String,
}
async fn create_support(
State(state): State<ApiState>,
Json(p): Json<CreateSupportPayload>,
) -> Result<Json<Value>, AppError> {
let user = state
.staff_service
.create_support(&p.username, &p.password)
.await?;
Ok(Json(json!({
"id": user.id,
"username": user.username,
"role": user.role,
})))
}
#[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)))
}