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.
|
# (NodeService::provision_node_via_ssh) и в .env.example netrunner-proxy.
|
||||||
PROXY_INTERNAL_SECRET=CHANGE_ME_PROXY_INTERNAL_SECRET_openssl_rand_hex_32
|
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 ===
|
# === APPLICATION RUNTIME SETTINGS ===
|
||||||
# =====================================================================
|
# =====================================================================
|
||||||
|
|||||||
Generated
+65
@@ -2,6 +2,12 @@
|
|||||||
# It is not intended for manual editing.
|
# It is not intended for manual editing.
|
||||||
version = 4
|
version = 4
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "adler2"
|
||||||
|
version = "2.0.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ahash"
|
name = "ahash"
|
||||||
version = "0.7.8"
|
version = "0.7.8"
|
||||||
@@ -649,6 +655,15 @@ version = "2.4.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5"
|
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]]
|
[[package]]
|
||||||
name = "crossbeam-epoch"
|
name = "crossbeam-epoch"
|
||||||
version = "0.9.18"
|
version = "0.9.18"
|
||||||
@@ -1056,6 +1071,16 @@ version = "0.1.9"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
|
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]]
|
[[package]]
|
||||||
name = "flume"
|
name = "flume"
|
||||||
version = "0.11.1"
|
version = "0.11.1"
|
||||||
@@ -1856,6 +1881,15 @@ version = "2.12.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
|
checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ipnetwork"
|
||||||
|
version = "0.20.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bf466541e9d546596ee94f9f69590f89473455f88372423e0008fc1a7daf100e"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "iri-string"
|
name = "iri-string"
|
||||||
version = "0.7.12"
|
version = "0.7.12"
|
||||||
@@ -2055,6 +2089,18 @@ version = "0.8.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
|
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]]
|
[[package]]
|
||||||
name = "md-5"
|
name = "md-5"
|
||||||
version = "0.10.6"
|
version = "0.10.6"
|
||||||
@@ -2135,6 +2181,16 @@ dependencies = [
|
|||||||
"unicase",
|
"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]]
|
[[package]]
|
||||||
name = "mio"
|
name = "mio"
|
||||||
version = "1.2.0"
|
version = "1.2.0"
|
||||||
@@ -2173,9 +2229,12 @@ dependencies = [
|
|||||||
"chrono",
|
"chrono",
|
||||||
"clap",
|
"clap",
|
||||||
"dotenvy",
|
"dotenvy",
|
||||||
|
"flate2",
|
||||||
"hex",
|
"hex",
|
||||||
"hmac 0.13.0",
|
"hmac 0.13.0",
|
||||||
|
"ipnet",
|
||||||
"jsonwebtoken",
|
"jsonwebtoken",
|
||||||
|
"maxminddb",
|
||||||
"metrics",
|
"metrics",
|
||||||
"metrics-exporter-prometheus",
|
"metrics-exporter-prometheus",
|
||||||
"rand 0.8.6",
|
"rand 0.8.6",
|
||||||
@@ -3451,6 +3510,12 @@ dependencies = [
|
|||||||
"rand_core 0.6.4",
|
"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]]
|
[[package]]
|
||||||
name = "simdutf8"
|
name = "simdutf8"
|
||||||
version = "0.1.5"
|
version = "0.1.5"
|
||||||
|
|||||||
@@ -40,6 +40,9 @@ argon2 = "0.5"
|
|||||||
rpassword = "7.5"
|
rpassword = "7.5"
|
||||||
rust_decimal = { version = "1.42.0", features = ["serde"] }
|
rust_decimal = { version = "1.42.0", features = ["serde"] }
|
||||||
reqwest = { version = "0.11", features = ["json"] }
|
reqwest = { version = "0.11", features = ["json"] }
|
||||||
|
maxminddb = "0.24"
|
||||||
|
ipnet = "2.10"
|
||||||
|
flate2 = "1.0"
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
opt-level = 3
|
opt-level = 3
|
||||||
|
|||||||
+11
-1
@@ -28,6 +28,7 @@ use crate::db::repository::AppRepository;
|
|||||||
use crate::modules::{
|
use crate::modules::{
|
||||||
auth::{controller as auth_ctrl, service::AuthService},
|
auth::{controller as auth_ctrl, service::AuthService},
|
||||||
billing::{controller as bill_ctrl, service::BillingService},
|
billing::{controller as bill_ctrl, service::BillingService},
|
||||||
|
geoip::service::GeoipService,
|
||||||
nodes::{controller as node_ctrl, service::NodeService},
|
nodes::{controller as node_ctrl, service::NodeService},
|
||||||
proxy_internal,
|
proxy_internal,
|
||||||
referrals::{controller as ref_ctrl, service::ReferralService},
|
referrals::{controller as ref_ctrl, service::ReferralService},
|
||||||
@@ -45,6 +46,11 @@ pub struct ApiState {
|
|||||||
pub user_service: UserService,
|
pub user_service: UserService,
|
||||||
pub staff_service: StaffService,
|
pub staff_service: StaffService,
|
||||||
pub support_service: SupportService,
|
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 jwt_secret: String,
|
||||||
pub bot_token: String,
|
pub bot_token: String,
|
||||||
/// Юзернейм бота (без `@`) — отдаётся фронту через `GET /api/v1/config`.
|
/// Юзернейм бота (без `@`) — отдаётся фронту через `GET /api/v1/config`.
|
||||||
@@ -87,6 +93,7 @@ impl Clone for ApiState {
|
|||||||
user_service: self.user_service.clone(),
|
user_service: self.user_service.clone(),
|
||||||
staff_service: self.staff_service.clone(),
|
staff_service: self.staff_service.clone(),
|
||||||
support_service: self.support_service.clone(),
|
support_service: self.support_service.clone(),
|
||||||
|
geoip_service: self.geoip_service.clone(),
|
||||||
jwt_secret: self.jwt_secret.clone(),
|
jwt_secret: self.jwt_secret.clone(),
|
||||||
bot_token: self.bot_token.clone(),
|
bot_token: self.bot_token.clone(),
|
||||||
bot_username: self.bot_username.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)),
|
auth_ctrl::router().layer(GovernorLayer::new(seed_limit_conf)),
|
||||||
)
|
)
|
||||||
.nest("/users", user_ctrl::router(state.clone()))
|
.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("/nodes", node_ctrl::public_router(state.clone()))
|
||||||
.nest("/referrals", ref_ctrl::router(state.clone()))
|
.nest("/referrals", ref_ctrl::router(state.clone()))
|
||||||
.route("/config", get(get_public_config))
|
.route("/config", get(get_public_config))
|
||||||
|
|||||||
+31
-5
@@ -208,6 +208,12 @@ async fn handle_callbacks(
|
|||||||
support: SupportService,
|
support: SupportService,
|
||||||
config: BotConfig,
|
config: BotConfig,
|
||||||
) -> ResponseResult<()> {
|
) -> 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) {
|
if let (Some(data), Some(msg)) = (q.data, q.message) {
|
||||||
let tg_id = q.from.id.0 as i64;
|
let tg_id = q.from.id.0 as i64;
|
||||||
info!(callback_data = %data, "Пользователь нажал кнопку в боте");
|
info!(callback_data = %data, "Пользователь нажал кнопку в боте");
|
||||||
@@ -292,17 +298,37 @@ async fn handle_callbacks(
|
|||||||
)
|
)
|
||||||
.await,
|
.await,
|
||||||
// Оплата криптой прямо в чате, без WebApp/кошелька — юзер платит
|
// Оплата криптой прямо в чате, без WebApp/кошелька — юзер платит
|
||||||
// внутри @CryptoBot, возвращается и жмёт "Я оплатил".
|
// внутри @CryptoBot, возвращается и жмёт "Я оплатил". Показываем
|
||||||
"menu_pay_crypto" => match billing.list_plans_usd().await {
|
// цену в валюте региона (по language_code, см. выше) для
|
||||||
Ok(plans) if !plans.is_empty() => {
|
// наглядности — САМА оплата остаётся в 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
|
let rows = plans
|
||||||
.chunks(1)
|
.chunks(1)
|
||||||
.map(|chunk| {
|
.map(|chunk| {
|
||||||
chunk
|
chunk
|
||||||
.iter()
|
.iter()
|
||||||
.map(|(name, cents)| {
|
.map(|(name, amount)| {
|
||||||
InlineKeyboardButton::callback(
|
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),
|
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::bot;
|
||||||
use netrunner_control_plane::db::repository::{AppRepository, PgRepository};
|
use netrunner_control_plane::db::repository::{AppRepository, PgRepository};
|
||||||
use netrunner_control_plane::modules::{
|
use netrunner_control_plane::modules::{
|
||||||
auth::service::AuthService, billing::service::BillingService, nodes::service::NodeService,
|
auth::service::AuthService, billing::service::BillingService, geoip::service::GeoipService,
|
||||||
referrals::service::ReferralService, staff::service::StaffService,
|
nodes::service::NodeService, referrals::service::ReferralService, staff::service::StaffService,
|
||||||
support::service::SupportService, users::service::UserService,
|
support::service::SupportService, users::service::UserService,
|
||||||
};
|
};
|
||||||
use netrunner_control_plane::tasks;
|
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 repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool.clone()));
|
||||||
let cancel_token = CancellationToken::new();
|
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 {
|
let state = ApiState {
|
||||||
repo: repo.clone(),
|
repo: repo.clone(),
|
||||||
auth_service: AuthService::new(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()),
|
user_service: UserService::new(repo.clone()),
|
||||||
staff_service: StaffService::new(repo.clone()),
|
staff_service: StaffService::new(repo.clone()),
|
||||||
support_service: SupportService::new(repo.clone()),
|
support_service: SupportService::new(repo.clone()),
|
||||||
|
geoip_service: geoip_service.clone(),
|
||||||
jwt_secret,
|
jwt_secret,
|
||||||
bot_token: bot_token.clone(),
|
bot_token: bot_token.clone(),
|
||||||
bot_username,
|
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_token = cancel_token.clone();
|
||||||
let tasks_billing = state.billing_service.clone();
|
let tasks_billing = state.billing_service.clone();
|
||||||
let tasks_repo = repo.clone();
|
let tasks_repo = repo.clone();
|
||||||
|
let tasks_geoip = geoip_service.clone();
|
||||||
let tasks_handle = tokio::spawn(async move {
|
let tasks_handle = tokio::spawn(async move {
|
||||||
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
|
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()),
|
user_service: UserService::new(repo.clone()),
|
||||||
staff_service: StaffService::new(repo.clone()),
|
staff_service: StaffService::new(repo.clone()),
|
||||||
support_service: crate::modules::support::service::SupportService::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(),
|
jwt_secret: "test-secret".into(),
|
||||||
bot_token: "test-bot-token".into(),
|
bot_token: "test-bot-token".into(),
|
||||||
bot_username: "test_bot".into(),
|
bot_username: "test_bot".into(),
|
||||||
|
|||||||
@@ -1,14 +1,21 @@
|
|||||||
use axum::{
|
use axum::{
|
||||||
extract::{Extension, State},
|
extract::{ConnectInfo, Extension, State},
|
||||||
|
http::HeaderMap,
|
||||||
middleware,
|
middleware,
|
||||||
routing::post,
|
routing::{get, post},
|
||||||
Json, Router,
|
Json, Router,
|
||||||
};
|
};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
|
use std::net::{IpAddr, SocketAddr};
|
||||||
use uuid::Uuid;
|
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)]
|
#[derive(Deserialize)]
|
||||||
pub struct InitiatePaymentPayload {
|
pub struct InitiatePaymentPayload {
|
||||||
@@ -23,6 +30,13 @@ pub struct ConfirmPaymentPayload {
|
|||||||
pub external_tx_id: String,
|
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> {
|
pub fn router(state: ApiState) -> Router<ApiState> {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/pay/initiate", post(initiate_payment_api))
|
.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))
|
.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: Запрашиваем у провайдера данные для оплаты (адрес, ссылку и т.д.)
|
/// Шаг 1: Запрашиваем у провайдера данные для оплаты (адрес, ссылку и т.д.)
|
||||||
async fn initiate_payment_api(
|
async fn initiate_payment_api(
|
||||||
Extension(user): Extension<User>,
|
Extension(user): Extension<User>,
|
||||||
|
|||||||
@@ -110,6 +110,30 @@ impl BillingService {
|
|||||||
.map_err(AppError::Database)
|
.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 —
|
/// Курс TON к валюте из кеша; `None` при промахе/недоступности Redis —
|
||||||
/// вызывающий код должен сходить за свежим значением и заполнить кеш сам.
|
/// вызывающий код должен сходить за свежим значением и заполнить кеш сам.
|
||||||
async fn get_cached_ton_rate(&self, currency_lower: &str) -> Option<rust_decimal::Decimal> {
|
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 auth;
|
||||||
pub mod billing;
|
pub mod billing;
|
||||||
|
pub mod geoip;
|
||||||
pub mod nodes;
|
pub mod nodes;
|
||||||
pub mod proxy_internal;
|
pub mod proxy_internal;
|
||||||
pub mod referrals;
|
pub mod referrals;
|
||||||
|
|||||||
@@ -144,6 +144,10 @@ mod tests {
|
|||||||
user_service: UserService::new(repo.clone()),
|
user_service: UserService::new(repo.clone()),
|
||||||
staff_service: StaffService::new(repo.clone()),
|
staff_service: StaffService::new(repo.clone()),
|
||||||
support_service: crate::modules::support::service::SupportService::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(),
|
jwt_secret: SECRET.into(),
|
||||||
bot_token: "test-bot-token".into(),
|
bot_token: "test-bot-token".into(),
|
||||||
bot_username: "test_bot".into(),
|
bot_username: "test_bot".into(),
|
||||||
|
|||||||
+24
-1
@@ -3,10 +3,13 @@
|
|||||||
//! подписки active -> grace -> expired и сверяет блокчейн на предмет платежей, чьё
|
//! подписки active -> grace -> expired и сверяет блокчейн на предмет платежей, чьё
|
||||||
//! подтверждение потерялось (см. `BillingService::reconcile_pending_payments`);
|
//! подтверждение потерялось (см. `BillingService::reconcile_pending_payments`);
|
||||||
//! - раз в `NODE_HEALTH_CHECK_INTERVAL` секунд (по умолчанию 60) пингует все VPN-ноды
|
//! - раз в `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::db::repository::AppRepository;
|
||||||
use crate::modules::billing::service::BillingService;
|
use crate::modules::billing::service::BillingService;
|
||||||
|
use crate::modules::geoip::service::GeoipService;
|
||||||
use std::{env, net::SocketAddr, sync::Arc, time::Duration};
|
use std::{env, net::SocketAddr, sync::Arc, time::Duration};
|
||||||
use tokio::{net::TcpStream, time};
|
use tokio::{net::TcpStream, time};
|
||||||
use tokio_util::sync::CancellationToken;
|
use tokio_util::sync::CancellationToken;
|
||||||
@@ -17,6 +20,7 @@ const HEALTH_CHECK_TIMEOUT: Duration = Duration::from_secs(3);
|
|||||||
pub async fn run_background_tasks(
|
pub async fn run_background_tasks(
|
||||||
billing: BillingService,
|
billing: BillingService,
|
||||||
repo: Arc<dyn AppRepository>,
|
repo: Arc<dyn AppRepository>,
|
||||||
|
geoip: GeoipService,
|
||||||
cancel_token: CancellationToken,
|
cancel_token: CancellationToken,
|
||||||
) {
|
) {
|
||||||
let interval_secs = env::var("SUBSCRIPTION_CHECK_INTERVAL")
|
let interval_secs = env::var("SUBSCRIPTION_CHECK_INTERVAL")
|
||||||
@@ -27,6 +31,10 @@ pub async fn run_background_tasks(
|
|||||||
.unwrap_or_else(|_| "60".to_string())
|
.unwrap_or_else(|_| "60".to_string())
|
||||||
.parse::<u64>()
|
.parse::<u64>()
|
||||||
.unwrap_or(60);
|
.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!(
|
info!(
|
||||||
"🔄 Фоновый биллинг-воркер запущен (интервал: {} сек)",
|
"🔄 Фоновый биллинг-воркер запущен (интервал: {} сек)",
|
||||||
@@ -36,9 +44,16 @@ pub async fn run_background_tasks(
|
|||||||
"🩺 Health-check воркер VPN-нод запущен (интервал: {} сек)",
|
"🩺 Health-check воркер VPN-нод запущен (интервал: {} сек)",
|
||||||
health_check_interval_secs
|
health_check_interval_secs
|
||||||
);
|
);
|
||||||
|
info!(
|
||||||
|
"🌍 GeoIP-воркер запущен (интервал: {} сек)",
|
||||||
|
geoip_refresh_interval_secs
|
||||||
|
);
|
||||||
|
|
||||||
let mut billing_interval = time::interval(Duration::from_secs(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));
|
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 {
|
loop {
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
@@ -63,6 +78,14 @@ pub async fn run_background_tasks(
|
|||||||
_ = health_interval.tick() => {
|
_ = health_interval.tick() => {
|
||||||
run_node_health_check(&repo).await;
|
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; }
|
_ = cancel_token.cancelled() => { break; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user