diff --git a/migrations/003_add_server_tables.sql b/migrations/003_add_server_tables.sql new file mode 100644 index 0000000..8a2016c --- /dev/null +++ b/migrations/003_add_server_tables.sql @@ -0,0 +1,52 @@ +-- Migration 003: Add server management tables +-- Purpose: Cloud server order tracking +-- Date: 2026-01-23 +-- Reference: CLAUDE.md "Server Management System" +-- +-- Background: +-- Telegram 봇을 통해 클라우드 서버 주문 내역을 기록하고 관리합니다. +-- 예치금 시스템과 통합하여 자동 결제를 지원합니다. +-- 서버 사양(cloud_providers, instance_specs)은 별도 외부 시스템에서 관리합니다. + +-- Step 1: Create server_orders table (order lifecycle tracking) +CREATE TABLE IF NOT EXISTS server_orders ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + spec_id INTEGER NOT NULL, + status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending', 'provisioning', 'active', 'failed', 'cancelled', 'terminated')), + region TEXT NOT NULL, + provider_instance_id TEXT, + ip_address TEXT, + root_password TEXT, + price_paid INTEGER NOT NULL, + error_message TEXT, + provisioned_at DATETIME, + terminated_at DATETIME, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) +); + +-- Step 2: Create user_servers table (ownership mapping) +CREATE TABLE IF NOT EXISTS user_servers ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + order_id INTEGER UNIQUE NOT NULL, + provider_id INTEGER NOT NULL, + label TEXT, + verified INTEGER DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id), + FOREIGN KEY (order_id) REFERENCES server_orders(id) +); + +-- Step 3: Create indexes for efficient queries +CREATE INDEX IF NOT EXISTS idx_server_orders_user ON server_orders(user_id); +CREATE INDEX IF NOT EXISTS idx_server_orders_status ON server_orders(status, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_user_servers_user ON user_servers(user_id); +CREATE INDEX IF NOT EXISTS idx_user_servers_provider ON user_servers(provider_id); + +-- Verification Queries (주석으로 제공) +-- SELECT * FROM server_orders WHERE user_id = 1 ORDER BY created_at DESC; +-- SELECT * FROM user_servers WHERE user_id = 1; diff --git a/src/constants/server.ts b/src/constants/server.ts new file mode 100644 index 0000000..2ff41d0 --- /dev/null +++ b/src/constants/server.ts @@ -0,0 +1,68 @@ +/** + * Server provisioning constants + * Centralized OS images, region mappings, and display helpers + */ + +/** + * OS image mappings + * Maps OS image IDs to user-friendly display names + */ +export const OS_IMAGES = { + 'ubuntu-22.04': 'Ubuntu 22.04 LTS', + 'ubuntu-24.04': 'Ubuntu 24.04 LTS', + 'debian-12': 'Debian 12', + 'centos-stream-9': 'CentOS Stream 9' +} as const; + +export type OSImageKey = keyof typeof OS_IMAGES; + +/** + * Region code to flag emoji and localized name mapping + * Covers both Linode and Vultr region codes + */ +export const REGION_FLAGS: Record = { + // Linode + 'ap-northeast': { flag: '🇯🇵', name: '오사카' }, + 'ap-south': { flag: '🇸🇬', name: '싱가포르' }, + 'ap-southeast': { flag: '🇦🇺', name: '시드니' }, + 'ap-west': { flag: '🇮🇳', name: '뭄바이' }, + 'us-west': { flag: '🇺🇸', name: 'LA' }, + 'us-central': { flag: '🇺🇸', name: '댈러스' }, + 'us-east': { flag: '🇺🇸', name: '뉴저지' }, + 'eu-west': { flag: '🇬🇧', name: '런던' }, + 'eu-central': { flag: '🇩🇪', name: '프랑크푸르트' }, + // Vultr + 'nrt': { flag: '🇯🇵', name: '도쿄' }, + 'icn': { flag: '🇰🇷', name: '서울' }, + 'sgp': { flag: '🇸🇬', name: '싱가포르' }, + 'syd': { flag: '🇦🇺', name: '시드니' }, + 'lax': { flag: '🇺🇸', name: 'LA' }, + 'ord': { flag: '🇺🇸', name: '시카고' }, + 'ewr': { flag: '🇺🇸', name: '뉴저지' }, + 'lhr': { flag: '🇬🇧', name: '런던' }, + 'fra': { flag: '🇩🇪', name: '프랑크푸르트' }, +}; + +/** + * Number emojis for list display (1-5) + */ +export const NUM_EMOJIS = ['1️⃣', '2️⃣', '3️⃣', '4️⃣', '5️⃣'] as const; + +/** + * Get formatted region display with flag and name + * @param regionCode - Region code (e.g., 'ap-northeast', 'nrt') + * @returns Formatted string like "🇯🇵 오사카" or original code if not found + */ +export function getRegionDisplay(regionCode: string): string { + const info = REGION_FLAGS[regionCode]; + return info ? `${info.flag} ${info.name}` : regionCode; +} + +/** + * Get user-friendly OS display name + * @param osImage - OS image ID (e.g., 'ubuntu-22.04') + * @returns Display name like "Ubuntu 22.04 LTS" or original ID if not found + */ +export function getOSDisplayName(osImage: string): string { + return OS_IMAGES[osImage as OSImageKey] || osImage; +} diff --git a/src/routes/handlers/callback-handler.ts b/src/routes/handlers/callback-handler.ts new file mode 100644 index 0000000..1fac946 --- /dev/null +++ b/src/routes/handlers/callback-handler.ts @@ -0,0 +1,758 @@ +import { answerCallbackQuery, editMessageText, sendMessage, sendMessageWithKeyboard } from '../../telegram'; +import { UserService } from '../../services/user-service'; +import { executeDomainRegister } from '../../domain-register'; +import { executeServerProvision } from '../../server-provision'; +import { + getSessionForUser, + updateSession, + deleteSession, + createSession, + ServerOrderSessionData, +} from '../../utils/session'; +import { getServerSpec } from '../../services/cloud-spec-service'; +import { getRegionDisplay, getOSDisplayName, NUM_EMOJIS } from '../../constants/server'; +import type { Env, TelegramUpdate } from '../../types'; + +/** + * Safely parse integer with range validation + * @param value - String to parse + * @param min - Minimum allowed value (inclusive) + * @param max - Maximum allowed value (inclusive) + * @returns Parsed integer or null if invalid/out of range + */ +function parseIntSafe(value: string, min: number, max: number): number | null { + const parsed = parseInt(value, 10); + if (isNaN(parsed) || parsed < min || parsed > max) { + return null; + } + return parsed; +} + +/** + * Callback Query 처리 (인라인 버튼 클릭) + */ +export async function handleCallbackQuery( + env: Env, + callbackQuery: TelegramUpdate['callback_query'] +): Promise { + if (!callbackQuery) return; + + const { id: queryId, from, message, data } = callbackQuery; + if (!data || !message) { + await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '잘못된 요청입니다.' }); + return; + } + + const chatId = message.chat.id; + const messageId = message.message_id; + const telegramUserId = from.id.toString(); + + const userService = new UserService(env.DB); + const user = await userService.getUserByTelegramId(telegramUserId); + + if (!user) { + await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '사용자를 찾을 수 없습니다.' }); + return; + } + + // 도메인 등록 처리 + if (data.startsWith('domain_reg:')) { + const parts = data.split(':'); + if (parts.length !== 3) { + await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '잘못된 데이터입니다.' }); + return; + } + + const domain = parts[1]; + const price = parseIntSafe(parts[2], 0, 10000000); // 0 ~ 10 million KRW + + if (price === null) { + await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '잘못된 가격 정보입니다.' }); + return; + } + + await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '등록 처리 중...' }); + await editMessageText( + env.BOT_TOKEN, + chatId, + messageId, + `⏳ ${domain} 등록 처리 중...` + ); + + const result = await executeDomainRegister(env, user.id, telegramUserId, domain, price); + + if (result.success) { + const expiresInfo = result.expiresAt ? `\n• 만료일: ${result.expiresAt}` : ''; + const nsInfo = result.nameservers && result.nameservers.length > 0 + ? `\n\n🌐 현재 네임서버:\n${result.nameservers.map(ns => `• ${ns}`).join('\n')}` + : ''; + + await editMessageText( + env.BOT_TOKEN, + chatId, + messageId, + `✅ 도메인 등록 완료! + +• 도메인: ${result.domain} +• 결제 금액: ${result.price?.toLocaleString()}원 +• 현재 잔액: ${result.newBalance?.toLocaleString()}원${expiresInfo}${nsInfo} + +🎉 축하합니다! 도메인이 성공적으로 등록되었습니다. +네임서버 변경이 필요하면 말씀해주세요.` + ); + } else { + await editMessageText( + env.BOT_TOKEN, + chatId, + messageId, + `❌ 등록 실패 + +${result.error} + +다시 시도하시려면 도메인 등록을 요청해주세요.` + ); + } + return; + } + + // 도메인 등록 취소 + if (data === 'domain_cancel') { + await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '취소되었습니다.' }); + await editMessageText( + env.BOT_TOKEN, + chatId, + messageId, + '❌ 도메인 등록이 취소되었습니다.' + ); + return; + } + + // ===== 세션 기반 서버 플로우 ===== + if (data.startsWith('srv:')) { + const parts = data.split(':'); + const sessionId = parts[1]; + const action = parts[2]; + + // 세션 조회 + 권한 검증 + const session = await getSessionForUser( + env.SESSION_KV, + sessionId, + user.id + ); + + if (!session) { + await answerCallbackQuery(env.BOT_TOKEN, queryId, { + text: '세션이 만료되었습니다. 다시 시작해주세요.' + }); + await editMessageText(env.BOT_TOKEN, chatId, messageId, + '⏰ 세션이 만료되었습니다.\n\n💡 "서버 추천해줘"라고 다시 말씀해주세요.' + ); + return; + } + + // select: 사양 선택 (추천 목록에서) + if (action === 'select') { + const index = parseInt(parts[3], 10); + const recs = session.data.recommendations; + + if (!recs || index < 0 || index >= recs.length) { + await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '잘못된 선택입니다.' }); + return; + } + + const selected = recs[index]; + + // 세션 업데이트 + await updateSession(env.SESSION_KV, sessionId, { + step: 'spec_confirm', + plan: selected.plan, + region: selected.region, + provider: selected.provider, + recommendations: undefined // 선택 후 목록 삭제 + }); + + await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '사양 조회 중...' }); + + // CLOUD_DB에서 상세 조회 + const spec = await getServerSpec( + env.CLOUD_DB, + selected.plan, + selected.region, + selected.provider + ); + + if (!spec) { + await editMessageText(env.BOT_TOKEN, chatId, messageId, '❌ 사양을 찾을 수 없습니다.'); + return; + } + + // 가격 정보 세션에 저장 + await updateSession(env.SESSION_KV, sessionId, { + priceKrw: spec.monthly_price_krw + }); + + const ramGB = (spec.memory_mb / 1024).toFixed(1); + const networkSpeed = spec.network_speed_gbps ? `${spec.network_speed_gbps} Gbps` : '공유'; + + await editMessageText( + env.BOT_TOKEN, + chatId, + messageId, + `📦 서버 사양 확인 + +컴퓨팅 +• vCPU: ${spec.vcpu}개 +• RAM: ${ramGB}GB +• 스토리지: ${spec.storage_gb}GB SSD + +네트워크 +• 트래픽: ${spec.transfer_tb}TB/월 +• 대역폭: ${networkSpeed} + +요금 +• 월 ${spec.monthly_price_krw.toLocaleString()}원 + +이 사양으로 진행하시겠습니까?`, + { + reply_markup: { + inline_keyboard: [ + [{ text: '✅ 다음 단계 (OS 선택)', callback_data: `srv:${sessionId}:os_list` }], + [ + { text: '◀️ 다른 사양 선택', callback_data: `srv:${sessionId}:reselect` }, + { text: '❌ 취소', callback_data: `srv:${sessionId}:cancel` } + ] + ] + } + } + ); + return; + } + + // os_list: OS 선택 화면 + if (action === 'os_list') { + await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: 'OS 선택' }); + + await updateSession(env.SESSION_KV, sessionId, { + step: 'os_select' + }); + + await editMessageText( + env.BOT_TOKEN, + chatId, + messageId, + `🖥️ OS 선택\n\n서버에 설치할 운영체제를 선택하세요:`, + { + reply_markup: { + inline_keyboard: [ + [{ text: '🐧 Ubuntu 22.04 LTS', callback_data: `srv:${sessionId}:os:ubuntu-22.04` }], + [{ text: '🐧 Ubuntu 24.04 LTS', callback_data: `srv:${sessionId}:os:ubuntu-24.04` }], + [{ text: '🎩 Debian 12', callback_data: `srv:${sessionId}:os:debian-12` }], + [{ text: '🎯 CentOS Stream 9', callback_data: `srv:${sessionId}:os:centos-stream-9` }], + [ + { text: '◀️ 뒤로', callback_data: `srv:${sessionId}:back_spec` }, + { text: '❌ 취소', callback_data: `srv:${sessionId}:cancel` } + ] + ] + } + } + ); + return; + } + + // os: OS 선택 완료 → 주문 생성 + 최종 확인 + if (action === 'os') { + const osImage = parts[3]; + + await updateSession(env.SESSION_KV, sessionId, { + step: 'final_confirm', + image: osImage + }); + + await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '주문 생성 중...' }); + + const { plan, region, provider } = session.data; + + // DB에서 사양 조회 + const spec = await getServerSpec( + env.CLOUD_DB, + plan!, + region!, + provider! + ); + + if (!spec) { + await editMessageText(env.BOT_TOKEN, chatId, messageId, '❌ 사양을 찾을 수 없습니다.'); + return; + } + + // 잔액 확인 + const deposit = await env.DB.prepare( + "SELECT balance FROM user_deposits WHERE user_id = ?" + ).bind(user.id).first<{ balance: number }>(); + const balance = deposit?.balance || 0; + + if (balance < spec.monthly_price_krw) { + const shortage = spec.monthly_price_krw - balance; + await editMessageText( + env.BOT_TOKEN, + chatId, + messageId, + `❌ 잔액 부족 + +• 필요 금액: ${spec.monthly_price_krw.toLocaleString()}원 +• 현재 잔액: ${balance.toLocaleString()}원 +• 부족 금액: ${shortage.toLocaleString()}원 + +💳 입금 계좌 +하나은행 427-910018-27104 (주식회사 아이언클래드) +입금 후 다시 시도해주세요.` + ); + return; + } + + // 주문 생성 + const label = `server-${Date.now()}`; + const orderResult = await env.DB.prepare(` + INSERT INTO server_orders (user_id, spec_id, status, label, region, image, price_paid, billing_type, created_at) + VALUES (?, ?, 'pending', ?, ?, ?, ?, 'monthly', datetime('now')) + `).bind(user.id, spec.pricing_id, label, region, osImage, spec.monthly_price_krw).run(); + + const orderId = orderResult.meta?.last_row_id; + + if (!orderId) { + await editMessageText(env.BOT_TOKEN, chatId, messageId, '❌ 주문 생성에 실패했습니다.'); + return; + } + + await updateSession(env.SESSION_KV, sessionId, { + orderId: orderId + }); + + // OS 이름 변환 + const ramGB = (spec.memory_mb / 1024).toFixed(1); + const specStr = `${spec.vcpu} vCPU / ${ramGB}GB RAM / ${spec.storage_gb}GB SSD`; + + // 최종 확인 화면 + await editMessageText( + env.BOT_TOKEN, + chatId, + messageId, + `✅ 최종 확인 + +• 사양: ${specStr} +• OS: ${getOSDisplayName(osImage)} +• 가격: ${spec.monthly_price_krw.toLocaleString()}원/월 +• 현재 잔액: ${balance.toLocaleString()}원 + +💡 요금 안내 +• 월 선불제이며, 중도 해지 시 시간당 요금으로 정산 후 환불됩니다. +• 예: 10일 사용 후 해지 → (시간당 요금 × 사용 시간) 차감 후 잔액 환불`, + { + reply_markup: { + inline_keyboard: [ + [{ text: '✅ 서버 생성', callback_data: `srv:${sessionId}:confirm` }], + [ + { text: '◀️ 뒤로', callback_data: `srv:${sessionId}:back_os` }, + { text: '❌ 취소', callback_data: `srv:${sessionId}:cancel` } + ] + ] + } + } + ); + return; + } + + // confirm: 서버 생성 실행 + if (action === 'confirm') { + const { orderId } = session.data; + if (!orderId) { + await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '주문 정보가 없습니다.' }); + return; + } + + await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '서버 생성 중...' }); + await editMessageText(env.BOT_TOKEN, chatId, messageId, + '⏳ 서버를 생성하고 있습니다... (1-3분 소요)' + ); + + const result = await executeServerProvision(env, user.id, telegramUserId, orderId); + + // 세션 삭제 + await deleteSession(env.SESSION_KV, sessionId); + + if (result.success) { + await editMessageText( + env.BOT_TOKEN, + chatId, + messageId, + `✅ 서버 생성 완료! + +• 사양: ${result.plan_label} +• 리전: ${result.region} +• IP 주소: ${result.ip_address} +• Root 비밀번호: ${result.root_password} + +📌 접속 방법 +ssh root@${result.ip_address} + +⚠️ 보안 권고 +1. 즉시 비밀번호를 변경하세요: passwd +2. SSH 키 인증 설정을 권장합니다. +3. 방화벽(UFW)을 활성화하세요. + +🎉 서버가 성공적으로 생성되었습니다!` + ); + } else { + await editMessageText( + env.BOT_TOKEN, + chatId, + messageId, + `❌ 서버 생성 실패 + +${result.error} + +다시 시도하시려면 서버 주문을 요청해주세요.` + ); + } + return; + } + + // cancel: 취소 + if (action === 'cancel') { + const { orderId } = session.data; + + // pending 주문 있으면 삭제 + if (orderId) { + await env.DB.prepare( + "UPDATE server_orders SET status = 'cancelled', terminated_at = datetime('now') WHERE id = ? AND user_id = ? AND status = 'pending'" + ).bind(orderId, user.id).run(); + } + + // 세션 삭제 + await deleteSession(env.SESSION_KV, sessionId); + + await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '취소되었습니다.' }); + await editMessageText(env.BOT_TOKEN, chatId, messageId, + '❌ 서버 선택이 취소되었습니다.\n\n다시 추천받으시려면 "서버 추천해줘"라고 말씀해주세요.' + ); + return; + } + + // reselect: 다른 사양 선택 (다시 추천 API 호출) + if (action === 'reselect') { + await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '다시 추천받는 중...' }); + await editMessageText(env.BOT_TOKEN, chatId, messageId, '🔄 다시 추천받는 중...'); + + // 기존 세션 삭제 + await deleteSession(env.SESSION_KV, sessionId); + + try { + // SERVER_RECOMMEND 서비스로 기본 추천 요청 + const requestBody = { + tech_stack: ['nginx'], + expected_users: 100, + use_case: 'general purpose server', + lang: 'ko' + }; + + const response = env.SERVER_RECOMMEND + ? await env.SERVER_RECOMMEND.fetch('https://internal/api/recommend', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(requestBody), + }) + : await fetch(env.SERVER_RECOMMEND_API_URL || 'https://server-recommend.kappa-d8e.workers.dev/api/recommend', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(requestBody), + }); + + if (!response.ok) { + throw new Error(`API error: ${response.status}`); + } + + const apiResult = await response.json() as { + recommendations?: Array<{ + server: { + instance_id: string; + region_code: string; + provider_name: string; + vcpu: number; + memory_mb: number; + storage_gb: number; + monthly_price: number; + }; + score: number; + }>; + }; + + if (!apiResult.recommendations || apiResult.recommendations.length === 0) { + throw new Error('No recommendations'); + } + + // 상위 5개 추천 + const topRecs = apiResult.recommendations.slice(0, 5); + + let responseText = `🎯 범용 서버 추천\n\n`; + topRecs.forEach((rec, index) => { + const server = rec.server; + const ramGB = (server.memory_mb / 1024).toFixed(1); + const priceKrw = Math.round(server.monthly_price); + const regionDisplay = getRegionDisplay(server.region_code); + responseText += `${NUM_EMOJIS[index]} ${server.vcpu} vCPU / ${ramGB}GB RAM / ${server.storage_gb}GB SSD\n`; + responseText += ` ${regionDisplay} | 💰 ${priceKrw.toLocaleString()}원/월 | 🎯 AI 점수: ${rec.score}/100\n\n`; + }); + responseText += `👆 서버를 신청하려면 아래 버튼을 선택하세요\n\n💡 다른 조건을 원하시면 "웹서버용 서버 추천" 형식으로 말씀해주세요.`; + + // 새 세션 생성 + const newSessionId = await createSession( + env.SESSION_KV, + user.id, + 'server_order', + { + recommendations: topRecs.map(rec => ({ + plan: rec.server.instance_id, + region: rec.server.region_code, + provider: rec.server.provider_name.toLowerCase() + })) + }, + 'recommend' + ); + + // 버튼 생성 + const buttons = topRecs.map((_, index) => ({ + text: `${index + 1}번 선택`, + callback_data: `srv:${newSessionId}:select:${index}` + })); + + const keyboard = []; + if (buttons.length > 0) keyboard.push(buttons.slice(0, 3)); + if (buttons.length > 3) keyboard.push(buttons.slice(3)); + + await sendMessageWithKeyboard(env.BOT_TOKEN, chatId, responseText, keyboard); + } catch (error) { + console.error('[srv:reselect] 추천 API 오류:', error); + await sendMessage( + env.BOT_TOKEN, + chatId, + '❌ 추천을 다시 받는 중 오류가 발생했습니다.\n\n💡 "서버 추천해줘"라고 말씀해주세요.' + ); + } + return; + } + + // back_spec: 사양 확인으로 뒤로 + if (action === 'back_spec') { + const { plan, region, provider } = session.data; + + await updateSession(env.SESSION_KV, sessionId, { + step: 'spec_confirm', + image: undefined + }); + + await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '사양 확인으로 이동' }); + + // 사양 상세 다시 조회 및 표시 + const spec = await getServerSpec( + env.CLOUD_DB, + plan!, + region!, + provider! + ); + + if (!spec) { + await editMessageText(env.BOT_TOKEN, chatId, messageId, '❌ 사양을 찾을 수 없습니다.'); + return; + } + + const ramGB = (spec.memory_mb / 1024).toFixed(1); + const networkSpeed = spec.network_speed_gbps ? `${spec.network_speed_gbps} Gbps` : '공유'; + + await editMessageText( + env.BOT_TOKEN, + chatId, + messageId, + `📦 서버 사양 확인 + +컴퓨팅 +• vCPU: ${spec.vcpu}개 +• RAM: ${ramGB}GB +• 스토리지: ${spec.storage_gb}GB SSD + +네트워크 +• 트래픽: ${spec.transfer_tb}TB/월 +• 대역폭: ${networkSpeed} + +요금 +• 월 ${spec.monthly_price_krw.toLocaleString()}원 + +이 사양으로 진행하시겠습니까?`, + { + reply_markup: { + inline_keyboard: [ + [{ text: '✅ 다음 단계 (OS 선택)', callback_data: `srv:${sessionId}:os_list` }], + [ + { text: '◀️ 다른 사양 선택', callback_data: `srv:${sessionId}:reselect` }, + { text: '❌ 취소', callback_data: `srv:${sessionId}:cancel` } + ] + ] + } + } + ); + return; + } + + // back_os: OS 선택으로 뒤로 + if (action === 'back_os') { + const { orderId } = session.data; + + // pending 주문 삭제 + if (orderId) { + await env.DB.prepare( + "DELETE FROM server_orders WHERE id = ? AND user_id = ? AND status = 'pending'" + ).bind(orderId, user.id).run(); + } + + await updateSession(env.SESSION_KV, sessionId, { + step: 'os_select', + image: undefined, + orderId: undefined + }); + + await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: 'OS 선택으로 이동' }); + + // OS 선택 화면 표시 + await editMessageText( + env.BOT_TOKEN, + chatId, + messageId, + `🖥️ OS 선택\n\n서버에 설치할 운영체제를 선택하세요:`, + { + reply_markup: { + inline_keyboard: [ + [{ text: '🐧 Ubuntu 22.04 LTS', callback_data: `srv:${sessionId}:os:ubuntu-22.04` }], + [{ text: '🐧 Ubuntu 24.04 LTS', callback_data: `srv:${sessionId}:os:ubuntu-24.04` }], + [{ text: '🎩 Debian 12', callback_data: `srv:${sessionId}:os:debian-12` }], + [{ text: '🎯 CentOS Stream 9', callback_data: `srv:${sessionId}:os:centos-stream-9` }], + [ + { text: '◀️ 뒤로', callback_data: `srv:${sessionId}:back_spec` }, + { text: '❌ 취소', callback_data: `srv:${sessionId}:cancel` } + ] + ] + } + } + ); + return; + } + + // 알 수 없는 action + await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '잘못된 요청입니다.' }); + return; + } + + // 서버 주문 확인 + if (data.startsWith('server_order:')) { + const parts = data.split(':'); + if (parts.length !== 2) { + await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '잘못된 데이터입니다.' }); + return; + } + + const orderId = parseIntSafe(parts[1], 1, 2147483647); // Max INT + + if (orderId === null) { + await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '잘못된 주문 ID입니다.' }); + return; + } + + await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '서버 생성 중...' }); + await editMessageText( + env.BOT_TOKEN, + chatId, + messageId, + '⏳ 서버를 생성하고 있습니다... (1-3분 소요)' + ); + + const result = await executeServerProvision(env, user.id, telegramUserId, orderId); + + if (result.success) { + await editMessageText( + env.BOT_TOKEN, + chatId, + messageId, + `✅ 서버 생성 완료! + +• 사양: ${result.plan_label} +• 리전: ${result.region} +• IP 주소: ${result.ip_address} +• Root 비밀번호: ${result.root_password} + +📌 접속 방법 +ssh root@${result.ip_address} + +⚠️ 보안 권고 +1. 즉시 비밀번호를 변경하세요: passwd +2. SSH 키 인증 설정을 권장합니다. +3. 방화벽(UFW)을 활성화하세요. + +🎉 서버가 성공적으로 생성되었습니다!` + ); + } else { + await editMessageText( + env.BOT_TOKEN, + chatId, + messageId, + `❌ 서버 생성 실패 + +${result.error} + +다시 시도하시려면 서버 주문을 요청해주세요.` + ); + } + return; + } + + // 서버 주문 취소 + if (data.startsWith('server_cancel:')) { + const parts = data.split(':'); + if (parts.length !== 2) { + await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '잘못된 데이터입니다.' }); + return; + } + + const orderId = parseIntSafe(parts[1], 1, 2147483647); + + if (orderId === null) { + await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '잘못된 주문 ID입니다.' }); + return; + } + + // 주문 취소 처리 (DB에서 status를 cancelled로 변경) + const cancelResult = await env.DB.prepare( + "UPDATE server_orders SET status = 'cancelled', terminated_at = datetime('now') WHERE id = ? AND user_id = ? AND status = 'pending'" + ).bind(orderId, user.id).run(); + + if (cancelResult.success && cancelResult.meta?.changes && cancelResult.meta.changes > 0) { + await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '취소되었습니다.' }); + await editMessageText( + env.BOT_TOKEN, + chatId, + messageId, + '❌ 서버 주문이 취소되었습니다.' + ); + } else { + await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '취소 실패 (이미 처리됨)' }); + await editMessageText( + env.BOT_TOKEN, + chatId, + messageId, + '⚠️ 주문 취소에 실패했습니다. (이미 처리되었거나 권한이 없습니다.)' + ); + } + return; + } + + // ===== 이하 기존 핸들러는 레거시 주문 전용 (새로운 세션 기반 플로우는 srv:로 시작) ===== + + await answerCallbackQuery(env.BOT_TOKEN, queryId); +} diff --git a/src/routes/handlers/message-handler.ts b/src/routes/handlers/message-handler.ts new file mode 100644 index 0000000..1ced688 --- /dev/null +++ b/src/routes/handlers/message-handler.ts @@ -0,0 +1,613 @@ +import { sendMessage, sendMessageWithKeyboard } from '../../telegram'; +import { checkRateLimit } from '../../security'; +import { handleCommand } from '../../commands'; +import { UserService } from '../../services/user-service'; +import { ConversationService } from '../../services/conversation-service'; +import { ERROR_MESSAGES } from '../../constants/messages'; +import { + createSession, + updateSession, + deleteSession, + getUserActiveSession, + ServerOrderSessionData, +} from '../../utils/session'; +import { getServerSpec } from '../../services/cloud-spec-service'; +import { getRegionDisplay, getOSDisplayName, NUM_EMOJIS } from '../../constants/server'; +import type { Env, TelegramUpdate } from '../../types'; + +/** + * 메시지 처리 핸들러 + */ +export async function handleMessage( + env: Env, + update: TelegramUpdate +): Promise { + if (!update.message?.text) return; + + const { message } = update; + const chatId = message.chat.id; + const chatIdStr = chatId.toString(); + const text = message.text!; + const telegramUserId = message.from.id.toString(); + + // 1. Rate Limiting 체크 + if (!(await checkRateLimit(env.RATE_LIMIT_KV, telegramUserId))) { + await sendMessage( + env.BOT_TOKEN, + chatId, + '⚠️ 너무 많은 요청입니다. 잠시 후 다시 시도해주세요.' + ); + return; + } + + // 2. 서비스 인스턴스 초기화 + const userService = new UserService(env.DB); + const conversationService = new ConversationService(env); + + // 3. 사용자 조회/생성 + let userId: number; + try { + userId = await userService.getOrCreateUser( + telegramUserId, + message.from.first_name, + message.from.username + ); + } catch (dbError) { + console.error('[handleMessage] 사용자 DB 오류:', dbError); + await sendMessage( + env.BOT_TOKEN, + chatId, + ERROR_MESSAGES.TEMPORARY_ERROR + ); + return; + } + + try { + // 4. 세션 기반 대화형 서버 주문 플로우 처리 + const serverSession = await getUserActiveSession( + env.SESSION_KV, + userId, + 'server_order' + ); + + if (serverSession) { + const { sessionId, session } = serverSession; + const { step } = session; + const lowerText = text.toLowerCase().trim(); + + // 취소 패턴 (모든 단계에서) + if (/^(취소|그만|중단|cancel|stop)/.test(lowerText)) { + // pending 주문 있으면 취소 + if (session.data.orderId) { + await env.DB.prepare( + "UPDATE server_orders SET status = 'cancelled' WHERE id = ? AND user_id = ? AND status = 'pending'" + ).bind(session.data.orderId, userId).run(); + } + await deleteSession(env.SESSION_KV, sessionId); + await sendMessage(env.BOT_TOKEN, chatId, + '❌ 서버 주문이 취소되었습니다.\n\n다시 시작하려면 "서버 추천해줘"라고 말씀해주세요.' + ); + return; + } + + // Step 1: recommend - 추천 목록에서 선택 + if (step === 'recommend' && session.data.recommendations) { + // 숫자 패턴: "1", "1번", "첫번째", "첫 번째" + const numPatterns: Record = { + '1': 0, '1번': 0, '첫번째': 0, '첫 번째': 0, '일번': 0, + '2': 1, '2번': 1, '두번째': 1, '두 번째': 1, '이번': 1, + '3': 2, '3번': 2, '세번째': 2, '세 번째': 2, '삼번': 2, + '4': 3, '4번': 3, '네번째': 3, '네 번째': 3, '사번': 3, + '5': 4, '5번': 4, '다섯번째': 4, '다섯 번째': 4, '오번': 4, + }; + + const matchedIndex = numPatterns[lowerText]; + if (matchedIndex !== undefined && matchedIndex < session.data.recommendations.length) { + const selected = session.data.recommendations[matchedIndex]; + + // 세션 업데이트 + await updateSession(env.SESSION_KV, sessionId, { + step: 'spec_confirm', + plan: selected.plan, + region: selected.region, + provider: selected.provider, + recommendations: undefined + }); + + // 사양 조회 + const spec = await getServerSpec( + env.CLOUD_DB, + selected.plan, + selected.region, + selected.provider + ); + + if (!spec) { + await sendMessage(env.BOT_TOKEN, chatId, '❌ 사양을 찾을 수 없습니다. 다시 시도해주세요.'); + return; + } + + await updateSession(env.SESSION_KV, sessionId, { + priceKrw: spec.monthly_price_krw + }); + + const ramGB = (spec.memory_mb / 1024).toFixed(1); + await sendMessage(env.BOT_TOKEN, chatId, + `📦 ${matchedIndex + 1}번 사양 선택 + +컴퓨팅 +• vCPU: ${spec.vcpu}개 +• RAM: ${ramGB}GB +• 스토리지: ${spec.storage_gb}GB SSD +• 트래픽: ${spec.transfer_tb}TB/월 + +요금 +• 월 ${spec.monthly_price_krw.toLocaleString()}원 + +🖥️ OS를 선택해주세요: +• "우분투" 또는 "ubuntu 22" +• "우분투 24" 또는 "ubuntu 24" +• "데비안" 또는 "debian" +• "센토스" 또는 "centos" + +💡 "뒤로"로 추천 목록으로, "다시"로 새 추천, "취소"로 중단할 수 있습니다.` + ); + return; + } + } + + // Step 2: spec_confirm - OS 선택 + if (step === 'spec_confirm') { + // "뒤로" 패턴 - 추천 목록으로 돌아가기 + if (/^(뒤로|back)$/.test(lowerText)) { + await sendMessage(env.BOT_TOKEN, chatId, '🔄 추천 목록을 다시 불러오는 중...'); + + try { + const requestBody = { + tech_stack: ['nginx'], + expected_users: 100, + use_case: 'general purpose server', + lang: 'ko' + }; + + const response = env.SERVER_RECOMMEND + ? await env.SERVER_RECOMMEND.fetch('https://internal/api/recommend', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(requestBody), + }) + : await fetch(env.SERVER_RECOMMEND_API_URL || 'https://server-recommend.kappa-d8e.workers.dev/api/recommend', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(requestBody), + }); + + if (!response.ok) throw new Error(`API error: ${response.status}`); + + const result = await response.json() as { + recommendations?: Array<{ + server: { + instance_id: string; + region_code: string; + provider_name: string; + vcpu: number; + memory_mb: number; + storage_gb: number; + monthly_price: number; + }; + score: number; + }>; + }; + + if (!result.recommendations || result.recommendations.length === 0) { + throw new Error('No recommendations'); + } + + const topRecs = result.recommendations.slice(0, 5); + + // 세션 업데이트 (step: recommend, recommendations 다시 저장) + await updateSession(env.SESSION_KV, sessionId, { + step: 'recommend', + plan: undefined, + region: undefined, + provider: undefined, + priceKrw: undefined, + recommendations: topRecs.map(rec => ({ + plan: rec.server.instance_id, + region: rec.server.region_code, + provider: rec.server.provider_name.toLowerCase() + })) + }); + + let responseText = `🎯 서버 추천\n\n`; + topRecs.forEach((rec, index) => { + const server = rec.server; + const ramGB = (server.memory_mb / 1024).toFixed(1); + const priceKrw = Math.round(server.monthly_price); + const regionDisplay = getRegionDisplay(server.region_code); + responseText += `${NUM_EMOJIS[index]} ${server.vcpu} vCPU / ${ramGB}GB RAM / ${server.storage_gb}GB SSD\n`; + responseText += ` ${regionDisplay} | 💰 ${priceKrw.toLocaleString()}원/월 | 🎯 점수: ${rec.score}/100\n\n`; + }); + responseText += `💬 원하시는 번호를 입력해주세요\n예: "1번" 또는 "첫번째"\n\n💡 "취소"로 언제든 중단할 수 있습니다.`; + + await sendMessage(env.BOT_TOKEN, chatId, responseText); + } catch (error) { + console.error('[back] 추천 API 오류:', error); + await sendMessage(env.BOT_TOKEN, chatId, + '❌ 추천 목록을 불러오는 중 오류가 발생했습니다.\n\n💡 "서버 추천해줘"라고 다시 말씀해주세요.' + ); + await deleteSession(env.SESSION_KV, sessionId); + } + return; + } + + // "다시" 패턴 - 새로 추천받기 (기존 세션 유지하면서 새 추천) + if (/^(다시|다른)/.test(lowerText)) { + await sendMessage(env.BOT_TOKEN, chatId, '🔄 새로운 추천을 받는 중...'); + + try { + const requestBody = { + tech_stack: ['nginx'], + expected_users: 100, + use_case: 'general purpose server', + lang: 'ko' + }; + + const response = env.SERVER_RECOMMEND + ? await env.SERVER_RECOMMEND.fetch('https://internal/api/recommend', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(requestBody), + }) + : await fetch(env.SERVER_RECOMMEND_API_URL || 'https://server-recommend.kappa-d8e.workers.dev/api/recommend', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(requestBody), + }); + + if (!response.ok) throw new Error(`API error: ${response.status}`); + + const result = await response.json() as { + recommendations?: Array<{ + server: { + instance_id: string; + region_code: string; + provider_name: string; + vcpu: number; + memory_mb: number; + storage_gb: number; + monthly_price: number; + }; + score: number; + }>; + }; + + if (!result.recommendations || result.recommendations.length === 0) { + throw new Error('No recommendations'); + } + + const topRecs = result.recommendations.slice(0, 5); + + // 세션 업데이트 (새 추천으로 교체) + await updateSession(env.SESSION_KV, sessionId, { + step: 'recommend', + plan: undefined, + region: undefined, + provider: undefined, + priceKrw: undefined, + recommendations: topRecs.map(rec => ({ + plan: rec.server.instance_id, + region: rec.server.region_code, + provider: rec.server.provider_name.toLowerCase() + })) + }); + + let responseText = `🎯 새로운 서버 추천\n\n`; + topRecs.forEach((rec, index) => { + const server = rec.server; + const ramGB = (server.memory_mb / 1024).toFixed(1); + const priceKrw = Math.round(server.monthly_price); + const regionDisplay = getRegionDisplay(server.region_code); + responseText += `${NUM_EMOJIS[index]} ${server.vcpu} vCPU / ${ramGB}GB RAM / ${server.storage_gb}GB SSD\n`; + responseText += ` ${regionDisplay} | 💰 ${priceKrw.toLocaleString()}원/월 | 🎯 점수: ${rec.score}/100\n\n`; + }); + responseText += `💬 원하시는 번호를 입력해주세요\n예: "1번" 또는 "첫번째"\n\n💡 "취소"로 언제든 중단할 수 있습니다.`; + + await sendMessage(env.BOT_TOKEN, chatId, responseText); + } catch (error) { + console.error('[다시] 추천 API 오류:', error); + await sendMessage(env.BOT_TOKEN, chatId, + '❌ 새 추천을 받는 중 오류가 발생했습니다.\n\n💡 "서버 추천해줘"라고 다시 말씀해주세요.' + ); + await deleteSession(env.SESSION_KV, sessionId); + } + return; + } + + // OS 패턴 매칭 + let osImage: string | null = null; + if (/우분투\s*22|ubuntu\s*22|우분투$|ubuntu$/.test(lowerText)) { + osImage = 'ubuntu-22.04'; + } else if (/우분투\s*24|ubuntu\s*24/.test(lowerText)) { + osImage = 'ubuntu-24.04'; + } else if (/데비안|debian/.test(lowerText)) { + osImage = 'debian-12'; + } else if (/센토스|centos/.test(lowerText)) { + osImage = 'centos-stream-9'; + } + + if (osImage) { + const { plan, region, provider } = session.data; + + // 사양 재조회 + const spec = await getServerSpec( + env.CLOUD_DB, + plan!, + region!, + provider! + ); + + if (!spec) { + await sendMessage(env.BOT_TOKEN, chatId, '❌ 사양 정보를 찾을 수 없습니다.'); + return; + } + + // 잔액 확인 + const deposit = await env.DB.prepare( + "SELECT balance FROM user_deposits WHERE user_id = ?" + ).bind(userId).first<{ balance: number }>(); + const balance = deposit?.balance || 0; + + if (balance < spec.monthly_price_krw) { + const shortage = spec.monthly_price_krw - balance; + await sendMessage(env.BOT_TOKEN, chatId, + `❌ 잔액 부족 + +• 필요 금액: ${spec.monthly_price_krw.toLocaleString()}원 +• 현재 잔액: ${balance.toLocaleString()}원 +• 부족 금액: ${shortage.toLocaleString()}원 + +💳 입금 계좌 +하나은행 427-910018-27104 (주식회사 아이언클래드) + +입금 후 다시 시도해주세요.` + ); + return; + } + + // 주문 생성 + const label = `server-${Date.now()}`; + const orderResult = await env.DB.prepare(` + INSERT INTO server_orders (user_id, spec_id, status, label, region, image, price_paid, billing_type, created_at) + VALUES (?, ?, 'pending', ?, ?, ?, ?, 'monthly', datetime('now')) + `).bind(userId, spec.pricing_id, label, region, osImage, spec.monthly_price_krw).run(); + + const orderId = orderResult.meta?.last_row_id; + + await updateSession(env.SESSION_KV, sessionId, { + step: 'final_confirm', + image: osImage, + orderId: orderId + }); + + + const ramGB = (spec.memory_mb / 1024).toFixed(1); + await sendMessage(env.BOT_TOKEN, chatId, + `✅ 최종 확인 + +• 사양: ${spec.vcpu} vCPU / ${ramGB}GB RAM / ${spec.storage_gb}GB SSD +• OS: ${getOSDisplayName(osImage)} +• 가격: ${spec.monthly_price_krw.toLocaleString()}원/월 +• 현재 잔액: ${balance.toLocaleString()}원 + +⚠️ 요금 안내 +월 선불제이며, 중도 해지 시 시간당 요금으로 정산 후 환불됩니다. + +🚀 서버를 생성하시려면 "확인" 또는 "생성"이라고 입력하세요. +❌ 취소하시려면 "취소"라고 입력하세요.` + ); + return; + } + } + + // Step 3: final_confirm - 최종 확인 + if (step === 'final_confirm') { + // "뒤로" 패턴 - OS 선택으로 + if (/^(뒤로|back|os)/.test(lowerText)) { + // pending 주문 삭제 + if (session.data.orderId) { + await env.DB.prepare( + "DELETE FROM server_orders WHERE id = ? AND user_id = ? AND status = 'pending'" + ).bind(session.data.orderId, userId).run(); + } + + await updateSession(env.SESSION_KV, sessionId, { + step: 'spec_confirm', + image: undefined, + orderId: undefined + }); + + // 선택된 사양 정보 표시 + const { priceKrw } = session.data; + const priceInfo = priceKrw ? `${priceKrw.toLocaleString()}원/월` : '?'; + + await sendMessage(env.BOT_TOKEN, chatId, + `🖥️ OS를 다시 선택해주세요: + +현재 선택된 사양: ${priceInfo} + +• "우분투" 또는 "ubuntu 22" +• "우분투 24" 또는 "ubuntu 24" +• "데비안" 또는 "debian" +• "센토스" 또는 "centos" + +💡 "뒤로"로 사양 선택으로 돌아가거나, "취소"로 중단할 수 있습니다.` + ); + return; + } + + // 확인 패턴 - 서버 생성 + if (/^(확인|진행|생성|네|예|ok|yes|confirm)/.test(lowerText)) { + const { orderId } = session.data; + + if (!orderId) { + await sendMessage(env.BOT_TOKEN, chatId, '❌ 주문 정보가 없습니다. 다시 시작해주세요.'); + await deleteSession(env.SESSION_KV, sessionId); + return; + } + + await sendMessage(env.BOT_TOKEN, chatId, '⏳ 서버를 생성하고 있습니다... (1-3분 소요)'); + + // 서버 생성 실행은 webhook.ts에서 executeServerProvision 임포트 + const { executeServerProvision } = await import('../../server-provision'); + const result = await executeServerProvision(env, userId, telegramUserId, orderId); + + // 세션 삭제 + await deleteSession(env.SESSION_KV, sessionId); + + if (result.success) { + await sendMessage(env.BOT_TOKEN, chatId, + `✅ 서버 생성 완료! + +• IP 주소: ${result.ip_address} +• Root 비밀번호: ${result.root_password} + +📌 접속 방법 +ssh root@${result.ip_address} + +⚠️ 보안 권고 +1. 즉시 비밀번호를 변경하세요: passwd +2. SSH 키 인증 설정을 권장합니다. + +🎉 서버가 성공적으로 생성되었습니다!` + ); + } else { + await sendMessage(env.BOT_TOKEN, chatId, + `❌ 서버 생성 실패 + +${result.error} + +다시 시도하시려면 "서버 추천해줘"라고 말씀해주세요.` + ); + } + return; + } + } + + // 세션은 있지만 매칭되는 입력이 아닌 경우 - 힌트 제공 + // (AI 응답으로 넘어가도록 여기서 return 하지 않음) + // 단, 명확한 서버 관련 질문이면 힌트 제공 + if (/서버|사양|os|운영체제/.test(lowerText) && !/추천|알려/.test(lowerText)) { + let hint = ''; + if (step === 'recommend') { + hint = '💡 추천 목록에서 번호를 선택해주세요. (예: "1번", "두번째")'; + } else if (step === 'spec_confirm') { + hint = '💡 OS를 선택해주세요. (예: "우분투", "debian")'; + } else if (step === 'final_confirm') { + hint = '💡 "확인"으로 서버를 생성하거나, "취소"로 주문을 취소할 수 있습니다.'; + } + if (hint) { + await sendMessage(env.BOT_TOKEN, chatId, hint); + return; + } + } + } + // === 세션 기반 대화형 플로우 처리 끝 === + + // 5. 명령어 처리 + if (text.startsWith('/')) { + const [command, ...argParts] = text.split(' '); + const args = argParts.join(' '); + const responseText = await handleCommand(env, userId, chatIdStr, command, args); + + // /start 명령어는 미니앱 버튼과 함께 전송 + if (command === '/start') { + const hostingUrl = env.HOSTING_SITE_URL || 'https://hosting.anvil.it.com'; + await sendMessageWithKeyboard(env.BOT_TOKEN, chatId, responseText, [ + [{ text: '🌐 서비스 보기', web_app: { url: hostingUrl } }], + [{ text: '💬 문의하기', url: 'https://t.me/AnvilForgeBot' }], + ]); + return; + } + + await sendMessage(env.BOT_TOKEN, chatId, responseText); + return; + } + + // 5. 일반 대화 처리 (ConversationService 위임) + const result = await conversationService.processUserMessage( + userId, + chatIdStr, + text, + telegramUserId + ); + + let finalResponse = result.responseText; + if (result.isProfileUpdated) { + finalResponse += '\n\n👤 프로필이 업데이트되었습니다.'; + } + + // 6. 응답 전송 (키보드 포함 여부 확인) + if (result.keyboardData) { + console.log('[Webhook] Keyboard data received:', result.keyboardData.type); + if (result.keyboardData.type === 'domain_register') { + const { domain, price } = result.keyboardData; + const callbackData = `domain_reg:${domain}:${price}`; + + await sendMessageWithKeyboard(env.BOT_TOKEN, chatId, finalResponse, [ + [ + { text: '✅ 등록하기', callback_data: callbackData }, + { text: '❌ 취소', callback_data: 'domain_cancel' } + ] + ]); + } else if (result.keyboardData.type === 'server_order') { + const { order_id } = result.keyboardData; + const confirmData = `server_order:${order_id}`; + const cancelData = `server_cancel:${order_id}`; + + await sendMessageWithKeyboard(env.BOT_TOKEN, chatId, finalResponse, [ + [ + { text: '✅ 생성하기', callback_data: confirmData }, + { text: '❌ 취소', callback_data: cancelData } + ] + ]); + } else if (result.keyboardData.type === 'server_recommend') { + const { specs } = result.keyboardData; + + // 세션 생성 (기존 세션 있으면 덮어씀) - 추천 목록 저장 + await createSession( + env.SESSION_KV, + userId, + 'server_order', + { + recommendations: specs.map(spec => ({ + plan: spec.plan, + region: spec.region, + provider: spec.provider + })) + }, + 'recommend' + ); + + // 대화형 안내 추가 (버튼 없이 메시지만) + const guideText = `\n\n💬 원하시는 번호를 입력해주세요\n예: "1번" 또는 "첫번째"\n\n💡 "취소"로 언제든 중단할 수 있습니다.`; + + await sendMessage(env.BOT_TOKEN, chatId, finalResponse + guideText); + } else { + // TypeScript exhaustiveness check - should never reach here + console.warn('[Webhook] Unknown keyboard type:', (result.keyboardData as { type: string }).type); + await sendMessage(env.BOT_TOKEN, chatId, finalResponse); + } + } else { + await sendMessage(env.BOT_TOKEN, chatId, finalResponse); + } + + } catch (error) { + console.error('[handleMessage] 처리 오류:', error); + await sendMessage( + env.BOT_TOKEN, + chatId, + '⚠️ 메시지 처리 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.' + ); + } +} diff --git a/src/routes/webhook.ts b/src/routes/webhook.ts index 3826bed..b6bb823 100644 --- a/src/routes/webhook.ts +++ b/src/routes/webhook.ts @@ -1,230 +1,7 @@ -import { Env, TelegramUpdate } from '../types'; -import { validateWebhookRequest, checkRateLimit } from '../security'; -import { sendMessage, sendMessageWithKeyboard, answerCallbackQuery, editMessageText } from '../telegram'; -import { executeDomainRegister } from '../domain-register'; -import { handleCommand } from '../commands'; -import { UserService } from '../services/user-service'; -import { ConversationService } from '../services/conversation-service'; -import { ERROR_MESSAGES } from '../constants/messages'; - -/** - * Safely parse integer with range validation - * @param value - String to parse - * @param min - Minimum allowed value (inclusive) - * @param max - Maximum allowed value (inclusive) - * @returns Parsed integer or null if invalid/out of range - */ -function parseIntSafe(value: string, min: number, max: number): number | null { - const parsed = parseInt(value, 10); - if (isNaN(parsed) || parsed < min || parsed > max) { - return null; - } - return parsed; -} - -// 메시지 처리 핸들러 -async function handleMessage( - env: Env, - update: TelegramUpdate -): Promise { - if (!update.message?.text) return; - - const { message } = update; - const chatId = message.chat.id; - const chatIdStr = chatId.toString(); - const text = message.text!; - const telegramUserId = message.from.id.toString(); - - // 1. Rate Limiting 체크 - if (!(await checkRateLimit(env.RATE_LIMIT_KV, telegramUserId))) { - await sendMessage( - env.BOT_TOKEN, - chatId, - '⚠️ 너무 많은 요청입니다. 잠시 후 다시 시도해주세요.' - ); - return; - } - - // 2. 서비스 인스턴스 초기화 - const userService = new UserService(env.DB); - const conversationService = new ConversationService(env); - - // 3. 사용자 조회/생성 - let userId: number; - try { - userId = await userService.getOrCreateUser( - telegramUserId, - message.from.first_name, - message.from.username - ); - } catch (dbError) { - console.error('[handleMessage] 사용자 DB 오류:', dbError); - await sendMessage( - env.BOT_TOKEN, - chatId, - ERROR_MESSAGES.TEMPORARY_ERROR - ); - return; - } - - try { - // 4. 명령어 처리 - if (text.startsWith('/')) { - const [command, ...argParts] = text.split(' '); - const args = argParts.join(' '); - const responseText = await handleCommand(env, userId, chatIdStr, command, args); - - // /start 명령어는 미니앱 버튼과 함께 전송 - if (command === '/start') { - const hostingUrl = env.HOSTING_SITE_URL || 'https://hosting.anvil.it.com'; - await sendMessageWithKeyboard(env.BOT_TOKEN, chatId, responseText, [ - [{ text: '🌐 서비스 보기', web_app: { url: hostingUrl } }], - [{ text: '💬 문의하기', url: 'https://t.me/AnvilForgeBot' }], - ]); - return; - } - - await sendMessage(env.BOT_TOKEN, chatId, responseText); - return; - } - - // 5. 일반 대화 처리 (ConversationService 위임) - const result = await conversationService.processUserMessage( - userId, - chatIdStr, - text, - telegramUserId - ); - - let finalResponse = result.responseText; - if (result.isProfileUpdated) { - finalResponse += '\n\n👤 프로필이 업데이트되었습니다.'; - } - - // 6. 응답 전송 (키보드 포함 여부 확인) - if (result.keyboardData && result.keyboardData.type === 'domain_register') { - const { domain, price } = result.keyboardData; - const callbackData = `domain_reg:${domain}:${price}`; - - await sendMessageWithKeyboard(env.BOT_TOKEN, chatId, finalResponse, [ - [ - { text: '✅ 등록하기', callback_data: callbackData }, - { text: '❌ 취소', callback_data: 'domain_cancel' } - ] - ]); - } else { - await sendMessage(env.BOT_TOKEN, chatId, finalResponse); - } - - } catch (error) { - console.error('[handleMessage] 처리 오류:', error); - await sendMessage( - env.BOT_TOKEN, - chatId, - '⚠️ 메시지 처리 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.' - ); - } -} - -// Callback Query 처리 (인라인 버튼 클릭) -async function handleCallbackQuery( - env: Env, - callbackQuery: TelegramUpdate['callback_query'] -): Promise { - if (!callbackQuery) return; - - const { id: queryId, from, message, data } = callbackQuery; - if (!data || !message) { - await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '잘못된 요청입니다.' }); - return; - } - - const chatId = message.chat.id; - const messageId = message.message_id; - const telegramUserId = from.id.toString(); - - const userService = new UserService(env.DB); - const user = await userService.getUserByTelegramId(telegramUserId); - - if (!user) { - await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '사용자를 찾을 수 없습니다.' }); - return; - } - - // 도메인 등록 처리 - if (data.startsWith('domain_reg:')) { - const parts = data.split(':'); - if (parts.length !== 3) { - await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '잘못된 데이터입니다.' }); - return; - } - - const domain = parts[1]; - const price = parseIntSafe(parts[2], 0, 10000000); // 0 ~ 10 million KRW - - if (price === null) { - await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '잘못된 가격 정보입니다.' }); - return; - } - - await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '등록 처리 중...' }); - await editMessageText( - env.BOT_TOKEN, - chatId, - messageId, - `⏳ ${domain} 등록 처리 중...` - ); - - const result = await executeDomainRegister(env, user.id, telegramUserId, domain, price); - - if (result.success) { - const expiresInfo = result.expiresAt ? `\n• 만료일: ${result.expiresAt}` : ''; - const nsInfo = result.nameservers && result.nameservers.length > 0 - ? `\n\n🌐 현재 네임서버:\n${result.nameservers.map(ns => `• ${ns}`).join('\n')}` - : ''; - - await editMessageText( - env.BOT_TOKEN, - chatId, - messageId, - `✅ 도메인 등록 완료! - -• 도메인: ${result.domain} -• 결제 금액: ${result.price?.toLocaleString()}원 -• 현재 잔액: ${result.newBalance?.toLocaleString()}원${expiresInfo}${nsInfo} - -🎉 축하합니다! 도메인이 성공적으로 등록되었습니다. -네임서버 변경이 필요하면 말씀해주세요.` - ); - } else { - await editMessageText( - env.BOT_TOKEN, - chatId, - messageId, - `❌ 등록 실패 - -${result.error} - -다시 시도하시려면 도메인 등록을 요청해주세요.` - ); - } - return; - } - - // 도메인 등록 취소 - if (data === 'domain_cancel') { - await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '취소되었습니다.' }); - await editMessageText( - env.BOT_TOKEN, - chatId, - messageId, - '❌ 도메인 등록이 취소되었습니다.' - ); - return; - } - - await answerCallbackQuery(env.BOT_TOKEN, queryId); -} +import type { Env } from '../types'; +import { validateWebhookRequest } from '../security'; +import { handleCallbackQuery } from './handlers/callback-handler'; +import { handleMessage } from './handlers/message-handler'; /** * Telegram Webhook 요청 처리 diff --git a/src/server-provision.ts b/src/server-provision.ts new file mode 100644 index 0000000..ee95cfd --- /dev/null +++ b/src/server-provision.ts @@ -0,0 +1,487 @@ +/** + * Server Provisioning Orchestrator + * + * Purpose: Execute actual server creation after user confirmation + * + * Flow: + * 1. Fetch order from DB (server_orders) + * 2. Fetch spec from CLOUD_DB (pricing + instance_types + providers + regions) + * 3. Validate status (only 'pending' orders) + * 4. Re-check balance (Optimistic Locking) + * 5. Update status: provisioning + * 6. Call Cloud API (Linode or Vultr) + * 7. Deduct balance + record transaction (Optimistic Locking, db.batch) + * 8. Update order (status='active', IP addresses, provider_instance_id) + * 9. Add to user_servers table + * 10. Return result + * + * On failure: + * - Set status='failed', error_message + * - Do NOT deduct balance (balance deduction happens only after successful provisioning) + * + * DB Architecture: + * - env.DB (telegram-conversations): server_orders, user_servers, user_deposits + * - env.CLOUD_DB (cloud-instances-db): pricing, instance_types, providers, regions + */ + +import { createLogger } from './utils/logger'; +import { executeWithOptimisticLock, OptimisticLockError } from './utils/optimistic-lock'; +import { createInstance as createLinodeInstance } from './services/linode-api'; +import { createInstance as createVultrInstance } from './services/vultr-api'; +import { notifyAdmin } from './services/notification'; +import { sendMessage } from './telegram'; +import type { + Env, + LinodeInstance, + VultrInstance, +} from './types'; + +const logger = createLogger('server-provision'); + +export interface ProvisionResult { + success: boolean; + order_id?: number; + instance_id?: string; + ip_address?: string; + root_password?: string; // Plain text (shown only once) + region?: string; // Region name (한글) + plan_label?: string; + error?: string; +} + +// Order row from DB +interface OrderRow { + id: number; + user_id: number; + spec_id: number; // pricing.id from CLOUD_DB + status: string; + region: string; // region_code + price_paid: number; +} + +// Spec info from CLOUD_DB +interface SpecInfo { + instance_id: string; // provider's plan ID (e.g., vc2-1c-1gb) + instance_name: string; // display name + vcpu: number; + memory_mb: number; + storage_gb: number; + region_code: string; + region_name: string; + provider_id: number; + provider_name: string; +} + +/** + * Execute server provisioning after user confirmation + * + * @param env - Environment variables (API keys, DB) + * @param userId - User ID (for ownership check) + * @param telegramUserId - Telegram User ID (for logging) + * @param orderId - Server order ID + * @returns ProvisionResult + */ +export async function executeServerProvision( + env: Env, + userId: number, + telegramUserId: string, + orderId: number +): Promise { + try { + // 1. Fetch order from DB (server_orders only) + const orderRow = await env.DB.prepare( + `SELECT id, user_id, spec_id, status, region, price_paid + FROM server_orders + WHERE id = ?` + ).bind(orderId).first(); + + if (!orderRow) { + logger.warn('Order not found', { orderId }); + return { success: false, error: '주문을 찾을 수 없습니다.' }; + } + + // 2. Validate ownership + if (orderRow.user_id !== userId) { + logger.warn('Order ownership mismatch', { + orderId, + userId, + orderUserId: orderRow.user_id, + }); + return { success: false, error: '본인의 주문만 처리할 수 있습니다.' }; + } + + // 3. Validate status (only 'pending' orders can be provisioned) + if (orderRow.status !== 'pending') { + logger.warn('Order status not pending', { + orderId, + status: orderRow.status, + }); + return { + success: false, + error: `이미 처리된 주문입니다. (상태: ${orderRow.status})`, + }; + } + + // 4. Fetch spec info from CLOUD_DB + if (!env.CLOUD_DB) { + logger.error('CLOUD_DB not available', undefined, { orderId }); + return { success: false, error: '서버 사양 데이터베이스에 접근할 수 없습니다.' }; + } + + const specInfo = await env.CLOUD_DB.prepare( + `SELECT + it.instance_id, + it.instance_name, + it.vcpu, + it.memory_mb, + it.storage_gb, + r.region_code, + r.region_name, + prov.id as provider_id, + prov.name as provider_name + FROM pricing p + JOIN instance_types it ON p.instance_type_id = it.id + JOIN regions r ON p.region_id = r.id + JOIN providers prov ON it.provider_id = prov.id + WHERE p.id = ?` + ).bind(orderRow.spec_id).first(); + + if (!specInfo) { + logger.warn('Spec not found in CLOUD_DB', { specId: orderRow.spec_id }); + return { success: false, error: '서버 사양을 찾을 수 없습니다.' }; + } + + // 5. Re-check balance (security measure) + const balanceRow = await env.DB.prepare( + 'SELECT balance FROM user_deposits WHERE user_id = ?' + ).bind(userId).first<{ balance: number }>(); + + const currentBalance = balanceRow?.balance || 0; + if (currentBalance < orderRow.price_paid) { + logger.warn('Insufficient balance on provision', { + orderId, + userId, + currentBalance, + requiredAmount: orderRow.price_paid, + }); + return { + success: false, + error: `잔액이 부족합니다. (현재: ${currentBalance.toLocaleString()}원, 필요: ${orderRow.price_paid.toLocaleString()}원)`, + }; + } + + // 6. Update status: provisioning + const statusUpdate = await env.DB.prepare( + "UPDATE server_orders SET status = 'provisioning', updated_at = CURRENT_TIMESTAMP WHERE id = ? AND status = 'pending'" + ).bind(orderId).run(); + + if (!statusUpdate.success || statusUpdate.meta.changes === 0) { + logger.error('Failed to update order status to provisioning', undefined, { + orderId, + updateResult: statusUpdate, + }); + return { success: false, error: '주문 상태 업데이트에 실패했습니다.' }; + } + + logger.info('Order status updated to provisioning', { + orderId, + provider: specInfo.provider_name, + plan: specInfo.instance_name, + region: orderRow.region, + }); + + // 7. Generate secure root password (20 chars) + const rootPassword = generateSecurePassword(); + + // 8. Call Cloud API (Linode or Vultr) + let instanceId: string | number; + let ipAddress: string; + + // Generate label from order ID and instance name + const instanceLabel = `order-${orderId}`; + + try { + if (specInfo.provider_name === 'linode') { + // Linode API + const linodeInstance: LinodeInstance = await createLinodeInstance( + { + type: specInfo.instance_id, + region: orderRow.region, + image: 'linode/ubuntu22.04', + root_pass: rootPassword, + label: instanceLabel, + }, + env + ); + + instanceId = linodeInstance.id; + ipAddress = linodeInstance.ipv4[0] || ''; + + logger.info('Linode instance created', { + orderId, + instanceId, + ipAddress, + label: linodeInstance.label, + }); + + } else if (specInfo.provider_name === 'vultr') { + // Vultr API + const vultrInstance: VultrInstance = await createVultrInstance( + { + plan: specInfo.instance_id, + region: orderRow.region, + os_id: 2136, // Ubuntu 24.04 LTS + label: instanceLabel, + hostname: instanceLabel, + }, + env + ); + + instanceId = vultrInstance.id; + ipAddress = vultrInstance.main_ip || ''; + + logger.info('Vultr instance created', { + orderId, + instanceId, + ipAddress, + label: vultrInstance.label, + }); + + } else { + throw new Error(`Unsupported provider: ${specInfo.provider_name}`); + } + + } catch (apiError) { + // Cloud API call failed + logger.error('Cloud API call failed', apiError as Error, { + orderId, + provider: specInfo.provider_name, + region: orderRow.region, + }); + + // Set status to 'failed' + await env.DB.prepare( + "UPDATE server_orders SET status = 'failed', error_message = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?" + ).bind( + `Cloud API 호출 실패: ${String(apiError)}`, + orderId + ).run(); + + return { + success: false, + error: `서버 생성에 실패했습니다. 관리자에게 문의하세요. (주문번호: #${orderId})`, + }; + } + + // 9. Deduct balance + record transaction (Optimistic Locking) + try { + await executeWithOptimisticLock(env.DB, async (attempt) => { + // 9-1. Get current version + const current = await env.DB.prepare( + 'SELECT balance, version FROM user_deposits WHERE user_id = ?' + ).bind(userId).first<{ balance: number; version: number }>(); + + if (!current) { + throw new Error('User deposit account not found'); + } + + // Double-check balance again (within lock) + if (current.balance < orderRow.price_paid) { + throw new Error('Insufficient balance during lock'); + } + + // 9-2. Update balance with version check + const balanceUpdate = await env.DB.prepare( + 'UPDATE user_deposits SET balance = balance - ?, version = version + 1, updated_at = CURRENT_TIMESTAMP WHERE user_id = ? AND version = ?' + ).bind(orderRow.price_paid, userId, current.version).run(); + + if (!balanceUpdate.success || balanceUpdate.meta.changes === 0) { + throw new OptimisticLockError('Version mismatch on balance update'); + } + + // 9-3. Record withdrawal transaction + const txInsert = await env.DB.prepare( + `INSERT INTO deposit_transactions (user_id, type, amount, status, description, confirmed_at) + VALUES (?, 'withdrawal', ?, 'confirmed', ?, CURRENT_TIMESTAMP)` + ).bind( + userId, + orderRow.price_paid, + `서버 프로비저닝: ${instanceLabel} (${specInfo.instance_name})` + ).run(); + + if (!txInsert.success) { + logger.error('Failed to insert withdrawal transaction', undefined, { + orderId, + userId, + amount: orderRow.price_paid, + attempt, + }); + throw new Error('Transaction insert failed'); + } + + logger.info('Balance deducted successfully', { + orderId, + userId, + amount: orderRow.price_paid, + newBalance: current.balance - orderRow.price_paid, + attempt, + }); + + return true; + }); + + } catch (lockError) { + // Balance deduction failed - instance already created, mark as failed for manual cleanup + logger.error('Balance deduction failed after instance creation', lockError as Error, { + orderId, + instanceId, + provider: specInfo.provider_name, + }); + + // 1. 관리자에게 즉시 알림 전송 (비용 누수 방지) + try { + await notifyAdmin( + 'api_error', + { + service: 'server-provision', + error: '인스턴스 생성 완료 후 잔액 차감 실패', + context: `주문번호: ${orderId}\n제공자: ${specInfo.provider_name}\n인스턴스 ID: ${instanceId}\n금액: ${orderRow.price_paid.toLocaleString()}원\n에러: ${String(lockError)}`, + }, + { + telegram: { + sendMessage: (chatId: number, text: string) => + sendMessage(env.BOT_TOKEN, chatId, text) + }, + adminId: env.SERVER_ADMIN_ID || env.DEPOSIT_ADMIN_ID || '', + env, + } + ); + } catch (notifyError) { + // 알림 실패는 로그만 기록 (메인 로직에 영향 없음) + logger.error('관리자 알림 전송 실패', notifyError as Error, { orderId }); + } + + // 2. 기존 에러 처리 로직 유지 + await env.DB.prepare( + "UPDATE server_orders SET status = 'failed', error_message = ?, provider_instance_id = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?" + ).bind( + `결제 처리 실패 (인스턴스 생성 완료, 수동 정리 필요): ${String(lockError)}`, + String(instanceId), + orderId + ).run(); + + return { + success: false, + error: '결제 처리 중 오류가 발생했습니다. 관리자에게 문의하세요.', + }; + } + + // 10. Update order (status='active', IP address, provider_instance_id) + const orderUpdate = await env.DB.prepare( + `UPDATE server_orders + SET status = 'active', + provider_instance_id = ?, + ip_address = ?, + root_password = ?, + provisioned_at = CURRENT_TIMESTAMP, + updated_at = CURRENT_TIMESTAMP + WHERE id = ?` + ).bind( + String(instanceId), + ipAddress, + rootPassword, + orderId + ).run(); + + if (!orderUpdate.success) { + logger.error('Failed to update order after provisioning', undefined, { + orderId, + instanceId, + }); + // Don't fail here - instance is already created and paid + } + + // 11. Add to user_servers table (only existing columns) + const serverInsert = await env.DB.prepare( + `INSERT INTO user_servers (user_id, order_id, provider_id, label, verified) + VALUES (?, ?, ?, ?, 1)` + ).bind( + userId, + orderId, + specInfo.provider_id, + instanceLabel + ).run(); + + if (!serverInsert.success) { + logger.error('Failed to insert user_servers record', undefined, { + orderId, + instanceId, + }); + // Don't fail here - instance is already created and paid + } + + logger.info('Server provisioning completed successfully', { + orderId, + instanceId, + ipAddress, + provider: specInfo.provider_name, + plan: specInfo.instance_name, + telegramUserId, + }); + + // 12. Return success + return { + success: true, + order_id: orderId, + instance_id: String(instanceId), + ip_address: ipAddress, + root_password: rootPassword, + region: specInfo.region_name, + plan_label: specInfo.instance_name, + }; + + } catch (error) { + logger.error('Server provisioning failed', error as Error, { + orderId, + telegramUserId, + }); + + // Try to mark order as failed (best effort) + try { + await env.DB.prepare( + "UPDATE server_orders SET status = 'failed', error_message = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?" + ).bind( + String(error), + orderId + ).run(); + } catch (updateError) { + logger.error('Failed to update order status to failed', updateError as Error, { + orderId, + }); + } + + return { + success: false, + error: `서버 프로비저닝 중 오류가 발생했습니다: ${String(error)}`, + }; + } +} + +/** + * Generate a secure random password (20 characters) + * + * Character set: uppercase, lowercase, digits, safe symbols + * Avoids ambiguous characters: I, l, 1, O, 0 + * + * @returns Random password string + */ +function generateSecurePassword(): string { + const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789!@#$%'; + let password = ''; + const randomValues = new Uint8Array(20); + crypto.getRandomValues(randomValues); + for (let i = 0; i < 20; i++) { + password += chars[randomValues[i] % chars.length]; + } + return password; +} diff --git a/src/services/cloud-spec-service.ts b/src/services/cloud-spec-service.ts new file mode 100644 index 0000000..be65238 --- /dev/null +++ b/src/services/cloud-spec-service.ts @@ -0,0 +1,43 @@ +export interface ServerSpec { + pricing_id: number; + vcpu: number; + memory_mb: number; + storage_gb: number; + transfer_tb: number; + network_speed_gbps: number | null; + monthly_price_krw: number; + region_name: string; +} + +/** + * 서버 사양 조회 (CLOUD_DB) + * + * @param db - CLOUD_DB 바인딩 + * @param plan - 인스턴스 ID (예: 'g6-nanode-1', 'vc2-1c-1gb') + * @param region - 리전 코드 (예: 'ap-northeast', 'nrt') + * @param provider - 제공자명 (소문자, 예: 'linode', 'vultr') + * @returns ServerSpec | null + */ +export async function getServerSpec( + db: D1Database, + plan: string, + region: string, + provider: string +): Promise { + return await db.prepare(` + SELECT + p.id as pricing_id, + it.vcpu, + it.memory_mb, + it.storage_gb, + it.transfer_tb, + it.network_speed_gbps, + p.monthly_price_krw, + r.region_name + FROM pricing p + JOIN instance_types it ON p.instance_type_id = it.id + JOIN regions r ON p.region_id = r.id AND r.provider_id = it.provider_id + JOIN providers pr ON it.provider_id = pr.id + WHERE it.instance_id = ? AND r.region_code = ? AND pr.name = ? + `).bind(plan, region, provider).first(); +} diff --git a/src/services/linode-api.ts b/src/services/linode-api.ts new file mode 100644 index 0000000..88a0b19 --- /dev/null +++ b/src/services/linode-api.ts @@ -0,0 +1,234 @@ +/** + * Linode API Client + * + * REST API 클라이언트 for Linode cloud provider + * - Instance management (create, get) + * - Region listing + * - Automatic retry with exponential backoff + */ + +import type { Env, LinodeInstance, LinodeCreateRequest } from '../types'; +import { createLogger } from '../utils/logger'; +import { retryWithBackoff } from '../utils/retry'; + +const logger = createLogger('linode-api'); + +/** + * Linode API Base URLs + */ +const DEFAULT_API_BASE = 'https://api.linode.com/v4'; + +/** + * Linode Region + */ +export interface LinodeRegion { + id: string; + label: string; + country: string; +} + +/** + * Linode API Error + */ +export class LinodeAPIError extends Error { + constructor( + message: string, + public readonly statusCode: number, + public readonly response?: any + ) { + super(message); + this.name = 'LinodeAPIError'; + } +} + +/** + * Create a Linode instance + * + * @param params - Instance creation parameters + * @param env - Environment variables (API key, base URL) + * @returns Created instance information + * @throws LinodeAPIError if API call fails + */ +export async function createInstance( + params: LinodeCreateRequest, + env: Env +): Promise { + const apiKey = env.LINODE_API_KEY; + if (!apiKey) { + throw new Error('LINODE_API_KEY is not configured'); + } + + const apiBase = env.LINODE_API_BASE || DEFAULT_API_BASE; + const url = `${apiBase}/linode/instances`; + + logger.info('Creating Linode instance', { + type: params.type, + region: params.region, + label: params.label, + }); + + return retryWithBackoff( + async () => { + const response = await fetch(url, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(params), + }); + + if (!response.ok) { + const errorBody = await response.json().catch(() => ({})); + logger.error('Linode API create instance failed', undefined, { + status: response.status, + statusText: response.statusText, + error: errorBody, + }); + + throw new LinodeAPIError( + `Linode API error: ${response.status} ${response.statusText}`, + response.status, + errorBody + ); + } + + const data = await response.json() as LinodeInstance; + logger.info('Linode instance created successfully', { + id: data.id, + label: data.label, + ipv4: data.ipv4, + }); + + return data; + }, + { + maxRetries: 3, + initialDelayMs: 1000, + serviceName: 'linode-api', + } + ); +} + +/** + * Get a Linode instance by ID + * + * @param instanceId - Linode instance ID + * @param env - Environment variables (API key, base URL) + * @returns Instance information + * @throws LinodeAPIError if API call fails + */ +export async function getInstance( + instanceId: number, + env: Env +): Promise { + const apiKey = env.LINODE_API_KEY; + if (!apiKey) { + throw new Error('LINODE_API_KEY is not configured'); + } + + const apiBase = env.LINODE_API_BASE || DEFAULT_API_BASE; + const url = `${apiBase}/linode/instances/${instanceId}`; + + logger.info('Getting Linode instance', { instanceId }); + + return retryWithBackoff( + async () => { + const response = await fetch(url, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${apiKey}`, + }, + }); + + if (!response.ok) { + const errorBody = await response.json().catch(() => ({})); + logger.error('Linode API get instance failed', undefined, { + status: response.status, + statusText: response.statusText, + instanceId, + error: errorBody, + }); + + throw new LinodeAPIError( + `Linode API error: ${response.status} ${response.statusText}`, + response.status, + errorBody + ); + } + + const data = await response.json() as LinodeInstance; + logger.info('Linode instance retrieved successfully', { + id: data.id, + label: data.label, + status: data.status, + }); + + return data; + }, + { + maxRetries: 3, + initialDelayMs: 1000, + serviceName: 'linode-api', + } + ); +} + +/** + * Get list of available Linode regions + * + * @param env - Environment variables (API key, base URL) + * @returns Array of available regions + * @throws LinodeAPIError if API call fails + */ +export async function getRegions(env: Env): Promise { + const apiKey = env.LINODE_API_KEY; + if (!apiKey) { + throw new Error('LINODE_API_KEY is not configured'); + } + + const apiBase = env.LINODE_API_BASE || DEFAULT_API_BASE; + const url = `${apiBase}/regions`; + + logger.info('Getting Linode regions'); + + return retryWithBackoff( + async () => { + const response = await fetch(url, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${apiKey}`, + }, + }); + + if (!response.ok) { + const errorBody = await response.json().catch(() => ({})); + logger.error('Linode API get regions failed', undefined, { + status: response.status, + statusText: response.statusText, + error: errorBody, + }); + + throw new LinodeAPIError( + `Linode API error: ${response.status} ${response.statusText}`, + response.status, + errorBody + ); + } + + const responseData = await response.json() as { data: LinodeRegion[] }; + const regions = responseData.data; + + logger.info('Linode regions retrieved successfully', { + count: regions.length, + }); + + return regions; + }, + { + maxRetries: 3, + initialDelayMs: 1000, + serviceName: 'linode-api', + } + ); +} diff --git a/src/services/server-recommend-service.ts b/src/services/server-recommend-service.ts new file mode 100644 index 0000000..bc02651 --- /dev/null +++ b/src/services/server-recommend-service.ts @@ -0,0 +1,65 @@ +import type { Env } from '../types'; + +export interface ServerRecommendation { + server: { + instance_id: string; + provider_name: string; + region_code: string; + vcpu: number; + memory_mb: number; + storage_gb: number; + monthly_price: number; + }; + score: number; +} + +export interface RecommendOptions { + tech_stack?: string[]; + expected_users?: number; + use_case?: string; + lang?: string; +} + +/** + * 서버 추천 API 호출 헬퍼 함수 + * + * @param env - Cloudflare Workers 환경 변수 + * @param options - 추천 옵션 (기본값: nginx, 100명, general purpose) + * @returns 서버 추천 목록 + * @throws API 호출 실패 시 에러 + */ +export async function fetchServerRecommendations( + env: Env, + options?: RecommendOptions +): Promise { + const requestBody = { + tech_stack: options?.tech_stack || ['nginx'], + expected_users: options?.expected_users || 100, + use_case: options?.use_case || 'general purpose server', + lang: options?.lang || 'ko' + }; + + const response = env.SERVER_RECOMMEND + ? await env.SERVER_RECOMMEND.fetch('https://internal/api/recommend', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(requestBody) + }) + : await fetch(env.SERVER_RECOMMEND_API_URL || 'https://server-recommend.kappa-d8e.workers.dev/api/recommend', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(requestBody) + }); + + if (!response.ok) { + throw new Error(`Recommendation API error: ${response.status}`); + } + + const data = await response.json() as { recommendations?: ServerRecommendation[] }; + + if (!data.recommendations || data.recommendations.length === 0) { + throw new Error('No recommendations available'); + } + + return data.recommendations; +} diff --git a/src/services/vultr-api.ts b/src/services/vultr-api.ts new file mode 100644 index 0000000..e9b7375 --- /dev/null +++ b/src/services/vultr-api.ts @@ -0,0 +1,240 @@ +/** + * Vultr API Client + * + * REST API 클라이언트 for Vultr cloud provider + * - Instance management (create, get) + * - Region listing + * - Automatic retry with exponential backoff + */ + +import type { Env, VultrInstance, VultrCreateRequest } from '../types'; +import { createLogger } from '../utils/logger'; +import { retryWithBackoff } from '../utils/retry'; + +const logger = createLogger('vultr-api'); + +/** + * Vultr API Base URLs + */ +const DEFAULT_API_BASE = 'https://api.vultr.com/v2'; + +/** + * Vultr Region + */ +export interface VultrRegion { + id: string; + city: string; + country: string; + continent: string; + options: string[]; +} + +/** + * Vultr API Error + */ +export class VultrAPIError extends Error { + constructor( + message: string, + public readonly statusCode: number, + public readonly response?: any + ) { + super(message); + this.name = 'VultrAPIError'; + } +} + +/** + * Create a Vultr instance + * + * @param params - Instance creation parameters + * @param env - Environment variables (API key, base URL) + * @returns Created instance information + * @throws VultrAPIError if API call fails + */ +export async function createInstance( + params: VultrCreateRequest, + env: Env +): Promise { + const apiKey = env.VULTR_API_KEY; + if (!apiKey) { + throw new Error('VULTR_API_KEY is not configured'); + } + + const apiBase = env.VULTR_API_BASE || DEFAULT_API_BASE; + const url = `${apiBase}/instances`; + + logger.info('Creating Vultr instance', { + plan: params.plan, + region: params.region, + label: params.label, + }); + + return retryWithBackoff( + async () => { + const response = await fetch(url, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(params), + }); + + if (!response.ok) { + const errorBody = await response.json().catch(() => ({})); + logger.error('Vultr API create instance failed', undefined, { + status: response.status, + statusText: response.statusText, + error: errorBody, + }); + + throw new VultrAPIError( + `Vultr API error: ${response.status} ${response.statusText}`, + response.status, + errorBody + ); + } + + const responseData = await response.json() as { instance: VultrInstance }; + const instance = responseData.instance; + + logger.info('Vultr instance created successfully', { + id: instance.id, + label: instance.label, + main_ip: instance.main_ip, + }); + + return instance; + }, + { + maxRetries: 3, + initialDelayMs: 1000, + serviceName: 'vultr-api', + } + ); +} + +/** + * Get a Vultr instance by ID + * + * @param instanceId - Vultr instance ID + * @param env - Environment variables (API key, base URL) + * @returns Instance information + * @throws VultrAPIError if API call fails + */ +export async function getInstance( + instanceId: string, + env: Env +): Promise { + const apiKey = env.VULTR_API_KEY; + if (!apiKey) { + throw new Error('VULTR_API_KEY is not configured'); + } + + const apiBase = env.VULTR_API_BASE || DEFAULT_API_BASE; + const url = `${apiBase}/instances/${instanceId}`; + + logger.info('Getting Vultr instance', { instanceId }); + + return retryWithBackoff( + async () => { + const response = await fetch(url, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${apiKey}`, + }, + }); + + if (!response.ok) { + const errorBody = await response.json().catch(() => ({})); + logger.error('Vultr API get instance failed', undefined, { + status: response.status, + statusText: response.statusText, + instanceId, + error: errorBody, + }); + + throw new VultrAPIError( + `Vultr API error: ${response.status} ${response.statusText}`, + response.status, + errorBody + ); + } + + const responseData = await response.json() as { instance: VultrInstance }; + const instance = responseData.instance; + + logger.info('Vultr instance retrieved successfully', { + id: instance.id, + label: instance.label, + status: instance.status, + }); + + return instance; + }, + { + maxRetries: 3, + initialDelayMs: 1000, + serviceName: 'vultr-api', + } + ); +} + +/** + * Get list of available Vultr regions + * + * @param env - Environment variables (API key, base URL) + * @returns Array of available regions + * @throws VultrAPIError if API call fails + */ +export async function getRegions(env: Env): Promise { + const apiKey = env.VULTR_API_KEY; + if (!apiKey) { + throw new Error('VULTR_API_KEY is not configured'); + } + + const apiBase = env.VULTR_API_BASE || DEFAULT_API_BASE; + const url = `${apiBase}/regions`; + + logger.info('Getting Vultr regions'); + + return retryWithBackoff( + async () => { + const response = await fetch(url, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${apiKey}`, + }, + }); + + if (!response.ok) { + const errorBody = await response.json().catch(() => ({})); + logger.error('Vultr API get regions failed', undefined, { + status: response.status, + statusText: response.statusText, + error: errorBody, + }); + + throw new VultrAPIError( + `Vultr API error: ${response.status} ${response.statusText}`, + response.status, + errorBody + ); + } + + const responseData = await response.json() as { regions: VultrRegion[] }; + const regions = responseData.regions; + + logger.info('Vultr regions retrieved successfully', { + count: regions.length, + }); + + return regions; + }, + { + maxRetries: 3, + initialDelayMs: 1000, + serviceName: 'vultr-api', + } + ); +} diff --git a/src/tools/server-tool.ts b/src/tools/server-tool.ts new file mode 100644 index 0000000..73c1378 --- /dev/null +++ b/src/tools/server-tool.ts @@ -0,0 +1,1015 @@ +import { createLogger, maskUserId } from "../utils/logger"; +import { retryWithBackoff } from "../utils/retry"; +import type { + Env, + ManageServerArgs, + InstanceSpec, + ServerOrder, +} from "../types"; + +const logger = createLogger("server-tool"); + +// 1. 도구 정의 +export const manageServerTool = { + type: "function", + function: { + name: "manage_server", + description: + '클라우드 서버(VPS) 추천/주문/관리. 서버, VPS, 클라우드, 호스팅 관련 요청 시 **무조건 즉시 호출**. 파라미터 없이 호출해도 됨 - API가 필요한 정보를 알려주면 그걸 사용자에게 전달하면 됨.', + parameters: { + type: "object", + properties: { + action: { + type: "string", + enum: [ + "recommend", + "list_specs", + "order", + "my_servers", + "server_info", + "cancel_order", + ], + description: + "recommend: **기본값** - 서버 생성/만들기/필요 요청 시 반드시 먼저 추천 (용도/예산/리전 기반). order: 사용자가 추천 목록에서 특정 번호를 선택했을 때만 사용 (spec_id 필수). list_specs: 전체 사양 목록, my_servers: 내 서버 목록, server_info: 서버 상세, cancel_order: 주문 취소", + }, + purpose: { + type: "string", + description: + "서버 용도 (예: 웹서버, 게임서버, 개발용, 데이터베이스). recommend action에서 사용", + }, + budget: { + type: "number", + description: "월 예산 (원). recommend action에서 사용", + }, + spec_id: { + type: "number", + description: "사양 ID. order action에서 필수", + }, + order_id: { + type: "number", + description: "주문 ID. cancel_order, server_info action에서 사용", + }, + region: { + type: "string", + description: + "선호 리전. 서울/Seoul/한국→seoul, 도쿄/Tokyo/일본→tokyo, 오사카/Osaka→osaka, 싱가포르/Singapore→singapore. recommend action에서 사용", + }, + label: { + type: "string", + description: "서버 라벨 (식별용 이름). order action에서 사용", + }, + provider: { + type: "string", + enum: ["linode", "vultr"], + description: + "제공자 필터 (선택사항). list_specs, recommend action에서 사용", + }, + expected_users: { + type: "number", + description: + "예상 동시 접속자 수 (게임서버: 플레이어 수, 웹앱: 동시 사용자)", + }, + daily_traffic: { + type: "number", + description: "일일 예상 요청 수 (웹서버 트래픽)", + }, + storage_needs_gb: { + type: "number", + description: "필요한 스토리지 용량 (GB)", + }, + tech_stack: { + type: "string", + description: + "사용 기술 스택 (nodejs, python, java, php, wordpress, docker 등)", + }, + }, + required: ["action"], + }, + }, +}; + +// 2. 메인 실행 함수 +export async function executeManageServer( + args: ManageServerArgs, + env?: Env, + telegramUserId?: string, + db?: D1Database, + cloudDb?: D1Database, +): Promise { + const { + action, + purpose, + budget, + spec_id, + order_id, + region, + label, + provider, + expected_users, + daily_traffic, + storage_needs_gb, + tech_stack, + } = args; + logger.info("시작", { + action, + purpose, + budget, + spec_id, + order_id, + region, + label, + provider, + expected_users, + daily_traffic, + storage_needs_gb, + tech_stack, + userId: maskUserId(telegramUserId), + }); + + if (!telegramUserId || !db) { + return "🚫 서버 관리 기능을 사용할 수 없습니다."; + } + + if (!cloudDb) { + return "🚫 서버 관리 기능을 사용할 수 없습니다. (CLOUD_DB 미설정)"; + } + + // 사용자 조회 + const user = await db + .prepare("SELECT id FROM users WHERE telegram_id = ?") + .bind(telegramUserId) + .first<{ id: number }>(); + + if (!user) { + return "🚫 사용자 정보를 찾을 수 없습니다."; + } + + const userId = user.id; + + try { + switch (action) { + case "recommend": + return await recommendServer( + { + purpose, + budget, + provider, + expected_users, + daily_traffic, + storage_needs_gb, + tech_stack, + region, + }, + cloudDb, + env, + ); + + case "list_specs": + return await listSpecs(provider, cloudDb); + + case "order": + if (!spec_id) return "🚫 사양 ID를 지정해주세요."; + return await orderServer( + spec_id, + region, + label, + userId, + db, + cloudDb, + env, + ); + + case "my_servers": + return await getMyServers(userId, db, cloudDb); + + case "server_info": + if (!order_id) return "🚫 주문 ID를 지정해주세요."; + return await getServerInfo(order_id, userId, db, cloudDb); + + case "cancel_order": + if (!order_id) return "🚫 주문 ID를 지정해주세요."; + return await cancelOrder(order_id, userId, db); + + default: + return `🚫 알 수 없는 작업: ${action}`; + } + } catch (error) { + logger.error("오류", error as Error, { action }); + return `🚫 서버 관리 오류: ${String(error)}`; + } +} + +// 3. 헬퍼 함수들 + +// 리전 필터링 상수 +const REGION_FILTER = ` + ( + (prov.name = 'linode' AND (r.region_name LIKE '%Tokyo%' OR r.region_name LIKE '%Osaka%' OR r.region_name LIKE '%Singapore%')) + OR (prov.name = 'vultr' AND (r.region_name LIKE '%Seoul%' OR r.region_name LIKE '%Tokyo%' OR r.region_name LIKE '%Osaka%' OR r.region_name LIKE '%Singapore%')) + ) +`; + +// 서버 추천 (용도/예산 기반) +async function recommendServer( + args: { + purpose?: string; + budget?: number; + provider?: "linode" | "vultr"; + expected_users?: number; + daily_traffic?: number; + storage_needs_gb?: number; + tech_stack?: string; + region?: string; // 선호 리전 (Seoul, Tokyo, Singapore) + }, + _cloudDb: D1Database, // 외부 API 사용으로 현재 미사용 + env?: Env, +): Promise { + logger.info("recommendServer 시작", args); + + // 외부 추천 API 호출 + const apiResult = await callExternalRecommendAPI(args, env); + + // 사용자 질문이 필요한 경우 + if (apiResult.type === 'user_question') { + logger.info('사용자 추가 정보 필요', { question: apiResult.question }); + return apiResult.question; + } + + // 외부 API 실패 시 에러 메시지 + if (apiResult.type === 'error') { + logger.error("외부 API 실패", new Error("추천 API 호출 실패")); + return "🚫 서버 추천 서비스에 연결할 수 없습니다. 잠시 후 다시 시도해주세요."; + } + + // 추천 결과 사용 + const recommendations = apiResult.recommendations; + logger.info("외부 API 추천 성공", { count: recommendations.length }); + + // 상위 5개만 사용 + const topRecommendations = recommendations.slice(0, 5); + + // 리스트 형태로 출력 + const numEmojis = ['1️⃣', '2️⃣', '3️⃣', '4️⃣', '5️⃣']; + let response = `🎯 ${args.purpose || "범용"} 서버 추천\n\n`; + + // 추천 서버 목록 (버튼 클릭 시 DB 조회용 정보 저장) + const specs = topRecommendations.map((rec, index) => ({ + num: index + 1, + plan: rec.server.instance_id, // 플랜 ID로 DB 조회 + region: rec.server.region_code, // 리전 코드로 DB 조회 + provider: rec.server.provider_name.toLowerCase(), + })); + + topRecommendations.forEach((rec, index) => { + const server = rec.server; + const ramGB = (server.memory_mb / 1024).toFixed(1); + const priceKrw = Math.round(server.monthly_price); + + response += `${numEmojis[index]} ${server.vcpu} vCPU / ${ramGB}GB RAM / ${server.storage_gb}GB SSD\n`; + response += ` 💰 ${priceKrw.toLocaleString()}원/월 | 🎯 AI 점수: ${rec.score}/100\n\n`; + }); + + response += `👆 서버를 신청하려면 아래 버튼을 선택하세요`; + + // 키보드 마커 추가 + const keyboardData = JSON.stringify({ + type: "server_recommend", + specs + }); + + return `__KEYBOARD__${keyboardData}__END__${response}`; +} + +// 외부 API 요청 인터페이스 +interface ExternalRecommendRequest { + tech_stack: string[]; + expected_users?: number; // 선택 - 없으면 API가 missing_fields로 요청 + use_case: string; + traffic_pattern?: 'steady' | 'spiky' | 'growing'; + region_preference?: string[]; + budget_limit?: number; + provider_filter?: string[]; + lang?: string; // 응답 언어 (ko, en, ja, zh 등) +} + +// 외부 API - 단일 추천 서버 정보 +interface ExternalRecommendation { + server: { + id: number; + provider_name: string; + instance_id: string; // 플랜 ID (예: vc2-1c-0.5gb-v6) + instance_name: string; + vcpu: number; + memory_mb: number; + storage_gb: number; + monthly_price: number; + region_name: string; + region_code: string; // 리전 코드 (예: nrt, icn) + }; + score: number; + analysis: { + tech_fit: string; + capacity: string; + cost_efficiency: string; + }; +} + +// 외부 API 응답 인터페이스 +interface ExternalRecommendResponse { + recommendations?: ExternalRecommendation[]; + infrastructure_tips?: string[]; + total_candidates?: number; + // 에러 응답 + error?: string; + missing_fields?: string[]; +} + +// callExternalRecommendAPI 반환 타입 +type RecommendAPIResult = + | { type: 'success'; recommendations: ExternalRecommendation[] } + | { type: 'user_question'; question: string } + | { type: 'error' }; + +// 외부 서버 추천 API 호출 +async function callExternalRecommendAPI( + args: { + purpose?: string; + expected_users?: number; + daily_traffic?: number; + storage_needs_gb?: number; + tech_stack?: string; + region?: string; + budget?: number; + provider?: "linode" | "vultr"; + }, + env?: Env +): Promise { + const apiUrl = env?.SERVER_RECOMMEND_API_URL || 'https://server-recommend.kappa-d8e.workers.dev/api/recommend'; + + logger.info('외부 추천 API 호출 시작', { apiUrl, args }); + + try { + // 1. tech_stack 파싱 및 기본값 설정 + const techStackArray: string[] = []; + if (args.tech_stack) { + techStackArray.push( + ...args.tech_stack.split(',').map(s => s.trim().toLowerCase()).filter(Boolean) + ); + } + + // tech_stack이 비어있으면 purpose에서 추출 또는 기본값 설정 + if (techStackArray.length === 0) { + if (args.purpose) { + const purposeLower = args.purpose.toLowerCase(); + // purpose에서 기술 스택 추출 + if (purposeLower.includes('nodejs') || purposeLower.includes('node')) { + techStackArray.push('nodejs'); + } else if (purposeLower.includes('python')) { + techStackArray.push('python'); + } else if (purposeLower.includes('java')) { + techStackArray.push('java'); + } else if (purposeLower.includes('php')) { + techStackArray.push('php'); + } else if (purposeLower.includes('wordpress')) { + techStackArray.push('wordpress'); + } else if (purposeLower.includes('docker')) { + techStackArray.push('docker'); + } + } + + // 여전히 비어있으면 기본값 (API가 "general"을 인식 못함 → nginx 사용) + if (techStackArray.length === 0) { + techStackArray.push('nginx'); + } + } + + // 2. use_case 생성 (purpose 또는 tech_stack 기반) - 한글→영문 변환 + let useCase = args.purpose || args.tech_stack || '범용 서버'; + + // 한글 purpose를 영문으로 매핑 + const useCaseLower = useCase.toLowerCase(); + if (useCaseLower.includes('웹서버') || useCaseLower.includes('웹 서버')) { + useCase = 'web server'; + } else if (useCaseLower.includes('게임서버') || useCaseLower.includes('게임 서버')) { + useCase = 'game server'; + } else if (useCaseLower.includes('개발용') || useCaseLower.includes('개발 서버')) { + useCase = 'development server'; + } else if (useCaseLower.includes('api 서버') || useCaseLower.includes('api서버')) { + useCase = 'API server'; + } else if (useCaseLower.includes('데이터베이스') || useCaseLower.includes('db 서버')) { + useCase = 'database server'; + } else if (useCaseLower.includes('범용') || useCaseLower.includes('서버 추천') || useCaseLower === '범용 서버') { + useCase = 'general purpose server'; + } + // 기타 경우는 그대로 유지 (이미 영문이거나 API가 처리할 수 있는 경우) + + // 3. traffic_pattern 추론 + let trafficPattern: 'steady' | 'spiky' | 'growing' | undefined; + if (args.daily_traffic) { + if (args.daily_traffic >= 1000000) { + trafficPattern = 'spiky'; + } else if (args.daily_traffic >= 100000) { + trafficPattern = 'growing'; + } else { + trafficPattern = 'steady'; + } + } + + // 4. region_preference 매핑 + const regionPreference: string[] = []; + if (args.region) { + const regionLower = args.region.toLowerCase(); + if (regionLower.includes('seoul') || regionLower.includes('서울')) { + regionPreference.push('seoul'); + } else if (regionLower.includes('tokyo') || regionLower.includes('도쿄')) { + regionPreference.push('tokyo'); + } else if (regionLower.includes('singapore') || regionLower.includes('싱가포르')) { + regionPreference.push('singapore'); + } else if (regionLower.includes('osaka') || regionLower.includes('오사카')) { + regionPreference.push('osaka'); + } + } + + // 5. budget_limit (KRW → USD, 환율 1400) + let budgetLimitUsd: number | undefined; + if (args.budget) { + budgetLimitUsd = Math.round(args.budget / 1400); + } + + // 6. provider_filter + const providerFilter: string[] = []; + if (args.provider) { + providerFilter.push(args.provider); + } + + // 7. expected_users (기본값 없음 - API가 필요하면 missing_fields로 요청) + const expectedUsers = args.expected_users; + + // 8. 언어 감지 (purpose 기반) + const detectLanguage = (text?: string): string => { + if (!text) return 'ko'; // 기본값: 한국어 + if (/[\u3040-\u309F\u30A0-\u30FF]/.test(text)) return 'ja'; // 히라가나/카타카나 + if (/[\u4E00-\u9FFF]/.test(text) && !/[가-힣]/.test(text)) return 'zh'; // 한자만 (한글 없음) + if (/[가-힣]/.test(text)) return 'ko'; // 한글 + if (/^[a-zA-Z\s\d.,!?]+$/.test(text)) return 'en'; // 영문만 + return 'ko'; + }; + const lang = detectLanguage(args.purpose); + + // 9. 요청 본문 구성 (필수 필드만 포함, 선택 필드는 값 있을 때만) + const requestBody: ExternalRecommendRequest = { + tech_stack: techStackArray, + use_case: useCase, + lang, + }; + + // 선택 필드: 값이 있을 때만 포함 + if (expectedUsers !== undefined) requestBody.expected_users = expectedUsers; + if (trafficPattern) requestBody.traffic_pattern = trafficPattern; + if (regionPreference.length > 0) requestBody.region_preference = regionPreference; + if (budgetLimitUsd) requestBody.budget_limit = budgetLimitUsd; + if (providerFilter.length > 0) requestBody.provider_filter = providerFilter; + + logger.info('외부 API 요청 본문', requestBody); + + // 9. API 호출 - Service Binding 사용 (Cloudflare Error 1042 방지) + // Service Binding이 있으면 직접 Worker 호출, 없으면 URL로 호출 + const useServiceBinding = !!env?.SERVER_RECOMMEND; + logger.info('API 호출 방식', { useServiceBinding }); + + const abortController = new AbortController(); + const timeoutId = setTimeout(() => abortController.abort(), 15000); + + const fetchFn = useServiceBinding + ? () => env!.SERVER_RECOMMEND!.fetch('https://internal/api/recommend', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(requestBody), + }) + : () => fetch(apiUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'User-Agent': 'TelegramBot/1.0', + }, + body: JSON.stringify(requestBody), + signal: abortController.signal, + }); + + const response = await retryWithBackoff(fetchFn, { maxRetries: 1, initialDelayMs: 1000 }); + + clearTimeout(timeoutId); + + // 400 응답: missing_fields 체크 (필수 필드 누락) + if (response.status === 400) { + const errorBody = await response.text().catch(() => '{}'); + logger.warn('외부 API 400 응답', { body: errorBody.substring(0, 500) }); + + try { + const errorData = JSON.parse(errorBody) as { missing_fields?: string[] }; + if (errorData.missing_fields && errorData.missing_fields.length > 0) { + const questions: string[] = []; + const missingFields = errorData.missing_fields; + + if (missingFields.includes('use_case')) { + questions.push('• 어떤 용도로 사용하실 건가요? (예: 웹서버, 게임서버, API 서버, 개발용)'); + } + if (missingFields.includes('tech_stack')) { + questions.push('• 사용할 기술 스택은? (예: nodejs, nginx, mysql, python, wordpress)'); + } + if (missingFields.includes('expected_users')) { + questions.push('• 예상 동시 접속자 수는? (예: 10명, 100명, 1000명)'); + } + + const userMessage = `🤔 서버 추천을 위해 몇 가지 정보가 필요합니다:\n\n${questions.join('\n')}\n\n예시: "nodejs 웹서버, 동시 50명 예상"`; + return { type: 'user_question', question: userMessage }; + } + } catch { + // JSON 파싱 실패 - 일반 에러로 처리 + } + return { type: 'error' }; + } + + // 기타 에러 응답 + if (!response.ok) { + const errorBody = await response.text().catch(() => 'Failed to read body'); + logger.warn('외부 API 응답 실패', { + status: response.status, + statusText: response.statusText, + body: errorBody.substring(0, 500) + }); + return { type: 'error' }; + } + + const data = await response.json() as ExternalRecommendResponse; + + // 11. 정상 응답 파싱 - recommendations 배열 확인 + if (!data.recommendations || data.recommendations.length === 0) { + logger.warn('외부 API 추천 결과 없음', { data }); + return { type: 'error' }; + } + + logger.info('외부 API 추천 성공', { count: data.recommendations.length }); + return { type: 'success', recommendations: data.recommendations }; + } catch (error) { + logger.error('외부 API 호출 실패', error as Error, { apiUrl }); + return { type: 'error' }; + } +} + + +// 전체 사양 목록 +async function listSpecs( + provider: "linode" | "vultr" | undefined, + cloudDb: D1Database, +): Promise { + logger.info("listSpecs 시작", { provider }); + + let query = ` + SELECT + p.id, + it.instance_name, + it.vcpu, + it.memory_mb, + it.storage_gb, + it.transfer_tb, + r.region_name, + p.monthly_price_krw, + prov.name as provider_name + FROM pricing p + JOIN instance_types it ON p.instance_type_id = it.id + JOIN regions r ON p.region_id = r.id + JOIN providers prov ON it.provider_id = prov.id + WHERE p.available = 1 + AND ${REGION_FILTER} + `; + + const bindings: string[] = []; + + if (provider) { + query += " AND prov.name = ?"; + bindings.push(provider); + } + + query += " ORDER BY p.monthly_price_krw ASC"; + + const { results } = await cloudDb + .prepare(query) + .bind(...bindings) + .all(); + + if (!results || results.length === 0) { + return "🚫 등록된 서버 사양이 없습니다."; + } + + let response = `📋 **서버 사양 목록** (${results.length}개)\n\n`; + + // 가격순으로 정렬된 목록 표시 + results.forEach((spec) => { + response += `#${spec.id}: ${spec.instance_name}\n`; + response += ` • ${spec.vcpu} vCPUs, ${(spec.memory_mb / 1024).toFixed(1)}GB RAM, ${spec.storage_gb}GB SSD\n`; + response += ` • 리전: ${spec.region_name}\n`; + response += ` • ${spec.monthly_price_krw.toLocaleString()}원/월\n\n`; + }); + + response += `💡 추천받으려면 "웹서버용 서버 추천" 형식으로 말씀해주세요.`; + + return response; +} + +// 서버 주문 (인라인 버튼 생성) +async function orderServer( + specId: number, + region: string | undefined, + label: string | undefined, + userId: number, + db: D1Database, + cloudDb: D1Database, + _env?: Env, +): Promise { + logger.info("orderServer 시작", { + specId, + region, + label, + userId: maskUserId(userId), + }); + + // 1. 사양 조회 (CLOUD_DB) - specId는 pricing.id + const spec = await cloudDb + .prepare( + ` + SELECT + p.id, + it.instance_name, + it.vcpu, + it.memory_mb, + it.storage_gb, + it.transfer_tb, + r.region_name, + r.region_code, + p.monthly_price_krw, + prov.name as provider_name + FROM pricing p + JOIN instance_types it ON p.instance_type_id = it.id + JOIN regions r ON p.region_id = r.id + JOIN providers prov ON it.provider_id = prov.id + WHERE p.id = ? AND p.available = 1 + LIMIT 1 + `, + ) + .bind(specId) + .first(); + + if (!spec) { + return `🚫 사양 #${specId}를 찾을 수 없습니다.`; + } + + // 2. 잔액 조회 (telegram-conversations DB) + let balance = 0; + const balanceRow = await db + .prepare("SELECT balance FROM user_deposits WHERE user_id = ?") + .bind(userId) + .first<{ balance: number }>(); + balance = balanceRow?.balance || 0; + + // 3. 기본값 설정 - region은 pricing에서 이미 결정됨 + const finalRegion = spec.region_code; + const finalLabel = label || `server-${Date.now()}`; + + logger.info("주문 준비", { + balance, + price: spec.monthly_price_krw, + region: finalRegion, + label: finalLabel, + }); + + // 4. 확인 페이지 생성 (인라인 버튼 포함) + if (balance >= spec.monthly_price_krw) { + // pending 주문 생성 (확인 버튼 클릭 시 실제 생성) + const insertResult = await db + .prepare( + ` + INSERT INTO server_orders (user_id, spec_id, region, price_paid) + VALUES (?, ?, ?, ?) + `, + ) + .bind(userId, specId, finalRegion, spec.monthly_price_krw) + .run(); + + if (!insertResult.success) { + logger.error("주문 생성 실패", new Error("DB insert failed"), { + userId: maskUserId(userId), + specId, + }); + return "🚫 주문 생성 중 오류가 발생했습니다."; + } + + const orderId = insertResult.meta?.last_row_id; + if (!orderId) { + logger.error("order_id 조회 실패", new Error("last_row_id is null")); + return "🚫 주문 ID를 가져올 수 없습니다."; + } + + logger.info("pending 주문 생성 완료", { + orderId, + userId: maskUserId(userId), + specId, + }); + + // 버튼 데이터를 특수 마커로 포함 + const keyboardData = JSON.stringify({ + type: "server_order", + order_id: orderId, + spec_id: specId, + price: spec.monthly_price_krw, + region: finalRegion, + }); + + const ramGB = (spec.memory_mb / 1024).toFixed(1); + return `__KEYBOARD__${keyboardData}__END__ +📋 서버 주문 확인 + +• 사양: ${spec.vcpu} vCPU / ${ramGB}GB RAM / ${spec.storage_gb}GB SSD +• 트래픽: ${spec.transfer_tb}TB/월 +• 가격: ${spec.monthly_price_krw.toLocaleString()}원/월 +• 현재 잔액: ${balance.toLocaleString()}원 ✅ + +다음 단계에서 OS와 패키지를 선택합니다.`; + } else { + const shortage = spec.monthly_price_krw - balance; + + const ramGB = (spec.memory_mb / 1024).toFixed(1); + return `📋 서버 주문 확인 + +• 사양: ${spec.vcpu} vCPU / ${ramGB}GB RAM / ${spec.storage_gb}GB SSD +• 트래픽: ${spec.transfer_tb}TB/월 +• 가격: ${spec.monthly_price_krw.toLocaleString()}원/월 +• 현재 잔액: ${balance.toLocaleString()}원 ⚠️ 부족 +• 부족 금액: ${shortage.toLocaleString()}원 + +💳 입금 계좌 +하나은행 427-910018-27104 (주식회사 아이언클래드) +입금 후 '홍길동 ${shortage}원 입금' 형식으로 알려주세요.`; + } +} + +// 내 서버 목록 +async function getMyServers( + userId: number, + db: D1Database, + cloudDb: D1Database, +): Promise { + logger.info("getMyServers 시작", { userId: maskUserId(userId) }); + + const { results } = await db + .prepare( + ` + SELECT + id as order_id, + label, + status, + ip_address, + region, + price_paid, + created_at, + provisioned_at, + spec_id + FROM server_orders + WHERE user_id = ? + ORDER BY created_at DESC + `, + ) + .bind(userId) + .all(); + + if (!results || results.length === 0) { + return '📋 주문한 서버가 없습니다.\n\n💡 "서버 추천" 또는 "서버 사양 목록"으로 시작하세요.'; + } + + let response = `📋 **내 서버 목록** (${results.length}개)\n\n`; + + // spec 정보를 배치로 조회 (N+1 문제 해결) + const specIds = [...new Set(results.map((r) => r.spec_id))]; + let specMap = new Map(); + + if (specIds.length > 0) { + const placeholders = specIds.map(() => "?").join(","); + const { results: specs } = await cloudDb + .prepare( + `SELECT id, instance_name FROM instance_types WHERE id IN (${placeholders})`, + ) + .bind(...specIds) + .all<{ id: number; instance_name: string }>(); + + if (specs && specs.length > 0) { + specMap = new Map(specs.map((s) => [s.id, s.instance_name])); + } + } + + // 주문 목록 표시 + for (const order of results) { + const specName = specMap.get(order.spec_id) || "알 수 없음"; + const statusIcon = + order.status === "active" + ? "✅" + : order.status === "pending" + ? "⏳" + : order.status === "provisioning" + ? "🔄" + : "❌"; + const statusLabel = + order.status === "active" + ? "운영중" + : order.status === "pending" + ? "대기" + : order.status === "provisioning" + ? "생성중" + : order.status === "terminated" + ? "종료" + : "오류"; + + response += `**#${order.order_id}** ${order.label} ${statusIcon}\n`; + response += ` • 사양: ${specName}\n`; + response += ` • 상태: ${statusLabel}\n`; + + if (order.ip_address) { + response += ` • IP: ${order.ip_address}\n`; + } + + response += ` • 리전: ${order.region}\n`; + response += ` • 가격: ${order.price_paid.toLocaleString()}원/월\n`; + response += ` • 생성일: ${new Date(order.created_at).toLocaleDateString("ko-KR")}\n\n`; + } + + response += `💡 상세 정보는 "서버 정보 #<주문ID>"로 조회하세요.`; + + return response; +} + +// 서버 상세 정보 +async function getServerInfo( + orderId: number, + userId: number, + db: D1Database, + cloudDb: D1Database, +): Promise { + logger.info("getServerInfo 시작", { orderId, userId: maskUserId(userId) }); + + const order = await db + .prepare( + ` + SELECT * + FROM server_orders + WHERE id = ? AND user_id = ? + `, + ) + .bind(orderId, userId) + .first(); + + if (!order) { + return `🚫 주문 #${orderId}를 찾을 수 없습니다. (권한 없음 또는 존재하지 않음)`; + } + + // spec 정보를 CLOUD_DB에서 조회 + const spec = await cloudDb + .prepare( + ` + SELECT + it.instance_name, + it.vcpu, + it.memory_mb, + it.storage_gb, + it.transfer_tb + FROM instance_types it + WHERE it.id = ? + `, + ) + .bind(order.spec_id) + .first<{ + instance_name: string; + vcpu: number; + memory_mb: number; + storage_gb: number; + transfer_tb: number; + }>(); + + if (!spec) { + return `🚫 사양 정보를 찾을 수 없습니다.`; + } + + const statusIcon = + order.status === "active" + ? "✅" + : order.status === "pending" + ? "⏳" + : order.status === "provisioning" + ? "🔄" + : "❌"; + const statusLabel = + order.status === "active" + ? "운영중" + : order.status === "pending" + ? "대기" + : order.status === "provisioning" + ? "생성중" + : order.status === "terminated" + ? "종료" + : "오류"; + + let response = `🖥️ **서버 상세 정보** #${orderId} ${statusIcon}\n\n`; + response += `**기본 정보**\n`; + response += `• 라벨: ${order.label}\n`; + response += `• 상태: ${statusLabel}\n`; + response += `• 사양: ${spec.instance_name}\n`; + response += `• vCPUs: ${spec.vcpu}\n`; + response += `• RAM: ${(spec.memory_mb / 1024).toFixed(1)}GB\n`; + response += `• 디스크: ${spec.storage_gb}GB SSD\n`; + response += `• 트래픽: ${spec.transfer_tb}TB/월\n\n`; + + response += `**네트워크**\n`; + response += `• IPv4: ${order.ip_address || "할당 대기중"}\n`; + if (order.ipv6_address) { + response += `• IPv6: ${order.ipv6_address}\n`; + } + response += `• 리전: ${order.region}\n\n`; + + response += `**접속 정보**\n`; + response += `• OS: ${order.image}\n`; + if (order.status === "active") { + response += `• Root 비밀번호: 최초 생성 시에만 표시됩니다.\n`; + response += `• 분실 시 서버 콘솔에서 재설정하세요.\n`; + response += `• SSH: ssh root@${order.ip_address}\n`; + } else if (order.status === "pending") { + response += `• 비밀번호는 서버 생성 후 제공됩니다.\n`; + } + response += "\n"; + + response += `**결제 정보**\n`; + response += `• 가격: ${order.price_paid.toLocaleString()}원/월 (선불)\n`; + response += `• 생성일: ${new Date(order.created_at).toLocaleDateString("ko-KR")}\n`; + if (order.provisioned_at) { + response += `• 프로비저닝 완료: ${new Date(order.provisioned_at).toLocaleDateString("ko-KR")}\n`; + } + if (order.terminated_at) { + response += `• 종료일: ${new Date(order.terminated_at).toLocaleDateString("ko-KR")}\n`; + } + + if (order.error_message) { + response += `\n⚠️ **오류**\n${order.error_message}`; + } + + if (order.status === "pending") { + response += "\n\n💡 주문이 대기 중입니다. 관리자가 확인 후 생성됩니다."; + } + + return response; +} + +// 주문 취소 (pending 상태만 가능) +async function cancelOrder( + orderId: number, + userId: number, + db: D1Database, +): Promise { + logger.info("cancelOrder 시작", { orderId, userId: maskUserId(userId) }); + + // 주문 조회 + const order = await db + .prepare( + "SELECT status, price_paid FROM server_orders WHERE id = ? AND user_id = ?", + ) + .bind(orderId, userId) + .first<{ status: string; price_paid: number }>(); + + if (!order) { + return `🚫 주문 #${orderId}를 찾을 수 없습니다.`; + } + + if (order.status !== "pending") { + return `🚫 이미 처리 중이거나 완료된 주문은 취소할 수 없습니다. (현재 상태: ${order.status})`; + } + + // 주문 취소 + const updateResult = await db + .prepare( + 'UPDATE server_orders SET status = ?, terminated_at = datetime("now") WHERE id = ?', + ) + .bind("cancelled", orderId) + .run(); + + if (!updateResult.success) { + logger.error("주문 취소 실패", new Error("DB update failed"), { + orderId, + userId: maskUserId(userId), + }); + return "🚫 주문 취소 중 오류가 발생했습니다."; + } + + logger.info("주문 취소 완료", { + orderId, + userId: maskUserId(userId), + price: order.price_paid, + }); + + return `✅ 주문 #${orderId} 취소 완료\n\n• 차감되지 않은 금액: ${order.price_paid.toLocaleString()}원`; +} diff --git a/src/types.ts b/src/types.ts index 8d65f46..2e3d579 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,5 +1,6 @@ export interface Env { DB: D1Database; + CLOUD_DB: D1Database; AI: Ai; BOT_TOKEN: string; WEBHOOK_SECRET: string; @@ -21,11 +22,20 @@ export interface Env { BRAVE_API_BASE?: string; WTTR_IN_URL?: string; HOSTING_SITE_URL?: string; + LINODE_API_KEY?: string; + VULTR_API_KEY?: string; + LINODE_API_BASE?: string; + VULTR_API_BASE?: string; + SERVER_ADMIN_ID?: string; + SERVER_RECOMMEND_API_URL?: string; RATE_LIMIT_KV: KVNamespace; + SESSION_KV: KVNamespace; + // Service Binding: Worker-to-Worker 호출 + SERVER_RECOMMEND?: Fetcher; } export interface IntentAnalysis { - action: 'chat' | 'n8n'; + action: "chat" | "n8n"; type?: string; confidence: number; } @@ -72,7 +82,7 @@ export interface TelegramChat { export interface BufferedMessage { id: number; - role: 'user' | 'bot'; + role: "user" | "bot"; message: string; created_at: string; } @@ -86,12 +96,95 @@ export interface Summary { } export interface ConversationContext { - previousSummary: Summary | null; // 최신 요약 (호환성 유지) - summaries: Summary[]; // 전체 요약 (최대 3개, 최신순) + previousSummary: Summary | null; // 최신 요약 (호환성 유지) + summaries: Summary[]; // 전체 요약 (최대 3개, 최신순) recentMessages: BufferedMessage[]; totalMessages: number; } +// Server Management - DB Entities +export interface CloudProvider { + id: number; + name: string; + display_name: string; + api_base_url: string; + enabled: number; +} + +export interface InstanceSpec { + id: number; + instance_name: string; + vcpu: number; + memory_mb: number; + storage_gb: number; + transfer_tb: number; + monthly_price_krw: number; + region_name: string; + provider_name: string; // internal use only (don't show to user) +} + +export interface ServerOrder { + id: number; + user_id: number; + spec_id: number; + status: string; + provider_instance_id: string | null; + label: string; + region: string; + image: string; + ip_address: string | null; + ipv6_address: string | null; + root_password: string | null; + price_paid: number; + billing_type: string; + error_message: string | null; + provisioned_at: string | null; + terminated_at: string | null; + created_at: string; +} + +export interface UserServer { + id: number; + user_id: number; + order_id: number; + provider_id: number; + provider_instance_id: string; + label: string; + status: string; + ip_address: string; + verified: number; + created_at: string; + updated_at: string; +} + +// App Requirements Calculator Types +export interface AppRequirementTier { + minUsers: number; + maxUsers?: number; + vcpus: number; + memoryMb: number; + storageGb: number; + tierName?: string; +} + +export interface AppRequirement { + appType: 'game' | 'web' | 'database' | 'container' | 'dev'; + appName: string; + appNameKo?: string; + keywords: string[]; + baseVcpus: number; + baseMemoryMb: number; + baseStorageGb: number; + memoryPerUserMb: number; + vcpuPerUsers: number; + maxUsersPerInstance?: number; + scalingNote?: string; + tiers?: AppRequirementTier[]; + sourceUrl?: string; + confidenceLevel: 'low' | 'medium' | 'high' | 'official'; + fetchedAt?: string; +} + // Cloudflare Email Workers 타입 export interface EmailMessage { from: string; @@ -150,14 +243,31 @@ export interface NamecheapDomainListItem { // Function Calling 인자 타입 export interface ManageDomainArgs { - action: 'register' | 'check' | 'whois' | 'list' | 'info' | 'get_ns' | 'set_ns' | 'price' | 'cheapest'; + action: + | "register" + | "check" + | "whois" + | "list" + | "info" + | "get_ns" + | "set_ns" + | "price" + | "cheapest"; domain?: string; nameservers?: string[]; tld?: string; } export interface ManageDepositArgs { - action: 'balance' | 'account' | 'request' | 'history' | 'cancel' | 'pending' | 'confirm' | 'reject'; + action: + | "balance" + | "account" + | "request" + | "history" + | "cancel" + | "pending" + | "confirm" + | "reject"; depositor_name?: string; amount?: number; transaction_id?: number; @@ -168,6 +278,27 @@ export interface SuggestDomainsArgs { keywords: string; } +export interface ManageServerArgs { + action: + | "recommend" + | "list_specs" + | "order" + | "my_servers" + | "server_info" + | "cancel_order"; + purpose?: string; + budget?: number; + spec_id?: number; + order_id?: number; + region?: string; + label?: string; + provider?: "linode" | "vultr"; + expected_users?: number; // 예상 동시 사용자 수 + daily_traffic?: number; // 일일 예상 트래픽 (요청 수) + storage_needs_gb?: number; // 필요한 스토리지 (GB) + tech_stack?: string; // 기술 스택 (nodejs, python, java, php 등) +} + export interface SearchWebArgs { query: string; } @@ -282,6 +413,48 @@ export interface BraveSearchResponse { }; } +// Linode API Types +export interface LinodeInstance { + id: number; + label: string; + status: string; + ipv4: string[]; + ipv6: string; + region: string; + type: string; + created: string; +} + +export interface LinodeCreateRequest { + type: string; + region: string; + image: string; + root_pass: string; + label?: string; + authorized_keys?: string[]; +} + +// Vultr API Types +export interface VultrInstance { + id: string; + label: string; + status: string; + main_ip: string; + v6_main_ip: string; + region: string; + plan: string; + date_created: string; + default_password: string; +} + +export interface VultrCreateRequest { + plan: string; + region: string; + os_id: number; + label?: string; + hostname?: string; +} + // OpenAI API 응답 타입 export interface OpenAIMessage { role: string; @@ -299,11 +472,13 @@ export interface OpenAIResponse { // Context7 API 응답 타입 export interface Context7Library { id: string; - name: string; + title?: string; + name?: string; } export interface Context7SearchResponse { libraries?: Context7Library[]; + results?: Context7Library[]; // API가 results로 반환 } export interface Context7DocsResponse { @@ -315,21 +490,39 @@ export interface Context7DocsResponse { // Telegram Inline Keyboard 데이터 export interface DomainRegisterKeyboardData { - type: 'domain_register'; + type: "domain_register"; domain: string; price: number; } -export type KeyboardData = DomainRegisterKeyboardData; +export interface ServerOrderKeyboardData { + type: "server_order"; + order_id: number; + spec_id: number; + price: number; + region: string; +} + +export interface ServerRecommendKeyboardData { + type: "server_recommend"; + specs: Array<{ + num: number; + plan: string; // 플랜 ID (예: vc2-1c-0.5gb-v6) + region: string; // 리전 코드 (예: nrt, icn) + provider: string; // 제공자 (linode, vultr) + }>; +} + +export type KeyboardData = DomainRegisterKeyboardData | ServerOrderKeyboardData | ServerRecommendKeyboardData; // Workers AI Types (from worker-configuration.d.ts) export type WorkersAIModel = - | '@cf/meta/llama-3.1-8b-instruct' - | '@cf/meta/llama-3.2-3b-instruct' - | '@cf/meta/llama-3-8b-instruct'; + | "@cf/meta/llama-3.1-8b-instruct" + | "@cf/meta/llama-3.2-3b-instruct" + | "@cf/meta/llama-3-8b-instruct"; export interface WorkersAIMessage { - role: 'system' | 'user' | 'assistant'; + role: "system" | "user" | "assistant"; content: string; } diff --git a/src/utils/session.ts b/src/utils/session.ts new file mode 100644 index 0000000..3800075 --- /dev/null +++ b/src/utils/session.ts @@ -0,0 +1,208 @@ +/** + * KV 기반 세션 관리 유틸리티 + * - 다단계 플로우 (서버 주문, 도메인 등록 등)의 임시 데이터 저장 + * - TTL 24시간 자동 만료 + */ + +export type SessionType = 'server_order' | 'domain_register'; + +export interface SessionData { + type: SessionType; + step: string; + data: T; + userId: number; + createdAt: number; + updatedAt: number; +} + +// 서버 주문 세션 데이터 +export interface ServerOrderSessionData { + // 추천 목록 (선택 전까지 임시 저장) + recommendations?: Array<{ + plan: string; + region: string; + provider: string; + }>; + + // 추천 정보 + purpose?: string; + budget?: number; + expectedUsers?: number; + + // 선택된 사양 + plan?: string; + provider?: string; + region?: string; + + // OS 선택 + image?: string; + + // 가격 (캐시) + priceKrw?: number; + + // 주문 ID (최종 확인 단계) + orderId?: number; +} + +// 서버 주문 단계 정의 (step 필드 타입 가이드) +export type ServerOrderStep = 'recommend' | 'spec_confirm' | 'os_select' | 'final_confirm'; + +const SESSION_TTL_SECONDS = 24 * 60 * 60; // 24시간 + +/** + * 세션 ID 생성 + * format: {type_prefix}_{userId}_{random} + */ +function generateSessionId(type: SessionType, userId: number): string { + const prefix = type === 'server_order' ? 'srv' : 'dom'; + const random = crypto.randomUUID().slice(0, 8); + return `${prefix}_${userId}_${random}`; +} + +/** + * 세션 생성 + */ +export async function createSession( + kv: KVNamespace, + userId: number, + type: SessionType, + initialData: T, + step: string = 'init' +): Promise { + const sessionId = generateSessionId(type, userId); + const now = Date.now(); + + const session: SessionData = { + type, + step, + data: initialData, + userId, + createdAt: now, + updatedAt: now, + }; + + await kv.put( + `session:${sessionId}`, + JSON.stringify(session), + { expirationTtl: SESSION_TTL_SECONDS } + ); + + // 사용자의 활성 세션 참조 저장 (같은 타입의 이전 세션 덮어쓰기) + await kv.put( + `user_session:${userId}:${type}`, + sessionId, + { expirationTtl: SESSION_TTL_SECONDS } + ); + + return sessionId; +} + +/** + * 세션 조회 + */ +export async function getSession( + kv: KVNamespace, + sessionId: string +): Promise | null> { + const raw = await kv.get(`session:${sessionId}`); + if (!raw) return null; + + try { + return JSON.parse(raw) as SessionData; + } catch { + return null; + } +} + +/** + * 세션 조회 + 권한 검증 + */ +export async function getSessionForUser( + kv: KVNamespace, + sessionId: string, + userId: number +): Promise | null> { + const session = await getSession(kv, sessionId); + + if (!session) return null; + if (session.userId !== userId) return null; + + return session; +} + +/** + * 사용자의 활성 세션 조회 + */ +export async function getUserActiveSession( + kv: KVNamespace, + userId: number, + type: SessionType +): Promise<{ sessionId: string; session: SessionData } | null> { + const sessionId = await kv.get(`user_session:${userId}:${type}`); + if (!sessionId) return null; + + const session = await getSession(kv, sessionId); + if (!session) { + // 참조는 있지만 세션이 만료됨 - 참조 정리 + await kv.delete(`user_session:${userId}:${type}`); + return null; + } + + return { sessionId, session }; +} + +/** + * 세션 업데이트 + */ +export async function updateSession( + kv: KVNamespace, + sessionId: string, + updates: Partial & { step?: string } +): Promise | null> { + const session = await getSession(kv, sessionId); + if (!session) return null; + + const { step, ...dataUpdates } = updates; + + const updated: SessionData = { + ...session, + step: step ?? session.step, + data: { ...session.data, ...dataUpdates } as T, + updatedAt: Date.now(), + }; + + await kv.put( + `session:${sessionId}`, + JSON.stringify(updated), + { expirationTtl: SESSION_TTL_SECONDS } + ); + + return updated; +} + +/** + * 세션 삭제 + */ +export async function deleteSession( + kv: KVNamespace, + sessionId: string +): Promise { + const session = await getSession(kv, sessionId); + + await kv.delete(`session:${sessionId}`); + + // 사용자 참조도 삭제 + if (session) { + await kv.delete(`user_session:${session.userId}:${session.type}`); + } +} + +/** + * 세션 만료 여부 확인 (UI용 메시지) + */ +export function isSessionExpired(session: SessionData | null): boolean { + if (!session) return true; + + const elapsed = Date.now() - session.createdAt; + return elapsed > SESSION_TTL_SECONDS * 1000; +} diff --git a/wrangler.toml b/wrangler.toml index e3db9b8..cba6a5b 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -6,7 +6,7 @@ compatibility_date = "2024-01-01" binding = "AI" [vars] -ENVIRONMENT = "production" # 환경 설정 (production | development) +ENVIRONMENT = "development" # 로컬: development, 배포 시 secrets로 production 설정 SUMMARY_THRESHOLD = "20" # 프로필 업데이트 주기 (메시지 수) MAX_SUMMARIES_PER_USER = "3" # 유지할 프로필 버전 수 (슬라이딩 윈도우) N8N_WEBHOOK_URL = "https://n8n.anvil.it.com" # n8n 연동 (선택) @@ -21,14 +21,36 @@ BRAVE_API_BASE = "https://api.search.brave.com/res/v1" WTTR_IN_URL = "https://wttr.in" HOSTING_SITE_URL = "https://hosting.anvil.it.com" +# VPS Provider API Endpoints +LINODE_API_BASE = "https://api.linode.com/v4" +VULTR_API_BASE = "https://api.vultr.com/v2" +DEFAULT_SERVER_REGION = "ap-northeast" # 오사카 (Linode: ap-northeast, Vultr: nrt) +SERVER_RECOMMEND_API_URL = "https://server-recommend.kappa-d8e.workers.dev/api/recommend" # 외부 AI 추천 API (선택) + [[d1_databases]] binding = "DB" database_name = "telegram-conversations" database_id = "c285bb5b-888b-405d-b36f-475ae5aed20e" +[[d1_databases]] +binding = "CLOUD_DB" +database_name = "cloud-instances-db" +database_id = "bbcb472d-b25e-4e48-b6ea-112f9fffb4a8" + [[kv_namespaces]] binding = "RATE_LIMIT_KV" id = "15bcdcbde94046fe936c89b2e7d85b64" +preview_id = "0d3af750739e40d4a0324889564d74a7" + +[[kv_namespaces]] +binding = "SESSION_KV" +id = "24ee962396cc4e9ab1fb47ceacf62c7d" +preview_id = "302ad556567447cbac49c20bded4eb7e" + +# Service Binding: Worker-to-Worker 호출용 (Cloudflare Error 1042 방지) +[[services]] +binding = "SERVER_RECOMMEND" +service = "server-recommend" # Email Worker 설정 (SMS → 메일 수신) # Cloudflare Dashboard에서 Email Routing 설정 필요: @@ -49,3 +71,6 @@ crons = ["0 15 * * *"] # UTC 15:00 = KST 00:00 # - DEPOSIT_API_SECRET: Deposit API 인증 키 (namecheap-api 연동) # - DOMAIN_OWNER_ID: 도메인 관리 권한 Telegram ID (보안상 secrets 권장) # - DEPOSIT_ADMIN_ID: 예치금 관리 권한 Telegram ID (보안상 secrets 권장) +# - LINODE_API_KEY: Linode Personal Access Token +# - VULTR_API_KEY: Vultr API Key +# - SERVER_ADMIN_ID: 서버 관리 알림 수신자 Telegram ID