Region-aware pricing: GeoIP + VPN-детект + публичный /plans + бот
Build & Push Image / Build and push Caddy (Cloudflare DNS) image (push) Successful in 26s
Build & Push Image / Build and push to Gitea Registry (push) Successful in 9m36s
CI / fmt + clippy + test (push) Failing after 24m37s

- modules::geoip — DB-IP Lite (.mmdb, без регистрации/ключа, в отличие от
  MaxMind) для страны по IP, X4BNet lists_vpn для грубой VPN/датацентр-
  эвристики. Обе базы скачиваются в рантайме, обновление — фоновая
  задача в tasks.rs (раз в сутки по умолчанию, GEOIP_REFRESH_INTERVAL).
- GET /api/v1/billing/plans (public_router, без auth_guard) — цены в
  валюте, определённой по IP посетителя (X-Real-IP/X-Forwarded-For,
  см. nginx-конфиги). VPN/датацентр IP -> всегда базовый USD, чтобы
  обход региона через любой публичный VPN не обесценивал скидки.
- Бот: Bot API не отдаёт IP пользователя вообще — валюта определяется по
  Telegram language_code. Показ цены — в валюте региона, САМА оплата
  через CryptoBot остаётся в USD/USDT (провайдер биллит 1:1 к USD-цене
  тарифа, менять валюту списания нельзя, не меняя сам провайдер).
This commit is contained in:
2026-07-11 00:55:03 +07:00
parent a89721fbee
commit d91aef012e
15 changed files with 557 additions and 13 deletions
+79 -3
View File
@@ -1,14 +1,21 @@
use axum::{
extract::{Extension, State},
extract::{ConnectInfo, Extension, State},
http::HeaderMap,
middleware,
routing::post,
routing::{get, post},
Json, Router,
};
use serde::Deserialize;
use serde_json::{json, Value};
use std::net::{IpAddr, SocketAddr};
use uuid::Uuid;
use crate::{api::ApiState, db::models::User, error::AppError, modules::auth::guard::auth_guard};
use crate::{
api::ApiState,
db::models::User,
error::AppError,
modules::{auth::guard::auth_guard, geoip::currency::currency_for_country},
};
#[derive(Deserialize)]
pub struct InitiatePaymentPayload {
@@ -23,6 +30,13 @@ pub struct ConfirmPaymentPayload {
pub external_tx_id: String,
}
/// Без auth_guard — цены должны быть видны анонимному посетителю сайта
/// (см. netrunner-landing/components/sections/Pricing.tsx), не только
/// залогиненным.
pub fn public_router() -> Router<ApiState> {
Router::new().route("/plans", get(list_plans_api))
}
pub fn router(state: ApiState) -> Router<ApiState> {
Router::new()
.route("/pay/initiate", post(initiate_payment_api))
@@ -31,6 +45,68 @@ pub fn router(state: ApiState) -> Router<ApiState> {
.route_layer(middleware::from_fn_with_state(state, auth_guard))
}
/// IP реального посетителя — из `X-Real-IP` (его ставит nginx на проде, см.
/// netrunner-data/nginx/*, netrunner-backend/nginx/*, восстановлен из
/// `CF-Connecting-IP` через `set_real_ip_from`/`real_ip_header`), иначе
/// `X-Forwarded-For` (первый адрес в цепочке), иначе — реальный TCP-пир
/// (для локальной разработки без nginx впереди).
fn extract_client_ip(headers: &HeaderMap, connect_info: &SocketAddr) -> IpAddr {
if let Some(real_ip) = headers
.get("x-real-ip")
.and_then(|v| v.to_str().ok())
.and_then(|s| s.trim().parse().ok())
{
return real_ip;
}
if let Some(forwarded) = headers
.get("x-forwarded-for")
.and_then(|v| v.to_str().ok())
.and_then(|s| s.split(',').next())
.and_then(|s| s.trim().parse().ok())
{
return forwarded;
}
connect_info.ip()
}
/// Тарифы в валюте, определённой по региону посетителя. Если IP похож на
/// VPN/датацентр (см. `geoip_service.is_vpn`) — намеренно НЕ используем
/// региональную цену, отдаём базовую USD (иначе покупка "из региона" через
/// любой публичный VPN обесценивала бы всю схему региональных скидок).
async fn list_plans_api(
State(state): State<ApiState>,
headers: HeaderMap,
ConnectInfo(connect_info): ConnectInfo<SocketAddr>,
) -> Result<Json<Value>, AppError> {
let ip = extract_client_ip(&headers, &connect_info);
let is_vpn = state.geoip_service.is_vpn(ip).await;
let country = state.geoip_service.country_for_ip(ip).await;
let currency = if is_vpn {
"USD"
} else {
country
.as_deref()
.and_then(currency_for_country)
.unwrap_or("USD")
};
let (resolved_currency, plans) = state
.billing_service
.list_plans_in_currency(currency)
.await?;
Ok(Json(json!({
"currency": resolved_currency,
"detected_country": country,
"vpn_detected": is_vpn,
"plans": plans.into_iter().map(|(name, amount)| json!({
"plan": name,
"amount": amount,
})).collect::<Vec<_>>(),
})))
}
/// Шаг 1: Запрашиваем у провайдера данные для оплаты (адрес, ссылку и т.д.)
async fn initiate_payment_api(
Extension(user): Extension<User>,