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:
Umbrix Dev
2026-02-08 09:18:55 +03:00
parent a0f5d69448
commit 3dffb35eaa
2 changed files with 268 additions and 25 deletions

View File

@@ -1,6 +1,6 @@
// API endpoint для создания нового пользователя VPN
// POST /api/create-user
// Принимает: { planType, telegramId, telegramUsername, firstName, lastName }
// Принимает: { planType, period, locationIds, telegramId, telegramUsername, firstName, lastName }
// Возвращает: { success: true, token, username, subscriptionUrl }
import { NextRequest, NextResponse } from 'next/server';
@@ -9,6 +9,8 @@ import { MARZBAN_PANEL_URL, getSubscriptionUrl } from '@/lib/constants';
import { logger } from '@/lib/logger';
import type { PlanType, CreateUserRequest, CreateUserResponse } from '@/types/marzban';
type Period = '1month' | '3months' | '6months' | '1year';
const MARZBAN_API = MARZBAN_PANEL_URL;
const ADMIN_USERNAME = process.env.MARZBAN_ADMIN_USERNAME;
const ADMIN_PASSWORD = process.env.MARZBAN_ADMIN_PASSWORD;
@@ -19,9 +21,27 @@ if (!ADMIN_USERNAME || !ADMIN_PASSWORD) {
export async function POST(request: NextRequest) {
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. Получаем токен админа
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();
// 2. Определяем параметры тарифа
const planConfig = getPlanConfig(planType);
// 2. Определяем параметры тарифа с учетом периода
const planConfig = getPlanConfig(planType, period as Period);
// 3. Генерируем уникальное имя пользователя (приоритет: @username > firstName_ID > userID > random)
// ВАЖНО: Marzban принимает только a-z, 0-9 и подчеркивания, БЕЗ @
@@ -62,7 +82,84 @@ export async function POST(request: NextRequest) {
console.log('✅ Generated username:', username);
logger.debug('✅ Generated username:', username);
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`, {
method: 'POST',
headers: {
@@ -82,12 +179,8 @@ export async function POST(request: NextRequest) {
vless: {},
trojan: {}
},
inbounds: {
vless: ['VLESS TCP', 'VLESS Reality'],
vmess: ['VMess WS'],
trojan: ['Trojan TCP']
},
note: `Тариф: ${planConfig.name}, TG: ${telegramId || 'N/A'}, Создан: ${new Date().toISOString()}`,
inbounds: userInbounds, // <-- ИСПОЛЬЗЕМ КАСТОМНЫЕ INBOUNDS
note: `Тариф: ${planConfig.name}, Период: ${period}, Локации: ${locationIds.length > 0 ? `#${locationIds.join(',')}` : 'Все'}, TG: ${telegramId || 'N/A'}, Создан: ${new Date().toISOString()}`,
}),
});
@@ -98,10 +191,10 @@ export async function POST(request: NextRequest) {
const userData = await createUserResponse.json();
// 5. Получаем subscription token
// 6. Получаем subscription token
const subscriptionToken = userData.subscription_url?.split('/sub/')[1]?.replace(/\/$/, '') || username;
// 6. Track referral if referrerId provided
// 7. Track referral if referrerId provided
if (referrerId) {
try {
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 configs: Record<string, { name: string; expireTimestamp: number; dataLimitBytes: number }> = {
// Базовые параметры тарифов
const basePlans = {
trial: {
name: '7 дней бесплатно',
expireTimestamp: now + (7 * 24 * 60 * 60), // 7 дней
dataLimitBytes: 0, // безлимит
dataLimitGB: 0, // безлимит
durationDays: 7,
},
start: {
name: 'Базовый',
expireTimestamp: now + (30 * 24 * 60 * 60), // 30 дней
dataLimitBytes: 50 * 1024 * 1024 * 1024, // 50 ГБ
dataLimitGB: 50,
durationDays: 30,
},
plus: {
name: 'Расширенный',
expireTimestamp: now + (30 * 24 * 60 * 60), // 30 дней
dataLimitBytes: 299 * 1024 * 1024 * 1024, // 299 ГБ
dataLimitGB: 299,
durationDays: 30,
},
max: {
name: 'Премиум',
expireTimestamp: now + (30 * 24 * 60 * 60), // 30 дней
dataLimitBytes: 0, // безлимит
dataLimitGB: 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,
};
}