✨ Backend API: выбор локаций и периодов оплаты
Создан новый endpoint /api/nodes:
- Получает список нод из Marzban API
- Фильтрует только connected ноды
- Форматирует для фронтенда (id, name, country, flag, ping)
Обновлен /api/create-user:
- Новые параметры: period ('1month'|'3months'|'6months'|'1year')
- Новые параметры: locationIds (массив ID выбранных нод)
- Динамический расчет expire с учетом периода
- Автоматическое определение inbounds для выбранных нод
- Fallback на дефолтные inbounds при ошибке
Функция getPlanConfig обновлена:
- Поддержка множителей периодов (1/3/6/12 месяцев)
- Расчет totalDays для корректного expire
Ready for: Frontend UI integration (Week 2)
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
// API endpoint для создания нового пользователя VPN
|
// API endpoint для создания нового пользователя VPN
|
||||||
// POST /api/create-user
|
// POST /api/create-user
|
||||||
// Принимает: { planType, telegramId, telegramUsername, firstName, lastName }
|
// Принимает: { planType, period, locationIds, telegramId, telegramUsername, firstName, lastName }
|
||||||
// Возвращает: { success: true, token, username, subscriptionUrl }
|
// Возвращает: { success: true, token, username, subscriptionUrl }
|
||||||
|
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
@@ -9,6 +9,8 @@ import { MARZBAN_PANEL_URL, getSubscriptionUrl } from '@/lib/constants';
|
|||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
import type { PlanType, CreateUserRequest, CreateUserResponse } from '@/types/marzban';
|
import type { PlanType, CreateUserRequest, CreateUserResponse } from '@/types/marzban';
|
||||||
|
|
||||||
|
type Period = '1month' | '3months' | '6months' | '1year';
|
||||||
|
|
||||||
const MARZBAN_API = MARZBAN_PANEL_URL;
|
const MARZBAN_API = MARZBAN_PANEL_URL;
|
||||||
const ADMIN_USERNAME = process.env.MARZBAN_ADMIN_USERNAME;
|
const ADMIN_USERNAME = process.env.MARZBAN_ADMIN_USERNAME;
|
||||||
const ADMIN_PASSWORD = process.env.MARZBAN_ADMIN_PASSWORD;
|
const ADMIN_PASSWORD = process.env.MARZBAN_ADMIN_PASSWORD;
|
||||||
@@ -19,9 +21,27 @@ if (!ADMIN_USERNAME || !ADMIN_PASSWORD) {
|
|||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const { planType, telegramId, telegramUsername, firstName, lastName, referrerId } = await request.json();
|
const {
|
||||||
|
planType,
|
||||||
|
period = '1month', // NEW: период оплаты
|
||||||
|
locationIds = [], // NEW: выбранные локации (ID нод)
|
||||||
|
telegramId,
|
||||||
|
telegramUsername,
|
||||||
|
firstName,
|
||||||
|
lastName,
|
||||||
|
referrerId
|
||||||
|
} = await request.json();
|
||||||
|
|
||||||
logger.debug('📥 Received data:', { planType, telegramId, telegramUsername, firstName, lastName, referrerId });
|
logger.debug('📥 Received data:', {
|
||||||
|
planType,
|
||||||
|
period,
|
||||||
|
locationIds,
|
||||||
|
telegramId,
|
||||||
|
telegramUsername,
|
||||||
|
firstName,
|
||||||
|
lastName,
|
||||||
|
referrerId
|
||||||
|
});
|
||||||
|
|
||||||
// 1. Получаем токен админа
|
// 1. Получаем токен админа
|
||||||
const tokenResponse = await fetch(`${MARZBAN_API}/api/admin/token`, {
|
const tokenResponse = await fetch(`${MARZBAN_API}/api/admin/token`, {
|
||||||
@@ -38,8 +58,8 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
const { access_token } = await tokenResponse.json();
|
const { access_token } = await tokenResponse.json();
|
||||||
|
|
||||||
// 2. Определяем параметры тарифа
|
// 2. Определяем параметры тарифа с учетом периода
|
||||||
const planConfig = getPlanConfig(planType);
|
const planConfig = getPlanConfig(planType, period as Period);
|
||||||
|
|
||||||
// 3. Генерируем уникальное имя пользователя (приоритет: @username > firstName_ID > userID > random)
|
// 3. Генерируем уникальное имя пользователя (приоритет: @username > firstName_ID > userID > random)
|
||||||
// ВАЖНО: Marzban принимает только a-z, 0-9 и подчеркивания, БЕЗ @
|
// ВАЖНО: Marzban принимает только a-z, 0-9 и подчеркивания, БЕЗ @
|
||||||
@@ -62,7 +82,84 @@ export async function POST(request: NextRequest) {
|
|||||||
console.log('✅ Generated username:', username);
|
console.log('✅ Generated username:', username);
|
||||||
logger.debug('✅ Generated username:', username);
|
logger.debug('✅ Generated username:', username);
|
||||||
logger.debug('📊 Telegram data:', { telegramId, telegramUsername, firstName, lastName });
|
logger.debug('📊 Telegram data:', { telegramId, telegramUsername, firstName, lastName });
|
||||||
// 4. Создаем пользователя в Marzban по ПРАВИЛЬНОЙ схеме
|
|
||||||
|
// 4. Определяем inbounds на основе выбранных локаций
|
||||||
|
let userInbounds = {
|
||||||
|
vless: ['VLESS TCP', 'VLESS Reality'],
|
||||||
|
vmess: ['VMess WS'],
|
||||||
|
trojan: ['Trojan TCP']
|
||||||
|
};
|
||||||
|
|
||||||
|
// Если locationIds указаны - получаем inbounds для выбранных нод
|
||||||
|
if (locationIds && locationIds.length > 0) {
|
||||||
|
try {
|
||||||
|
// Получить список нод
|
||||||
|
const nodesResponse = await fetch(`${MARZBAN_API}/api/nodes`, {
|
||||||
|
headers: { 'Authorization': `Bearer ${access_token}` },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (nodesResponse.ok) {
|
||||||
|
const allNodes = await nodesResponse.json();
|
||||||
|
const selectedNodes = allNodes.filter((node: any) => locationIds.includes(node.id));
|
||||||
|
|
||||||
|
logger.debug(`📍 Selected ${selectedNodes.length} nodes:`, selectedNodes.map((n: any) => n.name));
|
||||||
|
|
||||||
|
// Получить inbounds
|
||||||
|
const inboundsResponse = await fetch(`${MARZBAN_API}/api/inbounds`, {
|
||||||
|
headers: { 'Authorization': `Bearer ${access_token}` },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (inboundsResponse.ok) {
|
||||||
|
const allInbounds = await inboundsResponse.json();
|
||||||
|
|
||||||
|
// Создать mapping inbounds для выбранных нод
|
||||||
|
userInbounds = {
|
||||||
|
vless: [],
|
||||||
|
vmess: [],
|
||||||
|
trojan: []
|
||||||
|
};
|
||||||
|
|
||||||
|
// Группируем inbounds по протоколу
|
||||||
|
for (const node of selectedNodes) {
|
||||||
|
const nodeInbounds = allInbounds.filter((inbound: any) => {
|
||||||
|
// Ищем inbounds, которые содержат имя ноды в теге
|
||||||
|
const inboundTag = (inbound.tag || '').toLowerCase();
|
||||||
|
const nodeName = (node.name || '').toLowerCase();
|
||||||
|
return inboundTag.includes(nodeName) || inboundTag.includes(`node${node.id}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const inbound of nodeInbounds) {
|
||||||
|
const protocol = (inbound.protocol || '').toLowerCase();
|
||||||
|
if (protocol.includes('vless') && !userInbounds.vless.includes(inbound.tag)) {
|
||||||
|
userInbounds.vless.push(inbound.tag);
|
||||||
|
} else if (protocol.includes('vmess') && !userInbounds.vmess.includes(inbound.tag)) {
|
||||||
|
userInbounds.vmess.push(inbound.tag);
|
||||||
|
} else if (protocol.includes('trojan') && !userInbounds.trojan.includes(inbound.tag)) {
|
||||||
|
userInbounds.trojan.push(inbound.tag);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Если не нашли inbounds - используем дефолтные
|
||||||
|
if (userInbounds.vless.length === 0 && userInbounds.vmess.length === 0 && userInbounds.trojan.length === 0) {
|
||||||
|
logger.warn('⚠️ No inbounds found for selected nodes, using defaults');
|
||||||
|
userInbounds = {
|
||||||
|
vless: ['VLESS TCP', 'VLESS Reality'],
|
||||||
|
vmess: ['VMess WS'],
|
||||||
|
trojan: ['Trojan TCP']
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
logger.debug('✅ Mapped inbounds:', userInbounds);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to fetch nodes/inbounds, using defaults:', error);
|
||||||
|
// Используем дефолтные inbounds при ошибке
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Создаем пользователя в Marzban
|
||||||
const createUserResponse = await fetch(`${MARZBAN_API}/api/user`, {
|
const createUserResponse = await fetch(`${MARZBAN_API}/api/user`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
@@ -82,12 +179,8 @@ export async function POST(request: NextRequest) {
|
|||||||
vless: {},
|
vless: {},
|
||||||
trojan: {}
|
trojan: {}
|
||||||
},
|
},
|
||||||
inbounds: {
|
inbounds: userInbounds, // <-- ИСПОЛЬЗЕМ КАСТОМНЫЕ INBOUNDS
|
||||||
vless: ['VLESS TCP', 'VLESS Reality'],
|
note: `Тариф: ${planConfig.name}, Период: ${period}, Локации: ${locationIds.length > 0 ? `#${locationIds.join(',')}` : 'Все'}, TG: ${telegramId || 'N/A'}, Создан: ${new Date().toISOString()}`,
|
||||||
vmess: ['VMess WS'],
|
|
||||||
trojan: ['Trojan TCP']
|
|
||||||
},
|
|
||||||
note: `Тариф: ${planConfig.name}, TG: ${telegramId || 'N/A'}, Создан: ${new Date().toISOString()}`,
|
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -98,10 +191,10 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
const userData = await createUserResponse.json();
|
const userData = await createUserResponse.json();
|
||||||
|
|
||||||
// 5. Получаем subscription token
|
// 6. Получаем subscription token
|
||||||
const subscriptionToken = userData.subscription_url?.split('/sub/')[1]?.replace(/\/$/, '') || username;
|
const subscriptionToken = userData.subscription_url?.split('/sub/')[1]?.replace(/\/$/, '') || username;
|
||||||
|
|
||||||
// 6. Track referral if referrerId provided
|
// 7. Track referral if referrerId provided
|
||||||
if (referrerId) {
|
if (referrerId) {
|
||||||
try {
|
try {
|
||||||
await fetch(`${request.nextUrl.origin}/api/referral/track`, {
|
await fetch(`${request.nextUrl.origin}/api/referral/track`, {
|
||||||
@@ -139,31 +232,51 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getPlanConfig(planType: PlanType) {
|
function getPlanConfig(planType: PlanType, period: Period = '1month') {
|
||||||
const now = Math.floor(Date.now() / 1000);
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
|
||||||
const configs: Record<string, { name: string; expireTimestamp: number; dataLimitBytes: number }> = {
|
// Базовые параметры тарифов
|
||||||
|
const basePlans = {
|
||||||
trial: {
|
trial: {
|
||||||
name: '7 дней бесплатно',
|
name: '7 дней бесплатно',
|
||||||
expireTimestamp: now + (7 * 24 * 60 * 60), // 7 дней
|
dataLimitGB: 0, // безлимит
|
||||||
dataLimitBytes: 0, // безлимит
|
durationDays: 7,
|
||||||
},
|
},
|
||||||
start: {
|
start: {
|
||||||
name: 'Базовый',
|
name: 'Базовый',
|
||||||
expireTimestamp: now + (30 * 24 * 60 * 60), // 30 дней
|
dataLimitGB: 50,
|
||||||
dataLimitBytes: 50 * 1024 * 1024 * 1024, // 50 ГБ
|
durationDays: 30,
|
||||||
},
|
},
|
||||||
plus: {
|
plus: {
|
||||||
name: 'Расширенный',
|
name: 'Расширенный',
|
||||||
expireTimestamp: now + (30 * 24 * 60 * 60), // 30 дней
|
dataLimitGB: 299,
|
||||||
dataLimitBytes: 299 * 1024 * 1024 * 1024, // 299 ГБ
|
durationDays: 30,
|
||||||
},
|
},
|
||||||
max: {
|
max: {
|
||||||
name: 'Премиум',
|
name: 'Премиум',
|
||||||
expireTimestamp: now + (30 * 24 * 60 * 60), // 30 дней
|
dataLimitGB: 0, // безлимит
|
||||||
dataLimitBytes: 0, // безлимит
|
durationDays: 30,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
return configs[planType] || configs.trial;
|
// Множители для периодов
|
||||||
|
const periodMultipliers: Record<Period, number> = {
|
||||||
|
'1month': 1,
|
||||||
|
'3months': 3,
|
||||||
|
'6months': 6,
|
||||||
|
'1year': 12,
|
||||||
|
};
|
||||||
|
|
||||||
|
const plan = basePlans[planType as keyof typeof basePlans] || basePlans.trial;
|
||||||
|
const multiplier = periodMultipliers[period] || 1;
|
||||||
|
|
||||||
|
// Расчет итоговой длительности
|
||||||
|
const totalDays = plan.durationDays * multiplier;
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: plan.name,
|
||||||
|
expireTimestamp: now + (totalDays * 24 * 60 * 60),
|
||||||
|
dataLimitBytes: plan.dataLimitGB > 0 ? plan.dataLimitGB * 1024 * 1024 * 1024 : 0,
|
||||||
|
periodDays: totalDays,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
130
app/api/nodes/route.ts
Normal file
130
app/api/nodes/route.ts
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
// API endpoint для получения списка доступных локаций (нод) из Marzban
|
||||||
|
// GET /api/nodes
|
||||||
|
// Возвращает: { success: true, locations: [...] }
|
||||||
|
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { logger } from '@/lib/logger';
|
||||||
|
|
||||||
|
const MARZBAN_API = process.env.MARZBAN_PANEL_URL || process.env.MARZBAN_API_URL;
|
||||||
|
const ADMIN_USERNAME = process.env.MARZBAN_ADMIN_USERNAME;
|
||||||
|
const ADMIN_PASSWORD = process.env.MARZBAN_ADMIN_PASSWORD;
|
||||||
|
|
||||||
|
if (!ADMIN_USERNAME || !ADMIN_PASSWORD) {
|
||||||
|
throw new Error('MARZBAN_ADMIN_USERNAME and MARZBAN_ADMIN_PASSWORD must be set in .env');
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MarzbanNode {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
address: string;
|
||||||
|
port: number;
|
||||||
|
api_port: number;
|
||||||
|
status: 'connected' | 'connecting' | 'disconnected' | 'error';
|
||||||
|
message: string | null;
|
||||||
|
xray_version: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Location {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
address: string;
|
||||||
|
ping: string;
|
||||||
|
country: string;
|
||||||
|
flag: string;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mapping для ping'ов (в production можно делать реальный ping check)
|
||||||
|
const PING_MAP: Record<string, string> = {
|
||||||
|
'193.168.175.128': '15ms', // NL
|
||||||
|
'194.113.210.187': '120ms', // US
|
||||||
|
'103.6.169.78': '160ms', // Asia
|
||||||
|
// Добавить остальные ноды по мере появления
|
||||||
|
};
|
||||||
|
|
||||||
|
// Извлечь код страны из emoji флага
|
||||||
|
function extractCountryCode(nodeName: string): string {
|
||||||
|
// Ищем emoji флаг в названии (🇺🇸, 🇳🇱, etc.)
|
||||||
|
const flagMatch = nodeName.match(/[\u{1F1E6}-\u{1F1FF}]{2}/u);
|
||||||
|
if (!flagMatch) return 'Unknown';
|
||||||
|
|
||||||
|
// Конвертируем emoji флаг в код страны
|
||||||
|
const flag = flagMatch[0];
|
||||||
|
const codePoints = [...flag].map(char => char.codePointAt(0)! - 0x1F1E6 + 65);
|
||||||
|
return String.fromCharCode(...codePoints);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Извлечь emoji флаг из названия
|
||||||
|
function extractFlag(nodeName: string): string {
|
||||||
|
const flagMatch = nodeName.match(/[\u{1F1E6}-\u{1F1FF}]{2}/u);
|
||||||
|
return flagMatch ? flagMatch[0] : '🌍';
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
logger.debug('📡 Fetching nodes from Marzban API...');
|
||||||
|
|
||||||
|
// 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}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!tokenResponse.ok) {
|
||||||
|
throw new Error('Failed to get admin token');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { access_token } = await tokenResponse.json();
|
||||||
|
logger.debug('✅ Admin token obtained');
|
||||||
|
|
||||||
|
// 2. Получить список нод
|
||||||
|
const nodesResponse = await fetch(`${MARZBAN_API}/api/nodes`, {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${access_token}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!nodesResponse.ok) {
|
||||||
|
throw new Error(`Failed to fetch nodes: ${nodesResponse.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const nodes: MarzbanNode[] = await nodesResponse.json();
|
||||||
|
logger.debug(`📊 Fetched ${nodes.length} nodes from Marzban`);
|
||||||
|
|
||||||
|
// 3. Форматировать для фронтенда
|
||||||
|
const locations: Location[] = nodes
|
||||||
|
.filter(node => node.status === 'connected') // Только активные ноды
|
||||||
|
.map(node => ({
|
||||||
|
id: node.id,
|
||||||
|
name: node.name,
|
||||||
|
address: node.address,
|
||||||
|
ping: PING_MAP[node.address] || '50ms', // Дефолтный ping если не в мапе
|
||||||
|
country: extractCountryCode(node.name),
|
||||||
|
flag: extractFlag(node.name),
|
||||||
|
status: node.status,
|
||||||
|
}));
|
||||||
|
|
||||||
|
logger.debug(`✅ Formatted ${locations.length} active locations`);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
locations,
|
||||||
|
total: locations.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to fetch nodes:', error);
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Failed to fetch nodes',
|
||||||
|
locations: [],
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user