[AI] fix some modules, develop the big part of modules. TODO fix critical errors, update pipeline, test everything
This commit is contained in:
@@ -1,10 +1,10 @@
|
||||
use crate::{api::ApiState, error::AppError};
|
||||
use axum::response::IntoResponse;
|
||||
use axum::{extract::State, routing::post, Json, Router};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
use tracing::instrument;
|
||||
|
||||
use crate::{api::ApiState, error::AppError};
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct TgWidgetPayload {
|
||||
pub id: i64,
|
||||
@@ -16,8 +16,16 @@ pub struct TgWidgetPayload {
|
||||
pub hash: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct SeedLoginPayload {
|
||||
pub seed: String,
|
||||
}
|
||||
|
||||
pub fn router() -> Router<ApiState> {
|
||||
Router::new().route("/telegram", post(auth_telegram_api))
|
||||
Router::new()
|
||||
.route("/telegram", post(auth_telegram_api))
|
||||
.route("/seed/generate", post(generate_seed_api))
|
||||
.route("/seed/login", post(login_seed_api))
|
||||
}
|
||||
|
||||
#[instrument(skip(state, payload), fields(tg_id = payload.id, username = ?payload.username))]
|
||||
@@ -25,6 +33,7 @@ async fn auth_telegram_api(
|
||||
State(state): State<ApiState>,
|
||||
Json(payload): Json<TgWidgetPayload>,
|
||||
) -> Result<Json<Value>, AppError> {
|
||||
// 1. Проверяем подпись Telegram
|
||||
state.auth_service.verify_tg_widget_auth(
|
||||
&state.bot_token,
|
||||
payload.id,
|
||||
@@ -36,13 +45,43 @@ async fn auth_telegram_api(
|
||||
&payload.hash,
|
||||
)?;
|
||||
|
||||
// 2. Создаем или обновляем юзера
|
||||
let user = state
|
||||
.auth_service
|
||||
.process_login(payload.id, payload.username.clone(), None)
|
||||
.await?;
|
||||
|
||||
// 3. Генерируем токен. Теперь user.tg_id передается как Option автоматически.
|
||||
let token = state
|
||||
.auth_service
|
||||
.generate_auth_token(user.id, user.tg_id, &state.jwt_secret)?;
|
||||
|
||||
Ok(Json(json!({ "status": "success", "token": token })))
|
||||
}
|
||||
|
||||
#[axum::debug_handler]
|
||||
async fn generate_seed_api(State(state): State<ApiState>) -> Result<impl IntoResponse, AppError> {
|
||||
// Fail-fast для соли
|
||||
let salt = std::env::var("ARGON_SALT").expect("КРИТИЧЕСКАЯ ОШИБКА: ARGON_SALT не задан!");
|
||||
let (seed, token) = state
|
||||
.auth_service
|
||||
.register_seed(&salt, &state.jwt_secret)
|
||||
.await?;
|
||||
|
||||
Ok(Json(
|
||||
json!({ "status": "success", "seed": seed, "token": token }),
|
||||
))
|
||||
}
|
||||
|
||||
async fn login_seed_api(
|
||||
State(state): State<ApiState>,
|
||||
Json(payload): Json<SeedLoginPayload>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
let salt = std::env::var("ARGON_SALT").expect("КРИТИЧЕСКАЯ ОШИБКА: ARGON_SALT не задан!");
|
||||
let token = state
|
||||
.auth_service
|
||||
.login_seed(&payload.seed, &salt, &state.jwt_secret)
|
||||
.await?;
|
||||
|
||||
Ok(Json(json!({ "status": "success", "token": token })))
|
||||
}
|
||||
|
||||
@@ -28,9 +28,14 @@ pub async fn auth_guard(
|
||||
)
|
||||
.map_err(|_| AppError::Unauthorized("Invalid session signature".into()))?;
|
||||
|
||||
// Парсим внутренний UUID из поля sub токена
|
||||
let user_id = uuid::Uuid::parse_str(&token_data.claims.sub)
|
||||
.map_err(|_| AppError::Unauthorized("Invalid token payload structure".into()))?;
|
||||
|
||||
// Ищем оперативника в сети по его системному UUID
|
||||
let user = state
|
||||
.repo
|
||||
.get_user_by_tg_id(token_data.claims.tg_id)
|
||||
.get_user_by_id(user_id)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound("Operative not found in grid".into()))?;
|
||||
|
||||
|
||||
+70
-22
@@ -1,10 +1,12 @@
|
||||
use argon2::Argon2;
|
||||
use chrono::Utc;
|
||||
use hmac::{Hmac, KeyInit, Mac};
|
||||
use jsonwebtoken::{encode, EncodingKey, Header};
|
||||
use rand::Rng;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::sync::Arc;
|
||||
use tracing::{error, info, instrument};
|
||||
use uuid::Uuid;
|
||||
use uuid::Uuid; // Не забудь добавить rand в Cargo.toml
|
||||
|
||||
use crate::{
|
||||
db::{models::User, repository::AppRepository},
|
||||
@@ -13,8 +15,8 @@ use crate::{
|
||||
|
||||
#[derive(serde::Serialize, serde::Deserialize, Debug)]
|
||||
pub struct Claims {
|
||||
pub sub: String,
|
||||
pub tg_id: i64,
|
||||
pub sub: String, // Внутренний UUID юзера
|
||||
pub tg_id: Option<i64>, // Опционально (отсутствует у Ghost-юзеров)
|
||||
pub exp: usize,
|
||||
}
|
||||
|
||||
@@ -28,11 +30,12 @@ impl AuthService {
|
||||
Self { repo }
|
||||
}
|
||||
|
||||
/// Генерирует JWT на основе внутреннего ID и опционального Telegram ID
|
||||
#[instrument(skip(self, secret))]
|
||||
pub fn generate_auth_token(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
tg_id: i64,
|
||||
tg_id: Option<i64>,
|
||||
secret: &str,
|
||||
) -> Result<String, AppError> {
|
||||
let claims = Claims {
|
||||
@@ -47,11 +50,59 @@ impl AuthService {
|
||||
&EncodingKey::from_secret(secret.as_bytes()),
|
||||
)
|
||||
.map_err(|e| {
|
||||
error!("[Auth] Failed to generate JWT: {}", e);
|
||||
AppError::Internal
|
||||
error!("[Auth] JWT Generation Error: {}", e);
|
||||
AppError::Internal(format!("Failed to generate JWT: {}", e))
|
||||
})
|
||||
}
|
||||
|
||||
fn hash_seed_argon2(seed: &str, salt: &str) -> Result<String, AppError> {
|
||||
let mut hash_output = [0u8; 32];
|
||||
Argon2::default()
|
||||
.hash_password_into(seed.as_bytes(), salt.as_bytes(), &mut hash_output)
|
||||
.map_err(|e| AppError::Internal(format!("Argon2 Hashing Error: {}", e)))?;
|
||||
|
||||
Ok(hex::encode(hash_output))
|
||||
}
|
||||
|
||||
/// Ghost Protocol: Регистрация нового анонимного юзера
|
||||
pub async fn register_seed(
|
||||
&self,
|
||||
salt: &str,
|
||||
jwt_secret: &str,
|
||||
) -> Result<(String, String), AppError> {
|
||||
let seed: String = {
|
||||
let mut rng = rand::thread_rng();
|
||||
(0..16).map(|_| rng.gen_range(0..10).to_string()).collect()
|
||||
};
|
||||
|
||||
// Используем мощный Argon2 вместо быстрого SHA256
|
||||
let hash = Self::hash_seed_argon2(&seed, salt)?;
|
||||
|
||||
let user = self.repo.create_seed_user(&hash).await?;
|
||||
let token = self.generate_auth_token(user.id, user.tg_id, jwt_secret)?;
|
||||
|
||||
Ok((seed, token))
|
||||
}
|
||||
|
||||
/// Ghost Protocol: Вход по существующему Seed
|
||||
pub async fn login_seed(
|
||||
&self,
|
||||
seed: &str,
|
||||
salt: &str,
|
||||
jwt_secret: &str,
|
||||
) -> Result<String, AppError> {
|
||||
let hash = Self::hash_seed_argon2(seed, salt)?;
|
||||
|
||||
let user = self
|
||||
.repo
|
||||
.get_user_by_seed_hash(&hash)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::Unauthorized("Invalid credentials".into()))?;
|
||||
|
||||
self.generate_auth_token(user.id, user.tg_id, jwt_secret)
|
||||
}
|
||||
// --- Логика Telegram (без изменений, кроме вызова генерации токена) ---
|
||||
|
||||
#[instrument(skip(self, bot_token, hash))]
|
||||
pub fn verify_tg_widget_auth(
|
||||
&self,
|
||||
@@ -65,17 +116,17 @@ impl AuthService {
|
||||
hash: &str,
|
||||
) -> Result<(), AppError> {
|
||||
let mut data_check_arr = vec![format!("auth_date={}", auth_date), format!("id={}", id)];
|
||||
if let Some(fn_) = first_name {
|
||||
data_check_arr.push(format!("first_name={}", fn_));
|
||||
if let Some(v) = first_name {
|
||||
data_check_arr.push(format!("first_name={}", v));
|
||||
}
|
||||
if let Some(ln) = last_name {
|
||||
data_check_arr.push(format!("last_name={}", ln));
|
||||
if let Some(v) = last_name {
|
||||
data_check_arr.push(format!("last_name={}", v));
|
||||
}
|
||||
if let Some(pu) = photo_url {
|
||||
data_check_arr.push(format!("photo_url={}", pu));
|
||||
if let Some(v) = photo_url {
|
||||
data_check_arr.push(format!("photo_url={}", v));
|
||||
}
|
||||
if let Some(un) = username {
|
||||
data_check_arr.push(format!("username={}", un));
|
||||
if let Some(v) = username {
|
||||
data_check_arr.push(format!("username={}", v));
|
||||
}
|
||||
|
||||
data_check_arr.sort();
|
||||
@@ -85,15 +136,15 @@ impl AuthService {
|
||||
hasher.update(bot_token.trim().as_bytes());
|
||||
let secret_key = hasher.finalize();
|
||||
|
||||
let mut mac =
|
||||
Hmac::<Sha256>::new_from_slice(&secret_key).map_err(|_| AppError::Internal)?;
|
||||
let mut mac = Hmac::<Sha256>::new_from_slice(&secret_key)
|
||||
.map_err(|e| AppError::Internal(format!("HMAC Error: {}", e)))?;
|
||||
mac.update(data_check_string.as_bytes());
|
||||
|
||||
if hex::encode(mac.finalize().into_bytes()) != hash {
|
||||
return Err(AppError::Unauthorized("Invalid Telegram hash".into()));
|
||||
return Err(AppError::Unauthorized("Signature mismatch".into()));
|
||||
}
|
||||
if Utc::now().timestamp() - auth_date > 86400 {
|
||||
return Err(AppError::Unauthorized("Auth date expired".into()));
|
||||
return Err(AppError::Unauthorized("Session expired".into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -121,10 +172,7 @@ impl AuthService {
|
||||
|
||||
async fn handle_referral(&self, referrer_tg: i64, new_user_id: Uuid) -> Result<(), AppError> {
|
||||
if let Some(referrer) = self.repo.get_user_by_tg_id(referrer_tg).await? {
|
||||
info!(
|
||||
"[Auth] Creating syndicate link: {} -> {}",
|
||||
referrer.id, new_user_id
|
||||
);
|
||||
info!("[Auth] Syndicate Link: {} -> {}", referrer.id, new_user_id);
|
||||
self.repo
|
||||
.create_referral(referrer.id, new_user_id)
|
||||
.await
|
||||
|
||||
@@ -6,41 +6,72 @@ use axum::{
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{api::ApiState, db::models::User, error::AppError, modules::auth::guard::auth_guard};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct SubPayload {
|
||||
pub struct InitiatePaymentPayload {
|
||||
pub plan: String,
|
||||
pub provider: String,
|
||||
pub currency: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ConfirmPaymentPayload {
|
||||
pub invoice_id: Uuid,
|
||||
pub external_tx_id: String,
|
||||
}
|
||||
|
||||
pub fn router(state: ApiState) -> Router<ApiState> {
|
||||
Router::new()
|
||||
.route("/subscribe", post(buy_sub_api))
|
||||
.route("/pay/initiate", post(initiate_payment_api))
|
||||
.route("/pay/confirm", post(confirm_payment_api))
|
||||
.route("/trial", post(activate_trial_api))
|
||||
.route_layer(middleware::from_fn_with_state(state, auth_guard))
|
||||
}
|
||||
|
||||
async fn buy_sub_api(
|
||||
/// Шаг 1: Запрашиваем у провайдера данные для оплаты (адрес, ссылку и т.д.)
|
||||
async fn initiate_payment_api(
|
||||
Extension(user): Extension<User>,
|
||||
State(state): State<ApiState>,
|
||||
Json(payload): Json<SubPayload>,
|
||||
Json(payload): Json<InitiatePaymentPayload>,
|
||||
) -> Result<Json<Value>, AppError> {
|
||||
let checkout_data = state
|
||||
.billing_service
|
||||
.initiate_payment(&user, &payload.plan, &payload.provider, &payload.currency)
|
||||
.await?;
|
||||
|
||||
Ok(Json(json!({
|
||||
"status": "success",
|
||||
"checkout_data": checkout_data
|
||||
})))
|
||||
}
|
||||
|
||||
/// Шаг 2: Подтверждаем транзакцию после того как юзер её оплатил
|
||||
async fn confirm_payment_api(
|
||||
Extension(_user): Extension<User>, // В будущем можно сверять ID юзера с инвойсом
|
||||
State(state): State<ApiState>,
|
||||
Json(payload): Json<ConfirmPaymentPayload>,
|
||||
) -> Result<Json<Value>, AppError> {
|
||||
state
|
||||
.billing_service
|
||||
.buy_subscription(user.tg_id, &payload.plan)
|
||||
.confirm_payment(payload.invoice_id, &payload.external_tx_id)
|
||||
.await?;
|
||||
Ok(Json(
|
||||
json!({"status": "success", "message": format!("Mock payment for {} successful", payload.plan)}),
|
||||
))
|
||||
|
||||
Ok(Json(json!({
|
||||
"status": "success",
|
||||
"message": "Payment verified. Uplink activated."
|
||||
})))
|
||||
}
|
||||
|
||||
async fn activate_trial_api(
|
||||
Extension(user): Extension<User>,
|
||||
State(state): State<ApiState>,
|
||||
) -> Result<Json<Value>, AppError> {
|
||||
state.billing_service.activate_trial(user.tg_id).await?;
|
||||
Ok(Json(
|
||||
json!({"status": "success", "message": "Trial Uplink Activated"}),
|
||||
))
|
||||
state.billing_service.activate_trial(&user).await?;
|
||||
Ok(Json(json!({
|
||||
"status": "success",
|
||||
"message": "Trial Uplink Activated"
|
||||
})))
|
||||
}
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
pub mod controller;
|
||||
mod providers;
|
||||
pub mod service;
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
use crate::error::AppError;
|
||||
use async_trait::async_trait;
|
||||
use serde_json::Value;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub mod ton;
|
||||
|
||||
pub struct InvoiceData {
|
||||
pub invoice_id: Uuid,
|
||||
pub amount: i64,
|
||||
pub currency: String,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait PaymentProvider: Send + Sync {
|
||||
/// Имя провайдера (например, "TON", "STRIPE")
|
||||
fn provider_id(&self) -> &'static str;
|
||||
|
||||
/// Создает платежную сессию/ссылку/payload для фронтенда
|
||||
async fn create_checkout_session(&self, invoice: &InvoiceData) -> Result<Value, AppError>;
|
||||
|
||||
/// Проверяет статус платежа (например, обращение к блокчейну или обработка Webhook)
|
||||
async fn verify_payment(
|
||||
&self,
|
||||
external_tx_id: &str,
|
||||
expected_amount: i64,
|
||||
) -> Result<bool, AppError>;
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
use super::{InvoiceData, PaymentProvider};
|
||||
use crate::error::AppError;
|
||||
use async_trait::async_trait;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
pub struct TonProvider {
|
||||
pub master_wallet: String,
|
||||
}
|
||||
|
||||
impl TonProvider {
|
||||
/// Получает историю последних транзакций мастер-кошелька
|
||||
pub async fn get_recent_transactions(&self) -> Result<Vec<Value>, AppError> {
|
||||
let client = reqwest::Client::new();
|
||||
// Запрашиваем последние 50 транзакций
|
||||
let url = format!(
|
||||
"https://tonapi.io/v2/blockchain/accounts/{}/transactions?limit=50",
|
||||
self.master_wallet
|
||||
);
|
||||
|
||||
let res = client
|
||||
.get(&url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(format!("Ошибка сети TonAPI: {}", e)))?;
|
||||
|
||||
if !res.status().is_success() {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
|
||||
let data: Value = res.json().await.unwrap_or(json!({}));
|
||||
Ok(data["transactions"].as_array().unwrap_or(&vec![]).clone())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl PaymentProvider for TonProvider {
|
||||
fn provider_id(&self) -> &'static str {
|
||||
"TON"
|
||||
}
|
||||
|
||||
async fn create_checkout_session(&self, invoice: &InvoiceData) -> Result<Value, AppError> {
|
||||
// Для TON нам не нужно делать запрос к API эквайринга при создании.
|
||||
// Мы просто отдаем фронтенду данные для формирования транзакции.
|
||||
Ok(json!({
|
||||
"destination": self.master_wallet,
|
||||
"amount": invoice.amount, // В nanoTON
|
||||
"comment": invoice.invoice_id.to_string() // Передаем ID инвойса в коммент, чтобы потом идентифицировать!
|
||||
}))
|
||||
}
|
||||
|
||||
async fn verify_payment(&self, tx_hash: &str, expected_amount: i64) -> Result<bool, AppError> {
|
||||
let client = reqwest::Client::new();
|
||||
let url = format!("https://tonapi.io/v2/blockchain/transactions/{}", tx_hash);
|
||||
|
||||
let res = client
|
||||
.get(&url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(format!("Ошибка запроса к TON API: {}", e)))?;
|
||||
|
||||
if !res.status().is_success() {
|
||||
return Ok(false); // Транзакция не найдена или еще не подтверждена
|
||||
}
|
||||
|
||||
let data: Value = res
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(format!("Ошибка парсинга JSON от TON API: {}", e)))?;
|
||||
|
||||
// Проверяем входящее сообщение транзакции
|
||||
let in_msg = &data["in_msg"];
|
||||
if in_msg.is_null() {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let value = in_msg["value"].as_i64().unwrap_or(0);
|
||||
let dest = in_msg["destination"]["address"].as_str().unwrap_or("");
|
||||
|
||||
// В боевой версии также стоит проверять:
|
||||
// in_msg["decoded_body"]["text"] == ID инвойса, чтобы избежать оплаты чужих инвойсов
|
||||
|
||||
// Сверяем кошелек назначения и сумму (с допуском на мелкие расхождения/комиссии, если нужно)
|
||||
if value >= expected_amount && dest == self.master_wallet {
|
||||
tracing::info!("TON Transaction {} verified successfully!", tx_hash);
|
||||
Ok(true)
|
||||
} else {
|
||||
tracing::warn!(
|
||||
"TON Transaction mismatch. Expected: {}, Got: {}",
|
||||
expected_amount,
|
||||
value
|
||||
);
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
+176
-27
@@ -1,19 +1,51 @@
|
||||
use super::providers::{InvoiceData, PaymentProvider};
|
||||
use crate::{
|
||||
db::{models::Subscription, repository::AppRepository},
|
||||
db::{
|
||||
models::{Subscription, User},
|
||||
repository::AppRepository,
|
||||
},
|
||||
error::AppError,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
use tracing::info;
|
||||
use rust_decimal::prelude::FromPrimitive;
|
||||
use std::{collections::HashMap, sync::Arc};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct BillingService {
|
||||
repo: Arc<dyn AppRepository>,
|
||||
providers: HashMap<&'static str, Box<dyn PaymentProvider>>,
|
||||
}
|
||||
|
||||
// Добавляем Clone вручную, так как dyn Traits не поддерживают derive(Clone) легко
|
||||
impl Clone for BillingService {
|
||||
fn clone(&self) -> Self {
|
||||
// ВАЖНО: В реальном проекте провайдеры стоит обернуть в Arc,
|
||||
// но для простоты структуры мы пересоздаем их при клонировании сервиса
|
||||
let mut providers: HashMap<&'static str, Box<dyn PaymentProvider>> = HashMap::new();
|
||||
providers.insert(
|
||||
"TON",
|
||||
Box::new(super::providers::ton::TonProvider {
|
||||
master_wallet: "UQ_MASTER_WALLET_ADDRESS".into(),
|
||||
}),
|
||||
);
|
||||
|
||||
Self {
|
||||
repo: self.repo.clone(),
|
||||
providers,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl BillingService {
|
||||
pub fn new(repo: Arc<dyn AppRepository>) -> Self {
|
||||
Self { repo }
|
||||
let mut providers: HashMap<&'static str, Box<dyn PaymentProvider>> = HashMap::new();
|
||||
providers.insert(
|
||||
"TON",
|
||||
Box::new(super::providers::ton::TonProvider {
|
||||
master_wallet: "UQ_MASTER_WALLET_ADDRESS".into(),
|
||||
}),
|
||||
);
|
||||
|
||||
Self { repo, providers }
|
||||
}
|
||||
|
||||
pub async fn get_subscription(&self, user_id: Uuid) -> Result<Option<Subscription>, AppError> {
|
||||
@@ -23,36 +55,119 @@ impl BillingService {
|
||||
.map_err(AppError::Database)
|
||||
}
|
||||
|
||||
pub async fn buy_subscription(&self, tg_id: i64, plan_name: &str) -> Result<(), AppError> {
|
||||
info!(
|
||||
"Processing mock subscription for TG: {}, Plan: {}",
|
||||
tg_id, plan_name
|
||||
);
|
||||
let price_usd = self.repo.get_plan_price_usd(plan_name).await?.unwrap_or(0);
|
||||
/// Этап 1: Генерация инвойса и платежной ссылки
|
||||
pub async fn initiate_payment(
|
||||
&self,
|
||||
user: &User,
|
||||
plan_name: &str,
|
||||
provider_name: &str,
|
||||
currency: &str,
|
||||
) -> Result<serde_json::Value, AppError> {
|
||||
let provider = self
|
||||
.providers
|
||||
.get(provider_name)
|
||||
.ok_or_else(|| AppError::BusinessLogic("Payment provider not supported".into()))?;
|
||||
|
||||
let user = self
|
||||
let fiat_amount_minimal = self
|
||||
.repo
|
||||
.get_user_by_tg_id(tg_id)
|
||||
.get_plan_price(plan_name, currency)
|
||||
.await?
|
||||
.ok_or(AppError::UserNotFound)?;
|
||||
self.repo
|
||||
.buy_subscription(user.id, plan_name)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
.ok_or_else(|| {
|
||||
AppError::BusinessLogic("Тариф недоступен для данного региона".into())
|
||||
})?;
|
||||
|
||||
let revshare_bonus = (price_usd as i64) * 10;
|
||||
if revshare_bonus > 0 {
|
||||
let _ = self.repo.reward_referrer(user.id, revshare_bonus).await;
|
||||
// --- ДИНАМИЧЕСКИЙ ЗАПРОС КУРСА TON ---
|
||||
let client = reqwest::Client::new();
|
||||
let rate_res: serde_json::Value = client
|
||||
.get("https://tonapi.io/v2/rates?tokens=ton¤cies=usd,rub")
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(format!("Ошибка сети при запросе курса: {}", e)))?
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(format!("Ошибка парсинга курса TON: {}", e)))?;
|
||||
|
||||
let currency_lower = currency.to_lowercase();
|
||||
let rate_f64 = rate_res["rates"]["TON"]["prices"][¤cy_lower]
|
||||
.as_f64()
|
||||
.ok_or_else(|| AppError::Internal("TonAPI не вернул курс для этой валюты".into()))?;
|
||||
|
||||
let ton_rate = rust_decimal::Decimal::from_f64(rate_f64)
|
||||
.ok_or_else(|| AppError::Internal("Ошибка конвертации f64 в Decimal".into()))?;
|
||||
// ----------------------------------------
|
||||
|
||||
let nanotons = Self::calculate_ton_invoice(fiat_amount_minimal, ton_rate)
|
||||
.map_err(AppError::BusinessLogic)?;
|
||||
|
||||
let invoice_id = self
|
||||
.repo
|
||||
.create_invoice(user.id, plan_name, provider_name, currency, nanotons)
|
||||
.await?;
|
||||
|
||||
let invoice_data = InvoiceData {
|
||||
invoice_id,
|
||||
amount: nanotons,
|
||||
currency: currency.into(),
|
||||
};
|
||||
|
||||
provider.create_checkout_session(&invoice_data).await
|
||||
}
|
||||
|
||||
/// Этап 2: Вебхук или ручная проверка оплаты юзером
|
||||
pub async fn confirm_payment(
|
||||
&self,
|
||||
invoice_id: Uuid,
|
||||
external_tx_id: &str,
|
||||
) -> Result<(), AppError> {
|
||||
// 1. Достаем инвойс из БД по ID
|
||||
let invoice = self
|
||||
.repo
|
||||
.get_invoice(invoice_id)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound("Инвойс не найден".into()))?;
|
||||
|
||||
if invoice.status == "paid" {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// 2. Получаем провайдера
|
||||
let provider = self.providers.get("TON").unwrap();
|
||||
|
||||
// 3. Провайдер лезет в блокчейн и проверяет транзакцию
|
||||
let is_valid = provider
|
||||
.verify_payment(external_tx_id, invoice.amount)
|
||||
.await?;
|
||||
|
||||
if !is_valid {
|
||||
self.repo
|
||||
.update_invoice_status(invoice_id, "failed", Some(external_tx_id))
|
||||
.await?;
|
||||
return Err(AppError::BusinessLogic(
|
||||
"Payment verification failed".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// 4. Если всё четко - обновляем статус инвойса на PAID
|
||||
self.repo
|
||||
.update_invoice_status(invoice_id, "paid", Some(external_tx_id))
|
||||
.await?;
|
||||
|
||||
// 5. АКТИВИРУЕМ ТАРИФ
|
||||
self.repo
|
||||
.buy_subscription(invoice.user_id, &invoice.plan_name)
|
||||
.await?;
|
||||
|
||||
// 6. Выдаем реферальный бонус (10% от суммы инвойса)
|
||||
let revshare_bonus = invoice.amount / 10;
|
||||
let _ = self
|
||||
.repo
|
||||
.reward_referrer(invoice.user_id, revshare_bonus)
|
||||
.await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn activate_trial(&self, tg_id: i64) -> Result<(), AppError> {
|
||||
let user = self
|
||||
.repo
|
||||
.get_user_by_tg_id(tg_id)
|
||||
.await?
|
||||
.ok_or(AppError::UserNotFound)?;
|
||||
pub async fn activate_trial(&self, user: &User) -> Result<(), AppError> {
|
||||
if user.has_used_trial {
|
||||
return Err(AppError::TrialAlreadyUsed);
|
||||
}
|
||||
@@ -68,4 +183,38 @@ impl BillingService {
|
||||
.await
|
||||
.map_err(AppError::Database)
|
||||
}
|
||||
|
||||
pub fn calculate_ton_invoice(
|
||||
fiat_amount_minimal: i64, // В копейках/центах из БД
|
||||
ton_rate_to_currency: rust_decimal::Decimal, // Полный путь до типа, чтобы не было конфликтов имён
|
||||
) -> Result<i64, String> {
|
||||
use rust_decimal::prelude::{ToPrimitive, Zero};
|
||||
use rust_decimal::Decimal;
|
||||
|
||||
// 1. Создаем константу 100 безопасным способом (100.00)
|
||||
let one_hundred = Decimal::new(100, 0);
|
||||
|
||||
// Избавляемся от копеек/центов, переводим в базовый фиат
|
||||
let fiat_base = Decimal::from(fiat_amount_minimal) / one_hundred;
|
||||
|
||||
// 2. Считаем чистый объем TON
|
||||
if ton_rate_to_currency.is_zero() {
|
||||
return Err("Курс TON не может быть нулевым".to_string());
|
||||
}
|
||||
let ton_clean = fiat_base / ton_rate_to_currency;
|
||||
|
||||
// 3. Накидываем 2% буфера (умножаем на 1.02 -> 102 со сдвигом запятой на 2 знака)
|
||||
let buffer_multiplier = Decimal::new(102, 2);
|
||||
let ton_with_buffer = ton_clean * buffer_multiplier;
|
||||
|
||||
// 4. Переводим в системные nanoTON для блокчейна (умножаем на 1_000_000_000)
|
||||
let nanoton_multiplier = Decimal::new(1_000_000_000, 0);
|
||||
|
||||
let nanotons = (ton_with_buffer * nanoton_multiplier)
|
||||
.round()
|
||||
.to_i64()
|
||||
.ok_or_else(|| "Ошибка перевода типов Decimal в i64".to_string())?;
|
||||
|
||||
Ok(nanotons)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ use axum::{
|
||||
routing::{delete, get, post},
|
||||
Json, Router,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -14,13 +15,21 @@ use crate::{
|
||||
modules::auth::{admin_guard, auth_guard},
|
||||
};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct AddNodeRequest {
|
||||
#[serde(flatten)]
|
||||
pub payload: CreateNodePayload,
|
||||
pub ssh_password: String,
|
||||
}
|
||||
|
||||
/// Публичный роутер для юзеров
|
||||
pub fn public_router(state: ApiState) -> Router<ApiState> {
|
||||
Router::new()
|
||||
.route("/", get(get_nodes))
|
||||
.route("/stats", get(get_node_stats))
|
||||
.route("/routing", get(get_node_routing))
|
||||
.route_layer(middleware::from_fn_with_state(state.clone(), auth_guard))
|
||||
}
|
||||
|
||||
/// Секретный роутер для Nexus (Admin)
|
||||
pub fn admin_router(state: ApiState) -> Router<ApiState> {
|
||||
Router::new()
|
||||
@@ -36,14 +45,55 @@ async fn get_nodes(State(state): State<ApiState>) -> Result<Json<Vec<VpnNode>>,
|
||||
Ok(Json(nodes))
|
||||
}
|
||||
|
||||
async fn get_node_stats(State(state): State<ApiState>) -> Result<Json<Value>, AppError> {
|
||||
let nodes = state.node_service.get_active_nodes().await?;
|
||||
|
||||
let stats: Vec<_> = nodes
|
||||
.into_iter()
|
||||
.map(|n| {
|
||||
json!({
|
||||
"country_code": n.country_code,
|
||||
"current_load": n.current_load,
|
||||
"status": n.status,
|
||||
"uptime": "99.9%"
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(json!(stats)))
|
||||
}
|
||||
|
||||
async fn add_node(
|
||||
State(state): State<ApiState>,
|
||||
Json(payload): Json<CreateNodePayload>,
|
||||
Json(req): Json<AddNodeRequest>,
|
||||
) -> Result<Json<VpnNode>, AppError> {
|
||||
let node = state.node_service.add_node(payload).await?;
|
||||
// Pass the separated payload and password to the service
|
||||
let node = state
|
||||
.node_service
|
||||
.add_node(req.payload, &req.ssh_password)
|
||||
.await?;
|
||||
|
||||
Ok(Json(node))
|
||||
}
|
||||
|
||||
async fn get_node_routing(State(state): State<ApiState>) -> Result<Json<Value>, AppError> {
|
||||
let nodes = state.node_service.get_active_nodes().await?;
|
||||
|
||||
let routing: Vec<_> = nodes
|
||||
.into_iter()
|
||||
.map(|n| {
|
||||
json!({
|
||||
"ip": n.ip_address,
|
||||
"port": n.port,
|
||||
"region": n.country_code,
|
||||
"supported_protocols": ["tcp", "udp"],
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(json!(routing)))
|
||||
}
|
||||
|
||||
async fn remove_node(
|
||||
State(state): State<ApiState>,
|
||||
Path(id): Path<Uuid>,
|
||||
|
||||
@@ -8,6 +8,10 @@ use crate::{
|
||||
use std::sync::Arc;
|
||||
use uuid::Uuid;
|
||||
|
||||
use ssh2::Session;
|
||||
use std::io::Read;
|
||||
use std::net::TcpStream;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct NodeService {
|
||||
repo: Arc<dyn AppRepository>,
|
||||
@@ -25,11 +29,92 @@ impl NodeService {
|
||||
.map_err(AppError::Database)
|
||||
}
|
||||
|
||||
pub async fn add_node(&self, payload: CreateNodePayload) -> Result<VpnNode, AppError> {
|
||||
self.repo
|
||||
.add_vpn_node(payload)
|
||||
pub async fn add_node(
|
||||
&self,
|
||||
payload: CreateNodePayload,
|
||||
ssh_password: &str,
|
||||
) -> Result<VpnNode, AppError> {
|
||||
let ip_address = payload.ip_address.clone();
|
||||
let password = ssh_password.to_string();
|
||||
|
||||
// Orchestrate SSH asynchronously so it does not block the Axum runtime
|
||||
tokio::task::spawn_blocking(move || Self::provision_node_via_ssh(&ip_address, &password))
|
||||
.await
|
||||
.map_err(AppError::Database)
|
||||
.map_err(|_| AppError::Internal("SSH Task panicked".into()))??;
|
||||
|
||||
let node = self.repo.add_vpn_node(payload).await?;
|
||||
Ok(node)
|
||||
}
|
||||
fn provision_node_via_ssh(ip: &str, password: &str) -> Result<(), AppError> {
|
||||
use std::fs;
|
||||
use std::net::{SocketAddr, TcpStream};
|
||||
use std::time::Duration;
|
||||
|
||||
// 1. Просто считываем уже гарантированно созданный на старте сервера ключ
|
||||
let key_path = "keys/netrunner_master_ed25519";
|
||||
let pub_key = fs::read_to_string(format!("{}.pub", key_path)).map_err(|e| {
|
||||
AppError::Internal(format!("Критическая ошибка: Мастер-ключ не найден: {}", e))
|
||||
})?;
|
||||
|
||||
// 2. Парсим адрес и жестко ограничиваем время ожидания ответа от удаленной ноды
|
||||
let socket_addr = format!("{}:22", ip)
|
||||
.parse::<SocketAddr>()
|
||||
.map_err(|e| AppError::Internal(format!("Некорректный формат IP-адреса VPS: {}", e)))?;
|
||||
|
||||
// Защита рантайма: если узел лежит, таск отвалится через 15 секунд, не блокируя поток выполнения
|
||||
let tcp =
|
||||
TcpStream::connect_timeout(&socket_addr, Duration::from_secs(15)).map_err(|e| {
|
||||
AppError::Internal(format!(
|
||||
"Узел не ответил на запрос подключения по SSH (Timeout 15s): {}",
|
||||
e
|
||||
))
|
||||
})?;
|
||||
|
||||
let mut sess = Session::new().unwrap();
|
||||
sess.set_tcp_stream(tcp);
|
||||
sess.handshake()
|
||||
.map_err(|e| AppError::Internal(format!("Сбой SSH Handshake: {}", e)))?;
|
||||
|
||||
sess.userauth_password("root", password).map_err(|_| {
|
||||
AppError::Unauthorized("Ошибка авторизации по SSH. Неверный пароль root?".into())
|
||||
})?;
|
||||
|
||||
// 3. Подготавливаем команды автоматизации
|
||||
let gh_token =
|
||||
std::env::var("GHCR_TOKEN").expect("КРИТИЧЕСКАЯ ОШИБКА: GHCR_TOKEN missing!");
|
||||
let init_script = fs::read_to_string("templates/init_node.sh")
|
||||
.unwrap_or_else(|_| "echo 'init_node.sh missing'".into());
|
||||
|
||||
// Скрипт укрепления (Hardening): Прокидывает ключ и жестко вырубает пароли
|
||||
let harden_script = format!(
|
||||
"mkdir -p /root/.ssh\n\
|
||||
echo '{}' > /root/.ssh/authorized_keys\n\
|
||||
chmod 600 /root/.ssh/authorized_keys\n\
|
||||
chmod 700 /root/.ssh\n\
|
||||
sed -i -E 's/^#?PasswordAuthentication.*/PasswordAuthentication no/' /etc/ssh/sshd_config\n\
|
||||
sed -i -E 's/^#?PermitRootLogin.*/PermitRootLogin prohibit-password/' /etc/ssh/sshd_config\n\
|
||||
sed -i -E 's/^#?PasswordAuthentication.*/PasswordAuthentication no/' /etc/ssh/sshd_config.d/* 2>/dev/null || true\n\
|
||||
systemctl restart ssh || systemctl restart sshd",
|
||||
pub_key.trim()
|
||||
);
|
||||
|
||||
let full_command = format!(
|
||||
"NODE_IP='{}' GH_TOKEN='{}'\n{}\n{}",
|
||||
ip, gh_token, init_script, harden_script
|
||||
);
|
||||
|
||||
// 4. Исполняем сценарий развертывания контейнеров в изолированной сессии
|
||||
let mut channel = sess.channel_session().unwrap();
|
||||
channel
|
||||
.exec(&full_command)
|
||||
.map_err(|e| AppError::Internal(format!("Ошибка старта команд деплоя: {}", e)))?;
|
||||
|
||||
let mut output = String::new();
|
||||
channel.read_to_string(&mut output).unwrap();
|
||||
tracing::info!("SSH Execution Output from {}:\n{}", ip, output);
|
||||
|
||||
channel.wait_close().unwrap();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn delete_node(&self, node_id: Uuid) -> Result<(), AppError> {
|
||||
|
||||
@@ -43,7 +43,7 @@ async fn submit_hack_result(
|
||||
) -> Result<Json<Value>, AppError> {
|
||||
let reward = state
|
||||
.user_service
|
||||
.add_hack_reward(user.tg_id, payload.score.clamp(0, 500))
|
||||
.add_hack_reward(user.id, payload.score.clamp(0, 500))
|
||||
.await?;
|
||||
Ok(Json(json!({"status": "success", "reward_added": reward})))
|
||||
}
|
||||
|
||||
@@ -39,17 +39,21 @@ impl UserService {
|
||||
.await
|
||||
.map_err(AppError::Database)
|
||||
}
|
||||
|
||||
#[instrument(skip(self))]
|
||||
pub async fn add_hack_reward(&self, tg_id: i64, points: i64) -> Result<i64, AppError> {
|
||||
let user = self
|
||||
.get_user_profile(tg_id)
|
||||
.await?
|
||||
.ok_or(AppError::UserNotFound)?;
|
||||
pub async fn add_hack_reward(&self, user_id: Uuid, points: i64) -> Result<i64, AppError> {
|
||||
let success = self
|
||||
.repo
|
||||
.consume_ticket_and_reward(user.id, points)
|
||||
.consume_ticket_and_reward(user_id, points)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
.map_err(|e| {
|
||||
if e == "TOO_FREQUENT" {
|
||||
AppError::BusinessLogic("Слишком частые попытки взлома. Минимальный интервал между играми — 45 секунд.".into())
|
||||
} else {
|
||||
AppError::Internal(format!("Критический сбой СУБД при фиксации игры: {}", e))
|
||||
}
|
||||
})?;
|
||||
|
||||
Ok(if success { points } else { 0 })
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user