✨ 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:
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