Region-aware pricing: GeoIP + VPN-детект + публичный /plans + бот
- 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:
@@ -89,6 +89,22 @@ WEB_APP_BASE_URL=https://account.netrunner-vpn.com
|
||||
# (NodeService::provision_node_via_ssh) и в .env.example netrunner-proxy.
|
||||
PROXY_INTERNAL_SECRET=CHANGE_ME_PROXY_INTERNAL_SECRET_openssl_rand_hex_32
|
||||
|
||||
# =====================================================================
|
||||
# === РЕГИОНАЛЬНЫЕ ЦЕНЫ: GEOIP + VPN-ДЕТЕКТ (см. modules::geoip) ===
|
||||
# =====================================================================
|
||||
# Все три — опциональны, дефолты в main.rs указывают прямо на публичные
|
||||
# раздачи, ничего заполнять не обязательно для старта:
|
||||
# GEOIP_DB_URL_TEMPLATE — DB-IP Lite (.mmdb.gz, тот же формат, что
|
||||
# MaxMind, но без регистрации/лицензионного ключа), "{}" — плейсхолдер
|
||||
# под "YYYY-MM" (у DB-IP имя файла с датой, единой "latest"-ссылки нет).
|
||||
# VPN_LIST_URL — X4BNet lists_vpn, открытый список CIDR-диапазонов
|
||||
# известных VPN/датацентров (эвристика, не 100% точная — см. их README).
|
||||
# GEOIP_REFRESH_INTERVAL — как часто (сек) перекачивать обе базы,
|
||||
# по умолчанию раз в сутки (86400).
|
||||
# GEOIP_DB_URL_TEMPLATE=https://download.db-ip.com/free/dbip-country-lite-{}.mmdb.gz
|
||||
# VPN_LIST_URL=https://raw.githubusercontent.com/X4BNet/lists_vpn/main/output/vpn/ipv4.txt
|
||||
# GEOIP_REFRESH_INTERVAL=86400
|
||||
|
||||
# =====================================================================
|
||||
# === APPLICATION RUNTIME SETTINGS ===
|
||||
# =====================================================================
|
||||
|
||||
Generated
+65
@@ -2,6 +2,12 @@
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "adler2"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||
|
||||
[[package]]
|
||||
name = "ahash"
|
||||
version = "0.7.8"
|
||||
@@ -649,6 +655,15 @@ version = "2.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5"
|
||||
|
||||
[[package]]
|
||||
name = "crc32fast"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-epoch"
|
||||
version = "0.9.18"
|
||||
@@ -1056,6 +1071,16 @@ version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
|
||||
|
||||
[[package]]
|
||||
name = "flate2"
|
||||
version = "1.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"miniz_oxide",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "flume"
|
||||
version = "0.11.1"
|
||||
@@ -1856,6 +1881,15 @@ version = "2.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
|
||||
|
||||
[[package]]
|
||||
name = "ipnetwork"
|
||||
version = "0.20.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bf466541e9d546596ee94f9f69590f89473455f88372423e0008fc1a7daf100e"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iri-string"
|
||||
version = "0.7.12"
|
||||
@@ -2055,6 +2089,18 @@ version = "0.8.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
|
||||
|
||||
[[package]]
|
||||
name = "maxminddb"
|
||||
version = "0.24.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d6087e5d8ea14861bb7c7f573afbc7be3798d3ef0fae87ec4fd9a4de9a127c3c"
|
||||
dependencies = [
|
||||
"ipnetwork",
|
||||
"log",
|
||||
"memchr",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "md-5"
|
||||
version = "0.10.6"
|
||||
@@ -2135,6 +2181,16 @@ dependencies = [
|
||||
"unicase",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "miniz_oxide"
|
||||
version = "0.8.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
|
||||
dependencies = [
|
||||
"adler2",
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mio"
|
||||
version = "1.2.0"
|
||||
@@ -2173,9 +2229,12 @@ dependencies = [
|
||||
"chrono",
|
||||
"clap",
|
||||
"dotenvy",
|
||||
"flate2",
|
||||
"hex",
|
||||
"hmac 0.13.0",
|
||||
"ipnet",
|
||||
"jsonwebtoken",
|
||||
"maxminddb",
|
||||
"metrics",
|
||||
"metrics-exporter-prometheus",
|
||||
"rand 0.8.6",
|
||||
@@ -3451,6 +3510,12 @@ dependencies = [
|
||||
"rand_core 0.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "simd-adler32"
|
||||
version = "0.3.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
|
||||
|
||||
[[package]]
|
||||
name = "simdutf8"
|
||||
version = "0.1.5"
|
||||
|
||||
@@ -40,6 +40,9 @@ argon2 = "0.5"
|
||||
rpassword = "7.5"
|
||||
rust_decimal = { version = "1.42.0", features = ["serde"] }
|
||||
reqwest = { version = "0.11", features = ["json"] }
|
||||
maxminddb = "0.24"
|
||||
ipnet = "2.10"
|
||||
flate2 = "1.0"
|
||||
|
||||
[profile.release]
|
||||
opt-level = 3
|
||||
|
||||
+11
-1
@@ -28,6 +28,7 @@ use crate::db::repository::AppRepository;
|
||||
use crate::modules::{
|
||||
auth::{controller as auth_ctrl, service::AuthService},
|
||||
billing::{controller as bill_ctrl, service::BillingService},
|
||||
geoip::service::GeoipService,
|
||||
nodes::{controller as node_ctrl, service::NodeService},
|
||||
proxy_internal,
|
||||
referrals::{controller as ref_ctrl, service::ReferralService},
|
||||
@@ -45,6 +46,11 @@ pub struct ApiState {
|
||||
pub user_service: UserService,
|
||||
pub staff_service: StaffService,
|
||||
pub support_service: SupportService,
|
||||
/// Страна-по-IP + грубая VPN/датацентр-эвристика — определяет валюту
|
||||
/// региональной цены в `GET /plans` (см. modules::geoip). Не используется
|
||||
/// в боте (Telegram Bot API не отдаёт IP пользователя вообще) — там
|
||||
/// валюта определяется по `language_code`, см. bot.rs.
|
||||
pub geoip_service: GeoipService,
|
||||
pub jwt_secret: String,
|
||||
pub bot_token: String,
|
||||
/// Юзернейм бота (без `@`) — отдаётся фронту через `GET /api/v1/config`.
|
||||
@@ -87,6 +93,7 @@ impl Clone for ApiState {
|
||||
user_service: self.user_service.clone(),
|
||||
staff_service: self.staff_service.clone(),
|
||||
support_service: self.support_service.clone(),
|
||||
geoip_service: self.geoip_service.clone(),
|
||||
jwt_secret: self.jwt_secret.clone(),
|
||||
bot_token: self.bot_token.clone(),
|
||||
bot_username: self.bot_username.clone(),
|
||||
@@ -135,7 +142,10 @@ pub fn create_router(state: ApiState, frontend_dir: &str) -> Router {
|
||||
auth_ctrl::router().layer(GovernorLayer::new(seed_limit_conf)),
|
||||
)
|
||||
.nest("/users", user_ctrl::router(state.clone()))
|
||||
.nest("/billing", bill_ctrl::router(state.clone()))
|
||||
.nest(
|
||||
"/billing",
|
||||
bill_ctrl::router(state.clone()).merge(bill_ctrl::public_router()),
|
||||
)
|
||||
.nest("/nodes", node_ctrl::public_router(state.clone()))
|
||||
.nest("/referrals", ref_ctrl::router(state.clone()))
|
||||
.route("/config", get(get_public_config))
|
||||
|
||||
+31
-5
@@ -208,6 +208,12 @@ async fn handle_callbacks(
|
||||
support: SupportService,
|
||||
config: BotConfig,
|
||||
) -> ResponseResult<()> {
|
||||
// Единственный доступный сигнал региона в боте — Bot API не отдаёт IP
|
||||
// пользователя вообще (в отличие от сайта, см.
|
||||
// billing::controller::list_plans_api). Грубее геолокации (язык клиента
|
||||
// Telegram ≠ страна — диаспора, мультиязычные регионы), но лучше, чем
|
||||
// показывать всем только USD.
|
||||
let lang_code = q.from.language_code.clone();
|
||||
if let (Some(data), Some(msg)) = (q.data, q.message) {
|
||||
let tg_id = q.from.id.0 as i64;
|
||||
info!(callback_data = %data, "Пользователь нажал кнопку в боте");
|
||||
@@ -292,17 +298,37 @@ async fn handle_callbacks(
|
||||
)
|
||||
.await,
|
||||
// Оплата криптой прямо в чате, без WebApp/кошелька — юзер платит
|
||||
// внутри @CryptoBot, возвращается и жмёт "Я оплатил".
|
||||
"menu_pay_crypto" => match billing.list_plans_usd().await {
|
||||
Ok(plans) if !plans.is_empty() => {
|
||||
// внутри @CryptoBot, возвращается и жмёт "Я оплатил". Показываем
|
||||
// цену в валюте региона (по language_code, см. выше) для
|
||||
// наглядности — САМА оплата остаётся в USD/USDT (см.
|
||||
// pay_plan-ветку ниже): CryptoBotProvider биллит 1:1 к
|
||||
// USD-цене тарифа, менять валюту списания нельзя, иначе при
|
||||
// показе, скажем, VND юзер спишет в разы больше/меньше нужного.
|
||||
"menu_pay_crypto" => match billing
|
||||
.list_plans_in_currency(
|
||||
lang_code
|
||||
.as_deref()
|
||||
.and_then(crate::modules::geoip::currency::currency_for_telegram_lang)
|
||||
.unwrap_or("USD"),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok((display_currency, plans)) if !plans.is_empty() => {
|
||||
let rows = plans
|
||||
.chunks(1)
|
||||
.map(|chunk| {
|
||||
chunk
|
||||
.iter()
|
||||
.map(|(name, cents)| {
|
||||
.map(|(name, amount)| {
|
||||
InlineKeyboardButton::callback(
|
||||
format!("{} — ${:.2}", name, *cents as f64 / 100.0),
|
||||
format!(
|
||||
"{} — {}",
|
||||
name,
|
||||
crate::modules::geoip::currency::format_amount(
|
||||
&display_currency,
|
||||
*amount
|
||||
)
|
||||
),
|
||||
format!("pay_plan:{}", name),
|
||||
)
|
||||
})
|
||||
|
||||
+37
-3
@@ -17,8 +17,8 @@ use netrunner_control_plane::api::{create_router, ApiState};
|
||||
use netrunner_control_plane::bot;
|
||||
use netrunner_control_plane::db::repository::{AppRepository, PgRepository};
|
||||
use netrunner_control_plane::modules::{
|
||||
auth::service::AuthService, billing::service::BillingService, nodes::service::NodeService,
|
||||
referrals::service::ReferralService, staff::service::StaffService,
|
||||
auth::service::AuthService, billing::service::BillingService, geoip::service::GeoipService,
|
||||
nodes::service::NodeService, referrals::service::ReferralService, staff::service::StaffService,
|
||||
support::service::SupportService, users::service::UserService,
|
||||
};
|
||||
use netrunner_control_plane::tasks;
|
||||
@@ -289,6 +289,18 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool.clone()));
|
||||
let cancel_token = CancellationToken::new();
|
||||
|
||||
// DB-IP Lite (не MaxMind — тот же формат .mmdb, но без регистрации/ключа,
|
||||
// см. modules::geoip) + X4BNet lists_vpn (открытый список VPN/датацентр
|
||||
// CIDR-диапазонов). Дефолты указывают прямо на публичные раздачи — можно
|
||||
// переопределить, если когда-нибудь понадобится своё зеркало.
|
||||
let geoip_db_url_template = std::env::var("GEOIP_DB_URL_TEMPLATE").unwrap_or_else(|_| {
|
||||
"https://download.db-ip.com/free/dbip-country-lite-{}.mmdb.gz".to_string()
|
||||
});
|
||||
let vpn_list_url = std::env::var("VPN_LIST_URL").unwrap_or_else(|_| {
|
||||
"https://raw.githubusercontent.com/X4BNet/lists_vpn/main/output/vpn/ipv4.txt".to_string()
|
||||
});
|
||||
let geoip_service = GeoipService::new(geoip_db_url_template, vpn_list_url);
|
||||
|
||||
let state = ApiState {
|
||||
repo: repo.clone(),
|
||||
auth_service: AuthService::new(repo.clone()),
|
||||
@@ -298,6 +310,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
user_service: UserService::new(repo.clone()),
|
||||
staff_service: StaffService::new(repo.clone()),
|
||||
support_service: SupportService::new(repo.clone()),
|
||||
geoip_service: geoip_service.clone(),
|
||||
jwt_secret,
|
||||
bot_token: bot_token.clone(),
|
||||
bot_username,
|
||||
@@ -348,12 +361,33 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
}
|
||||
});
|
||||
|
||||
// Первая загрузка баз GeoIP/VPN-списка — не блокирует старт HTTP-сервера
|
||||
// (сеть может быть недоступна/медленной на старте контейнера), но
|
||||
// запускается сразу, не через сутки ожидания первого тика в
|
||||
// run_background_tasks.
|
||||
let startup_geoip = geoip_service.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = startup_geoip.refresh_geoip_db().await {
|
||||
tracing::warn!(
|
||||
"GeoIP: первичная загрузка базы страна-по-IP не удалась: {}",
|
||||
e
|
||||
);
|
||||
}
|
||||
if let Err(e) = startup_geoip.refresh_vpn_list().await {
|
||||
tracing::warn!(
|
||||
"GeoIP: первичная загрузка списка VPN-диапазонов не удалась: {}",
|
||||
e
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
let tasks_token = cancel_token.clone();
|
||||
let tasks_billing = state.billing_service.clone();
|
||||
let tasks_repo = repo.clone();
|
||||
let tasks_geoip = geoip_service.clone();
|
||||
let tasks_handle = tokio::spawn(async move {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
|
||||
tasks::run_background_tasks(tasks_billing, tasks_repo, tasks_token).await;
|
||||
tasks::run_background_tasks(tasks_billing, tasks_repo, tasks_geoip, tasks_token).await;
|
||||
});
|
||||
|
||||
// === КООРДИНАТОР АВАРИЙНОГО И СТАНДАРТНОГО ШУТДАУНА ===
|
||||
|
||||
@@ -221,6 +221,10 @@ mod tests {
|
||||
user_service: UserService::new(repo.clone()),
|
||||
staff_service: StaffService::new(repo.clone()),
|
||||
support_service: crate::modules::support::service::SupportService::new(repo.clone()),
|
||||
geoip_service: crate::modules::geoip::service::GeoipService::new(
|
||||
"https://example.invalid/{}.mmdb.gz".into(),
|
||||
"https://example.invalid/vpn.txt".into(),
|
||||
),
|
||||
jwt_secret: "test-secret".into(),
|
||||
bot_token: "test-bot-token".into(),
|
||||
bot_username: "test_bot".into(),
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -110,6 +110,30 @@ impl BillingService {
|
||||
.map_err(AppError::Database)
|
||||
}
|
||||
|
||||
/// Тарифы в конкретной валюте — для `GET /plans` (см.
|
||||
/// `billing::controller::list_plans_api`) и мультивалютного бота
|
||||
/// (`bot.rs`). Если для этой валюты в `plan_prices` нет ни одной активной
|
||||
/// строки (валюта определилась, но регион не заведён — маловероятно,
|
||||
/// т.к. валюта берётся из той же карты, что и миграция, но на всякий
|
||||
/// случай) — тихо фолбэкается на USD, а не отдаёт пустой список.
|
||||
pub async fn list_plans_in_currency(
|
||||
&self,
|
||||
currency: &str,
|
||||
) -> Result<(String, Vec<(String, i64)>), AppError> {
|
||||
if currency != "USD" {
|
||||
let regional = self
|
||||
.repo
|
||||
.list_active_plans_by_currency(currency)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
if !regional.is_empty() {
|
||||
return Ok((currency.to_string(), regional));
|
||||
}
|
||||
}
|
||||
let usd = self.list_plans_usd().await?;
|
||||
Ok(("USD".to_string(), usd))
|
||||
}
|
||||
|
||||
/// Курс TON к валюте из кеша; `None` при промахе/недоступности Redis —
|
||||
/// вызывающий код должен сходить за свежим значением и заполнить кеш сам.
|
||||
async fn get_cached_ton_rate(&self, currency_lower: &str) -> Option<rust_decimal::Decimal> {
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
//! Статические таблицы "страна/язык → валюта". Не БД — список валют вообще
|
||||
//! завязан на то, что реально заведено в `plan_prices` (см. `migrations/
|
||||
//! 0001_init.sql` + `0007_regional_pricing.sql`), т.е. это код, а не
|
||||
//! конфигурация — добавление новой валюты всё равно требует миграции.
|
||||
|
||||
/// ISO 3166-1 alpha-2 страна → код валюты в `plan_prices.currency`.
|
||||
/// Нет записи — значит для этой страны своей региональной цены нет,
|
||||
/// вызывающая сторона обязана фолбэкнуться на `"USD"`.
|
||||
pub fn currency_for_country(country_code: &str) -> Option<&'static str> {
|
||||
match country_code {
|
||||
"RU" => Some("RUB"),
|
||||
"CN" => Some("CNY"),
|
||||
"TR" => Some("TRY"),
|
||||
"IR" => Some("IRR"),
|
||||
"UA" => Some("UAH"),
|
||||
"BY" => Some("BYN"),
|
||||
"VN" => Some("VND"),
|
||||
"UZ" => Some("UZS"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Telegram `language_code` → валюта — единственный доступный сигнал в
|
||||
/// боте (Bot API не отдаёт IP пользователя вообще, см. `mod.rs`). Грубее
|
||||
/// геолокации (язык ≠ страна: диаспора, мультиязычные регионы), но лучше,
|
||||
/// чем everyone-USD.
|
||||
pub fn currency_for_telegram_lang(lang_code: &str) -> Option<&'static str> {
|
||||
// Telegram присылает "ru", "uk", иногда с региональным суффиксом
|
||||
// ("en-US") — берём только первичный подтег.
|
||||
let primary = lang_code.split(['-', '_']).next().unwrap_or(lang_code);
|
||||
match primary {
|
||||
"ru" => Some("RUB"),
|
||||
"zh" => Some("CNY"),
|
||||
"tr" => Some("TRY"),
|
||||
"fa" => Some("IRR"),
|
||||
"uk" => Some("UAH"),
|
||||
"be" => Some("BYN"),
|
||||
"vi" => Some("VND"),
|
||||
"uz" => Some("UZS"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Символ валюты для отображения (бот, потенциально фронт).
|
||||
pub fn currency_symbol(currency: &str) -> &'static str {
|
||||
match currency {
|
||||
"USD" => "$",
|
||||
"RUB" => "₽",
|
||||
"CNY" => "¥",
|
||||
"TRY" => "₺",
|
||||
"IRR" => "﷼",
|
||||
"UAH" => "₴",
|
||||
"BYN" => "Br",
|
||||
"VND" => "₫",
|
||||
"UZS" => "сум",
|
||||
_ => "$",
|
||||
}
|
||||
}
|
||||
|
||||
/// IRR/VND/UZS хранятся в `plan_prices.amount` целыми единицами (не центами/
|
||||
/// копейками) — см. комментарий в migrations/0007_regional_pricing.sql: у
|
||||
/// этих валют нет ходовой разменной монеты, дробные суммы бессмысленны для
|
||||
/// пользователя.
|
||||
fn is_whole_unit_currency(currency: &str) -> bool {
|
||||
matches!(currency, "IRR" | "VND" | "UZS")
|
||||
}
|
||||
|
||||
/// `amount` — как хранится в `plan_prices` (центы/копейки для большинства
|
||||
/// валют, целые единицы для IRR/VND/UZS, см. `is_whole_unit_currency`).
|
||||
/// Возвращает готовую для показа строку с символом валюты, БЕЗ значка
|
||||
/// тысячных разделителей (это I18n-забота фронта/бота, если понадобится).
|
||||
pub fn format_amount(currency: &str, amount: i64) -> String {
|
||||
let symbol = currency_symbol(currency);
|
||||
if is_whole_unit_currency(currency) {
|
||||
format!("{} {}", amount, symbol)
|
||||
} else {
|
||||
format!("{}{:.2}", symbol, amount as f64 / 100.0)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn country_lookup_known_and_unknown() {
|
||||
assert_eq!(currency_for_country("RU"), Some("RUB"));
|
||||
assert_eq!(currency_for_country("UZ"), Some("UZS"));
|
||||
assert_eq!(currency_for_country("US"), None);
|
||||
assert_eq!(currency_for_country(""), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn telegram_lang_strips_region_suffix() {
|
||||
assert_eq!(currency_for_telegram_lang("ru"), Some("RUB"));
|
||||
assert_eq!(currency_for_telegram_lang("ru-RU"), Some("RUB"));
|
||||
assert_eq!(currency_for_telegram_lang("en-US"), None);
|
||||
assert_eq!(currency_for_telegram_lang("en"), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
//! Определение страны по IP (self-hosted DB-IP Lite, формат `.mmdb` — тот же,
|
||||
//! что у MaxMind, но без регистрации/лицензионного ключа, см. README ниже) и
|
||||
//! грубая эвристика "это VPN/датацентр?" по открытому списку CIDR-диапазонов
|
||||
//! (X4BNet `lists_vpn`, тоже без ключей/оплаты).
|
||||
//!
|
||||
//! Обе базы скачиваются в рантайме (см. `service::GeoipService::refresh_*`),
|
||||
//! не коммитятся в репозиторий — только пути/URL в конфиге. Периодическое
|
||||
//! обновление — фоновая задача в `tasks.rs`, не здесь.
|
||||
//!
|
||||
//! Region-based pricing работает только там, где есть IP посетителя (сайт,
|
||||
//! `GET /plans` — см. `modules::billing::controller`) — у Telegram-бота IP
|
||||
//! юзера не видно вообще (ограничение самого Bot API), поэтому там валюта
|
||||
//! определяется по `language_code` (см. `currency::currency_for_telegram_lang`).
|
||||
pub mod currency;
|
||||
pub mod service;
|
||||
@@ -0,0 +1,143 @@
|
||||
//! Держит в памяти два периодически обновляемых источника:
|
||||
//! - `.mmdb`-база страна-по-IP (DB-IP Lite, тот же формат, что MaxMind
|
||||
//! GeoLite2, но без регистрации/лицензионного ключа — просто прямая
|
||||
//! ссылка на скачивание, обновляется у них раз в месяц).
|
||||
//! - список CIDR-диапазонов известных VPN/датацентров (X4BNet `lists_vpn`,
|
||||
//! тоже открытый, без ключей) — грубая эвристика, не идеальная (см. их
|
||||
//! же README), но достаточная для "не давать региональную скидку через
|
||||
//! очевидный VPN", не для полноценного антифрода.
|
||||
//!
|
||||
//! Обновление обеих баз — фоновая периодическая задача (см. `tasks.rs`), не
|
||||
//! часть этого файла: сервис только хранит текущее состояние и умеет его
|
||||
//! перезаписать (`refresh_*`), вызывающая сторона решает, когда дёргать.
|
||||
use flate2::read::GzDecoder;
|
||||
use ipnet::IpNet;
|
||||
use serde::Deserialize;
|
||||
use std::io::Read;
|
||||
use std::net::IpAddr;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
use tracing::{info, warn};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct MmdbCountryLookup<'a> {
|
||||
#[serde(borrow)]
|
||||
country: Option<MmdbCountryCode<'a>>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct MmdbCountryCode<'a> {
|
||||
iso_code: Option<&'a str>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct GeoipService {
|
||||
reader: Arc<RwLock<Option<maxminddb::Reader<Vec<u8>>>>>,
|
||||
vpn_ranges: Arc<RwLock<Vec<IpNet>>>,
|
||||
/// Шаблон URL с одним `{}` под "YYYY-MM" — DB-IP публикует файл с датой
|
||||
/// в имени, единой "latest"-ссылки у них нет.
|
||||
geoip_url_template: String,
|
||||
vpn_list_url: String,
|
||||
}
|
||||
|
||||
impl GeoipService {
|
||||
pub fn new(geoip_url_template: String, vpn_list_url: String) -> Self {
|
||||
Self {
|
||||
reader: Arc::new(RwLock::new(None)),
|
||||
vpn_ranges: Arc::new(RwLock::new(Vec::new())),
|
||||
geoip_url_template,
|
||||
vpn_list_url,
|
||||
}
|
||||
}
|
||||
|
||||
/// ISO 3166-1 alpha-2 код страны по IP, `None` — база ещё не загрузилась
|
||||
/// (первые секунды после старта) или IP не нашёлся (приватный диапазон,
|
||||
/// пробел в данных DB-IP).
|
||||
pub async fn country_for_ip(&self, ip: IpAddr) -> Option<String> {
|
||||
let guard = self.reader.read().await;
|
||||
let reader = guard.as_ref()?;
|
||||
let lookup: MmdbCountryLookup = reader.lookup(ip).ok()?;
|
||||
lookup
|
||||
.country
|
||||
.and_then(|c| c.iso_code)
|
||||
.map(|s| s.to_string())
|
||||
}
|
||||
|
||||
/// Грубая проверка "похоже на VPN/датацентр" — see module doc про точность.
|
||||
pub async fn is_vpn(&self, ip: IpAddr) -> bool {
|
||||
let guard = self.vpn_ranges.read().await;
|
||||
guard.iter().any(|net| net.contains(&ip))
|
||||
}
|
||||
|
||||
/// Скачивает и перезагружает `.mmdb`. Пробует текущий месяц, затем
|
||||
/// предыдущий (файл за новый месяц у DB-IP иногда появляется с
|
||||
/// задержкой в первые дни) — до первого успешного скачивания.
|
||||
pub async fn refresh_geoip_db(&self) -> Result<(), String> {
|
||||
let now = chrono::Utc::now();
|
||||
for months_back in 0..=1i64 {
|
||||
let date = now - chrono::Duration::days(31 * months_back);
|
||||
let month_tag = date.format("%Y-%m").to_string();
|
||||
let url = self.geoip_url_template.replace("{}", &month_tag);
|
||||
|
||||
match Self::try_download_mmdb(&url).await {
|
||||
Ok(reader) => {
|
||||
*self.reader.write().await = Some(reader);
|
||||
info!(url = %url, "GeoIP: база страна-по-IP обновлена");
|
||||
return Ok(());
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(url = %url, error = %e, "GeoIP: не удалось скачать за этот месяц, пробую предыдущий");
|
||||
}
|
||||
}
|
||||
}
|
||||
Err("GeoIP: не удалось скачать .mmdb ни за текущий, ни за прошлый месяц".into())
|
||||
}
|
||||
|
||||
async fn try_download_mmdb(url: &str) -> Result<maxminddb::Reader<Vec<u8>>, String> {
|
||||
let resp = reqwest::get(url)
|
||||
.await
|
||||
.map_err(|e| format!("HTTP-запрос: {e}"))?;
|
||||
if !resp.status().is_success() {
|
||||
return Err(format!("HTTP {}", resp.status()));
|
||||
}
|
||||
let compressed = resp
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|e| format!("тело ответа: {e}"))?;
|
||||
|
||||
let mut decoder = GzDecoder::new(&compressed[..]);
|
||||
let mut decompressed = Vec::new();
|
||||
decoder
|
||||
.read_to_end(&mut decompressed)
|
||||
.map_err(|e| format!("gzip-распаковка: {e}"))?;
|
||||
|
||||
maxminddb::Reader::from_source(decompressed).map_err(|e| format!("парсинг .mmdb: {e}"))
|
||||
}
|
||||
|
||||
/// Скачивает и перезагружает список CIDR-диапазонов VPN/датацентров.
|
||||
pub async fn refresh_vpn_list(&self) -> Result<(), String> {
|
||||
let resp = reqwest::get(&self.vpn_list_url)
|
||||
.await
|
||||
.map_err(|e| format!("HTTP-запрос: {e}"))?;
|
||||
if !resp.status().is_success() {
|
||||
return Err(format!("HTTP {}", resp.status()));
|
||||
}
|
||||
let text = resp.text().await.map_err(|e| format!("тело ответа: {e}"))?;
|
||||
|
||||
let ranges: Vec<IpNet> = text
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.filter(|l| !l.is_empty() && !l.starts_with('#'))
|
||||
.filter_map(|l| l.parse().ok())
|
||||
.collect();
|
||||
|
||||
if ranges.is_empty() {
|
||||
return Err("список VPN-диапазонов пуст после парсинга — подозрительно, не перезаписываю текущий".into());
|
||||
}
|
||||
|
||||
let count = ranges.len();
|
||||
*self.vpn_ranges.write().await = ranges;
|
||||
info!(count, "GeoIP: список VPN/датацентр-диапазонов обновлён");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
pub mod auth;
|
||||
pub mod billing;
|
||||
pub mod geoip;
|
||||
pub mod nodes;
|
||||
pub mod proxy_internal;
|
||||
pub mod referrals;
|
||||
|
||||
@@ -144,6 +144,10 @@ mod tests {
|
||||
user_service: UserService::new(repo.clone()),
|
||||
staff_service: StaffService::new(repo.clone()),
|
||||
support_service: crate::modules::support::service::SupportService::new(repo.clone()),
|
||||
geoip_service: crate::modules::geoip::service::GeoipService::new(
|
||||
"https://example.invalid/{}.mmdb.gz".into(),
|
||||
"https://example.invalid/vpn.txt".into(),
|
||||
),
|
||||
jwt_secret: SECRET.into(),
|
||||
bot_token: "test-bot-token".into(),
|
||||
bot_username: "test_bot".into(),
|
||||
|
||||
+24
-1
@@ -3,10 +3,13 @@
|
||||
//! подписки active -> grace -> expired и сверяет блокчейн на предмет платежей, чьё
|
||||
//! подтверждение потерялось (см. `BillingService::reconcile_pending_payments`);
|
||||
//! - раз в `NODE_HEALTH_CHECK_INTERVAL` секунд (по умолчанию 60) пингует все VPN-ноды
|
||||
//! по TCP и переоценивает их статус `online`/`offline` (см. `run_node_health_check`).
|
||||
//! по TCP и переоценивает их статус `online`/`offline` (см. `run_node_health_check`);
|
||||
//! - раз в `GEOIP_REFRESH_INTERVAL` секунд (по умолчанию раз в сутки) перекачивает
|
||||
//! базу страна-по-IP и список VPN/датацентр-диапазонов (см. `modules::geoip`).
|
||||
|
||||
use crate::db::repository::AppRepository;
|
||||
use crate::modules::billing::service::BillingService;
|
||||
use crate::modules::geoip::service::GeoipService;
|
||||
use std::{env, net::SocketAddr, sync::Arc, time::Duration};
|
||||
use tokio::{net::TcpStream, time};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
@@ -17,6 +20,7 @@ const HEALTH_CHECK_TIMEOUT: Duration = Duration::from_secs(3);
|
||||
pub async fn run_background_tasks(
|
||||
billing: BillingService,
|
||||
repo: Arc<dyn AppRepository>,
|
||||
geoip: GeoipService,
|
||||
cancel_token: CancellationToken,
|
||||
) {
|
||||
let interval_secs = env::var("SUBSCRIPTION_CHECK_INTERVAL")
|
||||
@@ -27,6 +31,10 @@ pub async fn run_background_tasks(
|
||||
.unwrap_or_else(|_| "60".to_string())
|
||||
.parse::<u64>()
|
||||
.unwrap_or(60);
|
||||
let geoip_refresh_interval_secs = env::var("GEOIP_REFRESH_INTERVAL")
|
||||
.unwrap_or_else(|_| "86400".to_string())
|
||||
.parse::<u64>()
|
||||
.unwrap_or(86400);
|
||||
|
||||
info!(
|
||||
"🔄 Фоновый биллинг-воркер запущен (интервал: {} сек)",
|
||||
@@ -36,9 +44,16 @@ pub async fn run_background_tasks(
|
||||
"🩺 Health-check воркер VPN-нод запущен (интервал: {} сек)",
|
||||
health_check_interval_secs
|
||||
);
|
||||
info!(
|
||||
"🌍 GeoIP-воркер запущен (интервал: {} сек)",
|
||||
geoip_refresh_interval_secs
|
||||
);
|
||||
|
||||
let mut billing_interval = time::interval(Duration::from_secs(interval_secs));
|
||||
let mut health_interval = time::interval(Duration::from_secs(health_check_interval_secs));
|
||||
// Первый тик срабатывает сразу (не ждёт полный интервал) — иначе первые
|
||||
// сутки после старта сервиса /plans работал бы вообще без базы GeoIP.
|
||||
let mut geoip_interval = time::interval(Duration::from_secs(geoip_refresh_interval_secs));
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
@@ -63,6 +78,14 @@ pub async fn run_background_tasks(
|
||||
_ = health_interval.tick() => {
|
||||
run_node_health_check(&repo).await;
|
||||
}
|
||||
_ = geoip_interval.tick() => {
|
||||
if let Err(e) = geoip.refresh_geoip_db().await {
|
||||
error!("GeoIP: ошибка обновления базы страна-по-IP: {}", e);
|
||||
}
|
||||
if let Err(e) = geoip.refresh_vpn_list().await {
|
||||
error!("GeoIP: ошибка обновления списка VPN-диапазонов: {}", e);
|
||||
}
|
||||
}
|
||||
_ = cancel_token.cancelled() => { break; }
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user