91e214dad2
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>
252 lines
8.3 KiB
Rust
252 lines
8.3 KiB
Rust
//! 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)))
|
||
}
|