1223 lines
45 KiB
Markdown
1223 lines
45 KiB
Markdown
|
|
# 🎯 Анализ: Система тарифов с выбором локаций + скидки за период
|
|||
|
|
|
|||
|
|
## 📊 Текущее состояние
|
|||
|
|
|
|||
|
|
### Что есть сейчас:
|
|||
|
|
1. ✅ **Тарифы** определены в `/app/plans/page.tsx`:
|
|||
|
|
- Trial (7 дней бесплатно)
|
|||
|
|
- Start (100₽/мес, 1 локация)
|
|||
|
|
- Plus (299₽/мес, 3 локации)
|
|||
|
|
- Max (350₽/мес, 15+ локаций)
|
|||
|
|
|
|||
|
|
2. ✅ **SetupWizard** (`/components/SetupWizard.tsx`):
|
|||
|
|
- Есть UI для выбора локаций
|
|||
|
|
- Но используется ПОСЛЕ покупки (в wizard'е настройки)
|
|||
|
|
- НЕ влияет на создание пользователя
|
|||
|
|
|
|||
|
|
3. ✅ **API создания пользователя** (`/app/api/create-user/route.ts`):
|
|||
|
|
- Создает пользователя с фиксированными inbounds
|
|||
|
|
- НЕТ привязки к нодам/локациям
|
|||
|
|
- Используется hardcoded список inbounds:
|
|||
|
|
```typescript
|
|||
|
|
inbounds: {
|
|||
|
|
vless: ['VLESS TCP', 'VLESS Reality'],
|
|||
|
|
vmess: ['VMess WS'],
|
|||
|
|
trojan: ['Trojan TCP']
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
4. ❌ **Что НЕ работает**:
|
|||
|
|
- Выбор локаций НЕ сохраняется в Marzban
|
|||
|
|
- Нет связи между тарифом и доступными нодами
|
|||
|
|
- Нет скидок за длительные периоды (3 мес, 6 мес, год)
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 🔍 Анализ Marzban API
|
|||
|
|
|
|||
|
|
### Доступные endpoints (из https://panel.umbrix.net/docs):
|
|||
|
|
|
|||
|
|
#### 1. **Управление нодами** (`/api/nodes`):
|
|||
|
|
```bash
|
|||
|
|
GET /api/nodes # Список всех нод с их параметрами
|
|||
|
|
GET /api/node/{node_id} # Детали конкретной ноды
|
|||
|
|
POST /api/node # Создать ноду
|
|||
|
|
PUT /api/node/{node_id} # Изменить ноду
|
|||
|
|
DELETE /api/node/{node_id} # Удалить ноду
|
|||
|
|
GET /api/nodes/usage # Статистика по нодам
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**Структура ноды** (`NodeResponse`):
|
|||
|
|
```json
|
|||
|
|
{
|
|||
|
|
"id": 1,
|
|||
|
|
"name": "🇺🇸 USA Node",
|
|||
|
|
"address": "194.113.210.187",
|
|||
|
|
"port": 62050,
|
|||
|
|
"api_port": 62051,
|
|||
|
|
"status": "connected",
|
|||
|
|
"message": null,
|
|||
|
|
"xray_version": "1.8.21",
|
|||
|
|
"usage": {
|
|||
|
|
"uplink": 123456789,
|
|||
|
|
"downlink": 987654321
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### 2. **Inbounds** (`/api/inbounds`):
|
|||
|
|
```bash
|
|||
|
|
GET /api/inbounds # Список всех inbounds (протоколов)
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**Структура inbound**:
|
|||
|
|
```json
|
|||
|
|
{
|
|||
|
|
"tag": "VLESS TCP",
|
|||
|
|
"protocol": "vless",
|
|||
|
|
"network": "tcp",
|
|||
|
|
"tls": "reality",
|
|||
|
|
"port": 443
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### 3. **Создание пользователя** (`POST /api/user`):
|
|||
|
|
```json
|
|||
|
|
{
|
|||
|
|
"username": "test_user",
|
|||
|
|
"status": "active",
|
|||
|
|
"expire": 1735689600, // Unix timestamp
|
|||
|
|
"data_limit": 107374182400, // Bytes
|
|||
|
|
"data_limit_reset_strategy": "no_reset",
|
|||
|
|
"proxies": {
|
|||
|
|
"vmess": {"id": "uuid-here"},
|
|||
|
|
"vless": {},
|
|||
|
|
"trojan": {}
|
|||
|
|
},
|
|||
|
|
"inbounds": {
|
|||
|
|
"vless": ["VLESS TCP", "VLESS Reality"],
|
|||
|
|
"vmess": ["VMess WS"],
|
|||
|
|
"trojan": ["Trojan TCP"]
|
|||
|
|
},
|
|||
|
|
"note": "Тариф Plus, TG: 12345"
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 🎨 Новая воронка с выбором локаций
|
|||
|
|
|
|||
|
|
### Идеальный User Flow:
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
1. Пользователь открывает /plans
|
|||
|
|
↓
|
|||
|
|
2. Выбирает тариф (Start/Plus/Max)
|
|||
|
|
↓
|
|||
|
|
3. Видит варианты периода оплаты:
|
|||
|
|
┌─────────────────────────────────────┐
|
|||
|
|
│ ⭕ 1 месяц - 299₽ (без скидки) │
|
|||
|
|
│ ⭕ 3 месяца - 807₽ (-10% = 897₽) │
|
|||
|
|
│ ⭕ 6 месяцев - 1523₽ (-15% = 1794₽)│
|
|||
|
|
│ ⭕ 1 год - 2870₽ (-20% = 3588₽)│
|
|||
|
|
└─────────────────────────────────────┘
|
|||
|
|
↓
|
|||
|
|
4. Выбирает локации (в зависимости от тарифа):
|
|||
|
|
┌─────────────────────────────────────┐
|
|||
|
|
│ Start: 1 локация из списка │
|
|||
|
|
│ Plus: 3 локации из списка │
|
|||
|
|
│ Max: Все локации │
|
|||
|
|
└─────────────────────────────────────┘
|
|||
|
|
|
|||
|
|
Список локаций (из Marzban API):
|
|||
|
|
☐ 🇳🇱 Нидерланды (ping: 15ms)
|
|||
|
|
☐ 🇩🇪 Германия (ping: 20ms)
|
|||
|
|
☐ 🇺🇸 США (ping: 120ms)
|
|||
|
|
☐ 🇸🇬 Сингапур (ping: 180ms)
|
|||
|
|
☑ 🇯🇵 Япония (ping: 160ms)
|
|||
|
|
☐ 🇬🇧 Великобритания (ping: 35ms)
|
|||
|
|
↓
|
|||
|
|
5. Нажимает "Оплатить" → Payment gateway
|
|||
|
|
↓
|
|||
|
|
6. После оплаты → API создает пользователя с выбранными локациями
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 🚀 Техническая реализация
|
|||
|
|
|
|||
|
|
### **Этап 1: Получить список нод из Marzban**
|
|||
|
|
|
|||
|
|
Создать новый API endpoint `/api/nodes`:
|
|||
|
|
|
|||
|
|
```typescript
|
|||
|
|
// app/api/nodes/route.ts
|
|||
|
|
import { NextResponse } from 'next/server';
|
|||
|
|
|
|||
|
|
const MARZBAN_API = process.env.MARZBAN_PANEL_URL;
|
|||
|
|
const ADMIN_USERNAME = process.env.MARZBAN_ADMIN_USERNAME;
|
|||
|
|
const ADMIN_PASSWORD = process.env.MARZBAN_ADMIN_PASSWORD;
|
|||
|
|
|
|||
|
|
export async function GET() {
|
|||
|
|
try {
|
|||
|
|
// 1. Получить токен админа
|
|||
|
|
const tokenResponse = await fetch(`${MARZBAN_API}/api/admin/token`, {
|
|||
|
|
method: 'POST',
|
|||
|
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|||
|
|
body: `grant_type=password&username=${ADMIN_USERNAME}&password=${ADMIN_PASSWORD}`,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const { access_token } = await tokenResponse.json();
|
|||
|
|
|
|||
|
|
// 2. Получить список нод
|
|||
|
|
const nodesResponse = await fetch(`${MARZBAN_API}/api/nodes`, {
|
|||
|
|
headers: { 'Authorization': `Bearer ${access_token}` },
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const nodes = await nodesResponse.json();
|
|||
|
|
|
|||
|
|
// 3. Форматировать для фронтенда
|
|||
|
|
const locations = nodes
|
|||
|
|
.filter(node => node.status === 'connected')
|
|||
|
|
.map(node => ({
|
|||
|
|
id: node.id,
|
|||
|
|
name: node.name, // "🇺🇸 USA Node"
|
|||
|
|
address: node.address,
|
|||
|
|
ping: calculatePing(node.address), // TODO: реальный ping
|
|||
|
|
country: extractCountry(node.name), // "US"
|
|||
|
|
}));
|
|||
|
|
|
|||
|
|
return NextResponse.json({ success: true, locations });
|
|||
|
|
} catch (error) {
|
|||
|
|
return NextResponse.json({ success: false, error: error.message }, { status: 500 });
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function extractCountry(nodeName: string): string {
|
|||
|
|
// Извлечь код страны из emoji флага
|
|||
|
|
const match = nodeName.match(/🇦-🇿{2}/);
|
|||
|
|
return match ? getFlagCode(match[0]) : 'Unknown';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function calculatePing(address: string): string {
|
|||
|
|
// TODO: Реальный ping check (или хардкод по регионам)
|
|||
|
|
const mockPings = {
|
|||
|
|
'194.113.210.187': '120ms', // USA
|
|||
|
|
'193.168.175.128': '15ms', // NL
|
|||
|
|
// ...
|
|||
|
|
};
|
|||
|
|
return mockPings[address] || '50ms';
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
### **Этап 2: Страница выбора тарифа с периодом**
|
|||
|
|
|
|||
|
|
Обновить `/app/plans/page.tsx`:
|
|||
|
|
|
|||
|
|
```tsx
|
|||
|
|
'use client';
|
|||
|
|
|
|||
|
|
import { useState, useEffect } from 'react';
|
|||
|
|
import { Check, ChevronRight } from 'lucide-react';
|
|||
|
|
|
|||
|
|
type Period = '1month' | '3months' | '6months' | '1year';
|
|||
|
|
type PlanType = 'start' | 'plus' | 'max';
|
|||
|
|
|
|||
|
|
interface Location {
|
|||
|
|
id: number;
|
|||
|
|
name: string;
|
|||
|
|
country: string;
|
|||
|
|
ping: string;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export default function Plans() {
|
|||
|
|
const [selectedPlan, setSelectedPlan] = useState<PlanType | null>(null);
|
|||
|
|
const [selectedPeriod, setSelectedPeriod] = useState<Period>('1month');
|
|||
|
|
const [selectedLocations, setSelectedLocations] = useState<number[]>([]);
|
|||
|
|
const [availableLocations, setAvailableLocations] = useState<Location[]>([]);
|
|||
|
|
|
|||
|
|
// Загрузить локации из API
|
|||
|
|
useEffect(() => {
|
|||
|
|
async function fetchLocations() {
|
|||
|
|
const response = await fetch('/api/nodes');
|
|||
|
|
const data = await response.json();
|
|||
|
|
if (data.success) {
|
|||
|
|
setAvailableLocations(data.locations);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
fetchLocations();
|
|||
|
|
}, []);
|
|||
|
|
|
|||
|
|
// Тарифы с ценами за разные периоды
|
|||
|
|
const plans = {
|
|||
|
|
start: {
|
|||
|
|
name: 'Start',
|
|||
|
|
locations: 1,
|
|||
|
|
prices: {
|
|||
|
|
'1month': 100,
|
|||
|
|
'3months': 270, // -10%
|
|||
|
|
'6months': 510, // -15%
|
|||
|
|
'1year': 960, // -20%
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
plus: {
|
|||
|
|
name: 'Plus',
|
|||
|
|
locations: 3,
|
|||
|
|
prices: {
|
|||
|
|
'1month': 299,
|
|||
|
|
'3months': 807, // -10%
|
|||
|
|
'6months': 1523, // -15%
|
|||
|
|
'1year': 2870, // -20%
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
max: {
|
|||
|
|
name: 'Max',
|
|||
|
|
locations: 999, // Все
|
|||
|
|
prices: {
|
|||
|
|
'1month': 350,
|
|||
|
|
'3months': 945, // -10%
|
|||
|
|
'6months': 1785, // -15%
|
|||
|
|
'1year': 3360, // -20%
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const handlePlanSelect = (plan: PlanType) => {
|
|||
|
|
setSelectedPlan(plan);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const handlePeriodSelect = (period: Period) => {
|
|||
|
|
setSelectedPeriod(period);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const handleLocationToggle = (locationId: number) => {
|
|||
|
|
if (!selectedPlan) return;
|
|||
|
|
|
|||
|
|
const maxLocations = plans[selectedPlan].locations;
|
|||
|
|
|
|||
|
|
if (selectedLocations.includes(locationId)) {
|
|||
|
|
setSelectedLocations(selectedLocations.filter(id => id !== locationId));
|
|||
|
|
} else if (maxLocations === 999 || selectedLocations.length < maxLocations) {
|
|||
|
|
setSelectedLocations([...selectedLocations, locationId]);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const handlePurchase = async () => {
|
|||
|
|
if (!selectedPlan || selectedLocations.length === 0) {
|
|||
|
|
alert('Выберите тариф и хотя бы одну локацию');
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Отправить на payment gateway
|
|||
|
|
const response = await fetch('/api/create-payment', {
|
|||
|
|
method: 'POST',
|
|||
|
|
headers: { 'Content-Type': 'application/json' },
|
|||
|
|
body: JSON.stringify({
|
|||
|
|
plan: selectedPlan,
|
|||
|
|
period: selectedPeriod,
|
|||
|
|
locations: selectedLocations,
|
|||
|
|
telegramId: getTelegramId(),
|
|||
|
|
}),
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const data = await response.json();
|
|||
|
|
if (data.success) {
|
|||
|
|
window.location.href = data.paymentUrl;
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const getDiscountPercent = (period: Period): number => {
|
|||
|
|
const discounts = {
|
|||
|
|
'1month': 0,
|
|||
|
|
'3months': 10,
|
|||
|
|
'6months': 15,
|
|||
|
|
'1year': 20,
|
|||
|
|
};
|
|||
|
|
return discounts[period];
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const calculateSavings = (plan: PlanType, period: Period): number => {
|
|||
|
|
const basePrices = plans[plan].prices;
|
|||
|
|
const monthlyPrice = basePrices['1month'];
|
|||
|
|
const months = { '1month': 1, '3months': 3, '6months': 6, '1year': 12 }[period];
|
|||
|
|
const fullPrice = monthlyPrice * months;
|
|||
|
|
const discountedPrice = basePrices[period];
|
|||
|
|
return fullPrice - discountedPrice;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<div className="min-h-screen bg-slate-900 text-white p-4">
|
|||
|
|
<h1 className="text-2xl font-bold mb-6">Выбор тарифа</h1>
|
|||
|
|
|
|||
|
|
{/* Шаг 1: Выбор тарифа */}
|
|||
|
|
{!selectedPlan && (
|
|||
|
|
<div className="space-y-4">
|
|||
|
|
{Object.entries(plans).map(([key, plan]) => (
|
|||
|
|
<button
|
|||
|
|
key={key}
|
|||
|
|
onClick={() => handlePlanSelect(key as PlanType)}
|
|||
|
|
className="w-full p-4 bg-slate-800 rounded-lg text-left hover:bg-slate-700"
|
|||
|
|
>
|
|||
|
|
<h3 className="text-xl font-bold">{plan.name}</h3>
|
|||
|
|
<p className="text-slate-400">
|
|||
|
|
{plan.locations === 999 ? 'Все локации' : `${plan.locations} локаций`}
|
|||
|
|
</p>
|
|||
|
|
<p className="text-blue-400 mt-2">От {plan.prices['1month']}₽/мес</p>
|
|||
|
|
</button>
|
|||
|
|
))}
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{/* Шаг 2: Выбор периода */}
|
|||
|
|
{selectedPlan && !selectedLocations.length && (
|
|||
|
|
<div className="space-y-4">
|
|||
|
|
<button onClick={() => setSelectedPlan(null)} className="text-blue-400">← Назад</button>
|
|||
|
|
|
|||
|
|
<h2 className="text-xl font-bold mb-4">Выберите период</h2>
|
|||
|
|
|
|||
|
|
{Object.entries(plans[selectedPlan].prices).map(([period, price]) => {
|
|||
|
|
const discount = getDiscountPercent(period as Period);
|
|||
|
|
const savings = calculateSavings(selectedPlan, period as Period);
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<button
|
|||
|
|
key={period}
|
|||
|
|
onClick={() => {
|
|||
|
|
handlePeriodSelect(period as Period);
|
|||
|
|
// Переходим к выбору локаций
|
|||
|
|
}}
|
|||
|
|
className="w-full p-4 bg-slate-800 rounded-lg text-left hover:bg-slate-700"
|
|||
|
|
>
|
|||
|
|
<div className="flex justify-between items-center">
|
|||
|
|
<div>
|
|||
|
|
<h3 className="text-lg font-bold">
|
|||
|
|
{period === '1month' && '1 месяц'}
|
|||
|
|
{period === '3months' && '3 месяца'}
|
|||
|
|
{period === '6months' && '6 месяцев'}
|
|||
|
|
{period === '1year' && '1 год'}
|
|||
|
|
</h3>
|
|||
|
|
{discount > 0 && (
|
|||
|
|
<p className="text-green-400 text-sm">
|
|||
|
|
💰 Экономия {savings}₽ (-{discount}%)
|
|||
|
|
</p>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
<div className="text-right">
|
|||
|
|
<p className="text-2xl font-bold">{price}₽</p>
|
|||
|
|
{discount > 0 && (
|
|||
|
|
<p className="text-slate-400 line-through text-sm">
|
|||
|
|
{plans[selectedPlan].prices['1month'] *
|
|||
|
|
({ '3months': 3, '6months': 6, '1year': 12 }[period] || 1)}₽
|
|||
|
|
</p>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</button>
|
|||
|
|
);
|
|||
|
|
})}
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{/* Шаг 3: Выбор локаций */}
|
|||
|
|
{selectedPlan && selectedPeriod && (
|
|||
|
|
<div className="space-y-4">
|
|||
|
|
<button onClick={() => setSelectedPeriod('1month')} className="text-blue-400">← Назад</button>
|
|||
|
|
|
|||
|
|
<h2 className="text-xl font-bold mb-2">Выберите локации</h2>
|
|||
|
|
<p className="text-slate-400 mb-4">
|
|||
|
|
{plans[selectedPlan].locations === 999
|
|||
|
|
? 'Доступны все локации'
|
|||
|
|
: `Выберите до ${plans[selectedPlan].locations} локаций`}
|
|||
|
|
</p>
|
|||
|
|
|
|||
|
|
<div className="space-y-2">
|
|||
|
|
{availableLocations.map(location => (
|
|||
|
|
<button
|
|||
|
|
key={location.id}
|
|||
|
|
onClick={() => handleLocationToggle(location.id)}
|
|||
|
|
disabled={
|
|||
|
|
!selectedLocations.includes(location.id) &&
|
|||
|
|
plans[selectedPlan].locations !== 999 &&
|
|||
|
|
selectedLocations.length >= plans[selectedPlan].locations
|
|||
|
|
}
|
|||
|
|
className={`w-full p-4 rounded-lg border transition-colors ${
|
|||
|
|
selectedLocations.includes(location.id)
|
|||
|
|
? 'bg-blue-600 border-blue-400'
|
|||
|
|
: 'bg-slate-800 border-slate-700 hover:border-slate-600'
|
|||
|
|
}`}
|
|||
|
|
>
|
|||
|
|
<div className="flex justify-between items-center">
|
|||
|
|
<div className="flex items-center gap-3">
|
|||
|
|
{selectedLocations.includes(location.id) && (
|
|||
|
|
<Check className="w-5 h-5 text-white" />
|
|||
|
|
)}
|
|||
|
|
<span>{location.name}</span>
|
|||
|
|
</div>
|
|||
|
|
<span className="text-slate-400 text-sm">{location.ping}</span>
|
|||
|
|
</div>
|
|||
|
|
</button>
|
|||
|
|
))}
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{selectedLocations.length > 0 && (
|
|||
|
|
<button
|
|||
|
|
onClick={handlePurchase}
|
|||
|
|
className="w-full py-4 bg-blue-600 hover:bg-blue-700 rounded-lg font-bold mt-6"
|
|||
|
|
>
|
|||
|
|
Перейти к оплате {plans[selectedPlan].prices[selectedPeriod]}₽
|
|||
|
|
<ChevronRight className="inline ml-2" />
|
|||
|
|
</button>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function getTelegramId() {
|
|||
|
|
return (window as any).Telegram?.WebApp?.initDataUnsafe?.user?.id || null;
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
### **Этап 3: Обновить API создания пользователя**
|
|||
|
|
|
|||
|
|
Модифицировать `/app/api/create-user/route.ts`:
|
|||
|
|
|
|||
|
|
```typescript
|
|||
|
|
export async function POST(request: NextRequest) {
|
|||
|
|
const {
|
|||
|
|
planType,
|
|||
|
|
period, // NEW: '1month' | '3months' | '6months' | '1year'
|
|||
|
|
locationIds, // NEW: [1, 3, 5] - выбранные ID нод
|
|||
|
|
telegramId,
|
|||
|
|
telegramUsername,
|
|||
|
|
firstName,
|
|||
|
|
lastName
|
|||
|
|
} = await request.json();
|
|||
|
|
|
|||
|
|
// 1. Получить токен админа (same as before)
|
|||
|
|
|
|||
|
|
// 2. Определить параметры тарифа с учетом периода
|
|||
|
|
const planConfig = getPlanConfig(planType, period);
|
|||
|
|
|
|||
|
|
// 3. Получить список нод и их inbounds
|
|||
|
|
const nodesResponse = await fetch(`${MARZBAN_API}/api/nodes`, {
|
|||
|
|
headers: { 'Authorization': `Bearer ${access_token}` },
|
|||
|
|
});
|
|||
|
|
const allNodes = await nodesResponse.json();
|
|||
|
|
|
|||
|
|
// 4. Фильтровать только выбранные ноды
|
|||
|
|
const selectedNodes = allNodes.filter(node => locationIds.includes(node.id));
|
|||
|
|
|
|||
|
|
// 5. Получить inbounds выбранных нод
|
|||
|
|
const inboundsResponse = await fetch(`${MARZBAN_API}/api/inbounds`, {
|
|||
|
|
headers: { 'Authorization': `Bearer ${access_token}` },
|
|||
|
|
});
|
|||
|
|
const allInbounds = await inboundsResponse.json();
|
|||
|
|
|
|||
|
|
// 6. Создать mapping inbounds для выбранных нод
|
|||
|
|
const userInbounds = {
|
|||
|
|
vless: [],
|
|||
|
|
vmess: [],
|
|||
|
|
trojan: []
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
selectedNodes.forEach(node => {
|
|||
|
|
// Добавить inbounds этой ноды в конфиг пользователя
|
|||
|
|
allInbounds
|
|||
|
|
.filter(inbound => inbound.node_id === node.id)
|
|||
|
|
.forEach(inbound => {
|
|||
|
|
if (inbound.protocol === 'vless') {
|
|||
|
|
userInbounds.vless.push(inbound.tag);
|
|||
|
|
} else if (inbound.protocol === 'vmess') {
|
|||
|
|
userInbounds.vmess.push(inbound.tag);
|
|||
|
|
} else if (inbound.protocol === 'trojan') {
|
|||
|
|
userInbounds.trojan.push(inbound.tag);
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// 7. Создать пользователя с кастомными inbounds
|
|||
|
|
const createUserResponse = await fetch(`${MARZBAN_API}/api/user`, {
|
|||
|
|
method: 'POST',
|
|||
|
|
headers: {
|
|||
|
|
'Authorization': `Bearer ${access_token}`,
|
|||
|
|
'Content-Type': 'application/json',
|
|||
|
|
},
|
|||
|
|
body: JSON.stringify({
|
|||
|
|
username: username,
|
|||
|
|
status: 'active',
|
|||
|
|
expire: planConfig.expireTimestamp,
|
|||
|
|
data_limit: planConfig.dataLimitBytes,
|
|||
|
|
data_limit_reset_strategy: 'no_reset',
|
|||
|
|
proxies: {
|
|||
|
|
vmess: { id: randomUUID() },
|
|||
|
|
vless: {},
|
|||
|
|
trojan: {}
|
|||
|
|
},
|
|||
|
|
inbounds: userInbounds, // <-- ГЛАВНОЕ ИЗМЕНЕНИЕ!
|
|||
|
|
note: `Тариф: ${planConfig.name}, Период: ${period}, Локации: ${selectedNodes.map(n => n.name).join(', ')}, TG: ${telegramId}`,
|
|||
|
|
}),
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// Rest of the code...
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function getPlanConfig(planType: PlanType, period: Period) {
|
|||
|
|
const basePlans = {
|
|||
|
|
trial: { name: 'Trial', dataLimitGB: 10, durationDays: 7 },
|
|||
|
|
start: { name: 'Start', dataLimitGB: 50, basePriceRub: 100 },
|
|||
|
|
plus: { name: 'Plus', dataLimitGB: 299, basePriceRub: 299 },
|
|||
|
|
max: { name: 'Max', dataLimitGB: 999999, basePriceRub: 350 },
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const periodMultipliers = {
|
|||
|
|
'1month': { months: 1, discount: 0 },
|
|||
|
|
'3months': { months: 3, discount: 0.10 },
|
|||
|
|
'6months': { months: 6, discount: 0.15 },
|
|||
|
|
'1year': { months: 12, discount: 0.20 },
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const plan = basePlans[planType];
|
|||
|
|
const periodConfig = periodMultipliers[period];
|
|||
|
|
|
|||
|
|
const durationDays = periodConfig.months * 30;
|
|||
|
|
const fullPrice = plan.basePriceRub * periodConfig.months;
|
|||
|
|
const discountedPrice = Math.round(fullPrice * (1 - periodConfig.discount));
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
name: plan.name,
|
|||
|
|
dataLimitBytes: plan.dataLimitGB * 1024 * 1024 * 1024,
|
|||
|
|
expireTimestamp: Math.floor(Date.now() / 1000) + (durationDays * 24 * 60 * 60),
|
|||
|
|
priceRub: discountedPrice,
|
|||
|
|
periodDays: durationDays,
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
### **Этап 4: Payment gateway integration**
|
|||
|
|
|
|||
|
|
Создать `/app/api/create-payment/route.ts`:
|
|||
|
|
|
|||
|
|
```typescript
|
|||
|
|
// Интеграция с платежным шлюзом (Stripe / YooKassa / Coinbase)
|
|||
|
|
export async function POST(request: NextRequest) {
|
|||
|
|
const { plan, period, locations, telegramId } = await request.json();
|
|||
|
|
|
|||
|
|
const planConfig = getPlanConfig(plan, period);
|
|||
|
|
|
|||
|
|
// 1. Создать заказ в БД (pending payment)
|
|||
|
|
const orderId = createOrder({
|
|||
|
|
telegramId,
|
|||
|
|
plan,
|
|||
|
|
period,
|
|||
|
|
locations,
|
|||
|
|
amount: planConfig.priceRub,
|
|||
|
|
status: 'pending',
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// 2. Создать платежную ссылку
|
|||
|
|
const paymentUrl = await createStripeCheckout({
|
|||
|
|
amount: planConfig.priceRub,
|
|||
|
|
currency: 'RUB',
|
|||
|
|
metadata: { orderId, telegramId },
|
|||
|
|
successUrl: `https://app.umbrix.net/payment-success?order=${orderId}`,
|
|||
|
|
cancelUrl: `https://app.umbrix.net/plans`,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
return NextResponse.json({ success: true, paymentUrl });
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
### **Этап 5: Webhook для обработки оплаты**
|
|||
|
|
|
|||
|
|
Создать `/app/api/payment-webhook/route.ts`:
|
|||
|
|
|
|||
|
|
```typescript
|
|||
|
|
// Webhook от платежного провайдера
|
|||
|
|
export async function POST(request: NextRequest) {
|
|||
|
|
const payload = await request.json();
|
|||
|
|
|
|||
|
|
// 1. Verify signature (Stripe/YooKassa)
|
|||
|
|
|
|||
|
|
// 2. Проверить статус оплаты
|
|||
|
|
if (payload.status === 'succeeded') {
|
|||
|
|
const { orderId } = payload.metadata;
|
|||
|
|
|
|||
|
|
// 3. Получить данные заказа из БД
|
|||
|
|
const order = getOrder(orderId);
|
|||
|
|
|
|||
|
|
// 4. Создать пользователя в Marzban
|
|||
|
|
await fetch('/api/create-user', {
|
|||
|
|
method: 'POST',
|
|||
|
|
headers: { 'Content-Type': 'application/json' },
|
|||
|
|
body: JSON.stringify({
|
|||
|
|
planType: order.plan,
|
|||
|
|
period: order.period,
|
|||
|
|
locationIds: order.locations,
|
|||
|
|
telegramId: order.telegramId,
|
|||
|
|
}),
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// 5. Обновить статус заказа
|
|||
|
|
updateOrder(orderId, { status: 'completed' });
|
|||
|
|
|
|||
|
|
// 6. Отправить уведомление в Telegram
|
|||
|
|
sendTelegramMessage(order.telegramId, '✅ Оплата прошла! Ваш VPN активирован.');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return NextResponse.json({ success: true });
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 🤖 Обновление n8n бота
|
|||
|
|
|
|||
|
|
### Добавить новые шаги в workflow:
|
|||
|
|
|
|||
|
|
```json
|
|||
|
|
{
|
|||
|
|
"nodes": [
|
|||
|
|
{
|
|||
|
|
"name": "Show Plans Menu",
|
|||
|
|
"type": "n8n-nodes-base.telegram",
|
|||
|
|
"parameters": {
|
|||
|
|
"chatId": "={{ $json.userId }}",
|
|||
|
|
"text": "💎 Выберите тариф:",
|
|||
|
|
"replyMarkup": {
|
|||
|
|
"inline_keyboard": [
|
|||
|
|
[{"text": "🌍 Start (100₽/мес)", "web_app": {"url": "https://app.umbrix.net/plans?plan=start"}}],
|
|||
|
|
[{"text": "🌎 Plus (299₽/мес)", "web_app": {"url": "https://app.umbrix.net/plans?plan=plus"}}],
|
|||
|
|
[{"text": "🌏 Max (350₽/мес)", "web_app": {"url": "https://app.umbrix.net/plans?plan=max"}}]
|
|||
|
|
]
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
]
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**ВАЖНО**: Все тарифы теперь открывают Mini App с pre-selected планом → выбор периода → выбор локаций → оплата.
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 📋 Чеклист внедрения
|
|||
|
|
|
|||
|
|
### Неделя 1: Backend API
|
|||
|
|
- [ ] Создать `/api/nodes` - список доступных локаций из Marzban
|
|||
|
|
- [ ] Обновить `/api/create-user` - добавить параметры `period` и `locationIds`
|
|||
|
|
- [ ] Добавить функцию `getPlanConfig(planType, period)` с расчетом скидок
|
|||
|
|
- [ ] Реализовать mapping нод → inbounds для создания пользователя
|
|||
|
|
|
|||
|
|
### Неделя 2: Frontend UI
|
|||
|
|
- [ ] Переработать `/app/plans/page.tsx` - мультишаговый выбор:
|
|||
|
|
- Шаг 1: Тариф (Start/Plus/Max)
|
|||
|
|
- Шаг 2: Период (1м/3м/6м/1год) с показом скидок
|
|||
|
|
- Шаг 3: Локации (чекбоксы с ограничением по тарифу)
|
|||
|
|
- [ ] Добавить компонент `PeriodSelector` с расчетом экономии
|
|||
|
|
- [ ] Добавить компонент `LocationSelector` с ping и флагами стран
|
|||
|
|
- [ ] Стилизация под Telegram Design System
|
|||
|
|
|
|||
|
|
### Неделя 3: Payment Integration
|
|||
|
|
- [ ] Выбрать платежный шлюз (YooKassa для RU, Stripe для международных)
|
|||
|
|
- [ ] Создать `/api/create-payment` - генерация платежных ссылок
|
|||
|
|
- [ ] Создать `/api/payment-webhook` - обработка успешных оплат
|
|||
|
|
- [ ] Создать БД таблицу `orders` для хранения pending платежей
|
|||
|
|
- [ ] Добавить страницу `/payment-success` - редирект после оплаты
|
|||
|
|
|
|||
|
|
### Неделя 4: n8n Bot Update
|
|||
|
|
- [ ] Добавить inline кнопки с pre-selected тарифами
|
|||
|
|
- [ ] Обновить WebApp URLs с query параметрами `?plan=start`
|
|||
|
|
- [ ] Добавить уведомления о успешной оплате
|
|||
|
|
- [ ] Добавить команду `/renew` для продления подписки
|
|||
|
|
|
|||
|
|
### Неделя 5: Testing & QA
|
|||
|
|
- [ ] E2E тесты полной воронки (выбор → оплата → создание пользователя)
|
|||
|
|
- [ ] Тестирование на разных тарифах и периодах
|
|||
|
|
- [ ] Проверка корректности создания inbounds для выбранных нод
|
|||
|
|
- [ ] Load testing (100 одновременных покупок)
|
|||
|
|
- [ ] Security audit payment webhook
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 💡 Кардинальные предложения
|
|||
|
|
|
|||
|
|
### **❌ Предложение 1: Динамическое ценообразование - ОТКЛОНЕНО**
|
|||
|
|
|
|||
|
|
~~Вместо фиксированных цен - учитывать нагрузку на ноды~~
|
|||
|
|
|
|||
|
|
**Причина отклонения**: Усложняет ценообразование для пользователей, все локации должны быть одинаковы по цене.
|
|||
|
|
|
|||
|
|
### **Предложение 2: Гибкие пакеты трафика**
|
|||
|
|
|
|||
|
|
Разделить тарифы на "Устройства" и "Трафик":
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
Base Plan (1 устройство):
|
|||
|
|
├── 50 ГБ - 100₽/мес
|
|||
|
|
├── 200 ГБ - 250₽/мес
|
|||
|
|
└── ∞ ГБ - 350₽/мес
|
|||
|
|
|
|||
|
|
+100₽ за каждое доп. устройство
|
|||
|
|
+50₽ за каждую доп. локацию
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### **⚠️ Предложение 3: Семейные планы - ТРЕБУЕТ АНАЛИЗА**
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
Family Plan (5 устройств, безлимит):
|
|||
|
|
- 499₽/мес для семьи
|
|||
|
|
- Каждый член семьи выбирает свои локации
|
|||
|
|
- Общий аккаунт с sub-users в Marzban
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**Проблема**: Marzban не имеет встроенного механизма ограничения количества устройств на уровне API.
|
|||
|
|
|
|||
|
|
**Варианты решения**:
|
|||
|
|
|
|||
|
|
#### Вариант 1: Мониторинг `online_at` (рекомендуемый)
|
|||
|
|
```typescript
|
|||
|
|
// GET /api/user/{username} возвращает:
|
|||
|
|
{
|
|||
|
|
"online_at": "2026-02-08T10:30:00Z", // Последняя активность
|
|||
|
|
"sub_last_user_agent": "v2rayNG/1.8.5"
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Логика:
|
|||
|
|
// - online_at обновляется при каждом подключении
|
|||
|
|
// - Если разница между текущим временем и online_at < 5 минут - устройство активно
|
|||
|
|
// - Подсчитываем активные сессии через периодический poll
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**Ограничения**:
|
|||
|
|
- ❌ НЕТ прямого поля `active_connections` или `device_count`
|
|||
|
|
- ❌ НЕТ поля `ip_limit` в UserCreate
|
|||
|
|
- ✅ ЕСТЬ `online_at` - последняя активность
|
|||
|
|
- ✅ ЕСТЬ `sub_last_user_agent` - информация об устройстве
|
|||
|
|
|
|||
|
|
**Реализация** (см. детали ниже в разделе "Семейные планы: Детальный анализ")
|
|||
|
|
|
|||
|
|
#### Вариант 2: Ограничение на уровне Xray (требует кастомных патчей)
|
|||
|
|
- Модифицировать `xray_config.json` с ограничением по IP
|
|||
|
|
- Требует fork Marzban с кастомной логикой
|
|||
|
|
- **НЕ рекомендуется** для MVP
|
|||
|
|
|
|||
|
|
#### Вариант 3: IP-based limiting (приблизительный)
|
|||
|
|
- Собирать статистику подключений через `/api/user/{username}/usage`
|
|||
|
|
- Отслеживать уникальные IP адреса
|
|||
|
|
- Ограничение: один пользователь может иметь несколько устройств с одним IP (домашняя сеть)
|
|||
|
|
|
|||
|
|
**Решение**: Отложить семейные планы на V2.0 до реализации мониторинга устройств.
|
|||
|
|
|
|||
|
|
### **✅ Предложение 4: Gamification - УЖЕ РЕАЛИЗОВАНО**
|
|||
|
|
|
|||
|
|
**Текущая система рефералов**:
|
|||
|
|
```typescript
|
|||
|
|
// /api/referral/track - отслеживание приглашений
|
|||
|
|
// /api/referral/stats - статистика пользователя
|
|||
|
|
|
|||
|
|
Бонусы:
|
|||
|
|
- +7 дней за каждого друга
|
|||
|
|
- +30 дней milestone bonus за каждые 5 друзей
|
|||
|
|
|
|||
|
|
Формула:
|
|||
|
|
bonusDays = (referralCount * 7) + (Math.floor(referralCount / 5) * 30)
|
|||
|
|
|
|||
|
|
Примеры:
|
|||
|
|
1 друг → +7 дней
|
|||
|
|
5 друзей → +35 дней (+30 milestone)
|
|||
|
|
10 друзей → +70 дней (+60 milestone) = 130 дней
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**Что можно улучшить**:
|
|||
|
|
- Добавить визуальные badges (Bronze/Silver/Gold)
|
|||
|
|
- Добавить leaderboard на странице `/referral`
|
|||
|
|
- Уведомления в Telegram при достижении milestone
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 🔥 Критические улучшения воронки
|
|||
|
|
|
|||
|
|
### **1. A/B Testing периодов оплаты**
|
|||
|
|
|
|||
|
|
```typescript
|
|||
|
|
// Показывать разным пользователям разные default периоды
|
|||
|
|
const defaultPeriod = userId % 2 === 0 ? '3months' : '6months';
|
|||
|
|
|
|||
|
|
// Трекинг конверсии:
|
|||
|
|
// - Какой период чаще выбирают?
|
|||
|
|
// - Какая скидка конвертирует лучше?
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### **2. Urgency триггеры**
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
⏰ Специальное предложение!
|
|||
|
|
🔥 -30% на годовую подписку (осталось 2 часа)
|
|||
|
|
💰 При оплате сегодня - бонус +7 дней
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### **3. Social proof**
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
✅ Уже 1,234 пользователей выбрали тариф Plus
|
|||
|
|
⭐ 4.9/5 - средняя оценка
|
|||
|
|
🌍 Топ-3 локации: 🇳🇱 🇩🇪 🇺🇸
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### **4. Упрощенная воронка для новичков**
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
Вместо 3 шагов (тариф → период → локации):
|
|||
|
|
|
|||
|
|
"Быстрый старт" кнопка:
|
|||
|
|
→ Plus (3 месяца) + Авто-выбор 3 лучших локаций по ping
|
|||
|
|
→ Сразу на оплату (экономия 807₽ вместо 897₽)
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 🎯 Priority Roadmap
|
|||
|
|
|
|||
|
|
### **MVP (2 недели)**:
|
|||
|
|
1. ✅ API `/api/nodes` - список локаций
|
|||
|
|
2. ✅ Обновить `/app/plans/page.tsx` - мультишаговый выбор
|
|||
|
|
3. ✅ Обновить `/api/create-user` - кастомные inbounds
|
|||
|
|
4. ⚠️ YooKassa integration (payment gateway)
|
|||
|
|
|
|||
|
|
### **V1.1 (4 недели)**:
|
|||
|
|
5. Скидки за период (3м/6м/1год)
|
|||
|
|
6. Показ экономии при выборе периода
|
|||
|
|
7. n8n bot updates
|
|||
|
|
|
|||
|
|
### **V1.2 (6 недель)**:
|
|||
|
|
8. ~~Динамическое ценообразование~~ ❌ ОТКЛОНЕНО
|
|||
|
|
9. A/B testing периодов
|
|||
|
|
10. Analytics dashboard (конверсия по воронке)
|
|||
|
|
|
|||
|
|
### **V2.0 (8 недель)**:
|
|||
|
|
11. **Device Monitor Service** - мониторинг устройств через Xray логи
|
|||
|
|
12. Семейные планы (после реализации п.11)
|
|||
|
|
13. Gamification улучшения (badges, leaderboard)
|
|||
|
|
14. Автоматическое продление подписки
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## ❓ Вопросы для обсуждения
|
|||
|
|
|
|||
|
|
1. **Какой payment gateway использовать?**
|
|||
|
|
- YooKassa (для РФ) ✅
|
|||
|
|
- Stripe (международный) ✅
|
|||
|
|
- Crypto (Coinbase Commerce) ?
|
|||
|
|
|
|||
|
|
2. **Какие скидки оптимальны?**
|
|||
|
|
- 3 мес: -10% ✅
|
|||
|
|
- 6 мес: -15% ✅
|
|||
|
|
- 1 год: -20% ✅
|
|||
|
|
|
|||
|
|
3. **~~Нужна ли локация-специфичная цена?~~** ❌ ОТКЛОНЕНО
|
|||
|
|
- Все локации одинаковы ✅
|
|||
|
|
|
|||
|
|
4. **Trial период - как ограничить abuse?**
|
|||
|
|
- 1 trial на Telegram ID ✅
|
|||
|
|
- Требовать номер телефона? ⚠️
|
|||
|
|
- Требовать payment method (0₽ charge)? ⚠️
|
|||
|
|
|
|||
|
|
5. **Семейные планы - реализовывать в MVP?**
|
|||
|
|
- ❌ Отложить на V2.0
|
|||
|
|
- ⚠️ Требует Device Monitor Service (40 часов разработки)
|
|||
|
|
- ✅ Для MVP - добавить в ToS ограничение без технического enforcement
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 📞 Следующие шаги
|
|||
|
|
|
|||
|
|
1. ✅ Проанализировать текущее состояние
|
|||
|
|
2. ✅ Изучить Marzban API
|
|||
|
|
3. ✅ Спроектировать новую воронку
|
|||
|
|
4. ✅ Проанализировать ограничения устройств
|
|||
|
|
5. ✅ Изучить реферальную систему
|
|||
|
|
6. ⏳ **Согласовать со stakeholder'ами**
|
|||
|
|
7. ⏳ **Начать разработку MVP**
|
|||
|
|
|
|||
|
|
**Готово к обсуждению!** 🚀
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 🔬 ДОПОЛНЕНИЕ: Семейные планы - Детальный анализ ограничения устройств
|
|||
|
|
|
|||
|
|
### Проблема:
|
|||
|
|
Marzban **НЕ имеет встроенного механизма ограничения количества одновременных подключений** (devices/connections).
|
|||
|
|
|
|||
|
|
### Анализ Marzban API:
|
|||
|
|
|
|||
|
|
#### ❌ Что НЕ доступно в API:
|
|||
|
|
```json
|
|||
|
|
// POST /api/user - UserCreate schema НЕ имеет:
|
|||
|
|
{
|
|||
|
|
"ip_limit": 5, // ❌ Нет такого поля
|
|||
|
|
"device_limit": 5, // ❌ Нет такого поля
|
|||
|
|
"max_connections": 5, // ❌ Нет такого поля
|
|||
|
|
"active_sessions": [] // ❌ Нет такого поля
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### ✅ Что ДОСТУПНО в API:
|
|||
|
|
```json
|
|||
|
|
// GET /api/user/{username} - UserResponse:
|
|||
|
|
{
|
|||
|
|
"username": "test_user",
|
|||
|
|
"status": "active",
|
|||
|
|
"online_at": "2026-02-08T10:30:00Z", // ✅ Последняя активность
|
|||
|
|
"sub_last_user_agent": "v2rayNG/1.8.5", // ✅ User-Agent устройства
|
|||
|
|
"used_traffic": 123456789,
|
|||
|
|
"data_limit": 107374182400
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// GET /api/user/{username}/usage - UsageResponse:
|
|||
|
|
{
|
|||
|
|
"username": "test_user",
|
|||
|
|
"usages": [
|
|||
|
|
{
|
|||
|
|
"date": "2026-02-08",
|
|||
|
|
"upload": 12345,
|
|||
|
|
"download": 67890,
|
|||
|
|
"total": 80235
|
|||
|
|
}
|
|||
|
|
]
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### Варианты решения:
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
### **Вариант 1: Server-side мониторинг через `online_at` (Рекомендуемый для MVP)**
|
|||
|
|
|
|||
|
|
**Как работает**:
|
|||
|
|
1. Периодически (каждые 30 секунд) опрашиваем `GET /api/user/{username}`
|
|||
|
|
2. Проверяем `online_at` - если разница < 2 минуты → устройство активно
|
|||
|
|
3. Если активных устройств > лимита → временно блокируем (`status: 'disabled'`)
|
|||
|
|
|
|||
|
|
**Реализация**:
|
|||
|
|
|
|||
|
|
```typescript
|
|||
|
|
// app/api/family-plans/monitor-devices/route.ts
|
|||
|
|
|
|||
|
|
import { NextRequest, NextResponse } from 'next/server';
|
|||
|
|
|
|||
|
|
const MARZBAN_API = process.env.MARZBAN_PANEL_URL;
|
|||
|
|
const ADMIN_USERNAME = process.env.MARZBAN_ADMIN_USERNAME;
|
|||
|
|
const ADMIN_PASSWORD = process.env.MARZBAN_ADMIN_PASSWORD;
|
|||
|
|
|
|||
|
|
interface FamilyPlanUser {
|
|||
|
|
username: string;
|
|||
|
|
deviceLimit: number;
|
|||
|
|
lastCheck: Date;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// В production - хранить в Redis или БД
|
|||
|
|
const familyPlanUsers: Map<string, FamilyPlanUser> = new Map();
|
|||
|
|
|
|||
|
|
// Cron job - запускать каждые 30 секунд
|
|||
|
|
export async function POST(request: NextRequest) {
|
|||
|
|
try {
|
|||
|
|
// 1. Получить токен админа
|
|||
|
|
const tokenResponse = await fetch(`${MARZBAN_API}/api/admin/token`, {
|
|||
|
|
method: 'POST',
|
|||
|
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|||
|
|
body: `grant_type=password&username=${ADMIN_USERNAME}&password=${ADMIN_PASSWORD}`,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const { access_token } = await tokenResponse.json();
|
|||
|
|
|
|||
|
|
// 2. Проверить каждого пользователя Family Plan
|
|||
|
|
for (const [username, config] of familyPlanUsers.entries()) {
|
|||
|
|
const userResponse = await fetch(`${MARZBAN_API}/api/user/${username}`, {
|
|||
|
|
headers: { 'Authorization': `Bearer ${access_token}` },
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const user = await userResponse.json();
|
|||
|
|
|
|||
|
|
// 3. Проверить активность
|
|||
|
|
const onlineAt = new Date(user.online_at);
|
|||
|
|
const now = new Date();
|
|||
|
|
const minutesSinceActive = (now.getTime() - onlineAt.getTime()) / 1000 / 60;
|
|||
|
|
|
|||
|
|
// Если пользователь активен (подключался менее 2 минут назад)
|
|||
|
|
if (minutesSinceActive < 2) {
|
|||
|
|
// ПРОБЛЕМА: Мы НЕ МОЖЕМ узнать сколько устройств подключено СЕЙЧАС!
|
|||
|
|
// online_at показывает только ПОСЛЕДНЮЮ активность
|
|||
|
|
|
|||
|
|
// Вариант решения: собирать статистику подключений
|
|||
|
|
// Но это требует доступа к логам Xray на уровне нод
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return NextResponse.json({ success: true, message: 'Device monitoring completed' });
|
|||
|
|
} catch (error) {
|
|||
|
|
return NextResponse.json({ success: false, error: error.message }, { status: 500 });
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**Проблема**: `online_at` показывает только **последнюю** активность, а НЕ **количество активных сессий**.
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
### **Вариант 2: IP-based мониторинг (Приблизительный)**
|
|||
|
|
|
|||
|
|
**Идея**: Отслеживать уникальные IP адреса через usage API.
|
|||
|
|
|
|||
|
|
**Проблема**:
|
|||
|
|
- Несколько устройств могут быть за одним IP (домашний роутер)
|
|||
|
|
- Один телефон может менять IP (мобильный интернет)
|
|||
|
|
- **НЕ точный** метод
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
### **Вариант 3: Кастомизация Xray config (Профессиональный)**
|
|||
|
|
|
|||
|
|
**Идея**: Модифицировать `xray_config.json` для ограничения по IP.
|
|||
|
|
|
|||
|
|
```json
|
|||
|
|
{
|
|||
|
|
"inbounds": [
|
|||
|
|
{
|
|||
|
|
"tag": "VLESS TCP",
|
|||
|
|
"protocol": "vless",
|
|||
|
|
"settings": {
|
|||
|
|
"clients": [
|
|||
|
|
{
|
|||
|
|
"id": "uuid-here",
|
|||
|
|
"email": "test_user@marzban",
|
|||
|
|
"level": 0,
|
|||
|
|
"maxIps": 5 // ← Кастомное поле (требует патч Xray)
|
|||
|
|
}
|
|||
|
|
]
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
]
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**Проблема**:
|
|||
|
|
- Требует fork Xray с кастомной логикой
|
|||
|
|
- Нужно пропатчить Marzban для поддержки `maxIps`
|
|||
|
|
- **Сложно** для MVP
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
### **Вариант 4: Использовать sub-users (Встроенная функция Marzban)**
|
|||
|
|
|
|||
|
|
**Идея**: Создать **родительский аккаунт** + **4 дочерних аккаунта**.
|
|||
|
|
|
|||
|
|
```typescript
|
|||
|
|
// Семейный план:
|
|||
|
|
// 1. Создать главного пользователя: family_john_doe
|
|||
|
|
// 2. Создать 4 sub-users:
|
|||
|
|
// - family_john_doe_device_1
|
|||
|
|
// - family_john_doe_device_2
|
|||
|
|
// - family_john_doe_device_3
|
|||
|
|
// - family_john_doe_device_4
|
|||
|
|
// - family_john_doe_device_5
|
|||
|
|
|
|||
|
|
// Каждый sub-user = отдельное устройство с собственной подпиской
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**Преимущества**:
|
|||
|
|
- ✅ Работает "из коробки" в Marzban
|
|||
|
|
- ✅ Каждое устройство = отдельная подписка
|
|||
|
|
- ✅ Легко контролировать (5 username'ов)
|
|||
|
|
|
|||
|
|
**Недостатки**:
|
|||
|
|
- ❌ Пользователю нужно выдавать 5 разных subscription URLs
|
|||
|
|
- ❌ Неудобно для конечного пользователя
|
|||
|
|
- ❌ Не настоящий "Family Plan" (просто 5 отдельных аккаунтов)
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
### **Рекомендация для V1.0**:
|
|||
|
|
|
|||
|
|
#### MVP подход:
|
|||
|
|
1. **Отказаться от ограничения устройств на техническом уровне**
|
|||
|
|
2. **Полагаться на честность пользователей**
|
|||
|
|
3. **Добавить в ToS**: "Семейный план предназначен для использования в одной семье (до 5 устройств)"
|
|||
|
|
4. **Мониторить аномальный трафик**: если один аккаунт генерирует 1TB/день - вручную проверить
|
|||
|
|
|
|||
|
|
#### V2.0 подход (после MVP):
|
|||
|
|
1. Реализовать **Server-side webhook** для мониторинга Xray логов
|
|||
|
|
2. Парсить логи подключений в реальном времени:
|
|||
|
|
```bash
|
|||
|
|
# Xray лог показывает:
|
|||
|
|
[Info] accepted tcp:xxx.xxx.xxx.xxx:12345 [email:test_user@marzban]
|
|||
|
|
```
|
|||
|
|
3. Собирать статистику активных IP за последние 5 минут
|
|||
|
|
4. Если > deviceLimit → отправить уведомление или временно заблокировать
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
### **Архитектура для V2.0**:
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
┌─────────────────────────────────────────┐
|
|||
|
|
│ Marzban Panel (API) │
|
|||
|
|
│ - Создание пользователей │
|
|||
|
|
│ - Управление подписками │
|
|||
|
|
└────────────┬────────────────────────────┘
|
|||
|
|
│
|
|||
|
|
▼
|
|||
|
|
┌─────────────────────────────────────────┐
|
|||
|
|
│ Xray Core (на каждой ноде) │
|
|||
|
|
│ - Обработка трафика │
|
|||
|
|
│ - Генерация логов подключений │
|
|||
|
|
└────────────┬────────────────────────────┘
|
|||
|
|
│
|
|||
|
|
│ (Webhooks или log streaming)
|
|||
|
|
▼
|
|||
|
|
┌─────────────────────────────────────────┐
|
|||
|
|
│ Device Monitor Service (NEW!) │
|
|||
|
|
│ - Парсинг логов Xray │
|
|||
|
|
│ - Подсчет активных IP per user │
|
|||
|
|
│ - Проверка лимитов устройств │
|
|||
|
|
│ - Автоблокировка при превышении │
|
|||
|
|
└────────────┬────────────────────────────┘
|
|||
|
|
│
|
|||
|
|
▼
|
|||
|
|
┌─────────────────────────────────────────┐
|
|||
|
|
│ PostgreSQL / Redis │
|
|||
|
|
│ - Хранение активных сессий │
|
|||
|
|
│ - Хранение семейных планов │
|
|||
|
|
└─────────────────────────────────────────┘
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**Стоимость разработки**: ~40 часов (Device Monitor Service)
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
### **Итоговое решение**:
|
|||
|
|
|
|||
|
|
| Вариант | Точность | Сложность | Время реализации | Рекомендация |
|
|||
|
|
|---------|----------|-----------|------------------|--------------|
|
|||
|
|
| 1. `online_at` мониторинг | ❌ Низкая | Средняя | 8 часов | ⚠️ Не точно |
|
|||
|
|
| 2. IP-based | ❌ Низкая | Низкая | 4 часа | ⚠️ Не рекомендуется |
|
|||
|
|
| 3. Xray патчи | ✅ Высокая | Очень высокая | 80+ часов | ❌ Слишком сложно |
|
|||
|
|
| 4. Sub-users | ✅ Высокая | Средняя | 12 часов | ⚠️ Неудобно для пользователей |
|
|||
|
|
| 5. **Без ограничений + ToS** | N/A | Очень низкая | 0 часов | ✅ **Для MVP** |
|
|||
|
|
| 6. Log-based monitor (V2.0) | ✅ Высокая | Высокая | 40 часов | ✅ **Для V2.0** |
|
|||
|
|
|
|||
|
|
**Вывод**: Для MVP - **НЕ реализовывать техническое ограничение устройств**. Добавить в ToS и мониторить аномальный трафик вручную.
|
|||
|
|
|
|||
|
|
Для V2.0 - разработать Device Monitor Service с парсингом Xray логов.
|
|||
|
|
|
|||
|
|
**Готово к обсуждению!** 🚀
|