From 1b5ee4ad4a08fab0b6b0713377b4f5fbdf2ad18f Mon Sep 17 00:00:00 2001 From: nutcas3 Date: Sat, 25 Apr 2026 01:27:51 +0300 Subject: [PATCH] feat: Add rating plan management, user blocking, and system stats API endpoints - Add GET/POST /v1/rating-plans and GET/DELETE /v1/rating-plans/:id endpoints for rating plan CRUD operations - Add POST /v1/block/:ip, POST /v1/unblock/:ip, and GET /v1/blocked/:ip endpoints for user blocking management - Add GET /v1/stats endpoint to retrieve system statistics (accounts/sessions/blocked users/alerts/uptime) - Add delete to routing imports and remove unused IntoResponse/warn imports - Add RatingPlan --- apps/charging-engine/src/api.rs | 155 +++++++++++++++++++++++++++++++- 1 file changed, 152 insertions(+), 3 deletions(-) diff --git a/apps/charging-engine/src/api.rs b/apps/charging-engine/src/api.rs index ceb7d3b..da61b16 100644 --- a/apps/charging-engine/src/api.rs +++ b/apps/charging-engine/src/api.rs @@ -1,11 +1,10 @@ use axum::{ extract::{Path, State}, - response::IntoResponse, - routing::{get, post}, + routing::{delete, get, post}, Json, Router, }; use tower_http::cors::{Any, CorsLayer}; -use tracing::{info, warn}; +use tracing::info; use crate::errors::{ChargingError, ChargingResult, validate_ip, validate_bytes, ErrorContext, log_error, validate_amount, validate_session_id}; use crate::models::*; @@ -28,6 +27,14 @@ pub fn create_router(state: AppState) -> Router { .route("/v1/credit/:ip/balance", get(get_balance)) .route("/v1/subscriber/:imsi", get(get_subscriber)) .route("/v1/usage", post(record_usage)) + .route("/v1/rating-plans", get(list_rating_plans)) + .route("/v1/rating-plans/:id", get(get_rating_plan)) + .route("/v1/rating-plans", post(add_rating_plan)) + .route("/v1/rating-plans/:id", delete(remove_rating_plan)) + .route("/v1/block/:ip", post(block_user)) + .route("/v1/unblock/:ip", post(unblock_user)) + .route("/v1/blocked/:ip", get(is_user_blocked)) + .route("/v1/stats", get(get_system_stats)) .route("/health", get(health_check)) .layer(cors) .with_state(state) @@ -196,3 +203,145 @@ pub async fn record_usage( pub async fn health_check() -> ChargingResult> { Ok(Json(HealthResponse::default())) } + +/// GET /v1/rating-plans +/// List all rating plans +pub async fn list_rating_plans( + State(state): State, +) -> ChargingResult> { + let plans = state.charging_engine.list_rating_plans().await + .with_context("Failed to list rating plans")?; + + Ok(Json(serde_json::json!({ + "plans": plans + }))) +} + +/// GET /v1/rating-plans/:id +/// Get a specific rating plan +pub async fn get_rating_plan( + Path(id): Path, + State(state): State, +) -> ChargingResult> { + let plan = state.charging_engine.get_rating_plan(&id).await + .with_context("Failed to get rating plan")?; + + match plan { + Some(p) => Ok(Json(serde_json::json!({ + "id": p.plan_id, + "name": p.name, + "data_rate": p.data_rate, + "voice_rate": p.voice_rate, + "sms_rate": p.sms_rate, + "monthly_fee": p.monthly_fee, + "data_limit": p.data_limit, + "voice_limit": p.voice_limit, + "sms_limit": p.sms_limit, + }))), + None => Err(ChargingError::RatingPlanNotFound(id)), + } +} + +/// POST /v1/rating-plans +/// Add a new rating plan +pub async fn add_rating_plan( + State(state): State, + Json(req): Json, +) -> ChargingResult> { + let plan = crate::charging::types::RatingPlan { + plan_id: req.get("plan_id").and_then(|v| v.as_str()).unwrap_or("default").to_string(), + name: req.get("name").and_then(|v| v.as_str()).unwrap_or("Default").to_string(), + data_rate: req.get("data_rate").and_then(|v| v.as_f64()).unwrap_or(0.001), + voice_rate: req.get("voice_rate").and_then(|v| v.as_f64()).unwrap_or(0.01), + sms_rate: req.get("sms_rate").and_then(|v| v.as_f64()).unwrap_or(0.1), + monthly_fee: req.get("monthly_fee").and_then(|v| v.as_f64()).unwrap_or(10.0), + data_limit: req.get("data_limit").and_then(|v| v.as_u64()).unwrap_or(1_000_000_000), + voice_limit: req.get("voice_limit").and_then(|v| v.as_u64()).unwrap_or(1000), + sms_limit: req.get("sms_limit").and_then(|v| v.as_u64()).unwrap_or(100), + }; + + state.charging_engine.add_rating_plan(plan.clone()).await + .with_context("Failed to add rating plan")?; + + Ok(Json(serde_json::json!({ + "status": "added", + "plan_id": plan.plan_id, + }))) +} + +/// DELETE /v1/rating-plans/:id +/// Remove a rating plan +pub async fn remove_rating_plan( + Path(id): Path, + State(state): State, +) -> ChargingResult> { + let removed = state.charging_engine.remove_rating_plan(&id).await + .with_context("Failed to remove rating plan")?; + + Ok(Json(serde_json::json!({ + "status": if removed { "removed" } else { "not_found" }, + "plan_id": id, + }))) +} + +/// POST /v1/block/:ip +/// Block a user +pub async fn block_user( + Path(ip): Path, + State(state): State, +) -> ChargingResult> { + state.charging_engine.block_user(&ip).await + .with_context("Failed to block user")?; + + Ok(Json(serde_json::json!({ + "status": "blocked", + "ip": ip, + }))) +} + +/// POST /v1/unblock/:ip +/// Unblock a user +pub async fn unblock_user( + Path(ip): Path, + State(state): State, +) -> ChargingResult> { + state.charging_engine.unblock_user(&ip).await + .with_context("Failed to unblock user")?; + + Ok(Json(serde_json::json!({ + "status": "unblocked", + "ip": ip, + }))) +} + +/// GET /v1/blocked/:ip +/// Check if user is blocked +pub async fn is_user_blocked( + Path(ip): Path, + State(state): State, +) -> ChargingResult> { + let blocked = state.charging_engine.is_user_blocked(&ip).await + .with_context("Failed to check blocked status")?; + + Ok(Json(serde_json::json!({ + "ip": ip, + "blocked": blocked, + }))) +} + +/// GET /v1/stats +/// Get system statistics +pub async fn get_system_stats( + State(state): State, +) -> ChargingResult> { + let stats = state.charging_engine.get_system_statistics().await + .with_context("Failed to get system statistics")?; + + Ok(Json(serde_json::json!({ + "total_accounts": stats.total_accounts, + "active_sessions": stats.active_sessions, + "blocked_users": stats.blocked_users, + "low_balance_alerts": stats.low_balance_alerts, + "uptime": stats.uptime, + }))) +}