import type { Env, ProvisionResponse, ProvisionOrder, OSImage, } from '../types'; import { retryWithBackoff, RetryError } from '../utils/retry'; import { createLogger, maskUserId } from '../utils/logger'; import { ERROR_MESSAGES } from '../constants/messages'; const logger = createLogger('server-tool'); const provisionLogger = createLogger('provision'); // Generate idempotency key for order requests // Format: tg-order-{userId}-{timestamp}-{random} function generateIdempotencyKey(userId: string): string { const timestamp = Date.now(); const random = Math.random().toString(36).substring(2, 10); return `tg-order-${userId}-${timestamp}-${random}`; } // 진행 중인 주문 확인 (중복 주문 방지) async function checkExistingOrder( db: D1Database, telegramUserId: string ): Promise<{ id: number; status: string; label: string | null } | null> { const result = await db.prepare( `SELECT id, status, label FROM server_orders WHERE telegram_user_id = ? AND status IN ('pending', 'provisioning') LIMIT 1` ).bind(telegramUserId).first<{ id: number; status: string; label: string | null }>(); return result || null; } export const manageServerTool = { type: 'function', function: { name: 'manage_server', description: '클라우드 서버 관리. 반드시 사용: 서버 시작(action="start", order_id), 서버 중지(action="stop", order_id), 서버 재시작(action="reboot", order_id), 서버 삭제/해지(action="delete", order_id - 만료일과 무관하게 즉시 삭제 가능), 내 서버 목록(action="list"), 서버 이름 변경(action="rename"). "N번 시작/중지/재시작/삭제/해지/취소", "#N 시작/재시작" 패턴 감지 시 반드시 호출.', parameters: { type: 'object', properties: { action: { type: 'string', enum: ['order', 'list', 'info', 'delete', 'images', 'start', 'stop', 'reboot', 'rename'], description: 'start: 서버 시작, stop: 서버 중지, reboot: 서버 재시작, delete: 서버 삭제, list: 내 서버 목록, info: 서버 상세, order: 서버 주문, rename: 이름 변경, images: OS 이미지 목록', }, server_id: { type: 'string', description: '서버 ID (레거시, 사용 안 함)', }, region_code: { type: 'string', description: '리전 코드 (레거시, 사용 안 함)', }, label: { type: 'string', description: '서버 라벨 (예: "myapp-prod"). order action에서 필수', }, pricing_id: { type: 'number', description: 'Pricing ID (서버 스펙 ID). order action에서 필수', }, order_id: { type: 'number', description: '주문 번호. info, delete, rename, start, stop, reboot action에서 필수', }, new_label: { type: 'string', description: '새 서버 이름. rename action에서 필수', }, image: { type: 'string', description: 'OS 이미지 키 (예: "ubuntu_22_04"). order action에서 선택 (기본값 사용 가능)', }, }, required: ['action'], }, }, }; // Cloud Orchestrator Provision API 호출 async function callProvisionAPI( endpoint: string, method: 'GET' | 'POST' | 'DELETE', env: Env, body?: Record, userId?: string ): Promise { provisionLogger.info('API 호출 시작', { endpoint, method, userId: maskUserId(userId), useServiceBinding: !!env?.CLOUD_ORCHESTRATOR }); try { const response = await retryWithBackoff( () => { const headers: Record = { 'Content-Type': 'application/json', }; // Add API Key if available if (env?.PROVISION_API_KEY) { headers['X-API-Key'] = env.PROVISION_API_KEY; } const requestInit = { method, headers, body: body ? JSON.stringify(body) : undefined, }; // Add userId as query param for GET/DELETE/POST let fullEndpoint = endpoint; if (userId && !endpoint.includes('?')) { fullEndpoint = `${endpoint}?user_id=${userId}`; } else if (userId && endpoint.includes('?')) { fullEndpoint = `${endpoint}&user_id=${userId}`; } // Service Binding 우선, fallback: HTTP if (env?.CLOUD_ORCHESTRATOR) { provisionLogger.info('Service Binding 사용', { endpoint: fullEndpoint }); return env.CLOUD_ORCHESTRATOR.fetch(`https://internal${fullEndpoint}`, requestInit); } else { const apiUrl = env?.CLOUD_ORCHESTRATOR_URL || 'https://cloud-orchestrator.kappa-d8e.workers.dev'; const url = `${apiUrl}${fullEndpoint}`; provisionLogger.info('HTTP 요청 사용 (fallback)', { url }); return fetch(url, requestInit); } }, { maxRetries: 3, serviceName: 'cloud-orchestrator-provision' } ); if (!response.ok) { const errorText = await response.text(); provisionLogger.error('API 호출 실패', new Error(errorText), { endpoint, status: response.status, }); // JSON 응답에서 오류 메시지 추출 let errorMessage = `HTTP ${response.status}`; try { const errorJson = JSON.parse(errorText); if (errorJson.error) { errorMessage = errorJson.error; } } catch { // JSON 파싱 실패 시 텍스트 그대로 사용 if (errorText && errorText.length < 200) { errorMessage = errorText; } } return { success: false, error: `프로비저닝 API 호출 실패: ${errorMessage}`, }; } const data = await response.json() as ProvisionResponse; provisionLogger.info('API 호출 성공', { endpoint, success: data.success }); return data; } catch (error) { provisionLogger.error('API 호출 에러', error as Error, { endpoint }); if (error instanceof RetryError) { return { success: false, error: ERROR_MESSAGES.SERVER_SERVICE_UNAVAILABLE, }; } return { success: false, error: '프로비저닝 API 호출 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.', }; } } // 서버 상태 이모지 function getStatusEmoji(status: string): string { switch (status) { case 'active': return '🟢'; case 'provisioning': return '🔄'; case 'stopped': return '⛔'; case 'deleted': case 'terminated': return '🗑️'; case 'failed': return '❌'; case 'pending': return '⏳'; // 내부용, UI에 표시 안 함 default: return '⚪'; } } // 서버 상태 텍스트 function getStatusText(status: string): string { switch (status) { case 'active': return '가동 중'; case 'provisioning': return '생성 중...'; case 'stopped': return '중지됨'; case 'deleted': case 'terminated': return '삭제됨'; case 'failed': return '실패'; default: return '알 수 없음'; } } // 날짜 포맷팅 (YYYY-MM-DD) function formatDate(dateString: string): string { try { const date = new Date(dateString); return date.toISOString().split('T')[0]; } catch { return dateString; } } // 남은 시간 계산 (만료일 기준) function calculateRemainingTime(expiresAt: string): string { try { const expiresTime = new Date(expiresAt).getTime(); const now = Date.now(); const remainingMs = expiresTime - now; if (remainingMs <= 0) { return '만료됨'; } const remainingHours = Math.floor(remainingMs / (1000 * 60 * 60)); const remainingDays = Math.floor(remainingHours / 24); const hours = remainingHours % 24; if (remainingDays > 0) { return `${remainingDays}일 ${hours}시간`; } else { return `${hours}시간`; } } catch { return '알 수 없음'; } } // 만료일 포맷팅 (YYYY-MM-DD HH:MM) function formatExpiry(expiresAt: string): string { try { const date = new Date(expiresAt); const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, '0'); const day = String(date.getDate()).padStart(2, '0'); const hours = String(date.getHours()).padStart(2, '0'); const minutes = String(date.getMinutes()).padStart(2, '0'); return `${year}-${month}-${day} ${hours}:${minutes}`; } catch { return expiresAt; } } // 서버 목록 포맷팅 function formatServerList(orders: ProvisionOrder[]): string { // 'pending' 상태는 내부용 (Queue 대기), UI에 표시하지 않음 // 'provisioning' 또는 'active' 상태만 표시 // 'terminated', 'deleted', 'failed' 상태는 제외 const activeOrders = orders?.filter(order => { // pending은 표시하지 않음 (내부 Queue 상태) if (order.status === 'pending') { return false; } // provisioning 또는 active만 표시 if (!['provisioning', 'active'].includes(order.status)) { return false; } // active 상태인데 provider_instance_id가 없으면 제외 (프로비저닝 실패) if (order.status === 'active' && !order.provider_instance_id) { return false; } return true; }) || []; if (activeOrders.length === 0) { return '🖥️ 등록된 서버가 없습니다.\n\n서버를 추천받으려면 "서버 추천"이라고 말씀해주세요.'; } let response = '__DIRECT__\n🖥️ 내 서버 목록\n\n'; activeOrders.forEach((order) => { const statusEmoji = getStatusEmoji(order.status); const statusText = getStatusText(order.status); const label = order.label || '(라벨 없음)'; response += `#${order.id} ${statusEmoji} ${label}\n`; if (order.ip_address) { response += ` • IP: ${order.ip_address}\n`; } response += ` • 상태: ${statusText}\n`; response += ` • 생성일: ${formatDate(order.created_at)}\n`; // 만료일 표시 (있을 경우) if (order.expires_at) { const remainingTime = calculateRemainingTime(order.expires_at); response += ` • 만료일: ${formatExpiry(order.expires_at)}\n`; response += ` • 남은 시간: ${remainingTime}\n`; } response += '\n'; }); response += '💡 서버 관리: "N번 시작/중지" 또는 "#N 재시작"'; return response.trim(); } // 서버 상세 정보 포맷팅 function formatServerInfo(order: ProvisionOrder): string { const statusEmoji = getStatusEmoji(order.status); const statusText = getStatusText(order.status); let response = '__DIRECT__\n🖥️ 서버 상세 정보\n\n'; response += '📋 기본 정보\n'; response += `• 주문번호: #${order.id}\n`; response += `• 라벨: ${order.label}\n`; response += `• 상태: ${statusEmoji} ${statusText}\n\n`; if (order.status === 'active' && order.ip_address) { response += '🔗 접속 정보\n'; response += `• IP: ${order.ip_address}\n`; if (order.image) { response += `• OS: ${order.image.replace(/_/g, ' ')}\n`; } if (order.root_password) { response += `• Password: ${order.root_password}\n\n`; response += '⚠️ 비밀번호는 안전한 곳에 보관하세요.\n'; } } else if (order.status === 'provisioning') { response += '⏳ 서버 생성 중입니다.\n잠시 후 다시 조회해주세요.\n'; } return response.trim(); } // OS 이미지 목록 포맷팅 function formatImageList(images: OSImage[]): string { if (!images || images.length === 0) { return '📀 사용 가능한 OS 이미지가 없습니다.'; } let response = '__DIRECT__\n📀 사용 가능한 OS 이미지\n\n'; images.forEach((image) => { // is_default can be boolean or number (1/0) const isDefault = image.is_default === true || image.is_default === 1; const marker = isDefault ? '✓ ' : ' '; const defaultText = isDefault ? ' (기본)' : ''; response += `${marker}${image.name}${defaultText}\n`; }); return response.trim(); } // 입금 계좌 정보 const DEPOSIT_ACCOUNT_INFO = `💳 입금 계좌 하나은행 427-910018-27104 예금주: (주)아이언클래드 입금 후 "입금 신고"로 알려주세요.`; // 서버 작업 직접 실행 export async function executeServerAction( action: string, args: { server_id?: string; region_code?: string; label?: string; pricing_id?: number; order_id?: number; new_label?: string; image?: string; }, env?: Env, telegramUserId?: string ): Promise { logger.info('작업 시작', { action, userId: maskUserId(telegramUserId), args: JSON.stringify(args).slice(0, 200), }); switch (action) { case 'order': { const { pricing_id, label, image } = args; if (!telegramUserId) { return '🚫 사용자 인증이 필요합니다.'; } if (!pricing_id || !label) { return '🚫 서버 주문에는 pricing_id와 label이 필요합니다.'; } if (!env) { return '🚫 환경 설정이 필요합니다.'; } // 중복 주문 방지: 진행 중인 주문 확인 if (env.DB) { const existingOrder = await checkExistingOrder(env.DB, telegramUserId); if (existingOrder) { const statusText = existingOrder.status === 'pending' ? '대기 중' : '프로비저닝 중'; return `⚠️ 이미 진행 중인 주문이 있습니다.\n\n` + `• 주문 번호: #${existingOrder.id}\n` + `• 라벨: ${existingOrder.label || '없음'}\n` + `• 상태: ${statusText}\n\n` + `완료 후 다시 시도해주세요.`; } } // Check balance first const balanceResult = await callProvisionAPI( '/api/provision/balance', 'GET', env, undefined, telegramUserId ); if (balanceResult.error || balanceResult.balance_krw === undefined) { return `🚫 잔액 조회 실패: ${balanceResult.error || '알 수 없는 오류'}`; } // Get pricing info to check if balance is sufficient // For now, we'll proceed with the order and let the API handle balance validation // Generate idempotency key to prevent duplicate orders on network retries const idempotencyKey = generateIdempotencyKey(telegramUserId); const orderBody: Record = { user_id: telegramUserId, pricing_id, label, dry_run: false, idempotency_key: idempotencyKey, }; if (image) { orderBody.image = image; } const result = await callProvisionAPI( '/api/provision', 'POST', env, orderBody, telegramUserId ); if (result.error || !result.order) { // Check if it's a balance error (error_code first, then fallback to text matching) if (result.error_code === 'INSUFFICIENT_BALANCE' || (result.error && result.error.includes('balance'))) { return `⚠️ 잔액이 부족합니다\n\n• 현재 잔액: ₩${balanceResult.balance_krw.toLocaleString()}\n\n${DEPOSIT_ACCOUNT_INFO}`; } return `🚫 서버 주문 실패: ${result.error || '알 수 없는 오류'}`; } const order = result.order; const statusEmoji = getStatusEmoji(order.status); const response = `__DIRECT__\n✅ 서버 주문이 접수되었습니다!\n\n📋 주문 정보\n• 주문번호: #${order.id}\n• 서버: ${order.label}\n• 가격: ₩${order.price_paid.toLocaleString()}\n• 상태: ${statusEmoji} ${getStatusText(order.status)}\n\n⏳ 서버 생성까지 2-5분 소요됩니다.\n완료되면 알림을 보내드립니다.`; return response; } case 'start': { const { order_id } = args; if (!order_id) { return '🚫 서버 시작에는 order_id가 필요합니다.'; } if (!telegramUserId) { return '🚫 사용자 인증이 필요합니다.'; } if (!env) { return '🚫 환경 설정이 필요합니다.'; } // Call the provision API to start the server const result = await callProvisionAPI( `/api/provision/orders/${order_id}/start`, 'POST', env, undefined, telegramUserId ); if (result.error) { return `🚫 서버 시작 실패: ${result.error}`; } logger.info('서버 시작 요청', { userId: maskUserId(telegramUserId), orderId: order_id }); return `__DIRECT__\n✅ 서버 시작 요청이 완료되었습니다.\n\n• 주문번호: #${order_id}\n• 상태: 시작 중...\n\n⏳ 서버가 시작되기까지 1-2분 소요될 수 있습니다.`; } case 'stop': { const { order_id } = args; if (!order_id) { return '🚫 서버 중지에는 order_id가 필요합니다.'; } if (!telegramUserId) { return '🚫 사용자 인증이 필요합니다.'; } if (!env) { return '🚫 환경 설정이 필요합니다.'; } // Call the provision API to stop the server const result = await callProvisionAPI( `/api/provision/orders/${order_id}/stop`, 'POST', env, undefined, telegramUserId ); if (result.error) { return `🚫 서버 중지 실패: ${result.error}`; } logger.info('서버 중지 요청', { userId: maskUserId(telegramUserId), orderId: order_id }); return `__DIRECT__\n✅ 서버 중지 요청이 완료되었습니다.\n\n• 주문번호: #${order_id}\n• 상태: 중지 중...\n\n⏳ 서버가 중지되기까지 1-2분 소요될 수 있습니다.`; } case 'reboot': { const { order_id } = args; if (!order_id) { return '🚫 서버 재시작에는 order_id가 필요합니다.'; } if (!telegramUserId) { return '🚫 사용자 인증이 필요합니다.'; } if (!env) { return '🚫 환경 설정이 필요합니다.'; } // Call the provision API to reboot the server const result = await callProvisionAPI( `/api/provision/orders/${order_id}/reboot`, 'POST', env, undefined, telegramUserId ); if (result.error) { return `🚫 서버 재시작 실패: ${result.error}`; } logger.info('서버 재시작 요청', { userId: maskUserId(telegramUserId), orderId: order_id }); return `__DIRECT__\n✅ 서버 재시작 요청이 완료되었습니다.\n\n• 주문번호: #${order_id}\n• 상태: 재시작 중...\n\n⏳ 서버가 재시작되기까지 2-3분 소요될 수 있습니다.`; } case 'list': { if (!telegramUserId) { return '🚫 사용자 인증이 필요합니다.'; } if (!env) { return '🚫 환경 설정이 필요합니다.'; } const result = await callProvisionAPI( '/api/provision/orders', 'GET', env, undefined, telegramUserId ); // API returns orders directly without success field if (result.error) { return `🚫 서버 목록 조회 실패: ${result.error}`; } if (!result.orders) { return `🚫 서버 목록 조회 실패: 응답 형식 오류`; } return formatServerList(result.orders); } case 'info': { const { order_id } = args; if (!telegramUserId) { return '🚫 사용자 인증이 필요합니다.'; } if (!order_id) { return '🚫 서버 상세 조회에는 order_id가 필요합니다.'; } if (!env) { return '🚫 환경 설정이 필요합니다.'; } const result = await callProvisionAPI( `/api/provision/orders/${order_id}`, 'GET', env, undefined, telegramUserId ); if (result.error) { return `🚫 서버 정보 조회 실패: ${result.error}`; } if (!result.order) { return `🚫 서버 정보 조회 실패: 해당 서버를 찾을 수 없습니다`; } return formatServerInfo(result.order); } case 'delete': { const { order_id } = args; if (!telegramUserId) { return '🚫 사용자 인증이 필요합니다.'; } if (!order_id) { return '🚫 서버 해지에는 order_id가 필요합니다.'; } if (!env || !env.SESSION_KV) { return '🚫 환경 설정이 필요합니다.'; } // First get the order info const infoResult = await callProvisionAPI( `/api/provision/orders/${order_id}`, 'GET', env, undefined, telegramUserId ); if (infoResult.error || !infoResult.order) { return `🚫 서버 정보 조회 실패: ${infoResult.error || '해당 서버를 찾을 수 없습니다'}`; } const order = infoResult.order; // Store pending deletion in SESSION_KV (5 minutes TTL) const deleteSessionKey = `delete_confirm:${telegramUserId}`; const deleteSessionData = JSON.stringify({ orderId: order.id, label: order.label, timestamp: Date.now(), }); await env.SESSION_KV.put(deleteSessionKey, deleteSessionData, { expirationTtl: 300, // 5 minutes }); logger.info('삭제 대기 세션 생성', { userId: telegramUserId, orderId: order.id }); // Return confirmation message (no keyboard) let response = '__DIRECT__\n⚠️ 서버 삭제 확인\n\n'; response += '📋 삭제할 서버 정보\n'; response += `• 주문번호: #${order.id}\n`; response += `• 라벨: ${order.label}\n`; if (order.ip_address) { response += `• IP: ${order.ip_address}\n`; } response += '\n'; response += '🚨 경고: 이 작업은 되돌릴 수 없습니다!\n'; response += '• 서버의 모든 데이터가 삭제됩니다\n'; response += '• 백업을 먼저 진행해주세요\n\n'; response += '정말 삭제하려면 "삭제"라고 입력하세요.\n'; response += '취소하려면 다른 메시지를 입력하거나 5분간 기다리세요.'; return response; } case 'images': { if (!env) { return '🚫 환경 설정이 필요합니다.'; } const result = await callProvisionAPI( '/api/provision/images', 'GET', env ); if (result.error) { return `🚫 이미지 목록 조회 실패: ${result.error}`; } if (!result.images) { return `🚫 이미지 목록 조회 실패: 응답 형식 오류`; } return formatImageList(result.images); } case 'rename': { const { order_id, new_label } = args; if (!telegramUserId) { return '🚫 사용자 인증이 필요합니다.'; } if (!order_id) { return '🚫 이름 변경할 서버 번호를 알려주세요. (예: "서버 #1 이름을 my-server로 변경")'; } if (!new_label) { return '🚫 새 서버 이름을 알려주세요. (예: "서버 #1 이름을 my-server로 변경")'; } if (!env || !env.DB) { return '🚫 환경 설정이 필요합니다.'; } // 서버 소유권 확인 및 이름 변경 const server = await env.DB.prepare( `SELECT id, label FROM server_orders WHERE id = ? AND telegram_user_id = ?` ).bind(order_id, telegramUserId).first<{ id: number; label: string | null }>(); if (!server) { return '🚫 해당 서버를 찾을 수 없거나 권한이 없습니다.'; } const oldLabel = server.label || `서버 #${server.id}`; await env.DB.prepare( `UPDATE server_orders SET label = ?, updated_at = datetime('now') WHERE id = ?` ).bind(new_label, order_id).run(); logger.info('서버 이름 변경', { orderId: order_id, oldLabel, newLabel: new_label, userId: maskUserId(telegramUserId) }); return `✅ 서버 이름이 변경되었습니다. • 이전: ${oldLabel} • 변경: ${new_label}`; } default: return `🚫 알 수 없는 작업: ${action}`; } } // 서버 삭제 시 환불액 계산 (시간당 요금제) function calculateRefund(pricePaid: number, createdAt: string): { refundAmount: number; usedHours: number; hourlyRate: number } { const HOURS_PER_MONTH = 720; // 30일 * 24시간 const hourlyRate = pricePaid / HOURS_PER_MONTH; const createdTime = new Date(createdAt).getTime(); const now = Date.now(); const usedMs = now - createdTime; const usedHours = Math.ceil(usedMs / (1000 * 60 * 60)); // 올림 처리 const usedAmount = Math.ceil(hourlyRate * usedHours); const refundAmount = Math.max(0, pricePaid - usedAmount); return { refundAmount, usedHours, hourlyRate }; } // 실제 서버 삭제 실행 (callback_query에서 호출) export async function executeServerDelete( orderId: number, telegramUserId: string, env: Env ): Promise<{ success: boolean; message: string }> { provisionLogger.info('서버 삭제 실행', { orderId, userId: maskUserId(telegramUserId), }); // Get order info for refund calculation const infoResult = await callProvisionAPI( `/api/provision/orders/${orderId}`, 'GET', env, undefined, telegramUserId ); if (!infoResult.order) { return { success: false, message: `🚫 서버 정보 조회 실패: ${infoResult.error || '해당 서버를 찾을 수 없습니다'}`, }; } const order = infoResult.order; const orderLabel = order.label || `#${orderId}`; const pricePaid = order.price_paid || 0; // Delete the order first const result = await callProvisionAPI( `/api/provision/orders/${orderId}`, 'DELETE', env, undefined, telegramUserId ); if (result.error) { provisionLogger.error('서버 삭제 실패', new Error(result.error), { orderId }); return { success: false, message: `🚫 서버 종료 실패: ${result.error}`, }; } // Update terminated_at in local DB try { await env.DB.prepare(` UPDATE server_orders SET terminated_at = datetime('now'), status = 'terminated', updated_at = datetime('now') WHERE id = ? `).bind(orderId).run(); } catch (dbError) { provisionLogger.error('terminated_at 업데이트 실패 (무시)', dbError as Error, { orderId }); } // Calculate refund let refundMessage = ''; if (pricePaid > 0 && order.created_at) { const { refundAmount, usedHours } = calculateRefund(pricePaid, order.created_at); if (refundAmount > 0) { try { // Get user from DB const userResult = await env.DB.prepare( 'SELECT id FROM users WHERE telegram_id = ?' ).bind(telegramUserId).first<{ id: number }>(); if (userResult) { // Get balance before refund const balanceBefore = await env.DB.prepare( 'SELECT balance FROM user_deposits WHERE user_id = ?' ).bind(userResult.id).first<{ balance: number }>(); const beforeBalance = balanceBefore?.balance ?? 0; // Add refund to user_deposits (with version increment for optimistic locking) await env.DB.prepare(` UPDATE user_deposits SET balance = balance + ?, version = version + 1 WHERE user_id = ? `).bind(refundAmount, userResult.id).run(); // Record refund transaction await env.DB.prepare(` INSERT INTO deposit_transactions (user_id, type, amount, status, description, depositor_name, created_at, confirmed_at) VALUES (?, 'refund', ?, 'confirmed', ?, '시스템', datetime('now'), datetime('now')) `).bind(userResult.id, refundAmount, `서버 해지 환불: ${orderLabel}`).run(); const afterBalance = beforeBalance + refundAmount; refundMessage = `\n\n💰 환불 정보\n• 환불 전 잔액: ${beforeBalance.toLocaleString()}원\n• 환불 금액: +${refundAmount.toLocaleString()}원\n• 환불 후 잔액: ${afterBalance.toLocaleString()}원`; provisionLogger.info('서버 삭제 환불 완료', { orderId, refundAmount, usedHours, beforeBalance, afterBalance }); } } catch (refundError) { provisionLogger.error('환불 처리 실패', refundError as Error, { orderId, refundAmount }); refundMessage = '\n\n⚠️ 환불 처리 중 오류가 발생했습니다. 관리자에게 문의해주세요.'; } } else { refundMessage = `\n\n💰 환불 정보\n• 결제 금액: ${pricePaid.toLocaleString()}원\n• 사용 시간: ${usedHours}시간\n• 환불 금액: 0원 (사용 기간 초과)`; } } // Clear server consultation session (if any) try { const { ServerSessionManager } = await import('../utils/session-manager'); const { getSessionConfig } = await import('../constants/agent-config'); const sessionManager = new ServerSessionManager(getSessionConfig('server')); await sessionManager.delete(env.DB, telegramUserId); } catch (error) { provisionLogger.error('서버 세션 삭제 실패 (무시)', error as Error); } provisionLogger.info('서버 삭제 완료', { orderId }); return { success: true, message: `✅ 서버가 종료되었습니다.\n\n• 주문번호: #${orderId}\n• 라벨: ${orderLabel}${refundMessage}`, }; } // 실제 서버 주문 실행 (텍스트 확인에서 호출) export async function executeServerOrder( orderData: { userId: string; index: number; plan: string; pricingId: number; region: string; label: string; }, telegramUserId: string, env: Env ): Promise<{ success: boolean; message: string }> { provisionLogger.info('서버 주문 실행', { userId: maskUserId(telegramUserId), plan: orderData.plan, pricingId: orderData.pricingId, }); // 중복 주문 방지: 진행 중인 주문 확인 if (env.DB) { const existingOrder = await checkExistingOrder(env.DB, telegramUserId); if (existingOrder) { const statusText = existingOrder.status === 'pending' ? '대기 중' : '프로비저닝 중'; return { success: false, message: `⚠️ 이미 진행 중인 주문이 있습니다.\n\n` + `• 주문 번호: #${existingOrder.id}\n` + `• 라벨: ${existingOrder.label || '없음'}\n` + `• 상태: ${statusText}\n\n` + `완료 후 다시 시도해주세요.`, }; } } // Generate idempotency key to prevent duplicate orders on network retries const idempotencyKey = generateIdempotencyKey(telegramUserId); // Call provision API const result = await callProvisionAPI( '/api/provision', 'POST', env, { user_id: telegramUserId, pricing_id: orderData.pricingId, label: orderData.label, idempotency_key: idempotencyKey, }, telegramUserId ); if (result.error) { provisionLogger.error('서버 주문 실패', new Error(result.error), { orderData }); // Check for specific error types (error_code first, then fallback to text matching) if (result.error_code === 'INSUFFICIENT_BALANCE' || result.error.includes('INSUFFICIENT_BALANCE') || result.error.includes('잔액')) { return { success: false, message: `💰 잔액이 부족합니다.\n\n입금 후 다시 시도해주세요.\n\n📌 입금 계좌\n하나은행 427-910018-27104\n(주)아이언클래드`, }; } return { success: false, message: `🚫 서버 신청 실패: ${result.error}`, }; } const order = result.order; provisionLogger.info('서버 주문 완료', { orderId: order?.id, plan: orderData.plan }); // Clear server consultation session try { const { ServerSessionManager } = await import('../utils/session-manager'); const { getSessionConfig } = await import('../constants/agent-config'); const sessionManager = new ServerSessionManager(getSessionConfig('server')); await sessionManager.delete(env.DB, telegramUserId); } catch (error) { provisionLogger.error('서버 세션 삭제 실패 (무시)', error as Error); } // Build success message let successMessage = `✅ 서버 신청이 완료되었습니다!\n\n`; successMessage += `📋 주문 정보\n`; successMessage += `• 주문번호: #${order?.id || 'N/A'}\n`; successMessage += `• 플랜: ${orderData.plan}\n`; successMessage += `• 라벨: ${orderData.label}\n`; if (order?.ip_address) { successMessage += `\n🌐 서버 정보\n`; successMessage += `• IP 주소: ${order.ip_address}\n`; successMessage += `• 비밀번호: 보안을 위해 별도로 조회하세요\n`; } else { successMessage += `\n⏳ 서버가 프로비저닝 중입니다.\n`; successMessage += `잠시 후 "내 서버" 명령으로 상태를 확인해주세요.`; } return { success: true, message: successMessage, }; } export async function executeManageServer( args: { action: string; server_id?: string; region_code?: string; label?: string; pricing_id?: number; order_id?: number; new_label?: string; image?: string; }, env?: Env, telegramUserId?: string ): Promise { const { action } = args; logger.info('시작', { action, userId: maskUserId(telegramUserId), }); try { const result = await executeServerAction(action, args, env, telegramUserId); logger.info('완료', { result: result?.slice(0, 100) }); return result; } catch (error) { logger.error('서버 관리 오류', error as Error, { action }); return '🚫 서버 관리 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.'; } }