Files
app_umbrix/app/api/create-user/route.ts
Umbrix Dev 3dffb35eaa 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)
2026-02-08 09:18:55 +03:00

283 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// API endpoint для создания нового пользователя VPN
// POST /api/create-user
// Принимает: { planType, period, locationIds, telegramId, telegramUsername, firstName, lastName }
// Возвращает: { success: true, token, username, subscriptionUrl }
import { NextRequest, NextResponse } from 'next/server';
import { randomUUID } from 'crypto'; // Криптографически безопасная генерация UUID
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;
if (!ADMIN_USERNAME || !ADMIN_PASSWORD) {
throw new Error('MARZBAN_ADMIN_USERNAME and MARZBAN_ADMIN_PASSWORD must be set in .env');
}
export async function POST(request: NextRequest) {
try {
const {
planType,
period = '1month', // NEW: период оплаты
locationIds = [], // NEW: выбранные локации (ID нод)
telegramId,
telegramUsername,
firstName,
lastName,
referrerId
} = await request.json();
logger.debug('📥 Received data:', {
planType,
period,
locationIds,
telegramId,
telegramUsername,
firstName,
lastName,
referrerId
});
// 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();
// 2. Определяем параметры тарифа с учетом периода
const planConfig = getPlanConfig(planType, period as Period);
// 3. Генерируем уникальное имя пользователя (приоритет: @username > firstName_ID > userID > random)
// ВАЖНО: Marzban принимает только a-z, 0-9 и подчеркивания, БЕЗ @
let username: string;
if (telegramUsername) {
// Есть @username в Telegram - используем БЕЗ @
username = telegramUsername.toLowerCase().replace(/[^a-z0-9_]/g, '_');
} else if (firstName && telegramId) {
// Нет username, используем имя + ID
const cleanName = firstName.toLowerCase().replace(/[^a-z0-9]/g, '_');
username = `${cleanName}_${telegramId}`;
} else if (telegramId) {
// Только ID
username = `user_${telegramId}`;
} else {
// Для тестирования вне Telegram
username = `user_${Date.now()}_${Math.random().toString(36).substring(7)}`;
}
console.log('✅ Generated username:', username);
logger.debug('✅ Generated username:', username);
logger.debug('📊 Telegram data:', { telegramId, telegramUsername, firstName, lastName });
// 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: {
'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() // Криптографически безопасный UUID
},
vless: {},
trojan: {}
},
inbounds: userInbounds, // <-- ИСПОЛЬЗЕМ КАСТОМНЫЕ INBOUNDS
note: `Тариф: ${planConfig.name}, Период: ${period}, Локации: ${locationIds.length > 0 ? `#${locationIds.join(',')}` : 'Все'}, TG: ${telegramId || 'N/A'}, Создан: ${new Date().toISOString()}`,
}),
});
if (!createUserResponse.ok) {
const errorText = await createUserResponse.text();
throw new Error(`Failed to create user: ${errorText}`);
}
const userData = await createUserResponse.json();
// 6. Получаем subscription token
const subscriptionToken = userData.subscription_url?.split('/sub/')[1]?.replace(/\/$/, '') || username;
// 7. Track referral if referrerId provided
if (referrerId) {
try {
await fetch(`${request.nextUrl.origin}/api/referral/track`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username: username,
referrer_username: referrerId,
}),
});
logger.debug('✅ Referral tracked:', { username, referrer: referrerId });
} catch (refError) {
logger.error('❌ Referral tracking failed:', refError);
// Don't fail user creation if referral tracking fails
}
}
return NextResponse.json({
success: true,
token: subscriptionToken,
username: username,
expiresAt: new Date(planConfig.expireTimestamp * 1000).toISOString(),
subscriptionUrl: getSubscriptionUrl(subscriptionToken),
});
} catch (error) {
logger.error('Create user error:', error);
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Unknown error'
},
{ status: 500 }
);
}
}
function getPlanConfig(planType: PlanType, period: Period = '1month') {
const now = Math.floor(Date.now() / 1000);
// Базовые параметры тарифов
const basePlans = {
trial: {
name: '7 дней бесплатно',
dataLimitGB: 0, // безлимит
durationDays: 7,
},
start: {
name: 'Базовый',
dataLimitGB: 50,
durationDays: 30,
},
plus: {
name: 'Расширенный',
dataLimitGB: 299,
durationDays: 30,
},
max: {
name: 'Премиум',
dataLimitGB: 0, // безлимит
durationDays: 30,
},
};
// Множители для периодов
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,
};
}